Three related fixes from the test audit. **The CLI journey fixture gets its own database.** It spawned a real picloud — whose dispatcher/orchestrator claim loops are global by design (one instance owns one database) — against the shared dev DB, so it could claim the manager-core suites' outbox/workflow rows (the same class of bug already fixed for the e2e suites, one binary over). It now clones one dedicated database per journey run from the migrated template. test-support gains `named_test_db_url` (explicit stable name) + a blocking wrapper for the sync `LazyLock` fixture. The one journey that talks to Postgres directly (dead-letter injection) now uses the fixture's DB URL, not the base DATABASE_URL, so it hits the database the server reads. **workflow_orchestrator moves to per-test databases.** Its `claim_ready_step` is global, so the old harness serialized every test behind a process-wide CLAIM_LOCK AND ran `DELETE FROM workflow_runs` (unscoped — it wiped every app's runs) before each one. A private database per test makes the global claim see only that test's rows, so both the lock and the unscoped DELETE are deleted. **DB-backed suites fail loud instead of skipping green.** ~15 manager-core suites `return None` when DATABASE_URL is unset and report PASS — so in any environment that lost its database the entire integration surface reports green while running nothing (why the CI gap went unnoticed for so long). New `picloud_test_support::abort_if_db_required` panics when `PICLOUD_REQUIRE_DB` is set (CI now sets it) but DATABASE_URL is not, injected into each suite's skip path. Local runs without the var still skip cleanly. Mutation-verified: with PICLOUD_REQUIRE_DB=1 and DATABASE_URL unset, a suite panics; without the var it skips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
333 lines
12 KiB
Rust
333 lines
12 KiB
Rust
//! Shared fixture and helpers for the CLI integration test binary.
|
|
//!
|
|
//! All tests in `tests/cli.rs` route through `fixture()`, a `LazyLock`
|
|
//! that spawns picloud on a private port the first time it's touched
|
|
//! and reuses that subprocess for every subsequent test. The dashboard
|
|
//! Playwright suite pays the same cost once for 63 tests; we do the
|
|
//! same here.
|
|
|
|
#![allow(dead_code)] // shared helpers — not every module uses every fn.
|
|
|
|
pub mod cleanup;
|
|
pub mod member;
|
|
pub mod server;
|
|
|
|
use std::path::PathBuf;
|
|
use std::process::Child;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{LazyLock, Mutex, OnceLock};
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use assert_cmd::Command as AssertCommand;
|
|
use tempfile::TempDir;
|
|
|
|
// --------------------------------------------------------------------
|
|
// Fixture
|
|
// --------------------------------------------------------------------
|
|
|
|
pub struct Fixture {
|
|
pub url: String,
|
|
pub admin_token: String,
|
|
pub admin_username: String,
|
|
/// The Postgres URL the spawned picloud is using — a per-journey-run cloned
|
|
/// database, NOT the base `DATABASE_URL`. A test that talks to the DB directly
|
|
/// (e.g. injecting a dead-letter row) must use THIS so it hits the same
|
|
/// database the server does.
|
|
pub database_url: String,
|
|
// Held in a Mutex so Drop can kill it without UB; we never re-enter.
|
|
child: Mutex<Option<Child>>,
|
|
}
|
|
|
|
impl Drop for Fixture {
|
|
fn drop(&mut self) {
|
|
if let Ok(mut guard) = self.child.lock() {
|
|
if let Some(mut child) = guard.take() {
|
|
server::kill_subprocess(&mut child);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static FIXTURE: LazyLock<Fixture> = LazyLock::new(init_fixture);
|
|
|
|
fn init_fixture() -> Fixture {
|
|
// ONE database for the whole journey binary, cloned fresh from the migrated
|
|
// template. The spawned picloud runs the real dispatcher/orchestrator, whose
|
|
// claim loops are global by design (one instance owns one database) — sharing
|
|
// the dev DB with the manager-core suites let it claim their outbox/workflow
|
|
// rows. A dedicated DB matches production and ends that interference.
|
|
let database_url = picloud_test_support::named_test_db_url_blocking("pt_cli_journeys")
|
|
.expect("DATABASE_URL is required to spawn picloud");
|
|
let username = admin_username();
|
|
let password = admin_password();
|
|
let port = server::pick_free_port();
|
|
let url = format!("http://127.0.0.1:{port}");
|
|
let mut child = server::spawn_picloud(&database_url, port, &username, &password);
|
|
if let Err(e) = server::wait_for_health(&url, Duration::from_secs(60)) {
|
|
server::kill_subprocess(&mut child);
|
|
panic!("picloud failed to become healthy: {e}");
|
|
}
|
|
let token = server::login_for_bearer_token(&url, &username, &password);
|
|
Fixture {
|
|
url,
|
|
admin_token: token,
|
|
admin_username: username,
|
|
database_url,
|
|
child: Mutex::new(Some(child)),
|
|
}
|
|
}
|
|
|
|
/// Returns the shared fixture, spawning the picloud subprocess on first
|
|
/// call. Returns `None` (and prints a skip message) when `DATABASE_URL`
|
|
/// is absent — matching the existing convention so the suite is a
|
|
/// no-op outside the integration environment.
|
|
pub fn fixture_or_skip() -> Option<&'static Fixture> {
|
|
if std::env::var("DATABASE_URL").is_err() {
|
|
// CI sets PICLOUD_REQUIRE_DB so a lost/misconfigured database fails the
|
|
// build instead of silently skipping the entire journey suite.
|
|
assert!(
|
|
std::env::var("PICLOUD_REQUIRE_DB").is_err(),
|
|
"DATABASE_URL is unset but PICLOUD_REQUIRE_DB is set — refusing to skip \
|
|
the CLI journeys silently"
|
|
);
|
|
eprintln!("skipping: DATABASE_URL not set");
|
|
return None;
|
|
}
|
|
Some(&FIXTURE)
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// Per-test env
|
|
// --------------------------------------------------------------------
|
|
|
|
pub struct TestEnv {
|
|
pub url: String,
|
|
pub token: String,
|
|
pub config_dir: TempDir,
|
|
pub home: TempDir,
|
|
}
|
|
|
|
/// Per-test env pre-loaded with the admin token, and a credentials
|
|
/// file already on disk so non-login commands ("pic apps create", …)
|
|
/// can run without first calling `pic login`. As of the env-var
|
|
/// consistency fix, `PICLOUD_URL`/`PICLOUD_TOKEN` (set by `pic_as`)
|
|
/// also work for *every* command, not just `login` — `config::resolve`
|
|
/// reads them first and falls back to the on-disk file.
|
|
pub fn admin_env(fx: &Fixture) -> TestEnv {
|
|
let env = TestEnv {
|
|
url: fx.url.clone(),
|
|
token: fx.admin_token.clone(),
|
|
config_dir: TempDir::new().expect("config tempdir"),
|
|
home: TempDir::new().expect("home tempdir"),
|
|
};
|
|
seed_credentials(&env, &fx.admin_username);
|
|
env
|
|
}
|
|
|
|
/// Per-test env pre-loaded with a specific (URL, token) pair. Used by
|
|
/// tests that want a non-admin token, a bogus token, or an unreachable
|
|
/// URL. Does **not** seed a credentials file — call `seed_credentials`
|
|
/// explicitly when the test needs to run non-login commands.
|
|
pub fn custom_env(url: &str, token: &str) -> TestEnv {
|
|
TestEnv {
|
|
url: url.to_string(),
|
|
token: token.to_string(),
|
|
config_dir: TempDir::new().expect("config tempdir"),
|
|
home: TempDir::new().expect("home tempdir"),
|
|
}
|
|
}
|
|
|
|
/// Write a valid credentials TOML into `env.config_dir` so subsequent
|
|
/// `pic_as(&env)` invocations can issue non-login subcommands. Mirrors
|
|
/// the file shape `pic login` produces (url/token/username). Tests that
|
|
/// exercise the "no credentials" / "stale token" error paths construct
|
|
/// `TestEnv` directly to keep the config dir empty.
|
|
pub fn seed_credentials(env: &TestEnv, username: &str) {
|
|
let body = format!(
|
|
"url = \"{}\"\ntoken = \"{}\"\nusername = \"{}\"\n",
|
|
env.url, env.token, username,
|
|
);
|
|
std::fs::write(env.config_dir.path().join("credentials"), body).expect("seed credentials file");
|
|
}
|
|
|
|
/// `pic` invocation with the env wired up — credentials dir, HOME, and
|
|
/// the `PICLOUD_URL`/`PICLOUD_TOKEN` shortcut env vars.
|
|
pub fn pic_as(env: &TestEnv) -> AssertCommand {
|
|
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
|
|
cmd.env("PICLOUD_URL", &env.url)
|
|
.env("PICLOUD_TOKEN", &env.token)
|
|
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
|
|
.env("HOME", env.home.path());
|
|
cmd
|
|
}
|
|
|
|
/// `pic` invocation with `PICLOUD_URL`/`PICLOUD_TOKEN` *cleared*, so the
|
|
/// command sees only the on-disk credentials file (or lack thereof).
|
|
pub fn pic_no_env(env: &TestEnv) -> AssertCommand {
|
|
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
|
|
cmd.env_remove("PICLOUD_URL")
|
|
.env_remove("PICLOUD_TOKEN")
|
|
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
|
|
.env("HOME", env.home.path());
|
|
cmd
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// Unique slugs / usernames
|
|
// --------------------------------------------------------------------
|
|
|
|
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
pub fn unique_slug(prefix: &str) -> String {
|
|
let ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis();
|
|
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
format!("pic-cli-{prefix}-{ms}-{n:x}")
|
|
}
|
|
|
|
pub fn unique_username(prefix: &str) -> String {
|
|
// Server regex: [a-z0-9._-]{2,32}. Build out of lowercase
|
|
// alphanumerics only; "piccli" prefix keeps collisions with other
|
|
// test suites obvious. Caller's `prefix` must be ≤8 chars and
|
|
// already match the regex.
|
|
let ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis();
|
|
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let name = format!("piccli{prefix}{ms:x}{n:x}");
|
|
assert!(name.len() <= 32, "username overflow: {name}");
|
|
name
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// Misc helpers
|
|
// --------------------------------------------------------------------
|
|
|
|
pub fn admin_username() -> String {
|
|
std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
|
|
}
|
|
|
|
pub fn admin_password() -> String {
|
|
std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
|
|
}
|
|
|
|
pub fn fixture_path(name: &str) -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests")
|
|
.join("fixtures")
|
|
.join(name)
|
|
}
|
|
|
|
/// Create a fresh app and deploy a `tests/fixtures/<fixture_name>` into
|
|
/// it. Returns the new script id plus the `AppGuard` that cleans the
|
|
/// app (and its scripts via `force=true`) on Drop. Used by invoke /
|
|
/// logs / output journeys that all need "deploy something, then drive
|
|
/// `pic` against it".
|
|
pub fn deploy_fixture(
|
|
env: &TestEnv,
|
|
app_label: &str,
|
|
fixture_name: &str,
|
|
) -> (String, cleanup::AppGuard) {
|
|
let slug = unique_slug(app_label);
|
|
pic_as(env)
|
|
.args(["apps", "create", &slug])
|
|
.assert()
|
|
.success();
|
|
let guard = cleanup::AppGuard::new(&env.url, &env.token, &slug);
|
|
|
|
let fixture = fixture_path(fixture_name);
|
|
pic_as(env)
|
|
.args([
|
|
"scripts",
|
|
"deploy",
|
|
fixture.to_str().unwrap(),
|
|
"--app",
|
|
&slug,
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
let out = pic_as(env)
|
|
.args(["scripts", "ls", "--app", &slug])
|
|
.output()
|
|
.expect("scripts ls");
|
|
let id = parse_first_id(std::str::from_utf8(&out.stdout).unwrap())
|
|
.expect("scripts ls should produce one row");
|
|
(id, guard)
|
|
}
|
|
|
|
/// Split a row from `pic apps ls` / `pic scripts ls` into trimmed
|
|
/// cells. The output writer space-pads each cell to its column's max
|
|
/// width before the tab, so raw `split('\t')` leaves trailing spaces;
|
|
/// this helper hides that detail from tests that only care about the
|
|
/// logical values.
|
|
pub fn cells(row: &str) -> Vec<&str> {
|
|
row.split('\t').map(str::trim).collect()
|
|
}
|
|
|
|
/// First data row's first tab-delimited cell, used to extract IDs from
|
|
/// `pic scripts ls` output. The header is expected to start with "id".
|
|
pub fn parse_first_id(table: &str) -> Option<String> {
|
|
let mut lines = table.lines().filter(|l| !l.trim().is_empty());
|
|
let header = lines.next()?;
|
|
if !header.starts_with("id") {
|
|
return None;
|
|
}
|
|
let row = lines.next()?;
|
|
let first = row.split('\t').next()?.trim();
|
|
if first.is_empty() {
|
|
None
|
|
} else {
|
|
Some(first.to_string())
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// Fixture-sharing sanity check
|
|
// --------------------------------------------------------------------
|
|
//
|
|
// Two tests record `fixture().url` into the same `OnceLock` — if the
|
|
// fixture isn't actually shared, the second test sees a different URL
|
|
// and panics. Belt-and-suspenders: pointer identity on `&Fixture`.
|
|
|
|
static OBSERVED_URL: OnceLock<String> = OnceLock::new();
|
|
|
|
fn observe_fixture_url(label: &str) {
|
|
let Some(fx) = fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let url = fx.url.clone();
|
|
match OBSERVED_URL.get() {
|
|
Some(prev) => assert_eq!(
|
|
prev, &url,
|
|
"{label} observed a different fixture URL: prior={prev} now={url}"
|
|
),
|
|
None => {
|
|
let _ = OBSERVED_URL.set(url);
|
|
}
|
|
}
|
|
// Same `&'static Fixture` from every call — proves the LazyLock is
|
|
// sharing, not respawning.
|
|
let a = fixture_or_skip().unwrap();
|
|
let b = fixture_or_skip().unwrap();
|
|
assert!(
|
|
std::ptr::eq(a, b),
|
|
"fixture_or_skip should return the same &'static Fixture"
|
|
);
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn fixture_url_is_shared_a() {
|
|
observe_fixture_url("fixture_url_is_shared_a");
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn fixture_url_is_shared_b() {
|
|
observe_fixture_url("fixture_url_is_shared_b");
|
|
}
|