fabro/lib/apps/fabro-cli/tests/it/scenario/artifacts.rs
Scott Werner d26b2324f0 Merge remote-tracking branch 'origin/main' into codex/restore-configured-artifact-storage
# Conflicts:
#	lib/apps/fabro-cli/tests/it/scenario/artifacts.rs
2026-09-28 16:27:30 -04:00

415 lines
13 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `fabro artifact list` and `fabro artifact cp` over a run whose artifacts
//! the engine's hooks collected: every file under `[run.artifacts] include`
//! in a stage's workspace, once per content, into configured artifact storage.
use std::path::PathBuf;
use std::time::Duration;
use fabro_test::{fabro_snapshot, test_context};
use super::petri::{RunningServer, run_detached, run_json, wait_for_success};
use crate::cmd::support::{read_text, text_tree};
#[tokio::test(flavor = "multi_thread")]
async fn artifact_worker_captures_large_files_in_the_configured_local_store() {
let context = test_context!();
let server = RunningServer::start_with(
"\n[server.artifacts]\nprovider = \"local\"\nprefix = \"selected-prefix\"\n",
&[],
)
.await;
// RunningServer explicitly selects --storage-dir, which also selects the
// local artifact root. Inspect that resolved backend, outside the sandbox.
let objects = server.storage_dir.join("objects/artifacts");
let workspace = artifact_workspace(&context);
tokio::fs::write(workspace.join("workflow.fabro"), r#"digraph Capture {
graph [goal="Capture binary files", default_max_retries=0]
start [shape=Mdiamond]
write [shape=parallelogram, script="mkdir -p assets && dd if=/dev/zero of=assets/medium.bin bs=1048576 count=3 && cp assets/medium.bin assets/same.bin && dd if=/dev/zero of=assets/limit.bin bs=1048576 count=10 && cp assets/limit.bin assets/skipped.bin && printf x >> assets/skipped.bin"]
keep [shape=parallelogram, script="test -f assets/skipped.bin"]
exit [shape=Msquare]
start -> write -> keep -> exit
}"#).await.unwrap();
let run_id = run_detached(&context, &server, &workspace);
wait_for_success(&server, &run_id).await;
let projection = run_json(&server, &format!("runs/{run_id}/state")).await;
let artifacts = projection["artifacts"].as_array().unwrap();
assert_eq!(
artifacts.len(),
3,
"unchanged files are captured once; oversize is skipped"
);
let database =
fabro_db::Database::connect(fabro_config::Storage::new(&server.storage_dir).sqlite_path())
.await
.unwrap();
let blobs = fabro_store::BlobStore::new(database.clone_pool());
let (rebuilt, _, _) = fabro_petri::test_support::rebuild(
database.pool(),
database.pool(),
run_id.parse().unwrap(),
)
.await
.unwrap();
assert_eq!(
serde_json::to_value(rebuilt.unwrap().artifacts).unwrap(),
projection["artifacts"]
);
for (path, size) in [
("medium.bin", 3 * 1024 * 1024),
("same.bin", 3 * 1024 * 1024),
("limit.bin", 10 * 1024 * 1024),
] {
let bytes = vec![0; size];
let hash = fabro_types::BlobHash::new(&bytes);
let capture = artifacts
.iter()
.find(|entry| entry["relative_path"] == format!("assets/{path}"))
.unwrap();
assert_eq!(capture["object"], hash.to_string());
assert!(capture.get("blob").is_none());
assert_eq!(
tokio::fs::read(
objects.join(format!("selected-prefix/{run_id}/captures/sha256/{hash}"))
)
.await
.unwrap(),
bytes
);
assert!(blobs.read(&hash).await.unwrap().is_none());
let destination = context.temp_dir.join(format!("download-{path}"));
let output = context
.command()
.args([
"artifact",
"cp",
&format!("{run_id}:assets/{path}"),
destination.to_str().unwrap(),
"--server",
&server.target(),
])
.output()
.unwrap();
assert!(output.status.success(), "artifact download failed");
assert_eq!(
tokio::fs::read(destination.join(path)).await.unwrap(),
bytes
);
}
let mut scopes = tokio::fs::read_dir(server.petri_run_dir(&run_id).join("scopes"))
.await
.unwrap();
let original = scopes
.next_entry()
.await
.unwrap()
.unwrap()
.path()
.join("work/assets");
assert_eq!(
tokio::fs::metadata(original.join("limit.bin"))
.await
.unwrap()
.len(),
10 * 1024 * 1024
);
assert_eq!(
tokio::fs::metadata(original.join("skipped.bin"))
.await
.unwrap()
.len(),
10 * 1024 * 1024 + 1
);
server.shutdown();
}
/// Three command stages that leave files under `assets/`. The second and
/// third write different contents to the same path, so the path names an
/// artifact of each; the third also writes a `summary.txt` that collides
/// by filename with the first stage's.
#[expect(
clippy::disallowed_methods,
reason = "the fixture files are written before the run starts"
)]
fn artifact_workspace(context: &fabro_test::TestContext) -> PathBuf {
let workspace = context.temp_dir.join("artifact-workspace");
std::fs::create_dir_all(&workspace).expect("the workspace creates");
std::fs::write(
workspace.join("workflow.fabro"),
"digraph ArtifactRun {\n graph [goal=\"Exercise artifact commands\", \
default_max_retries=0]\n start [shape=Mdiamond]\n exit [shape=Msquare]\n \
create_assets [shape=parallelogram, script=\"mkdir -p assets/node_a assets/shared && \
printf alpha > assets/node_a/summary.txt && printf one > \
assets/shared/report.txt\"]\n update_assets [shape=parallelogram, script=\"mkdir -p \
assets/retry && printf second > assets/retry/report.txt\"]\n create_colliding \
[shape=parallelogram, script=\"mkdir -p assets/other && printf beta > \
assets/other/summary.txt && printf third > assets/retry/report.txt\"]\n start -> \
create_assets -> update_assets -> create_colliding -> exit\n}\n",
)
.expect("the workflow writes");
std::fs::write(
workspace.join("workflow.toml"),
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run]\ngoal = \"Exercise \
artifact commands\"\n\n[run.artifacts]\ninclude = [\"assets/**\"]\n",
)
.expect("the settings write");
workspace
}
#[tokio::test(flavor = "multi_thread")]
async fn artifact_commands_read_the_artifacts_the_hooks_collected() {
let context = test_context!();
let server = RunningServer::start().await;
let workspace = artifact_workspace(&context);
let run_id = run_detached(&context, &server, &workspace);
wait_for_success(&server, &run_id).await;
let target = server.target();
let filters = context.filters();
let mut list_json = context.command();
list_json.args(["artifact", "list", &run_id, "--json", "--server", &target]);
fabro_snapshot!(filters.clone(), list_json, @r#"
success: true
exit_code: 0
----- stdout -----
[
{
"stage_id": "create_assets@1",
"node_slug": "create_assets",
"retry": 1,
"relative_path": "assets/node_a/summary.txt",
"size": 5
},
{
"stage_id": "create_assets@1",
"node_slug": "create_assets",
"retry": 1,
"relative_path": "assets/shared/report.txt",
"size": 3
},
{
"stage_id": "create_colliding@1",
"node_slug": "create_colliding",
"retry": 1,
"relative_path": "assets/other/summary.txt",
"size": 4
},
{
"stage_id": "create_colliding@1",
"node_slug": "create_colliding",
"retry": 1,
"relative_path": "assets/retry/report.txt",
"size": 5
},
{
"stage_id": "update_assets@1",
"node_slug": "update_assets",
"retry": 1,
"relative_path": "assets/retry/report.txt",
"size": 6
}
]
----- stderr -----
"#);
let mut list_filtered = context.command();
list_filtered.args([
"artifact",
"list",
&run_id,
"--node",
"update_assets",
"--retry",
"1",
"--json",
"--server",
&target,
]);
fabro_snapshot!(filters.clone(), list_filtered, @r#"
success: true
exit_code: 0
----- stdout -----
[
{
"stage_id": "update_assets@1",
"node_slug": "update_assets",
"retry": 1,
"relative_path": "assets/retry/report.txt",
"size": 6
}
]
----- stderr -----
"#);
let mut list_stage_filtered = context.command();
list_stage_filtered.args([
"artifact",
"list",
&run_id,
"--stage",
"create_colliding@1",
"--json",
"--server",
&target,
]);
fabro_snapshot!(filters.clone(), list_stage_filtered, @r#"
success: true
exit_code: 0
----- stdout -----
[
{
"stage_id": "create_colliding@1",
"node_slug": "create_colliding",
"retry": 1,
"relative_path": "assets/other/summary.txt",
"size": 4
},
{
"stage_id": "create_colliding@1",
"node_slug": "create_colliding",
"retry": 1,
"relative_path": "assets/retry/report.txt",
"size": 5
}
]
----- stderr -----
"#);
let single_dest = context.temp_dir.join("artifact-one");
let mut cp_single = context.command();
cp_single.args([
"artifact",
"cp",
&format!("{run_id}:assets/retry/report.txt"),
single_dest.to_str().unwrap(),
"--stage",
"create_colliding@1",
"--server",
&target,
]);
fabro_snapshot!(filters.clone(), cp_single, @"
success: true
exit_code: 0
----- stdout -----
Copied assets/retry/report.txt to [TEMP_DIR]/artifact-one/report.txt
----- stderr -----
");
assert_eq!(read_text(&single_dest.join("report.txt")), "third");
let node_dest = context.temp_dir.join("artifact-node");
let mut cp_node = context.command();
cp_node.args([
"artifact",
"cp",
&format!("{run_id}:assets/retry/report.txt"),
node_dest.to_str().unwrap(),
"--node",
"update_assets",
"--server",
&target,
]);
fabro_snapshot!(filters.clone(), cp_node, @"
success: true
exit_code: 0
----- stdout -----
Copied assets/retry/report.txt to [TEMP_DIR]/artifact-node/report.txt
----- stderr -----
");
assert_eq!(read_text(&node_dest.join("report.txt")), "second");
let stage_tree_dest = context.temp_dir.join("artifact-stage-tree");
let mut cp_stage_tree = context.command();
cp_stage_tree.args([
"artifact",
"cp",
&run_id,
stage_tree_dest.to_str().unwrap(),
"--stage",
"create_colliding@1",
"--tree",
"--server",
&target,
]);
fabro_snapshot!(filters.clone(), cp_stage_tree, @"
success: true
exit_code: 0
----- stdout -----
Copied 2 artifact(s) to [TEMP_DIR]/artifact-stage-tree
----- stderr -----
");
insta::assert_snapshot!(
text_tree(&stage_tree_dest).join("\n"),
@r"
create_colliding/retry_1/assets/other/summary.txt = beta
create_colliding/retry_1/assets/retry/report.txt = third
"
);
let tree_dest = context.temp_dir.join("artifact-tree");
let mut cp_tree = context.command();
cp_tree.args([
"artifact",
"cp",
&run_id,
tree_dest.to_str().unwrap(),
"--tree",
"--server",
&target,
]);
cp_tree.timeout(Duration::from_secs(30));
fabro_snapshot!(filters.clone(), cp_tree, @"
success: true
exit_code: 0
----- stdout -----
Copied 5 artifact(s) to [TEMP_DIR]/artifact-tree
----- stderr -----
");
insta::assert_snapshot!(
text_tree(&tree_dest).join("\n"),
@r"
create_assets/retry_1/assets/node_a/summary.txt = alpha
create_assets/retry_1/assets/shared/report.txt = one
create_colliding/retry_1/assets/other/summary.txt = beta
create_colliding/retry_1/assets/retry/report.txt = third
update_assets/retry_1/assets/retry/report.txt = second
"
);
let ambiguous_dest = context.temp_dir.join("artifact-ambiguous");
let mut cp_ambiguous = context.command();
cp_ambiguous.args([
"artifact",
"cp",
&format!("{run_id}:assets/retry/report.txt"),
ambiguous_dest.to_str().unwrap(),
"--server",
&target,
]);
fabro_snapshot!(filters.clone(), cp_ambiguous, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× Path 'assets/retry/report.txt' matches multiple artifacts: create_colliding@1:retry_1, update_assets@1:retry_1. Use --stage and/or --retry to disambiguate.
");
let flat_dest = context.temp_dir.join("artifact-flat");
let mut cp_flat = context.command();
cp_flat.args([
"artifact",
"cp",
&run_id,
flat_dest.to_str().unwrap(),
"--server",
&target,
]);
fabro_snapshot!(filters, cp_flat, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× Filename collision: 'summary.txt' exists in both create_assets@1:retry_1 and create_colliding@1:retry_1. Use --tree to preserve directory structure, or --stage and/or --retry to filter.
");
server.shutdown();
}