Commit be5c39902ae31e59fac48f5749c728f9a9cf4571
1 parent
fb1ff76d
graphlemon: code_diff artifact for task 837fc183-f87f-41d9-8bc9-6262d7172834
Showing
1 changed file
with
458 additions
and
0 deletions
.graphlemon/runs/837fc183-f87f-41d9-8bc9-6262d7172834/code_diff.patch
0 → 100644
| 1 | +diff --git a/frontend/app.js b/frontend/app.js | |
| 2 | +index da24c1e..2f17236 100644 | |
| 3 | +--- a/frontend/app.js | |
| 4 | ++++ b/frontend/app.js | |
| 5 | +@@ -97,6 +97,13 @@ const routes = { | |
| 6 | + const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; | |
| 7 | + const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/; | |
| 8 | + const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/; | |
| 9 | ++// Org Graph 三节点条点击可达详情 (2026-08-06 SDD): both v_dashboard's and | |
| 10 | ++// v_task_detail's org-strip nodes route here. `:org_node` is restricted to | |
| 11 | ++// the three real values on purpose -- anything else (typo, stray path | |
| 12 | ++// segment) falls through to the "Not found" fallback in render() instead of | |
| 13 | ++// reaching renderTaskDetail/mountTaskDetail with a bogus org_node that would | |
| 14 | ++// otherwise need its own defensive checks further down. | |
| 15 | ++const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|coder|validator)$/; | |
| 16 | + | |
| 17 | + let currentRouteCleanup = null; | |
| 18 | + function registerRouteCleanup(fn) { | |
| 19 | +@@ -114,9 +121,15 @@ function render() { | |
| 20 | + let routeArgs = []; | |
| 21 | + if (!route) { | |
| 22 | + const taskMatch = hash.match(TASK_DETAIL_RE); | |
| 23 | ++ const orgNodeMatch = hash.match(ORG_NODE_DETAIL_RE); | |
| 24 | + const skillMatch = hash.match(SKILL_AUDIT_RE); | |
| 25 | + const artifactMatch = hash.match(ARTIFACT_DETAIL_RE); | |
| 26 | +- if (taskMatch) { | |
| 27 | ++ if (orgNodeMatch) { | |
| 28 | ++ // Reuses v_task_detail itself (same render/mount) with the org_node | |
| 29 | ++ // carried through as a second arg -- no separate view/route handler. | |
| 30 | ++ route = { render: renderTaskDetail, mount: mountTaskDetail }; | |
| 31 | ++ routeArgs = [orgNodeMatch[1], orgNodeMatch[2]]; | |
| 32 | ++ } else if (taskMatch) { | |
| 33 | + route = { render: renderTaskDetail, mount: mountTaskDetail }; | |
| 34 | + routeArgs = [taskMatch[1]]; | |
| 35 | + } else if (skillMatch) { | |
| 36 | +@@ -537,6 +550,120 @@ function mountBugFeedback() { | |
| 37 | + | |
| 38 | + const ORG_NODE_ORDER = ["planner", "coder", "validator"]; | |
| 39 | + | |
| 40 | ++// ---- Org Graph 三节点条点击可达详情 (2026-08-06 SDD): shared by | |
| 41 | ++// v_dashboard's `#org_strip` and v_task_detail's own inline org-strip below | |
| 42 | ++// -- both mark each `.org-node` with `data-org-node`, both route through | |
| 43 | ++// `#/tasks/:task_id/org/:org_node`, both pick "最新一次 work_graph_run" for | |
| 44 | ++// that org_node the same way (C6: plain array filtering, no new backend | |
| 45 | ++// query params). DAG/log/artifact display itself stays entirely inside | |
| 46 | ++// renderGraphVisualizerPanel/renderTodoTree/mountLogViewer/v_artifact_browser | |
| 47 | ++// (C3-C5) -- this block only does the "接线": which run is "latest", and | |
| 48 | ++// where on the already-rendered page to scroll/highlight. | |
| 49 | ++ | |
| 50 | ++function getLatestRunForOrgNode(task, orgNode) { | |
| 51 | ++ const runs = task.work_graph_runs.filter((r) => r.org_node === orgNode); | |
| 52 | ++ return runs.length ? runs[runs.length - 1] : null; | |
| 53 | ++} | |
| 54 | ++ | |
| 55 | ++// v_dashboard's org-strip: role + hint text, only ever links out to | |
| 56 | ++// v_task_detail (a genuine page change, so plain hash assignment is fine). | |
| 57 | ++function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) { | |
| 58 | ++ return ORG_NODE_ORDER.map((node, i) => { | |
| 59 | ++ const isActive = activeOrgNode === node; | |
| 60 | ++ const runStatus = runStatusByOrgNode[node]; | |
| 61 | ++ const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始"; | |
| 62 | ++ const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : ""; | |
| 63 | ++ return '<div class="org-node' + (isActive ? " active" : "") + '" data-org-node="' + node + '"><div class="role">' | |
| 64 | ++ + node + '</div><div class="hint">' + escapeHtml(hint) + "</div></div>" + arrow; | |
| 65 | ++ }).join(""); | |
| 66 | ++} | |
| 67 | ++ | |
| 68 | ++// v_task_detail's inline "Org Graph" panel: plainer markup (no role/hint | |
| 69 | ++// split), `activeSet` already carries different semantics than the | |
| 70 | ++// dashboard's single activeOrgNode -- kept as its own function rather than | |
| 71 | ++// forcing the two call sites to share one shape. | |
| 72 | ++function renderTaskDetailOrgStripHtml(activeSet) { | |
| 73 | ++ return ORG_NODE_ORDER.map((node, i) => { | |
| 74 | ++ const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : ""; | |
| 75 | ++ return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '" data-org-node="' + node + '">' + node + "</div>" + arrow; | |
| 76 | ++ }).join(""); | |
| 77 | ++} | |
| 78 | ++ | |
| 79 | ++function mountOrgStripClicks(container, taskId) { | |
| 80 | ++ if (!taskId) return; | |
| 81 | ++ for (const el of container.querySelectorAll(".org-node[data-org-node]")) { | |
| 82 | ++ el.addEventListener("click", () => { | |
| 83 | ++ window.location.hash = "#/tasks/" + taskId + "/org/" + el.dataset.orgNode; | |
| 84 | ++ }); | |
| 85 | ++ } | |
| 86 | ++} | |
| 87 | ++ | |
| 88 | ++// "组合视图" content for v_task_detail's "Org 节点详情" panel: reuses the | |
| 89 | ++// artifact list's own markup pattern (li[data-artifact-id], same as | |
| 90 | ++// renderArtifactSummaryList) filtered down to this run's own artifacts -- | |
| 91 | ++// linking to #/artifacts/:id (v_artifact_browser), not re-rendering artifact | |
| 92 | ++// content itself (C5). The DAG/to-do-tree/log panels are NOT duplicated here | |
| 93 | ++// -- they're already rendered once, further down this same page, by the | |
| 94 | ++// unmodified renderGraphVisualizerPanel/renderTodoTree calls; this function | |
| 95 | ++// only describes which run that is and lists its artifacts. | |
| 96 | ++function renderOrgComboPanelContent(task, orgNode) { | |
| 97 | ++ const run = getLatestRunForOrgNode(task, orgNode); | |
| 98 | ++ if (!run) { | |
| 99 | ++ return '<div class="hint">' + escapeHtml(orgNode) + " 尚未开始(暂无 Work Graph run)</div>"; | |
| 100 | ++ } | |
| 101 | ++ const hasDag = !!run.graph_manifest && run.graph_manifest.nodes.length > 0; | |
| 102 | ++ const artifacts = task.artifacts.filter((a) => a.produced_by_run_id === run.run_id); | |
| 103 | ++ const artifactsHtml = artifacts.length ? ( | |
| 104 | ++ '<ul class="simple-list">' + artifacts.map((a) => | |
| 105 | ++ '<li data-artifact-id="' + a.artifact_id + '" style="cursor:pointer;"><span>' + escapeHtml(a.file_path) + "</span><span>" | |
| 106 | ++ + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.validation_status) + "</span></li>" | |
| 107 | ++ ).join("") + "</ul>" | |
| 108 | ++ ) : '<div class="hint">暂无 Artifact</div>'; | |
| 109 | ++ return ( | |
| 110 | ++ '<div class="hint">最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")" | |
| 111 | ++ + (hasDag ? " · 已定位到下方 Work Graph / to-do 树,点击节点可展开日志" : " · 该 run 尚未生成 Work Graph") + "</div>" | |
| 112 | ++ + '<h3 style="margin:14px 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.03em;color:#6b7280;">该 run 产出的 Artifact</h3>' | |
| 113 | ++ + artifactsHtml | |
| 114 | ++ ); | |
| 115 | ++} | |
| 116 | ++ | |
| 117 | ++function wireOrgComboArtifactClicks(scopeEl) { | |
| 118 | ++ const combo = scopeEl.querySelector("#org_combo_content"); | |
| 119 | ++ if (!combo) return; | |
| 120 | ++ for (const li of combo.querySelectorAll("li[data-artifact-id]")) { | |
| 121 | ++ li.addEventListener("click", () => { | |
| 122 | ++ window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId); | |
| 123 | ++ }); | |
| 124 | ++ } | |
| 125 | ++} | |
| 126 | ++ | |
| 127 | ++// Called both on initial mount (deep link / dashboard click landed here with | |
| 128 | ++// an org_node already known) and from the inline org-strip's own click | |
| 129 | ++// handler (already on this page -- 02-V happy path 2.2 explicitly wants an | |
| 130 | ++// in-page scroll, not another full page render). Scrolls to the DAG panel | |
| 131 | ++// first (renders whenever graph_manifest exists), falls back to the to-do | |
| 132 | ++// tree panel, then to the combo panel itself for the "尚无 run"/"尚未生成 | |
| 133 | ++// Work Graph" empty states -- never throws if a target is missing. | |
| 134 | ++function focusOrgNodePanel(content, task, orgNode) { | |
| 135 | ++ for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) { | |
| 136 | ++ el.classList.toggle("org-node-focused", el.dataset.orgNode === orgNode); | |
| 137 | ++ } | |
| 138 | ++ const comboContent = content.querySelector("#org_combo_content"); | |
| 139 | ++ if (comboContent) { | |
| 140 | ++ comboContent.innerHTML = renderOrgComboPanelContent(task, orgNode); | |
| 141 | ++ wireOrgComboArtifactClicks(content); | |
| 142 | ++ } | |
| 143 | ++ const run = getLatestRunForOrgNode(task, orgNode); | |
| 144 | ++ let target = run ? document.getElementById("graph-panel-" + run.run_id) : null; | |
| 145 | ++ if (!target && run) target = document.getElementById("todo-tree-" + run.run_id); | |
| 146 | ++ if (!target) target = content.querySelector("#org_combo_panel"); | |
| 147 | ++ if (target && target.scrollIntoView) { | |
| 148 | ++ target.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| 149 | ++ target.classList.add("org-highlight-flash"); | |
| 150 | ++ setTimeout(() => target.classList.remove("org-highlight-flash"), 1600); | |
| 151 | ++ } | |
| 152 | ++} | |
| 153 | ++ | |
| 154 | + function renderDashboard() { | |
| 155 | + if (!getCurrentProjectId()) return renderNoProjectSelected(); | |
| 156 | + return ` | |
| 157 | +@@ -585,16 +712,13 @@ function mountDashboard() { | |
| 158 | + const quotaBanner = document.getElementById("quota_info_banner"); | |
| 159 | + const wsBanner = document.getElementById("ws_disconnected_banner"); | |
| 160 | + const recentEvents = []; | |
| 161 | ++ // Set right before every renderOrgStrip() call below -- org-strip clicks | |
| 162 | ++ // need to know which task_id to route to (#/tasks/:task_id/org/:org_node). | |
| 163 | ++ let orgStripTaskId = null; | |
| 164 | + | |
| 165 | + function renderOrgStrip(activeOrgNode, runStatusByOrgNode) { | |
| 166 | +- orgStrip.innerHTML = ORG_NODE_ORDER.map((node, i) => { | |
| 167 | +- const isActive = activeOrgNode === node; | |
| 168 | +- const runStatus = runStatusByOrgNode[node]; | |
| 169 | +- const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始"; | |
| 170 | +- const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : ""; | |
| 171 | +- return '<div class="org-node' + (isActive ? " active" : "") + '"><div class="role">' | |
| 172 | +- + node + '</div><div class="hint">' + escapeHtml(hint) + "</div></div>" + arrow; | |
| 173 | +- }).join(""); | |
| 174 | ++ orgStrip.innerHTML = renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode); | |
| 175 | ++ mountOrgStripClicks(orgStrip, orgStripTaskId); | |
| 176 | + } | |
| 177 | + | |
| 178 | + async function refreshTasksAndOrgGraph() { | |
| 179 | +@@ -602,11 +726,13 @@ function mountDashboard() { | |
| 180 | + const tasks = await resp.json(); | |
| 181 | + const active = tasks.find((t) => t.status !== "done") || tasks[0]; | |
| 182 | + if (!active) { | |
| 183 | ++ orgStripTaskId = null; | |
| 184 | + orgStrip.innerHTML = '<div class="hint">暂无运行中任务</div>'; | |
| 185 | + taskSummary.innerHTML = '<div class="hint">暂无运行中任务 · <a href="#/goals/new">提出第一个目标 →</a></div>'; | |
| 186 | + quotaBanner.style.display = "none"; | |
| 187 | + return; | |
| 188 | + } | |
| 189 | ++ orgStripTaskId = active.task_id; | |
| 190 | + // v_dashboard Start affordance (2026-08-03): the full Pause/Stop set | |
| 191 | + // only makes sense on v_task_detail (they need the task's own status | |
| 192 | + // context to pick which one to show) -- the dashboard only ever needs | |
| 193 | +@@ -888,7 +1014,10 @@ function renderGraphVisualizerPanel(run, options) { | |
| 194 | + }).join(""); | |
| 195 | + | |
| 196 | + return ( | |
| 197 | +- '<div class="panel"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>" | |
| 198 | ++ // id="graph-panel-<run_id>": Org Graph 点击可达详情's scroll/highlight | |
| 199 | ++ // target (focusOrgNodePanel above) -- additive, doesn't change any | |
| 200 | ++ // existing class/data-* this function already emitted. | |
| 201 | ++ '<div class="panel" id="graph-panel-' + run.run_id + '"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>" | |
| 202 | + + '<div class="work-graph">' + rowsHtml + "</div>" | |
| 203 | + + '<div class="legend" style="margin-top:14px;">' + GRAPH_LEGEND_HTML + "</div>" | |
| 204 | + + "</div>" | |
| 205 | +@@ -1031,11 +1160,19 @@ function renderTaskDetail(taskId) { | |
| 206 | + `; | |
| 207 | + } | |
| 208 | + | |
| 209 | +-function mountTaskDetail(taskId) { | |
| 210 | ++function mountTaskDetail(taskId, initialOrgNode) { | |
| 211 | + const projectId = getCurrentProjectId(); | |
| 212 | + if (!projectId) return; | |
| 213 | + const content = document.getElementById("td_content"); | |
| 214 | + | |
| 215 | ++ // Org Graph 三节点条点击可达详情 (2026-08-06 SDD): which org_node's | |
| 216 | ++ // "组合视图" (DAG/日志/Artifact) is currently focused -- seeded from the | |
| 217 | ++ // #/tasks/:task_id/org/:org_node route (deep link / dashboard click) and | |
| 218 | ++ // updated in place when the inline org-strip below is clicked while | |
| 219 | ++ // already on this page, so a WS-triggered refresh() re-render keeps | |
| 220 | ++ // pointing at the same node instead of losing focus. | |
| 221 | ++ let focusedOrgNode = initialOrgNode || null; | |
| 222 | ++ | |
| 223 | + // v_log_viewer instances (02-V §2.7.1) mounted lazily on first to-do | |
| 224 | + // expand -- tracked here so a re-render (WS-triggered refresh) or leaving | |
| 225 | + // the page closes any live c_ws_log_tail connections instead of leaking them. | |
| 226 | +@@ -1069,7 +1206,9 @@ function mountTaskDetail(taskId) { | |
| 227 | + ); | |
| 228 | + }).join(""); | |
| 229 | + return ( | |
| 230 | +- '<div class="panel"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>" | |
| 231 | ++ // id="todo-tree-<run_id>": secondary scroll/highlight target when the | |
| 232 | ++ // DAG panel above didn't render (graph_manifest missing). | |
| 233 | ++ '<div class="panel" id="todo-tree-' + run.run_id + '"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>" | |
| 234 | + + '<ul class="todo-tree">' + items + "</ul>" | |
| 235 | + + '<div class="see-more">DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer</div>' | |
| 236 | + + "</div>" | |
| 237 | +@@ -1082,10 +1221,7 @@ function mountTaskDetail(taskId) { | |
| 238 | + for (const run of task.work_graph_runs) { | |
| 239 | + if (run.status !== "done" && run.status !== "failed") activeSet.add(run.org_node); | |
| 240 | + } | |
| 241 | +- const orgStripHtml = ORG_NODE_ORDER.map((node, i) => { | |
| 242 | +- const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : ""; | |
| 243 | +- return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '">' + node + "</div>" + arrow; | |
| 244 | +- }).join(""); | |
| 245 | ++ const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet); | |
| 246 | + | |
| 247 | + // v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one | |
| 248 | + // real thing "running" against it at a time -- exactly one of these | |
| 249 | +@@ -1168,6 +1304,13 @@ function mountTaskDetail(taskId) { | |
| 250 | + + "<div><b>CREATED_VIA</b>" + escapeHtml(task.created_via) + "</div>" | |
| 251 | + + "</div>" | |
| 252 | + + '<div class="panel"><h2>Org Graph</h2><div class="org-strip">' + orgStripHtml + "</div></div>" | |
| 253 | ++ // "Org 节点详情" (2026-08-06 SDD): click-through target -- filled in by | |
| 254 | ++ // focusOrgNodePanel() below, either right after this render (deep | |
| 255 | ++ // link/dashboard click already carries an org_node) or on a later | |
| 256 | ++ // inline org-strip click. Left as the default hint until then. | |
| 257 | ++ + '<div class="panel" id="org_combo_panel"><h2>Org 节点详情</h2><div id="org_combo_content">' | |
| 258 | ++ + '<div class="hint">点击上方 Org Graph 节点,查看该节点最新一次 Work Graph / 日志 / Artifact</div>' | |
| 259 | ++ + "</div></div>" | |
| 260 | + + todoTreesHtml | |
| 261 | + + graphVisualizerHtml | |
| 262 | + + '<div class="tooltip" id="graph-tooltip"></div>' | |
| 263 | +@@ -1191,6 +1334,22 @@ function mountTaskDetail(taskId) { | |
| 264 | + } | |
| 265 | + mountGraphVisualizerTooltips(content); | |
| 266 | + | |
| 267 | ++ // Org Graph 三节点条点击可达详情: clicking a node here (already on | |
| 268 | ++ // v_task_detail) stays on this page -- update focusedOrgNode + the URL | |
| 269 | ++ // (history.replaceState, no hashchange -> no full route re-render) and | |
| 270 | ++ // scroll/highlight in place, per 02-V happy path 2.2. | |
| 271 | ++ for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) { | |
| 272 | ++ el.addEventListener("click", () => { | |
| 273 | ++ focusedOrgNode = el.dataset.orgNode; | |
| 274 | ++ const newHash = "#/tasks/" + taskId + "/org/" + focusedOrgNode; | |
| 275 | ++ if (window.location.hash !== newHash) history.replaceState(null, "", newHash); | |
| 276 | ++ focusOrgNodePanel(content, task, focusedOrgNode); | |
| 277 | ++ }); | |
| 278 | ++ } | |
| 279 | ++ // Deep link / dashboard click landed here with an org_node already | |
| 280 | ++ // known -- populate + scroll to it once, right after this render. | |
| 281 | ++ if (focusedOrgNode) focusOrgNodePanel(content, task, focusedOrgNode); | |
| 282 | ++ | |
| 283 | + const resumeBtn = document.getElementById("btn_manual_resume"); | |
| 284 | + if (resumeBtn) { | |
| 285 | + resumeBtn.addEventListener("click", async () => { | |
| 286 | +diff --git a/frontend/style.css b/frontend/style.css | |
| 287 | +index 1ec077d..c3b4179 100644 | |
| 288 | +--- a/frontend/style.css | |
| 289 | ++++ b/frontend/style.css | |
| 290 | +@@ -45,6 +45,18 @@ body { font-family: system-ui, sans-serif; margin: 0; } | |
| 291 | + .org-node .role { font-weight: 600; font-size: 13px; } | |
| 292 | + .org-node .hint { margin-top: 2px; } | |
| 293 | + .org-arrow { flex: 0 0 30px; text-align: center; color: #6b7280; } | |
| 294 | ++ | |
| 295 | ++/* Org Graph 三节点条点击可达详情 (2026-08-06 SDD): org-strip nodes route to | |
| 296 | ++ `#/tasks/:task_id/org/:org_node` (v_dashboard) or scroll/highlight in | |
| 297 | ++ place (v_task_detail's own inline org-strip). */ | |
| 298 | ++.org-strip .org-node[data-org-node] { cursor: pointer; } | |
| 299 | ++.org-strip .org-node[data-org-node]:hover { box-shadow: 0 0 0 2px #4f46e5; } | |
| 300 | ++.org-strip .org-node.org-node-focused { box-shadow: 0 0 0 2px #4f46e5; } | |
| 301 | ++.org-highlight-flash { animation: org-highlight-flash-kf 1.6s ease-out; } | |
| 302 | ++@keyframes org-highlight-flash-kf { | |
| 303 | ++ 0% { box-shadow: 0 0 0 3px #f59e0b; } | |
| 304 | ++ 100% { box-shadow: none; } | |
| 305 | ++} | |
| 306 | + .task-summary .row { display: flex; justify-content: space-between; font-size: 13px; padding: 6px 0; border-bottom: 1px solid #e5e7eb; } | |
| 307 | + .task-summary .row:last-child { border-bottom: none; } | |
| 308 | + .progress { height: 8px; border-radius: 4px; background: #e5e7eb; overflow: hidden; margin-top: 6px; } | |
| 309 | +diff --git a/frontend/test_org_click_through_smoke.js b/frontend/test_org_click_through_smoke.js | |
| 310 | +new file mode 100644 | |
| 311 | +index 0000000..043319b | |
| 312 | +--- /dev/null | |
| 313 | ++++ b/frontend/test_org_click_through_smoke.js | |
| 314 | +@@ -0,0 +1,144 @@ | |
| 315 | ++// Org Graph 三节点条点击可达详情 (2026-08-06 SDD) smoke test. Same | |
| 316 | ++// no-framework/no-fixture convention as frontend/test_graph_console_smoke.js | |
| 317 | ++// -- loads app.js in a bare vm.Context and asserts on generated HTML | |
| 318 | ++// strings / regex matches / plain data for the new top-level functions: | |
| 319 | ++// - renderDashboardOrgStripHtml / renderTaskDetailOrgStripHtml: both | |
| 320 | ++// org-strip render locations now carry data-org-node on every node | |
| 321 | ++// (root cause from the BRD: renderOrgStrip() had zero data-* attrs). | |
| 322 | ++// - ORG_NODE_DETAIL_RE: the new #/tasks/:task_id/org/:org_node route -- | |
| 323 | ++// legal three-way org_node enum, illegal node name, missing segment. | |
| 324 | ++// - getLatestRunForOrgNode: "取最新 run" filter -- must return the | |
| 325 | ++// started_at-latest (array-last) entry for a given org_node, not the | |
| 326 | ++// first, and must not merge two runs' nodes together. | |
| 327 | ++// - renderOrgComboPanelContent: empty-state (no run yet) and populated | |
| 328 | ++// (with/without artifacts) cases, always via the existing simple-list | |
| 329 | ++// markup pattern -- no separate artifact-content renderer invented here. | |
| 330 | ++// | |
| 331 | ++// Run: node frontend/test_org_click_through_smoke.js | |
| 332 | ++ | |
| 333 | ++"use strict"; | |
| 334 | ++const fs = require("fs"); | |
| 335 | ++const path = require("path"); | |
| 336 | ++const vm = require("vm"); | |
| 337 | ++const assert = require("assert"); | |
| 338 | ++ | |
| 339 | ++function makeFakeDiv() { | |
| 340 | ++ let text = ""; | |
| 341 | ++ return { | |
| 342 | ++ set textContent(v) { text = v == null ? "" : String(v); }, | |
| 343 | ++ get textContent() { return text; }, | |
| 344 | ++ get innerHTML() { | |
| 345 | ++ return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| 346 | ++ }, | |
| 347 | ++ }; | |
| 348 | ++} | |
| 349 | ++ | |
| 350 | ++const sandbox = { | |
| 351 | ++ document: { | |
| 352 | ++ createElement: () => makeFakeDiv(), | |
| 353 | ++ getElementById: () => null, | |
| 354 | ++ querySelectorAll: () => [], | |
| 355 | ++ addEventListener: () => {}, | |
| 356 | ++ }, | |
| 357 | ++ window: { | |
| 358 | ++ location: { protocol: "http:", host: "localhost", hash: "" }, | |
| 359 | ++ addEventListener: () => {}, | |
| 360 | ++ }, | |
| 361 | ++ localStorage: { getItem: () => null, setItem: () => {} }, | |
| 362 | ++ console, | |
| 363 | ++}; | |
| 364 | ++vm.createContext(sandbox); | |
| 365 | ++ | |
| 366 | ++const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8"); | |
| 367 | ++// Top-level `const` regexes stay lexically scoped to the script and never | |
| 368 | ++// reach the sandbox object (same Annex B quirk test_graph_console_smoke.js | |
| 369 | ++// already documents for ARTIFACT_DETAIL_RE) -- re-export what this test needs. | |
| 370 | ++const augmentedSrc = src + "\nglobalThis.ORG_NODE_DETAIL_RE = ORG_NODE_DETAIL_RE;\n"; | |
| 371 | ++new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); | |
| 372 | ++ | |
| 373 | ++let assertionCount = 0; | |
| 374 | ++function check(condition, message) { | |
| 375 | ++ assertionCount++; | |
| 376 | ++ assert.ok(condition, message); | |
| 377 | ++} | |
| 378 | ++ | |
| 379 | ++// ---- 1. data-org-node on both org-strip render locations ---- | |
| 380 | ++{ | |
| 381 | ++ const dashboardHtml = sandbox.renderDashboardOrgStripHtml("coder", { planner: "done", coder: "running" }); | |
| 382 | ++ for (const node of ["planner", "coder", "validator"]) { | |
| 383 | ++ check(dashboardHtml.includes('data-org-node="' + node + '"'), "dashboard org-strip must carry data-org-node for " + node); | |
| 384 | ++ } | |
| 385 | ++ check(dashboardHtml.includes('class="org-node active" data-org-node="coder"'), "active node keeps its existing .active class alongside the new attribute"); | |
| 386 | ++ | |
| 387 | ++ const activeSet = new Set(["planner"]); | |
| 388 | ++ const taskDetailHtml = sandbox.renderTaskDetailOrgStripHtml(activeSet); | |
| 389 | ++ for (const node of ["planner", "coder", "validator"]) { | |
| 390 | ++ check(taskDetailHtml.includes('data-org-node="' + node + '"'), "v_task_detail inline org-strip must carry data-org-node for " + node); | |
| 391 | ++ } | |
| 392 | ++} | |
| 393 | ++ | |
| 394 | ++// ---- 2. ORG_NODE_DETAIL_RE: happy / boundary / failure ---- | |
| 395 | ++{ | |
| 396 | ++ const legal = "#/tasks/abc-123/org/coder".match(sandbox.ORG_NODE_DETAIL_RE); | |
| 397 | ++ check(legal, "legal task_id + coder must match"); | |
| 398 | ++ check(legal[1] === "abc-123" && legal[2] === "coder", "captured groups must be task_id and org_node"); | |
| 399 | ++ | |
| 400 | ++ for (const node of ["planner", "coder", "validator"]) { | |
| 401 | ++ check(("#/tasks/t1/org/" + node).match(sandbox.ORG_NODE_DETAIL_RE), "all three org_node enum values must match: " + node); | |
| 402 | ++ } | |
| 403 | ++ | |
| 404 | ++ check(!"#/tasks/t1/org/reviewer".match(sandbox.ORG_NODE_DETAIL_RE), "illegal org_node (not in the 3-way enum) must not match"); | |
| 405 | ++ check(!"#/tasks/t1/org/".match(sandbox.ORG_NODE_DETAIL_RE), "missing org_node segment must not match"); | |
| 406 | ++ check(!"#/tasks/t1".match(sandbox.ORG_NODE_DETAIL_RE), "plain #/tasks/:id (no /org/ suffix) must not match this regex"); | |
| 407 | ++} | |
| 408 | ++ | |
| 409 | ++// ---- 3. getLatestRunForOrgNode: latest-wins, no cross-run merging ---- | |
| 410 | ++{ | |
| 411 | ++ const task = { | |
| 412 | ++ work_graph_runs: [ | |
| 413 | ++ { run_id: "run-old", org_node: "planner", status: "done", started_at: "2026-08-01 00:00:00" }, | |
| 414 | ++ { run_id: "run-new", org_node: "planner", status: "done", started_at: "2026-08-02 00:00:00" }, | |
| 415 | ++ { run_id: "run-coder", org_node: "coder", status: "running", started_at: "2026-08-02 01:00:00" }, | |
| 416 | ++ ], | |
| 417 | ++ }; | |
| 418 | ++ const latestPlanner = sandbox.getLatestRunForOrgNode(task, "planner"); | |
| 419 | ++ check(latestPlanner.run_id === "run-new", "must return the array-last (time-latest) run for a repeated org_node, not the first"); | |
| 420 | ++ | |
| 421 | ++ const latestCoder = sandbox.getLatestRunForOrgNode(task, "coder"); | |
| 422 | ++ check(latestCoder.run_id === "run-coder", "single-run org_node resolves to that one run"); | |
| 423 | ++ | |
| 424 | ++ const noValidator = sandbox.getLatestRunForOrgNode(task, "validator"); | |
| 425 | ++ check(noValidator === null, "org_node with zero runs must resolve to null, not throw or return undefined-shaped data"); | |
| 426 | ++} | |
| 427 | ++ | |
| 428 | ++// ---- 4. renderOrgComboPanelContent: empty state + artifact filtering ---- | |
| 429 | ++{ | |
| 430 | ++ const emptyTask = { work_graph_runs: [], artifacts: [] }; | |
| 431 | ++ const emptyHtml = sandbox.renderOrgComboPanelContent(emptyTask, "validator"); | |
| 432 | ++ check(emptyHtml.includes("尚未开始"), "no-run org_node must render an explicit empty state, not blank/undefined"); | |
| 433 | ++ | |
| 434 | ++ const taskWithRun = { | |
| 435 | ++ work_graph_runs: [ | |
| 436 | ++ { run_id: "run-1", org_node: "coder", status: "done", graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] } }, | |
| 437 | ++ ], | |
| 438 | ++ artifacts: [ | |
| 439 | ++ { artifact_id: "art-1", produced_by_run_id: "run-1", file_path: "src/x.py", artifact_type: "code_diff", validation_status: "passed" }, | |
| 440 | ++ { artifact_id: "art-2", produced_by_run_id: "run-other", file_path: "src/y.py", artifact_type: "code_diff", validation_status: "passed" }, | |
| 441 | ++ ], | |
| 442 | ++ }; | |
| 443 | ++ const populatedHtml = sandbox.renderOrgComboPanelContent(taskWithRun, "coder"); | |
| 444 | ++ check(populatedHtml.includes("run-1"), "combo content must name the resolved run_id"); | |
| 445 | ++ check(populatedHtml.includes('data-artifact-id="art-1"'), "artifact produced by the resolved run must be listed"); | |
| 446 | ++ check(!populatedHtml.includes('data-artifact-id="art-2"'), "artifact from a different run_id must be filtered out"); | |
| 447 | ++ | |
| 448 | ++ const taskNoArtifacts = { | |
| 449 | ++ work_graph_runs: [{ run_id: "run-2", org_node: "coder", status: "failed", graph_manifest: null }], | |
| 450 | ++ artifacts: [], | |
| 451 | ++ }; | |
| 452 | ++ const noArtifactsHtml = sandbox.renderOrgComboPanelContent(taskNoArtifacts, "coder"); | |
| 453 | ++ check(noArtifactsHtml.includes("暂无 Artifact"), "run with zero artifacts must show the existing empty-state text, not blank/error"); | |
| 454 | ++} | |
| 455 | ++ | |
| 456 | ++console.log( | |
| 457 | ++ "frontend/test_org_click_through_smoke.js: all " + assertionCount + " assertions passed" | |
| 458 | ++); | ... | ... |