From 7e64bf8b699637550c157fa5423e34fa5142b722 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Apr 2026 15:35:57 -0400 Subject: [PATCH] feat(cli): render fatal errors with miette Wrap root CLI errors at the main boundary so fatal diagnostics use miette's styled renderer while preserving existing telemetry, exit codes, and auth help hints. --- Cargo.lock | 99 +++++++++++++++++++ Cargo.toml | 1 + ...-24-001-refactor-adopt-uv-patterns-plan.md | 2 +- lib/crates/fabro-cli/Cargo.toml | 1 + lib/crates/fabro-cli/src/main.rs | 88 ++++++++++++----- .../fabro-cli/tests/it/cmd/artifact_cp.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/create.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/diff.rs | 4 +- lib/crates/fabro-cli/tests/it/cmd/dump.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/exec.rs | 10 +- lib/crates/fabro-cli/tests/it/cmd/fork.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/graph.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/model.rs | 2 +- .../fabro-cli/tests/it/cmd/model_test.rs | 4 +- lib/crates/fabro-cli/tests/it/cmd/parse.rs | 6 +- .../fabro-cli/tests/it/cmd/pr_create.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/pr_view.rs | 4 +- .../fabro-cli/tests/it/cmd/preflight.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/repo.rs | 2 +- .../fabro-cli/tests/it/cmd/repo_init.rs | 4 +- lib/crates/fabro-cli/tests/it/cmd/rewind.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/rm.rs | 6 +- lib/crates/fabro-cli/tests/it/cmd/runner.rs | 2 +- .../fabro-cli/tests/it/cmd/sandbox_cp.rs | 2 +- .../fabro-cli/tests/it/cmd/sandbox_preview.rs | 2 +- .../fabro-cli/tests/it/cmd/sandbox_ssh.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/secret.rs | 2 +- .../fabro-cli/tests/it/cmd/server_start.rs | 60 +++++++---- lib/crates/fabro-cli/tests/it/cmd/start.rs | 4 +- lib/crates/fabro-cli/tests/it/cmd/upgrade.rs | 8 +- lib/crates/fabro-cli/tests/it/cmd/validate.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/wait.rs | 4 +- .../fabro-cli/tests/it/cmd/workflow_create.rs | 4 +- .../fabro-cli/tests/it/cmd/workflow_list.rs | 2 +- .../fabro-cli/tests/it/scenario/artifacts.rs | 4 +- lib/crates/fabro-cli/tests/it/support/mod.rs | 8 +- 36 files changed, 259 insertions(+), 96 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4fc855ee..94077edd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,6 +478,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.22.1" @@ -1663,6 +1672,7 @@ dependencies = [ "insta", "jsonwebtoken", "libc", + "miette", "object_store", "openssl", "paste", @@ -3520,6 +3530,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3964,6 +3980,36 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mime" version = "0.3.17" @@ -4625,6 +4671,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "panic-message" version = "0.3.0" @@ -6379,6 +6431,27 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "1.0.109" @@ -6534,12 +6607,32 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -7109,6 +7202,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.12.0" diff --git a/Cargo.toml b/Cargo.toml index 614d82766..c4becce7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ object_store = { version = "0.12.5", features = ["aws"] } rust-embed = "8" percent-encoding = "2" minijinja = "2" +miette = { version = "7.6", features = ["fancy"] } fabro-http = { path = "lib/crates/fabro-http" } fabro-redact = { path = "lib/crates/fabro-redact" } fabro-static = { path = "lib/crates/fabro-static" } diff --git a/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md b/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md index f428ddc12..59441758e 100644 --- a/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md +++ b/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md @@ -510,7 +510,7 @@ Solid arrows are real dependencies. Dashed lines show phases that are independen ### Phase 4 — miette CLI diagnostics wrapper -- [ ] **Unit 4.1: Wrap `fabro-cli` root error with `miette::Diagnostic`** +- [x] **Unit 4.1: Wrap `fabro-cli` root error with `miette::Diagnostic`** **Goal:** Render CLI errors through `miette` at the `main` boundary, preserving existing exit-class hint behavior while gaining styled chained errors and `help:` footer rendering. Source-highlighted output is out of scope until fabro has errors that carry span/source context. diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index 51c27fc22..8faebf72e 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -56,6 +56,7 @@ console.workspace = true indicatif.workspace = true daytona-sdk.workspace = true anyhow.workspace = true +miette.workspace = true dotenvy.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index f1dfb8d32..43269ffe8 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -20,6 +20,7 @@ mod user_config; #[cfg(test)] use std::ffi::OsString; +use std::fmt::{self, Debug, Display}; use anyhow::Result; use args::{ @@ -94,6 +95,8 @@ async fn main() { None }; + install_miette_hook(); + tel_panic::install_panic_hook(); fabro_telemetry::init_cli(); @@ -135,38 +138,69 @@ async fn main() { fabro_telemetry::shutdown(); if let Err(err) = result { - let style = console::Style::new().red().bold(); - for (i, cause) in err.chain().enumerate() { - let text = cause.to_string(); - if i == 0 { - for (j, line) in text.lines().enumerate() { - if j == 0 { - eprintln!("{} {line}", style.apply_to("error:")); - } else { - eprintln!(" {line}"); - } - } - } else { - for line in text.lines() { - eprintln!(" > {line}"); - } - } - } let json_mode = raw_args.iter().any(|a| a == "--json"); - if !json_mode && exit::exit_class_for(&err) == Some(ExitClass::AuthRequired) { - let hint_style = console::Style::new().cyan().bold(); - let cmd_style = console::Style::new().bold(); - eprintln!(); - eprintln!( - "{} Run {} to authenticate.", - hint_style.apply_to("hint:"), - cmd_style.apply_to("`fabro auth login`"), - ); - } + eprintln!( + "{:?}", + miette::Report::new(CliDiagnostic::new(err, !json_mode)) + ); std::process::exit(exit_code); } } +fn install_miette_hook() { + let _ = miette::set_hook(Box::new(|_| { + Box::new( + miette::MietteHandlerOpts::new() + .with_cause_chain() + .wrap_lines(false) + .break_words(false) + .build(), + ) + })); +} + +struct CliDiagnostic { + err: anyhow::Error, + show_auth_hint: bool, +} + +impl CliDiagnostic { + fn new(err: anyhow::Error, show_auth_hint: bool) -> Self { + Self { + err, + show_auth_hint, + } + } +} + +impl Display for CliDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + Display::fmt(&self.err, formatter) + } +} + +impl Debug for CliDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + Debug::fmt(&self.err, formatter) + } +} + +impl std::error::Error for CliDiagnostic { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.err.source() + } +} + +impl miette::Diagnostic for CliDiagnostic { + fn help<'a>(&'a self) -> Option> { + if self.show_auth_hint && exit::exit_class_for(&self.err) == Some(ExitClass::AuthRequired) { + Some(Box::new("Run `fabro auth login` to authenticate.")) + } else { + None + } + } +} + #[expect( clippy::disallowed_methods, reason = "CLI main reads documented process-env controls before telemetry and worker dispatch." diff --git a/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs index 71faa451b..9622a74e3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs @@ -15,6 +15,6 @@ fn artifact_cp_empty_run_reports_no_artifacts() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: No artifacts found for this run + × No artifacts found for this run "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index 70e8d3c37..abbd1edc7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -462,7 +462,7 @@ fn create_invalid_workflow_fails_without_creating_run() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Validation failed + × Validation failed "); let run_count = run_count_for_test_case(&context); diff --git a/lib/crates/fabro-cli/tests/it/cmd/diff.rs b/lib/crates/fabro-cli/tests/it/cmd/diff.rs index af9d0ea82..d0fefb277 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/diff.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/diff.rs @@ -43,7 +43,7 @@ fn diff_completed_run_without_changes_reports_no_patch() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Run completed but no stored diff exists — the run may not have produced any changes + × Run completed but no stored diff exists — the run may not have produced any changes "); } @@ -59,7 +59,7 @@ fn diff_missing_node_diff_reports_helpful_error() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: No diff found for node 'missing' — check the node ID and try again + × No diff found for node 'missing' — check the node ID and try again "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/dump.rs b/lib/crates/fabro-cli/tests/it/cmd/dump.rs index 355dc7f0c..ee0de5671 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/dump.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/dump.rs @@ -303,7 +303,7 @@ fn dump_rejects_non_empty_output_dir() { 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 + × output path [TEMP_DIR]/nonempty already exists and is not an empty directory; remove it first or choose a different path "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index 87a2fbce5..4b4b29657 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -103,7 +103,7 @@ fn exec_missing_api_key_exits_with_error() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: LLM credentials not configured for provider 'anthropic' + × LLM credentials not configured for provider 'anthropic' "); } @@ -129,7 +129,7 @@ fn exec_uses_user_config_defaults() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: LLM credentials not configured for provider 'openai' + × LLM credentials not configured for provider 'openai' "); } @@ -350,6 +350,12 @@ fn exec_server_target_auth_failure_exits_with_4() { fatal_error_line(&output.stderr), "LLM error: Authentication error for openai: Authentication required." ); + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = console::strip_ansi_codes(&stderr); + assert!( + stderr.contains("Run `fabro auth login` to authenticate."), + "auth failures should retain the login help footer:\n{stderr}" + ); } #[test] diff --git a/lib/crates/fabro-cli/tests/it/cmd/fork.rs b/lib/crates/fabro-cli/tests/it/cmd/fork.rs index e04b5dfa7..14dc873a7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fork.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fork.rs @@ -48,7 +48,7 @@ fn fork_outside_git_repo_errors() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: No run found matching '[ULID]' (tried run ID prefix and workflow name) + × No run found matching '[ULID]' (tried run ID prefix and workflow name) "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/graph.rs b/lib/crates/fabro-cli/tests/it/cmd/graph.rs index 7535cf24d..ace6c3eed 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/graph.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/graph.rs @@ -86,6 +86,6 @@ fn graph_invalid_workflow_fails_after_diagnostics() { ----- 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 + × Validation failed "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/model.rs b/lib/crates/fabro-cli/tests/it/cmd/model.rs index f71cb466a..ab8d87075 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model.rs @@ -181,7 +181,7 @@ fn list_invalid_provider_errors() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: unknown provider: not-a-provider + × unknown provider: not-a-provider "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/model_test.rs b/lib/crates/fabro-cli/tests/it/cmd/model_test.rs index a0a89a3c8..aa6960320 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model_test.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model_test.rs @@ -52,7 +52,7 @@ fn model_test_unknown_model_errors() { ----- stdout ----- ----- stderr ----- Testing nonexistent-model-xyz... done - error: Unknown model: nonexistent-model-xyz + × Unknown model: nonexistent-model-xyz "); } @@ -71,7 +71,7 @@ fn single_model_skip_exits_nonzero() { gemini-3.1-pro-preview gemini gemini-pro 1m $2.0 / $12.0 85 tok/s not configured ----- stderr ----- Testing gemini-3.1-pro-preview... done - error: 1 model(s) failed + × 1 model(s) failed "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/parse.rs b/lib/crates/fabro-cli/tests/it/cmd/parse.rs index 7cc0fdaa8..ca80cf735 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/parse.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/parse.rs @@ -125,11 +125,11 @@ fn parse_invalid_dot_fails_cleanly() { let mut cmd = context.command(); cmd.args(["parse", "bad.fabro"]); - fabro_snapshot!(context.filters(), cmd, @" + fabro_snapshot!(context.filters(), cmd, @r#" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Parse error: grammar error: Parsing Error: Error { input: \"\", code: Char } - "); + × Parse error: grammar error: Parsing Error: Error { input: "", code: Char } + "#); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs index 5f6bbe83f..3bc6511dd 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs @@ -52,7 +52,7 @@ fn pr_create_nongit_run_reports_missing_repo_origin() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Run has no repo origin URL — pull request creation requires git metadata. + × Run has no repo origin URL — pull request creation requires git metadata. "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs index eda959fe1..4ae50d7b8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs @@ -51,7 +51,7 @@ fn pr_view_missing_pull_request_json_errors() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: No pull request found in store. Create one first with: fabro pr create [ULID] + × No pull request found in store. Create one first with: fabro pr create [ULID] "); } @@ -107,7 +107,7 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: GitHub integration unavailable on server. + × GitHub integration unavailable on server. "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs b/lib/crates/fabro-cli/tests/it/cmd/preflight.rs index b03623511..da886760c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/preflight.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/preflight.rs @@ -52,7 +52,7 @@ fn preflight_invalid_workflow_fails_with_validation_output() { 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 + × Validation failed "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo.rs b/lib/crates/fabro-cli/tests/it/cmd/repo.rs index 656794cb2..fe8460ff8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/repo.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/repo.rs @@ -69,6 +69,6 @@ fn test_repo_deinit_fails_when_not_initialized() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: not initialized — .fabro/project.toml not found + × not initialized — .fabro/project.toml not found "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs index 1dddc9579..43198f396 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs @@ -122,7 +122,7 @@ fn repo_init_rejects_already_initialized_repo() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: already initialized — .fabro/project.toml exists at [TEMP_DIR]/.fabro/project.toml + × already initialized — .fabro/project.toml exists at [TEMP_DIR]/.fabro/project.toml "); } @@ -137,6 +137,6 @@ fn repo_init_errors_outside_git_repo() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: not a git repository — run `git init` first + × not a git repository — run `git init` first "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs index 94efdbcb9..93f0d5e77 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs @@ -48,7 +48,7 @@ fn rewind_outside_git_repo_errors() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: No run found matching '[ULID]' (tried run ID prefix and workflow name) + × No run found matching '[ULID]' (tried run ID prefix and workflow name) "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/crates/fabro-cli/tests/it/cmd/rm.rs index fe08fb3bf..0a71804c7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rm.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rm.rs @@ -85,7 +85,7 @@ fn rm_rejects_submitted_run_without_force() { ----- stdout ----- ----- stderr ----- cannot remove active run [ULID] (status: submitted, use force=true or --force to force) - error: some runs could not be removed + × some runs could not be removed "); } @@ -286,7 +286,7 @@ fn rm_without_force_uses_resolve_then_surfaces_server_conflict() { ----- stdout ----- ----- stderr ----- cannot remove active run [ULID] (status: running, use force=true or --force to force) - error: some runs could not be removed + × some runs could not be removed "); resolve_mock.assert(); delete_mock.assert(); @@ -310,7 +310,7 @@ fn rm_partial_failure_reports_which_identifiers_failed() { ----- 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 + × some runs could not be removed "); assert!( !run.run_dir.exists(), diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index 2f63983c4..38e9d5245 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -653,7 +653,7 @@ digraph Test { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Precondition failed: run already finished successfully — nothing to resume + × Precondition failed: run already finished successfully — nothing to resume "); let inspect_after = context diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs index 19f9e7926..122f8da98 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs @@ -50,7 +50,7 @@ fn sandbox_cp_run_without_sandbox_json_errors_cleanly() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Run has no active sandbox. + × Run has no active sandbox. "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs index dc6d6a0c5..5d6295f39 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs @@ -71,7 +71,7 @@ fn sandbox_preview_rejects_non_daytona_run() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Sandbox provider does not support this capability. + × Sandbox provider does not support this capability. "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs index cbf3a10b6..820a24b93 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs @@ -44,7 +44,7 @@ fn sandbox_ssh_rejects_non_daytona_run() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Sandbox provider does not support this capability. + × Sandbox provider does not support this capability. "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret.rs b/lib/crates/fabro-cli/tests/it/cmd/secret.rs index 4984331d8..6e7eca61d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/secret.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/secret.rs @@ -109,7 +109,7 @@ fn test_secret_rm_missing_key() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: secret not found: NOPE + × secret not found: NOPE "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs index b5c54475b..a06036ff5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -19,6 +19,8 @@ use fabro_test::{ }; use fabro_util::dev_token; +use crate::support::fatal_error_line; + const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; const TEST_SESSION_SECRET: &str = @@ -120,13 +122,28 @@ fn run_startup_failure(context: &TestContext, mode: ServerStartMode, case: &Star mode.name(), String::from_utf8_lossy(&output.stdout) ); + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = console::strip_ansi_codes(&stderr); + let mut expected_lines = case.expected_error.lines(); + let expected_fatal_line = expected_lines.next().unwrap_or(case.expected_error); assert_eq!( - String::from_utf8_lossy(&output.stderr), - format!("error: {}\n", case.expected_error), - "unexpected stderr for {} in {} mode", + fatal_error_line(&output.stderr), + expected_fatal_line, + "unexpected fatal line for {} in {} mode\nstderr:\n{}", case.name, - mode.name() + mode.name(), + stderr ); + for expected_line in expected_lines { + let expected_line = expected_line.trim_start(); + assert!( + stderr.contains(expected_line), + "missing expected stderr detail for {} in {} mode: {expected_line}\nstderr:\n{}", + case.name, + mode.name(), + stderr + ); + } let log_path = storage_dir.join("logs/server.log"); match mode { @@ -348,7 +365,7 @@ fn start_already_running_exits_with_error() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Server already running (pid [PID]) on [SOCKET_PATH] + × Server already running (pid [PID]) on [SOCKET_PATH] "); context @@ -765,8 +782,8 @@ fn start_with_missing_explicit_flag_config_errors_without_entering_install_mode( exit_code: 1 ----- stdout ----- ----- stderr ----- - error: reading config file [MISSING_CONFIG]: No such file or directory (os error 2) - > No such file or directory (os error 2) + × reading config file [MISSING_CONFIG]: No such file or directory (os error 2) + ╰─▶ No such file or directory (os error 2) "); } @@ -793,8 +810,8 @@ fn start_with_missing_env_config_errors_without_entering_install_mode() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: reading config file [MISSING_CONFIG]: No such file or directory (os error 2) - > No such file or directory (os error 2) + × reading config file [MISSING_CONFIG]: No such file or directory (os error 2) + ╰─▶ No such file or directory (os error 2) "); } @@ -819,18 +836,19 @@ fn start_with_malformed_default_settings_errors_without_entering_install_mode() exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Failed to parse settings file at [HOME_DIR]/.fabro/settings.toml: settings file is not valid TOML: TOML parse error at line 1, column 15 - | - 1 | [server.listen - | ^ - invalid table header - expected `.`, `]` - > settings file is not valid TOML: TOML parse error at line 1, column 15 - > | - > 1 | [server.listen - > | ^ - > invalid table header - > expected `.`, `]` + × Failed to parse settings file at [HOME_DIR]/.fabro/settings.toml: settings file is not valid TOML: TOML parse error at line 1, column 15 + │ | + │ 1 | [server.listen + │ | ^ + │ invalid table header + │ expected `.`, `]` + + ╰─▶ settings file is not valid TOML: TOML parse error at line 1, column 15 + | + 1 | [server.listen + | ^ + invalid table header + expected `.`, `]` "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/start.rs b/lib/crates/fabro-cli/tests/it/cmd/start.rs index 274b218fe..81b5c05d8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/start.rs @@ -172,7 +172,7 @@ fn start_rejects_already_active_or_completed_run() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: an engine process is still running for this run — cannot start + × an engine process is still running for this run — cannot start "); gate.release(); @@ -185,7 +185,7 @@ fn start_rejects_already_active_or_completed_run() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: cannot start run: status is succeeded(completed), expected submitted + × cannot start run: status is succeeded(completed), expected submitted "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/upgrade.rs b/lib/crates/fabro-cli/tests/it/cmd/upgrade.rs index 3b0cd7a9e..7a3a35576 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/upgrade.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/upgrade.rs @@ -97,8 +97,8 @@ fn upgrade_invalid_version_errors() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: invalid version: not-a-semver - > unexpected character 'n' while parsing major version number + × invalid version: not-a-semver + ╰─▶ unexpected character 'n' while parsing major version number "); } @@ -209,7 +209,7 @@ fn upgrade_brew_install_refuses_and_prints_brew_command() { ----- stderr ----- fabro was installed via Homebrew. Run `brew upgrade fabro` to update. - error: refusing to overwrite a Homebrew-managed binary + × refusing to overwrite a Homebrew-managed binary "); } @@ -245,6 +245,6 @@ fn upgrade_brew_install_rejects_version_flag() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: fabro is managed by Homebrew (formula `fabro`); Homebrew selects the version and channel. Use `brew upgrade fabro` (or reinstall with a different formula) instead of `fabro upgrade --version`/`--prerelease`/`--force`. + × fabro is managed by Homebrew (formula `fabro`); Homebrew selects the version and channel. Use `brew upgrade fabro` (or reinstall with a different formula) instead of `fabro upgrade --version`/`--prerelease`/`--force`. "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/validate.rs b/lib/crates/fabro-cli/tests/it/cmd/validate.rs index b9b989333..7c43f8e3c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/validate.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/validate.rs @@ -146,6 +146,6 @@ fn invalid() { 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 + × Validation failed "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/wait.rs b/lib/crates/fabro-cli/tests/it/cmd/wait.rs index e1331ad48..c44ec1e05 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/wait.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/wait.rs @@ -132,7 +132,7 @@ fn wait_submitted_run_times_out() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Timed out after 1s waiting for run '[ULID]' + × Timed out after 1s waiting for run '[ULID]' "); } @@ -189,7 +189,7 @@ fn wait_blocked_run_times_out_without_treating_it_as_terminal() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Timed out after 1s waiting for run '[ULID]' + × Timed out after 1s waiting for run '[ULID]' "); resolve_run.assert(); assert!( diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs b/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs index 039383198..96bc7bcaf 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs @@ -140,7 +140,7 @@ fn workflow_create_rejects_existing_workflow() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Workflow 'existing' already exists at [TEMP_DIR]/project/.fabro/workflows/existing + × Workflow 'existing' already exists at [TEMP_DIR]/project/.fabro/workflows/existing "); } @@ -155,7 +155,7 @@ fn workflow_create_errors_without_project_config() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: No .fabro/project.toml found in [TEMP_DIR] or any parent directory + × No .fabro/project.toml found in [TEMP_DIR] or any parent directory "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow_list.rs b/lib/crates/fabro-cli/tests/it/cmd/workflow_list.rs index 71955efca..70a0ae7b4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/workflow_list.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/workflow_list.rs @@ -37,7 +37,7 @@ fn workflow_list_errors_without_project_config() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: No .fabro/project.toml found in [TEMP_DIR] or any parent directory + × No .fabro/project.toml found in [TEMP_DIR] or any parent directory "); } diff --git a/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs b/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs index ec58d9496..1ba5e9539 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs @@ -153,7 +153,7 @@ fn artifact_commands_share_populated_run_fixture() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Path 'assets/retry/report.txt' matches multiple artifacts: create_colliding:retry_1, retry_assets:retry_1, retry_assets:retry_2. Use --node and/or --retry to disambiguate. + × Path 'assets/retry/report.txt' matches multiple artifacts: create_colliding:retry_1, retry_assets:retry_1, retry_assets:retry_2. Use --node and/or --retry to disambiguate. "); let flat_dest = context.temp_dir.join("artifact-flat"); @@ -169,6 +169,6 @@ fn artifact_commands_share_populated_run_fixture() { 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. + × 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. "); } diff --git a/lib/crates/fabro-cli/tests/it/support/mod.rs b/lib/crates/fabro-cli/tests/it/support/mod.rs index c84d115b0..067cddb94 100644 --- a/lib/crates/fabro-cli/tests/it/support/mod.rs +++ b/lib/crates/fabro-cli/tests/it/support/mod.rs @@ -26,8 +26,12 @@ pub(crate) fn fatal_error_line(stderr: &[u8]) -> String { console::strip_ansi_codes(&stderr) .lines() .rev() - .find_map(|line| line.strip_prefix("error: ").map(ToOwned::to_owned)) - .expect("stderr should contain a fatal `error:` line") + .find_map(|line| { + line.strip_prefix("error: ") + .or_else(|| line.trim_start().strip_prefix("× ")) + .map(ToOwned::to_owned) + }) + .expect("stderr should contain a fatal error line") } pub(crate) fn unique_run_id() -> String {