Default TestContext::command() working directory to temp_dir

Tests were flaky because command() inherited the real repo as the
working directory. When the repo was clean, detached runs attempted
git worktree creation against it, sometimes failing and injecting
extra warning lines into snapshots.

Now command() defaults to the non-git temp_dir, eliminating this
class of flakiness. Tests needing a specific directory override with
.current_dir(). Also canonicalizes fixture paths and adds a
[FIXTURES] snapshot filter via test_context!() macro.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-31 17:51:09 -04:00
parent e95f1a0386
commit b5e5b91d51
No known key found for this signature in database
21 changed files with 73 additions and 70 deletions

View file

@ -103,7 +103,6 @@ fn attach_before_completion_streams_to_finished_state() {
let gate = write_gated_workflow(&context.temp_dir.join("slow.fabro"), "slow", "Run slowly");
let mut run_cmd = context.command();
run_cmd.current_dir(&context.temp_dir);
run_cmd.env("OPENAI_API_KEY", "test");
run_cmd.args([
"run",
@ -136,7 +135,6 @@ fn attach_before_completion_streams_to_finished_state() {
gate.release();
});
let mut attach_cmd = context.command();
attach_cmd.current_dir(&context.temp_dir);
attach_cmd.args(["attach", &run_id]);
let (snapshot, _output) = run_and_format(&mut attach_cmd, &filters);
release_gate.join().expect("gate releaser should join");
@ -176,7 +174,6 @@ fn attach_json_errors_without_prompting_for_human_input() {
let run_output = context
.command()
.current_dir(&context.temp_dir)
.env("OPENAI_API_KEY", "test")
.args([
"run",

View file

@ -187,7 +187,6 @@ digraph Test {
let run = context
.command()
.current_dir(&context.temp_dir)
.args([
"run",
"--dry-run",

View file

@ -75,7 +75,6 @@ fn exec_missing_api_key_exits_with_error() {
cmd.arg("test prompt");
cmd.env_clear();
cmd.env("HOME", &context.home_dir);
cmd.current_dir(&context.temp_dir);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
@ -99,7 +98,6 @@ fn exec_uses_user_config_defaults() {
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
@ -125,7 +123,6 @@ fn exec_creates_file() {
"claude-haiku-4-5",
"Create a file called hello.txt containing exactly 'Hello'",
])
.current_dir(&context.temp_dir)
.timeout(std::time::Duration::from_secs(120))
.assert()
.success();
@ -153,7 +150,6 @@ fn exec_shell_command() {
"claude-haiku-4-5",
"Run the shell command `echo arc_test_marker_42` and tell me what it printed",
])
.current_dir(&context.temp_dir)
.timeout(std::time::Duration::from_secs(120))
.assert()
.success();
@ -174,7 +170,6 @@ fn exec_read_only_blocks_write() {
"claude-haiku-4-5",
"Create a file called forbidden.txt containing 'should not exist'",
])
.current_dir(&context.temp_dir)
.timeout(std::time::Duration::from_secs(120))
.assert()
.success();
@ -201,7 +196,6 @@ fn exec_json_output_format() {
"claude-haiku-4-5",
"Create a file called test.txt containing 'test'",
])
.current_dir(&context.temp_dir)
.timeout(std::time::Duration::from_secs(120))
.assert()
.success()
@ -237,7 +231,6 @@ fn exec_read_and_edit() {
"claude-haiku-4-5",
"Read data.txt then replace its entire content with 'new content'",
])
.current_dir(&context.temp_dir)
.timeout(std::time::Duration::from_secs(120))
.assert()
.success();

View file

@ -42,7 +42,6 @@ fn help() {
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, @"

View file

@ -36,7 +36,6 @@ fn parse_valid_workflow_prints_ast_json() {
"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###"
@ -125,7 +124,6 @@ fn parse_invalid_dot_fails_cleanly() {
"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, @"

View file

@ -49,7 +49,7 @@ fn preflight_invalid_workflow_fails_with_validation_output() {
----- stdout -----
----- stderr -----
Workflow: Invalid (2 nodes, 1 edges)
Graph: ../../../test/invalid.fabro
Graph: [FIXTURES]/invalid.fabro
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
error: Validation failed

View file

@ -46,12 +46,7 @@ fn test_repo_deinit_removes_fabro_toml_and_dir() {
assert!(context.temp_dir.join("fabro.toml").exists());
assert!(context.temp_dir.join("fabro").exists());
context
.repo()
.arg("deinit")
.current_dir(&context.temp_dir)
.assert()
.success();
context.repo().arg("deinit").assert().success();
assert!(
!context.temp_dir.join("fabro.toml").exists(),
@ -70,7 +65,6 @@ fn test_repo_deinit_fails_when_not_initialized() {
let mut cmd = context.repo();
cmd.arg("deinit");
cmd.current_dir(&context.temp_dir);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
@ -85,12 +79,7 @@ fn test_repo_init_skill_installs_skill_files() {
let context = test_context!();
context.git_init();
context
.repo()
.args(["init", "--skill"])
.current_dir(&context.temp_dir)
.assert()
.success();
context.repo().args(["init", "--skill"]).assert().success();
// Skill files should be installed under .claude/skills/fabro-create-workflow/
let skill_dir = context

View file

@ -33,7 +33,6 @@ fn repo_init_creates_fabro_toml_and_hello_workflow() {
context.git_init();
let mut cmd = context.command();
cmd.current_dir(&context.temp_dir);
cmd.args(["repo", "init"]);
fabro_snapshot!(context.filters(), cmd, @"
@ -111,7 +110,6 @@ fn repo_init_rejects_already_initialized_repo() {
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, @"
@ -127,7 +125,6 @@ fn repo_init_rejects_already_initialized_repo() {
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, @"

View file

@ -42,7 +42,6 @@ fn help() {
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, @"

View file

@ -56,7 +56,7 @@ fn dry_run_simple() {
----- stdout -----
----- stderr -----
Workflow: Simple (4 nodes, 3 edges)
Graph: ../../../test/simple.fabro
Graph: [FIXTURES]/simple.fabro
Goal: Run tests and report results
Sandbox: local (ready in [TIME])
@ -80,11 +80,9 @@ fn dry_run_simple() {
fn dry_run_writes_jsonl_and_live_json() {
let context = test_context!();
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FB8";
let workflow = example_fixture("simple.fabro");
context
.command()
.current_dir(&context.temp_dir)
.args([
"run",
"--dry-run",
@ -93,7 +91,7 @@ fn dry_run_writes_jsonl_and_live_json() {
"local",
"--run-id",
run_id,
workflow.to_str().unwrap(),
example_fixture("simple.fabro").to_str().unwrap(),
])
.assert()
.success();
@ -427,7 +425,7 @@ fn run_id_passthrough_uses_provided_ulid() {
"--auto-approve",
"--run-id",
run_id,
"../../../test/simple.fabro",
example_fixture("simple.fabro").to_str().unwrap(),
])
.assert()
.success();
@ -461,7 +459,6 @@ fn json_run_implies_auto_approve_for_human_gates() {
let output = context
.command()
.current_dir(&context.temp_dir)
.args([
"--json",
"run",
@ -811,7 +808,7 @@ fn detach_prints_ulid_and_exits() {
"--detach",
"--dry-run",
"--auto-approve",
"../../../test/simple.fabro",
example_fixture("simple.fabro").to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
@ -835,7 +832,7 @@ fn detach_creates_run_dir_with_detach_log() {
"--auto-approve",
"--run-id",
run_id,
"../../../test/simple.fabro",
example_fixture("simple.fabro").to_str().unwrap(),
])
.assert()
.success();

View file

@ -163,7 +163,6 @@ fn start_rejects_already_active_or_completed_run() {
let gate = write_gated_workflow(&context.temp_dir.join("slow.fabro"), "slow", "Run slowly");
let mut create_cmd = context.command();
create_cmd.current_dir(&context.temp_dir);
create_cmd.env("OPENAI_API_KEY", "test");
create_cmd.args([
"create",
@ -185,7 +184,6 @@ fn start_rejects_already_active_or_completed_run() {
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();
@ -193,7 +191,6 @@ fn start_rejects_already_active_or_completed_run() {
wait_for_status(&run.run_dir, &["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
@ -207,7 +204,6 @@ fn start_rejects_already_active_or_completed_run() {
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

View file

@ -41,7 +41,10 @@ enum GitWorkflowKind {
}
pub(crate) fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../../test/{name}"))
Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("../../../test/{name}"))
.canonicalize()
.expect("fixture path should exist")
}
pub(crate) fn output_stderr(output: &Output) -> String {

View file

@ -1,7 +1,10 @@
use fabro_test::{fabro_snapshot, test_context};
fn fixture(name: &str) -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../../test/{name}"))
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("../../../test/{name}"))
.canonicalize()
.expect("fixture path should exist")
}
#[test]
@ -43,7 +46,7 @@ fn simple() {
----- stdout -----
----- stderr -----
Workflow: Simple (4 nodes, 3 edges)
Graph: ../../../test/simple.fabro
Graph: [FIXTURES]/simple.fabro
Validation: OK
");
}
@ -59,7 +62,7 @@ fn branching() {
----- stdout -----
----- stderr -----
Workflow: Branch (6 nodes, 6 edges)
Graph: ../../../test/branching.fabro
Graph: [FIXTURES]/branching.fabro
warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry)
Validation: OK
");
@ -76,7 +79,7 @@ fn conditions() {
----- stdout -----
----- stderr -----
Workflow: Conditions (5 nodes, 5 edges)
Graph: ../../../test/conditions.fabro
Graph: [FIXTURES]/conditions.fabro
Validation: OK
");
}
@ -92,7 +95,7 @@ fn parallel() {
----- stdout -----
----- stderr -----
Workflow: Parallel (7 nodes, 7 edges)
Graph: ../../../test/parallel.fabro
Graph: [FIXTURES]/parallel.fabro
Validation: OK
");
}
@ -108,7 +111,7 @@ fn styled() {
----- stdout -----
----- stderr -----
Workflow: Styled (5 nodes, 4 edges)
Graph: ../../../test/styled.fabro
Graph: [FIXTURES]/styled.fabro
Validation: OK
");
}
@ -124,7 +127,7 @@ fn legacy_tool() {
----- stdout -----
----- stderr -----
Workflow: LegacyTool (3 nodes, 2 edges)
Graph: ../../../test/legacy_tool.fabro
Graph: [FIXTURES]/legacy_tool.fabro
Validation: OK
");
}
@ -140,7 +143,7 @@ fn invalid() {
----- stdout -----
----- stderr -----
Workflow: Invalid (2 nodes, 1 edges)
Graph: ../../../test/invalid.fabro
Graph: [FIXTURES]/invalid.fabro
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
error: Validation failed

View file

@ -43,7 +43,6 @@ fn list() {
let mut cmd = context.command();
cmd.args(["workflow", "list"]);
cmd.current_dir(&context.temp_dir);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0

View file

@ -147,7 +147,6 @@ fn workflow_create_rejects_existing_workflow() {
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, @"

View file

@ -31,7 +31,6 @@ fn help() {
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, @"

View file

@ -17,7 +17,6 @@ fn test_exec_creates_file() {
"claude-haiku-4-5",
"Create a file called hello.txt containing exactly 'Hello from exec scenario'",
]);
cmd.current_dir(&context.temp_dir);
cmd.timeout(Duration::from_secs(120));
cmd.assert().success();

View file

@ -2,7 +2,7 @@ use fabro_test::test_context;
use serde_json::Value;
use super::{fixture, read_json, timeout_for};
use crate::support::fabro_json_snapshot;
use crate::support::{example_fixture, fabro_json_snapshot};
#[fabro_macros::e2e_test()]
fn local_run_lifecycle() {
@ -112,7 +112,7 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
"--auto-approve",
"--run-id",
run_id,
"../../../test/simple.fabro",
example_fixture("simple.fabro").to_str().unwrap(),
])
.assert()
.success();
@ -155,7 +155,7 @@ fn dry_run_detach_attach_works_with_default_run_lookup() {
"--auto-approve",
"--run-id",
run_id,
"../../../test/simple.fabro",
example_fixture("simple.fabro").to_str().unwrap(),
])
.assert()
.success();

View file

@ -32,7 +32,10 @@ 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}"))
Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("../../../test/{name}"))
.canonicalize()
.expect("fixture path should exist")
}
pub(crate) fn read_json(path: impl AsRef<Path>) -> Value {

View file

@ -14,7 +14,7 @@ fn dry_run_branching() {
----- stdout -----
----- stderr -----
Workflow: Branch (6 nodes, 6 edges)
Graph: ../../../test/branching.fabro
Graph: [FIXTURES]/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)
@ -49,7 +49,7 @@ fn dry_run_conditions() {
----- stdout -----
----- stderr -----
Workflow: Conditions (5 nodes, 5 edges)
Graph: ../../../test/conditions.fabro
Graph: [FIXTURES]/conditions.fabro
Goal: Test condition evaluation with OR and parentheses
Sandbox: local (ready in [TIME])
@ -81,7 +81,7 @@ fn dry_run_parallel() {
----- stdout -----
----- stderr -----
Workflow: Parallel (7 nodes, 7 edges)
Graph: ../../../test/parallel.fabro
Graph: [FIXTURES]/parallel.fabro
Goal: Test parallel and fan-in execution
Sandbox: local (ready in [TIME])
@ -114,7 +114,7 @@ fn dry_run_styled() {
----- stdout -----
----- stderr -----
Workflow: Styled (5 nodes, 4 edges)
Graph: ../../../test/styled.fabro
Graph: [FIXTURES]/styled.fabro
Goal: Build a styled pipeline
Sandbox: local (ready in [TIME])
@ -147,7 +147,7 @@ fn dry_run_legacy_tool() {
----- stdout -----
----- stderr -----
Workflow: LegacyTool (3 nodes, 2 edges)
Graph: ../../../test/legacy_tool.fabro
Graph: [FIXTURES]/legacy_tool.fabro
Goal: Verify backwards compatibility with old tool naming
Sandbox: local (ready in [TIME])

View file

@ -1,9 +1,21 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::Output;
use assert_cmd::Command;
use regex::Regex;
/// Walk up from `start` to find the repo-level `test/` fixtures directory.
pub fn find_test_fixtures_dir(start: &Path) -> Option<PathBuf> {
let mut dir = start;
loop {
let candidate = dir.join("test");
if candidate.is_dir() {
return candidate.canonicalize().ok();
}
dir = dir.parent()?;
}
}
/// Static filters applied to every snapshot.
static INSTA_FILTERS: &[(&str, &str)] = &[
(r"fabro \d+\.\d+\.\d+", "fabro [VERSION]"),
@ -127,6 +139,12 @@ impl TestContext {
}
}
/// Register a custom filter (regex pattern → replacement).
pub fn add_filter(&mut self, pattern: &str, replacement: &str) {
self.filters
.push((regex::escape(pattern), replacement.to_string()));
}
/// Returns the combined static + context-specific filters.
pub fn filters(&self) -> Vec<(String, String)> {
let mut filters = self.filters.clone();
@ -139,8 +157,14 @@ impl TestContext {
}
/// Build a base `Command` with all isolation env vars set.
///
/// The working directory defaults to `self.temp_dir` (a non-git temp
/// directory) so tests never accidentally interact with the real repo.
/// Tests that need a specific working directory can override this with
/// a subsequent `.current_dir(path)` call.
pub fn command(&self) -> Command {
let mut cmd = Command::new(&self.fabro_bin);
cmd.current_dir(&self.temp_dir);
cmd.env("NO_COLOR", "1");
cmd.env("HOME", &self.home_dir);
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
@ -399,11 +423,21 @@ pub fn apply_filters(snapshot: &str, filters: &[(String, String)]) -> String {
}
/// Create a `TestContext` using the `fabro` binary built by cargo.
///
/// Automatically registers a `[FIXTURES]` snapshot filter for the `test/`
/// directory at the repository root (found by walking up from `CARGO_MANIFEST_DIR`).
#[macro_export]
macro_rules! test_context {
() => {
$crate::TestContext::new(std::path::PathBuf::from(env!("CARGO_BIN_EXE_fabro")))
};
() => {{
let mut ctx =
$crate::TestContext::new(std::path::PathBuf::from(env!("CARGO_BIN_EXE_fabro")));
if let Some(fixtures_dir) =
$crate::find_test_fixtures_dir(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
{
ctx.add_filter(fixtures_dir.to_str().unwrap(), "[FIXTURES]");
}
ctx
}};
}
/// Snapshot test macro that runs a command and compares output using insta.