From 73cc431d05beb8294d80e92d887ccb0f205373a7 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 30 Mar 2026 18:23:24 -0400 Subject: [PATCH] Add CLI integration test coverage --- lib/crates/fabro-cli/src/commands/asset/cp.rs | 23 +- lib/crates/fabro-cli/src/commands/run/logs.rs | 49 +- .../fabro-cli/src/commands/run/rewind.rs | 43 + .../fabro-cli/src/commands/run/start.rs | 29 +- .../fabro-cli/src/commands/system/prune.rs | 5 +- lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs | 117 +++ .../fabro-cli/tests/it/cmd/asset_list.rs | 120 +++ lib/crates/fabro-cli/tests/it/cmd/attach.rs | 55 ++ lib/crates/fabro-cli/tests/it/cmd/cp.rs | 92 +++ lib/crates/fabro-cli/tests/it/cmd/create.rs | 121 +++ lib/crates/fabro-cli/tests/it/cmd/diff.rs | 80 ++ lib/crates/fabro-cli/tests/it/cmd/exec.rs | 25 + lib/crates/fabro-cli/tests/it/cmd/fork.rs | 125 ++- lib/crates/fabro-cli/tests/it/cmd/graph.rs | 20 + lib/crates/fabro-cli/tests/it/cmd/inspect.rs | 128 +++ lib/crates/fabro-cli/tests/it/cmd/logs.rs | 155 ++++ lib/crates/fabro-cli/tests/it/cmd/mod.rs | 1 + .../fabro-cli/tests/it/cmd/model_test.rs | 15 + lib/crates/fabro-cli/tests/it/cmd/parse.rs | 109 +++ .../fabro-cli/tests/it/cmd/pr_create.rs | 35 + lib/crates/fabro-cli/tests/it/cmd/pr_list.rs | 15 + lib/crates/fabro-cli/tests/it/cmd/pr_view.rs | 19 + .../fabro-cli/tests/it/cmd/preflight.rs | 22 + lib/crates/fabro-cli/tests/it/cmd/preview.rs | 18 + lib/crates/fabro-cli/tests/it/cmd/ps.rs | 161 ++++ .../fabro-cli/tests/it/cmd/repo_init.rs | 114 +++ lib/crates/fabro-cli/tests/it/cmd/resume.rs | 66 ++ lib/crates/fabro-cli/tests/it/cmd/rewind.rs | 94 ++- lib/crates/fabro-cli/tests/it/cmd/rm.rs | 113 +++ lib/crates/fabro-cli/tests/it/cmd/ssh.rs | 18 + lib/crates/fabro-cli/tests/it/cmd/start.rs | 67 ++ .../fabro-cli/tests/it/cmd/store_dump.rs | 87 +++ lib/crates/fabro-cli/tests/it/cmd/support.rs | 735 ++++++++++++++++++ .../fabro-cli/tests/it/cmd/system_df.rs | 70 ++ .../fabro-cli/tests/it/cmd/system_prune.rs | 85 ++ lib/crates/fabro-cli/tests/it/cmd/upgrade.rs | 36 + lib/crates/fabro-cli/tests/it/cmd/wait.rs | 64 ++ .../fabro-cli/tests/it/cmd/workflow_create.rs | 127 +++ .../fabro-cli/tests/it/cmd/workflow_list.rs | 53 ++ lib/crates/fabro-test/src/lib.rs | 12 + lib/crates/fabro-workflow/src/assets.rs | 7 + .../fabro-workflow/src/operations/start.rs | 19 +- .../fabro-workflow/src/pipeline/initialize.rs | 21 +- 43 files changed, 3343 insertions(+), 27 deletions(-) create mode 100644 lib/crates/fabro-cli/tests/it/cmd/support.rs diff --git a/lib/crates/fabro-cli/src/commands/asset/cp.rs b/lib/crates/fabro-cli/src/commands/asset/cp.rs index a4e90f5b3..022ba6fd6 100644 --- a/lib/crates/fabro-cli/src/commands/asset/cp.rs +++ b/lib/crates/fabro-cli/src/commands/asset/cp.rs @@ -95,10 +95,10 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> .into_owned(); if let Some((_, existing)) = by_filename.iter().find(|(name, _)| name == &filename) { bail!( - "Filename collision: '{}' exists in both node '{}' and '{}'. Use --tree to preserve directory structure, or --node to filter.", + "Filename collision: '{}' exists in both {} and {}. Use --tree to preserve directory structure, or --node and/or --retry to filter.", filename, - existing.node_slug, - entry.node_slug + format_candidate(existing), + format_candidate(entry) ); } by_filename.push((filename, entry)); @@ -134,6 +134,10 @@ fn parse_source(source: &str) -> (&str, Option<&str>) { } } +fn format_candidate(entry: &AssetEntry) -> String { + format!("{}:retry_{}", entry.node_slug, entry.retry) +} + #[cfg(test)] mod tests { use super::*; @@ -165,4 +169,17 @@ mod tests { assert_eq!(id, "./foo"); assert_eq!(path, None); } + + #[test] + fn format_candidate_includes_retry() { + let entry = AssetEntry { + node_slug: "retry_assets".to_string(), + retry: 2, + relative_path: "assets/retry/report.txt".to_string(), + absolute_path: PathBuf::from("/tmp/report.txt"), + size: 6, + }; + + assert_eq!(format_candidate(&entry), "retry_assets:retry_2"); + } } diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index c3ad02f10..6650b63b1 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -251,6 +251,7 @@ async fn follow_store_logs( .context("Failed to watch store-backed run events")?; let stdout = io::stdout(); let mut out = stdout.lock(); + let mut next_seq = seq; loop { match time::timeout(Duration::from_millis(200), stream.next()).await { @@ -264,25 +265,25 @@ async fn follow_store_logs( writeln!(out, "{line}")?; } out.flush()?; + next_seq = event.seq.saturating_add(1); } Ok(Some(Err(err))) => return Err(err.into()), Ok(None) => break, Err(_) => { - if run_store + let concluded = run_store .get_conclusion() .await .context("Failed to read conclusion from store while following logs")? .is_some() - { - debug!("Run concluded, stopping follow"); - break; - } - if run_store - .get_status() - .await - .context("Failed to read status from store while following logs")? - .is_some_and(|record| record.status.is_terminal()) - { + || run_store + .get_status() + .await + .context("Failed to read status from store while following logs")? + .is_some_and(|record| record.status.is_terminal()); + + if concluded { + flush_remaining_store_events(run_store, next_seq, pretty, styles, &mut out) + .await?; debug!("Run reached terminal status, stopping follow"); break; } @@ -293,6 +294,32 @@ async fn follow_store_logs( Ok(()) } +async fn flush_remaining_store_events( + run_store: &dyn RunStore, + next_seq: u32, + pretty: bool, + styles: &Styles, + out: &mut dyn Write, +) -> Result<()> { + let events = run_store + .list_events() + .await + .context("Failed to list store-backed run events while finalizing follow")?; + + for event in events.into_iter().filter(|event| event.seq >= next_seq) { + let line = event_payload_line(&event)?; + if pretty { + if let Some(formatted) = format_event_pretty(&line, styles) { + writeln!(out, "{formatted}")?; + } + } else { + writeln!(out, "{line}")?; + } + } + out.flush()?; + Ok(()) +} + fn event_payload_line(event: &fabro_store::EventEnvelope) -> Result { serde_json::to_string(event.payload.as_value()).map_err(Into::into) } diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index 3b8c2b4b3..e4c03f152 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -5,10 +5,14 @@ use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_config::FabroSettingsExt; use fabro_git_storage::gitobj::Store; use fabro_util::terminal::Styles; +use fabro_workflow::git::MetadataStore; use fabro_workflow::operations::{ RewindInput, RewindTarget, RunTimeline, build_timeline_or_rebuild, find_run_id_by_prefix_or_store, rewind, }; +use fabro_workflow::records::CheckpointExt; +use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; +use fabro_workflow::run_status::{self, RunStatus}; use git2::Repository; use crate::args::{GlobalArgs, RewindArgs}; @@ -24,6 +28,13 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs find_run_id_by_prefix_or_store(&repo, durable_store.as_ref(), &args.run_id).await?; let store = Store::new(repo); let run_store = open_run_reader(&cli_settings.storage_dir(), &run_id).await?; + let run_info = resolve_run_combined( + durable_store.as_ref(), + &runs_base(&cli_settings.storage_dir()), + &run_id.to_string(), + ) + .await + .ok(); let timeline = build_timeline_or_rebuild(&store, run_store.as_deref(), &run_id).await?; @@ -42,6 +53,9 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs push: !args.no_push, }, )?; + if let Some(run_info) = run_info.as_ref() { + reset_rewound_run_state(&store, durable_store.as_ref(), &run_id, &run_info.path).await?; + } let run_id_string = run_id.to_string(); @@ -53,6 +67,35 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs Ok(()) } +async fn reset_rewound_run_state( + git_store: &Store, + durable_store: &dyn fabro_store::Store, + run_id: &fabro_types::RunId, + run_dir: &std::path::Path, +) -> Result<()> { + let checkpoint = MetadataStore::read_checkpoint(git_store.repo_dir(), &run_id.to_string())? + .context("rewound metadata branch is missing checkpoint.json")?; + checkpoint.save(&run_dir.join("checkpoint.json"))?; + run_status::write_run_status(run_dir, RunStatus::Submitted, None); + + for name in [ + "conclusion.json", + "pull_request.json", + "detached_failure.json", + "progress.jsonl", + "retro.json", + "final.patch", + ] { + let _ = std::fs::remove_file(run_dir.join(name)); + } + + durable_store + .delete_run(run_id) + .await + .map_err(|err| anyhow::anyhow!("failed to reset durable store run: {err}"))?; + Ok(()) +} + pub(crate) fn print_timeline(timeline: &RunTimeline, styles: &Styles) { if timeline.entries.is_empty() { eprintln!("No checkpoints found."); diff --git a/lib/crates/fabro-cli/src/commands/run/start.rs b/lib/crates/fabro-cli/src/commands/run/start.rs index b434b9596..0913d8fea 100644 --- a/lib/crates/fabro-cli/src/commands/run/start.rs +++ b/lib/crates/fabro-cli/src/commands/run/start.rs @@ -1,13 +1,14 @@ use std::path::Path; -use anyhow::{Result, anyhow}; +use anyhow::{Result, anyhow, bail}; use chrono::Utc; use fabro_config::FabroSettingsExt; use fabro_workflow::records::{RunRecord, RunRecordExt}; +use fabro_workflow::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt}; use super::launcher::{ - LauncherRecord, launcher_log_path, launcher_record_path, remove_launcher_record, - write_launcher_record, + LauncherRecord, active_launcher_record_for_run, launcher_log_path, launcher_record_path, + remove_launcher_record, write_launcher_record, }; /// Spawn a detached engine process for the given run directory. @@ -16,6 +17,10 @@ use super::launcher::{ /// workflow. Returns the child process handle (use `.id()` for the PID). #[allow(unsafe_code)] pub(crate) fn start_run(run_dir: &Path, resume: bool) -> Result { + if !resume { + ensure_startable_run(run_dir)?; + } + let record = RunRecord::load(run_dir) .map_err(|e| anyhow!("Cannot start run: failed to load run.json: {e}"))?; @@ -78,6 +83,24 @@ pub(crate) fn start_run(run_dir: &Path, resume: bool) -> Result Result<()> { + if active_launcher_record_for_run(run_dir).is_some() { + bail!("an engine process is still running for this run — cannot start"); + } + + let status_path = run_dir.join("status.json"); + if let Ok(record) = RunStatusRecord::load(&status_path) { + if !matches!(record.status, RunStatus::Submitted | RunStatus::Starting) { + bail!( + "cannot start run: status is {:?}, expected submitted", + record.status + ); + } + } + + Ok(()) +} + fn kill_child_best_effort(child: &mut std::process::Child) { let _ = child.kill(); let _ = child.wait(); diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index 660332b61..34dcee37a 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -61,15 +61,14 @@ async fn prune_from(args: &RunsPruneArgs, store: &dyn Store, base: &Path) -> Res if let Some(threshold) = staleness_threshold { let cutoff = Utc::now() - threshold; filtered.retain(|run| { - if run.status.is_active() { - return false; - } run.end_time .or(run.start_time_dt) .is_some_and(|time| time < cutoff) }); } + filtered.retain(|run| !run.status.is_active()); + if filtered.is_empty() { eprintln!("No matching runs to prune."); return Ok(()); diff --git a/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs index ae1ce05be..ea21e5fd8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{read_text, setup_asset_sandbox_run, setup_completed_dry_run, text_tree}; + #[test] fn help() { let context = test_context!(); @@ -30,3 +32,118 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn asset_cp_empty_run_reports_no_assets() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let dest = context.temp_dir.join("asset-dest"); + let mut cmd = context.command(); + cmd.args(["asset", "cp", &run.run_id, dest.to_str().unwrap()]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: No assets found for this run + "); +} + +#[test] +fn asset_cp_specific_path_copies_single_asset() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let dest = context.temp_dir.join("asset-one"); + let mut cmd = context.command(); + cmd.args([ + "asset", + "cp", + &format!("{}:assets/shared/report.txt", setup.run.run_id), + dest.to_str().unwrap(), + "--node", + "create_assets", + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Copied assets/shared/report.txt to [TEMP_DIR]/asset-one/report.txt + ----- stderr ----- + "); + assert_eq!(read_text(&dest.join("report.txt")), "one"); +} + +#[test] +fn asset_cp_ambiguous_path_requires_node_or_retry() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let dest = context.temp_dir.join("asset-one"); + let mut cmd = context.command(); + cmd.args([ + "asset", + "cp", + &format!("{}:assets/retry/report.txt", setup.run.run_id), + dest.to_str().unwrap(), + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Path 'assets/retry/report.txt' matches multiple assets: create_colliding:retry_1, retry_assets:retry_1, retry_assets:retry_2. Use --node and/or --retry to disambiguate. + "); +} + +#[test] +fn asset_cp_tree_preserves_structure() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let dest = context.temp_dir.join("asset-tree"); + let mut cmd = context.command(); + cmd.args([ + "asset", + "cp", + &setup.run.run_id, + dest.to_str().unwrap(), + "--tree", + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Copied 6 asset(s) to [TEMP_DIR]/asset-tree + ----- stderr ----- + "); + insta::assert_snapshot!( + text_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 = second + retry_assets/retry_1/assets/retry/report.txt = first + retry_assets/retry_2/assets/retry/report.txt = second + " + ); +} + +#[test] +fn asset_cp_flat_mode_rejects_filename_collisions() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let dest = context.temp_dir.join("asset-flat"); + let mut cmd = context.command(); + cmd.args(["asset", "cp", &setup.run.run_id, dest.to_str().unwrap()]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Filename collision: 'summary.txt' exists in both create_assets:retry_1 and create_colliding:retry_1. Use --tree to preserve directory structure, or --node and/or --retry to filter. + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs b/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs index c398a68fb..376ada4b5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{setup_asset_sandbox_run, setup_completed_dry_run}; + #[test] fn help() { let context = test_context!(); @@ -29,3 +31,121 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn asset_list_empty_run_reports_no_assets() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut cmd = context.command(); + cmd.args(["asset", "list", &run.run_id]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + No assets found for this run. + ----- stderr ----- + "); +} + +#[test] +fn asset_list_json_outputs_entries() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]".to_string(), + "[RUN_DIR]".to_string(), + )); + let mut cmd = context.command(); + cmd.args(["asset", "list", &setup.run.run_id, "--json"]); + + fabro_snapshot!(filters, cmd, @r#" + success: true + exit_code: 0 + ----- stdout ----- + [ + { + "node_slug": "create_assets", + "retry": 1, + "relative_path": "assets/node_a/summary.txt", + "absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_assets/retry_1/assets/node_a/summary.txt", + "size": 5 + }, + { + "node_slug": "create_assets", + "retry": 1, + "relative_path": "assets/shared/report.txt", + "absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_assets/retry_1/assets/shared/report.txt", + "size": 3 + }, + { + "node_slug": "create_colliding", + "retry": 1, + "relative_path": "assets/other/summary.txt", + "absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_colliding/retry_1/assets/other/summary.txt", + "size": 4 + }, + { + "node_slug": "create_colliding", + "retry": 1, + "relative_path": "assets/retry/report.txt", + "absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_colliding/retry_1/assets/retry/report.txt", + "size": 6 + }, + { + "node_slug": "retry_assets", + "retry": 1, + "relative_path": "assets/retry/report.txt", + "absolute_path": "[RUN_DIR]/cache/artifacts/assets/retry_assets/retry_1/assets/retry/report.txt", + "size": 5 + }, + { + "node_slug": "retry_assets", + "retry": 2, + "relative_path": "assets/retry/report.txt", + "absolute_path": "[RUN_DIR]/cache/artifacts/assets/retry_assets/retry_2/assets/retry/report.txt", + "size": 6 + } + ] + ----- stderr ----- + "#); +} + +#[test] +fn asset_list_filters_by_node_and_retry() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]".to_string(), + "[RUN_DIR]".to_string(), + )); + let mut cmd = context.command(); + cmd.args([ + "asset", + "list", + &setup.run.run_id, + "--node", + "retry_assets", + "--retry", + "2", + "--json", + ]); + + fabro_snapshot!(filters, cmd, @r#" + success: true + exit_code: 0 + ----- stdout ----- + [ + { + "node_slug": "retry_assets", + "retry": 2, + "relative_path": "assets/retry/report.txt", + "absolute_path": "[RUN_DIR]/cache/artifacts/assets/retry_assets/retry_2/assets/retry/report.txt", + "size": 6 + } + ] + ----- stderr ----- + "#); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 9307ad8ac..f56baff1e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{output_stdout, write_sleep_workflow}; + #[test] fn help() { let context = test_context!(); @@ -26,3 +28,56 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn attach_before_completion_streams_to_finished_state() { + let context = test_context!(); + write_sleep_workflow( + &context.temp_dir.join("slow.fabro"), + "slow", + "Run slowly", + 2, + ); + + let mut run_cmd = context.command(); + run_cmd.current_dir(&context.temp_dir); + run_cmd.env("OPENAI_API_KEY", "test"); + run_cmd.args([ + "run", + "--detach", + "--provider", + "openai", + "--sandbox", + "local", + "--no-retro", + "slow.fabro", + ]); + let run_output = run_cmd.output().expect("command should execute"); + assert!( + run_output.status.success(), + "run --detach failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run_output.stdout), + String::from_utf8_lossy(&run_output.stderr) + ); + let run_id = output_stdout(&run_output).trim().to_string(); + + let mut filters = context.filters(); + filters.push(( + r"\b\d+(\.\d+)?(ms|s)\b".to_string(), + "[DURATION]".to_string(), + )); + let mut attach_cmd = context.command(); + attach_cmd.current_dir(&context.temp_dir); + attach_cmd.args(["attach", &run_id]); + + fabro_snapshot!(filters, attach_cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Sandbox: local (ready in [TIME]) + ✓ start [DURATION] + ✓ wait [DURATION] + ✓ exit [DURATION] + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/cp.rs b/lib/crates/fabro-cli/tests/it/cmd/cp.rs index c0a8fcdca..1d15b91f4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/cp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/cp.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{read_text, setup_asset_sandbox_run, setup_created_dry_run, text_tree}; + #[test] fn help() { let context = test_context!(); @@ -28,3 +30,93 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn sandbox_cp_run_without_sandbox_json_errors_cleanly() { + let context = test_context!(); + let run = setup_created_dry_run(&context); + let dest = context.temp_dir.join("missing.txt"); + let mut cmd = context.cp(); + cmd.args([&format!("{}:foo.txt", run.run_id), dest.to_str().unwrap()]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Failed to load sandbox.json — was this run started with a recent version of arc? + > failed to read [DRY_RUN_DIR]/sandbox.json: No such file or directory (os error 2) + "); +} + +#[test] +fn sandbox_cp_downloads_file_from_run() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let dest = context.temp_dir.join("downloaded-root.txt"); + let mut cmd = context.cp(); + cmd.args([ + &format!("{}:sandbox_dir/download_me/root.txt", setup.run.run_id), + dest.to_str().unwrap(), + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + "); + assert_eq!(read_text(&dest), "keep"); +} + +#[test] +fn sandbox_cp_uploads_file_to_run() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let local = context.temp_dir.join("upload.txt"); + std::fs::write(&local, "uploaded-root") + .unwrap_or_else(|err| panic!("failed to write {}: {err}", local.display())); + let mut cmd = context.cp(); + cmd.args([ + local.to_str().unwrap(), + &format!("{}:sandbox_dir/uploaded.txt", setup.run.run_id), + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + "); + assert_eq!( + read_text(&setup.workspace_dir.join("sandbox_dir/uploaded.txt")), + "uploaded-root" + ); +} + +#[test] +fn sandbox_cp_recursive_downloads_directory() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let dest = context.temp_dir.join("download-dir"); + let mut cmd = context.cp(); + cmd.args([ + "-r", + &format!("{}:sandbox_dir/download_me", setup.run.run_id), + dest.to_str().unwrap(), + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + "); + insta::assert_snapshot!( + text_tree(&dest).join("\n"), + @r" + nested/child.txt = nested + root.txt = keep + " + ); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index c6fb38a71..4a5e4fbac 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -1,5 +1,10 @@ +use insta::assert_snapshot; +use serde_json::json; + use fabro_test::{fabro_snapshot, test_context}; +use super::support::{fixture, output_stdout, read_json, resolve_run}; + #[test] fn help() { let context = test_context!(); @@ -37,3 +42,119 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn create_persists_requested_overrides_into_run_json() { + let context = test_context!(); + let workflow = fixture("simple.fabro"); + let mut cmd = context.command(); + cmd.args([ + "create", + "--dry-run", + "--auto-approve", + "--goal", + "Ship the release", + "--model", + "gpt-5", + "--provider", + "openai", + "--sandbox", + "local", + "--label", + "env=dev", + "--label", + "team=cli", + "--verbose", + "--no-retro", + "--preserve-sandbox", + workflow.to_str().unwrap(), + ]); + let output = cmd.output().expect("command should execute"); + assert!( + output.status.success(), + "command failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = output_stdout(&output); + let run_id = stdout + .lines() + .find(|line| !line.trim().is_empty()) + .map(str::trim) + .expect("create should print a run ID") + .to_string(); + let run = resolve_run(&context, &run_id); + let run_json = read_json(&run.run_dir.join("run.json")); + let labels = json!({ + "env": run_json.pointer("/labels/env"), + "team": run_json.pointer("/labels/team"), + }); + let compact = json!({ + "workflow_slug": run_json["workflow_slug"], + "settings": { + "goal": run_json.pointer("/settings/goal"), + "dry_run": run_json.pointer("/settings/dry_run"), + "auto_approve": run_json.pointer("/settings/auto_approve"), + "no_retro": run_json.pointer("/settings/no_retro"), + "verbose": run_json.pointer("/settings/verbose"), + "llm": { + "model": run_json.pointer("/settings/llm/model"), + "provider": run_json.pointer("/settings/llm/provider"), + }, + "sandbox": { + "provider": run_json.pointer("/settings/sandbox/provider"), + "preserve": run_json.pointer("/settings/sandbox/preserve"), + }, + }, + "labels": labels, + }); + + assert_snapshot!(serde_json::to_string_pretty(&compact).unwrap(), @r###" + { + "workflow_slug": "simple", + "settings": { + "goal": "Ship the release", + "dry_run": true, + "auto_approve": true, + "no_retro": true, + "verbose": true, + "llm": { + "model": "gpt-5", + "provider": "openai" + }, + "sandbox": { + "provider": "local", + "preserve": true + } + }, + "labels": { + "env": "dev", + "team": "cli" + } + } + "###); +} + +#[test] +fn create_invalid_workflow_fails_without_creating_run() { + let context = test_context!(); + let workflow = fixture("invalid.fabro"); + let mut cmd = context.command(); + cmd.args(["create", workflow.to_str().unwrap()]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Validation failed + "); + + let runs_dir = context.storage_dir.join("runs"); + let run_count = std::fs::read_dir(&runs_dir) + .ok() + .map(|entries| entries.flatten().count()) + .unwrap_or(0); + assert_eq!(run_count, 0, "invalid create should not persist a run"); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/diff.rs b/lib/crates/fabro-cli/tests/it/cmd/diff.rs index 7a3cf7473..5c45c7836 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/diff.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/diff.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{git_filters, setup_git_backed_changed_run, setup_git_backed_noop_run}; + #[test] fn help() { let context = test_context!(); @@ -29,3 +31,81 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn diff_completed_run_without_changes_reports_no_patch() { + let context = test_context!(); + let setup = setup_git_backed_noop_run(&context); + let mut cmd = context.command(); + cmd.args(["diff", &setup.run.run_id]); + + fabro_snapshot!(git_filters(&context), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Run completed but no final.patch exists — the run may not have produced any changes + "); +} + +#[test] +fn diff_missing_node_diff_reports_helpful_error() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + let mut cmd = context.command(); + cmd.args(["diff", &setup.run.run_id, "--node", "missing"]); + + fabro_snapshot!(git_filters(&context), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: No diff found for node 'missing' — check the node ID and try again + > No such file or directory (os error 2) + "); +} + +#[test] +fn diff_completed_run_with_changes_prints_patch() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + let mut cmd = context.command(); + cmd.args(["diff", &setup.run.run_id]); + + fabro_snapshot!(git_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + diff --git a/story.txt b/story.txt + index [SHA]..[SHA] 100644 + --- a/story.txt + +++ b/story.txt + @@ -1 +1,3 @@ + line 1 + +line 2 + +line 3 + ----- stderr ----- + "); +} + +#[test] +fn diff_node_outputs_specific_patch() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + let mut cmd = context.command(); + cmd.args(["diff", &setup.run.run_id, "--node", "step_one"]); + + fabro_snapshot!(git_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + diff --git a/story.txt b/story.txt + index [SHA]..[SHA] 100644 + --- a/story.txt + +++ b/story.txt + @@ -1 +1,2 @@ + line 1 + +line 2 + ----- stderr ----- + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index 4f9805460..1505968ee 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -84,6 +84,31 @@ fn exec_missing_api_key_exits_with_error() { "); } +#[test] +fn exec_uses_user_config_defaults() { + let context = test_context!(); + context.write_home( + ".fabro/user.toml", + "[exec]\nprovider = \"openai\"\nmodel = \"gpt-4.1-mini\"\npermissions = \"read-only\"\noutput_format = \"json\"\n", + ); + + let mut cmd = context.exec_cmd(); + cmd.arg("test prompt"); + cmd.env_clear(); + cmd.env("HOME", &context.home_dir); + cmd.env("FABRO_STORAGE_DIR", &context.storage_dir); + cmd.env("FABRO_NO_UPGRADE_CHECK", "true"); + cmd.current_dir(&context.temp_dir); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: API key not set for provider 'openai' + "); +} + #[test] #[ignore = "requires API key"] fn exec_creates_file() { diff --git a/lib/crates/fabro-cli/tests/it/cmd/fork.rs b/lib/crates/fabro-cli/tests/it/cmd/fork.rs index 70a7e6793..bb4f5cfa3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fork.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fork.rs @@ -1,4 +1,11 @@ -use fabro_test::{fabro_snapshot, test_context}; +use insta::assert_snapshot; + +use fabro_test::{fabro_snapshot, run_and_format, test_context}; + +use super::support::{ + git_filters, git_show_json, git_stdout, metadata_run_ids, run_branch_commits, + run_branch_commits_since_base, setup_git_backed_changed_run, +}; #[test] fn help() { @@ -29,3 +36,119 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn fork_outside_git_repo_errors() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.current_dir(&context.temp_dir); + cmd.args(["fork", "01ARZ3NDEKTSV4RRFFQ69G5FAW"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: not in a git repository + > could not find repository at '.'; class=Repository (6); code=NotFound (-3) + "); +} + +#[test] +fn fork_latest_prints_new_run_and_resume_hint() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + let before = metadata_run_ids(&setup.repo_dir); + + let mut cmd = context.command(); + cmd.current_dir(&setup.repo_dir); + cmd.args(["fork", &setup.run.run_id, "--no-push"]); + + let (snapshot, output) = run_and_format(&mut cmd, &git_filters(&context)); + assert_snapshot!(snapshot, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + + Forked run [RUN_PREFIX] -> [RUN_PREFIX] + To resume: fabro resume [RUN_PREFIX] + "); + assert!(output.status.success(), "fork should succeed"); + + let after = metadata_run_ids(&setup.repo_dir); + let new_run_ids: Vec<_> = after.difference(&before).cloned().collect(); + assert_eq!( + new_run_ids.len(), + 1, + "fork should create one new run branch" + ); + let new_run_id = &new_run_ids[0]; + + let new_head = git_stdout( + &setup.repo_dir, + &["rev-parse", &format!("fabro/run/{new_run_id}")], + ); + let expected_head = run_branch_commits(&setup.repo_dir, &setup.run.run_id) + .into_iter() + .last() + .expect("source run should have a last run commit"); + assert_eq!(new_head.trim(), expected_head); +} + +#[test] +fn fork_from_earlier_checkpoint_uses_expected_sha() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + let before = metadata_run_ids(&setup.repo_dir); + let expected_head = + run_branch_commits_since_base(&setup.repo_dir, &setup.run.run_id, &setup.base_sha) + .into_iter() + .next() + .expect("source run should have a first run commit"); + + let output = context + .command() + .current_dir(&setup.repo_dir) + .args(["fork", &setup.run.run_id, "@1", "--no-push"]) + .output() + .expect("fork should execute"); + assert!( + output.status.success(), + "fork should succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let after = metadata_run_ids(&setup.repo_dir); + let new_run_ids: Vec<_> = after.difference(&before).cloned().collect(); + assert_eq!( + new_run_ids.len(), + 1, + "fork should create one new run branch" + ); + let new_run_id = &new_run_ids[0]; + + let new_head = git_stdout( + &setup.repo_dir, + &["rev-parse", &format!("fabro/run/{new_run_id}")], + ); + assert_eq!(new_head.trim(), expected_head); + + let checkpoint = git_show_json( + &setup.repo_dir, + &format!("fabro/meta/{new_run_id}:checkpoint.json"), + ); + assert_eq!(checkpoint["current_node"].as_str(), Some("step_one")); + assert_eq!( + checkpoint["git_commit_sha"].as_str(), + Some(expected_head.as_str()) + ); + + let start = git_show_json( + &setup.repo_dir, + &format!("fabro/meta/{new_run_id}:start.json"), + ); + let expected_branch = format!("fabro/run/{new_run_id}"); + assert_eq!(start["run_branch"].as_str(), Some(expected_branch.as_str())); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/graph.rs b/lib/crates/fabro-cli/tests/it/cmd/graph.rs index 081882309..569454c26 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/graph.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/graph.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::fixture; + #[test] fn help() { let context = test_context!(); @@ -64,3 +66,21 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn graph_invalid_workflow_fails_after_diagnostics() { + let context = test_context!(); + let workflow = fixture("invalid.fabro"); + let mut cmd = context.command(); + cmd.args(["graph", workflow.to_str().unwrap()]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node) + error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing) + error: Validation failed + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index 9abcbbd06..a4932b01d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -1,5 +1,12 @@ +use insta::assert_snapshot; + use fabro_test::{fabro_snapshot, test_context}; +use super::support::{ + compact_git_inspect, compact_inspect, run_success, setup_completed_dry_run, + setup_created_dry_run, setup_git_backed_changed_run, +}; + #[test] fn help() { let context = test_context!(); @@ -26,3 +33,124 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn inspect_created_run_shows_run_record_without_start_or_conclusion() { + let context = test_context!(); + let run = setup_created_dry_run(&context); + let output = run_success(&context, &["inspect", &run.run_id]); + + assert_snapshot!(serde_json::to_string_pretty(&compact_inspect(&output)).unwrap(), @r###" + [ + { + "run_id": "[ULID]", + "status": "submitted", + "run_record": { + "goal": "Run tests and report results", + "workflow_name": "Simple", + "workflow_slug": "simple", + "sandbox_provider": "local", + "dry_run": true + }, + "start_record": null, + "conclusion": null, + "checkpoint": null, + "sandbox": null + } + ] + "###); +} + +#[test] +fn inspect_completed_run_shows_run_start_conclusion_checkpoint() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let output = run_success(&context, &["inspect", &run.run_id]); + + assert_snapshot!(serde_json::to_string_pretty(&compact_inspect(&output)).unwrap(), @r###" + [ + { + "run_id": "[ULID]", + "status": "succeeded", + "run_record": { + "goal": "Run tests and report results", + "workflow_name": "Simple", + "workflow_slug": "simple", + "sandbox_provider": "local", + "dry_run": true + }, + "start_record": { + "has_start_time": true + }, + "conclusion": { + "status": "success", + "duration_ms": "[DURATION_MS]", + "stage_count": 3 + }, + "checkpoint": { + "current_node": "report", + "completed_nodes": [ + "start", + "run_tests", + "report" + ], + "next_node_id": "exit" + }, + "sandbox": { + "provider": "local" + } + } + ] + "###); +} + +#[test] +fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + let output = run_success(&context, &["inspect", &setup.run.run_id]); + + assert_snapshot!( + serde_json::to_string_pretty(&compact_git_inspect(&output)).unwrap(), + @r###" + [ + { + "run_id": "[ULID]", + "status": "succeeded", + "run_record": { + "goal": "Edit a tracked file", + "workflow_name": "Flow", + "workflow_slug": "flow", + "llm_provider": "openai", + "sandbox_provider": "local" + }, + "start_record": { + "has_start_time": true, + "run_branch": "fabro/run/[ULID]", + "base_sha": "[SHA]" + }, + "conclusion": { + "status": "success", + "duration_ms": "[DURATION_MS]", + "final_git_commit_sha": "[SHA]", + "stage_count": 3 + }, + "checkpoint": { + "current_node": "step_two", + "completed_nodes": [ + "start", + "step_one", + "step_two" + ], + "next_node_id": "exit", + "git_commit_sha": "[SHA]" + }, + "sandbox": { + "provider": "local", + "working_directory": "[WORKTREE]" + } + } + ] + "### + ); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/logs.rs b/lib/crates/fabro-cli/tests/it/cmd/logs.rs index 2cd56410a..3c1aaca1e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/logs.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/logs.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{setup_completed_dry_run, setup_detached_dry_run}; + #[test] fn help() { let context = test_context!(); @@ -30,3 +32,156 @@ fn help() { ----- stderr ----- "#); } + +#[test] +fn logs_completed_run_outputs_raw_ndjson() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z".to_string(), + "[TIMESTAMP]".to_string(), + )); + filters.push(( + r#""duration_ms":\s*\d+"#.to_string(), + r#""duration_ms": [DURATION_MS]"#.to_string(), + )); + let mut cmd = context.command(); + cmd.args(["logs", &run.run_id]); + + fabro_snapshot!(filters, cmd, @r###" + success: true + exit_code: 0 + ----- stdout ----- + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"Sandbox.Initializing","sandbox_provider":"local"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"Sandbox.Ready","url":null,"duration_ms": [DURATION_MS],"name":null,"cpu":null,"memory":null,"sandbox_provider":"local"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"SandboxInitialized","working_directory":"[TEMP_DIR]"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"WorkflowRunStarted","goal":"Run tests and report results","workflow_name":"Simple"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageStarted","node_id":"start","max_attempts":1,"node_label":"Start","handler_type":"start","attempt":1,"stage_index":0} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageCompleted","node_id":"start","max_attempts":1,"node_label":"Start","duration_ms": [DURATION_MS],"status":"success","preferred_label":null,"suggested_next_ids":[],"usage":null,"notes":"[Simulated] start","files_touched":[],"attempt":1,"stage_index":0} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"EdgeSelected","is_jump":false,"from_node_id":"start","label":null,"condition":null,"reason":"unconditional","stage_status":"success","to_node_id":"run_tests"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"CheckpointCompleted","node_id":"start","status":"success","node_label":"start"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageStarted","node_id":"run_tests","max_attempts":1,"node_label":"Run Tests","handler_type":"agent","attempt":1,"stage_index":1} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageCompleted","node_id":"run_tests","max_attempts":1,"node_label":"Run Tests","duration_ms": [DURATION_MS],"status":"success","preferred_label":null,"suggested_next_ids":[],"usage":null,"notes":"[Simulated] run_tests","files_touched":[],"attempt":1,"stage_index":1} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"EdgeSelected","is_jump":false,"from_node_id":"run_tests","label":null,"condition":null,"reason":"unconditional","stage_status":"success","to_node_id":"report"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"CheckpointCompleted","node_id":"run_tests","status":"success","node_label":"run_tests"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageStarted","node_id":"report","max_attempts":1,"node_label":"Report","handler_type":"agent","attempt":1,"stage_index":2} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageCompleted","node_id":"report","max_attempts":1,"node_label":"Report","duration_ms": [DURATION_MS],"status":"success","preferred_label":null,"suggested_next_ids":[],"usage":null,"notes":"[Simulated] report","files_touched":[],"attempt":1,"stage_index":2} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"EdgeSelected","is_jump":false,"from_node_id":"report","label":null,"condition":null,"reason":"unconditional","stage_status":"success","to_node_id":"exit"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"CheckpointCompleted","node_id":"report","status":"success","node_label":"report"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageStarted","node_id":"exit","max_attempts":1,"node_label":"Exit","handler_type":"exit","attempt":1,"stage_index":3} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageCompleted","node_id":"exit","max_attempts":1,"node_label":"Exit","duration_ms": [DURATION_MS],"status":"success","preferred_label":null,"suggested_next_ids":[],"usage":null,"notes":null,"files_touched":[],"attempt":1,"stage_index":3} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"WorkflowRunCompleted","duration_ms": [DURATION_MS],"artifact_count":0,"status":"success"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"Sandbox.CleanupStarted","sandbox_provider":"local"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"Sandbox.CleanupCompleted","duration_ms": [DURATION_MS],"sandbox_provider":"local"} + ----- stderr ----- + "###); +} + +#[test] +fn logs_tail_limits_output() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z".to_string(), + "[TIMESTAMP]".to_string(), + )); + filters.push(( + r#""duration_ms":\s*\d+"#.to_string(), + r#""duration_ms": [DURATION_MS]"#.to_string(), + )); + let mut cmd = context.command(); + cmd.args(["logs", "--tail", "2", &run.run_id]); + + fabro_snapshot!(filters, cmd, @r###" + success: true + exit_code: 0 + ----- stdout ----- + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"Sandbox.CleanupStarted","sandbox_provider":"local"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"Sandbox.CleanupCompleted","duration_ms": [DURATION_MS],"sandbox_provider":"local"} + ----- stderr ----- + "###); +} + +#[test] +fn logs_pretty_formats_small_run() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut filters = context.filters(); + filters.push((r"\b\d{2}:\d{2}:\d{2}\b".to_string(), "[CLOCK]".to_string())); + filters.push(( + r"\b\d+(\.\d+)?(ms|s)\b".to_string(), + "[DURATION]".to_string(), + )); + let mut cmd = context.command(); + cmd.args(["logs", "--pretty", &run.run_id]); + + fabro_snapshot!(filters, cmd, @r#" + success: true + exit_code: 0 + ----- stdout ----- + [CLOCK] Sandbox: local [DURATION] + [CLOCK] ▶ Simple [ULID] + Run tests and report results + + [CLOCK] ▶ Start + [CLOCK] ✓ Start [DURATION] (0 turns, 0 tools, 0 toks) + [CLOCK] → run_tests unconditional + [CLOCK] ▶ Run Tests + [CLOCK] ✓ Run Tests [DURATION] (0 turns, 0 tools, 0 toks) + [CLOCK] → report unconditional + [CLOCK] ▶ Report + [CLOCK] ✓ Report [DURATION] (0 turns, 0 tools, 0 toks) + [CLOCK] → exit unconditional + [CLOCK] ▶ Exit + [CLOCK] ✓ Exit [DURATION] (0 turns, 0 tools, 0 toks) + [CLOCK] ✓ SUCCESS [DURATION] + ----- stderr ----- + "#); +} + +#[test] +fn logs_follow_detached_run_streams_until_completion() { + let context = test_context!(); + let run = setup_detached_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z".to_string(), + "[TIMESTAMP]".to_string(), + )); + filters.push(( + r#""duration_ms":\s*\d+"#.to_string(), + r#""duration_ms": [DURATION_MS]"#.to_string(), + )); + let mut cmd = context.command(); + cmd.args(["logs", "--follow", &run.run_id]); + + fabro_snapshot!(filters, cmd, @r###" + success: true + exit_code: 0 + ----- stdout ----- + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"Sandbox.Initializing","sandbox_provider":"local"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"Sandbox.Ready","url":null,"duration_ms": [DURATION_MS],"name":null,"cpu":null,"memory":null,"sandbox_provider":"local"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"SandboxInitialized","working_directory":"[TEMP_DIR]"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"WorkflowRunStarted","goal":"Run tests and report results","workflow_name":"Simple"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageStarted","node_id":"start","max_attempts":1,"node_label":"Start","handler_type":"start","attempt":1,"stage_index":0} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageCompleted","node_id":"start","max_attempts":1,"node_label":"Start","duration_ms": [DURATION_MS],"status":"success","preferred_label":null,"suggested_next_ids":[],"usage":null,"notes":"[Simulated] start","files_touched":[],"attempt":1,"stage_index":0} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"EdgeSelected","is_jump":false,"from_node_id":"start","label":null,"condition":null,"reason":"unconditional","stage_status":"success","to_node_id":"run_tests"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"CheckpointCompleted","node_id":"start","status":"success","node_label":"start"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageStarted","node_id":"run_tests","max_attempts":1,"node_label":"Run Tests","handler_type":"agent","attempt":1,"stage_index":1} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageCompleted","node_id":"run_tests","max_attempts":1,"node_label":"Run Tests","duration_ms": [DURATION_MS],"status":"success","preferred_label":null,"suggested_next_ids":[],"usage":null,"notes":"[Simulated] run_tests","files_touched":[],"attempt":1,"stage_index":1} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"EdgeSelected","is_jump":false,"from_node_id":"run_tests","label":null,"condition":null,"reason":"unconditional","stage_status":"success","to_node_id":"report"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"CheckpointCompleted","node_id":"run_tests","status":"success","node_label":"run_tests"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageStarted","node_id":"report","max_attempts":1,"node_label":"Report","handler_type":"agent","attempt":1,"stage_index":2} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageCompleted","node_id":"report","max_attempts":1,"node_label":"Report","duration_ms": [DURATION_MS],"status":"success","preferred_label":null,"suggested_next_ids":[],"usage":null,"notes":"[Simulated] report","files_touched":[],"attempt":1,"stage_index":2} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"EdgeSelected","is_jump":false,"from_node_id":"report","label":null,"condition":null,"reason":"unconditional","stage_status":"success","to_node_id":"exit"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"CheckpointCompleted","node_id":"report","status":"success","node_label":"report"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageStarted","node_id":"exit","max_attempts":1,"node_label":"Exit","handler_type":"exit","attempt":1,"stage_index":3} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"StageCompleted","node_id":"exit","max_attempts":1,"node_label":"Exit","duration_ms": [DURATION_MS],"status":"success","preferred_label":null,"suggested_next_ids":[],"usage":null,"notes":null,"files_touched":[],"attempt":1,"stage_index":3} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"WorkflowRunCompleted","duration_ms": [DURATION_MS],"artifact_count":0,"status":"success"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"Sandbox.CleanupStarted","sandbox_provider":"local"} + {"ts":"[TIMESTAMP]","run_id":"[ULID]","event":"Sandbox.CleanupCompleted","duration_ms": [DURATION_MS],"sandbox_provider":"local"} + ----- stderr ----- + "###); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 3bb16e172..6c961bf5e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -55,6 +55,7 @@ mod ssh; mod start; mod store; mod store_dump; +mod support; mod system; mod system_df; mod system_prune; diff --git a/lib/crates/fabro-cli/tests/it/cmd/model_test.rs b/lib/crates/fabro-cli/tests/it/cmd/model_test.rs index f564ddba9..83f184aa0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model_test.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model_test.rs @@ -26,3 +26,18 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn model_test_unknown_model_errors() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["model", "test", "--model", "nonexistent-model-xyz"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Unknown model: nonexistent-model-xyz + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/parse.rs b/lib/crates/fabro-cli/tests/it/cmd/parse.rs index 792b3308d..b006812e0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/parse.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/parse.rs @@ -26,3 +26,112 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn parse_valid_workflow_prints_ast_json() { + let context = test_context!(); + context.write_temp( + "tiny.fabro", + "digraph Tiny {\n graph [goal=\"Parse a tiny workflow\"]\n start [shape=Mdiamond]\n exit [shape=Msquare]\n main [label=\"Main\", prompt=\"Do the thing\"]\n start -> main -> exit\n}\n", + ); + let mut cmd = context.command(); + cmd.current_dir(&context.temp_dir); + cmd.args(["parse", "tiny.fabro"]); + + fabro_snapshot!(context.filters(), cmd, @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "name": "Tiny", + "statements": [ + { + "GraphAttr": [ + [ + "goal", + { + "Str": "Parse a tiny workflow" + } + ] + ] + }, + { + "Node": { + "id": "start", + "attrs": [ + [ + "shape", + { + "Ident": "Mdiamond" + } + ] + ] + } + }, + { + "Node": { + "id": "exit", + "attrs": [ + [ + "shape", + { + "Ident": "Msquare" + } + ] + ] + } + }, + { + "Node": { + "id": "main", + "attrs": [ + [ + "label", + { + "Str": "Main" + } + ], + [ + "prompt", + { + "Str": "Do the thing" + } + ] + ] + } + }, + { + "Edge": { + "nodes": [ + "start", + "main", + "exit" + ], + "attrs": null + } + } + ] + } + ----- stderr ----- + "###); +} + +#[test] +fn parse_invalid_dot_fails_cleanly() { + let context = test_context!(); + context.write_temp( + "bad.fabro", + "digraph Bad {\n start [shape=Mdiamond]\n exit [shape=Msquare]\n start -> exit\n", + ); + let mut cmd = context.command(); + cmd.current_dir(&context.temp_dir); + cmd.args(["parse", "bad.fabro"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Parse error: grammar error: Parsing Error: Error { input: \"\", code: Char } + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs index d9a6909a9..260ced074 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{setup_completed_dry_run, setup_created_dry_run}; + #[test] fn help() { let context = test_context!(); @@ -27,3 +29,36 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn pr_create_unfinished_run_errors_before_network() { + let context = test_context!(); + let run = setup_created_dry_run(&context); + let mut cmd = context.command(); + cmd.args(["pr", "create", &run.run_id]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Failed to load start.json + > I/O error: No such file or directory (os error 2) + "); +} + +#[test] +fn pr_create_completed_dry_run_without_run_branch_errors() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut cmd = context.command(); + cmd.args(["pr", "create", &run.run_id]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Run has no run_branch — was it run with git push enabled? + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_list.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_list.rs index 61af4e123..8faf9b47c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_list.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_list.rs @@ -24,3 +24,18 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn pr_list_missing_github_credentials_errors() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["pr", "list"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs index 8a8583934..bba2a2c08 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::setup_completed_dry_run; + #[test] fn help() { let context = test_context!(); @@ -26,3 +28,20 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn pr_view_missing_pull_request_json_errors() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut cmd = context.command(); + cmd.args(["pr", "view", &run.run_id]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: No pull_request.json found in run directory. Create one first with: fabro pr create [ULID] + > No such file or directory (os error 2) + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs b/lib/crates/fabro-cli/tests/it/cmd/preflight.rs index 4f6405a72..b0dd6b8f0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/preflight.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::fixture; + #[test] fn help() { let context = test_context!(); @@ -31,3 +33,23 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn preflight_invalid_workflow_fails_with_validation_output() { + let context = test_context!(); + let workflow = fixture("invalid.fabro"); + let mut cmd = context.command(); + cmd.args(["preflight", workflow.to_str().unwrap()]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + Workflow: Invalid (2 nodes, 1 edges) + Graph: ../../../test/invalid.fabro + error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node) + error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing) + error: Validation failed + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/preview.rs b/lib/crates/fabro-cli/tests/it/cmd/preview.rs index 2c03c208a..24b9f0c5f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/preview.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/preview.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::setup_asset_sandbox_run; + #[test] fn help() { let context = test_context!(); @@ -30,3 +32,19 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn sandbox_preview_rejects_non_daytona_run() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let mut cmd = context.preview(); + cmd.args([&setup.run.run_id, "3000"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Preview URLs is only supported for Daytona sandboxes (this run uses 'local') + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index 118cebe66..ab733cc38 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{fixture, run_success, setup_completed_dry_run, setup_created_dry_run}; + #[test] fn help() { let context = test_context!(); @@ -29,3 +31,162 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn ps_default_excludes_non_running_runs() { + let context = test_context!(); + setup_completed_dry_run(&context); + let cmd = context.ps(); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + No running processes found. Use -a to show all runs. + "); +} + +#[test] +fn ps_all_json_lists_created_and_completed_runs() { + let context = test_context!(); + setup_completed_dry_run(&context); + setup_created_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})".to_string(), + "[TIMESTAMP]".to_string(), + )); + filters.push(( + r#""duration_ms":\s*\d+"#.to_string(), + r#""duration_ms": [DURATION_MS]"#.to_string(), + )); + let mut cmd = context.ps(); + cmd.args(["-a", "--json"]); + + fabro_snapshot!(filters, cmd, @r###" + success: true + exit_code: 0 + ----- stdout ----- + [ + { + "run_id": "[ULID]", + "dir_name": "20260330-dry-run-[ULID]", + "workflow_name": "Simple", + "workflow_slug": "simple", + "status": "submitted", + "start_time": "[TIMESTAMP]", + "labels": {}, + "host_repo_path": "[TEMP_DIR]", + "goal": "Run tests and report results" + }, + { + "run_id": "[ULID]", + "dir_name": "20260330-dry-run-[ULID]", + "workflow_name": "Simple", + "workflow_slug": "simple", + "status": "succeeded", + "status_reason": "completed", + "start_time": "[TIMESTAMP]", + "labels": {}, + "duration_ms": [DURATION_MS], + "host_repo_path": "[TEMP_DIR]", + "goal": "Run tests and report results" + } + ] + ----- stderr ----- + "###); +} + +#[test] +fn ps_quiet_outputs_run_ids_only() { + let context = test_context!(); + setup_completed_dry_run(&context); + setup_created_dry_run(&context); + let mut cmd = context.ps(); + cmd.args(["-a", "--quiet"]); + + fabro_snapshot!(context.filters(), cmd, @r###" + success: true + exit_code: 0 + ----- stdout ----- + [ULID] + [ULID] + ----- stderr ----- + "###); +} + +#[test] +fn ps_filters_by_workflow_and_label() { + let context = test_context!(); + let simple = fixture("simple.fabro"); + let branching = fixture("branching.fabro"); + + run_success( + &context, + &[ + "run", + "--dry-run", + "--auto-approve", + "--no-retro", + "--label", + "suite=alpha", + simple.to_str().unwrap(), + ], + ); + run_success( + &context, + &[ + "run", + "--dry-run", + "--auto-approve", + "--no-retro", + "--label", + "suite=beta", + branching.to_str().unwrap(), + ], + ); + + let mut filters = context.filters(); + filters.push(( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})".to_string(), + "[TIMESTAMP]".to_string(), + )); + filters.push(( + r#""duration_ms":\s*\d+"#.to_string(), + r#""duration_ms": [DURATION_MS]"#.to_string(), + )); + let mut cmd = context.ps(); + cmd.args([ + "-a", + "--json", + "--workflow", + "Simple", + "--label", + "suite=alpha", + ]); + + fabro_snapshot!(filters, cmd, @r###" + success: true + exit_code: 0 + ----- stdout ----- + [ + { + "run_id": "[ULID]", + "dir_name": "20260330-dry-run-[ULID]", + "workflow_name": "Simple", + "workflow_slug": "simple", + "status": "succeeded", + "status_reason": "completed", + "start_time": "[TIMESTAMP]", + "labels": { + "suite": "alpha" + }, + "duration_ms": [DURATION_MS], + "host_repo_path": "[TEMP_DIR]", + "goal": "Run tests and report results" + } + ] + ----- stderr ----- + "###); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs index c5f4bdd97..5925f3028 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs @@ -1,3 +1,5 @@ +use insta::assert_snapshot; + use fabro_test::{fabro_snapshot, test_context}; #[test] @@ -23,3 +25,115 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn repo_init_creates_fabro_toml_and_hello_workflow() { + let context = test_context!(); + context.git_init(); + + let mut cmd = context.command(); + cmd.current_dir(&context.temp_dir); + cmd.args(["repo", "init"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + ✔ fabro.toml + ✔ fabro/workflows/hello/workflow.fabro + ✔ fabro/workflows/hello/workflow.toml + + Project initialized! Run a workflow with: + + fabro run hello + + ! No git remote found — skipping GitHub App check + Run `git remote add origin ` then `fabro install` to set up the GitHub App + "); + + assert_snapshot!( + std::fs::read_to_string(context.temp_dir.join("fabro.toml")).unwrap(), + @r###" + # Fabro project configuration + # https://docs.fabro.computer/getting-started/quick-start + + version = 1 + + [fabro] + root = "fabro/" + + # Disable retrospective analysis after workflow runs: + # retro = false + + # Auto-create pull requests on successful workflow runs. + [pull_request] + enabled = true + draft = true + # auto_merge = true + "### + ); + assert_snapshot!( + std::fs::read_to_string(context.temp_dir.join("fabro/workflows/hello/workflow.fabro")) + .unwrap(), + @r###" + digraph Hello { + graph [goal="Say hello and demonstrate a basic Fabro workflow"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + greet [label="Greet", prompt="Say hello! Introduce yourself and explain that this is a test of the Fabro workflow engine."] + + start -> greet -> exit + } + "### + ); + assert_snapshot!( + std::fs::read_to_string(context.temp_dir.join("fabro/workflows/hello/workflow.toml")) + .unwrap(), + @r###" + version = 1 + graph = "workflow.fabro" + + [sandbox] + provider = "local" + "### + ); +} + +#[test] +fn repo_init_rejects_already_initialized_repo() { + let context = test_context!(); + context.git_init(); + std::fs::write(context.temp_dir.join("fabro.toml"), "version = 1\n").unwrap(); + + let mut cmd = context.command(); + cmd.current_dir(&context.temp_dir); + cmd.args(["repo", "init"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: already initialized — fabro.toml exists at [TEMP_DIR]/fabro.toml + "); +} + +#[test] +fn repo_init_errors_outside_git_repo() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.current_dir(&context.temp_dir); + cmd.args(["repo", "init"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: not a git repository — run `git init` first + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/resume.rs b/lib/crates/fabro-cli/tests/it/cmd/resume.rs index 01073db7e..42d58c224 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/resume.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/resume.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{git_stdout, output_stderr, setup_git_backed_changed_run}; + #[test] fn help() { let context = test_context!(); @@ -27,3 +29,67 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn resume_rewound_run_succeeds() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + + let rewind = context + .command() + .current_dir(&setup.repo_dir) + .args(["rewind", &setup.run.run_id, "@1", "--no-push"]) + .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 rewound_head = git_stdout( + &setup.repo_dir, + &["rev-parse", &format!("fabro/run/{}", setup.run.run_id)], + ); + + let mut resume_cmd = context.command(); + resume_cmd.current_dir(&setup.repo_dir); + resume_cmd.env("OPENAI_API_KEY", "test"); + resume_cmd.args(["resume", "-d", &setup.run.run_id]); + fabro_snapshot!(context.filters(), resume_cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + [ULID] + ----- stderr ----- + "); + + let mut wait_filters = context.filters(); + wait_filters.push(( + r"\b\d+(\.\d+)?(ms|s)\b".to_string(), + "[DURATION]".to_string(), + )); + let mut wait_cmd = context.command(); + wait_cmd.args(["wait", &setup.run.run_id]); + fabro_snapshot!(wait_filters, wait_cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Succeeded [ULID] [DURATION] + "); + + assert_eq!( + std::fs::read_to_string(setup.run.run_dir.join("worktree/story.txt")).unwrap(), + "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/{}", setup.run.run_id)], + ); + assert_ne!(resumed_head.trim(), rewound_head.trim()); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs index 7057b616d..2e0b8a779 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs @@ -1,4 +1,11 @@ -use fabro_test::{fabro_snapshot, test_context}; +use insta::assert_snapshot; + +use fabro_test::{fabro_snapshot, run_and_format, test_context}; + +use super::support::{ + git_filters, git_stdout, output_stderr as support_stderr, run_branch_commits_since_base, + setup_git_backed_changed_run, +}; #[test] fn help() { @@ -29,3 +36,88 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn rewind_outside_git_repo_errors() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.current_dir(&context.temp_dir); + cmd.args(["rewind", "01ARZ3NDEKTSV4RRFFQ69G5FAW", "--list"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: not in a git repository + > could not find repository at '.'; class=Repository (6); code=NotFound (-3) + "); +} + +#[test] +fn rewind_list_prints_timeline_for_completed_git_run() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + let mut cmd = context.command(); + cmd.current_dir(&setup.repo_dir); + cmd.args(["rewind", &setup.run.run_id, "--list"]); + + fabro_snapshot!(git_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + @ Node Details + @1 step_one + @2 step_two + "); +} + +#[test] +fn rewind_target_updates_metadata_and_resume_hint() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + let expected_run_head = + run_branch_commits_since_base(&setup.repo_dir, &setup.run.run_id, &setup.base_sha) + .into_iter() + .next() + .expect("source run should have a first run commit"); + + let mut cmd = context.command(); + cmd.current_dir(&setup.repo_dir); + cmd.args(["rewind", &setup.run.run_id, "@1", "--no-push"]); + + let (snapshot, output) = run_and_format(&mut cmd, &git_filters(&context)); + assert_snapshot!(snapshot, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Rewound metadata branch to @1 (step_one) + Rewound run branch fabro/run/[ULID] to [SHA] + + To resume: fabro resume [RUN_PREFIX] + "); + assert!(output.status.success(), "rewind should succeed"); + + let run_head = git_stdout( + &setup.repo_dir, + &["rev-parse", &format!("fabro/run/{}", setup.run.run_id)], + ); + assert_eq!(run_head.trim(), expected_run_head); + + let mut list_cmd = context.command(); + list_cmd.current_dir(&setup.repo_dir); + list_cmd.args(["rewind", &setup.run.run_id, "--list"]); + let list_output = list_cmd.output().expect("rewind --list should execute"); + assert!(list_output.status.success(), "rewind --list should succeed"); + let list = support_stderr(&list_output); + assert!( + list.contains("@1"), + "rewound timeline should keep @1: {list}" + ); + assert!( + !list.contains("@2"), + "rewound timeline should drop @2: {list}" + ); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/crates/fabro-cli/tests/it/cmd/rm.rs index 93e369e80..708f5fbef 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rm.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rm.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{setup_completed_dry_run, setup_created_dry_run}; + #[test] fn help() { let context = test_context!(); @@ -27,3 +29,114 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn rm_deletes_completed_run() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(), + "[ULID]".to_string(), + )); + + let mut cmd = context.command(); + cmd.args(["rm", &run.run_id]); + fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + [ULID] + "); + assert!(!run.run_dir.exists(), "run directory should be deleted"); + + let mut ps = context.ps(); + ps.args(["-a", "--json"]); + fabro_snapshot!(context.filters(), ps, @r###" + success: true + exit_code: 0 + ----- stdout ----- + [] + ----- stderr ----- + "###); +} + +#[test] +fn rm_rejects_submitted_run_without_force() { + let context = test_context!(); + let run = setup_created_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(), + "[ULID]".to_string(), + )); + let mut cmd = context.command(); + cmd.args(["rm", &run.run_id]); + fabro_snapshot!(filters, cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + cannot remove active run [ULID] (status: submitted, use -f to force) + error: some runs could not be removed + "); +} + +#[test] +fn rm_force_deletes_submitted_run() { + let context = test_context!(); + let run = setup_created_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(), + "[ULID]".to_string(), + )); + + let mut cmd = context.command(); + cmd.args(["rm", "--force", &run.run_id]); + fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + [ULID] + "); + assert!(!run.run_dir.exists(), "run directory should be deleted"); + + let mut ps = context.ps(); + ps.args(["-a", "--json"]); + fabro_snapshot!(context.filters(), ps, @r###" + success: true + exit_code: 0 + ----- stdout ----- + [] + ----- stderr ----- + "###); +} + +#[test] +fn rm_partial_failure_reports_which_identifiers_failed() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(), + "[ULID]".to_string(), + )); + let mut cmd = context.command(); + cmd.args(["rm", &run.run_id, "does-not-exist"]); + fabro_snapshot!(filters, cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + [ULID] + error: does-not-exist: No run found matching 'does-not-exist' (tried run ID prefix and workflow name) + error: some runs could not be removed + "); + assert!( + !run.run_dir.exists(), + "existing run should still be removed" + ); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/ssh.rs b/lib/crates/fabro-cli/tests/it/cmd/ssh.rs index a2c819963..20df52349 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ssh.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ssh.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::setup_asset_sandbox_run; + #[test] fn help() { let context = test_context!(); @@ -28,3 +30,19 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn sandbox_ssh_rejects_non_daytona_run() { + let context = test_context!(); + let setup = setup_asset_sandbox_run(&context); + let mut cmd = context.ssh(); + cmd.args([&setup.run.run_id, "--print"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: SSH access is only supported for Daytona sandboxes (this run uses 'local') + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/start.rs b/lib/crates/fabro-cli/tests/it/cmd/start.rs index 2676ba16b..74e63e069 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/start.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{output_stdout, resolve_run, wait_for_status, write_sleep_workflow}; + #[test] fn help() { let context = test_context!(); @@ -26,3 +28,68 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn start_rejects_already_active_or_completed_run() { + let context = test_context!(); + write_sleep_workflow( + &context.temp_dir.join("slow.fabro"), + "slow", + "Run slowly", + 3, + ); + + let mut create_cmd = context.command(); + create_cmd.current_dir(&context.temp_dir); + create_cmd.env("OPENAI_API_KEY", "test"); + create_cmd.args([ + "create", + "--provider", + "openai", + "--sandbox", + "local", + "--no-retro", + "slow.fabro", + ]); + let create_output = create_cmd.output().expect("command should execute"); + assert!( + create_output.status.success(), + "create failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&create_output.stdout), + String::from_utf8_lossy(&create_output.stderr) + ); + let run_id = output_stdout(&create_output).trim().to_string(); + let run = resolve_run(&context, &run_id); + + let mut start_cmd = context.command(); + start_cmd.current_dir(&context.temp_dir); + start_cmd.env("OPENAI_API_KEY", "test"); + start_cmd.args(["start", &run_id]); + start_cmd.assert().success(); + + wait_for_status(&run.run_dir, &["starting", "running"]); + + let mut active_cmd = context.command(); + active_cmd.current_dir(&context.temp_dir); + active_cmd.args(["start", &run_id]); + fabro_snapshot!(context.filters(), active_cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: an engine process is still running for this run — cannot start + "); + + wait_for_status(&run.run_dir, &["succeeded"]); + + let mut completed_cmd = context.command(); + completed_cmd.current_dir(&context.temp_dir); + completed_cmd.args(["start", &run_id]); + fabro_snapshot!(context.filters(), completed_cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: cannot start run: status is Succeeded, expected submitted + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs b/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs index 8a45cef8b..cd5ed238c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs @@ -1,5 +1,9 @@ +use insta::assert_snapshot; + use fabro_test::{fabro_snapshot, test_context}; +use super::support::setup_completed_dry_run; + #[test] fn help() { let context = test_context!(); @@ -27,3 +31,86 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn store_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([ + "store", + "dump", + "--output", + output_dir.to_str().unwrap(), + &run.run_id, + ]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Exported 14 files for run [ULID] to [TEMP_DIR]/export + ----- stderr ----- + "); + + assert_snapshot!(dump_file_summary(&output_dir), @r###" + checkpoint.json + checkpoints/0001.json + checkpoints/0002.json + checkpoints/0003.json + conclusion.json + events.jsonl + graph.fabro + nodes/report/visit-1/status.json + nodes/run_tests/visit-1/status.json + nodes/start/visit-1/status.json + run.json + sandbox.json + start.json + status.json + "###); +} + +#[test] +fn store_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([ + "store", + "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 = 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) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }) + .collect(); + files.sort(); + files.join("\n") + "\n" +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs new file mode 100644 index 000000000..28ce1d044 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -0,0 +1,735 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Output; +use std::time::{Duration, Instant}; + +use fabro_test::TestContext; +use serde_json::Value; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + +pub(crate) struct RunSetup { + pub(crate) run_id: String, + pub(crate) run_dir: PathBuf, +} + +pub(crate) struct GitRunSetup { + pub(crate) run: RunSetup, + pub(crate) repo_dir: PathBuf, + pub(crate) base_sha: String, +} + +pub(crate) struct ProjectFixture { + pub(crate) project_dir: PathBuf, + pub(crate) fabro_root: PathBuf, +} + +pub(crate) struct AssetSandboxSetup { + pub(crate) run: RunSetup, + pub(crate) workspace_dir: PathBuf, +} + +#[derive(Clone, Copy)] +enum GitWorkflowKind { + Changed, + Noop, +} + +pub(crate) fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../../test/{name}")) +} + +pub(crate) fn output_stderr(output: &Output) -> String { + stderr(output) +} + +pub(crate) fn output_stdout(output: &Output) -> String { + stdout(output) +} + +pub(crate) fn read_json(path: &Path) -> Value { + let content = std::fs::read_to_string(path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + serde_json::from_str(&content) + .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())) +} + +pub(crate) fn read_text(path: &Path) -> String { + std::fs::read_to_string(path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())) +} + +fn stdout(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("stdout should be valid UTF-8") +} + +fn stderr(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("stderr should be valid UTF-8") +} + +pub(crate) fn run_success(context: &TestContext, args: &[&str]) -> Output { + run_success_in(context, args, &context.temp_dir) +} + +fn run_success_in(context: &TestContext, args: &[&str], cwd: &Path) -> Output { + let mut cmd = context.command(); + cmd.current_dir(cwd); + cmd.timeout(COMMAND_TIMEOUT); + cmd.args(args); + let output = cmd.output().expect("command should execute"); + if !output.status.success() { + panic!( + "command failed: fabro {}\nstdout:\n{}\nstderr:\n{}", + args.join(" "), + stdout(&output), + stderr(&output) + ); + } + output +} + +pub(crate) fn setup_completed_dry_run(context: &TestContext) -> RunSetup { + let workflow = fixture("simple.fabro"); + run_success_in( + context, + &[ + "run", + "--dry-run", + "--auto-approve", + "--no-retro", + "--sandbox", + "local", + workflow.to_str().unwrap(), + ], + &context.temp_dir, + ); + only_run(context) +} + +pub(crate) fn setup_created_dry_run(context: &TestContext) -> RunSetup { + let workflow = fixture("simple.fabro"); + let output = run_success_in( + context, + &[ + "create", + "--dry-run", + "--auto-approve", + "--no-retro", + "--sandbox", + "local", + workflow.to_str().unwrap(), + ], + &context.temp_dir, + ); + let run_id = stdout(&output) + .lines() + .find(|line| !line.trim().is_empty()) + .map(str::trim) + .expect("create should print a run ID") + .to_string(); + resolve_run(context, &run_id) +} + +pub(crate) fn setup_detached_dry_run(context: &TestContext) -> RunSetup { + let workflow = fixture("simple.fabro"); + let output = run_success_in( + context, + &[ + "run", + "--detach", + "--dry-run", + "--auto-approve", + "--no-retro", + "--sandbox", + "local", + workflow.to_str().unwrap(), + ], + &context.temp_dir, + ); + let run_id = stdout(&output) + .lines() + .find(|line| !line.trim().is_empty()) + .map(str::trim) + .expect("run --detach should print a run ID") + .to_string(); + let run = resolve_run(context, &run_id); + let deadline = Instant::now() + COMMAND_TIMEOUT; + while !run.run_dir.join("progress.jsonl").exists() { + assert!( + Instant::now() < deadline, + "timed out waiting for progress.jsonl for {run_id}" + ); + std::thread::sleep(Duration::from_millis(50)); + } + 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_project_fixture(context: &TestContext) -> ProjectFixture { + let project_dir = context.temp_dir.join("project"); + let fabro_root = project_dir.join("fabro"); + write_text_file( + &project_dir.join("fabro.toml"), + "version = 1\n[fabro]\nroot = \"fabro/\"\n", + ); + std::fs::create_dir_all(fabro_root.join("workflows")) + .unwrap_or_else(|err| panic!("failed to create {}: {err}", fabro_root.display())); + ProjectFixture { + project_dir, + fabro_root, + } +} + +pub(crate) fn setup_asset_sandbox_run(context: &TestContext) -> AssetSandboxSetup { + let workspace_dir = context.temp_dir.join("asset-sandbox"); + 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("asset_sandbox.fabro"), + r#"digraph AssetSandbox { + graph [goal="Exercise asset and sandbox commands", default_max_retries=0] + start [shape=Mdiamond] + exit [shape=Msquare] + create_assets [shape=parallelogram, script="mkdir -p assets/shared assets/node_a sandbox_dir/download_me/nested && printf one > assets/shared/report.txt && printf alpha > assets/node_a/summary.txt && printf keep > sandbox_dir/download_me/root.txt && printf nested > sandbox_dir/download_me/nested/child.txt && sleep 1", max_retries=0] + retry_assets [shape=parallelogram, script="mkdir -p assets/retry && 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="50ms"] + create_colliding [shape=parallelogram, script="mkdir -p assets/other && printf beta > assets/other/summary.txt", max_retries=0] + start -> create_assets -> retry_assets -> create_colliding -> exit +} +"#, + ); + write_text_file( + &workspace_dir.join("run.toml"), + r#"version = 1 +graph = "asset_sandbox.fabro" +goal = "Exercise asset and sandbox commands" + +[sandbox] +provider = "local" +preserve = true + +[sandbox.local] +worktree_mode = "never" + +[assets] +include = ["assets/**"] +"#, + ); + + let mut cmd = context.command(); + cmd.current_dir(&workspace_dir); + cmd.timeout(COMMAND_TIMEOUT); + cmd.env("OPENAI_API_KEY", "test"); + cmd.args([ + "run", + "--auto-approve", + "--no-retro", + "--sandbox", + "local", + "--provider", + "openai", + "run.toml", + ]); + let output = cmd.output().expect("command should execute"); + if !output.status.success() { + panic!( + "command failed: fabro run --auto-approve --no-retro --sandbox local --provider openai run.toml\nstdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + } + + let run = only_run(context); + assert!( + run.run_dir + .join("cache/artifacts/assets/retry_assets/retry_2/manifest.json") + .exists(), + "setup F should materialize retry_2 assets" + ); + assert!( + run.run_dir.join("sandbox.json").exists(), + "setup F should persist sandbox.json" + ); + + AssetSandboxSetup { run, workspace_dir } +} + +pub(crate) fn add_project_workflow( + project: &ProjectFixture, + name: &str, + goal: &str, + dot_source: &str, +) -> PathBuf { + let workflow_dir = project.fabro_root.join("workflows").join(name); + std::fs::create_dir_all(&workflow_dir) + .unwrap_or_else(|err| panic!("failed to create {}: {err}", workflow_dir.display())); + write_text_file(&workflow_dir.join("workflow.fabro"), dot_source); + write_text_file( + &workflow_dir.join("workflow.toml"), + &format!("version = 1\ngoal = {goal:?}\ngraph = \"workflow.fabro\"\n"), + ); + workflow_dir +} + +pub(crate) fn add_user_workflow(context: &TestContext, name: &str, goal: &str) -> PathBuf { + let workflow_dir = context.home_dir.join(".fabro/workflows").join(name); + std::fs::create_dir_all(&workflow_dir) + .unwrap_or_else(|err| panic!("failed to create {}: {err}", workflow_dir.display())); + write_text_file( + &workflow_dir.join("workflow.toml"), + &format!("version = 1\ngoal = {goal:?}\ngraph = \"workflow.fabro\"\n"), + ); + write_text_file( + &workflow_dir.join("workflow.fabro"), + &format!( + "digraph {} {{\n graph [goal={goal:?}]\n start [shape=Mdiamond]\n exit [shape=Msquare]\n start -> exit\n}}\n", + to_pascal_case(name), + ), + ); + workflow_dir +} + +pub(crate) fn write_sleep_workflow(path: &Path, name: &str, goal: &str, sleep_seconds: u64) { + write_text_file( + path, + &format!( + "digraph {} {{\n graph [goal={goal:?}]\n start [shape=Mdiamond]\n exit [shape=Msquare]\n wait [shape=parallelogram, script=\"sleep {sleep_seconds}\"]\n start -> wait -> exit\n}}\n", + to_pascal_case(name), + ), + ); +} + +pub(crate) fn wait_for_status(run_dir: &Path, expected: &[&str]) -> String { + let deadline = Instant::now() + COMMAND_TIMEOUT; + loop { + if let Some(status) = read_json_if_exists(&run_dir.join("status.json")) + .and_then(|value| value["status"].as_str().map(ToOwned::to_owned)) + { + if expected.iter().any(|candidate| *candidate == status) { + return status; + } + } + assert!( + Instant::now() < deadline, + "timed out waiting for status {:?} in {}", + expected, + run_dir.display() + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +pub(crate) fn only_run(context: &TestContext) -> RunSetup { + let runs_dir = context.storage_dir.join("runs"); + let entries: Vec<_> = std::fs::read_dir(&runs_dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", runs_dir.display())) + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + assert_eq!( + entries.len(), + 1, + "expected exactly one run under {}", + runs_dir.display() + ); + let run_dir = entries[0].clone(); + let run_id = read_json(&run_dir.join("run.json"))["run_id"] + .as_str() + .expect("run.json should include run_id") + .to_string(); + RunSetup { run_id, run_dir } +} + +pub(crate) fn git_filters(context: &TestContext) -> Vec<(String, String)> { + let mut filters = context.filters(); + filters.push(( + r"\b[0-9A-HJKMNP-TV-Z]{8}\b".to_string(), + "[RUN_PREFIX]".to_string(), + )); + filters.push((r"\b[0-9a-f]{7,40}\b".to_string(), "[SHA]".to_string())); + filters +} + +pub(crate) fn resolve_run(context: &TestContext, run_id: &str) -> RunSetup { + let deadline = Instant::now() + COMMAND_TIMEOUT; + loop { + if let Some(run_dir) = find_run_dir(&context.storage_dir, run_id) { + return RunSetup { + run_id: run_id.to_string(), + run_dir, + }; + } + assert!( + Instant::now() < deadline, + "timed out waiting for run dir for {run_id}" + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +pub(crate) fn find_run_dir(storage_dir: &Path, run_id: &str) -> Option { + let runs_dir = storage_dir.join("runs"); + let entries = std::fs::read_dir(&runs_dir).ok()?; + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.is_dir() + && path + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with(run_id)) + }) +} + +pub(crate) fn git_stdout(repo_dir: &Path, args: &[&str]) -> String { + stdout(&git_success(repo_dir, args)) +} + +pub(crate) fn metadata_run_ids(repo_dir: &Path) -> BTreeSet { + git_stdout(repo_dir, &["branch", "--format=%(refname:short)"]) + .lines() + .map(str::trim) + .filter_map(|line| line.strip_prefix("fabro/meta/")) + .map(ToOwned::to_owned) + .collect() +} + +pub(crate) fn run_branch_commits(repo_dir: &Path, run_id: &str) -> Vec { + git_stdout( + repo_dir, + &["rev-list", "--reverse", &format!("fabro/run/{run_id}")], + ) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +pub(crate) fn run_branch_commits_since_base( + repo_dir: &Path, + run_id: &str, + base_sha: &str, +) -> Vec { + git_stdout( + repo_dir, + &[ + "rev-list", + "--reverse", + &format!("{base_sha}..fabro/run/{run_id}"), + ], + ) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +pub(crate) fn git_show_json(repo_dir: &Path, revspec: &str) -> Value { + let output = git_success(repo_dir, &["show", revspec]); + serde_json::from_str(&stdout(&output)) + .unwrap_or_else(|err| panic!("failed to parse git show {revspec}: {err}")) +} + +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) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) + .filter_map(Result::ok) + .map(|entry| entry.path()) + .collect(); + children.sort(); + + for path in children { + if path.is_dir() { + visit(root, &path, entries); + continue; + } + + let rel = path + .strip_prefix(root) + .unwrap_or_else(|err| panic!("failed to strip prefix {}: {err}", root.display())) + .display() + .to_string(); + let contents = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + entries.push(format!("{rel} = {contents}")); + } + } + + if !root.exists() { + return Vec::new(); + } + + let mut entries = Vec::new(); + visit(root, root, &mut entries); + entries +} + +pub(crate) fn compact_inspect(output: &Output) -> Value { + let items: Vec = + serde_json::from_str(&stdout(output)).expect("inspect output should be valid JSON"); + Value::Array( + items.into_iter() + .map(|item| { + let run_record = item["run_record"].clone(); + let checkpoint = item["checkpoint"].clone(); + let conclusion = item["conclusion"].clone(); + let sandbox = item["sandbox"].clone(); + serde_json::json!({ + "run_id": "[ULID]", + "status": item["status"], + "run_record": { + "goal": run_record.pointer("/settings/goal"), + "workflow_name": run_record.pointer("/graph/name"), + "workflow_slug": run_record.pointer("/workflow_slug"), + "sandbox_provider": run_record.pointer("/settings/sandbox/provider"), + "dry_run": run_record.pointer("/settings/dry_run"), + }, + "start_record": item["start_record"].as_object().map(|record| { + serde_json::json!({ + "has_start_time": record.contains_key("start_time"), + }) + }), + "conclusion": conclusion.as_object().map(|_| { + serde_json::json!({ + "status": conclusion["status"], + "duration_ms": "[DURATION_MS]", + "stage_count": conclusion["stages"].as_array().map(|stages| stages.len()), + }) + }), + "checkpoint": checkpoint.as_object().map(|_| { + serde_json::json!({ + "current_node": checkpoint["current_node"], + "completed_nodes": checkpoint["completed_nodes"], + "next_node_id": checkpoint["next_node_id"], + }) + }), + "sandbox": sandbox.as_object().map(|_| { + serde_json::json!({ + "provider": sandbox["provider"], + }) + }), + }) + }) + .collect(), + ) +} + +pub(crate) fn compact_git_inspect(output: &Output) -> Value { + let items: Vec = + serde_json::from_str(&stdout(output)).expect("inspect output should be valid JSON"); + Value::Array( + items.into_iter() + .map(|item| { + let run_record = item["run_record"].clone(); + let start_record = item["start_record"].clone(); + let checkpoint = item["checkpoint"].clone(); + let conclusion = item["conclusion"].clone(); + let sandbox = item["sandbox"].clone(); + serde_json::json!({ + "run_id": "[ULID]", + "status": item["status"], + "run_record": { + "goal": run_record.pointer("/settings/goal"), + "workflow_name": run_record.pointer("/graph/name"), + "workflow_slug": run_record.pointer("/workflow_slug"), + "llm_provider": run_record.pointer("/settings/llm/provider"), + "sandbox_provider": run_record.pointer("/settings/sandbox/provider"), + }, + "start_record": start_record.as_object().map(|_| { + serde_json::json!({ + "has_start_time": true, + "run_branch": "fabro/run/[ULID]", + "base_sha": "[SHA]", + }) + }), + "conclusion": conclusion.as_object().map(|_| { + serde_json::json!({ + "status": conclusion["status"], + "duration_ms": "[DURATION_MS]", + "final_git_commit_sha": "[SHA]", + "stage_count": conclusion["stages"].as_array().map(|stages| stages.len()), + }) + }), + "checkpoint": checkpoint.as_object().map(|_| { + serde_json::json!({ + "current_node": checkpoint["current_node"], + "completed_nodes": checkpoint["completed_nodes"], + "next_node_id": checkpoint["next_node_id"], + "git_commit_sha": "[SHA]", + }) + }), + "sandbox": sandbox.as_object().map(|_| { + serde_json::json!({ + "provider": sandbox["provider"], + "working_directory": "[WORKTREE]", + }) + }), + }) + }) + .collect(), + ) +} + +fn read_json_if_exists(path: &Path) -> Option { + if !path.exists() { + return None; + } + Some(read_json(path)) +} + +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 base_sha = git_stdout(&repo_dir, &["rev-parse", "HEAD"]) + .trim() + .to_string(); + + let mut cmd = context.command(); + cmd.current_dir(&repo_dir); + cmd.env("OPENAI_API_KEY", "test"); + cmd.args([ + "run", + "--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 = only_run(context); + let start = read_json(&run.run_dir.join("start.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.run_dir.join("final.patch").exists(), + "changed git-backed run should emit final.patch" + ); + assert!( + run.run_dir.join("nodes/step_one/diff.patch").exists(), + "changed git-backed run should emit a diff for step_one" + ); + assert!( + run.run_dir.join("nodes/step_two/diff.patch").exists(), + "changed git-backed run should emit a diff for step_two" + ); + } + GitWorkflowKind::Noop => { + assert!( + !run.run_dir.join("final.patch").exists(), + "no-op git-backed run should not emit final.patch" + ); + } + } + + GitRunSetup { + run, + repo_dir, + base_sha, + } +} + +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) + .unwrap_or_else(|err| panic!("failed to create {}: {err}", parent.display())); + } + std::fs::write(path, content) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display())); +} + +fn to_pascal_case(s: &str) -> String { + s.split(['-', '_']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => { + let upper: String = first.to_uppercase().collect(); + format!("{upper}{rest}", rest = chars.as_str()) + } + None => String::new(), + } + }) + .collect() +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_df.rs b/lib/crates/fabro-cli/tests/it/cmd/system_df.rs index 32e2f887a..9a0bafd60 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/system_df.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/system_df.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::setup_completed_dry_run; + #[test] fn help() { let context = test_context!(); @@ -23,3 +25,71 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn system_df_summarizes_runs_logs_and_databases() { + let context = test_context!(); + setup_completed_dry_run(&context); + std::fs::create_dir_all(context.storage_dir.join("logs")).unwrap(); + std::fs::write(context.storage_dir.join("logs/cli.log"), b"log line\n").unwrap(); + std::fs::write(context.storage_dir.join("fabro.db"), b"db").unwrap(); + std::fs::write(context.storage_dir.join("fabro.db-wal"), b"wal").unwrap(); + + let mut filters = context.filters(); + filters.push(( + r"\b\d+(\.\d+)?\s(?:[KMGT]?B|B)\b".to_string(), + "[SIZE]".to_string(), + )); + + let mut cmd = context.command(); + cmd.args(["system", "df"]); + fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + TYPE COUNT ACTIVE SIZE RECLAIMABLE + Runs 1 0 [SIZE] [SIZE] (100%) + Logs 1 - [SIZE] [SIZE] (100%) + Databases 2 - [SIZE] [SIZE] (0%) + + Data directory: [STORAGE_DIR] + ----- stderr ----- + "); +} + +#[test] +fn system_df_verbose_lists_runs_with_reclaimable_marker() { + let context = test_context!(); + setup_completed_dry_run(&context); + + let mut filters = context.filters(); + filters.push(( + r"\b\d+(\.\d+)?\s(?:[KMGT]?B|B)\b".to_string(), + "[SIZE]".to_string(), + )); + filters.push(( + r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(), + "[RUN_PREFIX]".to_string(), + )); + filters.push((r"\b\d+[mhd]\b".to_string(), "[AGE]".to_string())); + + let mut cmd = context.command(); + cmd.args(["system", "df", "-v"]); + fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + TYPE COUNT ACTIVE SIZE RECLAIMABLE + Runs 1 0 [SIZE] [SIZE] (100%) + Logs 0 - [SIZE] [SIZE] (0%) + Databases 0 - [SIZE] [SIZE] (0%) + + Data directory: [STORAGE_DIR] + + RUN ID WORKFLOW STATUS AGE SIZE + [RUN_PREFIX] Simple succeeded [AGE] [SIZE] * + + * = reclaimable + ----- stderr ----- + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs b/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs index 7b4ba8c11..d4b0cd9b2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{setup_completed_dry_run, setup_created_dry_run}; + #[test] fn help() { let context = test_context!(); @@ -29,3 +31,86 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn system_prune_dry_run_lists_matching_runs_without_deleting() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\b\d{8}-dry-run-[0-9A-HJKMNP-TV-Z]{26}\b".to_string(), + "[RUN_DIR]".to_string(), + )); + filters.push(( + r"\b\d+(\.\d+)?\s(?:[KMGT]?B|B)\b".to_string(), + "[SIZE]".to_string(), + )); + + let mut cmd = context.command(); + cmd.args(["system", "prune", "--workflow", "Simple"]); + fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + would delete: 20260330-dry-run-[ULID] (Simple) + ----- stderr ----- + + 1 run(s) would be deleted ([SIZE] freed). Pass --yes to confirm. + "); + assert!( + run.run_dir.exists(), + "dry-run prune should not delete the run" + ); +} + +#[test] +fn system_prune_yes_deletes_matching_runs() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\b\d+(\.\d+)?\s(?:[KMGT]?B|B)\b".to_string(), + "[SIZE]".to_string(), + )); + + let mut cmd = context.command(); + cmd.args(["system", "prune", "--workflow", "Simple", "--yes"]); + fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + 1 run(s) deleted ([SIZE] freed). + "); + assert!(!run.run_dir.exists(), "matching run should be deleted"); + + let mut ps = context.ps(); + ps.args(["-a", "--json"]); + fabro_snapshot!(context.filters(), ps, @r###" + success: true + exit_code: 0 + ----- stdout ----- + [] + ----- stderr ----- + "###); +} + +#[test] +fn system_prune_does_not_delete_active_or_submitted_runs() { + let context = test_context!(); + let run = setup_created_dry_run(&context); + let mut cmd = context.command(); + cmd.args(["system", "prune", "--workflow", "Simple", "--yes"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + No matching runs to prune. + "); + assert!( + run.run_dir.exists(), + "submitted run should not be deleted by system prune" + ); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/upgrade.rs b/lib/crates/fabro-cli/tests/it/cmd/upgrade.rs index f009d401f..b39fafa73 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/upgrade.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/upgrade.rs @@ -26,3 +26,39 @@ fn help() { ----- stderr ----- "#); } + +#[test] +fn upgrade_invalid_version_errors() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["upgrade", "--version", "not-a-semver"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: invalid version: not-a-semver + > unexpected character 'n' while parsing major version number + "); +} + +#[test] +fn upgrade_already_on_current_version_short_circuits() { + let context = test_context!(); + let mut filters = context.filters(); + filters.push(( + regex::escape(env!("CARGO_PKG_VERSION")), + "[VERSION]".to_string(), + )); + let mut cmd = context.command(); + cmd.args(["upgrade", "--version", env!("CARGO_PKG_VERSION")]); + + fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Already on version [VERSION] + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/wait.rs b/lib/crates/fabro-cli/tests/it/cmd/wait.rs index 0b30c8a42..597ba4132 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/wait.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/wait.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{setup_completed_dry_run, setup_created_dry_run}; + #[test] fn help() { let context = test_context!(); @@ -29,3 +31,65 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn wait_completed_run_prints_success_summary() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r"\b\d+(\.\d+)?(ms|s)\b".to_string(), + "[DURATION]".to_string(), + )); + let mut cmd = context.command(); + cmd.args(["wait", &run.run_id]); + + fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Succeeded [ULID] [DURATION] + "); +} + +#[test] +fn wait_completed_run_json_outputs_status_and_duration() { + let context = test_context!(); + let run = setup_completed_dry_run(&context); + let mut filters = context.filters(); + filters.push(( + r#""duration_ms":\s*\d+"#.to_string(), + r#""duration_ms": [DURATION_MS]"#.to_string(), + )); + let mut cmd = context.command(); + cmd.args(["wait", "--json", &run.run_id]); + + fabro_snapshot!(filters, cmd, @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "run_id": "[ULID]", + "status": "succeeded", + "duration_ms": [DURATION_MS] + } + ----- stderr ----- + "###); +} + +#[test] +fn wait_submitted_run_times_out() { + let context = test_context!(); + let run = setup_created_dry_run(&context); + let mut cmd = context.command(); + cmd.args(["wait", "--timeout", "1", "--interval", "10", &run.run_id]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Timed out after 1s waiting for run '[ULID]' + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs b/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs index a09fd0249..941612cf4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs @@ -1,5 +1,9 @@ +use insta::assert_snapshot; + use fabro_test::{fabro_snapshot, test_context}; +use super::support::setup_project_fixture; + #[test] fn help() { let context = test_context!(); @@ -27,3 +31,126 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn workflow_create_writes_scaffold_files() { + let context = test_context!(); + let project = setup_project_fixture(&context); + let mut cmd = context.command(); + cmd.current_dir(&project.project_dir); + cmd.args(["workflow", "create", "hello-world"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + ✔ fabro/workflows/hello-world/workflow.fabro + ✔ fabro/workflows/hello-world/workflow.toml + + Workflow created! Next steps: + + 1. Edit the graph: fabro/workflows/hello-world/workflow.fabro + 2. Validate: fabro validate hello-world + 3. Run: fabro run hello-world + "); + + assert_snapshot!( + std::fs::read_to_string(project.fabro_root.join("workflows/hello-world/workflow.fabro")) + .unwrap(), + @r###" + digraph HelloWorld { + graph [goal="TODO: describe the goal"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + main [label="Main", prompt="TODO: describe what this agent should do"] + + start -> main -> exit + } + "### + ); + assert_snapshot!( + std::fs::read_to_string(project.fabro_root.join("workflows/hello-world/workflow.toml")) + .unwrap(), + @r###" + version = 1 + "### + ); +} + +#[test] +fn workflow_create_uses_explicit_goal_in_scaffold() { + let context = test_context!(); + let project = setup_project_fixture(&context); + let mut cmd = context.command(); + cmd.current_dir(&project.project_dir); + cmd.args([ + "workflow", + "create", + "--goal", + "Ship a polished release", + "release-flow", + ]); + cmd.assert().success(); + + assert_snapshot!( + std::fs::read_to_string(project.fabro_root.join("workflows/release-flow/workflow.fabro")) + .unwrap(), + @r###" + digraph ReleaseFlow { + graph [goal="Ship a polished release"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + main [label="Main", prompt="TODO: describe what this agent should do"] + + start -> main -> exit + } + "### + ); +} + +#[test] +fn workflow_create_rejects_existing_workflow() { + let context = test_context!(); + let project = setup_project_fixture(&context); + std::fs::create_dir_all(project.fabro_root.join("workflows/existing")).unwrap(); + std::fs::write( + project.fabro_root.join("workflows/existing/workflow.toml"), + "version = 1\n", + ) + .unwrap(); + + let mut cmd = context.command(); + cmd.current_dir(&project.project_dir); + cmd.args(["workflow", "create", "existing"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Workflow 'existing' already exists at [TEMP_DIR]/project/fabro/workflows/existing + "); +} + +#[test] +fn workflow_create_errors_without_project_config() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.current_dir(&context.temp_dir); + cmd.args(["workflow", "create", "hello-world"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: No fabro.toml found in [TEMP_DIR] or any parent directory + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow_list.rs b/lib/crates/fabro-cli/tests/it/cmd/workflow_list.rs index 2927aa7d7..15f1e824c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/workflow_list.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/workflow_list.rs @@ -1,5 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; +use super::support::{add_project_workflow, add_user_workflow, setup_project_fixture}; + #[test] fn help() { let context = test_context!(); @@ -23,3 +25,54 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn workflow_list_errors_without_project_config() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.current_dir(&context.temp_dir); + cmd.args(["workflow", "list"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: No fabro.toml found in [TEMP_DIR] or any parent directory + "); +} + +#[test] +fn workflow_list_shows_project_and_user_sections() { + let context = test_context!(); + let project = setup_project_fixture(&context); + add_project_workflow( + &project, + "project-alpha", + "Project alpha goal", + "digraph ProjectAlpha {\n graph [goal=\"Project alpha goal\"]\n start [shape=Mdiamond]\n exit [shape=Msquare]\n main [label=\"Main\", prompt=\"Do project alpha\"]\n start -> main -> exit\n}\n", + ); + add_user_workflow(&context, "user-beta", "User beta goal"); + + let mut cmd = context.command(); + cmd.current_dir(&project.project_dir); + cmd.args(["workflow", "list"]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + 2 workflow(s) found + + User Workflows (~/.fabro/workflows) + + NAME DESCRIPTION + user-beta User beta goal + + Project Workflows (fabro/workflows) + + NAME DESCRIPTION + project-alpha Project alpha goal + "); +} diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index 097f94f3c..067713c88 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -53,14 +53,26 @@ impl TestContext { std::fs::create_dir_all(&storage_dir).expect("failed to create storage_dir"); let filters = vec![ + ( + regex::escape(&format!("/private{}", temp_dir.to_str().unwrap())), + "[TEMP_DIR]".to_string(), + ), ( regex::escape(temp_dir.to_str().unwrap()), "[TEMP_DIR]".to_string(), ), + ( + regex::escape(&format!("/private{}", home_dir.to_str().unwrap())), + "[HOME_DIR]".to_string(), + ), ( regex::escape(home_dir.to_str().unwrap()), "[HOME_DIR]".to_string(), ), + ( + regex::escape(&format!("/private{}", storage_dir.to_str().unwrap())), + "[STORAGE_DIR]".to_string(), + ), ( regex::escape(storage_dir.to_str().unwrap()), "[STORAGE_DIR]".to_string(), diff --git a/lib/crates/fabro-workflow/src/assets.rs b/lib/crates/fabro-workflow/src/assets.rs index d08c4ce35..7b7016dc8 100644 --- a/lib/crates/fabro-workflow/src/assets.rs +++ b/lib/crates/fabro-workflow/src/assets.rs @@ -80,6 +80,13 @@ pub fn scan_assets( } } + entries.sort_by(|left, right| { + left.node_slug + .cmp(&right.node_slug) + .then_with(|| left.retry.cmp(&right.retry)) + .then_with(|| left.relative_path.cmp(&right.relative_path)) + }); + Ok(entries) } diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 48aa377d7..7d5617a31 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -21,6 +21,7 @@ use crate::event::{ EventEmitter, RunNoticeLevel, StoreProgressLogger, WorkflowRunEvent, append_progress_event, build_redacted_event_payload, }; +use crate::git::MetadataStore; use crate::handler::HandlerRegistry; use crate::outcome::{Outcome, StageStatus}; use crate::pipeline::{ @@ -187,7 +188,7 @@ pub(super) async fn execute_persisted_run( } }; - let session = match RunSession::new(&persisted, services) { + let session = match RunSession::new(&persisted, services).await { Ok(session) => session, Err(err) => { let _ = persist_detached_failure( @@ -253,10 +254,22 @@ async fn persist_terminal_engine_failure( } impl RunSession { - fn new(persisted: &Persisted, services: StartServices) -> Result { + async fn new(persisted: &Persisted, services: StartServices) -> Result { let record = persisted.run_record(); let mut settings = record.settings.clone(); let working_directory = record.working_directory.clone(); + let git = services + .run_store + .get_start() + .await + .map_err(|err| FabroError::engine(err.to_string()))? + .and_then(|start| { + start.run_branch.as_ref().map(|_| GitCheckpointOptions { + base_sha: start.base_sha.clone(), + run_branch: start.run_branch.clone(), + meta_branch: Some(MetadataStore::branch_name(&record.run_id.to_string())), + }) + }); if let Some(env) = settings .sandbox @@ -371,7 +384,7 @@ impl RunSession { devcontainer, seed_context: None, run_store: services.run_store, - git: None, + git, github_app: services.github_app.clone(), worktree_mode: Some(resolve_worktree_mode(&settings)), registry_override: services.registry_override, diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 640738518..139ee6f28 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -7,7 +7,8 @@ use fabro_agent::Sandbox; use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner}; use fabro_llm::client::Client; use fabro_sandbox::{ - ReadBeforeWriteSandbox, SandboxEventCallback, WorkdirStrategy, WorktreeConfig, WorktreeSandbox, + ReadBeforeWriteSandbox, SandboxEventCallback, SandboxSpec, WorkdirStrategy, WorktreeConfig, + WorktreeSandbox, }; use shlex::try_quote; @@ -29,6 +30,7 @@ struct WorktreePlan { branch_name: String, base_sha: String, worktree_path: PathBuf, + skip_branch_creation: bool, } async fn run_hooks( @@ -64,6 +66,20 @@ async fn resolve_worktree_plan( return Ok(None); }; + if options.checkpoint.is_some() && matches!(options.sandbox, SandboxSpec::Local { .. }) { + if let Some(git) = options.run_options.git.as_ref() { + if let (Some(run_branch), Some(base_sha)) = (&git.run_branch, &git.base_sha) { + options.run_options.display_base_sha = Some(base_sha.clone()); + return Ok(Some(WorktreePlan { + branch_name: run_branch.clone(), + base_sha: base_sha.clone(), + worktree_path: options.run_options.run_dir.join("worktree"), + skip_branch_creation: true, + })); + } + } + } + let host_repo_path = options .run_options .host_repo_path @@ -157,6 +173,7 @@ async fn resolve_worktree_plan( branch_name: format!("{}{}", git::RUN_BRANCH_PREFIX, options.run_id), base_sha, worktree_path: options.run_options.run_dir.join("worktree"), + skip_branch_creation: false, })) } Err(e) => { @@ -424,7 +441,7 @@ pub async fn initialize( branch_name: plan.branch_name.clone(), base_sha: plan.base_sha.clone(), worktree_path: plan.worktree_path.to_string_lossy().into_owned(), - skip_branch_creation: false, + skip_branch_creation: plan.skip_branch_creation, }, ); worktree.set_event_callback(Arc::clone(&options.emitter).worktree_callback());