import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, it } from "vitest"; import { type BoardRenderer, type BoardServer, boardSignature, startBoardServer, } from "../board-server.ts"; import { planStatusSource } from "../plan-status.ts"; const roots: string[] = []; const servers: BoardServer[] = []; afterEach(() => { for (const server of servers.splice(0)) server.close(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); describe("board signature", () => { it("separates document changes from board state changes", () => { const root = fixture(); const initial = boardSignature(root); write(root, ".system/board.json", boardJson({})); const afterBoard = boardSignature(root); assert.equal(afterBoard.documents, initial.documents); assert.notEqual(afterBoard.board, initial.board); write( root, ".system/specs/PX-SPEC-SPEC0001-contract/index.md", `${spec()}\nmore\n`, ); const afterDocument = boardSignature(root); assert.notEqual(afterDocument.documents, afterBoard.documents); assert.equal(afterDocument.board, afterBoard.board); }); }); describe("plan status source", () => { it("rewrites exactly one status line and preserves the body", () => { const updated = planStatusSource(plan("draft"), "approved"); assert.equal(updated, plan("approved")); }); it("preserves CRLF frontmatter", () => { const source = plan("draft").replaceAll("\n", "\r\n"); assert.equal( planStatusSource(source, "approved"), plan("approved").replaceAll("\n", "\r\n"), ); }); it("rejects documents without a single status line", () => { assert.throws( () => planStatusSource("# Plan\n", "approved"), /frontmatter/, ); assert.throws( () => planStatusSource( plan("draft").replace( "status: draft", "status: draft\nstatus: draft", ), "approved", ), /exactly one status line/, ); }); }); describe("board server", () => { it("re-renders stale pages and serves live board state", async () => { const root = fixture(); let renders = 0; const client = await open(root, (_index, board, _proposals, revision) => { renders += 1; const directory = mkdtempSync(path.join(tmpdir(), "board-site-")); roots.push(directory); writeFileSync( path.join(directory, "index.html"), `
`, "utf8", ); return { directory }; }); assert.equal(renders, 1); assert.match(await client.text("/"), /data-renders="1"/); assert.match(await client.text("/"), /data-renders="1"/); const before = await client.json("/api/state"); assert.deepEqual(before.items, {}); assert.equal(typeof before.revision, "string"); const applied = await client.post("/api/state", { id: "PX-PLAN-PLAN0001", state: "done", }); assert.equal(applied.items["PX-PLAN-PLAN0001"].state, "done"); const after = await client.json("/api/state"); assert.equal(after.items["PX-PLAN-PLAN0001"].state, "done"); assert.equal(after.revision, before.revision); const reloaded = await client.text("/"); assert.match(reloaded, /data-renders="2"/); assert.match(reloaded, /data-items="1"/); write( root, ".system/issues/PX-ISSUE-ISSUE002-later/index.md", "---\nid: PX-ISSUE-ISSUE002\ntype: issue\ntitle: Later\nspecs: [PX-SPEC-SPEC0001]\n---\n\n# Later\n", ); const changed = await client.json("/api/state"); assert.notEqual(changed.revision, before.revision); assert.match(await client.text("/"), /data-renders="3"/); const late = await client.post("/api/state", { id: "PX-ISSUE-ISSUE002", state: "done", }); assert.equal(late.items["PX-ISSUE-ISSUE002"].state, "done"); }); it("toggles plan status on disk and rejects other documents", async () => { const root = fixture(); const client = await open(root); const planPath = ".system/plans/PX-PLAN-PLAN0001-build/index.md"; const result = await client.post("/api/status", { id: "PX-PLAN-PLAN0001", status: "approved", }); assert.deepEqual(result, { id: "PX-PLAN-PLAN0001", status: "approved" }); assert.match(read(root, planPath), /^status: approved$/m); await client.post("/api/status", { id: "PX-PLAN-PLAN0001", status: "draft", }); assert.equal(read(root, planPath), plan("draft")); assert.match( ( await client.failing("/api/status", { id: "PX-SPEC-SPEC0001", status: "approved", }) ).error, /Only plans carry a status/, ); assert.match( ( await client.failing("/api/status", { id: "PX-PLAN-PLAN0001", status: "shipped", }) ).error, /Expected plan update with id and status/, ); }); }); const stubRenderer: BoardRenderer = (_index, _board, _proposals, revision) => { const directory = mkdtempSync(path.join(tmpdir(), "board-site-")); roots.push(directory); writeFileSync( path.join(directory, "index.html"), ``, "utf8", ); return { directory }; }; async function open(root: string, render: BoardRenderer = stubRenderer) { const server = await startBoardServer(root, new Map(), render); servers.push(server); const url = new URL(server.url); const handshake = await fetch(server.url, { redirect: "manual" }); const cookie = (handshake.headers.get("set-cookie") ?? "").split(";")[0]; assert.equal(handshake.status, 303); assert.ok(cookie); const origin = `${url.protocol}//${url.host}`; const request = (target: string, init?: RequestInit) => fetch(`${origin}${target}`, { ...init, headers: { ...(init?.headers ?? {}), cookie: cookie ?? "" }, }); const send = (target: string, payload: unknown) => request(target, { method: "POST", headers: { "content-type": "application/json", origin }, body: JSON.stringify(payload), }); return { async text(target: string) { const response = await request(target); assert.equal(response.status, 200); return response.text(); }, async json(target: string) { const response = await request(target); assert.equal(response.status, 200); return (await response.json()) as never; }, async post(target: string, payload: unknown) { const response = await send(target, payload); assert.equal(response.status, 200); return (await response.json()) as never; }, async failing(target: string, payload: unknown) { const response = await send(target, payload); assert.equal(response.status, 400); return (await response.json()) as { error: string }; }, }; } const boardJson = (items: Record