test_graph_console_smoke.js
8.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// 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, ">").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('<div class="log-viewer-slot">'), "opted-in call reserves a log-viewer-slot");
}
// ---- 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 <li>.
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");