mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add CLI integration test coverage
This commit is contained in:
parent
55df05fe43
commit
73cc431d05
43 changed files with 3343 additions and 27 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
"#);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
}
|
||||
}
|
||||
]
|
||||
"###
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
"###);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ mod ssh;
|
|||
mod start;
|
||||
mod store;
|
||||
mod store_dump;
|
||||
mod support;
|
||||
mod system;
|
||||
mod system_df;
|
||||
mod system_prune;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
"###);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
735
lib/crates/fabro-cli/tests/it/cmd/support.rs
Normal file
735
lib/crates/fabro-cli/tests/it/cmd/support.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]'
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue