Spaces:
Sleeping
Sleeping
| // W16-A: verify TriggerButton navigates IMMEDIATELY after POST returns | |
| // event_id, instead of waiting for the lifecycle to terminate. | |
| // | |
| // Expected: click -> URL change measured in ms, NOT seconds. | |
| // | |
| // Usage from polyglot-alpha/ui: | |
| // node scripts/w16a-verify.mjs | |
| // | |
| // Outputs: | |
| // /tmp/w16a-verify.md | |
| // screenshots/w16a/*.png | |
| import { chromium } from "playwright"; | |
| import { promises as fs } from "fs"; | |
| import path from "path"; | |
| const UI = "http://localhost:3001"; | |
| const API = "http://127.0.0.1:8000"; | |
| const SHOT_DIR = path.resolve( | |
| path.dirname(new URL(import.meta.url).pathname), | |
| "..", | |
| "screenshots", | |
| "w16a", | |
| ); | |
| const MANIFEST = "/tmp/w16a-verify.md"; | |
| // Targets per the W16-A spec. | |
| const TARGET_CLICK_TO_NAV_MS = 500; // PASS if < 500ms | |
| const now = () => Date.now(); | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| const report = { | |
| startedAt: new Date().toISOString(), | |
| runs: [], | |
| }; | |
| async function runOnce(browser, mode) { | |
| const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); | |
| const page = await ctx.newPage(); | |
| const consoleErrors = []; | |
| page.on("console", (msg) => { | |
| if (msg.type() === "error") consoleErrors.push(msg.text()); | |
| }); | |
| page.on("pageerror", (err) => | |
| consoleErrors.push(`pageerror: ${err.message}`), | |
| ); | |
| // Use ?mode=mock to force the demo mode for the mock run. | |
| const url = mode === "mock" ? `${UI}/?mode=mock` : `${UI}/`; | |
| console.log(`\n=== ${mode.toUpperCase()} RUN ===`); | |
| console.log(`goto ${url}`); | |
| await page.goto(url, { waitUntil: "domcontentloaded" }); | |
| await page.waitForLoadState("networkidle").catch(() => {}); | |
| // Give the mode context a moment to hydrate from the query string before | |
| // we grab the button label (otherwise the SSR label shows "live" briefly). | |
| await sleep(1000); | |
| const namePattern = new RegExp(`Trigger ${mode} demo`, "i"); | |
| const triggerBtn = page.getByRole("button", { name: namePattern }); | |
| try { | |
| await triggerBtn.waitFor({ state: "visible", timeout: 30_000 }); | |
| } catch { | |
| // Fall back to aria-label. | |
| } | |
| const btn = | |
| (await triggerBtn.count()) > 0 | |
| ? triggerBtn.first() | |
| : page.getByRole("button", { name: /Trigger a live demo event/i }).first(); | |
| await fs.mkdir(SHOT_DIR, { recursive: true }); | |
| await page.screenshot({ path: path.join(SHOT_DIR, `${mode}-00-before.png`) }); | |
| // Tight poll loop: click and then sample URL every 20ms so the | |
| // click->URL-change delta is measured at sub-50ms resolution. | |
| const clickT0 = now(); | |
| // Fire-and-forget click — don't await the playwright click() promise itself | |
| // because it includes actionability checks that can add 30-100ms of slack. | |
| // Instead we start a polling loop and a separate click promise; whichever | |
| // sees the URL change wins. | |
| let navT = null; | |
| let navEventId = null; | |
| const navPromise = (async () => { | |
| const deadline = now() + 30_000; | |
| while (now() < deadline) { | |
| const u = page.url(); | |
| const m = u.match(/\/events\/(\d+)/); | |
| if (m) { | |
| navT = now() - clickT0; | |
| navEventId = m[1]; | |
| return; | |
| } | |
| await sleep(20); | |
| } | |
| })(); | |
| await btn.click(); | |
| await navPromise; | |
| // Detail page paint timing: time to first phase rail / DAG appears. | |
| let firstPaintMs = null; | |
| if (navEventId) { | |
| const paintDeadline = now() + 60_000; | |
| while (now() < paintDeadline) { | |
| const hasPhase = await page | |
| .locator("text=/phase|pending|running|Auction|Translation|Verdict/i") | |
| .first() | |
| .count() | |
| .catch(() => 0); | |
| if (hasPhase > 0) { | |
| firstPaintMs = now() - clickT0; | |
| break; | |
| } | |
| await sleep(50); | |
| } | |
| await page.screenshot({ path: path.join(SHOT_DIR, `${mode}-01-after-nav.png`) }); | |
| } else { | |
| await page.screenshot({ path: path.join(SHOT_DIR, `${mode}-01-stuck.png`) }); | |
| } | |
| // Quick API status sanity check — confirms backend really did pre-create | |
| // the event row. | |
| let backendStatus = null; | |
| if (navEventId) { | |
| try { | |
| const r = await fetch(`${API}/events/${navEventId}`); | |
| if (r.ok) { | |
| const d = await r.json(); | |
| backendStatus = String(d.status ?? ""); | |
| } else { | |
| backendStatus = `(HTTP ${r.status})`; | |
| } | |
| } catch (e) { | |
| backendStatus = `(fetch err ${e?.message ?? e})`; | |
| } | |
| } | |
| await ctx.close(); | |
| return { | |
| mode, | |
| clickToNavMs: navT, | |
| firstPaintMs, | |
| eventId: navEventId, | |
| backendStatus, | |
| consoleErrors: consoleErrors.slice(0, 10), | |
| pass: navT !== null && navT < TARGET_CLICK_TO_NAV_MS, | |
| }; | |
| } | |
| async function writeManifest() { | |
| const lines = []; | |
| lines.push(`# W16-A Verify`); | |
| lines.push(`Started: ${report.startedAt}`); | |
| lines.push(``); | |
| lines.push(`Target: click -> URL change **< ${TARGET_CLICK_TO_NAV_MS}ms**`); | |
| lines.push(``); | |
| for (const r of report.runs) { | |
| lines.push(`## ${r.mode.toUpperCase()}`); | |
| lines.push(`- event_id: \`${r.eventId ?? "n/a"}\``); | |
| lines.push( | |
| `- click -> URL change: **${r.clickToNavMs ?? "(no nav)"} ms** ${r.pass ? "PASS" : "FAIL"}`, | |
| ); | |
| lines.push( | |
| `- click -> first phase paint: ${r.firstPaintMs ?? "(not observed)"} ms`, | |
| ); | |
| lines.push(`- backend status @ verify: \`${r.backendStatus ?? "n/a"}\``); | |
| lines.push(`- console errors: ${r.consoleErrors.length}`); | |
| r.consoleErrors.forEach((e) => lines.push(` - ${e.slice(0, 220)}`)); | |
| lines.push(``); | |
| } | |
| await fs.writeFile(MANIFEST, lines.join("\n"), "utf8"); | |
| console.log(`manifest: ${MANIFEST}`); | |
| } | |
| async function main() { | |
| await fs.mkdir(SHOT_DIR, { recursive: true }); | |
| const browser = await chromium.launch({ headless: true }); | |
| try { | |
| for (const mode of ["mock", "live"]) { | |
| try { | |
| const r = await runOnce(browser, mode); | |
| report.runs.push(r); | |
| console.log( | |
| `${mode}: click->nav=${r.clickToNavMs}ms paint=${r.firstPaintMs}ms ` + | |
| `id=${r.eventId} backend=${r.backendStatus} pass=${r.pass}`, | |
| ); | |
| } catch (e) { | |
| console.error(`${mode} run error:`, e); | |
| report.runs.push({ | |
| mode, | |
| clickToNavMs: null, | |
| firstPaintMs: null, | |
| eventId: null, | |
| backendStatus: null, | |
| consoleErrors: [`SCRIPT EXC: ${e?.message ?? e}`], | |
| pass: false, | |
| }); | |
| } | |
| } | |
| } finally { | |
| await browser.close(); | |
| await writeManifest(); | |
| } | |
| const allPass = report.runs.every((r) => r.pass); | |
| process.exit(allPass ? 0 : 1); | |
| } | |
| await main(); | |