test_graph_console_smoke.js 12.6 KB
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
    },
  };
}

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("&quot;from&quot;:&quot;benchmark&quot;"), "the payload's escaped JSON names the real source node");
  assert.ok(loopBackHtml.includes("&quot;triggerCount&quot;: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");