//! Integration test: drive the built `aura` binary as a downstream user would, //! asserting the `run` subcommand's stdout/exit contract and the bad-args 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 for a process test that persists run /// records (so `aura sweep`'s `./runs/runs.jsonl` never dirties the repo). /// Unique per test + per process; no external tempfile dependency. fn temp_cwd(name: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name)); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp cwd"); dir } #[test] fn run_prints_json_and_exits_zero() { let out = Command::new(BIN).arg("run").output().expect("spawn aura run"); assert!(out.status.success(), "exit status: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); // exactly one line (the JSON object + a trailing newline from println!). assert_eq!(stdout.lines().count(), 1, "stdout was: {stdout:?}"); let line = stdout.trim_end(); // canonical cycle-0009 JSON shape: nested manifest + metrics, stable keys. // The manifest leads with a `commit` field; its value is the build's git // identity (asserted by `run_manifest_commit_carries_real_git_head`), so // this shape check pins only the surrounding structure, not the value. assert!(line.starts_with("{\"manifest\":{\"commit\":\""), "got: {line}"); assert!(line.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""), "got: {line}"); assert!(line.contains("\"window\":[1,7]"), "got: {line}"); assert!(line.contains("\"metrics\":{\"total_pips\":"), "got: {line}"); // the integer sign-flip count is stable across float renderings. assert!(line.ends_with("\"exposure_sign_flips\":1}}"), "got: {line}"); } /// Property: the RunManifest is self-identifying — its `commit` carries the /// real code identity that produced the run, not a placeholder. C8/C18 audit /// trail (this run = this commit): once runs are archived, `manifest.commit` /// must point back at the source. The build captures the current git HEAD at /// compile time; the `"unknown"` literal survives only when there is no git. /// /// Structural assertion (not exact-equality) on purpose: the working tree may /// be dirty when this runs (e.g. this very test file uncommitted), so a build /// that appends a `-dirty` suffix would not equal `rev-parse HEAD` verbatim. /// The honest contract is: the field CONTAINS the current HEAD sha AND is not /// the bare `"unknown"` placeholder — true regardless of dirtiness/suffix. #[test] fn run_manifest_commit_carries_real_git_head() { // The current HEAD sha, obtained the same way the build is expected to. let head = Command::new("git") .args(["rev-parse", "HEAD"]) .output() .expect("spawn git rev-parse HEAD"); assert!(head.status.success(), "git rev-parse HEAD failed"); let head_sha = String::from_utf8(head.stdout).expect("utf-8 sha"); let head_sha = head_sha.trim(); assert!(!head_sha.is_empty(), "empty HEAD sha"); // Drive the binary and pull `manifest.commit` out of the single JSON line. let out = Command::new(BIN).arg("run").output().expect("spawn aura run"); assert!(out.status.success(), "exit status: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); let line = stdout.trim_end(); // Locate the `"commit":""` value without a JSON dependency (the // sibling tests parse this output by substring too). let key = "\"commit\":\""; let start = line.find(key).expect("manifest has a commit field") + key.len(); let rest = &line[start..]; let end = rest.find('"').expect("commit field is a closed string"); let commit = &rest[..end]; assert_ne!( commit, "unknown", "manifest.commit is still the placeholder: {line}" ); assert!( commit.contains(head_sha), "manifest.commit {commit:?} should contain the current HEAD sha {head_sha:?}; line: {line}" ); } /// Property: `aura run` is strict about its argument vector — a trailing /// unexpected token (e.g. a typo'd flag like `aura run --sweep`) takes the /// error path (usage on stderr, exit 2), never a silent full run. This is the /// strict reading ratified in #16: keying only on the first token being `run` /// and ignoring the rest would let a typo masquerade as a successful run. /// The same test pins positive-preservation — bare `aura run` still emits the /// JSON report on stdout and exits 0 — so the strict guard cannot regress the /// happy path. #[test] fn run_with_trailing_token_is_strict_and_exits_two() { // Negative: an unexpected trailing token is rejected like a bad-args call. let out = Command::new(BIN) .args(["run", "extra-garbage"]) .output() .expect("spawn aura run extra-garbage"); assert_eq!( out.status.code(), Some(2), "`aura run extra-garbage` must reject the trailing token: {:?}", out.status ); assert!( out.stdout.is_empty(), "stdout should be empty on the usage path, got: {:?}", String::from_utf8_lossy(&out.stdout) ); let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr"); assert!(stderr.contains("usage"), "stderr was: {stderr:?}"); // Positive-preservation: bare `aura run` still runs and prints the report. let ok = Command::new(BIN).arg("run").output().expect("spawn aura run"); assert_eq!( ok.status.code(), Some(0), "bare `aura run` must still succeed: {:?}", ok.status ); let ok_stdout = String::from_utf8(ok.stdout).expect("utf-8 stdout"); assert!( ok_stdout.trim_start().starts_with("{\"manifest\":"), "bare `aura run` should print the JSON report, got: {ok_stdout:?}" ); } #[test] fn run_real_unspecced_symbol_refuses_with_exit_2() { // The instrument_spec lookup precedes any data access, so an un-specced symbol // refuses with NO local data required (CI-safe). "NONEXISTENT" is not in the // vetted table. let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "--real", "NONEXISTENT"]) .output() .expect("spawn aura"); assert_eq!(out.status.code(), Some(2), "expected exit 2"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("no vetted pip/instrument spec for symbol 'NONEXISTENT'"), "expected the per-instrument-pip refusal message, got: {stderr}" ); } /// Property: the un-specced-symbol refusal is a CLEAN refusal — it emits NOTHING /// on stdout, never a partial `RunReport`. The #22 acceptance is "refuse INSTEAD /// of emitting nonsense": the pip lookup precedes harness construction and data /// access, so a `None` spec must short-circuit to `exit(2)` before any JSON line /// can reach stdout. A regression that moved the lookup after a partial run would /// leak a `{"manifest":...}` line here while still exiting non-zero; this pins /// that it cannot. Archive-independent (the lookup never touches local data). #[test] fn run_real_unspecced_symbol_emits_no_stdout_report() { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "--real", "NONEXISTENT"]) .output() .expect("spawn aura"); assert_eq!(out.status.code(), Some(2), "expected exit 2: {:?}", out.status); assert!( out.stdout.is_empty(), "the refusal must not leak a partial report to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout) ); } /// Property: the pip-honesty refusal keys on the SYMBOL being un-specced, not on /// `--real` being taken at all. A *vetted* symbol (`EURUSD`, in the instrument /// table) gets PAST the `instrument_spec` lookup, so it never produces the /// pip-refusal message — it either runs to a report (local data present) or hits /// the DISTINCT no-local-data usage path ("no local data for symbol"). Either /// way the "no vetted pip/instrument spec" string is absent. This distinguishes /// the per-instrument-pip honesty lever from the unrelated arg-grammar / no-data /// error paths, and is archive-independent (asserts only on the absence of the /// pip-refusal string, true whether or not EURUSD data is on the machine). #[test] fn run_real_vetted_symbol_never_hits_the_pip_refusal() { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "--real", "EURUSD"]) .output() .expect("spawn aura"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( !stderr.contains("no vetted pip/instrument spec"), "a vetted symbol must clear the pip lookup, never the pip-refusal; stderr: {stderr}" ); // It resolves to exactly one of the two legitimate outcomes: a clean report // (exit 0, data present) or the distinct no-local-data refusal (exit 2). match out.status.code() { Some(0) => assert!( String::from_utf8_lossy(&out.stdout) .trim_start() .starts_with("{\"manifest\":"), "exit 0 must carry a JSON report, got: {:?}", String::from_utf8_lossy(&out.stdout) ), Some(2) => assert!( stderr.contains("no local data for symbol 'EURUSD'"), "exit 2 for a vetted symbol must be the no-data path, got: {stderr}" ), other => panic!("unexpected exit for a vetted real run: {other:?}; stderr: {stderr}"), } } /// Property: a vetted symbol threads its looked-up pip all the way to the binary's /// emitted manifest — `aura run --real GER40` renders the INDEX pip /// `sim-optimal(pip_size=1)`, not the FX `0.0001` literal the synthetic path uses. /// This is the #22 headline at the built-binary boundary (criterion 2: GER40=1.0 /// vs EURUSD=0.0001 vs un-specced→refuse): the metadata channel reaches stdout, so /// equity is honestly scaled per instrument. Gated on local GER40 data — skip /// cleanly when the archive is absent (the project's skip-on-no-data convention), /// so it never fails on a data-less machine; the no-data path is the distinct /// "no local data" refusal (exit 2), not the pip-refusal, asserted above. #[test] fn run_real_vetted_index_pip_reaches_the_emitted_manifest() { // The vetted GER40 Sept-2024 window (inclusive Unix-ms), the same calendar // month the gated ingest path drives; bounding it keeps the run fast. const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS]) .output() .expect("spawn aura"); // Skip on a data-less machine: a vetted symbol with no local data takes the // distinct no-data path (exit 2, "no local data"), never the pip-refusal. if out.status.code() == Some(2) { let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("no local data for symbol 'GER40'"), "exit 2 for vetted GER40 must be the no-data path, got: {stderr}" ); eprintln!("skip: no local GER40 data"); return; } assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); let line = stdout.trim_end(); // the looked-up index pip (1.0 -> "1") reaches the manifest, not the FX 0.0001. assert!( line.contains("\"broker\":\"sim-optimal(pip_size=1)\""), "GER40 must render the index pip in the manifest, got: {line}" ); } #[test] fn no_args_prints_usage_and_exits_two() { let out = Command::new(BIN).output().expect("spawn aura"); assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status); assert!(out.stdout.is_empty(), "stdout should be empty on the usage path"); let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr"); assert!(stderr.contains("usage"), "stderr was: {stderr:?}"); } /// Property: `--help`/`-h` is the success-path help affordance, not the error /// path. A newcomer's reflex first command (C22 first-contact ergonomics, #20) /// prints the usage to stdout and exits 0; the conventional `-h` alias behaves /// identically. An *unknown* subcommand keeps the error path (exit 2) so the /// help fix does not regress bad-args handling. #[test] fn help_flag_prints_usage_to_stdout_and_exits_zero() { for flag in ["--help", "-h"] { let out = Command::new(BIN).arg(flag).output().expect("spawn aura --help"); assert_eq!(out.status.code(), Some(0), "`aura {flag}` exit: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); assert!( !stdout.trim().is_empty(), "`aura {flag}` should print help to stdout, got: {stdout:?}" ); // The usage names the only subcommand a newcomer can run. assert!( stdout.contains("run"), "`aura {flag}` help should mention the `run` subcommand, got: {stdout:?}" ); } // Negative-preservation: an unknown subcommand is still the error path. let out = Command::new(BIN).arg("frobnicate").output().expect("spawn aura frobnicate"); assert_eq!( out.status.code(), Some(2), "unknown subcommand must stay exit 2: {:?}", out.status ); } /// Property: `aura graph` emits a single self-contained HTML page — the /// interactive pin-graph viewer with the deterministic model inlined and the /// vendored Graphviz-WASM + pan-zoom inlined (no remote fetch). Driven through /// the built binary so it pins the observable CLI contract. The DOT/SVG are /// Graphviz-version-dependent and not asserted (the prototype is the by-hand /// visual reference); this pins the page envelope + the retirement of the old /// ascii-dag notation. #[test] fn graph_emits_self_contained_html_viewer() { let out = Command::new(BIN).arg("graph").output().expect("spawn aura graph"); assert_eq!(out.status.code(), Some(0), "`aura graph` exit: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); // a self-contained HTML document... assert!( stdout.trim_start().starts_with(""), "not an HTML doc: {:?}", &stdout[..stdout.len().min(80)] ); // ...with the deterministic model inlined as the viewer's data source... assert!(stdout.contains("window.AURA_MODEL = {\"root\":"), "model not inlined: {stdout:?}"); // ...the Graphviz-WASM bootstrap present, and no remote asset fetch. assert!(stdout.contains("Viz.instance"), "viewer bootstrap missing"); assert!(!stdout.contains("