//! 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, // Held in a Mutex so Drop can kill it without UB; we never re-enter. child: Mutex>, } 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 = LazyLock::new(init_fixture); fn init_fixture() -> Fixture { let database_url = std::env::var("DATABASE_URL").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, 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() { 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/` 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 { 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 = 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"); }