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>
167 lines
5.5 KiB
Rust
167 lines
5.5 KiB
Rust
//! Picloud subprocess lifecycle for the CLI integration test binary.
|
|
//!
|
|
//! Mirrors what the original seed test did inline, lifted out so it can
|
|
//! be shared across modules via `common::fixture()`. The Fixture lives
|
|
//! in a `LazyLock` — and `LazyLock<T>` never drops, so we register an
|
|
//! `atexit` handler that SIGTERMs the child when the test binary exits
|
|
//! normally (which is the common case under `cargo test`).
|
|
//!
|
|
//! `PR_SET_PDEATHSIG` is intentionally *not* used: it fires when the
|
|
//! creating thread dies, and cargo runs each `#[test]` on its own
|
|
//! worker thread that exits as soon as the test returns — which would
|
|
//! kill picloud after the first test that triggered the LazyLock,
|
|
//! breaking every test after it.
|
|
//!
|
|
//! Abnormal exit (SIGKILL of the test binary) leaks the child; the
|
|
//! dashboard Playwright suite accepts the same tradeoff.
|
|
|
|
use std::io::{BufRead, BufReader};
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command as StdCommand, Stdio};
|
|
use std::sync::atomic::{AtomicI32, Ordering};
|
|
use std::sync::mpsc;
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use serde_json::Value;
|
|
|
|
pub fn pick_free_port() -> u16 {
|
|
let listener =
|
|
std::net::TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0 to pick port");
|
|
listener.local_addr().expect("local addr").port()
|
|
}
|
|
|
|
pub fn picloud_binary_path() -> PathBuf {
|
|
// The integration test binary lives at
|
|
// `<target>/debug/deps/cli-<hash>`. Walk up two levels to reach
|
|
// `<target>/debug` and look for `picloud` next to ourselves.
|
|
let exe = std::env::current_exe().expect("current_exe");
|
|
let debug_dir = exe
|
|
.parent()
|
|
.and_then(|p| p.parent())
|
|
.expect("test binary should live under target/debug/deps");
|
|
debug_dir.join(if cfg!(windows) {
|
|
"picloud.exe"
|
|
} else {
|
|
"picloud"
|
|
})
|
|
}
|
|
|
|
pub fn spawn_picloud(database_url: &str, port: u16, admin_user: &str, admin_pass: &str) -> Child {
|
|
let binary = picloud_binary_path();
|
|
assert!(
|
|
binary.exists(),
|
|
"expected picloud binary at {}. Run `cargo build -p picloud` first \
|
|
(or use `cargo test --workspace -- --include-ignored` which builds it)",
|
|
binary.display()
|
|
);
|
|
let mut child = StdCommand::new(&binary)
|
|
.env("PICLOUD_BIND", format!("127.0.0.1:{port}"))
|
|
.env("DATABASE_URL", database_url)
|
|
.env("PICLOUD_ADMIN_USERNAME", admin_user)
|
|
.env("PICLOUD_ADMIN_PASSWORD", admin_pass)
|
|
.env("RUST_LOG", "warn")
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.expect("spawn picloud");
|
|
|
|
// Drain stderr in a side thread so the pipe buffer doesn't fill and
|
|
// block the server.
|
|
if let Some(err) = child.stderr.take().map(BufReader::new) {
|
|
let (tx, _rx) = mpsc::channel::<String>();
|
|
thread::spawn(move || {
|
|
for line in err.lines().map_while(Result::ok) {
|
|
let _ = tx.send(line);
|
|
}
|
|
});
|
|
}
|
|
|
|
register_atexit_killer(child.id());
|
|
child
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// atexit-based child cleanup
|
|
// --------------------------------------------------------------------
|
|
|
|
static PICLOUD_PID: AtomicI32 = AtomicI32::new(0);
|
|
|
|
fn register_atexit_killer(pid: u32) {
|
|
// First spawn wins; subsequent spawns (none expected today) would
|
|
// overwrite, but the previous child would already be tracked via
|
|
// its Drop path on the Fixture.
|
|
PICLOUD_PID.store(pid as i32, Ordering::SeqCst);
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::sync::Once;
|
|
static REGISTERED: Once = Once::new();
|
|
REGISTERED.call_once(|| {
|
|
// SAFETY: atexit's contract is a `extern "C" fn()` callback;
|
|
// ours signals a child PID we own.
|
|
unsafe {
|
|
libc::atexit(kill_picloud_at_exit);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
extern "C" fn kill_picloud_at_exit() {
|
|
let pid = PICLOUD_PID.swap(0, Ordering::SeqCst);
|
|
if pid > 0 {
|
|
// SAFETY: SIGTERM to a PID we recorded; if PID has been reused
|
|
// we're killing the wrong process — accepted risk for a test
|
|
// helper.
|
|
unsafe {
|
|
libc::kill(pid, libc::SIGTERM);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn wait_for_health(url: &str, timeout: Duration) -> Result<(), String> {
|
|
let deadline = Instant::now() + timeout;
|
|
let client = reqwest::blocking::Client::builder()
|
|
.timeout(Duration::from_secs(2))
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
while Instant::now() < deadline {
|
|
if let Ok(resp) = client.get(format!("{url}/healthz")).send() {
|
|
if resp.status().is_success() {
|
|
return Ok(());
|
|
}
|
|
}
|
|
thread::sleep(Duration::from_millis(250));
|
|
}
|
|
Err(format!("/healthz never returned 200 within {timeout:?}"))
|
|
}
|
|
|
|
pub fn login_for_bearer_token(url: &str, username: &str, password: &str) -> String {
|
|
let client = reqwest::blocking::Client::new();
|
|
let resp = client
|
|
.post(format!("{url}/api/v1/admin/auth/login"))
|
|
.json(&serde_json::json!({
|
|
"username": username,
|
|
"password": password,
|
|
}))
|
|
.send()
|
|
.expect("login request");
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"login should succeed, got {}: {}",
|
|
resp.status(),
|
|
resp.text().unwrap_or_default()
|
|
);
|
|
let v: Value = resp.json().expect("login json");
|
|
v["token"]
|
|
.as_str()
|
|
.expect("login returns token")
|
|
.to_string()
|
|
}
|
|
|
|
pub fn kill_subprocess(child: &mut Child) {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|