test_run_detail_smoke.js 7.12 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";
// (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");