Add CLI integration test coverage

This commit is contained in:
Bryan Helmkamp 2026-03-30 18:23:24 -04:00
parent 55df05fe43
commit 73cc431d05
43 changed files with 3343 additions and 27 deletions

View file

@ -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");
}
}

View file

@ -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<String> {
serde_json::to_string(event.payload.as_value()).map_err(Into::into)
}

View file

@ -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.");

View file

@ -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<std::process::Child> {
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<std::process::Ch
Ok(child)
}
fn ensure_startable_run(run_dir: &Path) -> 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();

View file

@ -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(());

View file

@ -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.
");
}

View file

@ -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 -----
"#);
}

View file

@ -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]
");
}

View file

@ -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
"
);
}

View file

@ -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");
}

View file

@ -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 -----
");
}

View file

@ -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() {

View file

@ -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()));
}

View file

@ -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
");
}

View file

@ -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]"
}
}
]
"###
);
}

View file

@ -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 -----
"###);
}

View file

@ -55,6 +55,7 @@ mod ssh;
mod start;
mod store;
mod store_dump;
mod support;
mod system;
mod system_df;
mod system_prune;

View file

@ -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
");
}

View file

@ -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 }
");
}

View file

@ -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?
");
}

View file

@ -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
");
}

View file

@ -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)
");
}

View file

@ -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
");
}

View file

@ -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')
");
}

View file

@ -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 -----
"###);
}

View file

@ -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 <url>` 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
");
}

View file

@ -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());
}

View file

@ -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}"
);
}

View file

@ -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"
);
}

View file

@ -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')
");
}

View file

@ -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
");
}

View file

@ -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<String> = walkdir::WalkDir::new(output_dir)
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(|entry| {
entry
.path()
.strip_prefix(output_dir)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
files.sort();
files.join("\n") + "\n"
}

View file

@ -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<PathBuf> {
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<String> {
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<String> {
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<String> {
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<String> {
fn visit(root: &Path, dir: &Path, entries: &mut Vec<String>) {
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<Value> =
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<Value> =
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<Value> {
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()
}

View file

@ -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 -----
");
}

View file

@ -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"
);
}

View file

@ -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]
");
}

View file

@ -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]'
");
}

View file

@ -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
");
}

View file

@ -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
");
}

View file

@ -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(),

View file

@ -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)
}

View file

@ -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<Self, FabroError> {
async fn new(persisted: &Persisted, services: StartServices) -> Result<Self, FabroError> {
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,

View file

@ -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());