test_graph_console_smoke.js
12.6 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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
// 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");
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 <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");