fabro/lib/apps/fabro-cli/tests/it/cmd/run.rs
Bryan Helmkamp 0d74fdf01d
Port the CLI tests to Petri runs and the run stream
The CLI's integration tests seeded runs by appending legacy run events
and waited on legacy event names. Now every seeded run is a real dry
run: the fixtures start the run through the CLI, read the run id from
its output and wait for the stream's terminal lifecycle record. Waits,
assertions and snapshots read `RunStreamItem`s (`run.finished`, the
platform `run.lifecycle` record, `derived.parsed.kind == "question"`).

Test changes:
- support.rs: `run_completed_dry_run`, `wait_for_run_finished`,
  `wait_for_lifecycle`, `wait_for_stream_item`; the `append_seeded_*`
  writers, `wait_for_event_names` and the git-backed seeded fixtures
  are gone (the checkpoint patch is not in the projection yet).
- diff.rs keeps only the help test; inspect.rs drops the git-backed
  checkpoint test; events.rs, dump.rs, create.rs, attach.rs and
  dry_run_examples.rs snapshots are re-recorded over Petri's rendering
  with redactions for epoch millis, digests and commit shas.
- run.rs: the remote foreground mock serves stream pages and a run
  state with a conclusion and a `report` stage response; the event
  history test checks `run.finished` and the terminal lifecycle item.
- runner.rs / attach.rs: question ids containing `#` are percent-encoded
  in answer URLs.

Production fixes the ports surfaced:
- petri_worker.rs: a cancelled run exits without reporting a failure.
- runner.rs: resuming a run that already finished fails its precondition
  instead of starting a worker.

Left failing on purpose, each bound to a Petri-side gap reported to the
lead rather than to the port: sandbox_cp (4), sandbox_preview and
sandbox_ssh (the projection carries no sandbox instance), the artifact
collection tests in workflow::artifacts and run.rs (no artifact
collection for Petri runs yet), and the two dump blob-ref tests (blob
refs are not visible in the inspect output).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:30:18 -04:00

