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:
@@ -1,9 +1,9 @@
|
||||
//! Bare-metal end-to-end integration test.
|
||||
//! Integration-test binary for the `pic` CLI.
|
||||
//!
|
||||
//! Spawns a `picloud` subprocess against `DATABASE_URL` on a private
|
||||
//! port, logs in over HTTP to mint a bearer token, then drives the
|
||||
//! `pic` binary through the full edit-deploy-invoke-tail loop and
|
||||
//! cleans up the app it created.
|
||||
//! Every `#[test]` in this binary routes through `common::fixture()`, a
|
||||
//! `LazyLock` that spawns picloud once on a private port and reuses it
|
||||
//! across all journey modules. Mirrors the dashboard Playwright suite,
|
||||
//! which spins backend + Vite up once for 63 specs.
|
||||
//!
|
||||
//! Gated on `DATABASE_URL`. To run:
|
||||
//!
|
||||
@@ -11,361 +11,5 @@
|
||||
//! DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
|
||||
//! cargo test -p picloud-cli --test cli -- --include-ignored
|
||||
|
||||
#![allow(clippy::too_many_lines)]
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command as StdCommand, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use assert_cmd::Command as AssertCommand;
|
||||
use predicates::prelude::*;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// The bootstrap env vars are inert once any admin row exists, so we
|
||||
// can't carve out a dedicated test admin against the dev database. The
|
||||
// dev stack seeds `admin`/`admin` (see CLAUDE.md); we use those.
|
||||
// `PICLOUD_CLI_E2E_USERNAME` / `_PASSWORD` let CI override.
|
||||
fn admin_username() -> String {
|
||||
std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
|
||||
}
|
||||
|
||||
fn admin_password() -> String {
|
||||
std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn end_to_end_login_deploy_invoke_logs() {
|
||||
let Ok(database_url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("skipping: DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let port = pick_free_port();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
let mut server = spawn_picloud(&database_url, port);
|
||||
if let Err(e) = wait_for_health(&url, Duration::from_secs(60)) {
|
||||
kill_subprocess(&mut server);
|
||||
panic!("picloud failed to become healthy: {e}");
|
||||
}
|
||||
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
run_flow(&url);
|
||||
}));
|
||||
|
||||
// Always tear down regardless of outcome so a failed test doesn't
|
||||
// leak a child process.
|
||||
kill_subprocess(&mut server);
|
||||
|
||||
if let Err(p) = outcome {
|
||||
std::panic::resume_unwind(p);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_flow(url: &str) {
|
||||
let token = login_for_bearer_token(url);
|
||||
|
||||
let cfg_dir = TempDir::new().expect("tempdir");
|
||||
let home = TempDir::new().expect("home tempdir");
|
||||
let env = TestEnv {
|
||||
url: url.to_string(),
|
||||
token,
|
||||
config_dir: cfg_dir.path().to_path_buf(),
|
||||
home: home.path().to_path_buf(),
|
||||
};
|
||||
|
||||
// Slug carries the wall-clock so reruns against a long-lived dev
|
||||
// database don't collide on the unique-slug constraint.
|
||||
let slug = format!(
|
||||
"pic-cli-e2e-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis()
|
||||
);
|
||||
|
||||
let username = admin_username();
|
||||
|
||||
// 1) login
|
||||
pic(&env)
|
||||
.args(["login"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(format!("Logged in as {username}")));
|
||||
|
||||
let creds_path = env.config_dir.join("credentials");
|
||||
assert!(
|
||||
creds_path.exists(),
|
||||
"credentials file should exist after login"
|
||||
);
|
||||
let body = std::fs::read_to_string(&creds_path).unwrap();
|
||||
assert!(body.contains(&env.url), "creds should contain url: {body}");
|
||||
assert!(
|
||||
body.contains(&username),
|
||||
"creds should contain username: {body}"
|
||||
);
|
||||
|
||||
// 2) whoami
|
||||
pic(&env)
|
||||
.args(["whoami"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(username.clone()));
|
||||
|
||||
// 3) apps create
|
||||
pic(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(format!("Created app {slug}")));
|
||||
|
||||
// Ensure the app is cleaned up no matter what subsequent assertions do.
|
||||
let _guard = AppGuard {
|
||||
url: env.url.clone(),
|
||||
token: env.token.clone(),
|
||||
slug: slug.clone(),
|
||||
};
|
||||
|
||||
// 4) apps ls
|
||||
pic(&env)
|
||||
.args(["apps", "ls"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(slug.as_str()));
|
||||
|
||||
// 5) scripts deploy (create then update)
|
||||
let fixture = fixture_path("hello.rhai");
|
||||
pic(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&slug,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Created hello v1"));
|
||||
|
||||
pic(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&slug,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Updated hello v2"));
|
||||
|
||||
// 6) scripts ls and capture the id
|
||||
let ls_out = pic(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.expect("scripts ls");
|
||||
assert!(ls_out.status.success(), "scripts ls failed: {ls_out:?}");
|
||||
let id = parse_first_id(std::str::from_utf8(&ls_out.stdout).unwrap())
|
||||
.expect("scripts ls should print at least one row");
|
||||
|
||||
// 7) invoke
|
||||
let invoke_out = pic(&env)
|
||||
.args(["scripts", "invoke", &id])
|
||||
.output()
|
||||
.expect("scripts invoke");
|
||||
assert!(
|
||||
invoke_out.status.success(),
|
||||
"invoke failed: {}",
|
||||
String::from_utf8_lossy(&invoke_out.stderr)
|
||||
);
|
||||
let parsed: Value =
|
||||
serde_json::from_slice(&invoke_out.stdout).expect("invoke stdout should be JSON");
|
||||
assert_eq!(
|
||||
parsed["ok"], true,
|
||||
"expected hello.rhai response, got {parsed}"
|
||||
);
|
||||
|
||||
// 8) logs (the invoke above should have produced exactly one row)
|
||||
let logs_out = pic(&env).args(["logs", &id]).output().expect("pic logs");
|
||||
assert!(logs_out.status.success(), "logs failed: {logs_out:?}");
|
||||
let stdout = String::from_utf8_lossy(&logs_out.stdout);
|
||||
assert!(
|
||||
stdout.lines().any(|l| !l.trim().is_empty()),
|
||||
"logs should have at least one row, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
struct TestEnv {
|
||||
url: String,
|
||||
token: String,
|
||||
config_dir: PathBuf,
|
||||
home: PathBuf,
|
||||
}
|
||||
|
||||
fn pic(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)
|
||||
.env("HOME", &env.home);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn fixture_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
fn picloud_binary_path() -> PathBuf {
|
||||
// The integration test binary lives at
|
||||
// `<target>/debug/deps/cli-<hash>`. CARGO_MANIFEST_DIR points at the
|
||||
// crate; the workspace target dir is two levels up. `picloud` lands
|
||||
// next to our own test executable.
|
||||
let exe = std::env::current_exe().expect("current_exe");
|
||||
// current_exe is `.../target/debug/deps/cli-<hash>`. Walk up twice
|
||||
// to reach `.../target/debug`, then look for `picloud`.
|
||||
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"
|
||||
})
|
||||
}
|
||||
|
||||
fn pick_free_port() -> u16 {
|
||||
// Bind to :0, read the assigned port, drop the listener.
|
||||
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()
|
||||
}
|
||||
|
||||
fn spawn_picloud(database_url: &str, port: u16) -> Child {
|
||||
// Execute the pre-built `picloud` binary directly. Going through
|
||||
// `cargo run -p picloud` while inside `cargo test` would contend on
|
||||
// the same build lock and can deadlock. We assume the binary was
|
||||
// built as part of the workspace compile that produced this test —
|
||||
// and check explicitly so the panic is informative if not.
|
||||
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_username())
|
||||
.env("PICLOUD_ADMIN_PASSWORD", admin_password())
|
||||
.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. We only echo to test output on failure.
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
child
|
||||
}
|
||||
|
||||
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:?}"))
|
||||
}
|
||||
|
||||
fn login_for_bearer_token(url: &str) -> String {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{url}/api/v1/admin/auth/login"))
|
||||
.json(&serde_json::json!({
|
||||
"username": admin_username(),
|
||||
"password": admin_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()
|
||||
}
|
||||
|
||||
fn parse_first_id(table: &str) -> Option<String> {
|
||||
// The header line starts with "id"; the first row's first
|
||||
// tab-delimited cell is the script UUID.
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
fn kill_subprocess(child: &mut Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
struct AppGuard {
|
||||
url: String,
|
||||
token: String,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
impl Drop for AppGuard {
|
||||
fn drop(&mut self) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let _ = client
|
||||
.delete(format!(
|
||||
"{}/api/v1/admin/apps/{}?force=true",
|
||||
self.url, self.slug
|
||||
))
|
||||
.bearer_auth(&self.token)
|
||||
.send();
|
||||
}
|
||||
}
|
||||
mod common;
|
||||
mod integration;
|
||||
|
||||
Reference in New Issue
Block a user