// 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= 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- 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, "&").replace(//g, ">").replace(/"/g, """); }, }; } // 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 " }, { 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 <Project>"), "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); });