//! 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 // `/debug/deps/cli-`. 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-`. 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::(); 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 { // 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(); } }