feat(aura-cli): enrich the graph sample into a trend+momentum blend showcase
The built-in sample that `aura graph` renders and `aura sweep` runs is now a
"trend + momentum, weighted blend" strategy that exercises four authoring/viewer
capabilities the single-level sample left dark:
- multiply-nested composites: root -> signals -> {trend = sma_cross, momentum = macd};
- a multi-param node inside a composite: blend = LinComb(3) (weights[0]/[1] shown);
- a multi-output node: momentum (the macd composite) re-exports macd/signal/histogram,
two of which the blend consumes;
- bind(): blend.weights[2] is fixed as a structural constant, so it drops out of the
sweepable param surface (and renders absent).
The new `signals` composite reuses the existing sma_cross/macd builders unchanged; the
sweep re-paths its axes to the eight free params (still a 4-point grid) and runs an
18-tick warm-up stream (showcase_prices) so the MACD EMA-of-EMA chain and the all-Any
LinComb join warm up. The render fixture (sample-model.json) is re-captured, and a new
headless guard (viewer_nested_depth) pins that the viewer renders the nesting to depth 2
with valid Graphviz ids. Pure authoring over already-shipped primitives — no engine or
graph-viewer.js change.
The flat run_sample, run_macd/macd(), the inline model_golden test, and the viewer are
untouched. A third sweep-param pin in tests/cli_run.rs (the aura sweep E2E) was re-pathed
in lockstep with its in-crate twin.
closes #62
This commit is contained in:
+75
-12
@@ -14,7 +14,7 @@ use aura_engine::{
|
||||
OutField, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target,
|
||||
};
|
||||
use aura_registry::{rank_by, Registry};
|
||||
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
|
||||
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
|
||||
@@ -35,6 +35,23 @@ fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A warm-up-adequate synthetic stream for the enriched sample/sweep: ~18 ticks
|
||||
/// rising, falling, then rising again so the trend SMA spread and the MACD
|
||||
/// EMA-of-EMA histogram both warm up and flip sign. It shares the proven warm-up
|
||||
/// profile of `macd_prices` (the enriched sample embeds the same `macd` composite,
|
||||
/// so it needs the same warm-up length); the flat `run_sample` keeps the shorter
|
||||
/// `synthetic_prices`. Deterministic and fixed (C1).
|
||||
fn showcase_prices() -> Vec<(Timestamp, Scalar)> {
|
||||
[
|
||||
1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034,
|
||||
1.0012, 0.9998, 1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::F64(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bootstrap the sample signal-quality harness with two recording sinks (equity
|
||||
/// tapped on the SimBroker, exposure tapped on the Exposure node). Rust-authored
|
||||
/// wiring (C17/C20) over the raw bootstrap API — no builder DSL this cycle. The
|
||||
@@ -154,6 +171,42 @@ fn sma_cross(name: &str) -> Composite {
|
||||
)
|
||||
}
|
||||
|
||||
/// The blended signal: a trend leg (SMA-cross) and a momentum leg (MACD), combined
|
||||
/// by a weighted sum. A multiply-nested composite (root → signals → {trend,
|
||||
/// momentum}); the blend is a multi-param node living inside it, with one weight
|
||||
/// bound as a structural constant (so it drops out of the sweepable surface).
|
||||
fn signals(name: &str) -> Composite {
|
||||
Composite::new(
|
||||
name,
|
||||
vec![
|
||||
BlueprintNode::Composite(sma_cross("trend")), // 0 trend leg (one f64 "cross")
|
||||
BlueprintNode::Composite(macd("momentum")), // 1 momentum leg (3 outputs)
|
||||
// 2 blend: Σ wᵢ·termᵢ over [trend.cross, momentum.histogram, momentum.signal].
|
||||
// weights[2] is bound (a fixed signal-line weight) → removed from param_space;
|
||||
// weights[0]/[1] stay tunable. `.named("blend")` makes the path signals.blend.*
|
||||
// (and renders the `blend:` prefix).
|
||||
LinComb::builder(3)
|
||||
.named("blend")
|
||||
.bind("weights[2]", Scalar::F64(0.5))
|
||||
.into(),
|
||||
],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // trend.cross → blend.term[0]
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 2 }, // momentum.histogram → blend.term[1]
|
||||
Edge { from: 1, to: 2, slot: 2, from_field: 1 }, // momentum.signal → blend.term[2]
|
||||
],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![
|
||||
Target { node: 0, slot: 0 }, // price → trend role 0
|
||||
Target { node: 1, slot: 0 }, // price → momentum role 0
|
||||
],
|
||||
source: None,
|
||||
}],
|
||||
vec![OutField { node: 2, field: 0, name: "signal".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
/// The sample signal-quality blueprint (value-empty) **with its two recording
|
||||
/// sinks reachable**: returns the equity + exposure receivers a per-point sweep
|
||||
/// run drains (the SMA lengths + exposure scale are injected at compile via the
|
||||
@@ -170,7 +223,7 @@ fn sample_blueprint_with_sinks() -> (
|
||||
let bp = Composite::new(
|
||||
"sample",
|
||||
vec![
|
||||
BlueprintNode::Composite(sma_cross("sma_cross")),
|
||||
BlueprintNode::Composite(signals("signals")),
|
||||
Exposure::builder().into(),
|
||||
SimBroker::builder(0.0001).into(),
|
||||
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
|
||||
@@ -225,15 +278,20 @@ fn scalar_as_param_f64(s: &Scalar) -> f64 {
|
||||
fn sweep_family() -> SweepFamily {
|
||||
let bp = sample_blueprint_with_sinks().0;
|
||||
let space = bp.param_space();
|
||||
bp.axis("sma_cross.fast.length", [2, 3])
|
||||
.axis("sma_cross.slow.length", [4, 5])
|
||||
bp.axis("signals.trend.fast.length", [2, 3])
|
||||
.axis("signals.trend.slow.length", [4, 5])
|
||||
.axis("signals.momentum.fast.length", [2])
|
||||
.axis("signals.momentum.slow.length", [4])
|
||||
.axis("signals.momentum.signal.length", [3])
|
||||
.axis("signals.blend.weights[0]", [1.0])
|
||||
.axis("signals.blend.weights[1]", [1.0])
|
||||
.axis("exposure.scale", [0.5])
|
||||
.sweep(|point| {
|
||||
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
|
||||
let mut h = bp
|
||||
.bootstrap_with_params(point.to_vec())
|
||||
.expect("grid points are kind-checked against param_space");
|
||||
let prices = synthetic_prices();
|
||||
let prices = showcase_prices();
|
||||
let window = (
|
||||
prices.first().expect("non-empty stream").0,
|
||||
prices.last().expect("non-empty stream").0,
|
||||
@@ -506,12 +564,17 @@ mod tests {
|
||||
// so a caller can bootstrap one point, run it, and drain both sinks.
|
||||
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
|
||||
let mut h = bp
|
||||
.with("sma_cross.fast.length", 2)
|
||||
.with("sma_cross.slow.length", 4)
|
||||
.with("signals.trend.fast.length", 2)
|
||||
.with("signals.trend.slow.length", 4)
|
||||
.with("signals.momentum.fast.length", 2)
|
||||
.with("signals.momentum.slow.length", 4)
|
||||
.with("signals.momentum.signal.length", 3)
|
||||
.with("signals.blend.weights[0]", 1.0)
|
||||
.with("signals.blend.weights[1]", 1.0)
|
||||
.with("exposure.scale", 0.5)
|
||||
.bootstrap()
|
||||
.expect("sample blueprint compiles under a valid point");
|
||||
h.run(vec![synthetic_prices()]);
|
||||
h.run(vec![showcase_prices()]);
|
||||
assert!(!rx_eq.try_iter().collect::<Vec<_>>().is_empty(), "equity sink drained empty");
|
||||
assert!(!rx_ex.try_iter().collect::<Vec<_>>().is_empty(), "exposure sink drained empty");
|
||||
}
|
||||
@@ -527,10 +590,10 @@ mod tests {
|
||||
for line in &lines {
|
||||
assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}");
|
||||
}
|
||||
assert!(lines[0].contains(r#""params":[["sma_cross.fast.length",2.0],["sma_cross.slow.length",4.0],["exposure.scale",0.5]]"#), "line0: {}", lines[0]);
|
||||
assert!(lines[1].contains(r#""params":[["sma_cross.fast.length",2.0],["sma_cross.slow.length",5.0],["exposure.scale",0.5]]"#), "line1: {}", lines[1]);
|
||||
assert!(lines[2].contains(r#""params":[["sma_cross.fast.length",3.0],["sma_cross.slow.length",4.0],["exposure.scale",0.5]]"#), "line2: {}", lines[2]);
|
||||
assert!(lines[3].contains(r#""params":[["sma_cross.fast.length",3.0],["sma_cross.slow.length",5.0],["exposure.scale",0.5]]"#), "line3: {}", lines[3]);
|
||||
assert!(lines[0].contains(r#""params":[["signals.trend.fast.length",2.0],["signals.trend.slow.length",4.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line0: {}", lines[0]);
|
||||
assert!(lines[1].contains(r#""params":[["signals.trend.fast.length",2.0],["signals.trend.slow.length",5.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line1: {}", lines[1]);
|
||||
assert!(lines[2].contains(r#""params":[["signals.trend.fast.length",3.0],["signals.trend.slow.length",4.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line2: {}", lines[2]);
|
||||
assert!(lines[3].contains(r#""params":[["signals.trend.fast.length",3.0],["signals.trend.slow.length",5.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line3: {}", lines[3]);
|
||||
for line in &lines {
|
||||
assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}");
|
||||
assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}");
|
||||
|
||||
@@ -211,7 +211,7 @@ fn sweep_prints_four_json_lines_and_exits_zero() {
|
||||
// pin the first odometer point's manifest params, not the commit value.
|
||||
assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}");
|
||||
assert!(
|
||||
first.contains("\"params\":[[\"sma_cross.fast.length\",2.0],[\"sma_cross.slow.length\",4.0],[\"exposure.scale\",0.5]]"),
|
||||
first.contains("\"params\":[[\"signals.trend.fast.length\",2.0],[\"signals.trend.slow.length\",4.0],[\"signals.momentum.fast.length\",2.0],[\"signals.momentum.slow.length\",4.0],[\"signals.momentum.signal.length\",3.0],[\"signals.blend.weights[0]\",1.0],[\"signals.blend.weights[1]\",1.0],[\"exposure.scale\",0.5]]"),
|
||||
"got: {stdout}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"SimBroker","role":"node","params":[],"ins":[["f64","any","exposure"],["f64","any","price"]],"outs":[["equity","f64"]]}},"3":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"4":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["2.o0","3.i0"],["1.o0","4.i0"],["src_price.o0","0.i0"],["src_price.o0","2.i1"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["cross","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}
|
||||
{"root":{"nodes":{"0":{"comp":"signals"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"SimBroker","role":"node","params":[],"ins":[["f64","any","exposure"],["f64","any","price"]],"outs":[["equity","f64"]]}},"3":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"4":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["2.o0","3.i0"],["1.o0","4.i0"],["src_price.o0","0.i0"],["src_price.o0","2.i1"]]},"composites":{"signals":{"inputs":[["f64","any","price"]],"outputs":[["signal","f64"]],"nodes":{"0":{"comp":"trend"},"1":{"comp":"momentum"},"2":{"prim":{"name":"blend","type":"LinComb","role":"node","params":[["weights[0]","f64"],["weights[1]","f64"]],"ins":[["f64","any","term[0]"],["f64","any","term[1]"],["f64","any","term[2]"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o2","2.i1"],["1.o1","2.i2"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"trend":{"inputs":[["f64","any","price"]],"outputs":[["cross","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"momentum":{"inputs":[["f64","any","price"]],"outputs":[["macd","f64"],["signal","f64"],["histogram","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}},"3":{"prim":{"name":"signal","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"4":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["2.o0","3.i0"],["2.o0","4.i0"],["3.o0","4.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"],["3.o0","#1"],["4.o0","#2"]]}}}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Headless depth-2 nesting guard for the `aura graph` viewer (graph-viewer.js).
|
||||
//
|
||||
// Property protected: the viewer renders MULTIPLY-nested composites — a composite
|
||||
// inside a composite inside the root — with valid Graphviz node identifiers at
|
||||
// every depth. genDot builds ids as `n` + the node-key path joined by `__`
|
||||
// (graph-viewer.js: `cid = "n" + cpath.join("__")`), so a depth-2 interior node
|
||||
// gets a three-segment id like `n0__0__0`. A digit-led non-numeral id (`0__0__0`)
|
||||
// would be misparsed by Graphviz and collapse the interior; this guard pins that
|
||||
// the `n`-prefix rule holds at depth 2, beyond the depth-1 viewer_dot_ids.mjs guard.
|
||||
//
|
||||
// It loads the real viewer module and the re-captured sample fixture (which is now
|
||||
// the enriched root → signals → {trend, momentum} graph), then EXPANDS RECURSIVELY:
|
||||
// discover the top-level expandable (signals), expand, re-discover the newly
|
||||
// revealed expandables (trend, momentum), expand those — until none remain — and
|
||||
// asserts (a) every DOT id is valid and (b) at least one three-segment (depth-2) id
|
||||
// was actually produced, so the guard cannot pass vacuously on a flat model.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const model = JSON.parse(readFileSync(join(here, "fixtures", "sample-model.json"), "utf8"));
|
||||
|
||||
// Stub the global BEFORE requiring the viewer (mirrors the browser bootstrap).
|
||||
global.window = { AURA_MODEL: model };
|
||||
const { normalizeModel, genDot } = require(join(here, "..", "assets", "graph-viewer.js"));
|
||||
const ROOT = normalizeModel(model).root;
|
||||
|
||||
// Expand every expandable composite, re-discovering deeper ones each pass, until
|
||||
// the expandable set stops growing (reaches every nesting level).
|
||||
let expanded = new Set();
|
||||
let dot = "";
|
||||
for (let pass = 0; pass < 16; pass++) {
|
||||
const g = genDot(ROOT, "root", expanded, false);
|
||||
dot = g.dot;
|
||||
const fresh = g.expandable.filter((id) => !expanded.has(id));
|
||||
if (!fresh.length) break;
|
||||
fresh.forEach((id) => expanded.add(id));
|
||||
}
|
||||
|
||||
// Collect every node identifier referenced in the final DOT (node-def lines + edge
|
||||
// endpoints), mirroring viewer_dot_ids.mjs.
|
||||
const ids = new Set();
|
||||
for (const m of dot.matchAll(/^\s*([^\s\[]+)\s*\[label=/gm)) ids.add(m[1]);
|
||||
for (const m of dot.matchAll(/(\S+?):[a-z]\w*:[a-z]\s*->\s*(\S+?):[a-z]\w*:[a-z]/g)) {
|
||||
ids.add(m[1]);
|
||||
ids.add(m[2]);
|
||||
}
|
||||
|
||||
const QUOTED = /^".*"$/s;
|
||||
const NUMERAL = /^-?(\.[0-9]+|[0-9]+(\.[0-9]*)?)$/;
|
||||
const BARE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const isValid = (id) => QUOTED.test(id) || NUMERAL.test(id) || BARE.test(id);
|
||||
|
||||
const bad = [...ids].filter((id) => !isValid(id));
|
||||
if (bad.length) {
|
||||
console.error(
|
||||
"INVALID DOT node identifier(s) at nesting depth — Graphviz will misparse a\n" +
|
||||
"digit-prefixed, non-numeral id and collapse the nested composite:\n " +
|
||||
bad.map((id) => JSON.stringify(id)).join("\n ")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Anti-vacuous: at least one three-segment (depth-2) id must exist, proving the
|
||||
// guard actually expanded two composite levels (root → signals → trend/momentum).
|
||||
const depth2 = [...ids].filter((id) => /^n\d+__\d+__\d+/.test(id));
|
||||
if (!depth2.length) {
|
||||
console.error(
|
||||
"no depth-2 (three-segment `nA__B__C`) id found — the guard did not reach two\n" +
|
||||
"nesting levels; the fixture is not multiply-nested or the expand loop stalled."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`OK — ${ids.size} DOT ids valid; ${depth2.length} reached nesting depth 2.`);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Integration guard: the `aura graph` viewer renders multiply-nested composites
|
||||
//! (a composite inside a composite inside the root) with valid Graphviz node ids
|
||||
//! at every depth.
|
||||
//!
|
||||
//! Like `viewer_dot_ids.rs`/`viewer_dot.rs`, the property lives in JavaScript
|
||||
//! (`genDot` in assets/graph-viewer.js), so this shells out to `node` running the
|
||||
//! headless guard `tests/viewer_nested_depth.mjs`, which loads the real viewer
|
||||
//! module and the re-captured sample fixture and expands it recursively to depth 2.
|
||||
//!
|
||||
//! `node` is REQUIRED: if it is not on PATH this test FAILS (a skipped guard is
|
||||
//! not a guard).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn viewer_renders_nested_composites_to_depth_2_with_valid_ids() {
|
||||
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("viewer_nested_depth.mjs");
|
||||
assert!(
|
||||
script.exists(),
|
||||
"guard script missing at {}",
|
||||
script.display()
|
||||
);
|
||||
|
||||
let out = match Command::new("node").arg(&script).output() {
|
||||
Ok(out) => out,
|
||||
Err(e) => panic!(
|
||||
"node is required for the viewer depth-2 nesting guard but could not be run \
|
||||
({e}); install Node.js or ensure `node` is on PATH"
|
||||
),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"viewer depth-2 nesting guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
|
||||
out.status.code()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user