import assert from "node:assert/strict"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import vm from "node:vm"; import { test } from "node:test"; import { BoardStore } from "../.github/extensions/open-pr-board/lib/state.mjs"; import { applyDecisionOnGitHub } from "../.github/extensions/open-pr-board/lib/decisions.mjs"; import { approveAndEnableAutoMerge } from "../.github/extensions/open-pr-board/lib/github.mjs"; import { REVIEW_FIELDS_SCHEMA } from "../.github/extensions/open-pr-board/lib/review-prompt.mjs"; const pr = (headRefOid = "first") => ({ number: 1, title: "Example", url: "https://github.com/example/repo/pull/1", headRefOid, updatedAt: "2026-10-01T00:00:00Z", labels: [], }); async function createStore(t) { const dir = await mkdtemp(path.join(os.tmpdir(), "open-pr-board-")); t.after(() => rm(dir, { recursive: true, force: true })); const store = new BoardStore({ file: path.join(dir, "state.json"), repo: "example/repo" }); await store.syncPullRequests([pr()]); return store; } function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } test("approval passes the board's commit SHA to the GitHub helper", async (t) => { const store = await createStore(t); let options; await applyDecisionOnGitHub({ store, number: 1, kind: "approve", comment: "Reviewed.", refresh: async () => {}, github: { approveAndEnableAutoMerge: async (repo, number, input) => { options = input; return { status: "auto-merge-enabled" }; }, }, }); assert.deepEqual(options, { body: "Reviewed.", headRefOid: "first" }); }); test("approval and auto-merge are pinned to the reviewed commit", async () => { const headRefOid = "a".repeat(40); const calls = []; const result = await approveAndEnableAutoMerge("example/repo", 1, { body: "Reviewed.", headRefOid }, async (args, options) => { calls.push({ args, options }); if (calls.length === 1) return JSON.stringify({ state: "open", head: { sha: headRefOid } }); if (args[1] === "view") return JSON.stringify({ state: "OPEN", autoMergeRequest: {} }); return ""; }); assert.equal(result.status, "auto-merge-enabled"); assert.deepEqual(calls[1].args, ["api", "repos/example/repo/pulls/1/reviews", "--method", "POST", "--input", "-"]); assert.deepEqual(JSON.parse(calls[1].options.input), { event: "APPROVE", commit_id: headRefOid, body: "Reviewed." }); assert.deepEqual(calls[2].args, ["pr", "merge", "1", "-R", "example/repo", "--auto", "--merge", "--match-head-commit", headRefOid]); }); test("approval refuses a missing SHA, changed head, or closed pull request without writing", async () => { let calls = 0; const runner = async () => { calls++; throw new Error("must not run"); }; await assert.rejects(approveAndEnableAutoMerge("example/repo", 1, {}, runner), /no commit SHA/); assert.equal(calls, 0); for (const current of [{ state: "open", head: { sha: "second" } }, { state: "closed", head: { sha: "first" } }]) { calls = 0; await assert.rejects(approveAndEnableAutoMerge("example/repo", 1, { headRefOid: "first" }, async () => { calls++; return JSON.stringify(current); }), /Refresh and review/); assert.equal(calls, 1); } }); test("a head change after approval reports partial success instead of retrying the merge", async () => { let calls = 0; const result = await approveAndEnableAutoMerge("example/repo", 1, { headRefOid: "first" }, async () => { calls++; if (calls === 1) return JSON.stringify({ state: "open", head: { sha: "first" } }); if (calls === 3) throw new Error("Head commit changed"); return ""; }); assert.equal(calls, 3); assert.equal(result.status, "approval-submitted"); assert.match(result.warning, /Head commit changed/); }); test("review schema requires a nonempty queue token and a one-sentence rationale", () => { assert.ok(REVIEW_FIELDS_SCHEMA.required.includes("queueId")); assert.equal(REVIEW_FIELDS_SCHEMA.properties.queueId.minLength, 1); assert.match(REVIEW_FIELDS_SCHEMA.properties.rationale.description, /One sentence/); }); test("only the current token records a review; moves, rereviews, and reset invalidate old results", async (t) => { const store = await createStore(t); const payload = { number: 1, recommendation: "approve", rationale: "Ready." }; assert.equal((await store.recordReviews([payload]))[0].status, "skipped"); const { queued } = await store.markQueued([1]); const token = queued[0].queueId; for (const queueId of [undefined, "", "wrong"]) { assert.equal((await store.recordReviews([{ ...payload, queueId }]))[0].status, "skipped"); assert.equal(store.getItem(1).queueId, token); } await store.moveItem(1, "needs-insight"); assert.equal((await store.recordReviews([{ ...payload, queueId: token }]))[0].status, "skipped"); assert.equal(store.getItem(1).manualColumn, "needs-insight"); const next = (await store.markQueued([1])).queued[0].queueId; assert.equal((await store.recordReviews([{ ...payload, queueId: token }]))[0].status, "skipped"); assert.equal((await store.recordReviews([{ ...payload, queueId: next }]))[0].status, "recorded"); assert.equal((await store.recordReviews([{ ...payload, queueId: next }]))[0].status, "skipped"); const beforeReset = (await store.markQueued([1])).queued[0].queueId; await store.reset(); await store.syncPullRequests([pr()]); assert.equal((await store.recordReviews([{ ...payload, queueId: beforeReset }]))[0].status, "skipped"); assert.equal(store.snapshot().counts.unreviewed, 1); }); test("a transient persistence failure rejects its caller but does not poison later writes", async (t) => { const store = await createStore(t); const goodFile = store.file; const blocker = path.join(path.dirname(goodFile), "not-a-directory"); await writeFile(blocker, "block"); store.file = path.join(blocker, "state.json"); await assert.rejects(store.persist()); store.file = goodFile; await store.moveItem(1, "needs-insight"); const disk = JSON.parse(await readFile(goodFile, "utf8")); assert.equal(disk.items["1"].manualColumn, "needs-insight"); }); for (const kind of ["comment", "request-changes", "approve", "close"]) { test(`${kind} decisions reopen on new commits only when nonterminal`, async (t) => { const store = await createStore(t); await store.recordDecision(1, { kind, comment: "Feedback" }); await store.syncPullRequests([{ ...pr(), updatedAt: "2099-01-01T00:00:00Z" }]); assert.equal(store.getItem(1).decision.kind, kind, "metadata updates must not reopen a PR"); await store.syncPullRequests([pr("second")]); const nonterminal = kind === "comment" || kind === "request-changes"; assert.equal(store.getItem(1).decision === null, nonterminal); assert.equal(store.snapshot().items[0].column, nonterminal ? "unreviewed" : "actioned"); assert.equal(store.state.history.length, 1); }); } test("legacy nonterminal decisions learn the current commit and reopen on the next commit", async (t) => { const store = await createStore(t); store.getItem(1).decision = { kind: "comment" }; delete store.getItem(1).headRefOid; await store.syncPullRequests([pr()]); assert.equal(store.getItem(1).decision.headRefOid, "first"); await store.syncPullRequests([pr("second")]); assert.equal(store.getItem(1).decision, null); }); test("concurrent GitHub decisions for the same repo/PR cannot both write", async (t) => { const store = await createStore(t); const waiting = deferred(); let calls = 0; const github = { submitPullRequestReview: async () => { calls++; await waiting.promise; return { status: "comment-submitted" }; }, closePullRequest: async () => { calls++; throw new Error("must not be reached"); }, }; const args = { store, number: 1, comment: "Feedback", github, refresh: async () => {} }; const first = applyDecisionOnGitHub({ ...args, kind: "comment" }); await assert.rejects(applyDecisionOnGitHub({ ...args, kind: "close" }), /in progress/); assert.equal(calls, 1); waiting.resolve(); await first; await assert.rejects(applyDecisionOnGitHub({ ...args, kind: "close" }), /already actioned/); }); test("decision lock is released on failure and independent repositories can proceed", async (t) => { const store = await createStore(t); const args = { store, number: 1, kind: "comment", comment: "Feedback", refresh: async () => {} }; await assert.rejects(applyDecisionOnGitHub({ ...args, github: { submitPullRequestReview: async () => { throw new Error("offline"); } }, }), /offline/); const waiting = deferred(); const first = applyDecisionOnGitHub({ ...args, github: { submitPullRequestReview: async () => { await waiting.promise; return { status: "comment-submitted" }; } }, }); const other = new BoardStore({ file: path.join(path.dirname(store.file), "other.json"), repo: "example/other" }); await other.syncPullRequests([pr()]); await applyDecisionOnGitHub({ ...args, store: other, github: { submitPullRequestReview: async () => ({ status: "comment-submitted" }) }, }); waiting.resolve(); await first; }); class Element { constructor() { this.listeners = {}; this.attributes = {}; this.dataset = {}; this.style = { setProperty() {} }; this.classes = new Set(); this.classList = { add: (value) => this.classes.add(value), remove: (value) => this.classes.delete(value), contains: (value) => this.classes.has(value), }; } addEventListener(name, callback) { this.listeners[name] = callback; } setAttribute(name, value) { this.attributes[name] = value; } replaceChildren() {} append() {} getClientRects() { return [1]; } focus() { this.document.activeElement = this; } click() { this.listeners.click?.({ currentTarget: this }); } } async function createUI() { const nodes = new Map(); const document = new Element(); document.activeElement = null; document.getElementById = (id) => { if (!nodes.has(id)) { const node = new Element(); node.document = document; nodes.set(id, node); } return nodes.get(id); }; document.querySelector = (selector) => selector === ".toolbar" ? document.getElementById("toolbar") : { content: "" }; document.createElement = () => new Element(); const drawer = document.getElementById("drawer"); const tabs = ["review", "description", "conversation", "files", "checks"].map((tab) => { const node = document.getElementById(`tab-${tab}`); node.dataset.tab = tab; return node; }); const decisions = ["close", "comment", "request-changes", "approve"].map((kind) => { const node = document.getElementById(`decision-${kind}`); node.dataset.decision = kind; return node; }); drawer.querySelectorAll = (selector) => selector === ".tab" ? tabs : selector === "[data-decision]" ? decisions : [ document.getElementById("drawer-close"), document.getElementById("decision-approve"), ]; document.getElementById("board").querySelector = () => document.getElementById("origin-card"); const source = await readFile(new URL("../.github/extensions/open-pr-board/public/app.js", import.meta.url), "utf8"); const context = vm.createContext({ document, setTimeout, clearTimeout }); vm.runInContext(source.slice(0, source.indexOf("const events = new EventSource")), context); vm.runInContext(` renderBoard = () => {}; reviewTab = conversationTab = filesTab = checksTab = markdownBlock = () => null; api = () => globalThis.nextRequest.promise; globalThis.ui = { openDrawer, closeDrawer, loadDetail, submitDecision, select(number) { selectedNumber = number; board = { columns: [], items: [1, 2].map(number => ({ number, title: "Example", labels: [], createdAt: "2026-10-01", column: "unreviewed", })) }; }, state: () => ({ detail, detailError, activeTab }), render: renderDrawerBody, }; `, context); return { context, ui: context.ui, nodes, tabs, document }; } test("older PR detail responses and errors cannot overwrite a newer selected PR", async () => { const { context, ui } = await createUI(); for (const fail of [false, true]) { const old = deferred(), current = deferred(); ui.select(1); context.nextRequest = old; const loadingOld = ui.loadDetail(1); ui.select(2); context.nextRequest = current; const loadingCurrent = ui.loadDetail(2); current.resolve({ number: 2, bodyHtml: "Current" }); await loadingCurrent; if (fail) old.reject(new Error("Old failure")); else old.resolve({ number: 1 }); await loadingOld; assert.equal(ui.state().detail.number, 2); assert.equal(ui.state().detailError, null); } }); test("latest forced detail request wins, and closing invalidates pending detail", async () => { const { context, ui } = await createUI(); ui.select(1); const first = deferred(), latest = deferred(); context.nextRequest = first; const loadingFirst = ui.loadDetail(1); context.nextRequest = latest; const loadingLatest = ui.loadDetail(1, { force: true }); latest.resolve({ number: 1, version: "latest" }); await loadingLatest; first.resolve({ number: 1, version: "old" }); await loadingFirst; assert.equal(ui.state().detail.version, "latest"); const pending = deferred(); context.nextRequest = pending; const loading = ui.loadDetail(1); ui.closeDrawer(); pending.resolve({ number: 1 }); await loading; assert.equal(ui.state().detail, null); }); test("drawer focuses its close button, traps focus, and restores the card on close", async () => { const { ui, document, nodes } = await createUI(); ui.select(1); ui.openDrawer(1); assert.equal(document.activeElement, nodes.get("drawer-close")); assert.equal(nodes.get("board").inert, true); const last = nodes.get("decision-approve"); last.focus(); let prevented = false; document.listeners.keydown({ key: "Tab", preventDefault: () => { prevented = true; } }); assert.equal(prevented, true); assert.equal(document.activeElement, nodes.get("drawer-close")); document.listeners.keydown({ key: "Tab", shiftKey: true, preventDefault() {} }); assert.equal(document.activeElement, last); document.listeners.keydown({ key: "Escape", preventDefault() {} }); assert.equal(document.activeElement, nodes.get("origin-card")); assert.equal(nodes.get("board").inert, false); assert.equal(nodes.get("drawer").inert, true); }); test("tabs use roving focus with arrows and Home/End, and label their panel", async () => { const { ui, tabs, nodes, document, context } = await createUI(); ui.select(1); ui.render(); const key = (tab, value) => tab.listeners.keydown({ key: value, preventDefault() {} }); // Keep detail requests local and already satisfied while exercising tab navigation. vm.runInContext('detail = { number: 1 };', context); key(tabs[0], "End"); assert.equal(document.activeElement, tabs[4]); assert.equal(ui.state().activeTab, "checks"); assert.equal(nodes.get("drawer-body").attributes["aria-labelledby"], "tab-checks"); assert.equal(tabs[4].tabIndex, 0); assert.equal(tabs[0].tabIndex, -1); key(tabs[4], "ArrowRight"); assert.equal(ui.state().activeTab, "review"); key(tabs[0], "ArrowLeft"); assert.equal(ui.state().activeTab, "checks"); key(tabs[4], "Home"); assert.equal(ui.state().activeTab, "review"); assert.equal(tabs.filter(tab => tab.tabIndex === 0).length, 1); }); test("all decision buttons stay disabled while an action is pending", async () => { const { ui, context, nodes } = await createUI(); ui.select(1); const request = deferred(); context.nextRequest = request; vm.runInContext("toast = () => {}; loadDetail = async () => {};", context); const comment = nodes.get("decision-comment"); await ui.submitDecision(comment, "comment"); const submitting = ui.submitDecision(comment, "comment"); for (const kind of ["close", "comment", "request-changes", "approve"]) { assert.equal(nodes.get(`decision-${kind}`).disabled, true); } await ui.submitDecision(nodes.get("decision-close"), "close"); request.resolve({ status: "comment-submitted" }); await submitting; assert.equal(comment.disabled, false); });