Files
PiCloud/crates/picloud-cli/tests/cli.rs
MechaCat02 ca278bddc8 test(cli): bare-metal end-to-end integration test
Spawns the pre-built `picloud` binary against DATABASE_URL on a
private port, logs in over HTTP to mint a bearer token, then drives
`pic` through the full edit-deploy-invoke-tail loop with a unique
app slug per run and a `Drop`-based cleanup. Gated on DATABASE_URL
and tagged `#[ignore]` to match the existing integration-test
pattern in `crates/picloud/tests/api.rs`.

The test uses the dev `admin/admin` credentials (overridable via
PICLOUD_CLI_E2E_USERNAME / _PASSWORD) because the bootstrap env
vars are inert once the DB has any admin row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:53:56 +02:00

374 lines
11 KiB
Rust

//! Bare-metal end-to-end integration test.
//!
//! 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.
//!
//! Gated on `DATABASE_URL`. To run:
//!
//! docker compose up -d postgres
//! 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();
}
}