#![expect( clippy::disallowed_methods, reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path" )] use fabro_auth::{AuthCredential, AuthDetails}; use fabro_config::Storage; use fabro_model::Provider; 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::{output_stderr, wait_for_event_names}; use crate::support::{run_output_filters, 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" }), "queued" => serde_json::json!({ "kind": "queued" }), other => panic!("unsupported test status {other:?}"), }; serde_json::json!({ "id": run_id, "status": status, "created_at": "2026-04-05T12:00:00Z" }) } fn remote_run_state_response() -> serde_json::Value { serde_json::json!({ "spec": null, "graph_source": null, "start": null, "status": null, "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": {} }, "checkpoints": [], "conclusion": { "timestamp": "2026-04-05T12:00:01Z", "status": "succeeded", "duration_ms": 12, "stages": [], "billing": null, "total_retries": 0, }, "retro": null, "retro_prompt": null, "retro_response": null, "sandbox": null, "final_patch": null, "pull_request": null, "nodes": {} }) } 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": { "duration_ms": 12, "artifact_count": 0, "status": "succeeded", "reason": "completed" } }) } fn seed_anthropic_vault(storage_dir: &std::path::Path, base_url: &str) { let mut vault = Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load"); vault .set( "anthropic", &serde_json::to_string(&AuthCredential { provider: Provider::Anthropic, details: AuthDetails::ApiKey { key: "vault-anthropic-key".to_string(), }, }) .expect("Anthropic test credential should serialize"), SecretType::Credential, None, ) .expect("Anthropic credential should store in test vault"); vault .set( "ANTHROPIC_BASE_URL", base_url, SecretType::Environment, None, ) .expect("Anthropic base URL 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] 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=] --dry-run Execute with simulated LLM backend --auto-approve Auto-approve all human gates --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --goal Override the workflow goal (available as {{ goal }} in prompts) --quiet Suppress non-essential output [env: FABRO_QUIET=] --goal-file Read the workflow goal from a file --model Override default LLM model --provider Override default LLM provider -v, --verbose Enable verbose output --sandbox Sandbox for agent tools [possible values: local, docker, daytona] --in-place Run directly in the source checkout without git checkpoints --label Attach a label to this run (repeatable, format: KEY=VALUE) --no-retro Skip retro generation after the 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(), "queued").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 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(), "queued").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_uses_vault_credentials_for_worker_execution() { let mut context = test_context!(); context.write_home( ".fabro/settings.toml", "[server.auth]\nmethods = [\"dev-token\"]\n", ); context.isolated_server(); let run_id = unique_run_id(); let llm_server = MockServer::start(); seed_anthropic_vault( &context.storage_dir, &format!("{}/v1", llm_server.base_url()), ); 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([ "--run-id", run_id.as_str(), "--auto-approve", "--no-retro", "--sandbox", "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.find_run_dir(&run_id), &["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", "--no-retro", 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(), "queued").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(), "queued").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().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 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 run_id = unique_run_id(); 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.sandbox] provider = "local" preserve = true [run.sandbox.local] worktree_mode = "never" [run.artifacts] include = ["assets/**"] "#, ); let output = context .run_cmd() .current_dir(&workspace_dir) .env("OPENAI_API_KEY", "test") .args([ "--run-id", run_id.as_str(), "--auto-approve", "--no-retro", "--sandbox", "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] 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 run_id = unique_run_id(); let workflow = context.install_fixture("simple.fabro"); context .command() .args([ "run", "--dry-run", "--auto-approve", "--sandbox", "local", "--run-id", run_id.as_str(), workflow.to_str().unwrap(), ]) .assert() .success(); let run_dir = context.find_run_dir(&run_id); wait_for_event_names(&run_dir, &["run.completed", "sandbox.cleanup.completed"]); let output = context .command() .args(["logs", &run_id]) .output() .expect("logs command should execute"); assert!( output.status.success(), "logs failed:\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let progress: Vec = 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("logs 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.cleanup.completed") ); let tail_output = context .command() .args(["logs", "--tail", "1", &run_id]) .output() .expect("tail logs command should execute"); assert!( tail_output.status.success(), "tail logs 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 logs output should be JSON")) .expect("tail logs should include the latest event"); fabro_json_snapshot!(context, &live_content, @r#" { "actor": { "display": "system:worker", "id": "worker", "kind": "system" }, "event": "sandbox.cleanup.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_id_passthrough_uses_provided_ulid() { let context = test_context!(); context.ensure_home_server_auth_methods(); let run_id = unique_run_id(); let workflow = context.install_fixture("simple.fabro"); context .command() .args([ "run", "--dry-run", "--auto-approve", "--run-id", run_id.as_str(), workflow.to_str().unwrap(), ]) .assert() .success(); context.find_run_dir(&run_id); } #[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", "--sandbox", "local", "--no-retro", 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 = 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 run_id = unique_run_id(); let workflow = context.install_fixture("simple.fabro"); context .run_cmd() .args([ "--detach", "--dry-run", "--auto-approve", "--run-id", run_id.as_str(), workflow.to_str().unwrap(), ]) .assert() .success(); 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 } "# ); }