use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use crate::cmd::support::remote_run_summary_json; use crate::support::{LightweightCli, run_projection_json, unique_run_id}; fn live_run_state_response(run_id: &str) -> serde_json::Value { run_projection_json( run_id, &serde_json::json!({ "kind": "running" }), ) } fn run_sse_body(run_id: &str) -> String { let completed = serde_json::json!({ "seq": 2, "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" } }); format!("data: {completed}\n\n") } #[test] fn help_smoke_covers_high_cost_commands() { let cli = LightweightCli::new(); let mut artifact = cli.command(); artifact.args(["artifact", "--help"]); fabro_snapshot!(artifact, @" success: true exit_code: 0 ----- stdout ----- Inspect and copy run artifacts (screenshots, reports, traces) Usage: fabro artifact [OPTIONS] Commands: list List artifacts for a workflow run cp Copy artifacts from a workflow run help Print this message or the help of the given subcommand(s) Options: --json Output as JSON [env: FABRO_JSON=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] -h, --help Print help ----- stderr ----- "); let mut artifact_list = cli.command(); artifact_list.args(["artifact", "list", "--help"]); fabro_snapshot!(artifact_list, @" success: true exit_code: 0 ----- stdout ----- List artifacts for a workflow run Usage: fabro artifact list [OPTIONS] Arguments: Run ID (or prefix) 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=] --node Filter to artifacts from a specific node --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --stage Filter to artifacts from a specific stage visit (node@visit) --quiet Suppress non-essential output [env: FABRO_QUIET=] --retry Filter to artifacts from a specific retry attempt --verbose Enable verbose output [env: FABRO_VERBOSE=] -h, --help Print help ----- stderr ----- "); let mut artifact_cp = cli.command(); artifact_cp.args(["artifact", "cp", "--help"]); fabro_snapshot!(artifact_cp, @" success: true exit_code: 0 ----- stdout ----- Copy artifacts from a workflow run Usage: fabro artifact cp [OPTIONS] [DEST] Arguments: Source: RUN_ID (all artifacts) or RUN_ID:path (specific artifact) [DEST] Destination directory (defaults to current directory) [default: .] 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=] --node Filter to artifacts from a specific node --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --stage Filter to artifacts from a specific stage visit (node@visit) --quiet Suppress non-essential output [env: FABRO_QUIET=] --retry Filter to artifacts from a specific retry attempt --tree Preserve node[/visit_{N}]/retry_{N}/ directory structure --verbose Enable verbose output [env: FABRO_VERBOSE=] -h, --help Print help ----- stderr ----- "); let mut settings = cli.command(); settings.args(["settings", "--help"]); fabro_snapshot!(settings, @" success: true exit_code: 0 ----- stdout ----- Inspect effective settings Usage: fabro settings [OPTIONS] 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=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] -h, --help Print help ----- stderr ----- "); let mut attach = cli.command(); attach.args(["attach", "--help"]); fabro_snapshot!(attach, @" success: true exit_code: 0 ----- stdout ----- Attach to a running or finished workflow run Usage: fabro attach [OPTIONS] Arguments: Run ID prefix or workflow name 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=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] -h, --help Print help ----- stderr ----- "); } #[test] fn completion_smoke_covers_help_and_generation() { let cli = LightweightCli::new(); let mut help = cli.command(); help.args(["completion", "--help"]); fabro_snapshot!(help, @" success: true exit_code: 0 ----- stdout ----- Generate shell completions Usage: fabro completion [OPTIONS] Arguments: Shell to generate completions for [possible values: bash, elvish, fish, powershell, zsh] Options: --json Output as JSON [env: FABRO_JSON=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] -h, --help Print help ----- stderr ----- "); let mut zsh = cli.command(); zsh.args(["completion", "zsh"]); zsh.assert().success(); let mut fish = cli.command(); fish.args(["completion", "fish"]); fish.assert().success(); } #[test] fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { let context = test_context!(); let mut missing_arg = context.command(); missing_arg.arg("attach"); fabro_snapshot!(context.filters(), missing_arg, @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- error: the following required arguments were not provided: Usage: fabro attach --no-upgrade-check For more information, try '--help'. "); let success_server = MockServer::start(); let success_run_id = unique_run_id(); let resolve_mock = success_server.mock(|when, then| { when.method("GET") .path("/api/v1/runs/resolve") .query_param("selector", success_run_id.as_str()); then.status(200) .header("Content-Type", "application/json") .body( remote_run_summary_json( &success_run_id, "Remote Workflow", "remote-workflow", "Remote output", &serde_json::json!({ "kind": "running" }), "2026-04-05T12:00:00Z", ) .to_string(), ); }); success_server.mock(|when, then| { when.method("GET") .path(format!("/api/v1/runs/{success_run_id}/events")); then.status(200) .header("Content-Type", "application/json") .body( serde_json::json!({ "data": [{ "seq": 1, "event": "run.running", "id": "evt-run-running", "run_id": success_run_id, "ts": "2026-04-05T12:00:00Z", "properties": {} }], "meta": { "has_more": false } }) .to_string(), ); }); success_server.mock(|when, then| { when.method("GET") .path(format!("/api/v1/runs/{success_run_id}/state")); then.status(200) .header("Content-Type", "application/json") .body(live_run_state_response(success_run_id.as_str()).to_string()); }); success_server.mock(|when, then| { when.method("GET") .path(format!("/api/v1/runs/{success_run_id}/questions")) .query_param("page[limit]", "100") .query_param("page[offset]", "0"); then.status(200) .header("Content-Type", "application/json") .body(r#"{"data":[],"meta":{"has_more":false}}"#); }); let attach_mock = success_server.mock(|when, then| { when.method("GET") .path(format!("/api/v1/runs/{success_run_id}/attach")) .query_param("since_seq", "2"); then.status(200) .header("Content-Type", "text/event-stream") .body(run_sse_body(success_run_id.as_str())); }); context.set_http_target(&success_server.base_url()); let success_output = context .command() .args(["--json", "attach", &success_run_id]) .output() .expect("attach should execute"); assert!( success_output.status.success(), "attach failed:\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&success_output.stdout), String::from_utf8_lossy(&success_output.stderr) ); resolve_mock.assert(); attach_mock.assert(); let success_stdout = String::from_utf8(success_output.stdout).expect("stdout should be UTF-8"); assert!( success_stdout.contains("\"event\":\"run.completed\""), "{success_stdout}" ); let eof_server = MockServer::start(); let eof_run_id = unique_run_id(); eof_server.mock(|when, then| { when.method("GET") .path("/api/v1/runs/resolve") .query_param("selector", eof_run_id.as_str()); then.status(200) .header("Content-Type", "application/json") .body( remote_run_summary_json( &eof_run_id, "Remote Workflow", "remote-workflow", "Remote output", &serde_json::json!({ "kind": "running" }), "2026-04-05T12:00:00Z", ) .to_string(), ); }); eof_server.mock(|when, then| { when.method("GET") .path(format!("/api/v1/runs/{eof_run_id}/events")); then.status(200) .header("Content-Type", "application/json") .body( serde_json::json!({ "data": [{ "seq": 1, "event": "run.running", "id": "evt-run-running", "run_id": eof_run_id, "ts": "2026-04-05T12:00:00Z", "properties": {} }], "meta": { "has_more": false } }) .to_string(), ); }); eof_server.mock(|when, then| { when.method("GET") .path(format!("/api/v1/runs/{eof_run_id}/state")); then.status(200) .header("Content-Type", "application/json") .body(live_run_state_response(eof_run_id.as_str()).to_string()); }); eof_server.mock(|when, then| { when.method("GET") .path(format!("/api/v1/runs/{eof_run_id}/questions")) .query_param("page[limit]", "100") .query_param("page[offset]", "0"); then.status(200) .header("Content-Type", "application/json") .body(r#"{"data":[],"meta":{"has_more":false}}"#); }); eof_server.mock(|when, then| { when.method("GET") .path(format!("/api/v1/runs/{eof_run_id}/attach")) .query_param("since_seq", "2"); then.status(200) .header("Content-Type", "text/event-stream") .body(""); }); context.set_http_target(&eof_server.base_url()); let eof_output = context .command() .args(["attach", &eof_run_id]) .output() .expect("attach should execute"); assert!( !eof_output.status.success(), "attach should fail on premature EOF" ); let eof_stderr = String::from_utf8(eof_output.stderr).expect("stderr should be UTF-8"); assert!( eof_stderr.contains("terminal run event"), "expected a protocol error, got:\n{eof_stderr}" ); }