1240 lines
41 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, init_remote_fixture, mock_environment, mock_workflow_version_registrations,
output_stderr, remote_run_summary_json, run_state, wait_for_run_finished, write_workflow,
};
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 {
// A finished run whose `report` stage answered: the summary prints the
// last stage response as the run's output.
let mut state = run_projection_json(
run_id,
&serde_json::json!({
"kind": "succeeded",
"reason": "completed"
}),
);
let mut projection: fabro_types::RunProjection =
serde_json::from_value(state.clone()).expect("the projection fixture parses");
projection
.stage_entry("report", 1, fabro_types::first_event_seq(1))
.response = Some("Remote output".to_string());
state = serde_json::to_value(projection).expect("the projection serializes");
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": [],
"usage": null,
"total_retries": 0,
"diff": {}
});
state
}
/// The platform record that moves the run to `status`, as one item of the
/// run's stream.
fn lifecycle_item(
run_id: &str,
stream_seq: u64,
transition: &str,
status: &str,
) -> serde_json::Value {
serde_json::json!({
"run_id": run_id,
"stream_seq": stream_seq,
"kind": "platform",
"id": stream_seq.to_string(),
"recorded_at": 1_775_390_400_000_u64 + stream_seq,
"item": {
"seq": stream_seq,
"recorded_at": 1_775_390_400_000_u64 + stream_seq,
"record": {
"kind": "run.lifecycle",
"transition": transition,
"status": { "kind": status, "reason": "completed" }
}
}
})
}
/// One page of a run's stream.
fn stream_page(items: &[serde_json::Value], has_more: bool) -> serde_json::Value {
serde_json::json!({
"data": items,
"meta": { "has_more": has_more },
"event_contract_version": 3
})
}
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");
}
#[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 -----
Register a workflow version, create a run, and start it
Usage: fabro run [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
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)
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--workflow-repo <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
--target-from <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
--target <OWNER/REPO[@BRANCH]> Target GitHub repository and optional working branch
--target-repo <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
--dry-run Simulate execution; workflow source may still be fetched and uploaded
--auto-approve Auto-approve all human gates
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local 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 environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
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)
);
environment_mock.assert();
version_mock.assert();
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_intent() {
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 environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
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();
environment_mock.assert();
version_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 environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
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)
);
environment_mock.assert();
version_mock.assert();
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 environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
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)
);
environment_mock.assert();
version_mock.assert();
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_run_finished(&context.single_run_dir());
}
#[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 environment_mock = mock_environment(&cli_server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&cli_server);
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)
);
environment_mock.assert();
version_mock.assert();
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 environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
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("after", "0");
then.status(200)
.header("Content-Type", "application/json")
.body(
stream_page(
&[lifecycle_item(run_id.as_str(), 1, "running", "running")],
true,
)
.to_string(),
);
});
let second_page = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/events"))
.query_param("after", "1");
then.status(200)
.header("Content-Type", "application/json")
.body(
stream_page(
&[lifecycle_item(run_id.as_str(), 2, "succeeded", "succeeded")],
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);
environment_mock.assert();
version_mock.assert();
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_surfaces_server_rejection_for_unbound_template_inputs() {
let context = test_context!();
let server = MockServer::start();
let environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
let create = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(422)
.header("Content-Type", "text/plain")
.body("server-authoritative unbound-input rejection");
});
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)
);
environment_mock.assert();
version_mock.assert();
create.assert();
let stderr = output_stderr(&output);
assert!(stderr.contains("could not create run"), "{stderr}");
assert!(
stderr.contains("server-authoritative unbound-input rejection"),
"{stderr}"
);
}
#[test]
fn foreground_run_surfaces_server_rejection_for_invalid_workflow() {
let context = test_context!();
let server = MockServer::start();
let environment_mock = mock_environment(&server, "default", "docker");
let version_mock = mock_workflow_version_registrations(&server);
let create = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(422)
.header("Content-Type", "text/plain")
.body("server-authoritative invalid-workflow rejection");
});
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)
);
environment_mock.assert();
version_mock.assert();
create.assert();
let stderr = output_stderr(&output);
assert!(stderr.contains("could not create run"), "{stderr}");
assert!(
stderr.contains("server-authoritative invalid-workflow rejection"),
"{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/workflow.toml",
r#"_version = 1
[workflow]
graph = "workflow.fabro"
[run]
goal = "Show stored artifacts"
[run.environment]
id = "local"
[environments.local]
provider = "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",
"workflow.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 -----
Run: [ULID]
Web UI: http://localhost:3000/runs/[ULID]
✓ Start [TIME]
✓ Run Tests [TIME]
✓ Report [TIME]
✓ Exit [TIME]
=== Run Result ===
Run: [ULID]
Status: SUCCEEDED
Duration: [DURATION]
");
}
#[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 reach
// the server-authoritative run specification.
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)
);
assert_eq!(
run_state(&context.single_run_dir()).spec.graph.goal(),
"Ship the rate-limiting feature end to end.\n",
"goal file content should reach the admitted run"
);
}
#[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_run_finished(&run_dir);
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(|item| item.pointer("/item/record/kind"))
.and_then(Value::as_str),
Some("run.created")
);
assert_eq!(
progress
.first()
.and_then(|item| item.pointer("/item/record/spec/settings/run/execution/approval"))
.and_then(Value::as_str),
Some("auto")
);
assert!(
progress.iter().any(|item| item
.pointer("/item/record/body/event")
.and_then(Value::as_str)
== Some("run.finished")),
"store-backed event history should include the engine's run.finished"
);
let last = progress.last().expect("the history has a last item");
assert_eq!(
last.pointer("/item/record/kind").and_then(Value::as_str),
Some("run.lifecycle")
);
assert_eq!(
last.pointer("/item/record/transition")
.and_then(Value::as_str),
Some("succeeded")
);
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");
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();
// The gate's question: Petri records it as a parsed step progress.
assert!(
progress
.iter()
.any(|item| item.pointer("/item/derived/parsed/kind")
== Some(&Value::String("question".into()))),
"stdout should include the gate's question:\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",
"--environment",
"local",
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
}
"#
);
}
#[test]
fn run_starts_remote_workflow_once_and_failures_do_not_refetch() {
let context = test_context!();
let root = tempfile::tempdir().unwrap();
let source = root.path().join("source");
write_workflow(&source, ".fabro/workflows/review", "Remote");
init_remote_fixture(&source, "trunk");
let config = root.path().join("gitconfig");
std::fs::write(
&config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/acme/workflows\n",
source.display()
),
)
.unwrap();
for failure in ["none", "upload", "create", "start"] {
let server = MockServer::start();
let environment = mock_environment(&server, "default", "docker");
let version = if failure == "upload" {
server.mock(|when, then| {
when.method("POST").path("/api/v1/workflow-versions");
then.status(422).body("fixture registration rejection");
})
} else {
mock_workflow_version_registrations(&server)
};
let run_id = unique_run_id();
let create = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
if failure == "create" {
then.status(422).body("fixture create rejection");
} else {
then.status(201)
.header("content-type", "application/json")
.body(run_status_response(&run_id, "submitted").to_string());
}
});
let start = server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
if failure == "start" {
then.status(422).body("fixture start rejection");
} else {
then.status(200)
.header("content-type", "application/json")
.body(run_status_response(&run_id, "submitted").to_string());
}
});
let trace = root.path().join(format!("trace-{failure}"));
let output = context
.run_cmd()
.current_dir(root.path())
.env("GIT_CONFIG_GLOBAL", &config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0")
.env("GIT_TRACE", &trace)
.args([
"acme/workflows:review",
"--server",
&format!("{}/api/v1", server.base_url()),
"--dry-run",
"--detach",
"--json",
])
.output()
.unwrap();
assert_eq!(
output.status.success(),
failure == "none",
"{failure}: {}",
output_stderr(&output)
);
environment.assert();
version.assert();
create.assert_calls(usize::from(failure != "upload"));
start.assert_calls(usize::from(matches!(failure, "none" | "start")));
if failure == "none" {
assert_eq!(
serde_json::from_slice::<Value>(&output.stdout).unwrap(),
serde_json::json!({"run_id":run_id})
);
}
let trace = std::fs::read_to_string(&trace).unwrap();
assert_eq!(
trace
.lines()
.filter(|line| line.contains("built-in: git fetch "))
.count(),
1,
"{failure}: source fetched more than once"
);
}
}