Move cli.rs and scenario.rs tests into per-subcommand cmd/ files

Redistribute all tests from the monolithic cli.rs (2190 lines) and the
standalone tests from scenario.rs into their respective cmd/ files,
completing the migration to the one-file-per-subcommand structure.

- Delete cli.rs entirely; move tests to cmd/{run,config,llm,exec,doctor,serve}.rs
- Move scenario.rs standalone tests to cmd/{repo,secret,workflow,doctor}.rs
- Slim scenario.rs to only 6 parametrized E2E workflow scenarios + run lifecycle
- Create new cmd/serve.rs and cmd/workflow.rs modules
- Remove duplicate tests already covered by snapshot tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-30 08:25:47 -04:00
parent d4a91eb29d
commit d1a969510e
No known key found for this signature in database
15 changed files with 2498 additions and 2487 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,9 @@
use fabro_config::FabroSettings;
use fabro_config::mcp::McpTransport;
#[cfg(feature = "server")]
use fabro_config::user::ExecutionMode;
use fabro_test::{fabro_snapshot, test_context};
use predicates::prelude::*;
#[test]
fn help() {
@ -54,3 +59,555 @@ fn show_help() {
----- stderr -----
");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
#[allow(deprecated)]
fn arc() -> assert_cmd::Command {
let mut cmd = assert_cmd::Command::cargo_bin("fabro").unwrap();
cmd.arg("--no-upgrade-check");
cmd
}
fn parse_config_show(stdout: &[u8]) -> FabroSettings {
serde_yaml::from_slice(stdout).expect("stdout should be valid YAML FabroSettings")
}
fn setup_config_show_fixture() -> (tempfile::TempDir, tempfile::TempDir) {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let home_fabro = home.path().join(".fabro");
std::fs::create_dir_all(&home_fabro).unwrap();
std::fs::write(
home_fabro.join("user.toml"),
r#"
verbose = true
[llm]
model = "cli-model"
provider = "openai"
[vars]
cli_only = "1"
shared = "cli"
[checkpoint]
exclude_globs = ["cli-only", "shared"]
[[hooks]]
name = "shared"
event = "run_start"
command = "echo cli"
[mcp_servers.shared]
type = "stdio"
command = ["echo", "cli"]
[sandbox]
provider = "daytona"
[sandbox.daytona]
labels = { cli_only = "1", shared = "cli" }
[sandbox.env]
CLI_ONLY = "1"
SHARED = "cli"
"#,
)
.unwrap();
std::fs::write(
project.path().join("fabro.toml"),
r#"
version = 1
[fabro]
root = "fabro"
[llm]
model = "project-model"
[vars]
project_only = "1"
shared = "project"
[[hooks]]
name = "project"
event = "run_complete"
command = "echo project"
"#,
)
.unwrap();
let workflow_dir = project.path().join("fabro").join("workflows").join("demo");
std::fs::create_dir_all(&workflow_dir).unwrap();
std::fs::write(
workflow_dir.join("workflow.toml"),
r#"
version = 1
goal = "demo goal"
[llm]
model = "run-model"
provider = "anthropic"
[vars]
run_only = "1"
shared = "run"
[checkpoint]
exclude_globs = ["run-only", "shared"]
[[hooks]]
name = "shared"
event = "run_start"
command = "echo run"
[[hooks]]
name = "run-only"
event = "run_complete"
command = "echo run-only"
[mcp_servers.shared]
type = "stdio"
command = ["echo", "run"]
[mcp_servers.run_only]
type = "stdio"
command = ["echo", "run-only"]
[sandbox.daytona]
labels = { run_only = "1", shared = "run" }
[sandbox.env]
RUN_ONLY = "1"
SHARED = "run"
"#,
)
.unwrap();
std::fs::write(
project.path().join("standalone.fabro"),
"digraph Test { start -> end }",
)
.unwrap();
(home, project)
}
fn setup_external_workflow_fixture() -> (tempfile::TempDir, tempfile::TempDir, std::path::PathBuf) {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let storage_dir = home.path().join("fabro-data");
let home_fabro = home.path().join(".fabro");
std::fs::create_dir_all(&home_fabro).unwrap();
std::fs::write(
home_fabro.join("user.toml"),
format!(
r#"
storage_dir = "{}"
auto_approve = true
[setup]
commands = ["cli-setup"]
"#,
storage_dir.display()
),
)
.unwrap();
std::fs::write(
project.path().join("fabro.toml"),
r#"
version = 1
[setup]
commands = ["project-setup"]
[sandbox]
preserve = true
"#,
)
.unwrap();
std::fs::write(
project.path().join("workflow.fabro"),
r#"
digraph Test {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
start -> exit
}
"#,
)
.unwrap();
std::fs::write(
project.path().join("workflow.toml"),
r#"
version = 1
goal = "Ship it"
graph = "workflow.fabro"
[llm]
model = "claude-sonnet-4-6"
[setup]
commands = ["workflow-setup"]
"#,
)
.unwrap();
(home, project, storage_dir)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[test]
fn config_show_merges_cli_and_project_defaults() {
let (home, project) = setup_config_show_fixture();
let output = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show"])
.assert()
.success()
.get_output()
.stdout
.clone();
let cfg = parse_config_show(&output);
let llm = cfg.llm.as_ref().expect("llm config");
assert_eq!(llm.model.as_deref(), Some("project-model"));
assert_eq!(llm.provider.as_deref(), Some("openai"));
assert_eq!(cfg.goal.as_deref(), None);
assert_eq!(cfg.fabro.as_ref().map(|f| f.root.as_str()), Some("fabro"));
let vars = cfg.vars.as_ref().expect("vars");
assert_eq!(vars.get("cli_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("project_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("shared").map(String::as_str), Some("project"));
let sandbox = cfg.sandbox.as_ref().expect("sandbox");
let labels = sandbox
.daytona
.as_ref()
.and_then(|d| d.labels.as_ref())
.expect("daytona labels");
assert_eq!(labels.get("cli_only").map(String::as_str), Some("1"));
assert_eq!(labels.get("shared").map(String::as_str), Some("cli"));
}
#[test]
fn config_show_workflow_name_applies_run_overlay_and_deep_merges() {
let (home, project) = setup_config_show_fixture();
let output = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show", "demo"])
.assert()
.success()
.get_output()
.stdout
.clone();
let cfg = parse_config_show(&output);
let llm = cfg.llm.as_ref().expect("llm config");
assert_eq!(cfg.goal.as_deref(), Some("demo goal"));
assert_eq!(llm.model.as_deref(), Some("run-model"));
assert_eq!(llm.provider.as_deref(), Some("anthropic"));
let vars = cfg.vars.as_ref().expect("vars");
assert_eq!(vars.get("cli_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("project_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("run_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("shared").map(String::as_str), Some("run"));
assert_eq!(
cfg.checkpoint.exclude_globs,
vec![
"cli-only".to_string(),
"run-only".to_string(),
"shared".to_string()
]
);
assert_eq!(cfg.hooks.len(), 3);
let shared_hook = cfg
.hooks
.iter()
.find(|hook| hook.name.as_deref() == Some("shared"))
.expect("shared hook");
assert_eq!(shared_hook.command.as_deref(), Some("echo run"));
assert!(
cfg.hooks
.iter()
.any(|hook| hook.name.as_deref() == Some("project"))
);
assert!(
cfg.hooks
.iter()
.any(|hook| hook.name.as_deref() == Some("run-only"))
);
match &cfg.mcp_servers["shared"].transport {
McpTransport::Stdio { command, .. } => assert_eq!(command, &vec!["echo", "run"]),
other => panic!("unexpected MCP transport: {other:?}"),
}
assert!(cfg.mcp_servers.contains_key("run_only"));
let sandbox = cfg.sandbox.as_ref().expect("sandbox");
let labels = sandbox
.daytona
.as_ref()
.and_then(|d| d.labels.as_ref())
.expect("daytona labels");
assert_eq!(labels.get("cli_only").map(String::as_str), Some("1"));
assert_eq!(labels.get("run_only").map(String::as_str), Some("1"));
assert_eq!(labels.get("shared").map(String::as_str), Some("run"));
let env = sandbox.env.as_ref().expect("sandbox env");
assert_eq!(env.get("CLI_ONLY").map(String::as_str), Some("1"));
assert_eq!(env.get("RUN_ONLY").map(String::as_str), Some("1"));
assert_eq!(env.get("SHARED").map(String::as_str), Some("run"));
}
#[test]
fn config_show_explicit_workflow_path_uses_workflow_project_layers() {
let (home, project, _storage_dir) = setup_external_workflow_fixture();
let cwd = tempfile::tempdir().unwrap();
let workflow = project.path().join("workflow.toml");
let output = arc()
.env("HOME", home.path())
.current_dir(cwd.path())
.args(["config", "show", workflow.to_str().unwrap()])
.assert()
.success()
.get_output()
.stdout
.clone();
let cfg = parse_config_show(&output);
assert_eq!(cfg.auto_approve, Some(true));
assert_eq!(
cfg.setup.as_ref().expect("setup config").commands,
vec![
"workflow-setup".to_string(),
"project-setup".to_string(),
"cli-setup".to_string(),
]
);
assert_eq!(
cfg.sandbox.as_ref().expect("sandbox config").preserve,
Some(true)
);
}
#[test]
fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
let (home, project, storage_dir) = setup_external_workflow_fixture();
let cwd = tempfile::tempdir().unwrap();
let workflow = project.path().join("workflow.toml");
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FB8";
arc()
.env("HOME", home.path())
.current_dir(cwd.path())
.args([
"create",
"--dry-run",
"--model",
"gpt-5.2",
"--run-id",
run_id,
workflow.to_str().unwrap(),
])
.assert()
.success();
let runs_dir = storage_dir.join("runs");
let run_dir = std::fs::read_dir(&runs_dir)
.unwrap()
.flatten()
.map(|entry| entry.path())
.find(|path| {
path.is_dir()
&& path
.file_name()
.is_some_and(|name| name.to_string_lossy().ends_with(run_id))
})
.unwrap_or_else(|| {
panic!(
"expected run directory for {run_id} under {}",
runs_dir.display()
)
});
let run_record: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(run_dir.join("run.json")).unwrap()).unwrap();
assert_eq!(run_record["settings"]["auto_approve"].as_bool(), Some(true));
assert_eq!(
run_record["settings"]["storage_dir"].as_str(),
Some(storage_dir.to_str().unwrap())
);
assert_eq!(
run_record["settings"]["sandbox"]["preserve"].as_bool(),
Some(true)
);
assert_eq!(
run_record["settings"]["llm"]["model"].as_str(),
Some("gpt-5.2")
);
assert_eq!(
run_record["settings"]["setup"]["commands"],
serde_json::json!(["workflow-setup", "project-setup", "cli-setup"])
);
}
#[test]
fn config_show_fabro_path_matches_ambient_defaults() {
let (home, project) = setup_config_show_fixture();
let ambient = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show"])
.assert()
.success()
.get_output()
.stdout
.clone();
let graph = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show", "standalone.fabro"])
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(parse_config_show(&graph), parse_config_show(&ambient));
}
#[test]
fn config_show_missing_run_config_errors() {
let (home, project) = setup_config_show_fixture();
arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show", "missing.toml"])
.assert()
.failure()
.stderr(predicate::str::contains("Workflow not found"));
}
#[test]
fn config_show_legacy_cli_config_warns_and_ignores_it() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let home_fabro = home.path().join(".fabro");
std::fs::create_dir_all(&home_fabro).unwrap();
std::fs::write(
home_fabro.join("cli.toml"),
r#"
verbose = true
[llm]
model = "legacy-model"
"#,
)
.unwrap();
let assert = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show"])
.assert()
.success()
.stderr(predicate::str::contains("ignoring legacy config file"))
.stderr(predicate::str::contains("Rename it to"));
let cfg = parse_config_show(&assert.get_output().stdout);
assert_eq!(cfg.verbose, None);
assert_eq!(cfg.llm, None);
}
#[test]
fn config_show_user_config_wins_over_legacy_cli_config() {
let (home, project) = setup_config_show_fixture();
std::fs::write(
home.path().join(".fabro").join("cli.toml"),
r#"
[llm]
model = "legacy-model"
[vars]
shared = "legacy"
"#,
)
.unwrap();
let assert = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["config", "show"])
.assert()
.success()
.stderr(predicate::str::contains("ignoring legacy config file"));
let cfg = parse_config_show(&assert.get_output().stdout);
let llm = cfg.llm.as_ref().expect("llm config");
assert_eq!(llm.model.as_deref(), Some("project-model"));
assert_eq!(
cfg.vars
.as_ref()
.and_then(|vars| vars.get("shared").map(String::as_str)),
Some("project")
);
}
#[test]
#[cfg(feature = "server")]
fn config_show_server_url_overrides_cli_defaults() {
let (home, project) = setup_config_show_fixture();
let user_toml = home.path().join(".fabro").join("user.toml");
std::fs::write(
&user_toml,
format!(
"{}\nmode = \"standalone\"\n[server]\nbase_url = \"https://config.example.com\"\n",
std::fs::read_to_string(&user_toml).unwrap()
),
)
.unwrap();
let output = arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["--server-url", "https://cli.example.com", "config", "show"])
.assert()
.success()
.get_output()
.stdout
.clone();
let cfg = parse_config_show(&output);
assert_eq!(cfg.mode, Some(ExecutionMode::Server));
assert_eq!(
cfg.server
.as_ref()
.and_then(|server| server.base_url.as_deref()),
Some("https://cli.example.com")
);
}

View file

@ -1,4 +1,19 @@
use fabro_test::{fabro_snapshot, test_context};
use predicates::prelude::*;
#[allow(deprecated)]
fn arc() -> assert_cmd::Command {
let mut cmd = assert_cmd::Command::cargo_bin("fabro").unwrap();
cmd.arg("--no-upgrade-check");
cmd
}
#[allow(deprecated)]
fn fabro() -> assert_cmd::Command {
let mut cmd = assert_cmd::Command::cargo_bin("fabro").unwrap();
cmd.env("NO_COLOR", "1");
cmd
}
#[test]
fn help() {
@ -60,3 +75,20 @@ fn dry_run_flag() {
----- stderr -----
");
}
#[test]
#[ignore = "scenario: requires ANTHROPIC_API_KEY"]
fn live_doctor() {
dotenvy::dotenv().ok();
fabro().args(["doctor"]).assert().success();
}
#[test]
fn doctor_no_color_when_no_color_set() {
arc()
.args(["doctor", "--dry-run"])
.env_clear()
.env("NO_COLOR", "1")
.assert()
.stdout(predicate::str::contains("\x1b[").not());
}

View file

@ -1,4 +1,12 @@
use fabro_test::{fabro_snapshot, test_context};
use predicates::prelude::*;
#[allow(deprecated)]
fn arc() -> assert_cmd::Command {
let mut cmd = assert_cmd::Command::cargo_bin("fabro").unwrap();
cmd.arg("--no-upgrade-check");
cmd
}
#[test]
fn invalid_permissions() {
@ -33,3 +41,164 @@ fn no_prompt() {
For more information, try '--help'.
");
}
#[test]
fn exec_missing_api_key_exits_with_error() {
let tmp = tempfile::tempdir().expect("tempdir");
arc()
.args(["exec", "test prompt"])
.env_clear()
.env("HOME", tmp.path().to_str().unwrap())
.current_dir(tmp.path())
.assert()
.failure()
.stderr(predicate::str::contains("API key not set"));
}
#[test]
#[ignore = "requires API key"]
fn exec_creates_file() {
dotenvy::dotenv().ok();
let tmp = tempfile::tempdir().expect("tempdir");
arc()
.args([
"exec",
"--auto-approve",
"--permissions",
"full",
"--provider",
"anthropic",
"--model",
"claude-haiku-4-5",
"Create a file called hello.txt containing exactly 'Hello'",
])
.current_dir(tmp.path())
.timeout(std::time::Duration::from_secs(120))
.assert()
.success();
let path = tmp.path().join("hello.txt");
assert!(path.exists(), "hello.txt should have been created");
let content = std::fs::read_to_string(&path).expect("read hello.txt");
assert!(
content.contains("Hello"),
"Expected 'Hello' in hello.txt, got: {content}"
);
}
#[test]
#[ignore = "requires API key"]
fn exec_shell_command() {
dotenvy::dotenv().ok();
let tmp = tempfile::tempdir().expect("tempdir");
arc()
.args([
"exec",
"--auto-approve",
"--permissions",
"full",
"--provider",
"anthropic",
"--model",
"claude-haiku-4-5",
"Run the shell command `echo arc_test_marker_42` and tell me what it printed",
])
.current_dir(tmp.path())
.timeout(std::time::Duration::from_secs(120))
.assert()
.success();
}
#[test]
#[ignore = "requires API key"]
fn exec_read_only_blocks_write() {
dotenvy::dotenv().ok();
let tmp = tempfile::tempdir().expect("tempdir");
arc()
.args([
"exec",
"--auto-approve",
"--permissions",
"read-only",
"--provider",
"anthropic",
"--model",
"claude-haiku-4-5",
"Create a file called forbidden.txt containing 'should not exist'",
])
.current_dir(tmp.path())
.timeout(std::time::Duration::from_secs(120))
.assert()
.success();
assert!(
!tmp.path().join("forbidden.txt").exists(),
"forbidden.txt should NOT exist under read-only permissions"
);
}
#[test]
#[ignore = "requires API key"]
fn exec_json_output_format() {
dotenvy::dotenv().ok();
let tmp = tempfile::tempdir().expect("tempdir");
let output = arc()
.args([
"exec",
"--auto-approve",
"--permissions",
"full",
"--output-format",
"json",
"--provider",
"anthropic",
"--model",
"claude-haiku-4-5",
"Create a file called test.txt containing 'test'",
])
.current_dir(tmp.path())
.timeout(std::time::Duration::from_secs(120))
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).expect("valid utf8");
assert!(!stdout.trim().is_empty(), "json output should not be empty");
// Every non-empty line should be valid JSON
let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
assert!(!lines.is_empty(), "should have at least one NDJSON line");
let first: serde_json::Value =
serde_json::from_str(lines[0]).expect("first line should be valid JSON");
assert!(
first.get("event").is_some() || first.get("type").is_some(),
"NDJSON line should have an event or type field, got: {first}"
);
}
#[test]
#[ignore = "requires API key"]
fn exec_read_and_edit() {
dotenvy::dotenv().ok();
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("data.txt"), "old content").expect("write data.txt");
arc()
.args([
"exec",
"--auto-approve",
"--permissions",
"full",
"--provider",
"anthropic",
"--model",
"claude-haiku-4-5",
"Read data.txt then replace its entire content with 'new content'",
])
.current_dir(tmp.path())
.timeout(std::time::Duration::from_secs(120))
.assert()
.success();
let content = std::fs::read_to_string(tmp.path().join("data.txt")).expect("read data.txt");
assert!(
content.contains("new content"),
"Expected 'new content' in data.txt, got: {content}"
);
}

View file

@ -1,4 +1,12 @@
use fabro_test::{fabro_snapshot, test_context};
use predicates::prelude::*;
#[allow(deprecated)]
fn arc() -> assert_cmd::Command {
let mut cmd = assert_cmd::Command::cargo_bin("fabro").unwrap();
cmd.arg("--no-upgrade-check");
cmd
}
#[test]
fn prompt_bad_option() {
@ -53,3 +61,166 @@ fn prompt_schema_invalid() {
> expected ident at line 1 column 2
");
}
#[test]
fn prompt_reads_from_stdin() {
let result = arc()
.args(["llm", "prompt", "--no-stream", "-m", "test-model"])
.write_stdin("hello from stdin")
.assert()
.failure();
// Should NOT complain about missing prompt
result.stderr(predicate::str::contains("no prompt provided").not());
}
#[test]
fn prompt_concatenates_stdin_and_arg() {
let result = arc()
.args([
"llm",
"prompt",
"--no-stream",
"-m",
"test-model",
"summarize this",
])
.write_stdin("some input text")
.assert()
.failure();
result.stderr(predicate::str::contains("no prompt provided").not());
}
#[test]
#[ignore = "requires API key"]
fn prompt_no_stream_generates_response() {
arc()
.args([
"llm",
"prompt",
"--no-stream",
"-m",
"claude-sonnet-4-5",
"Say just the word 'hello'",
])
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[test]
#[ignore = "requires API key"]
fn prompt_stream_generates_response() {
arc()
.args([
"llm",
"prompt",
"-m",
"claude-sonnet-4-5",
"Say just the word 'hello'",
])
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[test]
#[ignore = "requires API key"]
fn prompt_usage_shows_tokens() {
arc()
.args([
"llm",
"prompt",
"--no-stream",
"-u",
"-m",
"claude-sonnet-4-5",
"Say just the word 'hello'",
])
.assert()
.success()
.stderr(predicate::str::contains("Tokens:"));
}
#[test]
#[ignore = "requires API key"]
fn prompt_schema_no_stream_generates_json() {
let assert = arc()
.args([
"llm", "prompt", "--no-stream", "-m", "claude-sonnet-4-5",
"--schema", r#"{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}"#,
"Return a JSON object with a greeting field set to hello",
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON");
assert!(
parsed.get("greeting").is_some(),
"expected 'greeting' key in output"
);
}
#[test]
#[ignore = "requires API key"]
fn prompt_schema_stream_generates_json() {
let assert = arc()
.args([
"llm", "prompt", "-m", "claude-sonnet-4-5",
"--schema", r#"{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}"#,
"Return a JSON object with a greeting field set to hello",
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON");
assert!(
parsed.get("greeting").is_some(),
"expected 'greeting' key in output"
);
}
#[test]
#[ignore = "requires API key"]
fn chat_multi_turn_with_system_prompt() {
let assert = arc()
.args([
"llm",
"chat",
"-m",
"claude-haiku-4-5",
"-s",
"You are a pilot. End every response with 'Roger that.'",
])
.write_stdin("What is your profession?\nWhat did I just ask you?\n")
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();
// Verify model info printed to stderr
assert!(
stderr.contains("Using model:"),
"stderr should show model info"
);
// Verify the system prompt influenced the output
assert!(
stdout.to_lowercase().contains("roger that"),
"response should follow pilot system prompt, got: {stdout}"
);
// Verify multi-turn: the second response should reference the first question
assert!(
stdout.to_lowercase().contains("profession")
|| stdout.to_lowercase().contains("asked")
|| stdout.to_lowercase().contains("pilot"),
"second response should show multi-turn context, got: {stdout}"
);
}

View file

@ -11,7 +11,9 @@ mod preview;
mod repo;
mod run;
mod secret;
mod serve;
mod ssh;
mod system;
mod top_level;
mod validate;
mod workflow;

View file

@ -1,4 +1,28 @@
use fabro_test::{fabro_snapshot, test_context};
use predicates;
#[allow(deprecated)]
fn fabro() -> assert_cmd::Command {
let mut cmd = assert_cmd::Command::cargo_bin("fabro").unwrap();
cmd.env("NO_COLOR", "1");
cmd
}
fn init_git_repo(path: &std::path::Path) {
std::process::Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.expect("git init should succeed");
}
fn init_fabro_project(path: &std::path::Path) {
std::fs::write(path.join("fabro.toml"), "version = 1\n").unwrap();
let workflow_dir = path.join("fabro/workflows/hello");
std::fs::create_dir_all(&workflow_dir).unwrap();
std::fs::write(workflow_dir.join("workflow.fabro"), "digraph {}").unwrap();
std::fs::write(workflow_dir.join("workflow.toml"), "version = 1\n").unwrap();
}
#[test]
fn help() {
@ -28,3 +52,71 @@ fn help() {
----- stderr -----
");
}
#[test]
fn test_repo_deinit_removes_fabro_toml_and_dir() {
let tmp = tempfile::tempdir().unwrap();
init_git_repo(tmp.path());
init_fabro_project(tmp.path());
assert!(tmp.path().join("fabro.toml").exists());
assert!(tmp.path().join("fabro").exists());
fabro()
.args(["repo", "deinit"])
.current_dir(tmp.path())
.assert()
.success();
assert!(
!tmp.path().join("fabro.toml").exists(),
"fabro.toml should be removed"
);
assert!(
!tmp.path().join("fabro").exists(),
"fabro/ directory should be removed"
);
}
#[test]
fn test_repo_deinit_fails_when_not_initialized() {
let tmp = tempfile::tempdir().unwrap();
init_git_repo(tmp.path());
fabro()
.args(["repo", "deinit"])
.current_dir(tmp.path())
.assert()
.failure()
.stderr(predicates::str::contains("not initialized"));
}
#[test]
fn test_repo_init_skill_installs_skill_files() {
let tmp = tempfile::tempdir().unwrap();
init_git_repo(tmp.path());
fabro()
.args(["repo", "init", "--skill"])
.current_dir(tmp.path())
.assert()
.success();
// Skill files should be installed under .claude/skills/fabro-create-workflow/
let skill_dir = tmp.path().join(".claude/skills/fabro-create-workflow");
assert!(skill_dir.join("SKILL.md").exists(), "SKILL.md should exist");
assert!(
skill_dir.join("references/dot-language.md").exists(),
"dot-language.md should exist"
);
}
#[test]
fn test_repo_init_help_does_not_show_skill() {
let out = fabro().args(["repo", "init", "--help"]).assert().success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
assert!(
!stdout.contains("--skill"),
"--skill should be hidden from help"
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,12 @@
use fabro_test::{fabro_snapshot, test_context};
use predicates;
#[allow(deprecated)]
fn fabro() -> assert_cmd::Command {
let mut cmd = assert_cmd::Command::cargo_bin("fabro").unwrap();
cmd.env("NO_COLOR", "1");
cmd
}
#[test]
fn help() {
@ -30,3 +38,122 @@ fn help() {
----- stderr -----
");
}
#[test]
fn test_secret_lifecycle() {
let tmp = tempfile::tempdir().unwrap();
let secret = |args: &[&str]| -> assert_cmd::assert::Assert {
fabro().env("HOME", tmp.path()).args(args).assert()
};
// 1. set FOO=bar
secret(&["secret", "set", "FOO", "bar"]).success();
// 2. get FOO -> stdout is "bar\n"
secret(&["secret", "get", "FOO"]).success().stdout("bar\n");
// 3. list -> contains FOO
secret(&["secret", "list"])
.success()
.stdout(predicates::str::contains("FOO"));
// 4. update FOO
secret(&["secret", "set", "FOO", "updated"]).success();
// 5. get FOO -> "updated\n"
secret(&["secret", "get", "FOO"])
.success()
.stdout("updated\n");
// 6. rm FOO
secret(&["secret", "rm", "FOO"]).success();
// 7. get FOO -> fails
secret(&["secret", "get", "FOO"]).failure();
}
#[test]
fn test_secret_list_show_values() {
let tmp = tempfile::tempdir().unwrap();
let secret = |args: &[&str]| -> assert_cmd::assert::Assert {
fabro().env("HOME", tmp.path()).args(args).assert()
};
secret(&["secret", "set", "A", "1"]).success();
secret(&["secret", "set", "B", "2"]).success();
// Without --show-values: just keys
let out = secret(&["secret", "list"]).success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
assert!(stdout.contains("A"));
assert!(stdout.contains("B"));
assert!(!stdout.contains("A=1"));
// With --show-values: KEY=VALUE
secret(&["secret", "list", "--show-values"])
.success()
.stdout(predicates::str::contains("A=1"))
.stdout(predicates::str::contains("B=2"));
}
#[test]
fn test_secret_list_alias_ls() {
let tmp = tempfile::tempdir().unwrap();
fabro()
.env("HOME", tmp.path())
.args(["secret", "set", "X", "y"])
.assert()
.success();
fabro()
.env("HOME", tmp.path())
.args(["secret", "ls"])
.assert()
.success()
.stdout(predicates::str::contains("X"));
}
#[test]
fn test_secret_get_missing_key() {
let tmp = tempfile::tempdir().unwrap();
fabro()
.env("HOME", tmp.path())
.args(["secret", "get", "NOPE"])
.assert()
.failure()
.stderr(predicates::str::contains("secret not found"));
}
#[test]
fn test_secret_rm_missing_key() {
let tmp = tempfile::tempdir().unwrap();
fabro()
.env("HOME", tmp.path())
.args(["secret", "rm", "NOPE"])
.assert()
.failure()
.stderr(predicates::str::contains("secret not found"));
}
#[test]
fn test_secret_value_with_equals() {
let tmp = tempfile::tempdir().unwrap();
fabro()
.env("HOME", tmp.path())
.args(["secret", "set", "URL", "https://x.com?a=1&b=2"])
.assert()
.success();
fabro()
.env("HOME", tmp.path())
.args(["secret", "get", "URL"])
.assert()
.success()
.stdout("https://x.com?a=1&b=2\n");
}

View file

@ -0,0 +1,11 @@
#[test]
#[cfg(feature = "server")]
fn help() {
#[allow(deprecated)]
let mut cmd = assert_cmd::Command::cargo_bin("fabro").unwrap();
cmd.arg("--no-upgrade-check");
let output = cmd.args(["serve", "--help"]).output().expect("runs");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
insta::assert_snapshot!(stdout);
}

View file

@ -9,7 +9,7 @@ fn version() {
success: true
exit_code: 0
----- stdout -----
fabro [VERSION] (c6ff6c9 2026-03-30)
fabro [VERSION] ([BUILD])
----- stderr -----
");
}

View file

@ -0,0 +1,31 @@
use predicates;
#[allow(deprecated)]
fn fabro() -> assert_cmd::Command {
let mut cmd = assert_cmd::Command::cargo_bin("fabro").unwrap();
cmd.env("NO_COLOR", "1");
cmd
}
#[test]
fn list() {
let tmp = tempfile::tempdir().unwrap();
// Minimal project structure: fabro.toml + a workflow
std::fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
let wf_dir = tmp.path().join("workflows/my_test_wf");
std::fs::create_dir_all(&wf_dir).unwrap();
std::fs::write(
wf_dir.join("workflow.toml"),
"version = 1\ngoal = \"A test workflow\"\n",
)
.unwrap();
fabro()
.args(["workflow", "list"])
.current_dir(tmp.path())
.assert()
.success()
// workflow list prints to stderr
.stderr(predicates::str::contains("my_test_wf"));
}

View file

@ -1,3 +1,2 @@
mod cli;
mod cmd;
mod scenario;

View file

@ -2,9 +2,7 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use assert_cmd::Command;
use assert_cmd::assert::Assert;
use fabro_store::RuntimeState;
use predicates::str::contains;
use serde_json::Value;
// ---------------------------------------------------------------------------
@ -24,12 +22,6 @@ fn fixture(name: &str) -> PathBuf {
.join(name)
}
fn fixture_root(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../test")
.join(name)
}
fn read_json(path: &Path) -> Value {
let content = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
@ -50,7 +42,7 @@ fn find_run_dir(storage_dir: &Path) -> PathBuf {
let runs_base = storage_dir.join("runs");
let entries: Vec<_> = std::fs::read_dir(&runs_base)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", runs_base.display()))
.filter_map(Result::ok)
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.collect();
assert_eq!(
@ -431,269 +423,6 @@ fn scenario_full_stack(sandbox: &str) {
);
}
// ---------------------------------------------------------------------------
// repo deinit
// ---------------------------------------------------------------------------
fn init_git_repo(path: &Path) {
std::process::Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.expect("git init should succeed");
}
fn init_fabro_project(path: &Path) {
std::fs::write(path.join("fabro.toml"), "version = 1\n").unwrap();
let workflow_dir = path.join("fabro/workflows/hello");
std::fs::create_dir_all(&workflow_dir).unwrap();
std::fs::write(workflow_dir.join("workflow.fabro"), "digraph {}").unwrap();
std::fs::write(workflow_dir.join("workflow.toml"), "version = 1\n").unwrap();
}
#[test]
fn test_repo_deinit_removes_fabro_toml_and_dir() {
let tmp = tempfile::tempdir().unwrap();
init_git_repo(tmp.path());
init_fabro_project(tmp.path());
assert!(tmp.path().join("fabro.toml").exists());
assert!(tmp.path().join("fabro").exists());
fabro()
.args(["repo", "deinit"])
.current_dir(tmp.path())
.assert()
.success();
assert!(
!tmp.path().join("fabro.toml").exists(),
"fabro.toml should be removed"
);
assert!(
!tmp.path().join("fabro").exists(),
"fabro/ directory should be removed"
);
}
#[test]
fn test_repo_deinit_fails_when_not_initialized() {
let tmp = tempfile::tempdir().unwrap();
init_git_repo(tmp.path());
fabro()
.args(["repo", "deinit"])
.current_dir(tmp.path())
.assert()
.failure()
.stderr(contains("not initialized"));
}
// ---------------------------------------------------------------------------
// repo init --skill
// ---------------------------------------------------------------------------
#[test]
fn test_repo_init_skill_installs_skill_files() {
let tmp = tempfile::tempdir().unwrap();
init_git_repo(tmp.path());
fabro()
.args(["repo", "init", "--skill"])
.current_dir(tmp.path())
.assert()
.success();
// Skill files should be installed under .claude/skills/fabro-create-workflow/
let skill_dir = tmp.path().join(".claude/skills/fabro-create-workflow");
assert!(skill_dir.join("SKILL.md").exists(), "SKILL.md should exist");
assert!(
skill_dir.join("references/dot-language.md").exists(),
"dot-language.md should exist"
);
}
#[test]
fn test_repo_init_help_does_not_show_skill() {
let out = fabro().args(["repo", "init", "--help"]).assert().success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
assert!(
!stdout.contains("--skill"),
"--skill should be hidden from help"
);
}
// ---------------------------------------------------------------------------
// secret subcommand lifecycle
// ---------------------------------------------------------------------------
#[test]
fn test_secret_lifecycle() {
let tmp = tempfile::tempdir().unwrap();
let secret = |args: &[&str]| -> Assert { fabro().env("HOME", tmp.path()).args(args).assert() };
// 1. set FOO=bar
secret(&["secret", "set", "FOO", "bar"]).success();
// 2. get FOO → stdout is "bar\n"
secret(&["secret", "get", "FOO"]).success().stdout("bar\n");
// 3. list → contains FOO
secret(&["secret", "list"])
.success()
.stdout(contains("FOO"));
// 4. update FOO
secret(&["secret", "set", "FOO", "updated"]).success();
// 5. get FOO → "updated\n"
secret(&["secret", "get", "FOO"])
.success()
.stdout("updated\n");
// 6. rm FOO
secret(&["secret", "rm", "FOO"]).success();
// 7. get FOO → fails
secret(&["secret", "get", "FOO"]).failure();
}
#[test]
fn test_secret_list_show_values() {
let tmp = tempfile::tempdir().unwrap();
let secret = |args: &[&str]| -> Assert { fabro().env("HOME", tmp.path()).args(args).assert() };
secret(&["secret", "set", "A", "1"]).success();
secret(&["secret", "set", "B", "2"]).success();
// Without --show-values: just keys
let out = secret(&["secret", "list"]).success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
assert!(stdout.contains('A'));
assert!(stdout.contains('B'));
assert!(!stdout.contains("A=1"));
// With --show-values: KEY=VALUE
secret(&["secret", "list", "--show-values"])
.success()
.stdout(contains("A=1"))
.stdout(contains("B=2"));
}
#[test]
fn test_secret_list_alias_ls() {
let tmp = tempfile::tempdir().unwrap();
fabro()
.env("HOME", tmp.path())
.args(["secret", "set", "X", "y"])
.assert()
.success();
fabro()
.env("HOME", tmp.path())
.args(["secret", "ls"])
.assert()
.success()
.stdout(contains("X"));
}
#[test]
fn test_secret_get_missing_key() {
let tmp = tempfile::tempdir().unwrap();
fabro()
.env("HOME", tmp.path())
.args(["secret", "get", "NOPE"])
.assert()
.failure()
.stderr(contains("secret not found"));
}
#[test]
fn test_secret_rm_missing_key() {
let tmp = tempfile::tempdir().unwrap();
fabro()
.env("HOME", tmp.path())
.args(["secret", "rm", "NOPE"])
.assert()
.failure()
.stderr(contains("secret not found"));
}
#[test]
fn test_secret_value_with_equals() {
let tmp = tempfile::tempdir().unwrap();
fabro()
.env("HOME", tmp.path())
.args(["secret", "set", "URL", "https://x.com?a=1&b=2"])
.assert()
.success();
fabro()
.env("HOME", tmp.path())
.args(["secret", "get", "URL"])
.assert()
.success()
.stdout("https://x.com?a=1&b=2\n");
}
// ---------------------------------------------------------------------------
// Standalone tests (no sandbox parametrization)
// ---------------------------------------------------------------------------
#[test]
fn test_validate_rejects_invalid() {
fabro()
.args(["validate", fixture_root("invalid.fabro").to_str().unwrap()])
.assert()
.failure();
}
#[test]
fn test_model_list() {
fabro()
.args(["model", "list"])
.assert()
.success()
.stdout(contains("claude-haiku"));
}
#[test]
fn test_workflow_list() {
let tmp = tempfile::tempdir().unwrap();
// Minimal project structure: fabro.toml + a workflow
std::fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap();
let wf_dir = tmp.path().join("workflows/my_test_wf");
std::fs::create_dir_all(&wf_dir).unwrap();
std::fs::write(
wf_dir.join("workflow.toml"),
"version = 1\ngoal = \"A test workflow\"\n",
)
.unwrap();
fabro()
.args(["workflow", "list"])
.current_dir(tmp.path())
.assert()
.success()
// workflow list prints to stderr
.stderr(contains("my_test_wf"));
}
#[test]
#[ignore = "scenario: requires ANTHROPIC_API_KEY"]
fn test_doctor() {
dotenvy::dotenv().ok();
fabro().args(["doctor"]).assert().success();
}
// ---------------------------------------------------------------------------
// Run lifecycle: ps / inspect / logs / rm / system df
// ---------------------------------------------------------------------------
@ -703,7 +432,7 @@ fn local_run_lifecycle() {
dotenvy::dotenv().ok();
let tmp = tempfile::tempdir().unwrap();
let fabro_home = |args: &[&str]| -> Assert {
let fabro_home = |args: &[&str]| -> assert_cmd::assert::Assert {
fabro()
.env("HOME", tmp.path())
.args(args)

View file

@ -7,6 +7,7 @@ use regex::Regex;
/// Static filters applied to every snapshot.
static INSTA_FILTERS: &[(&str, &str)] = &[
(r"fabro \d+\.\d+\.\d+", "fabro [VERSION]"),
(r"\([0-9a-f]{7} \d{4}-\d{2}-\d{2}\)", "([BUILD])"),
(r"\b[0-9A-HJKMNP-TV-Z]{26}\b", "[ULID]"),
(r"in \d+(\.\d+)?(ms|s)", "in [TIME]"),
(r"\\([\w\d])", "/$1"),