use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context}; use httpmock::MockServer; use insta::assert_snapshot; use serde_json::json; use super::support::{ created_run_id, fixture, output_stdout, remote_run_summary_json, resolve_run, run_count_for_test_case, run_state, }; use crate::support::unique_run_id; fn resolved_run(settings: &fabro_types::WorkflowSettings) -> fabro_types::settings::RunNamespace { settings.run.clone() } fn run_status_response(run_id: &str, status: &str) -> serde_json::Value { let status = match status { "submitted" => json!({ "kind": "submitted" }), other => panic!("unsupported test status {other:?}"), }; remote_run_summary_json( run_id, "Test Workflow", "test-workflow", "Test run", &status, "2026-04-05T12:00:00Z", ) } #[test] fn help() { let context = test_context!(); let mut cmd = context.command(); cmd.args(["create", "--help"]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- Create a workflow run (allocate run dir, persist spec) Usage: fabro create [OPTIONS] Arguments: Path to a .fabro workflow file or .toml task config Options: --json Output as JSON [env: FABRO_JSON=] --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 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 Override the workflow goal (available as {{ goal }} in prompts) --goal-file Read the workflow goal from a file --model Override default LLM model --provider Override default LLM provider -v, --verbose Enable verbose output --environment Named environment for agent tools --label Attach a label to this run (repeatable, format: KEY=VALUE) --parent 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 create_uses_explicit_server_target_and_prints_remote_run_id() { let context = test_context!(); let server = MockServer::start(); let run_id = unique_run_id(); let 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 output = context .create_cmd() .args([ "--server", &format!("{}/api/v1", server.base_url()), "--dry-run", fixture("simple.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) ); mock.assert(); assert_eq!(output_stdout(&output).trim(), run_id.as_str()); } #[test] fn create_defers_provider_validation_to_the_server() { let context = test_context!(); let server = MockServer::start(); let run_id = unique_run_id(); let mock = server.mock(|when, then| { when.method("POST") .path("/api/v1/runs") .body_includes(r#"provider=\"server-only\""#); then.status(201) .header("Content-Type", "application/json") .body(run_status_response(run_id.as_str(), "submitted").to_string()); }); let output = context .create_cmd() .args([ "--server", &format!("{}/api/v1", server.base_url()), "--dry-run", fixture("server-model.fabro").to_str().unwrap(), ]) .output() .expect("command should execute"); assert!( output.status.success(), "local validation should not reject a server-owned provider\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); mock.assert(); assert_eq!(output_stdout(&output).trim(), run_id.as_str()); } #[test] fn create_uses_configured_server_target_without_server_flag() { let context = test_context!(); let server = MockServer::start(); let run_id = unique_run_id(); let 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()); }); context.set_http_target(&server.base_url()); let output = context .create_cmd() .args(["--dry-run", fixture("simple.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) ); mock.assert(); assert_eq!(output_stdout(&output).trim(), run_id.as_str()); } #[test] fn create_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 output = context .create_cmd() .args([ "--server", &format!("{}/api/v1", server.base_url()), "--dry-run", "--parent", "nightly-parent", fixture("simple.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) ); resolve_mock.assert(); create_mock.assert(); assert_eq!(output_stdout(&output).trim(), run_id.as_str()); } #[test] fn create_rejects_storage_dir_flag() { let context = test_context!(); let output = context .create_cmd() .args([ "--storage-dir", "/tmp/fabro-create", "--dry-run", fixture("simple.fabro").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 create_cli_server_target_overrides_configured_server_target() { let context = test_context!(); let config_server = MockServer::start(); let config_mock = config_server.mock(|when, then| { when.method("POST").path("/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_mock = 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()); }); context.set_http_target(&config_server.base_url()); let output = context .create_cmd() .args([ "--server", &format!("{}/api/v1", cli_server.base_url()), "--dry-run", fixture("simple.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) ); cli_mock.assert(); config_mock.assert_calls(0); assert_eq!(output_stdout(&output).trim(), run_id.as_str()); } #[test] fn create_persists_directory_workflow_slug_and_cached_graph() { let context = test_context!(); context.ensure_home_server_auth_methods(); let workflow_path = context.temp_dir.join("sluggy/workflow.fabro"); context.write_temp( "sluggy/workflow.fabro", "\ digraph BarBaz { start [shape=Mdiamond, label=\"Start\"] exit [shape=Msquare, label=\"Exit\"] start -> exit } ", ); let create = context .command() .args([ "create", "--dry-run", "--auto-approve", workflow_path.to_str().unwrap(), ]) .assert() .success(); let run_id = created_run_id(create.get_output()); let run_dir = context.find_run_dir(&run_id); let state = run_state(&run_dir); let run = &state.spec; fabro_json_snapshot!( context, serde_json::json!({ "workflow_slug": run.workflow_slug, "graph_name": run.graph.name, "cached_graph_lines": state.spec.graph_source.as_ref().expect("graph should exist").lines().collect::>(), }), @r#" { "workflow_slug": "sluggy", "graph_name": "BarBaz", "cached_graph_lines": [ "digraph BarBaz {", " start [shape=Mdiamond, label=\"Start\"]", " exit [shape=Msquare, label=\"Exit\"]", " start -> exit", "}" ] } "# ); } #[test] fn create_persists_file_stem_slug_for_standalone_file() { let context = test_context!(); context.ensure_home_server_auth_methods(); let workflow_path = context.temp_dir.join("alpha.fabro"); context.write_temp( "alpha.fabro", "\ digraph FooWorkflow { start [shape=Mdiamond, label=\"Start\"] exit [shape=Msquare, label=\"Exit\"] start -> exit } ", ); let create = context .command() .args([ "create", "--dry-run", "--auto-approve", workflow_path.to_str().unwrap(), ]) .assert() .success(); let run_id = created_run_id(create.get_output()); let run_dir = context.find_run_dir(&run_id); let state = run_state(&run_dir); let run = &state.spec; fabro_json_snapshot!( context, serde_json::json!({ "workflow_slug": run.workflow_slug, "graph_name": run.graph.name, "cached_graph_lines": state.spec.graph_source.as_ref().expect("graph should exist").lines().collect::>(), }), @r#" { "workflow_slug": "alpha", "graph_name": "FooWorkflow", "cached_graph_lines": [ "digraph FooWorkflow {", " start [shape=Mdiamond, label=\"Start\"]", " exit [shape=Msquare, label=\"Exit\"]", " start -> exit", "}" ] } "# ); } #[expect( clippy::disallowed_methods, reason = "test asserts the raw template source" )] #[test] fn create_persists_requested_overrides_into_store() { let context = test_context!(); context.ensure_home_server_auth_methods(); let workflow = fixture("simple.fabro"); let mut cmd = context.command(); cmd.args([ "create", "--dry-run", "--auto-approve", "--goal", "Ship the release", "--model", "gpt-5", "--provider", "openai", "--environment", "default", "--label", "env=dev", "--label", "team=cli", "--verbose", "--preserve-sandbox", workflow.to_str().unwrap(), ]); let output = cmd.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 stdout = output_stdout(&output); let run_id = stdout .lines() .find(|line| !line.trim().is_empty()) .map(str::trim) .expect("create should print a run ID") .to_string(); let run = resolve_run(&context, &run_id); let state = run_state(&run.run_dir); let run_spec = &state.spec; let labels = json!({ "env": run_spec.labels.get("env"), "team": run_spec.labels.get("team"), }); let settings = &run_spec.settings; let resolved_run = resolved_run(settings); let compact = json!({ "workflow_slug": run_spec.workflow_slug, "settings": { "goal": match resolved_run.goal.as_ref() { Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()), _ => None, }, "dry_run": resolved_run.execution.mode == fabro_types::settings::run::RunMode::DryRun, "auto_approve": resolved_run.execution.approval == fabro_types::settings::run::ApprovalMode::Auto, "llm": { "model": resolved_run.model.name.clone(), "provider": resolved_run.model.provider.clone(), }, "environment": { "id": resolved_run.environment.id, "provider": resolved_run.environment.provider.to_string(), "preserve": resolved_run.environment.lifecycle.preserve, }, }, "labels": labels, }); assert_snapshot!(serde_json::to_string_pretty(&compact).unwrap(), @r###" { "workflow_slug": "simple", "settings": { "goal": "Ship the release", "dry_run": true, "auto_approve": true, "llm": { "model": "gpt-5", "provider": "openai" }, "environment": { "id": "default", "provider": "docker", "preserve": true } }, "labels": { "env": "dev", "team": "cli" } } "###); } #[test] fn create_json_does_not_imply_auto_approve() { let context = test_context!(); context.ensure_home_server_auth_methods(); let workflow = fixture("simple.fabro"); let output = context .command() .args(["--json", "create", "--dry-run", 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) ); let value: serde_json::Value = serde_json::from_slice(&output.stdout).expect("create JSON should parse"); let run_id = value["run_id"] .as_str() .expect("create JSON should include run_id"); let run = resolve_run(&context, run_id); assert!( resolved_run(&run_state(&run.run_dir).spec.settings,) .execution .approval != fabro_types::settings::run::ApprovalMode::Auto ); } #[test] fn create_invalid_workflow_fails_without_creating_run() { let context = test_context!(); let workflow = fixture("invalid.fabro"); let initial_run_count = run_count_for_test_case(&context); let mut cmd = context.create_cmd(); cmd.arg(workflow.to_str().unwrap()); fabro_snapshot!(context.filters(), cmd, @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- × Validation failed "); let run_count = run_count_for_test_case(&context); assert_eq!( run_count, initial_run_count, "invalid create should not persist a run for this test case" ); } #[test] fn create_rejects_unbound_template_inputs_without_creating_run() { let context = test_context!(); let workflow = fixture("templated_unbound.fabro"); let initial_run_count = run_count_for_test_case(&context); let mut cmd = context.create_cmd(); cmd.arg(workflow.to_str().unwrap()); fabro_snapshot!(context.filters(), cmd, @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- × Validation failed "); let run_count = run_count_for_test_case(&context); assert_eq!( run_count, initial_run_count, "invalid create should not persist a run for this test case" ); }