test_org_meta_graph_smoke.js 9.72 KB
// Org Graph unification Phase 3 (FR-5a + FR-5b) smoke test. Same no-
// framework/no-fixture convention as the other frontend/test_*_smoke.js
// files -- loads app.js in a bare vm.Context and asserts on generated HTML
// strings for the new pieces:
//   - FR-5a: ORG_META_STAGE_ORDER / orgMetaStageStatus / renderOrgMetaGraph
//     (the four-stage top-level meta-graph, Planner -> 复核 -> Coder ->
//     终审, separate from the existing 3-node renderOrgGraphTopology) and
//     renderOrgMetaStageDetailHtml's planner_reviewer special-case (a
//     verdict list, not a faked Work Graph panel).
//   - FR-5b: renderLoopBackTimelineHtml (the attempt_number-ordered
//     loop_back replay list, reusing run.loop_back_events which
//     task_query.py already exposed but nothing rendered before this).
// Mount-time DOM wiring (mountOrgMetaGraph/mountLoopBackTimeline's click
// handlers) needs real querySelectorAll traversal this bare sandbox doesn't
// provide -- same "manual browser verification only" boundary the other
// smoke tests already draw around mount*/edge-drawing functions; this file
// only pins the pure render* HTML-string output.
//
// Run: node frontend/test_org_meta_graph_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` stays lexically scoped to the script and never reaches
// the sandbox object (same Annex B quirk the other smoke tests already
// document for e.g. ARTIFACT_DETAIL_RE) -- re-export what this test needs.
const augmentedSrc = src + "\nglobalThis.ORG_META_STAGE_ORDER = ORG_META_STAGE_ORDER;\n";
new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);

let assertionCount = 0;
function check(condition, message) {
  assertionCount++;
  assert.ok(condition, message);
}

// ---- FR-5a: ORG_META_STAGE_ORDER has all four stages, in order ----
{
  check(
    sandbox.ORG_META_STAGE_ORDER.join(",") === "planner,planner_reviewer,coder,validator",
    "the meta-graph must cover all four stages in Planner -> 复核 -> Coder -> 终审 order"
  );
}

// ---- FR-5a: orgMetaStageStatus ----
{
  const emptyTask = { work_graph_runs: [], planner_review_events: [] };
  check(sandbox.orgMetaStageStatus(emptyTask, "planner") === "not_started", "no run yet -> not_started");
  check(sandbox.orgMetaStageStatus(emptyTask, "planner_reviewer") === "not_started", "no review events yet -> not_started");

  const taskWithRuns = {
    work_graph_runs: [
      { run_id: "run-1", org_nodes_involved: ["coder"], status: "running", started_at: "2026-08-01 00:00:00" },
    ],
    planner_review_events: [],
  };
  check(sandbox.orgMetaStageStatus(taskWithRuns, "coder") === "running", "status mirrors the latest run's own status");

  const passedReview = { work_graph_runs: [], planner_review_events: [
    { attempt: 1, verdict: "REJECTED", created_at: "t1" },
    { attempt: 2, verdict: "PASS", created_at: "t2" },
  ] };
  check(sandbox.orgMetaStageStatus(passedReview, "planner_reviewer") === "done", "latest verdict PASS -> done");

  const rejectedReview = { work_graph_runs: [], planner_review_events: [
    { attempt: 1, verdict: "REJECTED", created_at: "t1" },
  ] };
  check(sandbox.orgMetaStageStatus(rejectedReview, "planner_reviewer") === "reflecting", "latest verdict not PASS -> reflecting, not done");
}

// ---- FR-5a: renderOrgMetaGraph -- four boxes, three arrows, real statuses ----
{
  const task = {
    work_graph_runs: [
      { run_id: "run-1", org_nodes_involved: ["planner"], status: "done", started_at: "t1" },
      { run_id: "run-2", org_nodes_involved: ["coder"], status: "running", started_at: "t2" },
    ],
    planner_review_events: [{ attempt: 1, verdict: "PASS", created_at: "t1.5" }],
  };
  const html = sandbox.renderOrgMetaGraph(task);
  for (const stage of ["planner", "planner_reviewer", "coder", "validator"]) {
    check(html.includes('data-meta-stage="' + stage + '"'), "every stage box must carry data-meta-stage=" + stage);
  }
  check(html.includes("graph-topo-node status-done"), "planner shows its real run status (done)");
  check(html.includes("graph-topo-node status-running"), "coder shows its real run status (running)");
  check(html.includes("graph-topo-node status-not_started"), "validator has no run yet -> not_started");
  check((html.match(/graph-edge/g) || []).length === 3, "four stages are joined by exactly three arrows");
  check(html.includes('id="org-meta-detail-planner_reviewer"'), "every stage gets its own (initially hidden) expand target");
  check(html.includes('style="display:none;"'), "expand targets start collapsed");
}

