diff --git a/crates/aura-cli/tests/run_refuses_unrunnable_blueprint.rs b/crates/aura-cli/tests/run_refuses_unrunnable_blueprint.rs new file mode 100644 index 0000000..fc14a96 --- /dev/null +++ b/crates/aura-cli/tests/run_refuses_unrunnable_blueprint.rs @@ -0,0 +1,115 @@ +//! `aura run ` must REFUSE a user-authorable blueprint it +//! cannot run — a clean `aura:`-prefixed message on stderr and exit 1, the +//! established binding-refusal register — never a process panic. Both cases +//! are user mistakes on the hand-authored single-run path, so each deserves a +//! diagnosis, not a stack trace. +//! +//! Two independent unrunnable shapes, each pinned as its own minimal, +//! self-contained blueprint (inlined here — no shared fixture file): +//! 1. a declared tap whose `from` wire references an out-of-range node +//! index (taps are authored by raw node index, so a miscount is the +//! expected mistake) — compiles to `CompileError::TapWireOutOfRange`; +//! 2. an exposed output that is not a `bias` stream (a measurement-only +//! blueprint) — `wrap_r` cannot weld it to the R-evaluator. + +use std::path::Path; +use std::process::Command; + +/// Path to the freshly-built `aura` binary (Cargo sets this env var for the +/// test crate; the binary is named `aura` in `Cargo.toml`). +const BIN: &str = env!("CARGO_BIN_EXE_aura"); + +/// A fresh, unique working directory per test (so nothing a run might emit +/// dirties the repo). Unique per test + per process. +fn temp_cwd(name: &str) -> std::path::PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-refuse-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp cwd"); + dir +} + +/// Run `aura run ` in an isolated cwd and assert the CLEAN-REFUSAL +/// contract: exit code 1, an `aura:`-prefixed stderr message, and NO panic — +/// the property both cases share. `what` names the offending shape for the +/// failure message. +fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) { + let cwd = temp_cwd(tag); + let bp_path = cwd.join("blueprint.json"); + std::fs::write(&bp_path, bp_json).expect("write blueprint"); + + let out = Command::new(BIN) + .args(["run", bp_path.to_str().expect("utf-8 path")]) + .current_dir(&cwd) + .output() + .expect("spawn aura run"); + + let stderr = String::from_utf8_lossy(&out.stderr); + + // The defining symptom of the bug: the process panics (Rust's default + // unwind exits 101 and prints "panicked at ...") instead of refusing. + assert!( + !stderr.to_lowercase().contains("panic"), + "{what}: expected a clean refusal, got a PANIC:\n{stderr}" + ); + // The clean-refusal register: `aura: ` on stderr, exit 1 — exactly + // what the binding refusals in `run_signal_r` already emit. + assert!( + stderr.contains("aura:"), + "{what}: refusal must carry the `aura:` prefix:\n{stderr}" + ); + assert_eq!( + out.status.code(), + Some(1), + "{what}: a refusal exits 1 (not 101 panic, not 2 usage):\n{stderr}" + ); +} + +/// Property: a blueprint whose declared tap wire points at an out-of-range +/// node index is refused, not crashed. Minimal trigger — a single-node closed +/// strategy (`price → Bias → "bias"`, so the R-wrap welds cleanly) carrying +/// one tap whose `from.node` (5) is out of range for a one-node graph. This +/// compiles to `CompileError::TapWireOutOfRange`, which `run_signal_r`'s +/// `compile_with_params(...).expect(...)` currently turns into a panic. +#[test] +fn run_refuses_a_tap_wire_pointing_out_of_range() { + let bp = r#"{ + "format_version": 1, + "blueprint": { + "name": "bias_with_bad_tap", + "nodes": [ + {"primitive": {"type": "Bias", "name": "bias", + "bound": [{"pos": 0, "name": "scale", "kind": "F64", "value": {"F64": 1.0}}]}} + ], + "edges": [], + "input_roles": [{"name": "price", "targets": [{"node": 0, "slot": 0}], "source": "F64"}], + "output": [{"node": 0, "field": 0, "name": "bias"}], + "taps": [{"name": "bad", "from": {"node": 5, "field": 0}}] + } + }"#; + assert_clean_refusal(bp, "bad-tap-wire", "out-of-range tap wire"); +} + +/// Property: a blueprint whose single exposed output is not a `bias` stream +/// (a measurement-only blueprint — here `price → SMA → "measurement"`) is +/// refused, not crashed. `wrap_r` welds the nested signal by its `"bias"` +/// output port; a non-bias output makes that weld resolve to +/// `BuildError::UnknownOutPort`, which `wrap_r`'s `g.build().expect(...)` +/// currently turns into a panic. (This pins the refusal only — teaching +/// `aura run` to accept bias-less blueprints is a separate feature.) +#[test] +fn run_refuses_a_non_bias_output_blueprint() { + let bp = r#"{ + "format_version": 1, + "blueprint": { + "name": "measurement_only", + "nodes": [ + {"primitive": {"type": "SMA", "name": "avg", + "bound": [{"pos": 0, "name": "length", "kind": "I64", "value": {"I64": 2}}]}} + ], + "edges": [], + "input_roles": [{"name": "price", "targets": [{"node": 0, "slot": 0}], "source": "F64"}], + "output": [{"node": 0, "field": 0, "name": "measurement"}] + } + }"#; + assert_clean_refusal(bp, "non-bias-output", "non-bias exposed output"); +}