test_nav_project_context_smoke.js 12.4 KB
// U-047/U-048 (2026-08-07 SDD) smoke test: nav-bar "current project" context.
// Same no-framework/no-fixture convention as the other frontend/test_*_smoke.js
// files (see .claude/agent-memory/validator/feedback_no_js_test_framework.md)
// -- loads real app.js in a bare vm.Context and asserts on generated HTML
// strings / regex matches / real async control flow, not mocks of the logic
// under test.
//
// Covers:
//   - U-047: parseHashQuery -- happy path, no-query hash, empty value,
//     multiple params; and the regression this was explicitly designed not
//     to break: TASK_DETAIL_RE must still match the *path* half of a hash
//     that now carries a query string.
//   - U-048 nav highlighting: a nav link whose href has no query string and
//     the current hash's query-stripped path must be recognized as the same
//     route (this is exactly what render()'s "#nav > a" active-toggling
//     loop relies on).
//   - U-048 bucket mapping: computeNavStatusBucketCounts groups all 10 raw
//     task.status values into the 4 nav buckets (pending/running/attention/
//     done), and navBucketHref only appends ?status=<value> for the two
//     single-status buckets (pending/done) -- multi-status buckets
//     (running/attention) link to the unfiltered #/tasks, since
//     v_task_board's status filter is single-select.
//   - U-048 DOM contract: renderNavProjectContextHtml emits #nav_project_name
//     (linking to #/projects) and one .nav-count-<bucket> per bucket, and
//     renders nothing at all when no project is given (unselected-project
//     state, requirement 1).
//   - U-048 refreshNavProjectContext (real async function, real Promise
//     control, no reimplementation): single-fetch-per-refresh (one call to
//     /api/projects/:id, one to /api/tasks?project_id=... -- not one fetch
//     per bucket), stale/out-of-order response protection when the current
//     project changes mid-flight, and the unselected-project case doing zero
//     fetches.
//   - mountTaskBoard: pre-selects tb_status from an initial ?status= query
//     param, ignoring an unrecognized value instead of setting it blindly.
//
// Run: node frontend/test_nav_project_context_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;");
    },
  };
}

// Unlike makeFakeDiv (models a text node via textContent -> escaped
// innerHTML), nav_project_context needs a plain settable/gettable string --
// app.js writes a real HTML string there, not text that gets escaped.
function makeRawEl() {
  let html = "";
  return {
    set innerHTML(v) { html = v; },
    get innerHTML() { return html; },
  };
}

let currentProjectId = null;
let fetchCalls = [];
let pendingFetches = [];
function fakeFetch(url) {
  fetchCalls.push(url);
  return new Promise((resolve) => { pendingFetches.push({ url, resolve }); });
}
function resolveFetch(entry, status, body) {
  entry.resolve({ status, json: async () => body });
}

const navContextEl = makeRawEl();
const elementsById = {
  nav_project_context: navContextEl,
};

const sandbox = {
  document: {
    createElement: () => makeFakeDiv(),
    getElementById: (id) => elementsById[id] || null,
    querySelectorAll: () => [],
    addEventListener: () => {},
  },
  window: {
    location: { protocol: "http:", host: "localhost", hash: "" },
    addEventListener: () => {},
  },
  localStorage: { getItem: () => currentProjectId, setItem: (k, v) => { currentProjectId = v; } },
  fetch: fakeFetch,
  console,
};
vm.createContext(sandbox);

const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8");
// Top-level `const` regexes/arrays 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.TASK_DETAIL_RE = TASK_DETAIL_RE;\nglobalThis.NAV_STATUS_BUCKETS = NAV_STATUS_BUCKETS;\n";
new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);

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

