// 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 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 "); 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);