Files
awesome-copilot/eng/open-pr-board.test.mjs
T
Aaron PowellandCopilot App d63527fe89 fix(canvas): pin PR approval to the reviewed commit
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8045e7e0-dfd7-425a-9131-21ea5ca1553e
2026-10-12 11:09:37 +11:00

367 lines
16 KiB
JavaScript

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);
});