fabro/lib/apps/fabro-cli/tests/it/cmd/run.rs
2026-08-01 13:55:58 -04:00

1124 lines
35 KiB
Rust

#![expect(
clippy::disallowed_methods,
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use fabro_config::Storage;
use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};
use fabro_vault::{SecretType, Vault};
use httpmock::MockServer;
use serde_json::Value;
use super::support::{
created_run_id, output_stderr, remote_run_summary_json, run_state, wait_for_event_names,
};
use crate::support::{LightweightCli, run_output_filters, run_projection_json, unique_run_id};
fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
let status = match status {
"submitted" => serde_json::json!({ "kind": "submitted" }),
"pending" => serde_json::json!({ "kind": "pending", "reason": "approval_required" }),
"runnable" => serde_json::json!({ "kind": "runnable" }),
other => panic!("unsupported test status {other:?}"),
};
remote_run_summary_json(
run_id,
"Test Workflow",
"test-workflow",
"Test run",
&status,
"2026-04-05T12:00:00Z",
)
}
fn remote_run_state_response(run_id: &str) -> serde_json::Value {
let mut state = run_projection_json(
run_id,
&serde_json::json!({
"kind": "succeeded",
"reason": "completed"
}),
);
state["checkpoints"] = serde_json::json!([{
"seq": 1,
"checkpoint": {
"timestamp": "2026-04-05T12:00:01Z",
"current_node": "exit",
"completed_nodes": ["report"],
"node_retries": {},
"context_values": {
"response.report": "Remote output"
},
"node_outcomes": {},
"next_node_id": null,
"git_commit_sha": null,
"loop_failure_signatures": {},
"restart_failure_signatures": {},
"node_visits": {}
}
}]);
state["conclusion"] = serde_json::json!({
"timestamp": "2026-04-05T12:00:01Z",
"status": "succeeded",
"timing": {"wall_time_ms": 12, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"stages": [],
"billing": null,
"total_retries": 0,
"diff": {}
});
state
}
fn run_completed_event(run_id: &str) -> serde_json::Value {
serde_json::json!({
"seq": 1,
"event": "run.completed",
"id": "evt-run-completed",
"run_id": run_id,
"ts": "2026-04-05T12:00:01Z",
"properties": {
"timing": {"wall_time_ms": 12, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"artifact_count": 0,
"status": "succeeded",
"reason": "completed"
}
})
}
fn seed_anthropic_vault(storage_dir: &std::path::Path) {
let mut vault =
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"ANTHROPIC_API_KEY",
"vault-anthropic-key",
SecretType::Token,
None,
)
.expect("Anthropic credential should store in test vault");
}
fn run_running_event(run_id: &str, seq: u32) -> serde_json::Value {
serde_json::json!({
"seq": seq,
"event": "run.running",
"id": format!("evt-run-running-{seq}"),
"run_id": run_id,
"ts": "2026-04-05T12:00:00Z",
"properties": {}
})
}
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Launch a workflow run
Usage: fabro run [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Path to a .fabro workflow file or .toml task config
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--dry-run Execute with simulated LLM backend
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--auto-approve Auto-approve all human gates
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read the workflow goal from a file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--environment <ENVIRONMENT> Named environment for agent tools
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> Link this run to an existing orchestration parent run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
-d, --detach Run the workflow in the background and print the run ID
-h, --help Print help
----- stderr -----
");
}
#[test]
fn detach_uses_explicit_server_target_and_prints_remote_run_id() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
let start_mock = server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "runnable").to_string());
});
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
"--server",
&format!("{}/api/v1", server.base_url()),
"--detach",
"--dry-run",
"--auto-approve",
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
create_mock.assert();
start_mock.assert();
assert_eq!(output_stderr(&output), "");
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
run_id.as_str()
);
}
#[test]
fn run_parent_resolves_parent_and_sends_parent_id_in_manifest() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let parent_id = unique_run_id();
let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id);
let create_mock = server.mock(|when, then| {
when.method("POST")
.path("/api/v1/runs")
.json_body_includes(format!(r#"{{"parent_id":"{parent_id}"}}"#));
then.status(201)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
let start_mock = server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "runnable").to_string());
});
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
"--server",
&format!("{}/api/v1", server.base_url()),
"--detach",
"--dry-run",
"--auto-approve",
"--parent",
"nightly-parent",
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
resolve_mock.assert();
create_mock.assert();
start_mock.assert();
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
run_id.as_str()
);
}
#[test]
fn detach_uses_configured_server_target_without_server_flag() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
let start_mock = server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "runnable").to_string());
});
context.set_http_target(&server.base_url());
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
"--detach",
"--dry-run",
"--auto-approve",
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
create_mock.assert();
start_mock.assert();
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
run_id.as_str()
);
}
#[test]
fn run_create_failure_shows_action_context_and_response_body() {
let context = test_context!();
let server = MockServer::start();
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(422)
.header("Content-Type", "text/plain")
.body("Failed to deserialize request: missing field `dirty` at line 1 column 2834");
});
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
"--server",
&format!("{}/api/v1", server.base_url()),
"--detach",
"--dry-run",
"--auto-approve",
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
!output.status.success(),
"create failure should fail:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
create_mock.assert();
let stderr = output_stderr(&output);
assert!(stderr.contains("could not create run"), "{stderr}");
assert!(stderr.contains("missing field `dirty`"), "{stderr}");
assert!(
stderr.contains("422 Unprocessable Entity"),
"status should remain visible for plain-text API failures:\n{stderr}"
);
assert!(
!stderr.lines().any(|line| {
line.trim_end()
.ends_with("request failed with status 422 Unprocessable Entity")
}),
"stderr should not collapse to status-only output:\n{stderr}"
);
}
#[test]
fn run_uses_vault_credentials_for_worker_execution() {
let mut context = test_context!();
let llm_server = MockServer::start();
// Configure base_url via settings.toml to point to the mock server
context.write_home(
".fabro/settings.toml",
format!(
"[server.auth]\nmethods = [\"dev-token\"]\n\n[llm.providers.anthropic]\nbase_url = \"{}/v1\"\n",
llm_server.base_url()
),
);
context.isolated_server();
seed_anthropic_vault(&context.storage_dir);
let llm_mock = llm_server.mock(|when, then| {
when.method("POST")
.path("/v1/messages")
.header("x-api-key", "vault-anthropic-key");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"id": "msg_test_123",
"model": "claude-haiku-4-5",
"content": [
{
"type": "text",
"text": "Hello from vault"
}
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 12,
"output_tokens": 4
}
})
.to_string(),
);
});
context.write_temp(
"vault_worker_llm.fabro",
"\
digraph VaultWorkerLlm {
graph [goal=\"Use a vault-backed Anthropic credential\"]
rankdir=LR
start [shape=Mdiamond, label=\"Start\"]
exit [shape=Msquare, label=\"Exit\"]
draft [shape=tab, label=\"Draft\", prompt=\"Write a short greeting.\"]
start -> draft -> exit
}
",
);
let output = context
.run_cmd()
.env_remove("ANTHROPIC_API_KEY")
.env_remove("ANTHROPIC_BASE_URL")
.env_remove("OPENAI_API_KEY")
.env_remove("OPENAI_BASE_URL")
.env_remove("GEMINI_API_KEY")
.args([
"--auto-approve",
"--environment",
"local",
"--provider",
"anthropic",
"--model",
"claude-haiku-4-5",
context
.temp_dir
.join("vault_worker_llm.fabro")
.to_str()
.unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
llm_mock.assert();
wait_for_event_names(&context.single_run_dir(), &["run.completed"]);
}
#[test]
fn detach_rejects_storage_dir_flag() {
let context = test_context!();
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
"--storage-dir",
"/tmp/fabro-run",
"--detach",
"--dry-run",
"--auto-approve",
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
!output.status.success(),
"command should reject --storage-dir"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("unexpected argument '--storage-dir'"));
}
#[test]
fn detach_cli_server_target_overrides_configured_server_target() {
let context = test_context!();
let config_server = MockServer::start();
let config_create = config_server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(500)
.body("configured-server-should-not-be-used");
});
let config_start = config_server.mock(|when, then| {
when.method("POST").path_includes("/api/v1/runs/");
then.status(500)
.body("configured-server-should-not-be-used");
});
let cli_server = MockServer::start();
let run_id = unique_run_id();
let cli_create = cli_server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
let cli_start = cli_server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "runnable").to_string());
});
context.set_http_target(&config_server.base_url());
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
"--server",
&format!("{}/api/v1", cli_server.base_url()),
"--detach",
"--dry-run",
"--auto-approve",
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
cli_create.assert();
cli_start.assert();
config_create.assert_calls(0);
config_start.assert_calls(0);
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
run_id.as_str()
);
}
#[test]
fn remote_foreground_run_consumes_paginated_events_and_prints_server_backed_summary() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let preflight = server.mock(|when, then| {
when.method("POST").path("/api/v1/preflight");
then.status(500)
.header("Content-Type", "application/json")
.body(serde_json::json!({ "error": "preflight should not run" }).to_string());
});
server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "runnable").to_string());
});
let first_page = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/events"))
.query_param_missing("since_seq");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [run_running_event(run_id.as_str(), 1)],
"meta": { "has_more": true }
})
.to_string(),
);
});
let second_page = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/events"))
.query_param("since_seq", "2");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [run_completed_event(run_id.as_str())],
"meta": { "has_more": false }
})
.to_string(),
);
});
server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/questions"))
.query_param("page[limit]", "100")
.query_param("page[offset]", "0");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [],
"meta": { "has_more": false }
})
.to_string(),
);
});
server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/state"));
then.status(200)
.header("Content-Type", "application/json")
.body(remote_run_state_response(run_id.as_str()).to_string());
});
server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/artifacts"));
then.status(200)
.header("Content-Type", "application/json")
.body(serde_json::json!({ "data": [] }).to_string());
});
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
"--server",
&format!("{}/api/v1", server.base_url()),
"--dry-run",
"--auto-approve",
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
preflight.assert_calls(0);
first_page.assert();
second_page.assert();
let stderr = output_stderr(&output);
assert!(stderr.contains("=== Run Result ==="), "{stderr}");
assert!(stderr.contains("Remote output"), "{stderr}");
assert_eq!(
stderr
.lines()
.filter(|line| line.trim_start().starts_with("Run:"))
.count(),
2,
"{stderr}"
);
assert!(!stderr.contains("=== Artifacts ==="), "{stderr}");
}
#[test]
fn run_rejects_unbound_template_inputs_before_creating_remote_run() {
let context = test_context!();
let server = MockServer::start();
let create = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(500)
.header("Content-Type", "application/json")
.body(serde_json::json!({ "error": "run should not be created" }).to_string());
});
let workflow = context.install_fixture("templated_unbound.fabro");
let output = context
.run_cmd()
.args([
"--server",
&format!("{}/api/v1", server.base_url()),
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
!output.status.success(),
"run with unbound inputs should fail:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
create.assert_calls(0);
let stderr = output_stderr(&output);
assert!(
stderr.contains("inputs.app_dir"),
"stderr should name the unbound variable: {stderr}"
);
assert!(
stderr.contains("templated_unbound.fabro"),
"stderr should name the workflow source: {stderr}"
);
assert!(
!stderr.contains("<string>"),
"stderr should not expose MiniJinja's generic source name: {stderr}"
);
}
#[test]
fn foreground_run_rejects_invalid_workflow_before_creating_remote_run() {
let context = test_context!();
let server = MockServer::start();
let create = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(500)
.header("Content-Type", "application/json")
.body(serde_json::json!({ "error": "run should not be created" }).to_string());
});
let workflow = context.install_fixture("invalid.fabro");
let output = context
.run_cmd()
.args([
"--server",
&format!("{}/api/v1", server.base_url()),
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
!output.status.success(),
"invalid run should fail:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
create.assert_calls(0);
let stderr = output_stderr(&output);
assert!(stderr.contains("Workflow: Invalid"), "{stderr}");
assert!(
stderr.contains("Pipeline must have exactly one start node"),
"{stderr}"
);
assert!(stderr.contains("Validation failed"), "{stderr}");
}
#[test]
fn local_foreground_run_prints_artifact_paths_from_server_artifact_list() {
let context = test_context!();
let workspace_dir = context.temp_dir.join("artifact-summary");
context.write_temp(
"artifact-summary/workflow.fabro",
r#"digraph ArtifactSummary {
graph [goal="Show stored artifacts"]
start [shape=Mdiamond]
exit [shape=Msquare]
create_assets [shape=parallelogram, script="mkdir -p assets/shared && printf one > assets/shared/report.txt"]
start -> create_assets -> exit
}
"#,
);
context.write_temp(
"artifact-summary/run.toml",
r#"_version = 1
[workflow]
graph = "workflow.fabro"
[run]
goal = "Show stored artifacts"
[run.environment]
id = "local"
[run.artifacts]
include = ["assets/**"]
"#,
);
let output = context
.run_cmd()
.current_dir(&workspace_dir)
.env("OPENAI_API_KEY", "test")
.args([
"--auto-approve",
"--environment",
"local",
"--provider",
"openai",
"run.toml",
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = output_stderr(&output);
assert!(stderr.contains("=== Artifacts ==="), "{stderr}");
assert!(!stderr.contains("cache/artifacts"), "{stderr}");
assert!(stderr.contains("create_assets"), "{stderr}");
assert!(stderr.contains("assets/shared/report.txt"), "{stderr}");
assert!(stderr.contains("fabro artifact cp"), "{stderr}");
}
#[test]
fn dry_run_simple() {
let context = test_context!();
let workflow = context.install_fixture("simple.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(&workflow);
fabro_snapshot!(run_output_filters(&context), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Simple (4 nodes, 3 edges)
Graph: [GRAPH_PATH]
Goal: Run tests and report results
Run: [ULID]
Web UI: http://localhost:3000/runs/[ULID]
Sandbox: local (ready in [TIME])
✓ Start [TIME]
✓ Run Tests [TIME]
✓ Report [TIME]
✓ Exit [TIME]
=== Run Result ===
Run: [ULID]
Status: SUCCEEDED
Duration: [DURATION]
=== Output ===
[Simulated] Response for stage: report
");
}
#[test]
fn dry_run_with_goal_file_reads_contents_into_goal() {
// Regression test for the `--goal-file` flag that was previously
// being silently ignored in the v2 path. The file content must end
// up in the effective goal displayed in the workflow summary.
let context = test_context!();
let goal_dir = tempfile::tempdir().unwrap();
let goal_path = goal_dir.path().join("goal.md");
std::fs::write(&goal_path, "Ship the rate-limiting feature end to end.\n").unwrap();
let workflow = context.install_fixture("simple.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve", "--goal-file"]);
cmd.arg(&goal_path);
cmd.arg(&workflow);
let output = cmd.output().expect("run command should execute");
assert!(
output.status.success(),
"run should succeed:\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Ship the rate-limiting feature end to end."),
"goal file content should appear in workflow summary, got:\n{stderr}"
);
}
#[test]
fn dry_run_rejects_goal_and_goal_file_together() {
// clap `conflicts_with` must fire when both flags are supplied.
let context = test_context!();
let goal_dir = tempfile::tempdir().unwrap();
let goal_path = goal_dir.path().join("goal.md");
std::fs::write(&goal_path, "never read").unwrap();
let workflow = context.install_fixture("simple.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--goal", "inline override", "--goal-file"]);
cmd.arg(&goal_path);
cmd.arg(&workflow);
let output = cmd.output().expect("run command should execute");
assert!(
!output.status.success(),
"run should fail when --goal and --goal-file are both set"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cannot be used with")
|| stderr.contains("conflict")
|| stderr.to_lowercase().contains("mutually exclusive"),
"expected conflicts_with error, got:\n{stderr}"
);
}
#[test]
fn dry_run_persists_event_history_in_store() {
let context = test_context!();
context.ensure_home_server_auth_methods();
let workflow = context.install_fixture("simple.fabro");
context
.run_cmd()
.args([
"--dry-run",
"--auto-approve",
"--environment",
"local",
workflow.to_str().unwrap(),
])
.assert()
.success();
let run_dir = context.single_run_dir();
let run_id = run_state(&run_dir).spec.run_id.to_string();
wait_for_event_names(&run_dir, &["run.completed", "sandbox.stop.completed"]);
let output = context
.command()
.args(["events", &run_id])
.output()
.expect("events command should execute");
assert!(
output.status.success(),
"events failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let progress: Vec<Value> = String::from_utf8(output.stdout)
.expect("stdout should be UTF-8")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("events output should be JSONL"))
.collect();
assert!(
!progress.is_empty(),
"store-backed event history should have at least one line"
);
assert_eq!(
progress.first().and_then(|event| event["event"].as_str()),
Some("run.created")
);
assert_eq!(
progress
.first()
.and_then(|event| event.pointer("/properties/settings/run/execution/approval"))
.and_then(Value::as_str),
Some("auto")
);
assert!(
progress
.iter()
.any(|event| event["event"].as_str() == Some("run.completed")),
"store-backed event history should include run.completed"
);
assert_eq!(
progress.last().and_then(|event| event["event"].as_str()),
Some("sandbox.stop.completed")
);
let tail_output = context
.command()
.args(["events", "--tail", "1", &run_id])
.output()
.expect("tail events command should execute");
assert!(
tail_output.status.success(),
"tail events failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&tail_output.stdout),
String::from_utf8_lossy(&tail_output.stderr)
);
let live_content: Value = String::from_utf8(tail_output.stdout)
.expect("stdout should be UTF-8")
.lines()
.find(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("tail events output should be JSON"))
.expect("tail events should include the latest event");
fabro_json_snapshot!(context, &live_content, @r#"
{
"actor": {
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.stop.completed",
"id": "[EVENT_ID]",
"properties": {
"duration_ms": "[DURATION_MS]",
"provider": "local"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
}
"#);
assert_eq!(live_content, *progress.last().unwrap());
}
#[test]
fn run_rejects_removed_run_id_flag() {
let cli = LightweightCli::new();
let output = cli
.command()
.args([
"run",
"--dry-run",
"--auto-approve",
"--run-id",
"01KRBZW5C00000000000000001",
"workflow.fabro",
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&output.get_output().stderr);
assert!(
stderr.contains("unexpected argument '--run-id'"),
"{stderr}"
);
}
#[test]
fn json_run_requires_manual_input_for_human_gates_without_auto_approve() {
let context = test_context!();
context.ensure_home_server_auth_methods();
let workflow = context.temp_dir.join("human-gate.fabro");
context.write_temp(
"human-gate.fabro",
r#"digraph HumanGate {
graph [goal="Require explicit approval before continuing"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
approve [shape=hexagon, label="Approve?"]
ship [shape=parallelogram, script="echo shipped"]
revise [shape=parallelogram, script="echo revised"]
start -> approve
approve -> ship [label="[A] Approve"]
approve -> revise [label="[R] Revise"]
ship -> exit
revise -> exit
}
"#,
);
let output = context
.command()
.args([
"--json",
"run",
"--environment",
"local",
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
!output.status.success(),
"command unexpectedly succeeded:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
assert!(
stderr.contains("This run is waiting for human input, but --json is non-interactive."),
"stderr should explain the non-interactive interview failure:\n{stderr}"
);
let progress: Vec<Value> = String::from_utf8(output.stdout)
.expect("stdout should be UTF-8")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("run JSON output should be JSONL"))
.collect();
assert!(
progress
.iter()
.any(|event| event.get("event") == Some(&Value::String("interview.started".into()))),
"stdout should include the interview start event:\n{}",
serde_json::to_string_pretty(&progress).unwrap()
);
}
#[test]
fn detach_prints_ulid_and_exits() {
let context = test_context!();
let workflow = context.install_fixture("simple.fabro");
let mut cmd = context.run_cmd();
cmd.args([
"--detach",
"--dry-run",
"--auto-approve",
workflow.to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
[ULID]
----- stderr -----
");
}
#[test]
fn detach_creates_run_dir_with_detach_log() {
let context = test_context!();
let workflow = context.install_fixture("simple.fabro");
let run = context
.run_cmd()
.args([
"--detach",
"--dry-run",
"--auto-approve",
workflow.to_str().unwrap(),
])
.assert()
.success();
let run_id = created_run_id(run.get_output());
let run_dir = context.find_run_dir(&run_id);
fabro_json_snapshot!(
context,
serde_json::json!({
"run_dir": run_dir,
"launcher_log_exists": context.storage_dir.join("launchers").join(format!("{run_id}.log")).exists(),
}),
@r#"
{
"run_dir": "[RUN_DIR]",
"launcher_log_exists": false
}
"#
);
}