mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-12 23:04:14 +00:00
Fence review results with required queue tokens, lock concurrent GitHub actions, recover persistence after errors, and reopen feedbacked PRs on new commits. Guard detail request races and add modal focus management and keyboard tabs with regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8045e7e0-dfd7-425a-9131-21ea5ca1553e
307 lines
13 KiB
JavaScript
307 lines
13 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 { 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("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);
|
|
});
|