test(cli): prune slow integration outliers
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run

Replace remaining expensive CLI lifecycle checks with seeded fixtures or focused unit coverage so the concurrent suite spends less time on duplicate full-process setup.
This commit is contained in:
Bryan Helmkamp 2026-04-28 19:26:27 -07:00
parent 4ce07aac17
commit 788b389e4c
No known key found for this signature in database
10 changed files with 294 additions and 537 deletions

View file

@ -748,6 +748,8 @@ pub async fn run() -> anyhow::Result<()> {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use fabro_model::Provider;
use serde_json::json;
@ -861,6 +863,16 @@ mod tests {
assert_eq!(profile.provider(), Provider::OpenAi);
}
#[test]
fn ensure_provider_registered_reports_missing_credentials() {
let client = Client::new(HashMap::new(), None, vec![]);
let error = ensure_provider_registered(&client, Provider::Anthropic).unwrap_err();
assert_eq!(
error.to_string(),
"LLM credentials not configured for provider 'anthropic'"
);
}
#[test]
fn build_profile_gemini() {
let profile = build_profile(Provider::Gemini, "model", None);

View file

@ -15,7 +15,7 @@ use fabro_config::user::active_settings_path;
use fabro_util::terminal::Styles;
use tracing::debug;
use crate::args::{GraphArgs, GraphDirection};
use crate::args::{GraphArgs, GraphDirection, GraphOutputFormat};
use crate::command_context::CommandContext;
use crate::commands::run::output::api_diagnostics_to_local;
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest};
@ -68,10 +68,7 @@ pub(crate) async fn run(
std::fs::write(output_path, &rendered)
.with_context(|| format!("writing rendered graph to {}", output_path.display()))?;
if ctx.json_output() {
print_json_pretty(&serde_json::json!({
"path": absolute_or_current(output_path),
"format": args.format.to_string(),
}))?;
print_json_pretty(&output_file_json(output_path, args.format))?;
}
} else {
std::io::stdout().write_all(&rendered)?;
@ -85,3 +82,29 @@ pub(crate) async fn run(
Ok(())
}
fn output_file_json(output_path: &std::path::Path, format: GraphOutputFormat) -> serde_json::Value {
serde_json::json!({
"path": absolute_or_current(output_path),
"format": format.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_file_json_reports_absolute_path_and_format() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graph.svg");
assert_eq!(
output_file_json(&path, GraphOutputFormat::Svg),
serde_json::json!({
"path": path,
"format": "svg",
})
);
}
}

View file

@ -1,6 +1,8 @@
use fabro_test::{fabro_snapshot, test_context};
use super::support::{git_filters, setup_git_backed_noop_run, setup_seeded_git_backed_changed_run};
use super::support::{
git_filters, setup_seeded_git_backed_changed_run, setup_seeded_git_backed_noop_run,
};
#[test]
fn help() {
@ -34,9 +36,9 @@ fn help() {
#[test]
fn diff_completed_run_without_changes_reports_no_patch() {
let context = test_context!();
let setup = setup_git_backed_noop_run(&context);
let run = setup_seeded_git_backed_noop_run(&context);
let mut cmd = context.command();
cmd.args(["diff", &setup.run.run_id]);
cmd.args(["diff", &run.run_id]);
fabro_snapshot!(git_filters(&context), cmd, @"
success: false

View file

@ -90,23 +90,6 @@ fn no_prompt() {
");
}
#[test]
fn exec_missing_api_key_exits_with_error() {
let context = test_context!();
let mut cmd = context.exec_cmd();
cmd.arg("test prompt");
cmd.env_clear();
preserve_coverage_env!(cmd);
cmd.env("HOME", &context.home_dir);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× LLM credentials not configured for provider 'anthropic'
");
}
#[test]
fn exec_uses_user_config_defaults() {
let context = test_context!();

View file

@ -205,37 +205,6 @@ fn graph_json_without_output_is_rejected() {
assert!(stderr.contains("--json is not supported for this command"));
}
#[test]
fn graph_json_with_output_reports_file() {
let context = test_context!();
context.ensure_home_server_auth_methods();
let output_path = context.temp_dir.join("graph.svg");
let workflow = fixture("simple.fabro");
let output = context
.command()
.args([
"--json",
"graph",
workflow.to_str().unwrap(),
"--output",
output_path.to_str().unwrap(),
])
.output()
.expect("command should run");
assert!(
output.status.success(),
"stdout:\n{}\nstderr:\n{}",
output_stdout(&output),
output_stderr(&output)
);
let value: Value = serde_json::from_slice(&output.stdout).expect("graph JSON should parse");
assert_eq!(value["format"], "svg");
assert_eq!(value["path"], output_path.to_string_lossy().to_string());
assert!(output_path.exists());
}
#[test]
fn secret_list_json_missing_env_outputs_json_array() {
let context = test_context!();

View file

@ -1,12 +1,5 @@
#![expect(
clippy::disallowed_methods,
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use fabro_test::{fabro_snapshot, test_context};
use super::support::{git_stdout, output_stderr, run_state_by_id, setup_git_backed_changed_run};
#[test]
fn help() {
let context = test_context!();
@ -54,74 +47,3 @@ fn resume_requires_run_arg() {
For more information, try '--help'.
");
}
#[test]
fn resume_rewound_run_succeeds() {
let context = test_context!();
let setup = setup_git_backed_changed_run(&context);
let new_run_id = rewind_replacement_run_id(&context, &setup);
let rewound_head = run_state_by_id(&context, &new_run_id)
.checkpoint
.and_then(|checkpoint| checkpoint.git_commit_sha)
.expect("rewound run should record checkpoint sha");
let mut resume_cmd = context.command();
resume_cmd.current_dir(&setup.repo_dir);
resume_cmd.env("OPENAI_API_KEY", "test");
resume_cmd.args(["resume", &new_run_id]);
let resume_output = resume_cmd.output().expect("resume should execute");
assert!(
resume_output.status.success(),
"resume should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&resume_output.stdout),
output_stderr(&resume_output)
);
assert_eq!(
git_stdout(&setup.repo_dir, &[
"show",
&format!("fabro/run/{new_run_id}:story.txt")
]),
"line 1\nline 2\nline 3\n"
);
assert_eq!(
std::fs::read_to_string(setup.repo_dir.join("story.txt")).unwrap(),
"line 1\n"
);
let resumed_head = git_stdout(&setup.repo_dir, &[
"rev-parse",
&format!("fabro/run/{new_run_id}"),
]);
assert_ne!(resumed_head.trim(), rewound_head);
}
fn rewind_replacement_run_id(
context: &fabro_test::TestContext,
setup: &super::support::GitRunSetup,
) -> String {
let rewind = context
.command()
.current_dir(&setup.repo_dir)
.args(["rewind", &setup.run.run_id, "@2", "--json"])
.output()
.expect("rewind should execute");
assert!(
rewind.status.success(),
"rewind should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&rewind.stdout),
output_stderr(&rewind)
);
let response: serde_json::Value =
serde_json::from_slice(&rewind.stdout).expect("rewind json should parse");
assert_eq!(
response["source_run_id"].as_str(),
Some(setup.run.run_id.as_str())
);
assert_eq!(response["archived"].as_bool(), Some(true));
response["new_run_id"]
.as_str()
.expect("rewind response should include new_run_id")
.to_string()
}

View file

@ -44,11 +44,6 @@ pub(crate) struct RunSetup {
pub(crate) run_dir: PathBuf,
}
pub(crate) struct GitRunSetup {
pub(crate) run: RunSetup,
pub(crate) repo_dir: PathBuf,
}
pub(crate) struct SeededGitRunSetup {
pub(crate) run: RunSetup,
pub(crate) step_one_sha: String,
@ -68,12 +63,6 @@ pub(crate) struct WorkflowGate {
gate_path: PathBuf,
}
#[derive(Clone, Copy)]
enum GitWorkflowKind {
Changed,
Noop,
}
#[derive(Clone, Copy)]
enum SeededRunState {
Submitted,
@ -295,18 +284,18 @@ pub(crate) fn setup_detached_dry_run(context: &TestContext) -> RunSetup {
run
}
pub(crate) fn setup_git_backed_changed_run(context: &TestContext) -> GitRunSetup {
setup_git_backed_run(context, GitWorkflowKind::Changed)
}
pub(crate) fn setup_git_backed_noop_run(context: &TestContext) -> GitRunSetup {
setup_git_backed_run(context, GitWorkflowKind::Noop)
}
pub(crate) fn setup_seeded_git_backed_changed_run(context: &TestContext) -> SeededGitRunSetup {
block_on(seed_git_backed_changed_run(context))
}
pub(crate) fn setup_seeded_git_backed_noop_run(context: &TestContext) -> RunSetup {
block_on(seed_git_backed_noop_run(context))
}
pub(crate) fn setup_seeded_artifact_run(context: &TestContext) -> RunSetup {
block_on(seed_artifact_run(context))
}
pub(crate) fn setup_project_fixture(context: &TestContext) -> ProjectFixture {
let project_dir = context.temp_dir.join("project");
let fabro_root = project_dir.join(".fabro");
@ -325,51 +314,6 @@ impl WorkflowGate {
}
}
pub(crate) fn setup_artifact_run(context: &TestContext) -> WorkspaceRunSetup {
let workspace_dir = context.temp_dir.join("artifact-run");
std::fs::create_dir_all(&workspace_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", workspace_dir.display()));
write_text_file(
&workspace_dir.join("artifact_run.fabro"),
r#"digraph ArtifactRun {
graph [goal="Exercise artifact commands", default_max_retries=0]
start [shape=Mdiamond]
exit [shape=Msquare]
create_assets [shape=parallelogram, script="mkdir -p assets/shared assets/node_a && printf one > assets/shared/report.txt && printf alpha > assets/node_a/summary.txt", max_retries=0]
retry_assets [shape=parallelogram, script="mkdir -p assets/retry && touch -c -t 200001010000 assets/shared/report.txt assets/node_a/summary.txt && if [ ! -f .retry-sentinel ]; then printf first > assets/retry/report.txt && touch .retry-sentinel && sleep 1; else printf second > assets/retry/report.txt; fi", retry_policy="linear", timeout="500ms"]
create_colliding [shape=parallelogram, script="mkdir -p assets/other assets/retry && touch -c -t 200001010000 assets/shared/report.txt assets/node_a/summary.txt assets/retry/report.txt && printf beta > assets/other/summary.txt && printf second > assets/retry/report.txt", max_retries=0]
start -> create_assets -> retry_assets -> create_colliding -> exit
}
"#,
);
write_text_file(
&workspace_dir.join("run.toml"),
r#"_version = 1
[workflow]
graph = "artifact_run.fabro"
[run]
goal = "Exercise artifact commands"
[run.sandbox]
provider = "local"
preserve = true
[run.sandbox.local]
worktree_mode = "never"
[run.artifacts]
include = ["assets/**"]
"#,
);
let run = run_local_workflow(context, &workspace_dir, "run.toml");
WorkspaceRunSetup { run, workspace_dir }
}
pub(crate) fn setup_local_sandbox_run(context: &TestContext) -> WorkspaceRunSetup {
let workspace_dir = context.temp_dir.join("local-sandbox");
std::fs::create_dir_all(&workspace_dir)
@ -853,6 +797,69 @@ async fn seed_git_backed_changed_run(context: &TestContext) -> SeededGitRunSetup
}
}
async fn seed_git_backed_noop_run(context: &TestContext) -> RunSetup {
let base_sha = "1111111111111111111111111111111111111111";
let run = create_seeded_run(
context,
"flow.fabro",
noop_git_workflow_source(),
serde_json::json!({
"provider": "openai",
"sandbox": "local",
"no_retro": true,
"label": test_labels(context),
}),
Some(serde_json::json!({
"origin_url": "https://github.com/fabro-sh/seeded-fixture.git",
"branch": "main",
"sha": base_sha,
"dirty": "clean",
"push_outcome": {
"type": "succeeded",
"remote": "origin",
"branch": "main",
},
})),
)
.await;
let (client, base_url) = server_endpoint(&context.storage_dir)
.expect("test server endpoint should be available for seeded run events");
append_seeded_git_noop_events(&client, &base_url, &run, context, base_sha).await;
run
}
async fn seed_artifact_run(context: &TestContext) -> RunSetup {
let run = create_seeded_run(
context,
"artifact_run.fabro",
artifact_workflow_source(),
serde_json::json!({
"sandbox": "local",
"no_retro": true,
"label": test_labels(context),
}),
None,
)
.await;
let (client, base_url) = server_endpoint(&context.storage_dir)
.expect("test server endpoint should be available for seeded artifacts");
append_seeded_artifact_run_events(&client, &base_url, &run, context).await;
for (stage_id, path, contents) in [
("create_assets@1", "assets/node_a/summary.txt", "alpha"),
("create_assets@1", "assets/shared/report.txt", "one"),
("create_colliding@1", "assets/other/summary.txt", "beta"),
("create_colliding@1", "assets/retry/report.txt", "second"),
("retry_assets@1", "assets/retry/report.txt", "first"),
("retry_assets@2", "assets/retry/report.txt", "second"),
] {
upload_seeded_artifact(&client, &base_url, &run.run_id, stage_id, path, contents).await;
}
run
}
async fn create_seeded_run(
context: &TestContext,
target_path: &str,
@ -1205,6 +1212,152 @@ async fn append_seeded_git_completion_events(
.await;
}
async fn append_seeded_git_noop_events(
client: &fabro_http::HttpClient,
base_url: &str,
run: &RunSetup,
context: &TestContext,
base_sha: &str,
) {
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.started",
serde_json::json!({
"name": "Flow",
"base_branch": "main",
"base_sha": base_sha,
"run_branch": format!("fabro/run/{}", run.run_id),
"worktree_dir": context.temp_dir.display().to_string(),
"goal": "Leave tracked files unchanged",
}),
)
.await;
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.starting",
serde_json::json!({}),
)
.await;
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.running",
serde_json::json!({}),
)
.await;
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.completed",
serde_json::json!({
"duration_ms": 123,
"artifact_count": 0,
"status": "success",
"reason": "completed",
"total_usd_micros": null,
"final_git_commit_sha": base_sha,
"final_patch": null,
"billing": null,
}),
)
.await;
}
async fn append_seeded_artifact_run_events(
client: &fabro_http::HttpClient,
base_url: &str,
run: &RunSetup,
context: &TestContext,
) {
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.started",
serde_json::json!({
"name": "ArtifactRun",
"base_branch": null,
"base_sha": null,
"run_branch": null,
"worktree_dir": context.temp_dir.display().to_string(),
"goal": "Exercise artifact commands",
}),
)
.await;
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.starting",
serde_json::json!({}),
)
.await;
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.running",
serde_json::json!({}),
)
.await;
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.completed",
serde_json::json!({
"duration_ms": 123,
"artifact_count": 6,
"status": "success",
"reason": "completed",
"total_usd_micros": null,
"final_git_commit_sha": null,
"final_patch": null,
"billing": null,
}),
)
.await;
}
async fn upload_seeded_artifact(
client: &fabro_http::HttpClient,
base_url: &str,
run_id: &str,
stage_id: &str,
path: &str,
contents: &str,
) {
let response = client
.post(format!(
"{base_url}/api/v1/runs/{run_id}/stages/{stage_id}/artifacts?filename={path}"
))
.header(fabro_http::header::CONTENT_TYPE, "application/octet-stream")
.body(contents.to_string())
.send()
.await
.unwrap_or_else(|err| panic!("seeded artifact upload should execute: {err}"));
expect_reqwest_status(
response,
fabro_http::StatusCode::NO_CONTENT,
format!("POST /api/v1/runs/{run_id}/stages/{stage_id}/artifacts ({path})"),
)
.await;
}
async fn append_seeded_stage(
client: &fabro_http::HttpClient,
base_url: &str,
@ -1402,6 +1555,30 @@ fn changed_git_workflow_source() -> &'static str {
"#
}
fn noop_git_workflow_source() -> &'static str {
r#"digraph Flow {
graph [goal="Leave tracked files unchanged"];
start [shape=Mdiamond];
exit [shape=Msquare];
check [shape=parallelogram, script="test -f story.txt"];
start -> check -> exit;
}
"#
}
fn artifact_workflow_source() -> &'static str {
r#"digraph ArtifactRun {
graph [goal="Exercise artifact commands", default_max_retries=0]
start [shape=Mdiamond]
exit [shape=Msquare]
create_assets [shape=parallelogram, script="true", max_retries=0]
retry_assets [shape=parallelogram, script="true", retry_policy="linear", timeout="500ms"]
create_colliding [shape=parallelogram, script="true", max_retries=0]
start -> create_assets -> retry_assets -> create_colliding -> exit
}
"#
}
fn step_one_patch() -> &'static str {
"diff --git a/story.txt b/story.txt\nindex 1111111..2222222 100644\n--- a/story.txt\n+++ b/story.txt\n@@ -1 +1,2 @@\n line 1\n+line 2\n"
}
@ -1414,10 +1591,6 @@ fn final_story_patch() -> &'static str {
"diff --git a/story.txt b/story.txt\nindex 1111111..3333333 100644\n--- a/story.txt\n+++ b/story.txt\n@@ -1 +1,3 @@\n line 1\n+line 2\n+line 3\n"
}
pub(crate) fn git_stdout(repo_dir: &Path, args: &[&str]) -> String {
stdout(&git_success(repo_dir, args))
}
pub(crate) fn text_tree(root: &Path) -> Vec<String> {
fn visit(root: &Path, dir: &Path, entries: &mut Vec<String>) {
let mut children: Vec<_> = std::fs::read_dir(dir)
@ -1579,146 +1752,6 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value {
)
}
fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> GitRunSetup {
let repo_dir = context.temp_dir.join(match workflow {
GitWorkflowKind::Changed => "git-changed",
GitWorkflowKind::Noop => "git-noop",
});
std::fs::create_dir_all(&repo_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", repo_dir.display()));
git_success(&repo_dir, &["init", "-q"]);
git_success(&repo_dir, &["config", "user.name", "Fabro Test"]);
git_success(&repo_dir, &["config", "user.email", "test@example.com"]);
write_text_file(&repo_dir.join("story.txt"), "line 1\n");
write_text_file(&repo_dir.join("flow.fabro"), match workflow {
GitWorkflowKind::Changed => {
r#"digraph Flow {
graph [goal="Edit a tracked file"];
start [shape=Mdiamond];
exit [shape=Msquare];
step_one [shape=parallelogram, script="printf 'line 1\nline 2\n' > story.txt"];
step_two [shape=parallelogram, script="printf 'line 1\nline 2\nline 3\n' > story.txt"];
start -> step_one -> step_two -> exit;
}
"#
}
GitWorkflowKind::Noop => {
r#"digraph Flow {
graph [goal="Leave tracked files unchanged"];
start [shape=Mdiamond];
exit [shape=Msquare];
check [shape=parallelogram, script="test -f story.txt"];
start -> check -> exit;
}
"#
}
});
git_success(&repo_dir, &["add", "story.txt", "flow.fabro"]);
git_success(&repo_dir, &["commit", "-qm", "init"]);
let remote_dir = context.temp_dir.join(match workflow {
GitWorkflowKind::Changed => "git-changed-remote.git",
GitWorkflowKind::Noop => "git-noop-remote.git",
});
let remote_dir_str = remote_dir.display().to_string();
git_success(&context.temp_dir, &[
"init",
"--bare",
"-q",
&remote_dir_str,
]);
git_success(&repo_dir, &["remote", "add", "origin", &remote_dir_str]);
git_success(&repo_dir, &["push", "-u", "origin", "HEAD:main"]);
let base_sha = git_stdout(&repo_dir, &["rev-parse", "HEAD"])
.trim()
.to_string();
let run_id = unique_run_id();
let mut cmd = context.run_cmd();
cmd.current_dir(&repo_dir);
cmd.env("OPENAI_API_KEY", "test");
cmd.args([
"--run-id",
run_id.as_str(),
"--sandbox",
"local",
"--no-retro",
"--provider",
"openai",
"flow.fabro",
]);
let output = cmd.output().expect("command should execute");
if !output.status.success() {
panic!(
"command failed: fabro run --sandbox local --no-retro --provider openai flow.fabro\nstdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
}
let run = RunSetup {
run_dir: context.find_run_dir(&run_id),
run_id,
};
let start = serde_json::to_value(
run_state(&run.run_dir)
.start
.expect("start record should exist"),
)
.expect("start record should serialize to JSON");
assert_eq!(
start["run_branch"].as_str(),
Some(format!("fabro/run/{}", run.run_id).as_str())
);
assert_eq!(start["base_sha"].as_str(), Some(base_sha.as_str()));
match workflow {
GitWorkflowKind::Changed => {
assert!(
run_state(&run.run_dir).final_patch.is_some(),
"changed git-backed run should persist final patch in store"
);
let state = run_state(&run.run_dir);
assert!(
state
.iter_nodes()
.any(|(node, state)| node.node_id() == "step_one" && state.diff.is_some())
);
assert!(
state
.iter_nodes()
.any(|(node, state)| node.node_id() == "step_two" && state.diff.is_some())
);
}
GitWorkflowKind::Noop => {
assert!(
run_state(&run.run_dir).final_patch.is_none(),
"no-op git-backed run should not persist final.patch"
);
}
}
GitRunSetup { run, repo_dir }
}
fn git_success(repo_dir: &Path, args: &[&str]) -> Output {
let output = std::process::Command::new("git")
.current_dir(repo_dir)
.args(args)
.output()
.expect("git command should execute");
if !output.status.success() {
panic!(
"git command failed: git {}\nstdout:\n{}\nstderr:\n{}",
args.join(" "),
stdout(&output),
stderr(&output)
);
}
output
}
fn write_text_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)

