use fabro_test::{fabro_snapshot, test_context}; use crate::support::LightweightCli; fn fixture(name: &str) -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join(format!("../../../test/{name}")) .canonicalize() .expect("fixture path should exist") } #[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] Arguments: Path to the .fabro workflow file Options: --json Output as JSON [env: FABRO_JSON=] --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=] -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: [FIXTURES]/simple.fabro Validation: OK "); } #[test] fn simple_does_not_connect_to_configured_server() { let cli = LightweightCli::new(); let mut cmd = cli.command(); cmd.env("FABRO_SERVER", "http://127.0.0.1:9") .arg("validate") .arg(fixture("simple.fabro")); let output = cmd.output().expect("validate should execute"); assert!( output.status.success(), "validate should run locally without connecting to FABRO_SERVER\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), ); } /// Offline validation has no model catalog, so a model or provider the server /// owns must pass rather than be reported as unknown. #[test] fn server_owned_provider_is_not_rejected_by_offline_validation() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("server-model.fabro")); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Workflow: ServerModel (3 nodes, 2 edges) Graph: [FIXTURES]/server-model.fabro Validation: OK "); } #[test] fn model_fallback_table_is_not_catalog_checked_by_offline_validation() { let cli = LightweightCli::new(); let mut cmd = cli.command(); cmd.env("FABRO_SERVER", "http://127.0.0.1:9") .arg("validate") .arg(fixture("offline-fallbacks/workflow.fabro")); let output = cmd.output().expect("validate should execute"); assert!( output.status.success(), "offline validation should parse model fallback tables without a server catalog\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), ); } #[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: [FIXTURES]/branching.fabro warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry) fix: Add retry_target or fallback_retry_target attribute 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: [FIXTURES]/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: [FIXTURES]/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: [FIXTURES]/styled.fabro Validation: OK "); } #[test] fn inferred_command() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("inferred_command.fabro")); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Workflow: InferredCommand (3 nodes, 2 edges) Graph: [FIXTURES]/inferred_command.fabro Validation: OK "); } #[test] fn bare_fabro_with_unbound_inputs_validates_structurally_with_warning() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("templated_unbound.fabro")); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Workflow: TemplatedUnbound (3 nodes, 2 edges) Graph: [FIXTURES]/templated_unbound.fabro warning: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable) fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=` warning: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable) fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=` Validation: OK "); } /// Regression: https://github.com/fabro-sh/fabro/issues/286 /// /// Undefined template variables in a prompt loaded via `@file` reference must /// surface as the same warning diagnostic as an inline prompt — not a hard /// validation error. #[test] fn bare_fabro_with_unbound_inputs_in_imported_prompt_validates_structurally_with_warning() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("templated_unbound_imported/workflow.fabro")); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Workflow: TemplatedUnboundImported (3 nodes, 2 edges) Graph: [FIXTURES]/templated_unbound_imported/workflow.fabro warning: [FIXTURES]/templated_unbound_imported/work.md:1:12: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable) fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=` Validation: OK "); } /// Regression: https://github.com/fabro-sh/fabro/issues/330 /// /// Undefined template variables in partials included by an imported prompt must /// surface as structural validation warnings, matching direct `@file` prompts. #[test] fn bare_fabro_with_unbound_inputs_in_template_partial_validates_structurally_with_warning() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("templated_unbound_partial/workflow.fabro")); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Workflow: TemplatedUnboundPartial (3 nodes, 2 edges) Graph: [FIXTURES]/templated_unbound_partial/workflow.fabro warning: [FIXTURES]/templated_unbound_partial/test-include.partial.md:1:4: undefined template variable `inputs.hello` in node `test_imported_include` attribute `prompt` [node: test_imported_include] (template_undefined_variable) fix: bind `inputs.hello` via `[run.inputs]` in workflow.toml, or pass `--input inputs.hello=` Validation: OK "); } #[test] fn bare_fabro_picks_up_sibling_workflow_toml_inputs() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("templated_inputs/workflow.fabro")); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Workflow: TemplatedInputs (3 nodes, 2 edges) Graph: [FIXTURES]/templated_inputs/workflow.fabro Validation: OK "); } #[test] fn validate_accepts_static_template_dependencies() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("templates/static_dependencies/workflow.fabro")); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Workflow: TemplateIncludes (4 nodes, 3 edges) Graph: [FIXTURES]/templates/static_dependencies/workflow.fabro Validation: OK "); } #[test] fn validate_accepts_template_partial_sibling_under_workflow_root() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("templates/sibling_partial/workflow.fabro")); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Workflow: SiblingPartialTemplateInclude (3 nodes, 2 edges) Graph: [FIXTURES]/templates/sibling_partial/workflow.fabro Validation: OK "); } #[test] fn validate_reports_missing_template_dependency() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("templates/missing_dependency/workflow.fabro")); let mut filters = context.filters(); filters.push(( r"(?:\.\./)*\.\.\[FIXTURES\]/".to_string(), "[FIXTURES]/".to_string(), )); fabro_snapshot!(filters, cmd, @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- × failed to discover template dependencies: missing template dependency `missing.tpl.md` from `[FIXTURES]/templates/missing_dependency/workflow.fabro` "); } /// A node named only by an edge is almost always a typo, so validation must /// fail instead of quietly running it as a default agent stage. #[test] fn edge_only_node() { let context = test_context!(); let mut cmd = context.validate(); cmd.arg(fixture("edge_only_node.fabro")); fabro_snapshot!(context.filters(), cmd, @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- Workflow: EdgeOnlyNode (2 nodes, 2 edges) Graph: [FIXTURES]/edge_only_node.fabro error [node: misspelled_node]: Node 'misspelled_node' is referenced by edge 'start -> misspelled_node' but has no node declaration (edge_target_exists) fix: Declare node 'misspelled_node' or correct the edge endpoint × Validation failed "); } #[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: [FIXTURES]/invalid.fabro error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node) fix: Add a node with shape=Mdiamond or id 'start' error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing) fix: Remove outgoing edges from the exit node × Validation failed "); }