use assert_cmd::Command;
use predicates::prelude::*;
fn turnout(data_dir: &std::path::Path) -> Command {
let mut cmd = Command::cargo_bin("turnout").unwrap();
cmd.env("TURNOUT_DATA_DIR", data_dir);
cmd.env("TURNOUT_UPDATE_CHECK", "0");
cmd.env("TURNOUT_TOASTS", toast_file(data_dir));
cmd
}
fn toast_file(data_dir: &std::path::Path) -> std::path::PathBuf {
data_dir.join("toasts.jsonl")
}
fn toasts(data_dir: &std::path::Path) -> Vec<serde_json::Value> {
match std::fs::read_to_string(toast_file(data_dir)) {
Ok(text) => text.lines().map(|line| serde_json::from_str(line).unwrap()).collect(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(err) => panic!("cannot read the toasts: {err}"),
}
}
#[test]
fn status_before_setup_reports_uninitialized() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("Not set up yet"));
}
#[test]
fn setup_creates_data_directory_and_meta() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
turnout(&data)
.args(["setup", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("ready to store"));
assert!(data.join("meta.json").exists());
}
#[test]
fn setup_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path()).args(["setup", "--yes"]).assert().success();
turnout(dir.path())
.args(["setup", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("already set up"));
}
#[test]
fn setup_clears_the_way_out_of_a_refused_data_directory() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("meta.json"), r#"{"schema_version": 1}"#).unwrap();
std::fs::write(dir.path().join("servers.json"), r#"[{"name":"pi","url":"http://pi:8081"}]"#).unwrap();
turnout(dir.path())
.arg("status")
.assert()
.failure()
.stderr(predicate::str::contains("turnout setup"));
turnout(dir.path())
.args(["setup", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("older turnout").and(predicate::str::contains("already set up").not()));
assert!(
dir.path().join("settings-backup-v1").join("servers.json").is_file(),
"the values being re-entered must survive"
);
turnout(dir.path()).args(["server", "add", "pi", "--url", "http://pi:8081"]).assert().success();
turnout(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("servers pi"));
}
#[test]
fn setup_on_a_refused_directory_changes_nothing_unless_confirmed() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("meta.json"), r#"{"schema_version": 1}"#).unwrap();
std::fs::write(dir.path().join("servers.json"), r#"[{"name":"pi"}]"#).unwrap();
turnout(dir.path()).args(["setup"]).assert().failure();
assert_eq!(std::fs::read_to_string(dir.path().join("servers.json")).unwrap(), r#"[{"name":"pi"}]"#);
assert_eq!(
std::fs::read_to_string(dir.path().join("meta.json")).unwrap(),
r#"{"schema_version": 1}"#,
"the schema marker must not move without confirmation"
);
}
#[test]
fn status_after_setup_shows_overview() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path()).args(["setup", "--yes"]).assert().success();
turnout(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("Gateway not running").and(predicate::str::contains("none yet - add one with `turnout app add`")));
}
fn workspace() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path()).args(["setup", "--yes"]).assert().success();
let project = dir.path().join("project");
std::fs::create_dir(&project).unwrap();
(dir, project)
}
#[test]
fn app_crud_roundtrip() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", "7100"])
.assert()
.success()
.stdout(predicate::str::contains("'myapp' added"));
turnout(dir.path())
.args(["app", "list"])
.assert()
.success()
.stdout(predicate::str::contains("myapp").and(predicate::str::contains(":7100")));
turnout(dir.path())
.args(["app", "show", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("localhost:7100"));
turnout(dir.path()).args(["app", "remove", "myapp", "--yes"]).assert().success();
turnout(dir.path())
.args(["app", "list"])
.assert()
.success()
.stdout(predicate::str::contains("No apps yet"));
}
#[test]
fn app_add_rejects_duplicates_and_bad_names() {
let (dir, project) = workspace();
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.assert()
.failure()
.stderr(predicate::str::contains("already exists"));
turnout(dir.path())
.args(["app", "add", "My App", "--path"])
.arg(&project)
.assert()
.failure()
.stderr(predicate::str::contains("invalid name"));
}
#[test]
fn app_add_detects_npm_commands() {
let (dir, project) = workspace();
std::fs::write(project.join("package.json"), "{}").unwrap();
turnout(dir.path()).args(["app", "add", "webapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["app", "show", "webapp"])
.assert()
.success()
.stdout(predicate::str::contains("npm run dev"));
}
#[test]
fn app_add_maps_package_json_scripts_to_roles() {
let (dir, project) = workspace();
std::fs::write(
project.join("package.json"),
r#"{"scripts":{"serve":"vue-cli-service serve","build":"vue-cli-service build","storybook":"start-storybook"}}"#,
)
.unwrap();
std::fs::write(project.join("pnpm-lock.yaml"), "").unwrap();
turnout(dir.path()).args(["app", "add", "webapp", "--path"]).arg(&project).assert().success();
turnout(dir.path()).args(["app", "show", "webapp"]).assert().success().stdout(
predicate::str::contains("dev")
.and(predicate::str::contains("pnpm serve"))
.and(predicate::str::contains("pnpm storybook")),
);
}
#[test]
fn app_edit_updates_commands_and_port() {
let (dir, project) = workspace();
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["app", "edit", "myapp", "--port", "7200", "--command", "deploy=echo deploy"])
.assert()
.success();
turnout(dir.path())
.args(["app", "show", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("localhost:7200").and(predicate::str::contains("echo deploy")));
}
#[cfg(windows)]
#[test]
fn app_add_canonicalizes_the_drive_letter() {
let (dir, project) = workspace();
let display = project.display().to_string();
let lowercase = format!("{}{}", display[..1].to_lowercase(), &display[1..]);
turnout(dir.path()).args(["app", "add", "myapp", "--path", &lowercase]).assert().success();
let canonical = std::fs::canonicalize(&project).unwrap().display().to_string();
let canonical = canonical.strip_prefix(r"\\?\").unwrap_or(&canonical).to_string();
turnout(dir.path())
.args(["app", "show", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains(&canonical));
}
#[test]
fn app_add_refuses_a_port_another_app_holds() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "web", "--path"])
.arg(&project)
.args(["--port", "7001"])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "admin", "--path"])
.arg(&project)
.args(["--port", "7001"])
.assert()
.failure()
.stderr(predicate::str::contains("port 7001 is already used by app 'web'"));
turnout(dir.path())
.args(["app", "add", "admin", "--path"])
.arg(&project)
.args(["--port", "7002"])
.assert()
.success();
turnout(dir.path())
.args(["app", "edit", "admin", "--port", "7001"])
.assert()
.failure()
.stderr(predicate::str::contains("already used by app 'web'"));
turnout(dir.path()).args(["app", "edit", "web", "--port", "7001"]).assert().success();
}
#[test]
fn gateway_refuses_a_port_two_apps_share() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "web", "--path"])
.arg(&project)
.args(["--port", "7001"])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "admin", "--path"])
.arg(&project)
.args(["--port", "7002"])
.assert()
.success();
let apps = dir.path().join("apps.json");
let edited = std::fs::read_to_string(&apps).unwrap().replace("7002", "7001");
std::fs::write(&apps, edited).unwrap();
for command in [["gateway", "run"], ["gateway", "start"]] {
turnout(dir.path())
.args(command)
.assert()
.failure()
.stderr(predicate::str::contains("'admin' and 'web' share gateway port 7001"));
}
let state = std::fs::read_to_string(dir.path().join("state.json")).unwrap_or_default();
assert!(!state.contains("\"gateway\""), "a gateway that never started was recorded: {state}");
}
#[test]
fn gateway_start_refuses_a_port_another_process_holds() {
let (dir, project) = workspace();
let busy = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = busy.local_addr().unwrap().port();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", &port.to_string()])
.assert()
.success();
turnout(dir.path()).args(["gateway", "start"]).assert().failure().stderr(
predicate::str::contains(format!("port {port} is already in use by "))
.and(predicate::str::contains(format!("(pid {})", std::process::id())))
.and(predicate::str::contains("app edit myapp --port")),
);
assert!(!dir.path().join("jobs").join("gateway.json").exists(), "a dead gateway was recorded");
}
#[test]
fn gateway_start_and_stop_roundtrip() {
let (dir, project) = workspace();
let (port, reservation) = reserved_port();
let (front, front_reservation) = reserved_port();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", &port.to_string()])
.assert()
.success();
drop(reservation);
drop(front_reservation);
let _gateway = StopsGateway(dir.path().to_path_buf());
turnout(dir.path())
.env("TURNOUT_FRONT_PORT", front.to_string())
.args(["gateway", "start"])
.assert()
.success()
.stdout(
predicate::str::contains("Gateway started")
.and(predicate::str::contains(format!("http://localhost:{port}")))
.and(predicate::str::contains(format!("Front door: http://localhost:{front}")))
.and(predicate::str::contains(format!("myapp: http://myapp.localhost:{front}"))),
);
wait_for_port(front);
turnout(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains(format!("front door http://localhost:{front}")));
let status = status_json(dir.path());
assert_eq!(status["gateway"]["state"], "running", "{status}");
assert_eq!(status["gateway"]["front_door"], format!("http://localhost:{front}"));
assert!(
!status.to_string().contains("token"),
"the control channel's token must never leave the record: {status}"
);
let address = std::net::SocketAddr::from(([127, 0, 0, 1], port));
assert!(std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_millis(500)).is_ok());
let env = std::fs::read_to_string(project.join(".env.development.local")).unwrap();
assert!(env.contains(&format!("TURNOUT_GATEWAY_URL=http://localhost:{port}")), "{env}");
turnout(dir.path()).arg("ps").assert().success().stdout(
predicate::str::is_match(r"gateway\s+running \(bg\)")
.unwrap()
.and(predicate::str::contains(format!("http://localhost:{front}"))),
);
turnout(dir.path())
.args(["logs", "gateway"])
.assert()
.success()
.stdout(predicate::str::contains(format!("myapp: listening on http://localhost:{port}")));
turnout(dir.path())
.args(["gateway", "stop"])
.assert()
.success()
.stdout(predicate::str::contains("Gateway stopped"));
turnout(dir.path())
.args(["gateway", "stop"])
.assert()
.success()
.stdout(predicate::str::contains("not running"));
}
#[test]
fn gateway_stop_forgets_a_record_whose_process_is_gone() {
let (dir, _project) = workspace();
let (port, reservation) = reserved_port();
drop(reservation);
let record = dir.path().join("jobs").join("gateway.json");
std::fs::create_dir_all(record.parent().unwrap()).unwrap();
let entry = format!("{{\"work\":{{\"gateway\":{{\"ports\":{{\"{port}\":\"myapp\"}}}}}},\"pid\":2147483647,\"birth\":1,\"started\":1,\"detached\":true}}");
std::fs::write(&record, entry).unwrap();
turnout(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("no longer running"));
turnout(dir.path())
.arg("ps")
.assert()
.success()
.stdout(predicate::str::is_match(r"gateway\s+gone").unwrap());
turnout(dir.path())
.args(["gateway", "stop"])
.assert()
.success()
.stdout(predicate::str::contains("was no longer running"));
assert!(!record.exists(), "the stale record survived");
turnout(dir.path())
.args(["gateway", "stop"])
.assert()
.success()
.stdout(predicate::str::contains("not running"));
}
const ENV_HEADER: &str = "# Managed by turnout - the line below follows the app's gateway port.";
#[test]
fn app_add_writes_the_gateway_line_and_edit_rewrites_it() {
let (dir, project) = workspace();
let env_file = project.join(".env.development.local");
std::fs::write(
&env_file,
"VITE_OTHER=1
",
)
.unwrap();
std::fs::write(
project.join(".gitignore"),
"node_modules
",
)
.unwrap();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", "7001", "--env-var", "VITE_API_URL"])
.assert()
.success()
.stdout(predicate::str::contains("Wrote").and(predicate::str::contains("VITE_API_URL=http://localhost:7001")));
assert_eq!(
std::fs::read_to_string(&env_file).unwrap(),
format!(
"VITE_OTHER=1
{ENV_HEADER}
VITE_API_URL=http://localhost:7001
"
)
);
assert_eq!(
std::fs::read_to_string(project.join(".gitignore")).unwrap(),
"node_modules
.env.development.local
"
);
turnout(dir.path()).args(["app", "edit", "myapp", "--port", "7002"]).assert().success();
turnout(dir.path()).args(["app", "edit", "myapp", "--env-var", "VITE_API"]).assert().success();
let text = std::fs::read_to_string(&env_file).unwrap();
assert_eq!(
text,
format!(
"VITE_OTHER=1
{ENV_HEADER}
VITE_API=http://localhost:7002
"
)
);
assert_eq!(text.matches(ENV_HEADER).count(), 1, "the header was written twice");
turnout(dir.path())
.args(["app", "show", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("Env: VITE_API -> .env.development.local"));
turnout(dir.path()).args(["app", "edit", "myapp", "--port", "0"]).assert().success();
}
#[test]
fn app_add_refuses_a_bad_variable_name_and_a_path_for_the_env_file() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", "7001", "--env-var", "1bad"])
.assert()
.failure()
.stderr(predicate::str::contains("not a variable name"));
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", "7001", "--env-file", "sub/.env"])
.assert()
.failure()
.stderr(predicate::str::contains("not a file name in the project directory"));
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", "7001", "--env-file", ".env.local"])
.assert()
.success();
assert!(project.join(".env.local").exists());
assert!(!project.join(".env.development.local").exists());
}
#[test]
fn run_hands_the_gateway_url_to_dev_but_not_to_build() {
let (dir, project) = workspace();
let show = if cfg!(windows) {
"echo [%TURNOUT_GATEWAY_URL%]"
} else {
"echo [$TURNOUT_GATEWAY_URL]"
};
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", "7001"])
.args(["--command", &format!("dev={show}"), "--command", &format!("build={show}")])
.args(["--command", &format!("custom={show}"), "--command", "where=echo {gateway} {gateway_port}"])
.assert()
.success();
turnout(dir.path())
.args(["dev", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("[http://localhost:7001]"));
turnout(dir.path())
.args(["run", "custom", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("[http://localhost:7001]"));
turnout(dir.path())
.args(["build", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("http://localhost").not());
turnout(dir.path())
.args(["run", "where", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("http://localhost:7001 7001"));
turnout(dir.path())
.args(["app", "add", "bare", "--path"])
.arg(&project)
.args(["--port", "0"])
.args(["--command", "where=echo {gateway}"])
.assert()
.success();
turnout(dir.path())
.args(["run", "where", "bare"])
.assert()
.failure()
.stderr(predicate::str::contains("has no gateway port"));
}
#[test]
fn an_app_is_given_a_gateway_port_without_being_asked() {
let (dir, project) = workspace();
turnout(dir.path()).args(["app", "add", "first", "--path"]).arg(&project).assert().success();
let first = gateway_port_of(dir.path(), "first");
assert!((7100..=7199).contains(&first), "the port comes from the gateway range, got {first}");
let dotenv = std::fs::read_to_string(project.join(".env.development.local")).unwrap();
assert!(dotenv.contains(&format!("http://localhost:{first}")), "{dotenv}");
let second_project = project.parent().unwrap().join("second");
std::fs::create_dir_all(&second_project).unwrap();
turnout(dir.path())
.args(["app", "add", "second", "--path"])
.arg(&second_project)
.assert()
.success();
assert_ne!(first, gateway_port_of(dir.path(), "second"));
turnout(dir.path()).args(["app", "edit", "first", "--port", "7250"]).assert().success();
assert_eq!(gateway_port_of(dir.path(), "first"), 7250);
turnout(dir.path()).args(["app", "edit", "first", "--port", "0"]).assert().success();
let apps = read_apps(dir.path());
assert!(apps.iter().find(|a| a["name"] == "first").unwrap()["gateway_port"].is_null());
}
#[test]
fn the_migration_fills_the_apps_that_have_no_gateway_port() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "kept", "--path"])
.arg(&project)
.args(["--port", "7001"])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "filled", "--path"])
.arg(&project)
.args(["--port", "0"])
.assert()
.success();
let meta_path = dir.path().join("meta.json");
let mut meta: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
meta["schema_version"] = serde_json::json!(3);
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
turnout(dir.path()).args(["app", "list"]).assert().success();
assert_eq!(gateway_port_of(dir.path(), "kept"), 7001);
let filled = gateway_port_of(dir.path(), "filled");
assert!((7100..=7199).contains(&filled), "got {filled}");
}
fn read_apps(home: &std::path::Path) -> Vec<serde_json::Value> {
let text = std::fs::read_to_string(home.join("apps.json")).unwrap();
serde_json::from_str(&text).unwrap()
}
fn gateway_port_of(home: &std::path::Path, name: &str) -> u16 {
let apps = read_apps(home);
let app = apps.iter().find(|a| a["name"] == name).unwrap_or_else(|| panic!("no app '{name}' in {apps:?}"));
app["gateway_port"]
.as_u64()
.unwrap_or_else(|| panic!("app '{name}' has no gateway port: {app}")) as u16
}
#[test]
fn dev_gets_a_port_once_and_hands_it_over() {
let (dir, project) = workspace();
let show = if cfg!(windows) { "echo [%PORT%] [{port}]" } else { "echo [$PORT] [{port}]" };
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", &format!("dev={show}"), "--command", &format!("other={show}")])
.assert()
.success();
turnout(dir.path())
.args(["run", "other", "myapp"])
.assert()
.failure()
.stderr(predicate::str::contains("has no dev port yet"));
let first = turnout(dir.path()).args(["dev", "myapp"]).assert().success();
let out = String::from_utf8(first.get_output().stdout.clone()).unwrap();
let port: u16 = out.trim().trim_start_matches('[').split(']').next().unwrap().parse().expect(&out);
assert!((5100..=5199).contains(&port), "{out}");
assert_eq!(out.trim(), format!("[{port}] [{port}]"), "PORT and {{port}} disagree");
turnout(dir.path())
.args(["dev", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains(format!("[{port}] [{port}]")));
turnout(dir.path())
.args(["app", "show", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains(format!("Address: http://myapp.localhost (dev server port {port})")));
turnout(dir.path())
.args(["app", "add", "other", "--path"])
.arg(&project)
.args(["--dev-port", &port.to_string()])
.assert()
.failure()
.stderr(predicate::str::contains(format!("dev port {port} is already used by app 'myapp'")));
turnout(dir.path()).args(["app", "edit", "myapp", "--dev-port", "0"]).assert().success();
turnout(dir.path())
.args(["app", "show", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("assigned on the first `turnout dev`"));
}
#[test]
fn open_needs_a_running_gateway() {
let (dir, project) = workspace();
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["open", "myapp"])
.assert()
.failure()
.stderr(predicate::str::contains("the gateway is not running").and(predicate::str::contains("turnout open myapp")));
}
#[test]
fn the_front_door_routes_by_name_and_says_who_is_not_running() {
let (dir, project) = workspace();
let (stand_port, stand_listener) = reserved_port();
let (gateway_port, gateway_reservation) = reserved_port();
let (front, front_reservation) = reserved_port();
let (silent, silent_reservation) = reserved_port();
spawn_stand(stand_listener);
turnout(dir.path())
.args(["app", "add", "web", "--path"])
.arg(&project)
.args(["--port", &gateway_port.to_string(), "--dev-port", &stand_port.to_string()])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "gone", "--path"])
.arg(&project)
.args(["--dev-port", &silent.to_string()])
.assert()
.success();
turnout(dir.path()).args(["app", "add", "idle", "--path"]).arg(&project).assert().success();
let six = std::net::TcpListener::bind("[::1]:0").ok();
if let Some(listener) = six.as_ref() {
let six_port = listener.local_addr().unwrap().port();
turnout(dir.path())
.args(["app", "add", "six", "--path"])
.arg(&project)
.args(["--dev-port", &six_port.to_string()])
.assert()
.success();
}
if let Some(listener) = six {
spawn_stand(listener);
}
drop(gateway_reservation);
drop(front_reservation);
drop(silent_reservation);
let child = std::process::Command::new(assert_cmd::cargo::cargo_bin("turnout"))
.env("TURNOUT_DATA_DIR", dir.path())
.args(["gateway", "run", "--front-port", &front.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let _guard = ChildGuard(child);
wait_for_port(front);
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
runtime.block_on(async move {
let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap();
let base = format!("http://127.0.0.1:{front}");
let get = |path: &str, host: String| client.get(format!("{base}{path}")).header("host", host).send();
let response = get("/hello", "web.localhost".into()).await.unwrap();
assert_eq!(response.status(), 200);
assert_eq!(response.text().await.unwrap(), "hello from the stand");
let response = get("/hello", format!("web.localhost:{front}")).await.unwrap();
assert_eq!(response.status(), 200);
let response = get("/login", format!("web.localhost:{front}")).await.unwrap();
assert_eq!(response.status(), 302);
assert_eq!(response.headers()["location"], format!("http://web.localhost:{front}/after"));
if std::net::TcpListener::bind("[::1]:0").is_ok() {
let response = get("/hello", "six.localhost".into()).await.unwrap();
assert_eq!(response.status(), 200, "the door did not reach a server listening on ::1 only");
}
for name in ["gone", "idle"] {
let response = get("/", format!("{name}.localhost")).await.unwrap();
assert_eq!(response.status(), 503, "{name}");
let text = response.text().await.unwrap();
assert!(text.contains(&format!("turnout dev {name}")), "{text}");
}
let response = get("/", "nosuch.localhost".into()).await.unwrap();
assert_eq!(response.status(), 404);
assert!(response.text().await.unwrap().contains("no app named 'nosuch'"));
let response = get("/", "localhost".into()).await.unwrap();
assert_eq!(response.status(), 404);
assert!(response.text().await.unwrap().contains("names no app"));
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let mut request = format!("ws://127.0.0.1:{front}/ws").into_client_request().unwrap();
request.headers_mut().insert("host", "web.localhost".parse().unwrap());
let (mut socket, _) = tokio_tungstenite::connect_async(request).await.unwrap();
use futures_util::{SinkExt, StreamExt};
socket.send(tokio_tungstenite::tungstenite::Message::Text("ping".into())).await.unwrap();
match socket.next().await.unwrap().unwrap() {
tokio_tungstenite::tungstenite::Message::Text(text) => assert_eq!(text.as_str(), "echo: ping"),
other => panic!("expected the echo through the door, got {other:?}"),
}
});
}
#[cfg(windows)]
fn fake_ssh(dir: &std::path::Path) -> std::path::PathBuf {
let script = dir.join("fake-ssh.cmd");
std::fs::write(&script, "@echo %*\r\n").unwrap();
script
}
#[cfg(not(windows))]
fn fake_ssh(dir: &std::path::Path) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("fake-ssh");
std::fs::write(&script, "#!/bin/sh\necho \"$@\"\n").unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
#[test]
fn ssh_composes_the_session_from_the_catalogs() {
let (dir, project) = workspace();
add_staging(dir.path());
add_path(dir.path());
add_credential(dir.path());
turnout(dir.path())
.args(["server", "edit", "staging", "--credential", "deploy"])
.assert()
.success();
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args([
"target",
"add",
"--app",
"myapp",
"--server",
"staging",
"--credential",
"deploy",
"--path",
"wwwroot",
])
.assert()
.success();
let servers = dir.path().join("servers.json");
let edited = std::fs::read_to_string(&servers)
.unwrap()
.replacen("\"name\": \"staging\",", "\"name\": \"staging\",\n \"shell\": \"posix\",", 1);
assert_ne!(edited, std::fs::read_to_string(&servers).unwrap(), "the server entry was not found");
std::fs::write(&servers, edited).unwrap();
let client = fake_ssh(dir.path());
turnout(dir.path())
.env("TURNOUT_SSH", &client)
.args(["ssh", "myapp-staging"])
.assert()
.success()
.stdout(predicate::str::contains("-p 22 deploy@staging.example.com -t").and(predicate::str::contains("cd '/var/www/myapp' && exec $SHELL -l")));
turnout(dir.path())
.env("TURNOUT_SSH", &client)
.args(["ssh", "staging"])
.assert()
.success()
.stdout(predicate::str::contains("-p 22 deploy@staging.example.com").and(predicate::str::contains("-t").not()));
turnout(dir.path())
.args(["credential", "add", "keyed", "--user", "ops", "--auth", "key", "--key", "/keys/id_ed25519"])
.assert()
.success();
turnout(dir.path())
.env("TURNOUT_SSH", &client)
.args(["ssh", "staging", "--credential", "keyed"])
.assert()
.success()
.stdout(predicate::str::contains("-p 22 -i /keys/id_ed25519 ops@staging.example.com"));
turnout(dir.path())
.env("TURNOUT_SSH", dir.path().join("no-such-client"))
.args(["ssh", "staging"])
.assert()
.failure()
.stderr(predicate::str::contains("cannot run").and(predicate::str::contains("TURNOUT_SSH")));
}
#[test]
fn exec_resolves_the_host_before_dialing_and_needs_a_credential() {
let (dir, project) = workspace();
let (dead, reservation) = reserved_port();
drop(reservation);
turnout(dir.path())
.args([
"server",
"add",
"box",
"--url",
&format!("http://127.0.0.1:{dead}"),
"--host",
&format!("127.0.0.1:{dead}"),
])
.assert()
.success();
save_access(dir.path());
add_path(dir.path());
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["exec", "box", "--", "uptime"])
.assert()
.failure()
.stderr(predicate::str::contains("server 'box' has no credential").and(predicate::str::contains("--credential")));
turnout_secrets(dir.path())
.args(["exec", "box", "--credential", "deploy", "--", "uptime", "-p"])
.assert()
.failure()
.stderr(predicate::str::contains(format!("[deploy@127.0.0.1:{dead}] uptime -p")).and(predicate::str::contains("cannot reach")));
turnout(dir.path())
.args([
"target",
"add",
"--app",
"myapp",
"--server",
"box",
"--credential",
"deploy",
"--path",
"wwwroot",
])
.assert()
.success();
turnout_secrets(dir.path())
.args(["exec", "myapp-box", "--", "ls"])
.assert()
.failure()
.stderr(predicate::str::contains(format!("[deploy@127.0.0.1:{dead}] /var/www/myapp$ ls")));
turnout(dir.path()).args(["exec", "box"]).assert().failure();
}
#[test]
fn gateway_run_on_a_busy_port_suggests_stopping() {
let (dir, project) = workspace();
let busy = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = busy.local_addr().unwrap().port();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", &port.to_string()])
.assert()
.success();
turnout(dir.path())
.args(["gateway", "run"])
.assert()
.failure()
.stderr(predicate::str::contains("turnout gateway stop"));
}
#[test]
fn server_crud_roundtrip() {
let (dir, _) = workspace();
turnout(dir.path())
.args([
"server",
"add",
"staging",
"--url",
"https://staging.example.com",
"--label",
"Staging",
"--host",
"ssh.staging.example.com:2200",
"--insecure",
])
.assert()
.success();
turnout(dir.path()).args(["server", "show", "staging"]).assert().success().stdout(
predicate::str::contains("https://staging.example.com")
.and(predicate::str::contains("ssh.staging.example.com:2200"))
.and(predicate::str::contains("accept invalid certificates")),
);
turnout(dir.path()).args(["server", "edit", "staging", "--secure"]).assert().success();
turnout(dir.path())
.args(["server", "show", "staging"])
.assert()
.success()
.stdout(predicate::str::contains("verify certificates"));
}
#[test]
fn server_add_requires_valid_url() {
let (dir, _) = workspace();
turnout(dir.path())
.args(["server", "add", "bad", "--url", "staging.example.com"])
.assert()
.failure()
.stderr(predicate::str::contains("must start with http"));
}
#[test]
fn app_cannot_allow_unknown_server() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--server", "nosuch"])
.assert()
.failure()
.stderr(predicate::str::contains("no server named 'nosuch'"));
}
#[test]
fn removing_server_updates_apps() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--server", "staging"])
.assert()
.success();
turnout(dir.path())
.args(["server", "remove", "staging", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("Removed 'staging' from apps: myapp"));
turnout(dir.path())
.args(["app", "show", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("Servers: any - no allow-list"));
}
fn turnout_secrets(data_dir: &std::path::Path) -> Command {
let mut cmd = turnout(data_dir);
cmd.env("TURNOUT_KEYRING", "insecure-file");
cmd
}
fn add_staging(dir: &std::path::Path) {
turnout(dir)
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
}
fn add_credential(dir: &std::path::Path) {
turnout(dir).args(["credential", "add", "deploy", "--user", "deploy"]).assert().success();
}
fn add_path(dir: &std::path::Path) {
turnout(dir).args(["path", "add", "wwwroot", "--dir", "/var/www/myapp"]).assert().success();
}
fn save_access(dir: &std::path::Path) {
add_credential(dir);
turnout_secrets(dir)
.args(["pass", "set", "deploy"])
.write_stdin("s3cret\n")
.assert()
.success()
.stdout(predicate::str::contains("saved"));
}
#[test]
fn pass_set_copy_roundtrip() {
let (dir, _) = workspace();
add_staging(dir.path());
save_access(dir.path());
turnout_secrets(dir.path())
.args(["pass", "copy", "deploy", "--show"])
.assert()
.success()
.stdout(predicate::str::contains("s3cret"));
turnout_secrets(dir.path())
.args(["pass", "copy", "deploy", "--user", "--show"])
.assert()
.success()
.stdout(predicate::str::contains("deploy"));
turnout_secrets(dir.path())
.args(["credential", "show", "deploy"])
.assert()
.success()
.stdout(predicate::str::contains("stored in the OS keyring").and(predicate::str::contains("s3cret").not()));
turnout_secrets(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("credentials deploy"));
}
#[test]
fn pass_set_requires_a_known_credential() {
let (dir, _) = workspace();
add_credential(dir.path());
turnout_secrets(dir.path())
.args(["pass", "set", "nosuch"])
.write_stdin("value\n")
.assert()
.failure()
.stderr(predicate::str::contains("no credential named 'nosuch'"));
}
#[test]
fn pass_remove_deletes_the_secret_but_keeps_the_credential() {
let (dir, _) = workspace();
add_staging(dir.path());
save_access(dir.path());
turnout_secrets(dir.path()).args(["pass", "remove", "deploy", "--yes"]).assert().success();
turnout_secrets(dir.path())
.args(["pass", "copy", "deploy", "--show"])
.assert()
.failure()
.stderr(predicate::str::contains("no secret stored"));
turnout_secrets(dir.path())
.args(["credential", "list"])
.assert()
.success()
.stdout(predicate::str::contains("deploy"));
}
#[test]
fn server_remove_keeps_the_credential() {
let (dir, _) = workspace();
add_staging(dir.path());
save_access(dir.path());
turnout(dir.path())
.args(["server", "edit", "staging", "--credential", "deploy"])
.assert()
.success();
turnout_secrets(dir.path()).args(["server", "remove", "staging", "--yes"]).assert().success();
turnout_secrets(dir.path())
.args(["pass", "copy", "deploy", "--show"])
.assert()
.success()
.stdout(predicate::str::contains("s3cret"));
}
#[test]
fn credential_remove_clears_it_from_servers() {
let (dir, _) = workspace();
add_staging(dir.path());
save_access(dir.path());
turnout(dir.path())
.args(["server", "edit", "staging", "--credential", "deploy"])
.assert()
.success();
turnout_secrets(dir.path())
.args(["credential", "remove", "deploy", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("Cleared it from servers: staging"));
turnout(dir.path())
.args(["server", "show", "staging"])
.assert()
.success()
.stdout(predicate::str::contains("Credential: none"));
turnout_secrets(dir.path())
.args(["pass", "copy", "deploy", "--show"])
.assert()
.failure()
.stderr(predicate::str::contains("no credential named"));
}
#[cfg(not(target_os = "linux"))]
#[test]
fn pass_copy_never_prints_the_secret() {
let (dir, _) = workspace();
add_staging(dir.path());
save_access(dir.path());
turnout_secrets(dir.path())
.args(["pass", "copy", "deploy"])
.assert()
.success()
.stdout(predicate::str::contains("copied to the clipboard").and(predicate::str::contains("s3cret").not()));
}
#[test]
fn use_binds_app_and_shows_in_status() {
let (dir, project) = workspace();
add_staging(dir.path());
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["use", "myapp", "staging", "--no-check"])
.assert()
.success()
.stdout(predicate::str::contains("'myapp' now uses 'staging'"));
turnout(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("myapp -> staging"));
}
#[test]
fn use_respects_the_allow_list() {
let (dir, project) = workspace();
add_staging(dir.path());
turnout(dir.path())
.args(["server", "add", "other", "--url", "https://other.example.com"])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--server", "staging"])
.assert()
.success();
turnout(dir.path())
.args(["use", "myapp", "other", "--no-check"])
.assert()
.failure()
.stderr(predicate::str::contains("not allowed"));
}
fn reserved_port() -> (u16, std::net::TcpListener) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
(listener.local_addr().unwrap().port(), listener)
}
fn wait_for_port(port: u16) {
wait_for_addr(std::net::SocketAddr::from(([127, 0, 0, 1], port)));
}
fn wait_for_addr(address: std::net::SocketAddr) {
let port = address.port();
for _ in 0..100 {
if std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_millis(100)).is_ok() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!("port {port} never came up");
}
struct StopsGateway(std::path::PathBuf);
impl Drop for StopsGateway {
fn drop(&mut self) {
let _ = turnout(&self.0).args(["gateway", "stop"]).output();
}
}
fn status_json(data_dir: &std::path::Path) -> serde_json::Value {
let output = turnout(data_dir).args(["status", "--json"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
serde_json::from_slice(&output.stdout).unwrap_or_else(|err| panic!("{err}: {}", String::from_utf8_lossy(&output.stdout)))
}
struct ChildGuard(std::process::Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
}
}
fn spawn_stand(listener: std::net::TcpListener) -> u16 {
let address = listener.local_addr().unwrap();
let port = address.port();
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
runtime.block_on(async move {
use axum::http::HeaderMap;
use axum::routing::get;
let base = format!("http://127.0.0.1:{port}");
let revoked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let app = axum::Router::new()
.route(
"/hello",
get(|| async { ([("set-cookie", "sid=abc123; Path=/; HttpOnly")], "hello from the stand") }),
)
.route(
"/echo-cookie",
get(|headers: HeaderMap| async move { headers.get("cookie").and_then(|v| v.to_str().ok()).unwrap_or("none").to_string() }),
)
.route("/health", get(|| async { "ok" }))
.route("/expire", get(|| async { (axum::http::StatusCode::UNAUTHORIZED, "log in again") }))
.route(
"/me",
get({
let revoked = revoked.clone();
move |headers: HeaderMap| {
let revoked = revoked.clone();
async move {
let cookie = headers.get("cookie").and_then(|v| v.to_str().ok()).unwrap_or_default();
if cookie.contains("sid=abc123") && !revoked.load(std::sync::atomic::Ordering::SeqCst) {
(axum::http::StatusCode::OK, "me")
} else {
(axum::http::StatusCode::UNAUTHORIZED, "who are you?")
}
}
}
}),
)
.route(
"/revoke",
get({
let revoked = revoked.clone();
move || {
let revoked = revoked.clone();
async move {
revoked.store(true, std::sync::atomic::Ordering::SeqCst);
"revoked"
}
}
}),
)
.route("/bye", get(|| async { ([("set-cookie", "sid=deleted; Path=/; Max-Age=0")], "bye") }))
.route("/down", get(|| async { (axum::http::StatusCode::BAD_GATEWAY, "bad gateway") }))
.route("/sick/health", get(|| async { (axum::http::StatusCode::SERVICE_UNAVAILABLE, "unavailable") }))
.route(
"/login",
get(move || async move { (axum::http::StatusCode::FOUND, [("location", format!("{base}/after"))]) }),
)
.route(
"/ws",
get(|ws: axum::extract::ws::WebSocketUpgrade| async move {
ws.on_upgrade(|mut socket| async move {
while let Some(Ok(message)) = socket.recv().await {
if let axum::extract::ws::Message::Text(text) = message {
if text.as_str() == "bye" {
let frame = axum::extract::ws::CloseFrame {
code: 1008,
reason: "policy".into(),
};
let _ = socket.send(axum::extract::ws::Message::Close(Some(frame))).await;
break;
}
let reply = axum::extract::ws::Message::Text(format!("echo: {text}").into());
if socket.send(reply).await.is_err() {
break;
}
}
}
})
}),
);
listener.set_nonblocking(true).unwrap();
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
axum::serve(listener, app).await.unwrap();
});
});
wait_for_addr(address);
port
}
#[test]
fn gateway_proxies_with_cookie_jar_and_location_rewrite() {
let (dir, project) = workspace();
let (stand_port, stand_listener) = reserved_port();
let (gateway_port, gateway_reservation) = reserved_port();
spawn_stand(stand_listener);
turnout(dir.path())
.args(["server", "add", "stand", "--url", &format!("http://127.0.0.1:{stand_port}")])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", &gateway_port.to_string()])
.assert()
.success();
turnout(dir.path()).args(["use", "myapp", "stand", "--no-check"]).assert().success();
drop(gateway_reservation);
let child = std::process::Command::new(assert_cmd::cargo::cargo_bin("turnout"))
.env("TURNOUT_DATA_DIR", dir.path())
.env("TURNOUT_FRONT_PORT", "0")
.args(["gateway", "run"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let _guard = ChildGuard(child);
wait_for_port(gateway_port);
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
runtime.block_on(async move {
let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap();
let base = format!("http://127.0.0.1:{gateway_port}");
let response = client.get(format!("{base}/hello")).send().await.unwrap();
assert_eq!(response.status(), 200);
assert!(response.headers().get("set-cookie").is_none(), "set-cookie must be stripped");
assert_eq!(response.text().await.unwrap(), "hello from the stand");
let response = client.get(format!("{base}/echo-cookie")).send().await.unwrap();
assert_eq!(response.text().await.unwrap(), "sid=abc123");
let response = client.get(format!("{base}/login")).send().await.unwrap();
assert_eq!(response.status(), 302);
let location = response.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(location, &format!("http://localhost:{gateway_port}/after"));
use futures_util::{SinkExt, StreamExt};
let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{gateway_port}/ws")).await.unwrap();
socket.send(tokio_tungstenite::tungstenite::Message::text("ping")).await.unwrap();
let reply = socket.next().await.unwrap().unwrap();
assert_eq!(reply.to_text().unwrap(), "echo: ping");
socket.send(tokio_tungstenite::tungstenite::Message::text("bye")).await.unwrap();
match socket.next().await.unwrap().unwrap() {
tokio_tungstenite::tungstenite::Message::Close(Some(frame)) => {
assert_eq!(u16::from(frame.code), 1008, "the close code must survive the proxy");
assert_eq!(frame.reason.as_str(), "policy", "the close reason must survive the proxy");
}
other => panic!("expected a close frame through the gateway, got {other:?}"),
}
});
}
#[test]
fn run_executes_app_commands_with_exit_codes() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", "hello=echo hi from turnout", "--command", "fail=exit 3"])
.assert()
.success();
turnout(dir.path())
.args(["run", "hello", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("hi from turnout"));
turnout(dir.path()).args(["run", "fail", "myapp"]).assert().code(3);
turnout(dir.path())
.args(["run", "nosuch", "myapp"])
.assert()
.failure()
.stderr(predicate::str::contains("has no 'nosuch' command"));
}
#[test]
fn every_job_leaves_its_output_in_a_log_file() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args([
"--command",
"hello=echo out-line",
"--command",
"both=echo out-line && echo err-line 1>&2",
"--command",
"second=echo replaced",
])
.assert()
.success();
let log = dir.path().join("logs").join("myapp.hello.log");
turnout(dir.path()).args(["run", "hello", "myapp"]).assert().success();
assert!(log.is_file(), "no log at {}", log.display());
assert!(std::fs::read_to_string(&log).unwrap().contains("out-line"));
turnout(dir.path()).args(["run", "both", "myapp"]).assert().success();
let both = std::fs::read_to_string(dir.path().join("logs").join("myapp.both.log")).unwrap();
assert!(both.contains("out-line") && both.contains("err-line"), "{both}");
turnout(dir.path()).args(["run", "hello", "myapp"]).assert().success();
let text = std::fs::read_to_string(&log).unwrap();
assert_eq!(text.matches("out-line").count(), 1, "the log grew instead of being replaced: {text}");
}
#[test]
fn a_command_named_like_an_npm_script_still_gets_a_log() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", "test:e2e=echo e2e ran"])
.assert()
.success();
turnout(dir.path()).args(["run", "test:e2e", "myapp"]).assert().success();
let log = dir.path().join("logs").join("myapp.test%3Ae2e.log");
assert!(log.is_file(), "no log at {}", log.display());
assert!(std::fs::read_to_string(&log).unwrap().contains("e2e ran"));
}
#[test]
fn verbose_streams_the_output_and_keeps_the_exit_code() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args([
"--command",
"build=echo building",
"--command",
"test=echo testing",
"--command",
"lint=echo linting",
"--command",
"dev=echo serving",
"--command",
"fail=exit 3",
])
.assert()
.success();
for command in [["build"], ["test"], ["lint"]] {
turnout(dir.path())
.args(command)
.args(["myapp", "--verbose"])
.assert()
.success()
.stdout(predicate::str::contains(command[0].trim_end_matches('d')));
}
turnout(dir.path())
.args(["dev", "myapp", "--verbose"])
.assert()
.success()
.stdout(predicate::str::contains("serving"));
turnout(dir.path()).args(["run", "fail", "myapp", "-v"]).assert().code(3);
}
#[test]
fn a_quiet_command_hides_its_output_until_it_fails() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args([
"--command",
"build=echo compiling module one && echo compiling module two",
"--command",
"broken=echo compiling module one && echo boom: undefined symbol 1>&2 && exit 2",
])
.assert()
.success();
turnout(dir.path())
.env("TURNOUT_CONSOLE", "quiet")
.args(["build", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("compiling module one").not())
.stdout(predicate::str::contains("Building myapp"));
turnout(dir.path())
.env("TURNOUT_CONSOLE", "quiet")
.args(["run", "broken", "myapp"])
.assert()
.code(2)
.stderr(predicate::str::contains("boom: undefined symbol"))
.stderr(predicate::str::contains("compiling module one"))
.stderr(predicate::str::contains("full output:"));
let log = dir.path().join("logs").join("myapp.build.log");
assert!(std::fs::read_to_string(&log).unwrap().contains("compiling module two"));
}
#[test]
fn a_dev_server_goes_quiet_once_it_is_ready() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args([
"--command",
"dev=echo VITE v5.4.0 ready in 431 ms && echo hmr update /src/App.vue && echo Error: failed to resolve import",
])
.assert()
.success();
turnout(dir.path())
.env("TURNOUT_CONSOLE", "ready")
.args(["dev", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("myapp ready in"))
.stdout(predicate::str::contains("hmr update").not())
.stdout(predicate::str::contains("Error: failed to resolve import"));
let log = std::fs::read_to_string(dir.path().join("logs").join("myapp.dev.log")).unwrap();
assert!(log.contains("hmr update /src/App.vue"), "{log}");
}
fn pause_a_second() -> &'static str {
if cfg!(windows) {
"ping -n 2 127.0.0.1 >nul"
} else {
"sleep 1"
}
}
#[test]
fn a_server_that_never_announces_itself_gets_the_console_back() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args([
"--command",
&format!("dev=echo booting the thing && {} && echo still going", pause_a_second()),
])
.assert()
.success();
turnout(dir.path())
.env("TURNOUT_CONSOLE", "ready")
.env("TURNOUT_READY_PATIENCE_MS", "300")
.args(["dev", "myapp"])
.assert()
.success()
.stderr(predicate::str::contains("booting the thing"))
.stdout(predicate::str::contains("still going"));
}
#[test]
fn open_is_offered_only_where_a_server_can_come_up() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", "build=echo building", "--command", "dev=echo ready in 10 ms"])
.assert()
.success();
for command in [["build"], ["test"], ["lint"]] {
turnout(dir.path()).args(command).args(["myapp", "--open"]).assert().failure();
}
turnout(dir.path())
.env("TURNOUT_CONSOLE", "ready")
.args(["dev", "myapp", "--open"])
.assert()
.success()
.stdout(predicate::str::contains("myapp ready in"))
.stderr(predicate::str::contains("--open needs the gateway's front door"));
turnout(dir.path())
.args(["run", "build", "myapp", "--open"])
.assert()
.success()
.stderr(predicate::str::contains("--open waits for a server"));
turnout(dir.path())
.args(["dev", "myapp", "--verbose", "--open"])
.assert()
.success()
.stdout(predicate::str::contains("ready in 10 ms"))
.stderr(predicate::str::contains("--open needs the gateway's front door"));
}
#[test]
fn run_resolves_app_from_current_directory() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", "hello=echo resolved by cwd"])
.assert()
.success();
let nested = project.join("src");
std::fs::create_dir(&nested).unwrap();
turnout(dir.path())
.current_dir(&nested)
.args(["run", "hello"])
.assert()
.success()
.stdout(predicate::str::contains("resolved by cwd"));
turnout(dir.path())
.current_dir(dir.path())
.args(["run", "hello"])
.assert()
.failure()
.stderr(predicate::str::contains("not inside a known app directory"));
}
#[test]
fn a_target_names_the_whole_deploy() {
let (dir, project) = workspace();
add_staging(dir.path());
add_path(dir.path());
add_credential(dir.path());
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["path", "edit", "wwwroot", "--restart", "systemctl restart myapp"])
.assert()
.success();
turnout(dir.path())
.args([
"target",
"add",
"--app",
"myapp",
"--server",
"staging",
"--credential",
"deploy",
"--path",
"wwwroot",
])
.assert()
.success()
.stdout(predicate::str::contains("myapp-staging"));
turnout(dir.path()).args(["target", "show", "myapp-staging"]).assert().success().stdout(
predicate::str::contains("myapp")
.and(predicate::str::contains("staging"))
.and(predicate::str::contains("/var/www/myapp")),
);
turnout(dir.path())
.args(["server", "show", "staging"])
.assert()
.success()
.stdout(predicate::str::contains("myapp-staging"));
turnout(dir.path())
.args(["path", "show", "wwwroot"])
.assert()
.success()
.stdout(predicate::str::contains("systemctl restart myapp").and(predicate::str::contains("myapp-staging")));
turnout(dir.path())
.args([
"target",
"add",
"other",
"--app",
"myapp",
"--server",
"staging",
"--credential",
"deploy",
"--path",
"ghost",
])
.assert()
.failure()
.stderr(predicate::str::contains("no path named 'ghost'"));
turnout(dir.path())
.args([
"target",
"add",
"other",
"--app",
"ghost",
"--server",
"staging",
"--credential",
"deploy",
"--path",
"wwwroot",
])
.assert()
.failure()
.stderr(predicate::str::contains("no app named 'ghost'"));
turnout(dir.path()).args(["target", "rename", "myapp-staging", "live"]).assert().success();
turnout(dir.path())
.args(["target", "list"])
.assert()
.success()
.stdout(predicate::str::contains("live").and(predicate::str::contains("myapp-staging").not()));
}
fn a_full_target(dir: &std::path::Path, project: &std::path::Path, extra: &[&str]) {
add_staging(dir);
add_path(dir);
add_credential(dir);
turnout(dir).args(["app", "add", "myapp", "--path"]).arg(project).assert().success();
turnout(dir)
.args([
"target",
"add",
"--app",
"myapp",
"--server",
"staging",
"--credential",
"deploy",
"--path",
"wwwroot",
])
.args(extra)
.assert()
.success();
}
fn target_json(dir: &std::path::Path) -> serde_json::Value {
let output = turnout(dir).args(["target", "show", "myapp-staging", "--json"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
serde_json::from_slice(&output.stdout).unwrap()
}
#[test]
fn a_target_carries_its_address_and_health_check() {
let (dir, project) = workspace();
a_full_target(dir.path(), &project, &["--health", "/health", "--health-wait", "90"]);
let target = target_json(dir.path());
assert_eq!(target["health"], serde_json::json!({"url": "/health", "command": null, "wait": 90}));
assert_eq!(target["url"], serde_json::Value::Null);
assert_eq!(target["address"], "https://staging.example.com", "the server's, when the target names none");
turnout(dir.path())
.args(["target", "show", "myapp-staging"])
.assert()
.success()
.stdout(predicate::str::contains("GET https://staging.example.com/health").and(predicate::str::contains("rolled back")));
turnout(dir.path())
.args([
"target",
"edit",
"myapp-staging",
"--url",
"https://admin.example.com/",
"--health-command",
"curl -fsS localhost:3000/health",
])
.assert()
.success();
let target = target_json(dir.path());
assert_eq!(target["url"], "https://admin.example.com");
assert_eq!(
target["health"],
serde_json::json!({"url": null, "command": "curl -fsS localhost:3000/health", "wait": 90}),
"a new probe keeps the wait"
);
turnout(dir.path())
.args(["target", "edit", "myapp-staging", "--health", "", "--health-wait", "5"])
.assert()
.failure()
.stderr(predicate::str::contains("--health-wait belongs to a check"));
turnout(dir.path())
.args(["target", "edit", "myapp-staging", "--url", "admin.example.com"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid URL"));
turnout(dir.path())
.args(["target", "edit", "myapp-staging", "--health", "/up", "--health-command", "true"])
.assert()
.failure();
assert_eq!(target_json(dir.path())["url"], "https://admin.example.com");
turnout(dir.path())
.args(["target", "edit", "myapp-staging", "--health", "", "--url", ""])
.assert()
.success();
let target = target_json(dir.path());
assert_eq!(target["health"], serde_json::Value::Null);
assert_eq!(target["url"], serde_json::Value::Null);
}
#[test]
fn open_a_target_opens_its_stand() {
let (dir, project) = workspace();
a_full_target(dir.path(), &project, &[]);
let opened = dir.path().join("opened.txt");
turnout(dir.path())
.env("TURNOUT_OPENED", &opened)
.args(["open", "myapp-staging"])
.assert()
.success()
.stdout(predicate::str::contains("Opening https://staging.example.com"));
turnout(dir.path())
.args(["target", "edit", "myapp-staging", "--url", "https://admin.example.com"])
.assert()
.success();
turnout(dir.path())
.env("TURNOUT_OPENED", &opened)
.args(["open", "myapp-staging"])
.assert()
.success();
assert_eq!(
std::fs::read_to_string(&opened).unwrap().lines().collect::<Vec<_>>(),
vec!["https://staging.example.com", "https://admin.example.com"]
);
}
#[test]
fn deploy_takes_several_targets() {
let (dir, project) = workspace();
a_full_target(dir.path(), &project, &[]);
turnout(dir.path())
.args(["deploy", "myapp-staging", "other", "--path", "wwwroot"])
.assert()
.failure()
.stderr(predicate::str::contains("name a single target"));
turnout(dir.path())
.args(["deploy", "myapp-staging", "myapp-staging"])
.assert()
.failure()
.stderr(predicate::str::contains("named twice"));
turnout(dir.path())
.args(["deploy", "myapp-staging", "--json"])
.assert()
.failure()
.stderr(predicate::str::contains("--diff"));
turnout(dir.path())
.args(["deploy", "myapp-staging", "--diff", "--detach"])
.assert()
.failure()
.stderr(predicate::str::contains("cannot be used with"));
}
#[test]
fn a_build_older_than_its_sources_is_named() {
let (dir, project) = workspace();
a_full_target(dir.path(), &project, &[]);
turnout(dir.path())
.args(["server", "edit", "staging", "--host", "127.0.0.1:1"])
.assert()
.success();
turnout(dir.path()).args(["app", "edit", "myapp", "--dist", "dist"]).assert().success();
let dist = project.join("dist");
std::fs::create_dir_all(&dist).unwrap();
std::fs::write(dist.join("index.html"), "built").unwrap();
let built = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
std::fs::File::options()
.write(true)
.open(dist.join("index.html"))
.unwrap()
.set_modified(built)
.unwrap();
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::write(project.join("src").join("main.ts"), "edited").unwrap();
turnout(dir.path()).args(["deploy", "myapp-staging", "--no-build"]).assert().failure().stderr(
predicate::str::contains("no password stored")
.and(predicate::str::contains("dist is older than its sources: src/main.ts changed"))
.and(predicate::str::contains("'myapp' has no build command")),
);
}
#[test]
fn path_remove_takes_the_targets_that_used_it() {
let (dir, project) = workspace();
add_staging(dir.path());
add_path(dir.path());
add_credential(dir.path());
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args([
"target",
"add",
"--app",
"myapp",
"--server",
"staging",
"--credential",
"deploy",
"--path",
"wwwroot",
])
.assert()
.success();
turnout(dir.path())
.args(["path", "remove", "wwwroot", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("myapp-staging").and(predicate::str::contains("the directory on the server is untouched")));
turnout(dir.path())
.args(["target", "list"])
.assert()
.success()
.stdout(predicate::str::contains("No targets yet"));
}
#[test]
fn credential_manages_the_key_file() {
let (dir, _) = workspace();
turnout(dir.path())
.args(["credential", "add", "deploy", "--user", "deploy", "--key", "/home/me/.ssh/id_ed25519"])
.assert()
.success();
turnout(dir.path())
.args(["credential", "show", "deploy"])
.assert()
.success()
.stdout(predicate::str::contains("Auth: key").and(predicate::str::contains("/home/me/.ssh/id_ed25519")));
turnout(dir.path())
.args(["credential", "edit", "deploy", "--auth", "password", "--key", ""])
.assert()
.success();
turnout(dir.path())
.args(["credential", "show", "deploy"])
.assert()
.success()
.stdout(predicate::str::contains("Auth: password").and(predicate::str::contains("id_ed25519").not()));
turnout(dir.path())
.args(["credential", "edit", "deploy", "--auth", "key"])
.assert()
.failure()
.stderr(predicate::str::contains("needs a key file"));
}
#[test]
fn a_user_at_host_is_refused_with_the_way_over() {
let (dir, _) = workspace();
add_staging(dir.path());
turnout(dir.path())
.args(["server", "edit", "staging", "--host", "deploy@staging.example.com"])
.assert()
.failure()
.stderr(predicate::str::contains("credential").and(predicate::str::contains("--user deploy")));
}
#[test]
fn backup_and_restore_validate_preconditions() {
let (dir, project) = workspace();
add_staging(dir.path());
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["backup", "myapp", "--server", "staging"])
.assert()
.failure()
.stderr(predicate::str::contains("has no credential"));
add_credential(dir.path());
turnout(dir.path())
.args(["server", "edit", "staging", "--credential", "deploy"])
.assert()
.success();
turnout(dir.path())
.args(["restore", "myapp", "--server", "staging", "--list"])
.assert()
.failure()
.stderr(predicate::str::contains("no target for 'myapp' on 'staging'"));
}
#[test]
fn deploy_validates_its_preconditions() {
let (dir, project) = workspace();
add_staging(dir.path());
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["deploy", "myapp", "--no-build"])
.assert()
.failure()
.stderr(predicate::str::contains("name a target").and(predicate::str::contains("--server")));
turnout(dir.path())
.args(["deploy", "myapp", "--server", "staging", "--no-build"])
.assert()
.failure()
.stderr(predicate::str::contains("has no credential"));
add_credential(dir.path());
turnout(dir.path())
.args(["server", "edit", "staging", "--credential", "deploy"])
.assert()
.success();
turnout(dir.path())
.args(["deploy", "myapp", "--server", "staging", "--no-build"])
.assert()
.failure()
.stderr(
predicate::str::contains("no target for 'myapp' on 'staging'")
.and(predicate::str::contains("turnout target add"))
.and(predicate::str::contains("--path")),
);
add_path(dir.path());
turnout(dir.path())
.args([
"target",
"add",
"--app",
"myapp",
"--server",
"staging",
"--credential",
"deploy",
"--path",
"wwwroot",
])
.assert()
.success();
turnout(dir.path())
.args(["deploy", "myapp", "--server", "staging", "--no-build"])
.assert()
.failure()
.stderr(predicate::str::contains("no artifact directory"));
turnout(dir.path())
.args(["deploy", "myapp-staging", "--no-build"])
.assert()
.failure()
.stderr(predicate::str::contains("no artifact directory"));
}
#[test]
fn a_target_naming_a_missing_entity_says_which_target() {
let (dir, project) = workspace();
add_staging(dir.path());
add_credential(dir.path());
add_path(dir.path());
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args([
"target",
"add",
"live",
"--app",
"myapp",
"--server",
"staging",
"--credential",
"deploy",
"--path",
"wwwroot",
])
.assert()
.success();
let catalog = dir.path().join("targets.json");
let edited = std::fs::read_to_string(&catalog).unwrap().replace("\"wwwroot\"", "\"gone\"");
std::fs::write(&catalog, edited).unwrap();
turnout(dir.path()).args(["deploy", "live", "--no-build"]).assert().failure().stderr(
predicate::str::contains("target 'live'")
.and(predicate::str::contains("gone"))
.and(predicate::str::contains("turnout path list")),
);
}
#[test]
fn deploy_accepts_a_credential_and_path_for_one_run() {
let (dir, project) = workspace();
add_staging(dir.path());
add_credential(dir.path());
add_path(dir.path());
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args([
"deploy",
"myapp",
"--server",
"staging",
"--credential",
"deploy",
"--path",
"wwwroot",
"--no-build",
])
.assert()
.failure()
.stderr(predicate::str::contains("no artifact directory"));
}
#[test]
fn group_use_binds_all_members() {
let (dir, project) = workspace();
add_staging(dir.path());
let second = dir.path().join("second");
std::fs::create_dir(&second).unwrap();
turnout(dir.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
turnout(dir.path()).args(["app", "add", "api", "--path"]).arg(&second).assert().success();
turnout(dir.path())
.args(["group", "add", "contour", "--app", "web", "--app", "api"])
.assert()
.success();
turnout(dir.path())
.args(["use", "contour", "staging", "--no-check"])
.assert()
.success()
.stdout(predicate::str::contains("Group 'contour' now uses 'staging'"));
turnout(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("web -> staging").and(predicate::str::contains("api -> staging")));
turnout(dir.path())
.args(["group", "show", "contour"])
.assert()
.success()
.stdout(predicate::str::contains("web -> staging"));
}
#[test]
fn group_names_cannot_clash_with_apps() {
let (dir, project) = workspace();
turnout(dir.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["group", "add", "web", "--app", "web"])
.assert()
.failure()
.stderr(predicate::str::contains("must not clash"));
}
#[test]
fn removing_an_app_updates_groups() {
let (dir, project) = workspace();
let second = dir.path().join("second");
std::fs::create_dir(&second).unwrap();
turnout(dir.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
turnout(dir.path()).args(["app", "add", "api", "--path"]).arg(&second).assert().success();
turnout(dir.path())
.args(["group", "add", "contour", "--app", "web", "--app", "api"])
.assert()
.success();
turnout(dir.path())
.args(["app", "remove", "web", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("Removed 'web' from groups: contour"));
turnout(dir.path())
.args(["group", "show", "contour"])
.assert()
.success()
.stdout(predicate::str::contains("web").not());
}
#[test]
fn complete_lists_live_entity_names() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
turnout(dir.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
turnout(dir.path()).args(["group", "add", "contour", "--app", "web"]).assert().success();
turnout(dir.path())
.args(["credential", "add", "deployer", "--user", "deploy"])
.assert()
.success();
turnout(dir.path())
.args(["path", "add", "webroot", "--dir", "/var/www/site"])
.assert()
.success();
turnout(dir.path()).args(["complete", "apps"]).assert().success().stdout("web\n");
turnout(dir.path()).args(["complete", "servers"]).assert().success().stdout("staging\n");
turnout(dir.path())
.args(["complete", "bindable"])
.assert()
.success()
.stdout(predicate::str::contains("web").and(predicate::str::contains("contour")));
turnout(dir.path())
.args([
"target",
"add",
"web-staging",
"--app",
"web",
"--server",
"staging",
"--credential",
"deployer",
"--path",
"webroot",
])
.assert()
.success();
turnout(dir.path()).args(["complete", "targets"]).assert().success().stdout("web-staging\n");
turnout(dir.path()).args(["complete", "credentials"]).assert().success().stdout("deployer\n");
turnout(dir.path()).args(["complete", "paths"]).assert().success().stdout("webroot\n");
}
#[test]
fn bash_completions_carry_the_dynamic_wrapper() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path()).args(["completions", "bash"]).assert().success().stdout(
predicate::str::contains("_turnout_dynamic").and(predicate::str::contains("-F _turnout_dynamic -o nosort -o bashdefault -o default turnout tn")),
);
turnout(dir.path())
.args(["completions", "zsh"])
.assert()
.success()
.stdout(predicate::str::contains("_turnout_dynamic").not());
}
#[test]
fn completions_cover_the_command_surface() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path())
.args(["completions", "powershell"])
.assert()
.success()
.stdout(predicate::str::contains("turnout").and(predicate::str::contains("deploy")));
}
#[test]
fn missing_names_still_fail_without_a_terminal() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
for args in [
vec!["app", "show"],
vec!["server", "show"],
vec!["use"],
vec!["use", "myapp"],
vec!["pass", "copy"],
] {
turnout(dir.path())
.args(&args)
.assert()
.failure()
.stderr(predicate::str::contains("no terminal to prompt on").or(predicate::str::contains("no credentials yet")));
}
}
#[test]
fn destructive_commands_explain_how_to_skip_the_prompt() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path()).args(["group", "add", "contour", "--app", "myapp"]).assert().success();
for args in [
vec!["app", "remove", "myapp"],
vec!["server", "remove", "staging"],
vec!["group", "remove", "contour"],
] {
turnout(dir.path())
.args(&args)
.assert()
.failure()
.stderr(predicate::str::contains("--yes").and(predicate::str::contains("no terminal to prompt on")));
}
turnout(dir.path())
.args(["app", "list"])
.assert()
.success()
.stdout(predicate::str::contains("myapp"));
turnout(dir.path())
.args(["server", "list"])
.assert()
.success()
.stdout(predicate::str::contains("staging"));
turnout(dir.path())
.args(["group", "list"])
.assert()
.success()
.stdout(predicate::str::contains("contour"));
}
#[test]
fn pass_set_says_when_no_secret_arrived_on_stdin() {
let (dir, _project) = workspace();
add_credential(dir.path());
turnout(dir.path())
.args(["pass", "set", "deploy"])
.write_stdin("")
.assert()
.failure()
.stderr(predicate::str::contains("no secret on stdin"));
}
#[test]
fn deploy_setup_refuses_to_run_unattended() {
let (dir, project) = workspace();
turnout(dir.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["deploy-setup", "web"])
.assert()
.failure()
.stderr(predicate::str::contains("is a wizard").and(predicate::str::contains("turnout server edit")));
}
#[test]
fn actions_are_journaled_without_secrets() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
turnout(dir.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
turnout(dir.path()).args(["use", "web", "staging", "--no-check"]).assert().success();
turnout(dir.path())
.args(["credential", "add", "staging-access", "--user", "deploy-account"])
.assert()
.success();
turnout(dir.path())
.args(["pass", "set", "staging-access"])
.env("TURNOUT_KEYRING", "insecure-file")
.write_stdin("hunter2-topsecret")
.assert()
.success();
turnout(dir.path()).args(["server", "edit", "staging", "--label", "Staging"]).assert().success();
turnout(dir.path()).args(["group", "add", "contour", "--app", "web"]).assert().success();
let journal = std::fs::read_to_string(dir.path().join("journal.jsonl")).unwrap();
assert!(journal.contains(r#""action":"use""#), "{journal}");
assert!(journal.contains(r#""action":"app.add""#), "{journal}");
assert!(journal.contains(r#""action":"server.edit""#), "{journal}");
assert!(journal.contains(r#""action":"group.add""#), "{journal}");
assert!(!journal.contains("hunter2"), "the secret leaked: {journal}");
assert!(!journal.contains("deploy-account"), "the login leaked: {journal}");
turnout(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("Recent\n").and(predicate::str::contains("web -> staging")));
}
#[test]
fn status_counts_catalogs() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains(" myapp ").and(predicate::str::contains("servers staging")));
}
fn with_update_check(data_dir: &std::path::Path) -> Command {
let mut cmd = turnout(data_dir);
cmd.env("TURNOUT_UPDATE_CHECK", "1").env_remove("CI");
cmd
}
#[test]
fn a_failed_update_lookup_is_recorded_without_a_version() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path()).args(["setup", "--yes"]).assert().success();
with_update_check(dir.path())
.env("TURNOUT_UPDATE_URL", "http://update-check.invalid/releases/latest")
.arg("check-update")
.assert()
.success();
let cache = std::fs::read_to_string(dir.path().join("update-check.json")).unwrap();
assert!(cache.contains("checked_at"), "{cache}");
assert!(!cache.contains("latest"), "an unreachable host must not yield a version: {cache}");
}
#[test]
fn the_update_notice_stays_out_of_piped_output() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path()).args(["setup", "--yes"]).assert().success();
let cache = format!(r#"{{"checked_at":{},"latest":"99.0.0"}}"#, u64::MAX / 2);
std::fs::write(dir.path().join("update-check.json"), cache).unwrap();
with_update_check(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("99.0.0").not())
.stderr(predicate::str::contains("99.0.0").not());
}
#[test]
fn the_update_check_can_be_switched_off() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path()).args(["setup", "--yes"]).assert().success();
turnout(dir.path())
.env("TURNOUT_UPDATE_CHECK", "off")
.env_remove("CI")
.arg("status")
.assert()
.success();
std::thread::sleep(std::time::Duration::from_millis(500));
assert!(
!dir.path().join("update-check.json").exists(),
"a disabled check must not reach the network or write a cache"
);
}
#[test]
fn a_broken_update_check_never_fails_the_command() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path()).args(["setup", "--yes"]).assert().success();
with_update_check(dir.path())
.env("TURNOUT_UPDATE_URL", "http://update-check.invalid/releases/latest")
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("Gateway not running"));
}
#[test]
fn a_leftover_binary_from_self_update_is_swept() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join(if cfg!(windows) { "turnout.exe" } else { "turnout" });
std::fs::copy(assert_cmd::cargo::cargo_bin("turnout"), &exe).unwrap();
let mut leftover = exe.clone().into_os_string();
leftover.push(".old");
let leftover = std::path::PathBuf::from(leftover);
std::fs::write(&leftover, b"the previous binary").unwrap();
let output = {
let mut tries = 0;
loop {
let attempt = std::process::Command::new(&exe)
.env("TURNOUT_DATA_DIR", dir.path())
.env("TURNOUT_UPDATE_CHECK", "0")
.arg("status")
.output();
match attempt {
Ok(output) => break output,
Err(err) if err.raw_os_error() == Some(26) && tries < 50 => {
tries += 1;
std::thread::sleep(std::time::Duration::from_millis(20));
}
Err(err) => panic!("cannot run the copied binary: {err}"),
}
}
};
assert!(output.status.success(), "status must succeed: {}", String::from_utf8_lossy(&output.stderr));
assert!(!leftover.exists(), "the leftover binary should have been removed");
assert!(exe.exists(), "the running binary must survive the sweep");
}
#[test]
fn a_setup_survives_export_and_import() {
let (source, project) = workspace();
turnout(source.path())
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
turnout(source.path())
.args(["app", "add", "web", "--path"])
.arg(&project)
.args(["--port", "7100"])
.assert()
.success();
let file = source.path().join("export.json");
turnout(source.path()).args(["export", "--output"]).arg(&file).assert().success();
let target = tempfile::tempdir().unwrap();
turnout(target.path()).args(["setup", "--yes"]).assert().success();
turnout(target.path()).arg("import").arg(&file).assert().success();
turnout(target.path())
.args(["app", "show", "web"])
.assert()
.success()
.stdout(predicate::str::contains("localhost:7100"));
turnout(target.path())
.args(["server", "list"])
.assert()
.success()
.stdout(predicate::str::contains("staging"));
}
#[test]
fn a_plain_export_carries_no_secret_values() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
turnout(dir.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
add_credential(dir.path());
turnout(dir.path())
.env("TURNOUT_KEYRING", "insecure-file")
.args(["pass", "set", "deploy"])
.write_stdin("hunter2")
.assert()
.success();
let file = dir.path().join("export.json");
turnout(dir.path()).args(["export", "--output"]).arg(&file).assert().success();
let text = std::fs::read_to_string(&file).unwrap();
assert!(!text.contains("hunter2"), "the secret leaked into a plain export: {text}");
assert!(text.contains("\"user\": \"deploy\""), "the credential itself should still travel: {text}");
}
#[test]
fn secrets_travel_encrypted_and_arrive_usable() {
let (source, project) = workspace();
turnout(source.path())
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
turnout(source.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
add_credential(source.path());
turnout(source.path())
.env("TURNOUT_KEYRING", "insecure-file")
.args(["pass", "set", "deploy"])
.write_stdin("hunter2")
.assert()
.success();
let file = source.path().join("export.json");
turnout(source.path())
.env("TURNOUT_KEYRING", "insecure-file")
.args(["export", "--with-secrets", "--output"])
.arg(&file)
.write_stdin("lab passphrase")
.assert()
.success();
let text = std::fs::read_to_string(&file).unwrap();
assert!(!text.contains("hunter2"), "the secret must be encrypted: {text}");
let target = tempfile::tempdir().unwrap();
turnout(target.path()).args(["setup", "--yes"]).assert().success();
turnout(target.path())
.env("TURNOUT_KEYRING", "insecure-file")
.arg("import")
.arg(&file)
.write_stdin("lab passphrase")
.assert()
.success();
turnout(target.path())
.env("TURNOUT_KEYRING", "insecure-file")
.args(["pass", "copy", "deploy", "--show"])
.assert()
.success()
.stdout(predicate::str::contains("hunter2"));
}
#[test]
fn a_wrong_passphrase_imports_nothing_at_all() {
let (source, project) = workspace();
turnout(source.path())
.args(["server", "add", "staging", "--url", "https://staging.example.com"])
.assert()
.success();
turnout(source.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
add_credential(source.path());
turnout(source.path())
.env("TURNOUT_KEYRING", "insecure-file")
.args(["pass", "set", "deploy"])
.write_stdin("hunter2")
.assert()
.success();
let file = source.path().join("export.json");
turnout(source.path())
.env("TURNOUT_KEYRING", "insecure-file")
.args(["export", "--with-secrets", "--output"])
.arg(&file)
.write_stdin("right passphrase")
.assert()
.success();
let target = tempfile::tempdir().unwrap();
turnout(target.path()).args(["setup", "--yes"]).assert().success();
turnout(target.path())
.env("TURNOUT_KEYRING", "insecure-file")
.arg("import")
.arg(&file)
.write_stdin("wrong passphrase")
.assert()
.failure()
.stderr(predicate::str::contains("wrong passphrase"));
turnout(target.path())
.args(["app", "list"])
.assert()
.success()
.stdout(predicate::str::contains("No apps yet"));
}
#[test]
fn import_keeps_local_entries_unless_forced() {
let (source, project) = workspace();
turnout(source.path())
.args(["app", "add", "web", "--path"])
.arg(&project)
.args(["--port", "7100"])
.assert()
.success();
let file = source.path().join("export.json");
turnout(source.path()).args(["export", "--output"]).arg(&file).assert().success();
let (target, other) = workspace();
turnout(target.path())
.args(["app", "add", "web", "--path"])
.arg(&other)
.args(["--port", "9999"])
.assert()
.success();
turnout(target.path())
.arg("import")
.arg(&file)
.assert()
.success()
.stdout(predicate::str::contains("Skipped").and(predicate::str::contains("--force")));
turnout(target.path())
.args(["app", "show", "web"])
.assert()
.success()
.stdout(predicate::str::contains("localhost:9999"));
turnout(target.path()).arg("import").arg(&file).arg("--force").assert().success();
turnout(target.path())
.args(["app", "show", "web"])
.assert()
.success()
.stdout(predicate::str::contains("localhost:7100"));
}
#[test]
fn a_newer_data_directory_is_refused_with_a_way_out() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path()).args(["setup", "--yes"]).assert().success();
std::fs::write(dir.path().join("meta.json"), r#"{"schema_version": 99}"#).unwrap();
turnout(dir.path())
.args(["app", "list"])
.assert()
.failure()
.stderr(predicate::str::contains("newer turnout").and(predicate::str::contains("self-update")));
let meta = std::fs::read_to_string(dir.path().join("meta.json")).unwrap();
assert!(meta.contains("99"), "the marker must be left alone: {meta}");
}
#[test]
fn a_current_data_directory_migrates_nothing() {
let (dir, project) = workspace();
turnout(dir.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["app", "list"])
.assert()
.success()
.stderr(predicate::str::contains("Migrating").not());
let backups: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("settings-backup"))
.collect();
assert!(backups.is_empty(), "no backup folder should appear for a no-op");
}
#[test]
fn key_setup_writes_no_key_when_it_cannot_sign_in() {
let (dir, _project) = workspace();
add_staging(dir.path());
turnout(dir.path())
.args(["credential", "add", "keyless", "--user", "deploy", "--auth", "password"])
.assert()
.success();
let home = directories::UserDirs::new().expect("a home directory");
let would_write = home.home_dir().join(".ssh").join("id_ed25519_keyless");
assert!(!would_write.exists(), "the test's own key path must start clean: {}", would_write.display());
turnout_secrets(dir.path())
.args(["key", "setup", "staging", "--credential", "keyless"])
.assert()
.failure()
.stderr(predicate::str::contains("no password stored"))
.stderr(predicate::str::contains("turnout pass set keyless"));
assert!(
!would_write.exists(),
"a key was written before the sign-in was known to be possible: {}",
would_write.display()
);
assert!(
!would_write.with_extension("pub").exists(),
"the public half was left behind: {}",
would_write.display()
);
}
fn pause_for(seconds: u32) -> String {
if cfg!(windows) {
format!("ping -n {} 127.0.0.1 >nul", seconds + 1)
} else {
format!("sleep {seconds}")
}
}
fn eventually(seconds: u64, what: &str, mut check: impl FnMut() -> Result<(), String>) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds);
loop {
match check() {
Ok(()) => return,
Err(seen) if std::time::Instant::now() > deadline => panic!("{what} did not happen within {seconds}s; last seen:\n{seen}"),
Err(_) => std::thread::sleep(std::time::Duration::from_millis(100)),
}
}
}
fn stdout_of(dir: &std::path::Path, args: &[&str]) -> String {
let output = turnout(dir).args(args).output().unwrap();
String::from_utf8_lossy(&output.stdout).into_owned()
}
#[test]
fn a_detached_job_outlives_turnout_and_stops_with_its_tree() {
let (dir, project) = workspace();
let serve = format!("echo started-line && {} && echo late> marker.txt", pause_for(4));
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", &format!("serve={serve}")])
.assert()
.success();
turnout(dir.path())
.args(["run", "serve", "myapp", "--detach"])
.assert()
.success()
.stdout(predicate::str::contains("myapp serve runs in the background").and(predicate::str::contains("turnout stop myapp serve")));
let ps = stdout_of(dir.path(), &["ps"]);
assert!(predicate::str::is_match(r"myapp serve\s+running \(bg\)").unwrap().eval(&ps), "{ps}");
eventually(10, "the job's first line in its log", || {
let out = stdout_of(dir.path(), &["logs", "myapp", "serve"]);
if out.contains("started-line") { Ok(()) } else { Err(out) }
});
turnout(dir.path())
.args(["run", "serve", "myapp", "-d"])
.assert()
.failure()
.stderr(predicate::str::contains("myapp serve is already running"));
turnout(dir.path())
.args(["run", "serve", "myapp"])
.assert()
.failure()
.stderr(predicate::str::contains("myapp serve is already running"));
turnout(dir.path())
.args(["stop", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("Stopped myapp serve"));
assert!(!stdout_of(dir.path(), &["ps"]).contains("myapp serve"));
turnout(dir.path())
.args(["stop", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("Nothing of myapp is running"));
std::thread::sleep(std::time::Duration::from_secs(6));
assert!(!project.join("marker.txt").exists(), "a child of the stopped job lived on");
}
#[test]
fn a_detached_job_that_fails_at_once_says_so() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", "boom=echo about to fail && exit 3", "--command", "quick=echo quick-out"])
.assert()
.success();
turnout(dir.path())
.args(["run", "boom", "myapp", "--detach"])
.assert()
.code(3)
.stderr(predicate::str::contains("about to fail").and(predicate::str::contains("myapp boom failed at once (exit 3)")));
turnout(dir.path())
.args(["run", "quick", "myapp", "--detach"])
.assert()
.success()
.stdout(predicate::str::contains("myapp quick finished at once"));
let ps = stdout_of(dir.path(), &["ps"]);
assert!(predicate::str::is_match(r"myapp boom\s+failed \(exit 3\)").unwrap().eval(&ps), "{ps}");
assert!(predicate::str::is_match(r"myapp quick\s+done").unwrap().eval(&ps), "{ps}");
turnout(dir.path())
.args(["logs", "myapp", "quick"])
.assert()
.success()
.stdout(predicate::str::contains("quick-out"));
}
#[test]
fn following_a_log_ends_with_the_job() {
let (dir, project) = workspace();
let tick = format!("echo first-tick && {} && echo last-tick", pause_for(2));
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", &format!("tick={tick}")])
.assert()
.success();
turnout(dir.path()).args(["run", "tick", "myapp", "-d"]).assert().success();
turnout(dir.path())
.args(["logs", "myapp", "tick", "--follow"])
.timeout(std::time::Duration::from_secs(30))
.assert()
.success()
.stdout(predicate::str::contains("first-tick").and(predicate::str::contains("last-tick")));
let last = stdout_of(dir.path(), &["logs", "myapp", "tick", "-n", "1"]);
assert!(last.contains("last-tick") && !last.contains("first-tick"), "{last}");
}
#[test]
fn a_foreground_job_is_listed_and_can_be_stopped_from_elsewhere() {
let (dir, project) = workspace();
let long = format!("echo fg-start && {} && echo never> marker.txt", pause_for(10));
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", &format!("long={long}")])
.assert()
.success();
let mut foreground = std::process::Command::new(assert_cmd::cargo::cargo_bin("turnout"));
foreground
.env("TURNOUT_DATA_DIR", dir.path())
.env("TURNOUT_UPDATE_CHECK", "0")
.args(["run", "long", "myapp"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(&mut foreground, 0);
let mut child = foreground.spawn().unwrap();
eventually(10, "the foreground job in ps", || {
let ps = stdout_of(dir.path(), &["ps"]);
if predicate::str::is_match(r"myapp long\s+running\s+\d").unwrap().eval(&ps) {
Ok(())
} else {
Err(ps)
}
});
turnout(dir.path())
.args(["stop", "myapp", "long"])
.assert()
.success()
.stdout(predicate::str::contains("Stopped myapp long"));
let status = child.wait().unwrap();
assert!(!status.success(), "the stopped turnout exited cleanly: {status}");
std::thread::sleep(std::time::Duration::from_secs(1));
assert!(!stdout_of(dir.path(), &["ps"]).contains("myapp long"));
}
#[test]
fn logs_go_where_the_log_directory_says() {
let (dir, project) = workspace();
let elsewhere = dir.path().join("elsewhere");
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", "hello=echo moved-out"])
.assert()
.success();
turnout(dir.path())
.env("TURNOUT_LOG_DIR", &elsewhere)
.args(["run", "hello", "myapp"])
.assert()
.success();
assert!(elsewhere.join("myapp.hello.log").is_file());
assert!(!dir.path().join("logs").join("myapp.hello.log").exists());
turnout(dir.path())
.args(["logs", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("moved-out"));
}
fn toasts_eventually(dir: &std::path::Path, count: usize) -> Vec<serde_json::Value> {
eventually(15, &format!("{count} notification(s)"), || {
let sent = toasts(dir);
if sent.len() >= count { Ok(()) } else { Err(format!("{sent:#?}")) }
});
toasts(dir)
}
#[test]
fn a_background_failure_is_one_notification_and_its_log_is_kept() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args([
"--command",
"boom=echo compiling && echo error: expected a semicolon && echo npm error code 3 && exit 3",
])
.assert()
.success();
turnout(dir.path()).args(["run", "boom", "myapp", "--detach"]).assert().code(3);
let sent = toasts_eventually(dir.path(), 1);
assert_eq!(sent.len(), 1, "{sent:#?}");
let toast = &sent[0];
assert_eq!(toast["title"], "myapp boom failed");
assert_eq!(toast["body"], "error: expected a semicolon");
let note = toast["note"].as_str().unwrap();
assert!(note.starts_with("exit 3 after") && note.ends_with("turnout logs myapp boom --failed"), "{note}");
assert_eq!(toast["failure"], true);
let kept = dir.path().join("logs").join("failed").join("myapp.boom.log");
let open = toast["open"].as_str().unwrap();
assert!(open.starts_with("file://") && open.ends_with("/logs/failed/myapp.boom.log"), "{open}");
assert!(std::fs::read_to_string(&kept).unwrap().contains("compiling"));
turnout(dir.path())
.args(["app", "edit", "myapp", "--command", "boom=echo fixed-now"])
.assert()
.success();
turnout(dir.path()).args(["run", "boom", "myapp", "-d"]).assert().success();
let sent = toasts_eventually(dir.path(), 2);
assert_eq!(sent[1]["title"], "myapp boom finished", "{sent:#?}");
assert_eq!(sent[1]["failure"], false);
let latest = stdout_of(dir.path(), &["logs", "myapp", "boom"]);
assert!(latest.contains("fixed-now") && !latest.contains("compiling"), "{latest}");
let failure = stdout_of(dir.path(), &["logs", "myapp", "boom", "--failed"]);
assert!(failure.contains("error: expected a semicolon"), "{failure}");
}
#[test]
fn a_background_server_says_where_it_answers_and_stop_is_silent() {
let (dir, project) = workspace();
let serve = format!("echo Server listening on http://localhost:5555 && {}", pause_for(20));
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", &format!("serve={serve}")])
.assert()
.success();
turnout(dir.path()).args(["run", "serve", "myapp", "--detach"]).assert().success();
let sent = toasts_eventually(dir.path(), 1);
assert_eq!(sent[0]["title"], "myapp serve is ready", "{sent:#?}");
assert_eq!(sent[0]["open"], "http://localhost:5555");
assert_eq!(sent[0]["buttons"][0]["label"], "Open in browser");
turnout(dir.path()).args(["stop", "myapp", "serve"]).assert().success();
std::thread::sleep(std::time::Duration::from_secs(2));
assert_eq!(toasts(dir.path()).len(), 1, "a stopped job sent more: {:#?}", toasts(dir.path()));
}
#[test]
fn a_foreground_job_sends_no_notification() {
let (dir, project) = workspace();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", "boom=echo in-the-terminal && exit 4", "--command", "fine=echo ok"])
.assert()
.success();
turnout(dir.path()).args(["run", "boom", "myapp"]).assert().code(4);
turnout(dir.path()).args(["run", "fine", "myapp"]).assert().success();
assert!(toasts(dir.path()).is_empty(), "{:#?}", toasts(dir.path()));
turnout(dir.path())
.args(["logs", "myapp", "boom", "--failed"])
.assert()
.success()
.stdout(predicate::str::contains("in-the-terminal"));
turnout(dir.path())
.args(["logs", "myapp", "fine", "--failed"])
.assert()
.failure()
.stderr(predicate::str::contains("myapp fine has no failure on record"));
}
#[test]
fn a_job_that_cannot_start_leaves_the_reason_in_its_log() {
let (dir, project) = workspace();
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
let gone = project.join("no-such-dir");
turnout(dir.path())
.args(["job-run", "--app", "myapp", "--command", "ghost", "--label", "Running ghost", "--dir"])
.arg(&gone)
.args(["--", "echo", "never"])
.assert()
.code(1);
let log = std::fs::read_to_string(dir.path().join("logs").join("myapp.ghost.log")).unwrap();
assert!(log.contains("turnout: cannot run 'echo never'"), "{log}");
let kept = std::fs::read_to_string(dir.path().join("logs").join("failed").join("myapp.ghost.log")).unwrap();
assert!(kept.contains("cannot run"), "{kept}");
let sent = toasts(dir.path());
assert_eq!(sent.len(), 1, "{sent:#?}");
assert_eq!(sent[0]["title"], "myapp ghost failed");
assert!(sent[0]["body"].as_str().unwrap().contains("cannot run 'echo never'"), "{sent:#?}");
assert!(sent[0]["note"].as_str().unwrap().starts_with("did not start"), "{sent:#?}");
}
#[test]
fn the_background_preference_covers_what_it_names_and_yields_to_the_flags() {
let (dir, project) = workspace();
let slow = format!("echo slow-start && {}", pause_for(2));
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--command", &format!("slow={slow}"), "--command", "quick=echo quick-here"])
.assert()
.success();
let preferring = |value: &str| {
let mut cmd = turnout(dir.path());
cmd.env("TURNOUT_DETACH", value).env("TURNOUT_CONSOLE", "quiet");
cmd
};
preferring("slow")
.args(["run", "slow", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("myapp slow runs in the background").and(predicate::str::contains("TURNOUT_DETACH covers 'slow'")));
turnout(dir.path())
.args(["logs", "myapp", "slow", "--follow"])
.timeout(std::time::Duration::from_secs(30))
.assert()
.success();
preferring("slow")
.args(["run", "quick", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("runs in the background").not());
for keep in [&["--foreground"][..], &["-v"][..]] {
preferring("all").args(["run", "slow", "myapp"]).args(keep).assert().success().stdout(
predicate::str::contains("in the background")
.not()
.and(predicate::str::contains("at once").not()),
);
}
turnout(dir.path())
.env("TURNOUT_DETACH", "all")
.args(["run", "quick", "myapp"])
.assert()
.success()
.stdout(predicate::str::contains("quick-here"));
preferring("slwo")
.args(["run", "quick", "myapp"])
.assert()
.success()
.stderr(predicate::str::contains("TURNOUT_DETACH names 'slwo'"));
turnout(dir.path())
.args(["run", "quick", "myapp", "--detach", "--foreground"])
.assert()
.failure()
.stderr(predicate::str::contains("cannot be used with"));
}
#[test]
fn watching_needs_a_terminal() {
let (dir, _project) = workspace();
turnout(dir.path())
.args(["ps", "--watch"])
.assert()
.failure()
.stderr(predicate::str::contains("--watch redraws a table on a terminal"));
turnout(dir.path()).arg("ps").assert().success().stdout(predicate::str::contains("No jobs"));
}
#[test]
fn the_migration_carries_a_live_gateway_into_the_registry() {
let (dir, _project) = workspace();
let wind_back = |state: serde_json::Value| {
std::fs::write(dir.path().join("meta.json"), r#"{"schema_version":4}"#).unwrap();
std::fs::write(dir.path().join("state.json"), state.to_string()).unwrap();
};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let pid = std::process::id();
wind_back(serde_json::json!({"bindings": {}, "gateway": {"pid": pid, "ports": {port.to_string(): "myapp"}}}));
turnout(dir.path()).arg("status").assert().success();
let record = dir.path().join("jobs").join("gateway.json");
let entry: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&record).unwrap()).unwrap();
assert_eq!(entry["pid"], serde_json::json!(pid));
assert_eq!(entry["work"]["gateway"]["ports"][port.to_string()], serde_json::json!("myapp"));
let state = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
assert!(!state.contains("gateway"), "the old record stayed behind: {state}");
std::fs::remove_file(&record).unwrap();
drop(listener);
wind_back(serde_json::json!({"bindings": {}, "gateway": {"pid": pid, "ports": {port.to_string(): "myapp"}}}));
turnout(dir.path())
.arg("status")
.assert()
.success()
.stderr(predicate::str::contains("no longer running"))
.stdout(predicate::str::contains("Gateway not running"));
assert!(!record.exists(), "a dead gateway was carried over");
}
#[test]
fn reading_commands_print_json() {
let (dir, project) = workspace();
for command in [
&["app", "list"][..],
&["server", "list"],
&["credential", "list"],
&["path", "list"],
&["target", "list"],
&["group", "list"],
&["pass", "list"],
&["ps"],
&["cookie", "list"],
] {
let output = turnout(dir.path()).args(command).arg("--json").output().unwrap();
assert!(output.status.success(), "{command:?}: {}", String::from_utf8_lossy(&output.stderr));
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|err| panic!("{command:?}: {err}"));
assert_eq!(value, serde_json::json!([]), "{command:?}");
}
add_staging(dir.path());
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path()).args(["use", "myapp", "staging", "--no-check"]).assert().success();
turnout(dir.path()).args(["group", "add", "contour", "--app", "myapp"]).assert().success();
let output = turnout(dir.path()).args(["app", "show", "myapp", "--json"]).output().unwrap();
let app: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(app["name"], "myapp");
assert_eq!(app["server"], "staging");
assert_eq!(app["address"], "http://myapp.localhost");
assert_eq!(app["dist"], serde_json::Value::Null, "an absent value is null, never a missing key");
assert!(app["gateway_port"].is_u64(), "{app}");
let output = turnout(dir.path()).args(["server", "show", "staging", "--json"]).output().unwrap();
let server: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(server["url"], "https://staging.example.com");
assert_eq!(server["ssh_host"], "staging.example.com");
assert_eq!(server["ssh_host_from_url"], true);
assert_eq!(server["bound"], serde_json::json!(["myapp"]));
let output = turnout(dir.path()).args(["group", "show", "contour", "--json"]).output().unwrap();
let group: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(group["apps"], serde_json::json!([{"app": "myapp", "server": "staging"}]));
let status = status_json(dir.path());
assert_eq!(status["set_up"], true);
assert_eq!(status["apps"][0]["server"], "staging");
assert_eq!(status["groups"][0]["name"], "contour");
assert_eq!(status["gateway"]["state"], "stopped");
assert_eq!(status["catalog"]["servers"], serde_json::json!(["staging"]));
let recent = status["recent"].as_array().unwrap();
assert!(!recent.is_empty());
for key in ["at", "action", "app", "server", "detail"] {
assert!(recent[0].get(key).is_some(), "'{key}' missing from {}", recent[0]);
}
turnout(dir.path()).args(["deploy", "--json"]).assert().failure();
turnout(dir.path()).args(["ps", "--watch", "--json"]).assert().failure();
}
#[test]
fn credential_json_never_carries_the_secret() {
let dir = tempfile::tempdir().unwrap();
turnout_secrets(dir.path()).args(["setup", "--yes"]).assert().success();
add_credential(dir.path());
turnout_secrets(dir.path())
.args(["pass", "set", "deploy"])
.write_stdin("s3cret-value\n")
.assert()
.success();
for command in [&["credential", "show", "deploy", "--json"][..], &["pass", "list", "--json"]] {
let output = turnout_secrets(dir.path()).args(command).output().unwrap();
let text = String::from_utf8_lossy(&output.stdout);
assert!(text.contains(r#""secret_stored": true"#), "{command:?}: {text}");
assert!(!text.contains("s3cret-value"), "{command:?} leaked the secret: {text}");
}
}
#[test]
fn status_short_is_a_prompt_line() {
let dir = tempfile::tempdir().unwrap();
turnout(dir.path()).args(["status", "--short"]).assert().success().stdout("");
turnout(dir.path()).args(["setup", "--yes"]).assert().success();
turnout(dir.path()).args(["status", "--short"]).assert().success().stdout("");
let project = dir.path().join("project");
std::fs::create_dir(&project).unwrap();
add_staging(dir.path());
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["status", "--short"])
.current_dir(&project)
.assert()
.success()
.stdout("myapp unbound\n");
turnout(dir.path()).args(["use", "myapp", "staging", "--no-check"]).assert().success();
turnout(dir.path()).args(["status", "--short"]).assert().success().stdout("staging · gw off\n");
turnout(dir.path()).args(["status", "--short", "--json"]).assert().failure();
}
#[test]
fn use_dash_goes_back_to_the_server_before() {
let (dir, project) = workspace();
for name in ["dev", "prod"] {
turnout(dir.path())
.args(["server", "add", name, "--url", &format!("https://{name}.example.com")])
.assert()
.success();
}
let other = dir.path().join("other");
std::fs::create_dir(&other).unwrap();
turnout(dir.path()).args(["app", "add", "web", "--path"]).arg(&project).assert().success();
turnout(dir.path()).args(["app", "add", "api", "--path"]).arg(&other).assert().success();
turnout(dir.path())
.args(["use", "-", "-n"])
.assert()
.failure()
.stderr(predicate::str::contains("nothing to go back to"));
turnout(dir.path()).args(["use", "web", "dev", "-n"]).assert().success();
turnout(dir.path())
.args(["use", "web", "-", "-n"])
.assert()
.failure()
.stderr(predicate::str::contains("has not used another server yet"));
turnout(dir.path()).args(["use", "web", "prod", "-n"]).assert().success();
turnout(dir.path())
.args(["use", "-", "-n"])
.assert()
.success()
.stdout(predicate::str::contains("'web' is back on 'dev' (was 'prod')."));
turnout(dir.path())
.args(["use", "-", "-n"])
.assert()
.success()
.stdout(predicate::str::contains("'web' is back on 'prod' (was 'dev')."));
turnout(dir.path())
.args(["use", "-", "prod"])
.assert()
.failure()
.stderr(predicate::str::contains("takes no server"));
turnout(dir.path())
.args(["group", "add", "contour", "--app", "web", "--app", "api"])
.assert()
.success();
turnout(dir.path()).args(["use", "contour", "dev", "-n"]).assert().success();
turnout(dir.path()).args(["use", "contour", "-", "-n"]).assert().success().stdout(
predicate::str::contains("'web' is back on 'prod' (was 'dev').").and(predicate::str::contains("Staying where they are, with no server before: api.")),
);
turnout(dir.path())
.args(["server", "remove", "prod", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("Unbound: web"));
let state = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
assert!(!state.contains("prod"), "{state}");
}
#[test]
fn use_reports_the_health_of_the_stand() {
let (dir, project) = workspace();
let (stand_port, stand_listener) = reserved_port();
spawn_stand(stand_listener);
let (dead_port, dead) = reserved_port();
drop(dead);
turnout(dir.path())
.args(["server", "add", "stand", "--url", &format!("http://127.0.0.1:{stand_port}")])
.assert()
.success();
turnout(dir.path())
.args(["server", "add", "sick", "--url", &format!("http://127.0.0.1:{stand_port}/sick")])
.assert()
.success();
turnout(dir.path())
.args(["server", "add", "gone", "--url", &format!("http://127.0.0.1:{dead_port}")])
.assert()
.success();
turnout(dir.path()).args(["app", "add", "myapp", "--path"]).arg(&project).assert().success();
turnout(dir.path())
.args(["use", "myapp", "stand"])
.assert()
.success()
.stdout(predicate::str::contains("'stand' is up").and(predicate::str::contains("/health answered 200")));
turnout(dir.path()).args(["use", "myapp", "sick"]).assert().success().stdout(
predicate::str::contains("'myapp' now uses 'sick'").and(predicate::str::contains("'sick' is down - ").and(predicate::str::contains("answered 503"))),
);
turnout(dir.path())
.args(["use", "myapp", "gone"])
.assert()
.success()
.stdout(predicate::str::contains("'myapp' now uses 'gone'").and(predicate::str::contains("'gone' did not answer")));
turnout(dir.path())
.args(["use", "myapp", "stand", "--no-check"])
.assert()
.success()
.stdout(predicate::str::contains("is up").not());
}
fn run_gateway(dir: &std::path::Path, gateway_reservation: std::net::TcpListener) -> ChildGuard {
let gateway_port = gateway_reservation.local_addr().unwrap().port();
drop(gateway_reservation);
let child = std::process::Command::new(assert_cmd::cargo::cargo_bin("turnout"))
.env("TURNOUT_DATA_DIR", dir)
.env("TURNOUT_FRONT_PORT", "0")
.env("TURNOUT_TOASTS", toast_file(dir))
.args(["gateway", "run"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let guard = ChildGuard(child);
wait_for_port(gateway_port);
wait_until("the gateway's record", || status_json(dir)["gateway"]["state"] == "running");
guard
}
fn wait_until(what: &str, mut done: impl FnMut() -> bool) {
for _ in 0..100 {
if done() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!("{what} never happened");
}
fn signals(dir: &std::path::Path) -> Vec<serde_json::Value> {
status_json(dir)["gateway"]["signals"].as_array().cloned().unwrap_or_default()
}
#[test]
fn the_gateway_says_when_a_stand_is_down_or_a_session_expired() {
let (dir, project) = workspace();
let (stand_port, stand_listener) = reserved_port();
let (gateway_port, gateway_reservation) = reserved_port();
spawn_stand(stand_listener);
turnout(dir.path())
.args(["server", "add", "stand", "--url", &format!("http://127.0.0.1:{stand_port}")])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", &gateway_port.to_string()])
.assert()
.success();
turnout(dir.path()).args(["use", "myapp", "stand", "--no-check"]).assert().success();
let _gateway = run_gateway(dir.path(), gateway_reservation);
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
let get = |path: &str| {
let url = format!("http://127.0.0.1:{gateway_port}{path}");
runtime.block_on(async move { reqwest::get(url).await.unwrap().status().as_u16() })
};
assert_eq!(get("/expire"), 401);
assert_eq!(get("/hello"), 200, "logs in: the stand sets a cookie");
for _ in 0..3 {
assert_eq!(get("/expire"), 401);
}
wait_until("the session signal", || signals(dir.path()).len() == 1);
let lapsed = &signals(dir.path())[0];
assert_eq!(lapsed["kind"], "session_expired");
assert_eq!(lapsed["app"], "myapp");
assert_eq!(lapsed["status"], 401);
turnout(dir.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("the 'stand' session of 'myapp' expired"));
turnout(dir.path())
.args(["status", "--short"])
.assert()
.success()
.stdout("stand login · gw ok\n");
for _ in 0..3 {
assert_eq!(get("/down"), 502);
}
wait_until("the stand signal", || signals(dir.path()).len() == 2);
turnout(dir.path()).args(["status", "--short"]).assert().success().stdout("stand 502 · gw ok\n");
assert_eq!(get("/echo-cookie"), 200);
wait_until("the stand to be back", || signals(dir.path()).len() == 1);
wait_until("two toasts", || toasts(dir.path()).len() >= 2);
std::thread::sleep(std::time::Duration::from_millis(300));
let toasts = toasts(dir.path());
assert_eq!(toasts.len(), 2, "one per episode, not one per request: {toasts:?}");
assert_eq!(toasts[0]["title"], "myapp: the stand session expired");
assert_eq!(toasts[0]["note"], "turnout cookie set myapp");
assert_eq!(toasts[1]["title"], "stand answers 502");
assert_eq!(toasts[1]["open"], format!("http://127.0.0.1:{stand_port}"));
}
#[test]
fn cookie_set_hands_a_browser_session_to_the_gateway() {
let (dir, project) = workspace();
let (stand_port, stand_listener) = reserved_port();
let (gateway_port, gateway_reservation) = reserved_port();
spawn_stand(stand_listener);
turnout(dir.path())
.args(["server", "add", "stand", "--url", &format!("http://127.0.0.1:{stand_port}")])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", &gateway_port.to_string()])
.assert()
.success();
turnout(dir.path()).args(["use", "myapp", "stand", "--no-check"]).assert().success();
turnout(dir.path())
.args(["cookie", "set", "myapp"])
.write_stdin("Cookie: sid=from-the-browser")
.assert()
.failure()
.stderr(predicate::str::contains("the gateway is not running"));
let _gateway = run_gateway(dir.path(), gateway_reservation);
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
let get = |path: &str| {
let url = format!("http://127.0.0.1:{gateway_port}{path}");
runtime.block_on(async move { reqwest::get(url).await.unwrap().text().await.unwrap() })
};
get("/hello");
get("/expire");
wait_until("the session signal", || signals(dir.path()).len() == 1);
let copied = "curl 'https://stand.example.com/api/me' -H 'accept: */*' -b 'sid=from-the-browser; lang=en'";
turnout(dir.path())
.args(["cookie", "set", "myapp"])
.write_stdin(copied)
.assert()
.success()
.stdout(predicate::str::contains("now sends 2 cookies to 'stand' for 'myapp': sid, lang").and(predicate::str::contains("from-the-browser").not()));
let mut sent: Vec<String> = get("/echo-cookie").split("; ").map(str::to_string).collect();
sent.sort();
assert_eq!(sent, ["lang=en", "sid=from-the-browser"]);
wait_until("the session signal to go", || signals(dir.path()).is_empty());
let output = turnout(dir.path()).args(["cookie", "list", "--json"]).output().unwrap();
let jars: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(jars.as_array().map(Vec::len), Some(1), "{jars}");
assert_eq!(jars[0]["app"], "myapp");
assert_eq!(jars[0]["server"], "stand");
assert_eq!(jars[0]["cookies"], serde_json::json!(["lang", "sid"]));
assert_eq!(jars[0]["expires"], serde_json::Value::Null, "cookies from a browser last for the session");
assert_eq!(jars[0]["last_answer"]["status"], 200, "{jars}");
assert!(jars[0]["proven"].is_string(), "the GET that carried them proved them: {jars}");
let journal = std::fs::read_to_string(dir.path().join("journal.jsonl")).unwrap();
assert!(journal.contains(r#""action":"cookie.set""#), "{journal}");
assert!(!journal.contains("from-the-browser"), "the session leaked into the journal: {journal}");
turnout(dir.path())
.args(["cookie", "set", "myapp"])
.write_stdin("sid=x; Path=/; HttpOnly")
.assert()
.failure()
.stderr(predicate::str::contains("Set-Cookie"));
turnout(dir.path()).args(["cookie", "clear", "myapp"]).assert().success();
assert_eq!(get("/echo-cookie"), "none");
turnout(dir.path())
.args(["cookie", "list"])
.assert()
.success()
.stdout(predicate::str::contains("holds no cookies"));
}
#[test]
fn the_control_channel_refuses_requests_without_the_token() {
let (dir, project) = workspace();
let (gateway_port, gateway_reservation) = reserved_port();
turnout(dir.path())
.args(["server", "add", "stand", "--url", "http://127.0.0.1:9"])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", &gateway_port.to_string()])
.assert()
.success();
let _gateway = run_gateway(dir.path(), gateway_reservation);
let record: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join("jobs").join("gateway.json")).unwrap()).unwrap();
let control = &record["work"]["gateway"]["control"];
let port = control["port"].as_u64().unwrap();
let token = control["token"].as_str().unwrap().to_string();
assert_eq!(token.len(), 64);
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
runtime.block_on(async move {
let client = reqwest::Client::new();
let url = format!("http://127.0.0.1:{port}/jars");
assert_eq!(client.get(&url).send().await.unwrap().status(), 403);
assert_eq!(client.get(&url).bearer_auth("0".repeat(64)).send().await.unwrap().status(), 403);
let jar = format!("http://127.0.0.1:{port}/jar/myapp/stand");
let put = client
.put(&jar)
.header("content-type", "application/json")
.body(r#"{"cookies":[["sid","planted"]]}"#);
assert_eq!(put.send().await.unwrap().status(), 403, "a page must not plant a session");
let check = format!("http://127.0.0.1:{port}/jar/myapp/stand/check");
assert_eq!(client.post(&check).send().await.unwrap().status(), 403, "nor make the gateway send requests");
assert_eq!(client.get(&url).bearer_auth(&token).send().await.unwrap().status(), 200);
});
}
fn doctor_json(data_dir: &std::path::Path, args: &[&str]) -> (serde_json::Value, Option<i32>) {
let output = turnout(data_dir)
.env("TURNOUT_KEYRING", "insecure-file")
.arg("doctor")
.args(args)
.arg("--json")
.output()
.unwrap();
let view = serde_json::from_slice(&output.stdout).unwrap_or_else(|err| {
panic!(
"{err}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
});
(view, output.status.code())
}
fn a_check<'a>(view: &'a serde_json::Value, check: &str, subject: Option<&str>) -> &'a serde_json::Value {
view["checks"]
.as_array()
.unwrap()
.iter()
.find(|c| c["check"] == check && subject.is_none_or(|subject| c["subject"] == subject))
.unwrap_or_else(|| panic!("no '{check}' check about {subject:?} in {view:#}"))
}
#[test]
fn the_doctor_before_setup_names_the_way_in() {
let dir = tempfile::tempdir().unwrap();
let (view, code) = doctor_json(dir.path(), &[]);
assert_eq!(code, Some(1), "a failed check is a failed exit");
let setup = a_check(&view, "setup", None);
assert_eq!(setup["state"], "fail");
assert_eq!(setup["fix"], "turnout setup");
assert_eq!(view["problems"], 1);
}
#[test]
fn the_doctor_finds_a_stray_on_the_dev_port_and_a_drifted_dotenv_file() {
let (dir, project) = workspace();
let (dev_port, _stray) = reserved_port();
let (gateway_port, gateway_reservation) = reserved_port();
drop(gateway_reservation);
let echo = "dev=echo up";
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", &gateway_port.to_string(), "--dev-port", &dev_port.to_string(), "--command", echo])
.assert()
.success();
let env_file = project.join(".env.development.local");
let written = std::fs::read_to_string(&env_file).unwrap();
std::fs::write(&env_file, written.replace(&gateway_port.to_string(), "7001")).unwrap();
let (view, code) = doctor_json(dir.path(), &[]);
assert_eq!(code, Some(1), "{view:#}");
let stray = a_check(&view, "dev_port", Some("myapp"));
assert_eq!(stray["state"], "warn", "{stray}");
assert_eq!(stray["fixable"], true);
let pid = std::process::id().to_string();
assert!(stray["message"].as_str().unwrap().contains(&format!("pid {pid}")), "{stray}");
let drift = a_check(&view, "env_file", Some("myapp"));
assert_eq!(drift["state"], "fail", "{drift}");
assert!(drift["message"].as_str().unwrap().contains("http://localhost:7001"), "{drift}");
assert_eq!(drift["fix"], "turnout doctor --fix");
turnout(dir.path())
.args(["dev", "myapp"])
.assert()
.failure()
.stderr(predicate::str::contains(format!("dev port {dev_port} of 'myapp' is held by")).and(predicate::str::contains(format!("pid {pid}"))));
let output = turnout(dir.path()).args(["doctor", "--fix", "--json"]).output().unwrap();
let fixed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("ending a process needs a yes"), "{stderr}");
assert_eq!(
fixed["fixed"],
serde_json::json!([format!(
"myapp: wrote TURNOUT_GATEWAY_URL=http://localhost:{gateway_port} to .env.development.local"
)])
);
assert_eq!(a_check(&fixed, "env_file", Some("myapp"))["state"], "ok", "{fixed:#}");
assert_eq!(a_check(&fixed, "dev_port", Some("myapp"))["state"], "warn", "the stray is still there");
assert!(std::fs::read_to_string(&env_file).unwrap().contains(&format!("localhost:{gateway_port}")));
}
#[test]
fn a_cookie_the_stand_deletes_leaves_the_jar() {
let (dir, project) = workspace();
let (stand_port, stand_listener) = reserved_port();
let (gateway_port, gateway_reservation) = reserved_port();
spawn_stand(stand_listener);
turnout(dir.path())
.args(["server", "add", "stand", "--url", &format!("http://127.0.0.1:{stand_port}")])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", &gateway_port.to_string()])
.assert()
.success();
turnout(dir.path()).args(["use", "myapp", "stand", "--no-check"]).assert().success();
let _gateway = run_gateway(dir.path(), gateway_reservation);
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
let get = |path: &str| {
let url = format!("http://127.0.0.1:{gateway_port}{path}");
runtime.block_on(async move { reqwest::get(url).await.unwrap().text().await.unwrap() })
};
get("/hello");
assert_eq!(get("/echo-cookie"), "sid=abc123");
get("/bye");
assert_eq!(get("/echo-cookie"), "none", "the deleted cookie went back to the stand");
}
#[test]
fn the_doctor_checks_a_session_by_asking_the_stand_again() {
let (dir, project) = workspace();
let (stand_port, stand_listener) = reserved_port();
let (gateway_port, gateway_reservation) = reserved_port();
spawn_stand(stand_listener);
turnout(dir.path())
.args(["server", "add", "stand", "--url", &format!("http://127.0.0.1:{stand_port}")])
.assert()
.success();
turnout(dir.path())
.args(["app", "add", "myapp", "--path"])
.arg(&project)
.args(["--port", &gateway_port.to_string()])
.assert()
.success();
turnout(dir.path()).args(["use", "myapp", "stand", "--no-check"]).assert().success();
let _gateway = run_gateway(dir.path(), gateway_reservation);
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
let get = |path: &str| {
let url = format!("http://127.0.0.1:{gateway_port}{path}");
runtime.block_on(async move { reqwest::get(url).await.unwrap().status().as_u16() })
};
let (view, _) = doctor_json(dir.path(), &[]);
let session = a_check(&view, "session", Some("myapp"));
assert_eq!(session["state"], "info", "nobody logged in yet: {session}");
assert!(session["message"].as_str().unwrap().contains("no session on stand"), "{session}");
assert_eq!(get("/hello"), 200, "logs in");
assert_eq!(get("/me"), 200, "a request the session proves");
let (view, _) = doctor_json(dir.path(), &[]);
let session = a_check(&view, "session", Some("myapp"));
assert_eq!(session["state"], "ok", "{session}");
assert!(session["message"].as_str().unwrap().contains("GET /me answers 200"), "{session}");
assert_eq!(a_check(&view, "stand", Some("stand"))["state"], "ok");
assert_eq!(a_check(&view, "gateway", None)["state"], "ok");
runtime.block_on(async { reqwest::get(format!("http://127.0.0.1:{stand_port}/revoke")).await.unwrap() });
let (view, code) = doctor_json(dir.path(), &[]);
assert_eq!(code, Some(1));
let session = a_check(&view, "session", Some("myapp"));
assert_eq!(session["state"], "fail", "{session}");
assert!(session["message"].as_str().unwrap().contains("GET /me now answers 401"), "{session}");
assert!(session["fix"].as_str().unwrap().contains("turnout cookie set myapp"), "{session}");
wait_until("the session signal", || signals(dir.path()).iter().any(|s| s["kind"] == "session_expired"));
}
#[test]
fn the_server_doctor_names_the_step_a_deploy_would_stop_at() {
let (dir, _project) = workspace();
let (ssh_port, ssh_reservation) = reserved_port();
drop(ssh_reservation);
turnout(dir.path())
.args(["credential", "add", "deploy", "--user", "deploy", "--auth", "agent"])
.assert()
.success();
turnout(dir.path())
.args([
"server",
"add",
"box",
"--url",
"http://127.0.0.1:9",
"--host",
&format!("127.0.0.1:{ssh_port}"),
"--credential",
"deploy",
])
.assert()
.success();
let (view, code) = doctor_json(dir.path(), &["--server", "box"]);
assert_eq!(code, Some(1));
assert_eq!(view["server"], "box");
assert_eq!(a_check(&view, "sshd", Some("box"))["state"], "fail", "{view:#}");
assert_eq!(a_check(&view, "web", Some("box"))["state"], "fail");
assert_eq!(view["deploy_stops_at"], "connect");
turnout(dir.path())
.args(["doctor", "--server", "box"])
.assert()
.code(1)
.stdout(predicate::str::contains("┌ Server box").and(predicate::str::contains("a deploy would stop at the connect step")));
turnout(dir.path())
.args(["doctor", "--server", "nope"])
.assert()
.failure()
.stderr(predicate::str::contains("no server named 'nope'"));
turnout(dir.path())
.args(["doctor", "--server", "box", "--fix"])
.assert()
.failure()
.stderr(predicate::str::contains("cannot be used with"));
}