test_run_detail_smoke.js
7.12 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
// v_run_detail (`#/runs/:run_id`, U-046 2026-08-06 SDD) smoke test. Same
// no-framework/no-fixture convention as frontend/test_graph_console_smoke.js
// and frontend/test_org_click_through_smoke.js (see
// .claude/agent-memory/validator/feedback_no_js_test_framework.md) -- loads
// app.js in a bare vm.Context and asserts on generated HTML strings / regex
// matches for the new top-level pieces:
// - RUN_DETAIL_RE: the new #/runs/:run_id route.
// - renderRunNodeListHtml: the run-detail node list, including the
// todo-detail-<node_exec_id> DOM-id contract mountLogViewer relies on
// (`#todo-detail-<id> .log-autoscroll`) and the empty-run case.
// - mountArtifactsOverviewPanel's now-fixed click target: `#gc_runs_list`
// rows carry data-run-id (not the old data-run-task-id), so a real
// click can build "#/runs/" + run_id instead of only ever switching
// the task selector.
//
// Run: node frontend/test_run_detail_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");
// Top-level `const` regexes stay lexically scoped to the script and never
// reach the sandbox object (same Annex B quirk the other smoke tests
// already document) -- re-export what this test needs.
const augmentedSrc = src + "\nglobalThis.RUN_DETAIL_RE = RUN_DETAIL_RE;\n";
// (renderRunNodeListHtml/renderForkBadgeHtml/mountForkPanels are already
// top-level function declarations, so they land in the sandbox globalThis
// directly -- no extra re-export needed for those, unlike the const regex.)
new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
let assertionCount = 0;
function check(condition, message) {
assertionCount++;
assert.ok(condition, message);
}
// ---- 1. RUN_DETAIL_RE: happy / boundary / failure ----
{
const legal = "#/runs/run-abc-123".match(sandbox.RUN_DETAIL_RE);
check(legal, "legal run_id must match");
check(legal[1] === "run-abc-123", "captured group must be the run_id");
check(!"#/runs/".match(sandbox.RUN_DETAIL_RE), "missing run_id segment must not match");
check(!"#/runs/abc/extra".match(sandbox.RUN_DETAIL_RE), "extra path segments must not match");
check(!"#/tasks/abc".match(sandbox.RUN_DETAIL_RE), "an unrelated route must not match");
}
// ---- 2. renderRunNodeListHtml: node list + todo-detail-<id> DOM contract ----
{
const run = {
run_id: "run-1", org_node: "coder", status: "running",
nodes: [
{
node_exec_id: "exec-1", node_id: "n1", subagent_type: "coder", status: "running",
retry_count: 0, reflection_notes: null,
},
{
node_exec_id: "exec-2", node_id: "n2", subagent_type: "coder", status: "done",
retry_count: 1, reflection_notes: "retried once after a transient error",
},
],
};
const html = sandbox.renderRunNodeListHtml(run);
check(html.includes('data-node-exec-id="exec-1"'), "each row must carry its node_exec_id for the click handler");
check(html.includes('id="todo-detail-exec-1"'), "must reproduce the todo-detail-<id> id mountLogViewer's querySelector needs");
check(html.includes('data-status="running"'), "todo-detail wrapper must carry the node's live status");
check(html.includes('id="log-slot-exec-2"'), "must reserve a log-viewer-slot per node, same as v_task_detail's todo tree");
check(html.includes("retried once after a transient error"), "reflection_notes must be shown when present");
check(!html.includes("reflection_notes: <br>"), "no stray reflection_notes line when it's null (exec-1)");
const emptyHtml = sandbox.renderRunNodeListHtml({ nodes: [] });
check(emptyHtml.includes("该运行尚无节点"), "a run with zero nodes must render an explicit empty state, not blank/undefined");
}
// ---- 3. gc_runs_list rows: run-detail click target (2026-08-06 fix) ----
{
// mountArtifactsOverviewPanel itself does an async fetch and can't run in
// this sandbox (no `fetch`), but the row-id contract it depends on is a
// pure string-building concern worth pinning directly: replicate the
// exact <li> markup mountArtifactsOverviewPanel emits for one run and
// confirm it carries data-run-id (not the removed data-run-task-id).
const escapeHtml = sandbox.escapeHtml;
const r = { org_node: "coder", run_id: "run-1234567890", status: "done", node_count: 3 };
const li = '<li data-run-id="' + r.run_id + '" style="cursor:pointer;"><span>' + escapeHtml(r.org_node) + " · run "
+ r.run_id.slice(0, 8) + "</span><span>" + escapeHtml(r.status) + " · " + r.node_count + " 节点</span></li>";
check(li.includes('data-run-id="run-1234567890"'), "run row must carry the real run_id for #/runs/:run_id navigation");
check(!li.includes("data-run-task-id"), "the old task-switcher click target must be gone");
}
// ---- 4. D (2026-08-06, time-travel debugging): fork panel only on 'done' nodes ----
{
const run = {
run_id: "run-1", org_node: "planner", status: "running",
nodes: [
{ node_exec_id: "exec-running", node_id: "prd", subagent_type: "planner", status: "running", retry_count: 0 },
{ node_exec_id: "exec-done", node_id: "sdd", subagent_type: "planner", status: "done", retry_count: 0 },
],
};
const html = sandbox.renderRunNodeListHtml(run);
const doneDetail = html.slice(html.indexOf('id="todo-detail-exec-done"'));
const runningDetail = html.slice(
html.indexOf('id="todo-detail-exec-running"'), html.indexOf('id="todo-detail-exec-done"')
);
check(doneDetail.includes("btn-fork"), "a 'done' node must offer a fork trigger");
check(doneDetail.includes("fork-content"), "the fork form's textarea must be present for a 'done' node");
check(!runningDetail.includes("btn-fork"), "a non-'done' node must not offer a fork trigger (source must be 'done', FR-1)");
}
// ---- 5. D: renderForkBadgeHtml -- only shown for an actual fork ----
{
const original = { run_id: "run-1", parent_run_id: null, fork_of_node_exec_id: null };
check(sandbox.renderForkBadgeHtml(original) === "", "a normal (non-forked) run must render no badge");
const forked = { run_id: "run-2", parent_run_id: "run-1", fork_of_node_exec_id: "run-1-prd" };
const badge = sandbox.renderForkBadgeHtml(forked);
check(badge.includes("run-1-prd"), "badge must name which node was forked from");
check(badge.includes('href="#/runs/run-1"'), "badge must link back to the parent run");
}
console.log("frontend/test_run_detail_smoke.js: all " + assertionCount + " assertions passed");