tracker/e2e/review-check.mjs
// End-to-end check of the review form in a real browser.
//
// Run it against a tracker that uses a COPY of the data, because it submits a
// review:
// cp -r data /tmp/review-data && go run . -addr :8098 -data /tmp/review-data
// node e2e/review-check.mjs <path-to-chrome> http://localhost:8098 /tmp/review-data/milestones.json T-201
//
// Checks: a live update does not wipe a half-written review; Reject + text +
// checklist tick + reviewer name saves; the page shows the saved notice on the
// Rejected tab; the data file changed; the reviewer name is remembered.
// Needs Node 22+ (global fetch and WebSocket).
import { spawn } from "node:child_process";
import { readFileSync, utimesSync } from "node:fs";
const [chrome, base, dataFile, taskId] = process.argv.slice(2);
if (!chrome || !base || !dataFile || !taskId) {
console.error("usage: node e2e/review-check.mjs <chrome> <base-url> <milestones.json copy> <task-id>");
process.exit(2);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const proc = spawn(chrome, ["--headless=new", "--no-sandbox", "--disable-gpu", "--remote-debugging-port=9337", "about:blank"], { stdio: "ignore" });
let ws;
let id = 0;
const pending = new Map();
const send = (method, params = {}) =>
new Promise((res) => {
const i = ++id;
pending.set(i, res);
ws.send(JSON.stringify({ id: i, method, params }));
});
const evaluate = async (expression) =>
(await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result.value;
const results = [];
const check = (name, ok, detail = "") => {
results.push(ok);
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " (" + detail + ")" : ""}`);
};
try {
let target;
for (let i = 0; i < 50 && !target; i++) {
try {
target = (await (await fetch("http://127.0.0.1:9337/json")).json()).find((t) => t.type === "page");
} catch {}
await sleep(200);
}
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r) => ws.addEventListener("open", r));
ws.addEventListener("message", (e) => {
const m = JSON.parse(e.data);
if (pending.has(m.id)) {
pending.get(m.id)(m.result);
pending.delete(m.id);
}
});
await send("Page.navigate", { url: `${base}/reviews?state=todo` });
await sleep(2500);
const form = `document.querySelector('#${taskId} form.review-form')`;
check("form is shown for the task", await evaluate(`!!${form}`));
// Type into the textarea the way a person does, then change the data file.
await evaluate(`(() => { const t = ${form}.querySelector('textarea'); t.focus(); t.value = 'ร่างผลการรีวิว **ยังไม่บันทึก**'; t.dispatchEvent(new Event('input', { bubbles: true })); })()`);
const now = new Date();
utimesSync(dataFile, now, now); // makes the watcher push an update
await sleep(2500);
check("live update keeps the half-written text",
(await evaluate(`${form}.querySelector('textarea').value`)) === "ร่างผลการรีวิว **ยังไม่บันทึก**");
// Fill the form and save.
const before = readFileSync(dataFile, "utf8");
await evaluate(`(() => {
const f = ${form};
f.querySelector('textarea').value = 'ต้องแก้ **ข้อ 2** ก่อน';
f.querySelector('input[value="reject"]').click();
f.querySelector('input[name="checklist"][value="0"]').click();
f.querySelector('input[name="reviewer"]').value = 'ทดสอบ e2e';
f.querySelector('button[type="submit"]').click();
})()`);
await sleep(2500);
const url = await evaluate("location.href");
check("redirected to the Rejected tab", url.includes("state=rejected") && url.includes(`saved=${taskId}`), url);
check("saved notice is shown", await evaluate("!!document.querySelector('.notice')"));
check("decision renders as Markdown", await evaluate(`!!document.querySelector('#${taskId} .decision strong')`));
const after = readFileSync(dataFile, "utf8");
check("data file records the review", after !== before && after.includes("ต้องแก้ **ข้อ 2** ก่อน") && after.includes('"decided_by": "ทดสอบ e2e"'));
await send("Page.navigate", { url: `${base}/reviews?state=todo` });
await sleep(2000);
check("reviewer name is remembered",
(await evaluate(`[...document.querySelectorAll('input[name="reviewer"]')].every((el) => el.value === 'ทดสอบ e2e')`)) === true);
} finally {
ws?.close();
proc.kill();
}
const ok = results.length > 0 && results.every(Boolean);
console.log(ok ? "PASS" : "FAIL");
process.exit(ok ? 0 : 1);