diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index c6308ec6b..363e88051 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -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); diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index 4cfab7f13..4e2297645 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -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", + }) + ); + } +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/diff.rs b/lib/crates/fabro-cli/tests/it/cmd/diff.rs index c7579817c..febbb3f4d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/diff.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/diff.rs @@ -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 diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index add3ac73d..6f7e730b0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -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!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs index 245a3d151..5c94712e3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs @@ -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!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/resume.rs b/lib/crates/fabro-cli/tests/it/cmd/resume.rs index b8c9bd789..c1c72856a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/resume.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/resume.rs @@ -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() -} diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 66a2cbf7d..a6f37c683 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -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 { fn visit(root: &Path, dir: &Path, entries: &mut Vec) { 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) diff --git a/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs b/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs index 1ba5e9539..b5d5b006f 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs @@ -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 diff --git a/lib/crates/fabro-cli/tests/it/scenario/mod.rs b/lib/crates/fabro-cli/tests/it/scenario/mod.rs index aba9686f5..ec7320e43 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/mod.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/mod.rs @@ -8,7 +8,6 @@ mod artifacts; mod auth; mod exec; mod lifecycle; -mod recovery; mod server_lifecycle; mod smoke; diff --git a/lib/crates/fabro-cli/tests/it/scenario/recovery.rs b/lib/crates/fabro-cli/tests/it/scenario/recovery.rs deleted file mode 100644 index fb6a4a085..000000000 --- a/lib/crates/fabro-cli/tests/it/scenario/recovery.rs +++ /dev/null @@ -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 { - 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" - ); -}