#![expect( clippy::disallowed_methods, reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path" )] use std::fs; use std::time::Duration; use fabro_client::ServerTarget; use fabro_test::{fabro_snapshot, test_context}; use insta::assert_snapshot; use super::support::{ local_dev_token, run_state, server_target, setup_completed_dry_run, setup_seeded_completed_dry_run, setup_seeded_created_dry_run, }; use crate::support::{LightweightCli, seed_dev_token_auth}; #[test] fn help() { let context = test_context!(); let mut cmd = context.command(); cmd.args(["dump", "--help"]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- Export a run's durable state to a directory Usage: fabro dump [OPTIONS] --output 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=] -o, --output Output directory (must not exist or be empty) --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 dump_accepts_server_target_from_separate_home() { let context = test_context!(); let run = setup_seeded_completed_dry_run(&context); let cli = LightweightCli::new(); let output_dir = context.temp_dir.join("remote-export"); let server = server_target(&context.storage_dir); if let Some(dev_token) = local_dev_token(&context.storage_dir) { let target = server .parse::() .expect("server target should parse"); seed_dev_token_auth(cli.home(), &target, &dev_token); } let mut cmd = cli.command(); cmd.args([ "dump", "--server", &server, "--output", output_dir.to_str().unwrap(), &run.run_id, ]); let output = cmd.output().expect("dump should execute"); assert!( output.status.success(), "dump via remote server target failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); assert!(output_dir.join("run.json").is_file()); } #[test] fn dump_exports_large_command_output_backed_by_blob_refs() { let context = test_context!(); let workflow = context.temp_dir.join("large-output.fabro"); fs::write( &workflow, r#"digraph LargeOutput { graph [goal="Generate oversized command output"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] big [shape=parallelogram, label="Big", script="printf '%*s' 120000 '' | tr ' ' x"] start -> big -> exit } "#, ) .unwrap(); let mut run_cmd = context.run_cmd(); run_cmd.current_dir(&context.temp_dir); run_cmd.timeout(Duration::from_secs(30)); run_cmd.args(["--environment", "local"]); run_cmd.arg(&workflow); let run_output = run_cmd.output().expect("command should execute"); assert!( run_output.status.success(), "workflow run failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&run_output.stdout), String::from_utf8_lossy(&run_output.stderr) ); let run_id = run_state(&context.single_run_dir()).spec.run_id.to_string(); let mut inspect_cmd = context.command(); inspect_cmd.args(["inspect", "--json", &run_id]); let inspect_output = inspect_cmd.output().expect("inspect should execute"); assert!( inspect_output.status.success(), "inspect failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&inspect_output.stdout), String::from_utf8_lossy(&inspect_output.stderr) ); let inspect_json = String::from_utf8(inspect_output.stdout).unwrap(); assert!( inspect_json.contains("blob://sha256/"), "inspect output should contain blob refs to exercise hydration\n{inspect_json}" ); let output_dir = context.temp_dir.join("export"); let mut dump_cmd = context.command(); dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]); let dump_output = dump_cmd.output().expect("dump should execute"); assert!( dump_output.status.success(), "dump failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&dump_output.stdout), String::from_utf8_lossy(&dump_output.stderr) ); let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap(); assert!( !run_json.contains("blob://sha256/"), "run export should hydrate blob refs\n{run_json}" ); } #[test] fn dump_exports_blob_refs_and_artifacts_together() { let context = test_context!(); let workspace_dir = context.temp_dir.join("mixed-export"); fs::create_dir_all(&workspace_dir).unwrap(); fs::write( workspace_dir.join("mixed-export.fabro"), r#"digraph MixedExport { graph [goal="Generate oversized command output and artifacts"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] big [shape=parallelogram, label="Big", script="mkdir -p assets/shared && printf exported > assets/shared/report.txt && printf '%*s' 120000 '' | tr ' ' x"] start -> big -> exit } "#, ) .unwrap(); fs::write( workspace_dir.join("run.toml"), r#"_version = 1 [workflow] graph = "mixed-export.fabro" [run] goal = "Generate oversized command output and artifacts" [run.environment] id = "local" [run.artifacts] include = ["assets/**"] "#, ) .unwrap(); let mut run_cmd = context.run_cmd(); run_cmd.current_dir(&workspace_dir); run_cmd.timeout(Duration::from_secs(30)); run_cmd.args(["--environment", "local", "run.toml"]); let run_output = run_cmd.output().expect("command should execute"); assert!( run_output.status.success(), "workflow run failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&run_output.stdout), String::from_utf8_lossy(&run_output.stderr) ); let run_id = run_state(&context.single_run_dir()).spec.run_id.to_string(); let mut inspect_cmd = context.command(); inspect_cmd.args(["inspect", "--json", &run_id]); let inspect_output = inspect_cmd.output().expect("inspect should execute"); assert!( inspect_output.status.success(), "inspect failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&inspect_output.stdout), String::from_utf8_lossy(&inspect_output.stderr) ); let inspect_json = String::from_utf8(inspect_output.stdout).unwrap(); assert!( inspect_json.contains("blob://sha256/"), "inspect output should contain blob refs to exercise hydration\n{inspect_json}" ); let output_dir = context.temp_dir.join("export-mixed"); let mut dump_cmd = context.command(); dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]); let dump_output = dump_cmd.output().expect("dump should execute"); assert!( dump_output.status.success(), "dump failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&dump_output.stdout), String::from_utf8_lossy(&dump_output.stderr) ); let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap(); assert!( !run_json.contains("blob://sha256/"), "run export should hydrate blob refs\n{run_json}" ); assert_eq!( fs::read_to_string( output_dir.join("artifacts/002-big@1/retry-0001/assets/shared/report.txt") ) .unwrap(), "exported" ); } #[test] fn dump_exports_completed_run_snapshot() { let context = test_context!(); let run = setup_completed_dry_run(&context); let output_dir = context.temp_dir.join("export"); let mut cmd = context.command(); cmd.args([ "dump", "--output", output_dir.to_str().unwrap(), &run.run_id, ]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- Exported 13 files for run [ULID] to [TEMP_DIR]/export ----- stderr ----- "); assert_snapshot!(dump_file_summary(&output_dir), @" checkpoints/0014.json checkpoints/0018.json checkpoints/0022.json events.jsonl graph.fabro run.json run.log stages/001-start@1/status.json stages/002-run_tests@1/response.md stages/002-run_tests@1/status.json stages/003-report@1/response.md stages/003-report@1/status.json stages/004-exit@1/status.json "); } #[test] fn dump_succeeds_when_run_log_is_missing() { let context = test_context!(); let run = setup_seeded_created_dry_run(&context); let output_dir = context.temp_dir.join("export-missing-log"); let mut cmd = context.command(); cmd.args([ "dump", "--output", output_dir.to_str().unwrap(), &run.run_id, ]); let output = cmd.output().expect("dump should execute"); assert!( output.status.success(), "dump failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); assert!( !output_dir.join("run.log").exists(), "dump should skip run.log when the server has no run log" ); } #[test] fn dump_rejects_non_empty_output_dir() { let context = test_context!(); let run = setup_seeded_completed_dry_run(&context); let output_dir = context.temp_dir.join("nonempty"); std::fs::create_dir_all(&output_dir).unwrap(); std::fs::write(output_dir.join("file.txt"), "x").unwrap(); let mut cmd = context.command(); cmd.args([ "dump", "--output", output_dir.to_str().unwrap(), &run.run_id, ]); fabro_snapshot!(context.filters(), cmd, @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- × output path [TEMP_DIR]/nonempty already exists and is not an empty directory; remove it first or choose a different path "); } fn dump_file_summary(output_dir: &std::path::Path) -> String { let mut files: Vec = walkdir::WalkDir::new(output_dir) .into_iter() .filter_map(Result::ok) .filter(|entry| entry.file_type().is_file()) .map(|entry| { entry .path() .strip_prefix(output_dir) .expect("walked file should stay under the output directory") .to_string_lossy() .replace('\\', "/") }) .collect(); files.sort(); files.join("\n") + "\n" }