async function main() {
  // ---- 1. parseHashQuery: happy / edge cases ----
  {
    const noQuery = sandbox.parseHashQuery("#/tasks");
    check(noQuery.path === "#/tasks" && Object.keys(noQuery.params).length === 0, "no '?' -> whole string is the path, empty params");

    const single = sandbox.parseHashQuery("#/tasks?status=done");
    check(single.path === "#/tasks" && single.params.status === "done", "single key=value param parsed correctly");

    const multi = sandbox.parseHashQuery("#/tasks?status=done&org_node=coder");
    check(multi.path === "#/tasks" && multi.params.status === "done" && multi.params.org_node === "coder", "multiple params all parsed");

    const trailing = sandbox.parseHashQuery("#/tasks?");
    check(trailing.path === "#/tasks" && Object.keys(trailing.params).length === 0, "trailing '?' with nothing after it -> empty params, not a crash");

    const emptyValue = sandbox.parseHashQuery("#/tasks?status=");
    check(emptyValue.params.status === "", "key with empty value still produces a key, not dropped");

    const encoded = sandbox.parseHashQuery("#/tasks?keyword=a%20b");
    check(encoded.params.keyword === "a b", "percent-encoded values are decoded");
  }

  // ---- 2. Regression: TASK_DETAIL_RE must still match the query-stripped path ----
  {
    const withQuery = sandbox.parseHashQuery("#/tasks/abc-123?foo=bar");
    const match = withQuery.path.match(sandbox.TASK_DETAIL_RE);
    check(match && match[1] === "abc-123", "TASK_DETAIL_RE must match the path half of a hash carrying a query string");

    const withoutQuery = sandbox.parseHashQuery("#/tasks/abc-123");
    check(withoutQuery.path.match(sandbox.TASK_DETAIL_RE)[1] === "abc-123", "no-query case keeps working exactly as before (no regression)");
  }

  // ---- 3. Nav highlighting: query-stripped paths must compare equal ----
  {
    const currentHashPath = sandbox.parseHashQuery("#/tasks?status=queued").path;
    const plainNavLinkPath = sandbox.parseHashQuery("#/tasks").path;
    check(currentHashPath === plainNavLinkPath, "a plain '#/tasks' nav link must still be recognized as 'active' when the real hash carries a status query string");
  }

  // ---- 4. computeNavStatusBucketCounts: all 10 statuses land in exactly one of the 4 buckets ----
  {
    const allStatuses = [
      "queued", "planning_brd_prd_sdd", "planning_failed", "coding", "coding_failed",
      "validating", "routed_fast_lane", "routed_full_replan", "blocked_pending_human", "done",
    ];
    const tasks = allStatuses.map((status, i) => ({ task_id: "t" + i, status }));
    const counts = sandbox.computeNavStatusBucketCounts(tasks);
    const total = Object.values(counts).reduce((a, b) => a + b, 0);
    check(total === allStatuses.length, "every raw status must be counted into exactly one bucket, none dropped/double-counted");
    check(counts.pending === 1 && counts.done === 1, "pending/done are single-status buckets (queued/done respectively)");
    check(counts.running === 5, "running bucket: planning_brd_prd_sdd, coding, validating, routed_fast_lane, routed_full_replan");
    check(counts.attention === 3, "attention bucket: planning_failed, coding_failed, blocked_pending_human");

    const empty = sandbox.computeNavStatusBucketCounts([]);
    check(Object.values(empty).every((n) => n === 0), "zero tasks -> every bucket is 0, not undefined/NaN");
  }

  // ---- 5. navBucketHref: single-status buckets carry ?status=, multi-status ones don't ----
  {
    for (const bucket of sandbox.NAV_STATUS_BUCKETS) {
      const href = sandbox.navBucketHref(bucket);
      if (bucket.statuses.length === 1) {
        check(href === "#/tasks?status=" + bucket.statuses[0], bucket.key + " (single-status) must link with its exact status filter");
      } else {
        check(href === "#/tasks", bucket.key + " (multi-status) must link to the unfiltered board, v_task_board's filter is single-select");
      }
    }
  }

  // ---- 6. renderNavProjectContextHtml: DOM contract + unselected-project state ----
  {
    const html = sandbox.renderNavProjectContextHtml(
      { project_id: "p1", name: "My <Project>" },
      { pending: 3, running: 2, attention: 1, done: 12 },
    );
    check(html.includes('id="nav_project_name"'), "must carry #nav_project_name");
    check(html.includes('href="#/projects"'), "project name must link to #/projects (switch mechanism, no rebuild)");
    check(html.includes("My &lt;Project&gt;"), "project name must be HTML-escaped");
    check(html.includes('nav-count-pending') && html.includes("pending 3"), "pending bucket count rendered");
    check(html.includes('nav-count-running') && html.includes("running 2"), "running bucket count rendered");
    check(html.includes('nav-count-attention') && html.includes("attention 1"), "attention bucket count rendered");
    check(html.includes('nav-count-done') && html.includes("done 12"), "done bucket count rendered");

    check(sandbox.renderNavProjectContextHtml(null, {}) === "", "no project selected -> render nothing (requirement 1)");
  }

  // ---- 7. refreshNavProjectContext: unselected-project state does zero fetches ----
  {
    currentProjectId = null;
    fetchCalls = [];
    await sandbox.refreshNavProjectContext();
    check(fetchCalls.length === 0, "no current project -> must not fetch anything");
    check(navContextEl.innerHTML === "", "no current project -> nav context area must be empty");
  }

  // ---- 8. refreshNavProjectContext: single-fetch-per-refresh + stale/out-of-order protection ----
  {
    fetchCalls = [];
    pendingFetches = [];

    currentProjectId = "proj-A";
    const promiseA = sandbox.refreshNavProjectContext();
    currentProjectId = "proj-B";
    const promiseB = sandbox.refreshNavProjectContext();

    check(pendingFetches.length === 4, "two refreshes -> exactly 2 fetches each (project + tasks), never per-bucket (single-fetch-per-refresh)");
    check(fetchCalls[0].includes("/api/projects/proj-A") && fetchCalls[1].includes("project_id=proj-A"), "call A fetches proj-A's project + tasks");
    check(fetchCalls[2].includes("/api/projects/proj-B") && fetchCalls[3].includes("project_id=proj-B"), "call B fetches proj-B's project + tasks");

    // Resolve B (the newer, current request) first...
    resolveFetch(pendingFetches[2], 200, { project_id: "proj-B", name: "Project B" });
    resolveFetch(pendingFetches[3], 200, [{ task_id: "t1", status: "done" }]);
    await promiseB;
    check(navContextEl.innerHTML.includes("Project B"), "current (B) response must be rendered");

    // ...then resolve A's (older, now-superseded) response arriving late.
    resolveFetch(pendingFetches[0], 200, { project_id: "proj-A", name: "Project A" });
    resolveFetch(pendingFetches[1], 200, [{ task_id: "t2", status: "queued" }]);
    await promiseA;
    check(navContextEl.innerHTML.includes("Project B"), "a stale response for a since-superseded project must not overwrite the current (B) nav state");
    check(!navContextEl.innerHTML.includes("Project A"), "project A's late response must be discarded entirely");
  }

  // ---- 9. pickInitialTaskBoardStatus: mountTaskBoard's ?status= pre-select logic ----
  // (mountTaskBoard itself isn't called directly here -- it also wires up a
  // real WebSocket + several other DOM elements this bare sandbox doesn't
  // stub, same reasoning documented in feedback_no_js_test_framework.md for
  // why mount* functions aren't exercised end-to-end elsewhere in this repo's
  // frontend smoke tests either.)
  {
    check(sandbox.pickInitialTaskBoardStatus({ status: "coding" }) === "coding", "a recognized status query param is picked");
    check(sandbox.pickInitialTaskBoardStatus({ status: "not_a_real_status" }) === null, "an unrecognized status value must be ignored, not blindly returned");
    check(sandbox.pickInitialTaskBoardStatus({}) === null, "no status query param at all -> null (filter stays untouched)");
    check(sandbox.pickInitialTaskBoardStatus(undefined) === null, "missing queryParams entirely (defensive) -> null, no throw");
  }

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

main().catch((e) => {
  console.error(e);
  process.exit(1);
});