// v_graph_console (`#/graph`, 2026-08-03 SDD) smoke test. Not a framework,
// not a fixture -- app.js has no automated JS test precedent (SDD's own
// scope note: this repo relies on manual browser verification for the
// console's DOM-rendering functions), but the "leave one runnable check
// behind" rule still applies to non-trivial new logic. This loads app.js
// in a bare vm.Context with just enough document/window/localStorage
// stubbing for escapeHtml() and the render* functions to run, then asserts
// on the generated HTML strings for the new/changed functions:
// - renderOrgGraphTopology (FR-1: node status classes + retry-loop edges)
// - renderGraphVisualizerPanel (FR-4: enableNodeClick opt-in, and the
// backward-compat case that actually matters -- v_task_detail still
// calls this via `.map(renderGraphVisualizerPanel)`, which passes the
// array index as a second argument; that must NOT be misread as an
// options object with enableNodeClick=true)
// - renderArtifactSummaryList (FR-5: type grouping/truncation/empty state,
// plus 2026-08-04's click-through-to-v_artifact_browser fix: each item
// now carries data-artifact-id)
// - ARTIFACT_DETAIL_RE (2026-08-04: #/artifacts/:artifact_id route that
// v_artifact_browser's own mountArtifactBrowser(initialArtifactId) uses
// to pre-select the clicked artifact -- see render()'s route dispatch)
//
// Run: node frontend/test_graph_console_smoke.js
"use strict";
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const assert = require("assert");
function makeFakeDiv() {
let text = "";
return {
set textContent(v) { text = v == null ? "" : String(v); },
get textContent() { return text; },
get innerHTML() {
return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
},
};
}
const sandbox = {
document: {
createElement: () => makeFakeDiv(),
getElementById: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
},
window: {
location: { protocol: "http:", host: "localhost", hash: "" },
addEventListener: () => {},
},
localStorage: { getItem: () => null, setItem: () => {} },
console,
};
vm.createContext(sandbox);
const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8");
// `function ...` declarations at top level become sandbox globals
// automatically (Annex B global-object semantics), but top-level `const`
// (e.g. ARTIFACT_DETAIL_RE) stays lexically scoped to the script and never
// reaches the sandbox object -- explicitly re-export the one const this
// test needs, in the same script/lexical scope, rather than changing app.js's
// declaration style just for testability.
const augmentedSrc = src + "\nglobalThis.ARTIFACT_DETAIL_RE = ARTIFACT_DETAIL_RE;\n";
new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
// ---- computeGraphLayers: still the shared layering logic v_graph_console
// leans on for FR-2/3, unchanged by this SDD but worth pinning down since
// nothing else in this repo exercises it automatically ----
{
const nodes = [
{ node_id: "a", depends_on: [] },
{ node_id: "b", depends_on: ["a"] },
{ node_id: "c", depends_on: ["a"] },
{ node_id: "d", depends_on: ["b", "c"] },
];
// cross-realm arrays (sandbox's Array != this file's Array) don't
// deepStrictEqual cleanly -- compare plain joined strings instead.
const layers = sandbox.computeGraphLayers(nodes);
assert.strictEqual(layers.length, 3, "expected 3 layers");
assert.strictEqual(Array.from(layers[0]).map((n) => n.node_id).join(","), "a");
assert.strictEqual(Array.from(layers[1]).map((n) => n.node_id).sort().join(","), "b,c");
assert.strictEqual(Array.from(layers[2]).map((n) => n.node_id).join(","), "d");
}
// ---- renderOrgGraphTopology (FR-1) ----
{
const html = sandbox.renderOrgGraphTopology("coder", { planner: "done", coder: "running" }, null);
assert.ok(html.includes("graph-topo-node status-done"), "planner should render its run status (done)");
assert.ok(html.includes("graph-topo-node status-running active"), "coder is both active and running");
assert.ok(html.includes("graph-topo-node status-not_started"), "validator has no run yet -> not_started");
assert.ok(!html.includes("retry-loop"), "no retry edge drawn when task.route is null");
const fastLane = sandbox.renderOrgGraphTopology("coder", {}, "fast_lane");
assert.ok(fastLane.includes("validator → coder(fast_lane)"), "fast_lane draws validator->coder");
const fullReplan = sandbox.renderOrgGraphTopology("planner", {}, "full_replan");
assert.ok(fullReplan.includes("validator → planner(full_replan)"), "full_replan draws validator->planner");
}
// ---- renderGraphVisualizerPanel (FR-4): options is new+optional ----
{
const run = {
org_node: "coder", run_id: "run-1",
graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] },
nodes: [{ node_id: "n1", node_exec_id: "exec-1", subagent_type: "coder", status: "running" }],
};
// v_task_detail's real, unchanged call site: task.work_graph_runs.map(renderGraphVisualizerPanel)
const legacyHtml = [run].map(sandbox.renderGraphVisualizerPanel)[0];
assert.ok(!legacyHtml.includes("clickable"), "legacy .map() call must not add the clickable class");
assert.ok(!legacyHtml.includes("data-clickable-node-exec-id"), "legacy .map() call must not add the new data attribute");
assert.ok(!legacyHtml.includes("log-viewer-slot"), "legacy .map() call must not add a log-viewer-slot");
// a hypothetical second run at .map index=1 must not have its index
// misread as a truthy options object with enableNodeClick set
const secondIndexHtml = sandbox.renderGraphVisualizerPanel(run, 1);
assert.ok(!secondIndexHtml.includes("clickable"), "Array.map's index=1 second arg must not enable click-to-expand");
const graphConsoleHtml = sandbox.renderGraphVisualizerPanel(run, { enableNodeClick: true });
assert.ok(graphConsoleHtml.includes("work-node status-running clickable"), "opted-in call adds the clickable class");
assert.ok(graphConsoleHtml.includes('data-clickable-node-exec-id="exec-1"'), "opted-in call adds the click-target id");
assert.ok(graphConsoleHtml.includes('
'), "opted-in call reserves a log-viewer-slot");
assert.ok(graphConsoleHtml.includes('data-node-id="n1"'), "every node carries data-node-id for edge-drawing lookup");
}
// ---- renderGraphVisualizerPanel: Work Graph self-planning + loop-back
// (2026-08-08) -- retry_self badge and on_failure edge data. Real line
// positions require getBoundingClientRect (mountGraphVisualizerEdges),
// which this bare vm sandbox can't provide -- that part is manual-browser-
// verified only, same convention this file's own header note already
// documents for DOM-rendering functions in general. What CAN be
// mechanically checked here is the HTML-string generation itself: the
// badge text/count, and the data-on-failure-edges payload the real mount
// function would read.
{
const runWithSelfLoop = {
org_node: "planner", run_id: "run-2",
graph_manifest: { nodes: [{ node_id: "benchmark", depends_on: [] }] },
nodes: [{ node_id: "benchmark", node_exec_id: "exec-2", subagent_type: "planner", status: "reflecting" }],
loop_back_events: [
{ source_node_id: "benchmark", target_node_id: "benchmark", attempt_number: 1, feedback: ["missing scenario"] },
{ source_node_id: "benchmark", target_node_id: "benchmark", attempt_number: 2, feedback: ["still missing"] },
],
};
const html = sandbox.renderGraphVisualizerPanel(runWithSelfLoop);
assert.ok(html.includes('class="loop-badge"'), "a node with self-loop events gets a loop-badge");
assert.ok(html.includes(">↺ 2<"), "the badge shows the real count of self-loop attempts, not just a presence flag");
// the attribute is always present (simpler than conditionally omitting
// it), just empty when there's no on_failure edge to draw.
assert.ok(html.includes('data-on-failure-edges="[]"'), "retry_self alone has no on_failure edges -- payload is an empty array");
const runWithoutLoop = {
org_node: "planner", run_id: "run-3",
graph_manifest: { nodes: [{ node_id: "brd", depends_on: [] }] },
nodes: [{ node_id: "brd", node_exec_id: "exec-3", subagent_type: "planner", status: "done" }],
};
const plainHtml = sandbox.renderGraphVisualizerPanel(runWithoutLoop);
assert.ok(!plainHtml.includes("loop-badge"), "a run with no loop_back_events at all (older tasks) renders no badge");
const runWithLoopBackEdge = {
org_node: "planner", run_id: "run-4",
graph_manifest: {
nodes: [{ node_id: "sdd", depends_on: [] }, { node_id: "benchmark", depends_on: ["sdd"] }],
edges: [{ from: "benchmark", to: "sdd", edge_type: "on_failure" }],
},
nodes: [
{ node_id: "sdd", node_exec_id: "exec-sdd", subagent_type: "planner", status: "done" },
{ node_id: "benchmark", node_exec_id: "exec-bm", subagent_type: "planner", status: "running" },
],
loop_back_events: [
{ source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 1, feedback: ["x"] },
],
};
const loopBackHtml = sandbox.renderGraphVisualizerPanel(runWithLoopBackEdge);
assert.ok(loopBackHtml.includes("data-on-failure-edges"), "a real on_failure edge emits the edges payload for mountGraphVisualizerEdges to read");
assert.ok(loopBackHtml.includes(""from":"benchmark""), "the payload's escaped JSON names the real source node");
assert.ok(loopBackHtml.includes(""triggerCount":1"), "the payload counts real triggers from loop_back_events, not just declares the edge");
assert.ok(loopBackHtml.includes("loop_back(失败回路)"), "the legend explains the on_failure line when one is present");
const noEdgeLegendHtml = sandbox.renderGraphVisualizerPanel(runWithSelfLoop);
assert.ok(!noEdgeLegendHtml.includes("loop_back(失败回路)"), "no on_failure edge -- legend stays unchanged, no dangling explanation");
}
// ---- countSelfLoopAttempts ----
{
const run = {
loop_back_events: [
{ source_node_id: "a", target_node_id: "a", attempt_number: 1 },
{ source_node_id: "a", target_node_id: "b", attempt_number: 1 }, // loop_back, not a self-loop -- must not count
{ source_node_id: "a", target_node_id: "a", attempt_number: 2 },
],
};
assert.strictEqual(sandbox.countSelfLoopAttempts(run, "a"), 2, "counts only true self-loops (source===target===nodeId)");
assert.strictEqual(sandbox.countSelfLoopAttempts(run, "b"), 0, "a node that was only ever a loop_back TARGET (not source) has zero self-loops");
assert.strictEqual(sandbox.countSelfLoopAttempts({}, "a"), 0, "missing loop_back_events (older task) is treated as zero, not a crash");
}
// ---- renderArtifactSummaryList (FR-5) ----
{
const artifacts = [
{ artifact_id: "art-1", artifact_type: "brd", file_path: "docs/brd.md", validation_status: "passed" },
{ artifact_id: "art-2", artifact_type: "code_diff", file_path: "src/x.py", validation_status: "pending" },
{ artifact_id: "art-3", artifact_type: "sdd", file_path: "docs/sdd.md", validation_status: "passed" },
];
const docsHtml = sandbox.renderArtifactSummaryList(artifacts, ["brd", "prd", "sdd", "benchmark"], "空");
assert.ok(docsHtml.includes("brd") && docsHtml.includes("sdd"), "docs list groups brd+sdd together");
assert.ok(!docsHtml.includes("code_diff"), "docs list excludes code-type artifacts");
const emptyHtml = sandbox.renderArtifactSummaryList([], ["brd"], "暂无文档类 Artifact");
assert.ok(emptyHtml.includes("暂无文档类 Artifact"), "empty group shows the given empty-state text");
// 2026-08-04 fix: each item must carry a real artifact_id so
// mountArtifactsOverviewPanel can wire a click -> #/artifacts/:id
// handler (see render()'s ARTIFACT_DETAIL_RE dispatch) instead of the
// old bare, unclickable
.
assert.ok(docsHtml.includes('data-artifact-id="art-1"'), "brd item carries its real artifact_id");
assert.ok(docsHtml.includes('data-artifact-id="art-3"'), "sdd item carries its real artifact_id");
}
// ---- ARTIFACT_DETAIL_RE (2026-08-04 click-through fix) ----
{
const match = "#/artifacts/abc-123".match(sandbox.ARTIFACT_DETAIL_RE);
assert.ok(match, "#/artifacts/:id must match ARTIFACT_DETAIL_RE");
assert.strictEqual(match[1], "abc-123", "captured group must be the artifact_id");
assert.ok(!"#/artifacts".match(sandbox.ARTIFACT_DETAIL_RE), "bare #/artifacts (unfiltered browser) must not match -- stays a static route");
assert.ok(!"#/artifacts/abc/extra".match(sandbox.ARTIFACT_DETAIL_RE), "extra path segments must not match");
}
console.log("frontend/test_graph_console_smoke.js: all assertions passed");