test_run_detail_smoke.js 5.34 KB
// 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, "&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");
// 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";
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");
}

console.log("frontend/test_run_detail_smoke.js: all " + assertionCount + " assertions passed");