mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
The `fabro store dump` -> `fabro dump` rename left `commands/store/` as a vestigial directory with a stale one-line `StoreRunExport` alias. Move `dump.rs` and `rebuild.rs` up to `commands/`, import `RunDump` directly, rename `dump::dump_command` -> `dump::run`, and clean up stale docs and a noise test that only asserted clap's default error output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
10 KiB
Rust
326 lines
10 KiB
Rust
#![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_test::{fabro_snapshot, test_context};
|
|
use insta::assert_snapshot;
|
|
|
|
use super::support::{local_dev_token, server_target, setup_completed_dry_run};
|
|
use crate::support::{LightweightCli, unique_run_id};
|
|
|
|
#[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 <OUTPUT> <RUN>
|
|
|
|
Arguments:
|
|
<RUN> Run ID prefix or workflow name
|
|
|
|
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=]
|
|
-o, --output <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_completed_dry_run(&context);
|
|
let cli = LightweightCli::new();
|
|
let output_dir = context.temp_dir.join("remote-export");
|
|
let server = server_target(&context.storage_dir);
|
|
|
|
let mut cmd = cli.command();
|
|
cmd.args([
|
|
"dump",
|
|
"--server",
|
|
&server,
|
|
"--output",
|
|
output_dir.to_str().unwrap(),
|
|
&run.run_id,
|
|
]);
|
|
if let Some(dev_token) = local_dev_token(&context.storage_dir) {
|
|
cmd.env("FABRO_DEV_TOKEN", dev_token);
|
|
}
|
|
|
|
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 run_id = unique_run_id();
|
|
let mut run_cmd = context.run_cmd();
|
|
run_cmd.current_dir(&context.temp_dir);
|
|
run_cmd.timeout(Duration::from_secs(30));
|
|
run_cmd.args([
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
"--no-retro",
|
|
"--sandbox",
|
|
"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 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.sandbox]
|
|
provider = "local"
|
|
preserve = true
|
|
|
|
[run.sandbox.local]
|
|
worktree_mode = "never"
|
|
|
|
[run.artifacts]
|
|
include = ["assets/**"]
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let run_id = unique_run_id();
|
|
let mut run_cmd = context.run_cmd();
|
|
run_cmd.current_dir(&workspace_dir);
|
|
run_cmd.timeout(Duration::from_secs(30));
|
|
run_cmd.args([
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
"--no-retro",
|
|
"--sandbox",
|
|
"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 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/big@1/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 12 files for run [ULID] to [TEMP_DIR]/export
|
|
----- stderr -----
|
|
");
|
|
|
|
assert_snapshot!(dump_file_summary(&output_dir), @"
|
|
checkpoints/0013.json
|
|
checkpoints/0017.json
|
|
checkpoints/0021.json
|
|
events.jsonl
|
|
graph.fabro
|
|
run.json
|
|
stages/exit@1/status.json
|
|
stages/report@1/response.md
|
|
stages/report@1/status.json
|
|
stages/run_tests@1/response.md
|
|
stages/run_tests@1/status.json
|
|
stages/start@1/status.json
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn dump_rejects_non_empty_output_dir() {
|
|
let context = test_context!();
|
|
let run = setup_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 -----
|
|
error: 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<String> = 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"
|
|
}
|