View file

@ -2,7 +2,7 @@ use std::time::Duration;
use fabro_test::{fabro_snapshot, test_context};
use crate::cmd::support::{read_text, setup_artifact_run, text_tree};
use crate::cmd::support::{read_text, setup_seeded_artifact_run, text_tree};
fn artifact_filters(context: &fabro_test::TestContext) -> Vec<(String, String)> {
let mut filters = context.filters();
@ -16,11 +16,11 @@ fn artifact_filters(context: &fabro_test::TestContext) -> Vec<(String, String)>
#[test]
fn artifact_commands_share_populated_run_fixture() {
let context = test_context!();
let setup = setup_artifact_run(&context);
let run = setup_seeded_artifact_run(&context);
let filters = artifact_filters(&context);
let mut list_json = context.command();
list_json.args(["artifact", "list", &setup.run.run_id, "--json"]);
list_json.args(["artifact", "list", &run.run_id, "--json"]);
fabro_snapshot!(filters.clone(), list_json, @r#"
success: true
exit_code: 0
@ -70,7 +70,7 @@ fn artifact_commands_share_populated_run_fixture() {
list_filtered.args([
"artifact",
"list",
&setup.run.run_id,
&run.run_id,
"--node",
"retry_assets",
"--retry",
@ -97,7 +97,7 @@ fn artifact_commands_share_populated_run_fixture() {
cp_single.args([
"artifact",
"cp",
&format!("{}:assets/shared/report.txt", setup.run.run_id),
&format!("{}:assets/shared/report.txt", run.run_id),
single_dest.to_str().unwrap(),
"--node",
"create_assets",
@ -116,7 +116,7 @@ fn artifact_commands_share_populated_run_fixture() {
cp_tree.args([
"artifact",
"cp",
&setup.run.run_id,
&run.run_id,
tree_dest.to_str().unwrap(),
"--tree",
]);
@ -145,7 +145,7 @@ fn artifact_commands_share_populated_run_fixture() {
cp_ambiguous.args([
"artifact",
"cp",
&format!("{}:assets/retry/report.txt", setup.run.run_id),
&format!("{}:assets/retry/report.txt", run.run_id),
ambiguous_dest.to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cp_ambiguous, @"
@ -158,12 +158,7 @@ fn artifact_commands_share_populated_run_fixture() {
let flat_dest = context.temp_dir.join("artifact-flat");
let mut cp_flat = context.command();
cp_flat.args([
"artifact",
"cp",
&setup.run.run_id,
flat_dest.to_str().unwrap(),
]);
cp_flat.args(["artifact", "cp", &run.run_id, flat_dest.to_str().unwrap()]);
fabro_snapshot!(context.filters(), cp_flat, @"
success: false
exit_code: 1

View file

@ -8,7 +8,6 @@ mod artifacts;
mod auth;
mod exec;
mod lifecycle;
mod recovery;
mod server_lifecycle;
mod smoke;

View file

@ -1,181 +0,0 @@
#![expect(
clippy::disallowed_methods,
reason = "This recovery scenario test uses the real git CLI to set up repository history for end-to-end assertions."
)]
use std::collections::BTreeSet;
use std::path::Path;
use fabro_test::{fabro_snapshot, test_context};
use git2::Repository;
use crate::support::unique_run_id;
fn list_metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
let repo = Repository::discover(repo_dir).expect("recovery fixture should be a git repo");
repo.references()
.expect("recovery fixture should list git references")
.flatten()
.filter_map(|reference| reference.name().map(ToOwned::to_owned))
.filter_map(|name| {
name.strip_prefix("refs/heads/fabro/meta/")
.map(ToOwned::to_owned)
})
.collect()
}
#[expect(
clippy::disallowed_methods,
reason = "This sync git integration helper retries metadata branch deletion until libgit2 releases its lock."
)]
fn delete_metadata_branch_when_ready(repo_dir: &Path, run_id: &str) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let repo = Repository::discover(repo_dir).expect("recovery fixture should stay a git repo");
let mut reference = repo
.find_reference(&format!("refs/heads/fabro/meta/{run_id}"))
.expect("metadata branch should exist");
match reference.delete() {
Ok(()) => return,
Err(err) => {
assert!(
std::time::Instant::now() < deadline,
"metadata branch for {run_id} never became writable: {err}"
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
}
}
fn init_repo_with_workflow(repo_dir: &Path) {
std::fs::write(repo_dir.join("README.md"), "recovery test\n")
.expect("recovery README fixture should write");
std::fs::write(
repo_dir.join("workflow.fabro"),
"\
digraph Recovery {
start [shape=Mdiamond, label=\"Start\"]
exit [shape=Msquare, label=\"Exit\"]
plan [label=\"Plan\", shape=parallelogram, script=\"echo plan\"]
build [label=\"Build\", shape=parallelogram, script=\"echo build\"]
start -> plan -> build -> exit
}
",
)
.expect("recovery workflow fixture should write");
let init = std::process::Command::new("git")
.args(["init"])
.current_dir(repo_dir)
.status()
.expect("git init should launch");
assert!(init.success(), "git init should succeed");
let add = std::process::Command::new("git")
.args(["add", "README.md", "workflow.fabro"])
.current_dir(repo_dir)
.status()
.expect("git add should launch");
assert!(add.success(), "git add should succeed");
let commit = std::process::Command::new("git")
.args([
"-c",
"user.name=Fabro",
"-c",
"user.email=noreply@fabro.sh",
"commit",
"-m",
"init",
])
.current_dir(repo_dir)
.status()
.expect("git commit should launch");
assert!(commit.success(), "git commit should succeed");
let remote_dir = repo_dir
.parent()
.expect("recovery repo should have a parent")
.join(format!(
"{}-remote.git",
repo_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("recovery")
));
let remote_init = std::process::Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&remote_dir)
.status()
.expect("git init --bare should launch");
assert!(remote_init.success(), "git init --bare should succeed");
let remote_add = std::process::Command::new("git")
.args(["remote", "add", "origin"])
.arg(&remote_dir)
.current_dir(repo_dir)
.status()
.expect("git remote add should launch");
assert!(remote_add.success(), "git remote add should succeed");
let push = std::process::Command::new("git")
.args(["push", "-u", "origin", "HEAD:main"])
.current_dir(repo_dir)
.status()
.expect("git push should launch");
assert!(push.success(), "git push should succeed");
}
#[test]
fn rewind_list_reports_empty_timeline_when_metadata_branch_is_missing() {
let context = test_context!();
context.ensure_home_server_auth_methods();
let repo_dir = tempfile::tempdir().unwrap();
let source_run_id = unique_run_id();
init_repo_with_workflow(repo_dir.path());
context
.command()
.current_dir(repo_dir.path())
.args([
"run",
"--dry-run",
"--no-retro",
"--sandbox",
"local",
"--run-id",
source_run_id.as_str(),
"workflow.fabro",
])
.assert()
.success();
delete_metadata_branch_when_ready(repo_dir.path(), &source_run_id);
assert!(
list_metadata_run_ids(repo_dir.path()).is_empty(),
"metadata branch should start missing"
);
let mut rewind_list = context.command();
rewind_list.current_dir(repo_dir.path());
rewind_list.args(["rewind", &source_run_id, "--list"]);
rewind_list.timeout(std::time::Duration::from_secs(15));
fabro_snapshot!(context.filters(), rewind_list, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
@ Node Details
@1 start (no run commit)
@2 plan
@3 build
");
assert!(
list_metadata_run_ids(repo_dir.path()).is_empty(),
"server timeline should not rebuild missing metadata"
);
}