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>
This commit is contained in:
373
crates/picloud-cli/tests/cli.rs
Normal file
373
crates/picloud-cli/tests/cli.rs
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
//! 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/picloud-cli/tests/fixtures/hello.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/hello.rhai
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Smallest possible Rhai script for the integration test: returns a JSON
|
||||||
|
// object so the orchestrator wraps it as the HTTP response body.
|
||||||
|
let body = #{ ok: true, greeting: "hello from pic" };
|
||||||
|
body
|
||||||
Reference in New Issue
Block a user