mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-10 22:43:37 +00:00
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:
parent
d4a91eb29d
commit
d1a969510e
15 changed files with 2498 additions and 2487 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
11
lib/crates/fabro-cli/tests/it/cmd/serve.rs
Normal file
11
lib/crates/fabro-cli/tests/it/cmd/serve.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ fn version() {
|
|||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
fabro [VERSION] (c6ff6c9 2026-03-30)
|
||||
fabro [VERSION] ([BUILD])
|
||||
----- stderr -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
31
lib/crates/fabro-cli/tests/it/cmd/workflow.rs
Normal file
31
lib/crates/fabro-cli/tests/it/cmd/workflow.rs
Normal 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"));
|
||||
}
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
mod cli;
|
||||
mod cmd;
|
||||
mod scenario;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue