fix(tests): isolate CLI integration tests from repo project config

Config discovery walks from the workflow file's parent directory, so
tests using fixtures at their repo path (test/simple.fabro) would find
the repo's .fabro/project.toml. This caused settings like preserve=true
to leak into tests and break sandbox cleanup event assertions.

Add TestContext::install_fixture() which copies fixtures into the test's
temp dir. Update all CLI run/attach/start tests to use it. Remove the
now-unused example_fixture() function.

Restore preserve=true in .fabro/project.toml — tests are now isolated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-15 11:18:19 -04:00
parent 5fa6b6e7a8
commit 2106aba53b
No known key found for this signature in database
9 changed files with 83 additions and 45 deletions

View file

@ -6,6 +6,7 @@ draft = false
[run.sandbox]
provider = "daytona"
preserve = true
[run.sandbox.daytona]
auto_stop_interval = 30

View file

@ -9,7 +9,7 @@ use serde_json::Value;
use super::support::{
output_stdout, resolve_run, server_endpoint, wait_for_status, write_gated_workflow,
};
use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, unique_run_id};
use crate::support::{fabro_json_snapshot, run_output_filters, unique_run_id};
const SHARED_DAEMON_TIMEOUT: Duration = Duration::from_secs(30);
@ -121,6 +121,7 @@ fn wait_for_output_signal(
fn attach_replays_completed_detached_run() {
let context = test_context!();
let run_id = unique_run_id();
let workflow = context.install_fixture("simple.fabro");
context
.command()
@ -132,7 +133,7 @@ fn attach_replays_completed_detached_run() {
"--detach",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.assert()
.success();

View file

@ -7,7 +7,7 @@ use httpmock::MockServer;
use serde_json::Value;
use super::support::{output_stderr, wait_for_event_names};
use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, unique_run_id};
use crate::support::{fabro_json_snapshot, run_output_filters, unique_run_id};
fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
serde_json::json!({
@ -185,6 +185,7 @@ fn detach_uses_explicit_server_target_and_prints_remote_run_id() {
.body(run_status_response(run_id.as_str(), "queued").to_string());
});
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
@ -193,7 +194,7 @@ fn detach_uses_explicit_server_target_and_prints_remote_run_id() {
"--detach",
"--dry-run",
"--auto-approve",
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
@ -239,13 +240,14 @@ fn detach_uses_configured_server_target_without_server_flag() {
),
);
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
"--detach",
"--dry-run",
"--auto-approve",
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
@ -358,6 +360,7 @@ digraph VaultWorkerLlm {
#[test]
fn detach_rejects_storage_dir_flag() {
let context = test_context!();
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
@ -367,7 +370,7 @@ fn detach_rejects_storage_dir_flag() {
"--dry-run",
"--auto-approve",
"--no-retro",
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
@ -417,6 +420,7 @@ fn detach_cli_server_target_overrides_configured_server_target() {
),
);
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
@ -425,7 +429,7 @@ fn detach_cli_server_target_overrides_configured_server_target() {
"--detach",
"--dry-run",
"--auto-approve",
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
@ -521,6 +525,7 @@ fn remote_foreground_run_consumes_paginated_events_and_prints_server_backed_summ
.body(remote_run_state_response().to_string());
});
let workflow = context.install_fixture("simple.fabro");
let output = context
.run_cmd()
.args([
@ -528,7 +533,7 @@ fn remote_foreground_run_consumes_paginated_events_and_prints_server_backed_summ
&format!("{}/api/v1", server.base_url()),
"--dry-run",
"--auto-approve",
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
@ -630,16 +635,17 @@ include = ["assets/**"]
#[test]
fn dry_run_simple() {
let context = test_context!();
let workflow = context.install_fixture("simple.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(example_fixture("simple.fabro"));
cmd.arg(&workflow);
fabro_snapshot!(run_output_filters(&context), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Simple (4 nodes, 3 edges)
Graph: [FIXTURES]/simple.fabro
Graph: [TEMP_DIR]/simple.fabro
Goal: Run tests and report results
Run: [ULID]
@ -671,10 +677,11 @@ fn dry_run_with_goal_file_reads_contents_into_goal() {
let goal_path = goal_dir.path().join("goal.md");
std::fs::write(&goal_path, "Ship the rate-limiting feature end to end.\n").unwrap();
let workflow = context.install_fixture("simple.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve", "--goal-file"]);
cmd.arg(&goal_path);
cmd.arg(example_fixture("simple.fabro"));
cmd.arg(&workflow);
let output = cmd.output().expect("run command should execute");
assert!(
@ -698,10 +705,11 @@ fn dry_run_rejects_goal_and_goal_file_together() {
let goal_path = goal_dir.path().join("goal.md");
std::fs::write(&goal_path, "never read").unwrap();
let workflow = context.install_fixture("simple.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--goal", "inline override", "--goal-file"]);
cmd.arg(&goal_path);
cmd.arg(example_fixture("simple.fabro"));
cmd.arg(&workflow);
let output = cmd.output().expect("run command should execute");
assert!(
!output.status.success(),
@ -720,6 +728,7 @@ fn dry_run_rejects_goal_and_goal_file_together() {
fn dry_run_persists_event_history_in_store() {
let context = test_context!();
let run_id = unique_run_id();
let workflow = context.install_fixture("simple.fabro");
context
.command()
@ -731,7 +740,7 @@ fn dry_run_persists_event_history_in_store() {
"local",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.assert()
.success();
@ -818,6 +827,7 @@ fn dry_run_persists_event_history_in_store() {
fn run_id_passthrough_uses_provided_ulid() {
let context = test_context!();
let run_id = unique_run_id();
let workflow = context.install_fixture("simple.fabro");
context
.command()
@ -827,7 +837,7 @@ fn run_id_passthrough_uses_provided_ulid() {
"--auto-approve",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.assert()
.success();
@ -902,12 +912,13 @@ fn json_run_requires_manual_input_for_human_gates_without_auto_approve() {
#[test]
fn detach_prints_ulid_and_exits() {
let context = test_context!();
let workflow = context.install_fixture("simple.fabro");
let mut cmd = context.run_cmd();
cmd.args([
"--detach",
"--dry-run",
"--auto-approve",
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
@ -922,6 +933,7 @@ fn detach_prints_ulid_and_exits() {
fn detach_creates_run_dir_with_detach_log() {
let context = test_context!();
let run_id = unique_run_id();
let workflow = context.install_fixture("simple.fabro");
context
.run_cmd()
@ -931,7 +943,7 @@ fn detach_creates_run_dir_with_detach_log() {
"--auto-approve",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.assert()
.success();

View file

@ -1,7 +1,7 @@
use fabro_test::{fabro_snapshot, test_context};
use super::support::{output_stdout, resolve_run, wait_for_status, write_gated_workflow};
use crate::support::{example_fixture, fabro_json_snapshot, unique_run_id};
use crate::support::{fabro_json_snapshot, unique_run_id};
const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
@ -37,6 +37,7 @@ fn help() {
fn start_by_run_id_starts_created_run() {
let context = test_context!();
let run_id = unique_run_id();
let workflow = context.install_fixture("simple.fabro");
context
.command()
@ -46,7 +47,7 @@ fn start_by_run_id_starts_created_run() {
"--auto-approve",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.assert()
.success();
@ -87,6 +88,7 @@ fn start_by_run_id_starts_created_run() {
fn start_by_run_id_starts_created_run_without_run_json_or_status_json() {
let context = test_context!();
let run_id = unique_run_id();
let workflow = context.install_fixture("simple.fabro");
context
.command()
@ -96,7 +98,7 @@ fn start_by_run_id_starts_created_run_without_run_json_or_status_json() {
"--auto-approve",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.assert()
.success();

View file

@ -65,6 +65,11 @@ enum GitWorkflowKind {
Noop,
}
/// Returns the repo-relative path to a test fixture.
///
/// Prefer `TestContext::install_fixture` for tests that run `fabro run`,
/// since config discovery walks from the workflow file's parent directory
/// and can find the repo's `.fabro/project.toml`.
pub(crate) fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("../../../test/{name}"))
@ -115,7 +120,7 @@ fn run_success_in(context: &TestContext, args: &[&str], cwd: &Path) -> Output {
}
pub(crate) fn setup_completed_dry_run(context: &TestContext) -> RunSetup {
let workflow = fixture("simple.fabro");
let workflow = context.install_fixture("simple.fabro");
run_completed_dry_run(context, &workflow)
}
@ -160,7 +165,7 @@ fn run_completed_dry_run(context: &TestContext, workflow: &Path) -> RunSetup {
}
pub(crate) fn setup_created_dry_run(context: &TestContext) -> RunSetup {
let workflow = fixture("simple.fabro");
let workflow = context.install_fixture("simple.fabro");
run_created_dry_run(context, &workflow)
}
@ -228,7 +233,7 @@ fn fast_simple_workflow(context: &TestContext) -> PathBuf {
reason = "This sync integration helper polls run artifacts after spawning a detached CLI process."
)]
pub(crate) fn setup_detached_dry_run(context: &TestContext) -> RunSetup {
let workflow = fixture("simple.fabro");
let workflow = context.install_fixture("simple.fabro");
let run_id = unique_run_id();
let mut cmd = context.run_cmd();
cmd.current_dir(&context.temp_dir);

View file

@ -4,7 +4,7 @@ use fabro_test::test_context;
use serde_json::Value;
use super::{fixture, run_state, timeout_for};
use crate::support::{example_fixture, fabro_json_snapshot, unique_run_id};
use crate::support::{fabro_json_snapshot, unique_run_id};
#[fabro_macros::e2e_test()]
fn local_run_lifecycle() {
@ -107,6 +107,7 @@ fn local_run_lifecycle() {
fn dry_run_create_start_attach_works_with_default_run_lookup() {
let context = test_context!();
let run_id = unique_run_id();
let workflow = context.install_fixture("simple.fabro");
context
.command()
@ -116,7 +117,7 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
"--auto-approve",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.assert()
.success();
@ -154,6 +155,7 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
fn dry_run_detach_attach_works_with_default_run_lookup() {
let context = test_context!();
let run_id = unique_run_id();
let workflow = context.install_fixture("simple.fabro");
context
.command()
@ -164,7 +166,7 @@ fn dry_run_detach_attach_works_with_default_run_lookup() {
"--auto-approve",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
workflow.to_str().unwrap(),
])
.assert()
.success();

View file

@ -1,5 +1,3 @@
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use fabro_store::EventEnvelope;
use fabro_test::TestContext;
@ -44,13 +42,6 @@ macro_rules! fabro_json_snapshot {
pub(crate) use fabro_json_snapshot;
pub(crate) fn example_fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("../../../test/{name}"))
.canonicalize()
.expect("fixture path should exist")
}
pub(crate) fn run_output_filters(context: &TestContext) -> Vec<(String, String)> {
let mut filters = context.filters();
filters.push((r"\b\d+ms\b".to_string(), "[TIME]".to_string()));

View file

@ -1,20 +1,21 @@
use fabro_test::{fabro_snapshot, test_context};
use crate::support::{example_fixture, run_output_filters};
use crate::support::run_output_filters;
#[test]
fn dry_run_branching() {
let context = test_context!();
let workflow = context.install_fixture("branching.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(example_fixture("branching.fabro"));
cmd.arg(&workflow);
fabro_snapshot!(run_output_filters(&context), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Branch (6 nodes, 6 edges)
Graph: [FIXTURES]/branching.fabro
Graph: [TEMP_DIR]/branching.fabro
Goal: Implement and validate a feature
warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry)
@ -41,16 +42,17 @@ fn dry_run_branching() {
#[test]
fn dry_run_conditions() {
let context = test_context!();
let workflow = context.install_fixture("conditions.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(example_fixture("conditions.fabro"));
cmd.arg(&workflow);
fabro_snapshot!(run_output_filters(&context), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Conditions (5 nodes, 5 edges)
Graph: [FIXTURES]/conditions.fabro
Graph: [TEMP_DIR]/conditions.fabro
Goal: Test condition evaluation with OR and parentheses
Run: [ULID]
@ -74,16 +76,17 @@ fn dry_run_conditions() {
#[test]
fn dry_run_parallel() {
let context = test_context!();
let workflow = context.install_fixture("parallel.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(example_fixture("parallel.fabro"));
cmd.arg(&workflow);
fabro_snapshot!(run_output_filters(&context), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Parallel (7 nodes, 7 edges)
Graph: [FIXTURES]/parallel.fabro
Graph: [TEMP_DIR]/parallel.fabro
Goal: Test parallel and fan-in execution
Run: [ULID]
@ -108,16 +111,17 @@ fn dry_run_parallel() {
#[test]
fn dry_run_styled() {
let context = test_context!();
let workflow = context.install_fixture("styled.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(example_fixture("styled.fabro"));
cmd.arg(&workflow);
fabro_snapshot!(run_output_filters(&context), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Styled (5 nodes, 4 edges)
Graph: [FIXTURES]/styled.fabro
Graph: [TEMP_DIR]/styled.fabro
Goal: Build a styled pipeline
Run: [ULID]
@ -142,16 +146,17 @@ fn dry_run_styled() {
#[test]
fn dry_run_legacy_tool() {
let context = test_context!();
let workflow = context.install_fixture("legacy_tool.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(example_fixture("legacy_tool.fabro"));
cmd.arg(&workflow);
fabro_snapshot!(run_output_filters(&context), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: LegacyTool (3 nodes, 2 edges)
Graph: [FIXTURES]/legacy_tool.fabro
Graph: [TEMP_DIR]/legacy_tool.fabro
Goal: Verify backwards compatibility with old tool naming
Run: [ULID]

View file

@ -1105,6 +1105,25 @@ impl TestContext {
self
}
/// Copy a fixture file from the repo `test/` directory into `temp_dir`,
/// isolating the test from the repo's `.fabro/project.toml`.
///
/// Returns the path to the copied file inside `temp_dir`.
pub fn install_fixture(&self, name: &str) -> PathBuf {
let src =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../../test/{name}"));
let src = src
.canonicalize()
.unwrap_or_else(|_| panic!("fixture {name} not found at {}", src.display()));
let dest = self.temp_dir.join(name);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).expect("failed to create parent dirs");
}
std::fs::copy(&src, &dest)
.unwrap_or_else(|_| panic!("failed to copy fixture {name} to {}", dest.display()));
dest
}
/// Initialize a git repository in `temp_dir`.
#[expect(
clippy::disallowed_methods,