test(cli): extract shared Fixture into tests/common
The single bare-metal integration test now reuses a `LazyLock<Fixture>` that spawns picloud once on a private port and shares it across every test in the binary. Sets the stage for per-surface journey modules (auth, apps, scripts, invoke, logs, roles, output) without each one paying for its own server spawn — same trick the dashboard Playwright suite uses with global-setup. Notes: - `tests/cli.rs` becomes a tiny module list; the seed flow moved to `tests/integration.rs`. The seed slug now goes through `common::unique_slug` so parallel/serial reruns can't collide. - `autotests = false` + an explicit `[[test]] name = "cli"` keeps Cargo from auto-promoting future `tests/*.rs` files into their own binaries (which would each respawn picloud). - Subprocess cleanup uses `libc::atexit` to SIGTERM picloud when the test binary exits. PR_SET_PDEATHSIG was tried and rejected: it fires when the *thread* that forked dies, and cargo's per-test worker threads exit between tests, which killed the fixture mid-suite. - New helpers: AppGuard/UserGuard (RAII teardown), member_user / grant_membership / update_membership (direct API for role tests), unique_slug / unique_username, pic_as / pic_no_env. - Two `fixture_url_is_shared_*` tests prove the LazyLock is actually shared, not respawned per test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
247
crates/picloud-cli/tests/common/mod.rs
Normal file
247
crates/picloud-cli/tests/common/mod.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
//! 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<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 {
|
||||
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. Mirrors what the seed
|
||||
/// test built inline.
|
||||
pub fn admin_env(fx: &Fixture) -> TestEnv {
|
||||
TestEnv {
|
||||
url: fx.url.clone(),
|
||||
token: fx.admin_token.clone(),
|
||||
config_dir: TempDir::new().expect("config tempdir"),
|
||||
home: TempDir::new().expect("home tempdir"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `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)
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
Reference in New Issue
Block a user