Replace trycmd tests with fabro-test crate and insta snapshots

Adopt uv's testing pattern: a shared `fabro-test` crate with TestContext
and fabro_snapshot! macro, plus one test file per subcommand under
tests/it/cmd/. This replaces the trycmd-based tests which were hard to
read and didn't compose well with programmatic assertions.

- Create lib/crates/fabro-test with TestContext, run_and_format,
  apply_filters, INSTA_FILTERS, and test_context!/fabro_snapshot! macros
- Add 42 snapshot tests across 16 subcommand files
- Delete trycmd.rs and all tests/cmd/ trycmd files
- Remove trycmd dependency, add fabro-test dev-dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-30 08:13:24 -04:00
parent e2d1ad2144
commit d4a91eb29d
No known key found for this signature in database
72 changed files with 1395 additions and 514 deletions

139
Cargo.lock generated
View file

@ -310,17 +310,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "automod"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b5778837666541195063243828c5b6139221b47dc4ec3ba81738e532469ab1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "aws-lc-rs"
version = "1.16.1"
@ -729,15 +718,6 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "content_inspector"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7bda66e858c683005a53a9a60c69a4aca7eeaa45d124526e389f7aec8e62f38"
dependencies = [
"memchr",
]
[[package]]
name = "convert_case"
version = "0.10.0"
@ -1493,6 +1473,7 @@ dependencies = [
"fabro-server",
"fabro-store",
"fabro-telemetry",
"fabro-test",
"fabro-types",
"fabro-util",
"fabro-validate",
@ -1526,7 +1507,6 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"trycmd",
"ulid",
"walkdir",
"x509-parser",
@ -1926,6 +1906,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "fabro-test"
version = "0.176.2"
dependencies = [
"assert_cmd",
"insta",
"regex",
"tempfile",
]
[[package]]
name = "fabro-tracker"
version = "0.176.2"
@ -2776,16 +2766,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "humantime-serde"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c"
dependencies = [
"humantime",
"serde",
]
[[package]]
name = "hyper"
version = "1.8.1"
@ -3086,6 +3066,7 @@ checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4"
dependencies = [
"console 0.15.11",
"once_cell",
"regex",
"similar",
"tempfile",
]
@ -4134,16 +4115,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "ouroboros"
version = "0.18.5"
@ -4693,26 +4664,6 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -5641,37 +5592,6 @@ dependencies = [
"serde",
]
[[package]]
name = "snapbox"
version = "0.6.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c1abc378119f77310836665f8523018532cf7e3faeb3b10b01da5a7321bf8e1"
dependencies = [
"anstream",
"anstyle",
"content_inspector",
"dunce",
"filetime",
"libc",
"normalize-line-endings",
"os_pipe",
"similar",
"snapbox-macros",
"tempfile",
"wait-timeout",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "snapbox-macros"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b750c344002d7cc69afb9da00ebd9b5c0f8ac2eb7d115d9d45d5b5f47718d74"
dependencies = [
"anstream",
]
[[package]]
name = "socket2"
version = "0.6.2"
@ -6341,7 +6261,7 @@ dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
"toml_edit",
]
[[package]]
@ -6391,21 +6311,6 @@ dependencies = [
"winnow 0.7.14",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned 1.0.4",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.14",
]
[[package]]
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
@ -6553,24 +6458,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "trycmd"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a81ea3136ddc88e19c2cc2eb3176b72abee4e831367cd8949f2a88ac5497e64e"
dependencies = [
"anstream",
"automod",
"glob",
"humantime",
"humantime-serde",
"rayon",
"serde",
"shlex",
"snapbox",
"toml_edit 0.23.10+spec-1.0.0",
]
[[package]]
name = "tungstenite"
version = "0.26.2"

View file

@ -55,6 +55,7 @@ hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
insta = "1"
fabro-test = { path = "lib/crates/fabro-test" }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
openssh = "0.11"

View file

@ -99,4 +99,4 @@ predicates = "3"
serde_json.workspace = true
tempfile = "3"
httpmock = "0.8"
trycmd = "0.15"
fabro-test = { workspace = true }

View file

@ -1,9 +0,0 @@
```console
$ fabro config --help
[..]
...
$ fabro config show --help
[..]
...
```

View file

@ -1,6 +0,0 @@
```console
$ fabro cp --help
[..]
...
```

View file

@ -1,3 +0,0 @@
...
[..]Fabro Doctor[..]
...

View file

@ -1,10 +0,0 @@
bin.name = "fabro"
args = ["doctor", "--dry-run"]
[env]
inherit = false
[env.add]
PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
HOME = "/tmp/fabro-trycmd-nonexistent"
ANTHROPIC_API_KEY = "sk-test-dummy"

View file

@ -1,6 +0,0 @@
```console
$ fabro doctor --help
[..]
...
```

View file

@ -1,3 +0,0 @@
...
[..]invalid value[..]
...

View file

@ -1,7 +0,0 @@
bin.name = "fabro"
args = ["exec", "--permissions", "bogus", "test prompt"]
status = "failed"
stderr-to-stdout = true
[env]
inherit = false

View file

@ -1,3 +0,0 @@
...
[..]Usage:[..]
...

View file

@ -1,7 +0,0 @@
bin.name = "fabro"
args = ["exec"]
status = "failed"
stderr-to-stdout = true
[env]
inherit = false

View file

@ -1,6 +0,0 @@
```console
$ fabro init --help
[..]
...
```

View file

@ -1,6 +0,0 @@
```console
$ fabro install --help
[..]
...
```

View file

@ -1,3 +0,0 @@
...
[..]expected key=value[..]
...

View file

@ -1,7 +0,0 @@
bin.name = "fabro"
args = ["llm", "prompt", "-o", "bad_option", "hello"]
status = "failed"
stderr-to-stdout = true
[env]
inherit = false

View file

@ -1,3 +0,0 @@
...
[..]no prompt provided[..]
...

View file

@ -1,7 +0,0 @@
bin.name = "fabro"
args = ["llm", "prompt"]
status = "failed"
stderr-to-stdout = true
[env]
inherit = false

View file

@ -1,3 +0,0 @@
...
[..]--schema must be valid JSON[..]
...

View file

@ -1,7 +0,0 @@
bin.name = "fabro"
args = ["llm", "prompt", "--no-stream", "-m", "test-model", "--schema", "not json", "hello"]
status = "failed"
stderr-to-stdout = true
[env]
inherit = false

View file

@ -1,25 +0,0 @@
```console
$ fabro model
 MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED 
 claude-opus-4-6   anthropic  opus, claude-opus    1m   $15.0 / $75.0   25 tok/s 
 claude-sonnet-4-5   anthropic      200k   $3.0 / $15.0   50 tok/s 
 claude-sonnet-4-6   anthropic  sonnet, claude-sonnet    200k   $3.0 / $15.0   50 tok/s 
 claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0   100 tok/s 
 gpt-5.2   openai   gpt5    1m   $1.8 / $14.0   65 tok/s 
 gpt-5-mini   openai   gpt5-mini    1m   $0.2 / $2.0   70 tok/s 
 gpt-5.2-codex   openai       1m   $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex   openai   codex    1m   $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex-spark   openai   codex-spark    131k   - / -  1000 tok/s 
 gpt-5.4   openai   gpt54, gpt-54    1m   $2.5 / $15.0   70 tok/s 
 gpt-5.4-pro   openai   gpt54-pro, gpt-54-pro    1m  $30.0 / $180.0   20 tok/s 
 gpt-5.4-mini   openai   gpt54-mini, gpt-54-mini   400k   $0.8 / $4.5   140 tok/s 
 gemini-3.1-pro-preview   gemini   gemini-pro    1m   $2.0 / $12.0   85 tok/s 
 gemini-3.1-pro-preview-customtools  gemini   gemini-customtools    1m   $2.0 / $12.0   85 tok/s 
 gemini-3-flash-preview   gemini   gemini-flash    1m   $0.5 / $3.0   150 tok/s 
 gemini-3.1-flash-lite-preview   gemini   gemini-flash-lite    1m   $0.2 / $1.5   200 tok/s 
 kimi-k2.5   kimi   kimi    262k   $0.6 / $3.0   50 tok/s 
 glm-4.7   zai   glm, glm4    203k   $0.6 / $2.2   100 tok/s 
 minimax-m2.5   minimax   minimax    197k   $0.3 / $1.2   45 tok/s 
 mercury-2   inception  mercury    131k   $0.2 / $0.8  1000 tok/s 

```

View file

@ -1,6 +0,0 @@
```console
$ fabro model --help
[..]
...
```

View file

@ -1,9 +0,0 @@
```console
$ fabro model list --provider anthropic
 MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED 
 claude-opus-4-6   anthropic  opus, claude-opus    1m  $15.0 / $75.0   25 tok/s 
 claude-sonnet-4-5  anthropic      200k   $3.0 / $15.0   50 tok/s 
 claude-sonnet-4-6  anthropic  sonnet, claude-sonnet   200k   $3.0 / $15.0   50 tok/s 
 claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0  100 tok/s 

```

View file

@ -1,8 +0,0 @@
```console
$ fabro model list --query codex
 MODEL   PROVIDER  ALIASES   CONTEXT   COST   SPEED 
 gpt-5.2-codex   openai       1m  $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex   openai   codex    1m  $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex-spark  openai   codex-spark   131k   - / -  1000 tok/s 

```

View file

@ -1,6 +0,0 @@
```console
$ fabro model list --query OPUS
 MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED 
 claude-opus-4-6  anthropic  opus, claude-opus   1m  $15.0 / $75.0  25 tok/s 

```

View file

@ -1,6 +0,0 @@
```console
$ fabro model list --query opus
 MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED 
 claude-opus-4-6  anthropic  opus, claude-opus   1m  $15.0 / $75.0  25 tok/s 

```

View file

@ -1,25 +0,0 @@
```console
$ fabro model list
 MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED 
 claude-opus-4-6   anthropic  opus, claude-opus    1m   $15.0 / $75.0   25 tok/s 
 claude-sonnet-4-5   anthropic      200k   $3.0 / $15.0   50 tok/s 
 claude-sonnet-4-6   anthropic  sonnet, claude-sonnet    200k   $3.0 / $15.0   50 tok/s 
 claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0   100 tok/s 
 gpt-5.2   openai   gpt5    1m   $1.8 / $14.0   65 tok/s 
 gpt-5-mini   openai   gpt5-mini    1m   $0.2 / $2.0   70 tok/s 
 gpt-5.2-codex   openai       1m   $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex   openai   codex    1m   $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex-spark   openai   codex-spark    131k   - / -  1000 tok/s 
 gpt-5.4   openai   gpt54, gpt-54    1m   $2.5 / $15.0   70 tok/s 
 gpt-5.4-pro   openai   gpt54-pro, gpt-54-pro    1m  $30.0 / $180.0   20 tok/s 
 gpt-5.4-mini   openai   gpt54-mini, gpt-54-mini   400k   $0.8 / $4.5   140 tok/s 
 gemini-3.1-pro-preview   gemini   gemini-pro    1m   $2.0 / $12.0   85 tok/s 
 gemini-3.1-pro-preview-customtools  gemini   gemini-customtools    1m   $2.0 / $12.0   85 tok/s 
 gemini-3-flash-preview   gemini   gemini-flash    1m   $0.5 / $3.0   150 tok/s 
 gemini-3.1-flash-lite-preview   gemini   gemini-flash-lite    1m   $0.2 / $1.5   200 tok/s 
 kimi-k2.5   kimi   kimi    262k   $0.6 / $3.0   50 tok/s 
 glm-4.7   zai   glm, glm4    203k   $0.6 / $2.2   100 tok/s 
 minimax-m2.5   minimax   minimax    197k   $0.3 / $1.2   45 tok/s 
 mercury-2   inception  mercury    131k   $0.2 / $0.8  1000 tok/s 

```

View file

@ -1,6 +0,0 @@
```console
$ fabro pr --help
[..]
...
```

View file

@ -1,6 +0,0 @@
```console
$ fabro preview --help
[..]
...
```

View file

@ -1,6 +0,0 @@
```console
$ fabro repo --help
[..]
...
```

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["run", "--dry-run", "--auto-approve", "../../../test/branching.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["run", "--dry-run", "--auto-approve", "../../../test/conditions.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["run", "--dry-run", "--auto-approve", "../../../test/legacy_tool.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["run", "--dry-run", "--auto-approve", "../../../test/parallel.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["run", "--dry-run", "--auto-approve", "../../../test/simple.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["run", "--dry-run", "--auto-approve", "../../../test/styled.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,6 +0,0 @@
```console
$ fabro run --help
[..]
...
```

View file

@ -1,6 +0,0 @@
```console
$ fabro ssh --help
[..]
...
```

View file

@ -1,6 +0,0 @@
```console
$ fabro system --help
[..]
...
```

View file

@ -1,3 +0,0 @@
...
[..]unexpected argument[..]
...

View file

@ -1,7 +0,0 @@
bin.name = "fabro"
args = ["--no-dotenv", "doctor"]
status = "failed"
stderr-to-stdout = true
[env]
inherit = false

View file

@ -1,5 +0,0 @@
```console
$ fabro --version
fabro [..]
```

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["validate", "../../../test/branching.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["validate", "../../../test/conditions.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,6 +0,0 @@
```console
$ fabro validate --help
[..]
...
```

View file

@ -1,10 +0,0 @@
bin.name = "fabro"
args = ["validate", "../../../test/invalid.fabro"]
status = "failed"
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["validate", "../../../test/legacy_tool.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["validate", "../../../test/parallel.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["validate", "../../../test/simple.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -1,9 +0,0 @@
bin.name = "fabro"
args = ["validate", "../../../test/styled.fabro"]
[env]
inherit = false
[fs]
sandbox = false
cwd = "../../.."

View file

@ -0,0 +1,56 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.config();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Inspect merged configuration
Usage: fabro config [OPTIONS] <COMMAND>
Commands:
show Print the merged FabroSettings as YAML
help Print this message or the help of the given subcommand(s)
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}
#[test]
fn show_help() {
let context = test_context!();
let mut cmd = context.config();
cmd.args(["show", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Print the merged FabroSettings as YAML
Usage: fabro config show [OPTIONS] [WORKFLOW]
Arguments:
[WORKFLOW] Optional workflow name, .fabro path, or .toml run config to overlay
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}

View file

@ -0,0 +1,30 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.cp();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Copy files to/from a run's sandbox
Usage: fabro cp [OPTIONS] <SRC> <DST>
Arguments:
<SRC> Source: <run-id>:<path> or local path
<DST> Destination: <run-id>:<path> or local path
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-r, --recursive Recurse into directories
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}

View file

@ -0,0 +1,62 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.doctor();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Check environment and integration health
Usage: fabro doctor [OPTIONS]
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-v, --verbose Show detailed information for each check
--dry-run Skip live service probes (LLM, sandbox, API, web, Brave Search)
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}
#[test]
fn dry_run_flag() {
let context = test_context!();
let mut cmd = context.doctor();
cmd.arg("--dry-run");
cmd.env(
"PATH",
"/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin",
);
cmd.env("ANTHROPIC_API_KEY", "sk-test-dummy");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Fabro Doctor
Required
[!] Configuration (no user config file found)
[] LLM providers (1 configured)
[!] GitHub App (not configured)
Optional
[!] Cloud sandbox (no sandbox configured)
[!] Brave Search (not configured)
Found issues in 4 categories.
Warnings:
Configuration Create ~/.fabro/user.toml
GitHub App Configure GitHub App in server.toml and set env vars to enable GitHub integration
Cloud sandbox Set DAYTONA_API_KEY to enable cloud sandbox execution
Brave Search Set BRAVE_SEARCH_API_KEY to enable web search
----- stderr -----
");
}

View file

@ -0,0 +1,35 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn invalid_permissions() {
let context = test_context!();
let mut cmd = context.exec_cmd();
cmd.args(["--permissions", "bogus", "test prompt"]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: invalid value 'bogus' for '--permissions <PERMISSIONS>'
[possible values: read-only, read-write, full]
For more information, try '--help'.
");
}
#[test]
fn no_prompt() {
let context = test_context!();
fabro_snapshot!(context.filters(), context.exec_cmd(), @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: the following required arguments were not provided:
<PROMPT>
Usage: fabro exec --no-upgrade-check --storage-dir <STORAGE_DIR> <PROMPT>
For more information, try '--help'.
");
}

View file

@ -0,0 +1,25 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.init_cmd();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Initialize a new project (deprecated: use `repo init`)
Usage: fabro init [OPTIONS]
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}

View file

@ -0,0 +1,26 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.install();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Set up the Fabro environment (LLMs, certs, GitHub)
Usage: fabro install [OPTIONS]
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--web-url <WEB_URL> Base URL for the web UI (used for OAuth callback URLs) [default: http://localhost:5173]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}

View file

@ -0,0 +1,55 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn prompt_bad_option() {
let context = test_context!();
let mut cmd = context.llm();
cmd.args(["prompt", "-o", "bad_option", "hello"]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: invalid value 'bad_option' for '--option <OPTION>': expected key=value, got bad_option
For more information, try '--help'.
");
}
#[test]
fn prompt_no_text() {
let context = test_context!();
let mut cmd = context.llm();
cmd.arg("prompt");
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Error: no prompt provided. Pass a prompt as an argument or pipe text via stdin.
");
}
#[test]
fn prompt_schema_invalid() {
let context = test_context!();
let mut cmd = context.llm();
cmd.args([
"prompt",
"--no-stream",
"-m",
"test-model",
"--schema",
"not json",
"hello",
]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Using model: test-model
error: --schema must be valid JSON
> expected ident at line 1 column 2
");
}

View file

@ -0,0 +1,17 @@
mod config;
mod cp;
mod doctor;
mod exec;
mod init;
mod install;
mod llm;
mod model;
mod pr;
mod preview;
mod repo;
mod run;
mod secret;
mod ssh;
mod system;
mod top_level;
mod validate;

View file

@ -0,0 +1,161 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.model();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
List and test LLM models
Usage: fabro model [OPTIONS] [COMMAND]
Commands:
list List available models
test Test model availability by sending a simple prompt
help Print this message or the help of the given subcommand(s)
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}
#[test]
fn bare() {
let context = test_context!();
fabro_snapshot!(context.filters(), context.model(), @"
success: true
exit_code: 0
----- stdout -----
 MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED 
 claude-opus-4-6   anthropic  opus, claude-opus    1m   $15.0 / $75.0   25 tok/s 
 claude-sonnet-4-5   anthropic      200k   $3.0 / $15.0   50 tok/s 
 claude-sonnet-4-6   anthropic  sonnet, claude-sonnet    200k   $3.0 / $15.0   50 tok/s 
 claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0   100 tok/s 
 gpt-5.2   openai   gpt5    1m   $1.8 / $14.0   65 tok/s 
 gpt-5-mini   openai   gpt5-mini    1m   $0.2 / $2.0   70 tok/s 
 gpt-5.2-codex   openai       1m   $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex   openai   codex    1m   $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex-spark   openai   codex-spark    131k   - / -  1000 tok/s 
 gpt-5.4   openai   gpt54, gpt-54    1m   $2.5 / $15.0   70 tok/s 
 gpt-5.4-pro   openai   gpt54-pro, gpt-54-pro    1m  $30.0 / $180.0   20 tok/s 
 gpt-5.4-mini   openai   gpt54-mini, gpt-54-mini   400k   $0.8 / $4.5   140 tok/s 
 gemini-3.1-pro-preview   gemini   gemini-pro    1m   $2.0 / $12.0   85 tok/s 
 gemini-3.1-pro-preview-customtools  gemini   gemini-customtools    1m   $2.0 / $12.0   85 tok/s 
 gemini-3-flash-preview   gemini   gemini-flash    1m   $0.5 / $3.0   150 tok/s 
 gemini-3.1-flash-lite-preview   gemini   gemini-flash-lite    1m   $0.2 / $1.5   200 tok/s 
 kimi-k2.5   kimi   kimi    262k   $0.6 / $3.0   50 tok/s 
 glm-4.7   zai   glm, glm4    203k   $0.6 / $2.2   100 tok/s 
 minimax-m2.5   minimax   minimax    197k   $0.3 / $1.2   45 tok/s 
 mercury-2   inception  mercury    131k   $0.2 / $0.8  1000 tok/s 
----- stderr -----
");
}
#[test]
fn list() {
let context = test_context!();
let mut cmd = context.model();
cmd.arg("list");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
 MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED 
 claude-opus-4-6   anthropic  opus, claude-opus    1m   $15.0 / $75.0   25 tok/s 
 claude-sonnet-4-5   anthropic      200k   $3.0 / $15.0   50 tok/s 
 claude-sonnet-4-6   anthropic  sonnet, claude-sonnet    200k   $3.0 / $15.0   50 tok/s 
 claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0   100 tok/s 
 gpt-5.2   openai   gpt5    1m   $1.8 / $14.0   65 tok/s 
 gpt-5-mini   openai   gpt5-mini    1m   $0.2 / $2.0   70 tok/s 
 gpt-5.2-codex   openai       1m   $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex   openai   codex    1m   $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex-spark   openai   codex-spark    131k   - / -  1000 tok/s 
 gpt-5.4   openai   gpt54, gpt-54    1m   $2.5 / $15.0   70 tok/s 
 gpt-5.4-pro   openai   gpt54-pro, gpt-54-pro    1m  $30.0 / $180.0   20 tok/s 
 gpt-5.4-mini   openai   gpt54-mini, gpt-54-mini   400k   $0.8 / $4.5   140 tok/s 
 gemini-3.1-pro-preview   gemini   gemini-pro    1m   $2.0 / $12.0   85 tok/s 
 gemini-3.1-pro-preview-customtools  gemini   gemini-customtools    1m   $2.0 / $12.0   85 tok/s 
 gemini-3-flash-preview   gemini   gemini-flash    1m   $0.5 / $3.0   150 tok/s 
 gemini-3.1-flash-lite-preview   gemini   gemini-flash-lite    1m   $0.2 / $1.5   200 tok/s 
 kimi-k2.5   kimi   kimi    262k   $0.6 / $3.0   50 tok/s 
 glm-4.7   zai   glm, glm4    203k   $0.6 / $2.2   100 tok/s 
 minimax-m2.5   minimax   minimax    197k   $0.3 / $1.2   45 tok/s 
 mercury-2   inception  mercury    131k   $0.2 / $0.8  1000 tok/s 
----- stderr -----
");
}
#[test]
fn list_provider() {
let context = test_context!();
let mut cmd = context.model();
cmd.args(["list", "--provider", "anthropic"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
 MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED 
 claude-opus-4-6   anthropic  opus, claude-opus    1m  $15.0 / $75.0   25 tok/s 
 claude-sonnet-4-5  anthropic      200k   $3.0 / $15.0   50 tok/s 
 claude-sonnet-4-6  anthropic  sonnet, claude-sonnet   200k   $3.0 / $15.0   50 tok/s 
 claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0  100 tok/s 
----- stderr -----
");
}
#[test]
fn list_query() {
let context = test_context!();
let mut cmd = context.model();
cmd.args(["list", "--query", "opus"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
 MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED 
 claude-opus-4-6  anthropic  opus, claude-opus   1m  $15.0 / $75.0  25 tok/s 
----- stderr -----
");
}
#[test]
fn list_query_aliases() {
let context = test_context!();
let mut cmd = context.model();
cmd.args(["list", "--query", "codex"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
 MODEL   PROVIDER  ALIASES   CONTEXT   COST   SPEED 
 gpt-5.2-codex   openai       1m  $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex   openai   codex    1m  $1.8 / $14.0   100 tok/s 
 gpt-5.3-codex-spark  openai   codex-spark   131k   - / -  1000 tok/s 
----- stderr -----
");
}
#[test]
fn list_query_case_insensitive() {
let context = test_context!();
let mut cmd = context.model();
cmd.args(["list", "--query", "OPUS"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
 MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED 
 claude-opus-4-6  anthropic  opus, claude-opus   1m  $15.0 / $75.0  25 tok/s 
----- stderr -----
");
}

View file

@ -0,0 +1,33 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.pr();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Pull request operations
Usage: fabro pr [OPTIONS] <COMMAND>
Commands:
create Create a pull request from a completed run
list List pull requests from workflow runs
view View pull request details
merge Merge a pull request
close Close a pull request
help Print this message or the help of the given subcommand(s)
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}

View file

@ -0,0 +1,32 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.preview();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Get a preview URL for a port on a run's sandbox
Usage: fabro preview [OPTIONS] <RUN> <PORT>
Arguments:
<RUN> Run ID or prefix
<PORT> Port number
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--signed Generate a signed URL (embeds auth token, no headers needed)
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--ttl <TTL> Signed URL expiry in seconds (default 3600, requires --signed) [default: 3600]
--open Open URL in browser (implies --signed)
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}

View file

@ -0,0 +1,30 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.repo();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Repository commands
Usage: fabro repo [OPTIONS] <COMMAND>
Commands:
init Initialize a new project
deinit Remove fabro.toml and fabro/ directory
help Print this message or the help of the given subcommand(s)
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}

View file

@ -0,0 +1,236 @@
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}"))
}
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Launch a workflow run
Usage: fabro run [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Path to a .fabro workflow file or .toml task config
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--dry-run Execute with simulated LLM backend
--auto-approve Auto-approve all human gates
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--goal <GOAL> Override the workflow goal (exposed as $goal in prompts)
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--goal-file <GOAL_FILE> Read the workflow goal from a file
--model <MODEL> Override default LLM model
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona, ssh]
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--no-retro Skip retro generation after the run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
-d, --detach Run the workflow in the background and print the run ID
-h, --help Print help
----- stderr -----
");
}
#[test]
fn dry_run_simple() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(fixture("simple.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Simple (4 nodes, 3 edges)
Graph: ../../../test/simple.fabro
Goal: Run tests and report results
Sandbox: local (ready in [TIME])
Start 0ms
Run Tests 0ms
Report 0ms
Exit 0ms
=== Run Result ===
Run: [ULID]
Status: SUCCESS
Duration: 0 seconds
Run: [STORAGE_DIR]/runs/20260330-dry-run-[ULID]
=== Output ===
[Simulated] Response for stage: report
");
}
#[test]
fn dry_run_branching() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(fixture("branching.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Branch (6 nodes, 6 edges)
Graph: ../../../test/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)
Sandbox: local (ready in [TIME])
Start 0ms
Plan 0ms
Implement 0ms
Validate 0ms
Tests passing? 0ms
Exit 0ms
=== Run Result ===
Run: [ULID]
Status: SUCCESS
Duration: 0 seconds
Run: [STORAGE_DIR]/runs/20260330-dry-run-[ULID]
=== Output ===
[Simulated] Response for stage: validate
");
}
#[test]
fn dry_run_conditions() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(fixture("conditions.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Conditions (5 nodes, 5 edges)
Graph: ../../../test/conditions.fabro
Goal: Test condition evaluation with OR and parentheses
Sandbox: local (ready in [TIME])
start 0ms
Decide 0ms
Path B 0ms
exit 0ms
=== Run Result ===
Run: [ULID]
Status: SUCCESS
Duration: 0 seconds
Run: [STORAGE_DIR]/runs/20260330-dry-run-[ULID]
=== Output ===
[Simulated] Response for stage: path_b
");
}
#[test]
fn dry_run_parallel() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(fixture("parallel.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Parallel (7 nodes, 7 edges)
Graph: ../../../test/parallel.fabro
Goal: Test parallel and fan-in execution
Sandbox: local (ready in [TIME])
start 0ms
Fork Work 0ms
Merge Results 0ms
Review 0ms
exit 0ms
=== Run Result ===
Run: [ULID]
Status: SUCCESS
Duration: 0 seconds
Run: [STORAGE_DIR]/runs/20260330-dry-run-[ULID]
=== Output ===
[Simulated] Response for stage: review
");
}
#[test]
fn dry_run_styled() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(fixture("styled.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Styled (5 nodes, 4 edges)
Graph: ../../../test/styled.fabro
Goal: Build a styled pipeline
Sandbox: local (ready in [TIME])
start 0ms
Plan 0ms
Implement 0ms
Critical Review 0ms
exit 0ms
=== Run Result ===
Run: [ULID]
Status: SUCCESS
Duration: 0 seconds
Run: [STORAGE_DIR]/runs/20260330-dry-run-[ULID]
=== Output ===
[Simulated] Response for stage: critical_review
");
}
#[test]
fn dry_run_legacy_tool() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(fixture("legacy_tool.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: LegacyTool (3 nodes, 2 edges)
Graph: ../../../test/legacy_tool.fabro
Goal: Verify backwards compatibility with old tool naming
Sandbox: local (ready in [TIME])
Start 0ms
Echo 0ms
Exit 0ms
=== Run Result ===
Run: [ULID]
Status: SUCCESS
Duration: 0 seconds
Run: [STORAGE_DIR]/runs/20260330-dry-run-[ULID]
");
}

View file

@ -0,0 +1,32 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.secret();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Manage secrets in ~/.fabro/.env
Usage: fabro secret [OPTIONS] <COMMAND>
Commands:
get Get a secret value
list List secret names
rm Remove a secret
set Set a secret value
help Print this message or the help of the given subcommand(s)
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}

View file

@ -0,0 +1,30 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.ssh();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
SSH into a run's Daytona sandbox
Usage: fabro ssh [OPTIONS] <RUN>
Arguments:
<RUN> Run ID or prefix
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--ttl <TTL> SSH access expiry in minutes (default 60) [default: 60]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--print Print the SSH command instead of connecting
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}

View file

@ -0,0 +1,30 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.system();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
System maintenance commands
Usage: fabro system [OPTIONS] <COMMAND>
Commands:
prune Delete old workflow runs
df Show disk usage
help Print this message or the help of the given subcommand(s)
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}

View file

@ -0,0 +1,33 @@
use fabro_test::{fabro_snapshot, test_context};
#[test]
fn version() {
let context = test_context!();
let mut cmd = context.command();
cmd.arg("--version");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
fabro [VERSION] (c6ff6c9 2026-03-30)
----- stderr -----
");
}
#[test]
fn no_dotenv_flag() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["--no-dotenv", "doctor"]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: unexpected argument '--no-dotenv' found
Usage: fabro [OPTIONS] <COMMAND>
For more information, try '--help'.
");
}

View file

@ -0,0 +1,147 @@
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}"))
}
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Validate a workflow
Usage: fabro validate [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Path to the .fabro workflow file
Options:
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
-h, --help Print help
----- stderr -----
");
}
#[test]
fn simple() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("simple.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Simple (4 nodes, 3 edges)
Graph: ../../../test/simple.fabro
Validation: OK
");
}
#[test]
fn branching() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("branching.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Branch (6 nodes, 6 edges)
Graph: ../../../test/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
");
}
#[test]
fn conditions() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("conditions.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Conditions (5 nodes, 5 edges)
Graph: ../../../test/conditions.fabro
Validation: OK
");
}
#[test]
fn parallel() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("parallel.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Parallel (7 nodes, 7 edges)
Graph: ../../../test/parallel.fabro
Validation: OK
");
}
#[test]
fn styled() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("styled.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Styled (5 nodes, 4 edges)
Graph: ../../../test/styled.fabro
Validation: OK
");
}
#[test]
fn legacy_tool() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("legacy_tool.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: LegacyTool (3 nodes, 2 edges)
Graph: ../../../test/legacy_tool.fabro
Validation: OK
");
}
#[test]
fn invalid() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("invalid.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Workflow: Invalid (2 nodes, 1 edges)
Graph: ../../../test/invalid.fabro
error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node)
error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing)
error: Validation failed
");
}

View file

@ -1,3 +1,3 @@
mod cli;
mod cmd;
mod scenario;
mod trycmd;

View file

@ -1,30 +0,0 @@
macro_rules! trycmd_subcommand {
($name:ident, $dir:expr) => {
#[test]
fn $name() {
let home = tempfile::tempdir().unwrap();
let home = home.path().display().to_string();
trycmd::TestCases::new()
.env("HOME", home)
.case(concat!("tests/cmd/", $dir, "/*.trycmd"))
.case(concat!("tests/cmd/", $dir, "/*.toml"));
}
};
}
trycmd_subcommand!(cli_cp, "cp");
trycmd_subcommand!(cli_config, "config");
trycmd_subcommand!(cli_doctor, "doctor");
trycmd_subcommand!(cli_exec, "exec");
trycmd_subcommand!(cli_init, "init");
trycmd_subcommand!(cli_install, "install");
trycmd_subcommand!(cli_llm, "llm");
trycmd_subcommand!(cli_model, "model");
trycmd_subcommand!(cli_pr, "pr");
trycmd_subcommand!(cli_repo, "repo");
trycmd_subcommand!(cli_preview, "preview");
trycmd_subcommand!(cli_run, "run");
trycmd_subcommand!(cli_ssh, "ssh");
trycmd_subcommand!(cli_system, "system");
trycmd_subcommand!(cli_top_level, "top-level");
trycmd_subcommand!(cli_validate, "validate");

View file

@ -0,0 +1,19 @@
[package]
name = "fabro-test"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Test utilities for fabro integration tests"
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
assert_cmd = "2"
insta = { workspace = true, features = ["filters"] }
regex = { workspace = true }
tempfile = "3"

View file

@ -0,0 +1,290 @@
use std::path::PathBuf;
use std::process::Output;
use assert_cmd::Command;
use regex::Regex;
/// Static filters applied to every snapshot.
static INSTA_FILTERS: &[(&str, &str)] = &[
(r"fabro \d+\.\d+\.\d+", "fabro [VERSION]"),
(r"\b[0-9A-HJKMNP-TV-Z]{26}\b", "[ULID]"),
(r"in \d+(\.\d+)?(ms|s)", "in [TIME]"),
(r"\\([\w\d])", "/$1"),
];
/// An isolated test context for running fabro CLI commands.
///
/// Creates temporary directories for home, storage, and working directory,
/// and provides methods to build commands with proper isolation env vars.
pub struct TestContext {
pub temp_dir: PathBuf,
pub home_dir: PathBuf,
pub storage_dir: PathBuf,
fabro_bin: PathBuf,
filters: Vec<(String, String)>,
_root: tempfile::TempDir,
}
impl TestContext {
/// Create a new isolated test context.
///
/// `fabro_bin` should be the path to the compiled `fabro` binary,
/// typically obtained via `env!("CARGO_BIN_EXE_fabro")`.
pub fn new(fabro_bin: PathBuf) -> Self {
let root = tempfile::tempdir().expect("failed to create temp dir");
let root_path = root.path().to_path_buf();
let temp_dir = root_path.join("temp");
let home_dir = root_path.join("home");
let storage_dir = root_path.join("storage");
std::fs::create_dir_all(&temp_dir).expect("failed to create temp_dir");
std::fs::create_dir_all(&home_dir).expect("failed to create home_dir");
std::fs::create_dir_all(&storage_dir).expect("failed to create storage_dir");
let filters = vec![
(
regex::escape(temp_dir.to_str().unwrap()),
"[TEMP_DIR]".to_string(),
),
(
regex::escape(home_dir.to_str().unwrap()),
"[HOME_DIR]".to_string(),
),
(
regex::escape(storage_dir.to_str().unwrap()),
"[STORAGE_DIR]".to_string(),
),
];
Self {
temp_dir,
home_dir,
storage_dir,
fabro_bin,
filters,
_root: root,
}
}
/// Returns the combined static + context-specific filters.
pub fn filters(&self) -> Vec<(String, String)> {
let mut filters: Vec<(String, String)> = INSTA_FILTERS
.iter()
.map(|(pat, rep)| ((*pat).to_string(), (*rep).to_string()))
.collect();
filters.extend(self.filters.clone());
filters
}
/// Build a base `Command` with all isolation env vars set.
pub fn command(&self) -> Command {
let mut cmd = Command::new(&self.fabro_bin);
cmd.env("NO_COLOR", "1");
cmd.env("HOME", &self.home_dir);
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
cmd.env("FABRO_STORAGE_DIR", &self.storage_dir);
cmd
}
/// Build a `validate` subcommand.
pub fn validate(&self) -> Command {
let mut cmd = self.command();
cmd.arg("validate");
cmd
}
/// Build a `run` subcommand.
pub fn run_cmd(&self) -> Command {
let mut cmd = self.command();
cmd.arg("run");
cmd
}
/// Build a `ps` subcommand.
pub fn ps(&self) -> Command {
let mut cmd = self.command();
cmd.arg("ps");
cmd
}
/// Build a `model` subcommand.
pub fn model(&self) -> Command {
let mut cmd = self.command();
cmd.arg("model");
cmd
}
/// Build a `secret` subcommand.
pub fn secret(&self) -> Command {
let mut cmd = self.command();
cmd.arg("secret");
cmd
}
/// Build a `doctor` subcommand.
pub fn doctor(&self) -> Command {
let mut cmd = self.command();
cmd.arg("doctor");
cmd
}
/// Build a `exec` subcommand.
pub fn exec_cmd(&self) -> Command {
let mut cmd = self.command();
cmd.arg("exec");
cmd
}
/// Build a `llm` subcommand.
pub fn llm(&self) -> Command {
let mut cmd = self.command();
cmd.arg("llm");
cmd
}
/// Build a `config` subcommand.
pub fn config(&self) -> Command {
let mut cmd = self.command();
cmd.arg("config");
cmd
}
/// Build a `cp` subcommand.
pub fn cp(&self) -> Command {
let mut cmd = self.command();
cmd.arg("cp");
cmd
}
/// Build an `init` subcommand.
pub fn init_cmd(&self) -> Command {
let mut cmd = self.command();
cmd.arg("init");
cmd
}
/// Build an `install` subcommand.
pub fn install(&self) -> Command {
let mut cmd = self.command();
cmd.arg("install");
cmd
}
/// Build a `pr` subcommand.
pub fn pr(&self) -> Command {
let mut cmd = self.command();
cmd.arg("pr");
cmd
}
/// Build a `preview` subcommand.
pub fn preview(&self) -> Command {
let mut cmd = self.command();
cmd.arg("preview");
cmd
}
/// Build a `repo` subcommand.
pub fn repo(&self) -> Command {
let mut cmd = self.command();
cmd.arg("repo");
cmd
}
/// Build an `ssh` subcommand.
pub fn ssh(&self) -> Command {
let mut cmd = self.command();
cmd.arg("ssh");
cmd
}
/// Build a `system` subcommand.
pub fn system(&self) -> Command {
let mut cmd = self.command();
cmd.arg("system");
cmd
}
}
/// Execute a command and format the output for snapshot testing.
///
/// Returns the formatted string and the raw `Output`.
/// Prints unfiltered output to stderr for debugging failed tests.
pub fn run_and_format(cmd: &mut Command, filters: &[(String, String)]) -> (String, Output) {
let output = cmd.output().expect("failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
// Print unfiltered output for debugging
#[allow(clippy::print_stderr)]
{
eprint!("{stdout}");
eprint!("{stderr}");
}
let filtered_stdout = apply_filters(&stdout, filters);
let filtered_stderr = apply_filters(&stderr, filters);
let formatted = format!(
"success: {success}\nexit_code: {code}\n----- stdout -----\n{stdout}----- stderr -----\n{stderr}",
success = output.status.success(),
code = output.status.code().unwrap_or(-1),
stdout = filtered_stdout,
stderr = filtered_stderr,
);
(formatted, output)
}
/// Apply regex-based filters to a snapshot string.
pub fn apply_filters(snapshot: &str, filters: &[(String, String)]) -> String {
let mut result = snapshot.to_string();
for (pattern, replacement) in filters {
if let Ok(re) = Regex::new(pattern) {
result = re.replace_all(&result, replacement.as_str()).to_string();
}
}
result
}
/// Create a `TestContext` using the `fabro` binary built by cargo.
#[macro_export]
macro_rules! test_context {
() => {
$crate::TestContext::new(std::path::PathBuf::from(env!("CARGO_BIN_EXE_fabro")))
};
}
/// Snapshot test macro that runs a command and compares output using insta.
///
/// Usage:
/// ```ignore
/// fabro_snapshot!(context.filters(), context.validate().arg("--help"), @"...");
/// ```
#[macro_export]
macro_rules! fabro_snapshot {
($spawnable:expr, @$snapshot:literal) => {{
let filters: Vec<(String, String)> = $crate::TestContext::default_filters();
let mut cmd = $spawnable;
let (snapshot, _output) = $crate::run_and_format(&mut cmd, &filters);
insta::assert_snapshot!(snapshot, @$snapshot);
}};
($filters:expr, $spawnable:expr, @$snapshot:literal) => {{
let filters: Vec<(String, String)> = $filters;
let mut cmd = $spawnable;
let (snapshot, _output) = $crate::run_and_format(&mut cmd, &filters);
insta::assert_snapshot!(snapshot, @$snapshot);
}};
}
impl TestContext {
/// Returns just the static default filters (no context-specific paths).
pub fn default_filters() -> Vec<(String, String)> {
INSTA_FILTERS
.iter()
.map(|(pat, rep)| ((*pat).to_string(), (*rep).to_string()))
.collect()
}
}