// ---- FR-5a: planner_reviewer special-case detail (verdict list, not a
// faked Work Graph panel) ----
{
  const emptyEventsHtml = sandbox.renderOrgMetaStageDetailHtml({ planner_review_events: [] }, "planner_reviewer");
  check(emptyEventsHtml.includes("尚无复核记录"), "zero review events -> explicit empty state");
  check(!emptyEventsHtml.includes("work-graph"), "planner_reviewer must never render a fake DAG panel");

  const task = {
    planner_review_events: [
      { attempt: 1, verdict: "REJECTED", used_existing_artifacts: false, created_at: "2026-08-12 10:00:00" },
      { attempt: 2, verdict: "PASS", used_existing_artifacts: true, created_at: "2026-08-12 10:05:00" },
    ],
  };
  const html = sandbox.renderOrgMetaStageDetailHtml(task, "planner_reviewer");
  check(html.includes("attempt 1"), "each attempt is listed");
  check(html.includes("REJECTED") && html.includes("PASS"), "each attempt's real verdict is shown");
  check(html.includes("2026-08-12 10:00:00") && html.includes("2026-08-12 10:05:00"), "each attempt's real timestamp is shown");
  check(html.includes("复用已有产出"), "used_existing_artifacts=true is surfaced, not silently dropped");
}

// ---- FR-5a: planner/coder/validator detail reuses renderGraphVisualizerPanel
// unmodified, with the existing empty-run / empty-DAG fallbacks ----
{
  const noRunTask = { work_graph_runs: [] };
  const noRunHtml = sandbox.renderOrgMetaStageDetailHtml(noRunTask, "coder");
  check(noRunHtml.includes("尚未开始"), "no run yet for this stage -> explicit empty state");

  const emptyDagTask = {
    work_graph_runs: [{ run_id: "run-1", org_nodes_involved: ["coder"], status: "failed", graph_manifest: null }],
  };
  const emptyDagHtml = sandbox.renderOrgMetaStageDetailHtml(emptyDagTask, "coder");
  check(emptyDagHtml.includes("run-1") && emptyDagHtml.includes("尚未生成 Work Graph"), "run exists but has no DAG yet -> explicit fallback naming the run");

  const realRunTask = {
    work_graph_runs: [{
      run_id: "run-2", org_nodes_involved: ["coder"], status: "running",
      graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] },
      nodes: [{ node_id: "n1", node_exec_id: "exec-1", subagent_type: "coder", status: "running" }],
    }],
  };
  const realHtml = sandbox.renderOrgMetaStageDetailHtml(realRunTask, "coder");
  check(realHtml.includes("run_id=run-2"), "a real run renders the actual renderGraphVisualizerPanel DAG output");
  check(!realHtml.includes("clickable"), "meta-graph expand opts out of click-to-expand-logs (enableNodeClick: false)");
}

// ---- FR-5b: renderLoopBackTimelineHtml ----
{
  const noEventsHtml = sandbox.renderLoopBackTimelineHtml({ loop_back_events: [] });
  check(noEventsHtml === "", "a run with no loop_back_events renders nothing, not an empty panel");
  check(sandbox.renderLoopBackTimelineHtml({}) === "", "missing loop_back_events entirely is treated the same as empty");

  const run = {
    run_id: "run-1",
    loop_back_events: [
      { source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 1, feedback: ["missing scenario"], created_at: "t1" },
      { source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 2, feedback: ["still missing", "and X"], created_at: "t2" },
    ],
  };
  const html = sandbox.renderLoopBackTimelineHtml(run);
  check(html.includes("attempt 1") && html.includes("attempt 2"), "every attempt gets its own timeline entry, in order");
  check(html.includes("benchmark") && html.includes("sdd"), "each entry names its real source/target node");
  check(html.includes('data-attempt-index="0"') && html.includes('data-attempt-index="1"'), "entries are addressable by index for the click-to-reveal wiring");
  check(html.includes("missing scenario"), "attempt 1's real feedback text is rendered");
  check(html.includes("still missing") && html.includes("and X"), "attempt 2's real (multi-item) feedback text is rendered");
  check(html.includes('style="display:none;"'), "feedback panels start collapsed -- a click reveals one at a time");

  const noFeedbackRun = {
    loop_back_events: [{ source_node_id: "a", target_node_id: "a", attempt_number: 1, feedback: [], created_at: "t1" }],
  };
  const noFeedbackHtml = sandbox.renderLoopBackTimelineHtml(noFeedbackRun);
  check(noFeedbackHtml.includes("未记录 feedback"), "an attempt with empty feedback[] still renders an explicit state, not blank");
}

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