fabro/lib/apps/fabro-cli/tests/it/cmd/server_start.rs
2026-07-23 17:59:34 -04:00

1298 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![expect(
clippy::disallowed_types,
reason = "integration test: occupies a fixed TCP port via sync std::net::TcpListener to \
verify the server-start fallback path when the default port is unavailable"
)]
#![expect(
clippy::disallowed_methods,
reason = "integration test stages server-start fixtures with sync std::fs::write"
)]
use std::process::Stdio;
use std::sync::{Arc, Barrier};
use std::time::{Duration, Instant};
use fabro_config::{Storage, envfile};
use fabro_static::EnvVars;
use fabro_test::{
TestContext, apply_test_isolation, fabro_snapshot, isolated_storage_dir, server_log_files,
test_context, wait_for_log_line,
};
use fabro_util::dev_token;
use crate::support::fatal_error_line;
const TEST_DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
const TEST_SESSION_SECRET: &str =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn write_dev_token_server_settings(config_path: &std::path::Path, rest: &str) {
std::fs::write(
config_path,
format!("_version = 1\n\n[server.auth]\nmethods = [\"dev-token\"]\n\n{rest}"),
)
.expect("writing dev-token server settings fixture");
}
fn provision_dev_token_auth(_home_dir: &std::path::Path, storage_dir: &std::path::Path) {
let runtime_directory = Storage::new(storage_dir).runtime_directory();
let server_env_path = runtime_directory.env_path();
envfile::merge_env_file(&server_env_path, [
("FABRO_DEV_TOKEN", TEST_DEV_TOKEN),
("SESSION_SECRET", TEST_SESSION_SECRET),
])
.expect("merging server auth into server.env");
dev_token::write_dev_token(&runtime_directory.dev_token_path(), TEST_DEV_TOKEN)
.expect("writing runtime dev-token");
}
#[derive(Clone, Copy, Debug)]
enum ServerStartMode {
Foreground,
Daemon,
}
impl ServerStartMode {
const ALL: [Self; 2] = [Self::Foreground, Self::Daemon];
fn name(self) -> &'static str {
match self {
Self::Foreground => "foreground",
Self::Daemon => "daemon",
}
}
fn add_args(self, cmd: &mut assert_cmd::Command) {
if matches!(self, Self::Foreground) {
cmd.arg("--foreground");
}
}
}
struct StartupFailureCase {
name: &'static str,
settings: &'static str,
server_env: &'static [(&'static str, &'static str)],
expected_error: &'static str,
}
fn run_startup_failure(context: &TestContext, mode: ServerStartMode, case: &StartupFailureCase) {
let storage_root = isolated_storage_dir();
let storage_dir = storage_root
.path()
.join(format!("{}-{}", case.name, mode.name()));
let socket_path = storage_root
.path()
.join(format!("{}-{}.sock", case.name, mode.name()));
let config_dir = tempfile::tempdir_in("/tmp").expect("creating startup failure config dir");
let config_path = config_dir.path().join("settings.toml");
std::fs::write(&config_path, case.settings).expect("writing startup failure settings");
if !case.server_env.is_empty() {
envfile::merge_env_file(
&Storage::new(&storage_dir).runtime_directory().env_path(),
case.server_env.iter().copied(),
)
.expect("writing startup failure server.env");
}
let mut cmd = context.command();
cmd.args(["server", "start"]);
mode.add_args(&mut cmd);
cmd.arg("--storage-dir")
.arg(&storage_dir)
.arg("--bind")
.arg(&socket_path)
.arg("--config")
.arg(&config_path);
let output = cmd
.output()
.expect("server start failure command should run");
assert!(
!output.status.success(),
"server start should reject {} in {} mode\nstdout:\n{}\nstderr:\n{}",
case.name,
mode.name(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stdout.is_empty(),
"server start rejection should not write stdout for {} in {} mode:\n{}",
case.name,
mode.name(),
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = console::strip_ansi_codes(&stderr);
let mut expected_lines = case.expected_error.lines();
let expected_fatal_line = expected_lines.next().unwrap_or(case.expected_error);
assert_eq!(
fatal_error_line(&output.stderr),
expected_fatal_line,
"unexpected fatal line for {} in {} mode\nstderr:\n{}",
case.name,
mode.name(),
stderr
);
for expected_line in expected_lines {
let expected_line = expected_line.trim_start();
assert!(
stderr.contains(expected_line),
"missing expected stderr detail for {} in {} mode: {expected_line}\nstderr:\n{}",
case.name,
mode.name(),
stderr
);
}
let log_path = storage_dir.join("logs/server.log");
match mode {
ServerStartMode::Foreground => assert!(
!log_path.exists(),
"foreground stdout bootstrap should fail before creating server.log for {}",
case.name
),
ServerStartMode::Daemon => assert!(
!log_path.exists(),
"daemon validation should fail before creating server.log for {}",
case.name
),
}
}
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["server", "start", "--help"]);
let mut filters = context.filters();
// `--watch-web` is gated by `#[cfg(debug_assertions)]` in ServeArgs and
// only appears in debug-build help output. Strip it so the snapshot is
// consistent across debug and release builds.
filters.push((
r"(?m)^ {6}--watch-web\n {10}Run `bun run dev`.*\n".to_string(),
String::new(),
));
fabro_snapshot!(filters, cmd, @"
success: true
exit_code: 0
----- stdout -----
Start the HTTP API server
Usage: fabro server start [OPTIONS]
Options:
--json
Output as JSON [env: FABRO_JSON=]
--storage-dir <STORAGE_DIR>
Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=]
--debug
Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--foreground
Run in the foreground instead of daemonizing
--bind <BIND>
Address to bind to (IP or IP:port for TCP, or path containing / for Unix socket)
--no-upgrade-check
Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet
Suppress non-essential output [env: FABRO_QUIET=]
--web
Enable the embedded web UI and browser auth routes
--no-web
Disable the embedded web UI, browser auth routes, and web-only helper endpoints
--verbose
Enable verbose output [env: FABRO_VERBOSE=]
--model <MODEL>
Override default LLM model
--provider <PROVIDER>
Override default LLM provider
--environment <ENVIRONMENT>
Named environment for agent tools
--max-concurrent-runs <MAX_CONCURRENT_RUNS>
Maximum number of concurrent run executions
--config <CONFIG>
Path to server config file (default: ~/.fabro/settings.toml)
-h, --help
Print help
----- stderr -----
");
}
#[test]
fn start_rejects_invalid_startup_configuration_in_foreground_and_daemon() {
const DEV_TOKEN_SETTINGS: &str = r#"_version = 1
[server.auth]
methods = ["dev-token"]
"#;
const GITHUB_SETTINGS: &str = r#"_version = 1
[server.web]
enabled = true
[server.auth]
methods = ["github"]
[server.auth.github]
allowed_usernames = ["octocat"]
[server.integrations.github]
client_id = "Iv1.testclient"
"#;
const GITHUB_WITHOUT_CLIENT_ID_SETTINGS: &str = r#"_version = 1
[server.web]
enabled = true
[server.auth]
methods = ["github"]
[server.auth.github]
allowed_usernames = ["octocat"]
"#;
const GITHUB_WEB_DISABLED_SETTINGS: &str = r#"_version = 1
[server.web]
enabled = false
[server.auth]
methods = ["github"]
[server.auth.github]
allowed_usernames = ["octocat"]
[server.integrations.github]
client_id = "Iv1.testclient"
"#;
const EMPTY_AUTH_METHODS_SETTINGS: &str = r"_version = 1
[server.auth]
methods = []
";
let context = test_context!();
let cases = [
StartupFailureCase {
name: "missing-session-secret",
settings: DEV_TOKEN_SETTINGS,
server_env: &[("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)],
expected_error: "Fabro server refuses to start: auth is configured but SESSION_SECRET is not set.",
},
StartupFailureCase {
name: "missing-dev-token",
settings: DEV_TOKEN_SETTINGS,
server_env: &[("SESSION_SECRET", TEST_SESSION_SECRET)],
expected_error: "Fabro server refuses to start: dev-token auth is enabled but FABRO_DEV_TOKEN is not set.",
},
StartupFailureCase {
name: "missing-github-client-secret",
settings: GITHUB_SETTINGS,
server_env: &[("SESSION_SECRET", TEST_SESSION_SECRET)],
expected_error: "Fabro server refuses to start: github auth is enabled but GITHUB_APP_CLIENT_SECRET is not configured in the vault.",
},
StartupFailureCase {
name: "empty-auth-methods",
settings: EMPTY_AUTH_METHODS_SETTINGS,
server_env: &[],
expected_error: "failed to resolve server settings:\n server.auth.methods: invalid value - must not be empty",
},
StartupFailureCase {
name: "github-web-disabled",
settings: GITHUB_WEB_DISABLED_SETTINGS,
server_env: &[
("SESSION_SECRET", TEST_SESSION_SECRET),
("GITHUB_APP_CLIENT_SECRET", "github-client-secret"),
],
expected_error: "Fabro server refuses to start: github auth is enabled but server.web.enabled is false.",
},
StartupFailureCase {
name: "github-missing-client-id",
settings: GITHUB_WITHOUT_CLIENT_ID_SETTINGS,
server_env: &[
("SESSION_SECRET", TEST_SESSION_SECRET),
("GITHUB_APP_CLIENT_SECRET", "github-client-secret"),
],
expected_error: "Fabro server refuses to start: github auth is enabled but server.integrations.github.client_id is not configured.",
},
StartupFailureCase {
name: "invalid-dev-token",
settings: DEV_TOKEN_SETTINGS,
server_env: &[
("SESSION_SECRET", TEST_SESSION_SECRET),
("FABRO_DEV_TOKEN", "not-a-valid-dev-token"),
],
expected_error: "Fabro server refuses to start: FABRO_DEV_TOKEN has invalid format.",
},
];
for case in &cases {
for mode in ServerStartMode::ALL {
run_startup_failure(&context, mode, case);
}
}
}
#[test]
fn start_already_running_exits_with_error() {
let context = test_context!();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
provision_dev_token_auth(&context.home_dir, &storage_dir);
let sock_dir = tempfile::tempdir_in("/tmp").unwrap();
let bind_addr = sock_dir.path().join("test.sock");
let bind_str = bind_addr.to_string_lossy().to_string();
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "start", "--bind", &bind_str])
.assert()
.success();
let mut filters = context.filters();
filters.push((r"pid \d+".to_string(), "pid [PID]".to_string()));
filters.push((regex::escape(&bind_str), "[SOCKET_PATH]".to_string()));
let mut cmd = context.command();
cmd.env("FABRO_STORAGE_DIR", &storage_dir);
cmd.args(["server", "start", "--bind", &bind_str]);
fabro_snapshot!(filters, cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× Server already running (pid [PID]) on [SOCKET_PATH]
");
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "stop"])
.assert()
.success();
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "This integration test needs the real foreground process to verify install-mode startup behavior."
)]
fn start_without_default_settings_reports_missing_web_assets_for_browser_install() {
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut cmd, home_dir.path());
cmd.env("FABRO_STORAGE_DIR", &storage_dir)
.env(EnvVars::FABRO_TEST_DISABLE_SPA_ASSETS, "1")
.args(["server", "start", "--bind", "127.0.0.1:0"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
let output = cmd.output().expect("server start should run");
assert!(
!output.status.success(),
"server start should reject browser install without web assets"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("browser install mode requires web UI assets"),
"expected missing-assets guidance, got: {stderr}"
);
assert!(
stderr.contains("fabro install"),
"expected terminal install guidance, got: {stderr}"
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "This sync integration test spawns the real foreground server process to verify log ownership."
)]
fn foreground_start_writes_tracing_to_stdout_by_default() {
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
let socket_path = storage_root.path().join("foreground.sock");
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
let config_path = config_dir.path().join("settings.toml");
write_dev_token_server_settings(&config_path, "");
provision_dev_token_auth(home_dir.path(), &storage_dir);
let storage_log_path = storage_dir.join("logs").join("server.log");
std::fs::create_dir_all(storage_log_path.parent().unwrap()).unwrap();
std::fs::write(&storage_log_path, "stale pre-start log entry\n").unwrap();
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut cmd, home_dir.path());
cmd.args(["server", "start", "--foreground"])
.arg("--storage-dir")
.arg(&storage_dir)
.arg("--bind")
.arg(&socket_path)
.arg("--config")
.arg(&config_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("server start should spawn");
let record_path = storage_dir.join("server.json");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if record_path.exists() {
break;
}
if let Some(status) = child.try_wait().expect("server start should poll") {
let output = child
.wait_with_output()
.expect("server start output should be readable");
panic!(
"foreground server exited before writing server.json with status {status}:\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(
record_path.exists(),
"expected foreground start to create server.json"
);
let stop_output = {
let mut stop = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut stop, home_dir.path());
stop.args(["server", "stop"])
.arg("--storage-dir")
.arg(&storage_dir)
.output()
.expect("server stop should run")
};
assert!(
stop_output.status.success(),
"server stop should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&stop_output.stdout),
String::from_utf8_lossy(&stop_output.stderr)
);
let output = child
.wait_with_output()
.expect("server start output should be readable");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("API server started"),
"expected foreground stdout to contain server tracing, got:\n{stdout}\nforeground stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
stdout.contains("Shutdown signal received, stopping server"),
"expected foreground stdout to contain shutdown tracing, got:\n{stdout}"
);
assert!(
stdout.find("API server started")
< stdout.find("Shutdown signal received, stopping server"),
"expected shutdown trace to append after startup trace, got:\n{stdout}",
);
let storage_log = std::fs::read_to_string(&storage_log_path).unwrap_or_default();
assert_eq!(storage_log, "stale pre-start log entry\n");
let home_server_logs = server_log_files(&home_dir.path().join(".fabro").join("logs"));
assert!(
home_server_logs.is_empty(),
"expected foreground server start to avoid home server logs, found: {home_server_logs:?}"
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "This sync integration test spawns the real foreground server process to verify log ownership."
)]
fn foreground_start_with_file_destination_writes_tracing_to_storage_server_log() {
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
let socket_path = storage_root.path().join("foreground-file.sock");
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
let config_path = config_dir.path().join("settings.toml");
write_dev_token_server_settings(
&config_path,
r#"
[server.logging]
destination = "file"
"#,
);
provision_dev_token_auth(home_dir.path(), &storage_dir);
let storage_log_path = storage_dir.join("logs").join("server.log");
std::fs::create_dir_all(storage_log_path.parent().unwrap()).unwrap();
std::fs::write(&storage_log_path, "stale pre-start log entry\n").unwrap();
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut cmd, home_dir.path());
cmd.args(["server", "start", "--foreground"])
.arg("--storage-dir")
.arg(&storage_dir)
.arg("--bind")
.arg(&socket_path)
.arg("--config")
.arg(&config_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("server start should spawn");
let record_path = storage_dir.join("server.json");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if record_path.exists() {
break;
}
if let Some(status) = child.try_wait().expect("server start should poll") {
let output = child
.wait_with_output()
.expect("server start output should be readable");
panic!(
"foreground server exited before writing server.json with status {status}:\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(
record_path.exists(),
"expected foreground start to create server.json"
);
wait_for_log_line(&storage_log_path, "API server started");
let stop_output = {
let mut stop = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut stop, home_dir.path());
stop.args(["server", "stop"])
.arg("--storage-dir")
.arg(&storage_dir)
.output()
.expect("server stop should run")
};
assert!(
stop_output.status.success(),
"server stop should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&stop_output.stdout),
String::from_utf8_lossy(&stop_output.stderr)
);
let output = child
.wait_with_output()
.expect("server start output should be readable");
wait_for_log_line(
&storage_log_path,
"Shutdown signal received, stopping server",
);
let storage_log = std::fs::read_to_string(&storage_log_path).unwrap_or_default();
assert!(
storage_log.contains("API server started"),
"expected {} to contain server tracing, got:\n{}\nforeground stderr:\n{}",
storage_log_path.display(),
storage_log,
String::from_utf8_lossy(&output.stderr)
);
assert!(
storage_log.contains("Shutdown signal received, stopping server"),
"expected {} to contain shutdown tracing, got:\n{}",
storage_log_path.display(),
storage_log
);
assert!(
!storage_log.contains("stale pre-start log entry"),
"expected startup to truncate stale log contents, got:\n{storage_log}",
);
assert!(
storage_log.find("API server started")
< storage_log.find("Shutdown signal received, stopping server"),
"expected shutdown trace to append after startup trace, got:\n{storage_log}",
);
let home_server_logs = server_log_files(&home_dir.path().join(".fabro").join("logs"));
assert!(
home_server_logs.is_empty(),
"expected foreground server start to avoid home server logs, found: {home_server_logs:?}"
);
}
#[test]
fn daemon_start_writes_tracing_to_storage_server_log() {
let context = test_context!();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
let socket_path = storage_root.path().join("daemon.sock");
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
let config_path = config_dir.path().join("settings.toml");
write_dev_token_server_settings(&config_path, "");
provision_dev_token_auth(&context.home_dir, &storage_dir);
let storage_log_path = storage_dir.join("logs").join("server.log");
std::fs::create_dir_all(storage_log_path.parent().unwrap()).unwrap();
std::fs::write(&storage_log_path, "stale pre-start log entry\n").unwrap();
context
.command()
.args(["server", "start"])
.arg("--storage-dir")
.arg(&storage_dir)
.arg("--bind")
.arg(&socket_path)
.arg("--config")
.arg(&config_path)
.assert()
.success();
wait_for_log_line(&storage_log_path, "API server started");
context
.command()
.args(["server", "stop"])
.arg("--storage-dir")
.arg(&storage_dir)
.assert()
.success();
wait_for_log_line(
&storage_log_path,
"Shutdown signal received, stopping server",
);
let storage_log = std::fs::read_to_string(&storage_log_path).unwrap_or_default();
assert!(
storage_log.contains("API server started"),
"expected {} to contain startup tracing, got:\n{}",
storage_log_path.display(),
storage_log
);
assert!(
storage_log.contains("Shutdown signal received, stopping server"),
"expected {} to contain shutdown tracing, got:\n{}",
storage_log_path.display(),
storage_log
);
assert!(
!storage_log.contains("stale pre-start log entry"),
"expected startup to truncate stale log contents, got:\n{storage_log}",
);
assert!(
storage_log.find("API server started")
< storage_log.find("Shutdown signal received, stopping server"),
"expected shutdown trace to append after startup trace, got:\n{storage_log}",
);
let home_server_logs = server_log_files(&context.home_dir.join(".fabro").join("logs"));
assert!(
home_server_logs.is_empty(),
"expected daemonized server start to avoid home server logs, found: {home_server_logs:?}"
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "This sync integration test needs the real foreground process to verify install-mode startup warnings."
)]
fn start_with_no_web_and_missing_assets_starts_api_only() {
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
let config_path = config_dir.path().join("settings.toml");
write_dev_token_server_settings(&config_path, "");
provision_dev_token_auth(home_dir.path(), &storage_dir);
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut cmd, home_dir.path());
cmd.env(EnvVars::FABRO_TEST_DISABLE_SPA_ASSETS, "1")
.args(["server", "start", "--no-web", "--bind", "127.0.0.1:0"])
.arg("--storage-dir")
.arg(&storage_dir)
.arg("--config")
.arg(&config_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
let output = cmd.output().expect("server start should run");
assert!(
output.status.success(),
"server start --no-web should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("install mode active"),
"server start --no-web should not launch browser install mode: {stderr}"
);
let mut stop = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut stop, home_dir.path());
let stop_output = stop
.args(["server", "stop"])
.arg("--storage-dir")
.arg(&storage_dir)
.output()
.expect("server stop should run");
assert!(
stop_output.status.success(),
"server stop should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&stop_output.stdout),
String::from_utf8_lossy(&stop_output.stderr)
);
}
#[test]
fn start_with_missing_explicit_flag_config_errors_without_entering_install_mode() {
let context = test_context!();
let missing_config = tempfile::tempdir_in("/tmp")
.unwrap()
.path()
.join("missing-settings.toml");
let mut filters = context.filters();
filters.push((
regex::escape(missing_config.to_str().unwrap()),
"[MISSING_CONFIG]".to_string(),
));
let mut explicit_cmd = context.command();
explicit_cmd.args([
"server",
"start",
"--config",
missing_config.to_str().unwrap(),
]);
fabro_snapshot!(filters.clone(), explicit_cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× reading config file [MISSING_CONFIG]: No such file or directory (os error 2)
╰─▶ No such file or directory (os error 2)
");
}
#[test]
fn start_with_missing_env_config_errors_without_entering_install_mode() {
let context = test_context!();
let missing_config = tempfile::tempdir_in("/tmp")
.unwrap()
.path()
.join("missing-settings.toml");
let mut filters = context.filters();
filters.push((
regex::escape(missing_config.to_str().unwrap()),
"[MISSING_CONFIG]".to_string(),
));
let mut env_cmd = context.command();
env_cmd
.env("FABRO_CONFIG", &missing_config)
.args(["server", "start"]);
fabro_snapshot!(filters, env_cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× reading config file [MISSING_CONFIG]: No such file or directory (os error 2)
╰─▶ No such file or directory (os error 2)
");
}
#[test]
fn start_with_malformed_default_settings_errors_without_entering_install_mode() {
let context = test_context!();
let settings_dir = context.home_dir.join(".fabro");
std::fs::create_dir_all(&settings_dir).unwrap();
let settings_path = settings_dir.join("settings.toml");
std::fs::write(&settings_path, "[server.listen\n").unwrap();
let mut filters = context.filters();
filters.push((
regex::escape(settings_path.to_str().unwrap()),
"[SETTINGS_PATH]".to_string(),
));
let mut cmd = context.command();
cmd.args(["server", "start"]);
fabro_snapshot!(filters, cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× Failed to parse settings file at [HOME_DIR]/.fabro/settings.toml: settings file is not valid TOML: TOML parse error at line 1, column 15
│ |
│ 1 | [server.listen
│ | ^
│ invalid table header
│ expected `.`, `]`
╰─▶ settings file is not valid TOML: TOML parse error at line 1, column 15
|
1 | [server.listen
| ^
invalid table header
expected `.`, `]`
");
}
#[test]
fn start_without_bind_uses_home_socket_instead_of_storage_socket() {
let context = test_context!();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
provision_dev_token_auth(&context.home_dir, &storage_dir);
let expected_socket = context.home_dir.join(".fabro").join("fabro.sock");
let storage_socket = storage_dir.join("fabro.sock");
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "start"])
.assert()
.success();
let output = context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "status", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["bind"].as_str(), expected_socket.to_str());
assert_ne!(json["bind"].as_str(), storage_socket.to_str());
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "stop"])
.assert()
.success();
}
#[test]
fn start_without_bind_uses_configured_tcp_listen_address() {
let context = test_context!();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
let config_path = config_dir.path().join("settings.toml");
write_dev_token_server_settings(
&config_path,
r#"[server.listen]
type = "tcp"
address = "127.0.0.1:0"
"#,
);
provision_dev_token_auth(&context.home_dir, &storage_dir);
let mut cmd = context.command();
cmd.env("FABRO_STORAGE_DIR", &storage_dir);
cmd.args(["server", "start", "--config", config_path.to_str().unwrap()]);
let output = cmd.output().expect("server start command should run");
assert!(
output.status.success(),
"server start should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let bind_regex = regex::Regex::new(r"127\.0\.0\.1:\d+").unwrap();
assert!(
bind_regex.is_match(&stderr),
"expected configured tcp bind in stderr, got {stderr}"
);
let output = context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "status", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
let bind = json["bind"].as_str().expect("bind should be a string");
assert!(
bind.starts_with("127.0.0.1:"),
"expected configured tcp bind, got {bind}"
);
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "stop"])
.assert()
.success();
}
#[test]
fn start_with_tcp_host_only_bind_resolves_to_host_and_port() {
let context = test_context!();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
provision_dev_token_auth(&context.home_dir, &storage_dir);
let mut cmd = context.command();
cmd.env("FABRO_STORAGE_DIR", &storage_dir);
cmd.args(["server", "start", "--bind", "127.0.0.1"]);
let output = cmd.output().expect("server start command should run");
assert!(
output.status.success(),
"server start should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Server started (pid "),
"expected startup message, got {stderr}"
);
let bind_regex = regex::Regex::new(r"127\.0\.0\.1:\d+").unwrap();
assert!(
bind_regex.is_match(&stderr),
"expected resolved tcp bind in stderr, got {stderr}"
);
let output = context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "status", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
let bind = json["bind"].as_str().expect("bind should be a string");
assert!(
bind.starts_with("127.0.0.1:"),
"expected resolved tcp bind, got {bind}"
);
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "stop"])
.assert()
.success();
}
#[test]
fn start_with_tcp_host_only_bind_warns_and_falls_back_when_default_port_is_unavailable() {
let context = test_context!();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
let occupied = match std::net::TcpListener::bind(("127.0.0.1", 32276)) {
Ok(listener) => listener,
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {
return;
}
Err(error) => panic!("failed to bind default TCP port 32276: {error}"),
};
let mut filters = context.filters();
filters.push((r"pid \d+".to_string(), "pid [PID]".to_string()));
filters.push((r"127\.0\.0\.1:\d+".to_string(), "[TCP_BIND]".to_string()));
provision_dev_token_auth(&context.home_dir, &storage_dir);
let mut cmd = context.command();
cmd.env("FABRO_STORAGE_DIR", &storage_dir);
cmd.args(["server", "start", "--bind", "127.0.0.1"]);
fabro_snapshot!(filters, cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Warning: TCP port 32276 is unavailable on 127.0.0.1; falling back to a random port.
Server started (pid [PID]) on [TCP_BIND]
Web UI: http://[TCP_BIND]
Auth: dev-token
");
let output = context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "status", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
let bind = json["bind"].as_str().expect("bind should be a string");
assert_ne!(bind, "127.0.0.1:32276");
assert!(
bind.starts_with("127.0.0.1:"),
"expected resolved tcp bind, got {bind}"
);
drop(occupied);
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "stop"])
.assert()
.success();
}
#[test]
fn default_test_contexts_share_one_eager_session_server() {
let context_a = test_context!();
let context_b = test_context!();
assert_eq!(
context_a.storage_dir, context_b.storage_dir,
"default test contexts in one session should share storage owned by one server"
);
let output_a = context_a
.command()
.args(["server", "status", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let output_b = context_b
.command()
.args(["server", "status", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let status_a: serde_json::Value = serde_json::from_slice(&output_a).unwrap();
let status_b: serde_json::Value = serde_json::from_slice(&output_b).unwrap();
assert_eq!(status_a["status"].as_str(), Some("running"));
assert_eq!(status_a["pid"], status_b["pid"]);
}
#[test]
fn default_test_context_server_keeps_object_store_off_disk() {
let context = test_context!();
context
.command()
.args(["server", "status", "--json"])
.assert()
.success();
assert!(
!context.storage_dir.join("store").exists(),
"shared test daemon should not materialize on-disk object store files"
);
}
#[test]
fn isolated_server_switches_context_to_separate_daemon() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
let shared_storage_dir = context.storage_dir.clone();
let shared_status = context
.command()
.args(["server", "status", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let shared_status: serde_json::Value = serde_json::from_slice(&shared_status).unwrap();
context.isolated_server();
assert_ne!(
context.storage_dir, shared_storage_dir,
"isolated_server should move the context onto a separate server-owned storage dir"
);
let isolated_status = context
.command()
.args(["server", "status", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let isolated_status: serde_json::Value = serde_json::from_slice(&isolated_status).unwrap();
assert_eq!(isolated_status["status"].as_str(), Some("running"));
assert_ne!(isolated_status["pid"], shared_status["pid"]);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "This sync integration test uses OS threads to exercise concurrent CLI auto-start behavior across separate processes."
)]
fn concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up() {
fn run_ps_json(
home_dir: &std::path::Path,
temp_dir: &std::path::Path,
config_path: &std::path::Path,
) -> std::process::Output {
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
fabro_test::apply_test_isolation(&mut cmd, home_dir);
cmd.current_dir(temp_dir)
.env("FABRO_HOME", home_dir.join(".fabro"))
.env("FABRO_CONFIG", config_path)
.args(["ps", "-a", "--json"])
.output()
.expect("ps command should execute")
}
fn daemon_match_count(socket_path: &str) -> usize {
let output = std::process::Command::new("ps")
.args(["-ww", "-axo", "command="])
.stdout(Stdio::piped())
.output()
.expect("ps should execute");
assert!(output.status.success(), "ps should succeed");
String::from_utf8(output.stdout)
.expect("ps output should be UTF-8")
.lines()
.filter(|line| line.contains("fabro server") && line.contains(socket_path))
.count()
}
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
let socket_path = storage_root.path().join("shared.sock");
let socket_path_str = socket_path.display().to_string();
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
let config_path = config_dir.path().join("settings.toml");
std::fs::write(
&config_path,
format!(
"_version = 1\n\n[server.auth]\nmethods = [\"dev-token\"]\n\n[server.storage]\nroot = \"{}\"\n\n[cli.target]\ntype = \"unix\"\npath = \"{}\"\n",
storage_dir.display(),
socket_path.display()
),
)
.unwrap();
let home_a = tempfile::tempdir_in("/tmp").unwrap();
let home_b = tempfile::tempdir_in("/tmp").unwrap();
provision_dev_token_auth(home_a.path(), &storage_dir);
provision_dev_token_auth(home_b.path(), &storage_dir);
let temp_a = tempfile::tempdir_in("/tmp").unwrap();
let temp_b = tempfile::tempdir_in("/tmp").unwrap();
let barrier = Arc::new(Barrier::new(3));
let barrier_a = Arc::clone(&barrier);
let config_a = config_path.clone();
let thread_a = std::thread::spawn(move || {
barrier_a.wait();
run_ps_json(home_a.path(), temp_a.path(), &config_a)
});
let barrier_b = Arc::clone(&barrier);
let config_b = config_path.clone();
let thread_b = std::thread::spawn(move || {
barrier_b.wait();
run_ps_json(home_b.path(), temp_b.path(), &config_b)
});
barrier.wait();
let output_a = thread_a.join().expect("thread A should join");
let output_b = thread_b.join().expect("thread B should join");
assert!(
output_a.status.success(),
"first concurrent ps should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output_a.stdout),
String::from_utf8_lossy(&output_a.stderr)
);
assert!(
output_b.status.success(),
"second concurrent ps should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output_b.stdout),
String::from_utf8_lossy(&output_b.stderr)
);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if storage_dir.join("server.json").exists() && daemon_match_count(&socket_path_str) == 1 {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(
storage_dir.join("server.json").exists(),
"shared storage should have an active server record"
);
assert_eq!(
daemon_match_count(&socket_path_str),
1,
"concurrent auto-start should converge on one daemon"
);
let stop = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"))
.env("FABRO_TEST_IN_MEMORY_STORE", "1")
.env("NO_COLOR", "1")
.env("FABRO_CONFIG", &config_path)
.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled")
.args(["server", "stop", "--timeout", "0"])
.output()
.expect("server stop should execute");
assert!(
stop.status.success(),
"server stop should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&stop.stdout),
String::from_utf8_lossy(&stop.stderr)
);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if !storage_dir.join("server.json").exists() && daemon_match_count(&socket_path_str) == 0 {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(
!storage_dir.join("server.json").exists(),
"last TestContext drop should remove the server record"
);
assert_eq!(
daemon_match_count(&socket_path_str),
0,
"last TestContext drop should clean up the shared daemon"
);
}