diff --git a/crates/aura-cli/assets/graph-viewer.js b/crates/aura-cli/assets/graph-viewer.js
index a812f9d..7cbdce9 100644
--- a/crates/aura-cli/assets/graph-viewer.js
+++ b/crates/aura-cli/assets/graph-viewer.js
@@ -186,12 +186,16 @@ function genDot(def, role, expandedSet, showTypes) {
}
// === styled tooltip + interaction ============================================
+// `md` is a pure string transform (no DOM access) hoisted above the document
+// guard so it can be exported for headless testing (see module.exports below);
+// this is a location-only move, not a behaviour change — it is still called
+// exactly the same way from `showTip` inside the browser-only block.
+const md = (s) => s.replace(/\*\*(.+?)\*\*/g, "$1").replace(/`(.+?)`/g, "$1").replace(/\n/g, "
");
// The DOM bootstrap runs only in the browser. Under a node test harness there is
// no `document`/`Viz`, so the whole interaction block is skipped (the pure parts
-// — normalizeModel, genDot — are exported below for headless testing).
+// — normalizeModel, genDot, md — are exported below for headless testing).
if (typeof document !== "undefined") {
const tip = document.getElementById("tip");
-const md = (s) => s.replace(/\*\*(.+?)\*\*/g, "$1").replace(/`(.+?)`/g, "$1").replace(/\n/g, "
");
function showTip(text, x, y) {
tip.innerHTML = md(text); tip.style.display = "block";
const w = tip.offsetWidth, h = tip.offsetHeight;
@@ -253,6 +257,8 @@ Viz.instance().then(v => { viz = v; show(); }).catch(e => { document.getElementB
// Headless export: the node DOT-id guard requires this file (after setting
// global.window) and exercises the pure generator. No effect in the browser.
+// `md` is included solely so the tooltip guard (viewer_tooltip.mjs) can pin its
+// markdown-to-HTML conversion; it is unchanged, just now reachable headlessly.
if (typeof module !== "undefined" && module.exports) {
- module.exports = { normalizeModel, genDot };
+ module.exports = { normalizeModel, genDot, md };
}
diff --git a/crates/aura-cli/tests/viewer_tooltip.mjs b/crates/aura-cli/tests/viewer_tooltip.mjs
new file mode 100644
index 0000000..e924709
--- /dev/null
+++ b/crates/aura-cli/tests/viewer_tooltip.mjs
@@ -0,0 +1,121 @@
+// Headless tooltip guard for the `aura graph` viewer (graph-viewer.js).
+//
+// Property protected: the INFO maps genDot populates (INFO.B for a node body,
+// INFO.C for an expanded composite's cluster frame, INFO.P for a port) and the
+// md() markdown-to-HTML step that turns an INFO entry into the #tip div's
+// innerHTML all keep their CURRENT exact string shape. Before this guard the DOT
+// half of the viewer (normalizeModel/genDot output) was covered by the sibling
+// viewer_*.mjs suite, but the INFO population (addInfo, the composite-cluster
+// caption) and md() were asserted by no test — a silent wording/markup change
+// there would reach the browser tooltip undetected.
+//
+// It loads the real viewer module and drives the exported pure `genDot` (for
+// INFO.B/INFO.C/INFO.P) and `md` (for the markdown step). `node` is REQUIRED: a
+// skipped guard is not a guard.
+
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const require = createRequire(import.meta.url);
+const here = dirname(fileURLToPath(import.meta.url));
+
+// One composite ("sub", holding a primitive "double") + one root-level
+// primitive ("gauge"), wired as: root.nodes = { "0": , "1":
+// }. Small enough that every INFO entry below is traceable by
+// hand; big enough to cover collapsed-composite, expanded-composite, and
+// primitive INFO shapes in one fixture.
+const model = {
+ root: {
+ nodes: {
+ "0": { comp: "sub" },
+ "1": { prim: {
+ name: "g", type: "Mul2", role: "node",
+ params: [["k", "f64"]],
+ bound: [],
+ ins: [["f64", "any", "a"]],
+ outs: [["y", "f64"]],
+ } },
+ },
+ edges: [],
+ },
+ composites: {
+ sub: {
+ inputs: [["f64", "any", "x"]],
+ outputs: [["y", "f64"]],
+ nodes: {
+ "0": { prim: {
+ name: "double", type: "Mul2", role: "node",
+ params: [], bound: [],
+ ins: [["f64", "any", "a"]],
+ outs: [["y", "f64"]],
+ } },
+ },
+ edges: [],
+ },
+ },
+};
+
+// COMP (used by genDot to resolve a composite reference by name) is captured at
+// require time from window.AURA_MODEL — mirrors the browser bootstrap and the
+// sibling viewer_nested_depth.mjs guard.
+global.window = { AURA_MODEL: model };
+const { normalizeModel, genDot, md } = require(join(here, "..", "assets", "graph-viewer.js"));
+const ROOT = normalizeModel(model).root;
+
+const fail = (msg) => { console.error(msg); process.exit(1); };
+const expect = (actual, expected, msg) => {
+ if (actual !== expected) {
+ fail(`${msg}\n--- expected ---\n${JSON.stringify(expected)}\n--- actual ---\n${JSON.stringify(actual)}`);
+ }
+};
+
+// --- Property 1: collapsed composite — INFO.B carries type/role + params line.
+{
+ const { info } = genDot(ROOT, "root", new Set(), false);
+ expect(
+ info.B["n0"],
+ "**sub** · composite\nparams: none",
+ "collapsed composite INFO.B[n0] shape changed"
+ );
+}
+
+// --- Property 2: expanded composite — INFO.C carries the cluster caption.
+{
+ const { info } = genDot(ROOT, "root", new Set(["n0"]), false);
+ expect(
+ info.C["clust_n0"],
+ "**sub** · composite · click frame to collapse",
+ "expanded composite INFO.C[clust_n0] shape changed"
+ );
+}
+
+// --- Property 3: primitive node — INFO.B body line + INFO.P port entries.
+{
+ const { info } = genDot(ROOT, "root", new Set(), false);
+ expect(
+ info.B["n1"],
+ "**Mul2** · node\nparams: `k:f64`",
+ "primitive INFO.B[n1] shape changed"
+ );
+ expect(info.P["P_n1_i0"], "**a** · `f64`", "primitive input port INFO.P[P_n1_i0] shape changed");
+ expect(info.P["P_n1_o0"], "**y** · `f64`", "primitive output port INFO.P[P_n1_o0] shape changed");
+}
+
+// --- Property 4: md() — the markdown-to-HTML step the #tip div consumes.
+{
+ expect(md("**x**\n`y`"), "x
y", "md() markdown conversion changed");
+ // End-to-end: md() applied to the real INFO.B[n1] string from Property 3 —
+ // bold the type, wrap the param signature in , turn the newline into
+ //
, and leave the hand-authored markup untouched
+ // (md() only rewrites **, `` ` ``, and \n — it does not touch raw HTML).
+ const { info } = genDot(ROOT, "root", new Set(), false);
+ expect(
+ md(info.B["n1"]),
+ "Mul2 · node
params: k:f64",
+ "md() applied to a real INFO.B entry changed shape"
+ );
+}
+
+console.log("OK — INFO.B/INFO.C/INFO.P tooltip shapes and md() conversion pinned.");
+process.exit(0);
diff --git a/crates/aura-cli/tests/viewer_tooltip.rs b/crates/aura-cli/tests/viewer_tooltip.rs
new file mode 100644
index 0000000..e31a59f
--- /dev/null
+++ b/crates/aura-cli/tests/viewer_tooltip.rs
@@ -0,0 +1,39 @@
+//! Integration guard: the `aura graph` viewer's tooltip INFO maps (INFO.B for a
+//! node body, INFO.C for an expanded composite's cluster frame, INFO.P for a
+//! port) and the md() markdown-to-HTML step keep their current exact shape.
+//!
+//! The property lives in JavaScript (`addInfo`/`genDot`/`md` in
+//! assets/graph-viewer.js), so this shells out to `node` running the headless
+//! guard `tests/viewer_tooltip.mjs`, which loads the real viewer module and
+//! drives the exported `genDot`/`md` over a minimal collapsed+expanded
+//! composite / primitive fixture.
+//!
+//! `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_tooltip_info_and_markdown_shapes_are_pinned() {
+ let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("tests")
+ .join("viewer_tooltip.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 tooltip 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 tooltip guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
+ out.status.code()
+ );
+}