// W15-UI: real-browser Playwright debug of LIVE trigger user experience. // // Goal: simulate the user's click path, capture timings (click -> URL nav, // SSE open), progressive labels, console errors, screenshots over time, // and contrast against the mock path. Read-only diagnostic — do NOT modify // any application code. // // Usage: from polyglot-alpha/ui: // node scripts/w15-ui-debug.mjs // // Outputs: // screenshots/w15-ui/00..NN.png // /tmp/w15-ui-findings.md import { chromium } from "playwright"; import { promises as fs } from "fs"; import path from "path"; const UI = "http://localhost:3001"; // Node 18's undici has a localhost resolution quirk that makes fetch() to // `http://localhost:8000` fail with "fetch failed"; use 127.0.0.1 directly. const API = "http://127.0.0.1:8000"; const SHOT_DIR = path.resolve( path.dirname(new URL(import.meta.url).pathname), "..", "screenshots", "w15-ui", ); const MANIFEST = "/tmp/w15-ui-findings.md"; const now = () => Date.now(); const ms = (t0) => `${(now() - t0).toString().padStart(6, " ")}ms`; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Aggregated diagnostic record (rendered into the final markdown manifest). const report = { startedAt: new Date().toISOString(), live: { clickT0: null, urlNavMs: null, sseOpenMs: null, eventId: null, progressiveLabels: [], consoleErrors: [], consoleWarns: [], httpErrors: [], finalStatus: null, finalReason: null, finalPhases: [], bidTxHashes: [], screenshots: [], timeline: [], }, mock: { clickT0: null, urlNavMs: null, submittedMs: null, eventId: null, finalStatus: null, screenshots: [], consoleErrors: [], }, }; function pushTimeline(bucket, msg) { const line = `+${(now() - bucket.clickT0).toString().padStart(6, " ")}ms ${msg}`; bucket.timeline.push(line); console.log(line); } async function attachListeners(page, bucket) { page.on("console", (msg) => { const type = msg.type(); const text = msg.text(); if (type === "error") bucket.consoleErrors.push(text); else if (type === "warning") bucket.consoleWarns?.push?.(text); }); page.on("pageerror", (err) => { bucket.consoleErrors.push(`pageerror: ${err.message}`); }); page.on("response", (resp) => { const s = resp.status(); if (s >= 400) { bucket.httpErrors?.push?.(`${s} ${resp.url()}`); } }); page.on("request", (req) => { const url = req.url(); if (url.includes("/sse/events") && bucket.sseOpenMs === null && bucket.clickT0) { bucket.sseOpenMs = now() - bucket.clickT0; pushTimeline(bucket, `SSE request opened: ${url.slice(API.length)}`); } }); } async function clearStateAndGoto(page, url) { // Visit a blank page first so we can safely clear storage for the origin. await page.goto(url, { waitUntil: "domcontentloaded" }); await page.evaluate(() => { try { localStorage.clear(); sessionStorage.clear(); } catch {} }); await page.reload({ waitUntil: "domcontentloaded" }); } async function findTriggerButton(page, modeWord) { // Click handler is on the visible "Trigger live demo" / "Trigger mock demo" // button rendered by components/TriggerButton.tsx. const namePattern = new RegExp(`Trigger ${modeWord} demo`, "i"); return page.getByRole("button", { name: namePattern }); } async function captureLabelSamples(page, bucket, samples) { // Read the trigger button's current label so we can record the progressive // status messages while the lifecycle ticks. try { const text = (await page .locator('button:has-text("…"), button:has-text("Triggered"), button:has-text("Trigger")') .first() .textContent()) ?? ""; const trimmed = text.trim().slice(0, 120); if ( trimmed && (samples.length === 0 || samples[samples.length - 1].label !== trimmed) ) { samples.push({ atMs: now() - bucket.clickT0, label: trimmed }); pushTimeline(bucket, `LABEL: "${trimmed}"`); } } catch {} } async function shot(page, bucket, name) { const full = path.join(SHOT_DIR, name); await page.screenshot({ path: full, fullPage: false }); bucket.screenshots.push(name); pushTimeline(bucket, `screenshot ${name}`); } async function fetchEventDetail(eventId) { try { const r = await fetch(`${API}/events/${eventId}`); if (!r.ok) return null; return await r.json(); } catch (e) { return null; } } async function scrapeBidTxHashes(page) { // Find rows under Phase 2 (USDC Auction) that have a tx-hash-looking string. // The structure is unknown at runtime; fall back to a global regex sweep. try { const txes = await page.evaluate(() => { const hashRe = /0x(sim_[A-Za-z0-9_]+|[0-9a-fA-F]{64})/g; const text = document.body?.innerText ?? ""; return Array.from(new Set(text.match(hashRe) ?? [])); }); return txes; } catch { return []; } } async function runLive(browser) { const bucket = report.live; const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); await attachListeners(page, bucket); console.log("\n=== LIVE RUN ==="); await clearStateAndGoto(page, `${UI}/`); await page.waitForLoadState("networkidle").catch(() => {}); // Wait explicitly for the trigger button to be present + enabled. The // first home-page load can take several seconds because Next.js dev mode // compiles the route lazily and the WorkflowOverview client component is // dynamically imported. const triggerBtn = page.getByRole("button", { name: /Trigger a live demo event/i }); try { await triggerBtn.waitFor({ state: "visible", timeout: 30_000 }); } catch { // Fall back to text-based locator if the aria-label changed. } await shot(page, bucket, "00-home-live.png"); // Press the trigger. bucket.clickT0 = now(); pushTimeline(bucket, "CLICK Trigger live demo"); await triggerBtn.first().click({ timeout: 10_000 }); // Poll URL + label every 250ms until navigation OR 30s timeout. const navTimeoutMs = 30_000; const navDeadline = now() + navTimeoutMs; let navigated = false; while (now() < navDeadline) { await captureLabelSamples(page, bucket, bucket.progressiveLabels); const url = page.url(); if (/\/events\/(\d+)/.test(url)) { navigated = true; bucket.urlNavMs = now() - bucket.clickT0; bucket.eventId = url.match(/\/events\/(\d+)/)[1]; pushTimeline(bucket, `URL nav -> /events/${bucket.eventId}`); break; } await sleep(250); } if (!navigated) { pushTimeline(bucket, `STUCK: no URL nav within ${navTimeoutMs}ms`); await shot(page, bucket, "01-stuck.png"); } else { // Give the new route a moment to settle then snapshot. await page.waitForLoadState("domcontentloaded").catch(() => {}); await sleep(500); await shot(page, bucket, "01-after-click.png"); } // Watch phase progression for up to 180s, screenshot every 30s. if (navigated) { const intervals = [30, 60, 90, 120, 150, 180]; const baseT = now(); for (let i = 0; i < intervals.length; i++) { const targetMs = intervals[i] * 1000; while (now() - baseT < targetMs) { await sleep(500); } const fname = `0${i + 2}-${intervals[i]}s.png`; await shot(page, bucket, fname); // Scrape DOM for error banners we care about. const errText = await page.evaluate(() => { const t = document.body?.innerText ?? ""; const out = []; if (/RSS unreachable/i.test(t)) out.push("RSS unreachable"); if (/all_seeders_low_gas/i.test(t)) out.push("all_seeders_low_gas"); if (/FAILED/i.test(t)) out.push("FAILED-banner"); return out; }); if (errText.length) pushTimeline(bucket, `DOM markers: ${errText.join(", ")}`); // Check terminal status via API; bail out early if final. const detail = await fetchEventDetail(bucket.eventId); if (detail) { const st = String(detail.status || "").toUpperCase(); pushTimeline(bucket, `API status: ${st}`); if (["SUBMITTED", "REJECTED", "FAILED"].includes(st)) { bucket.finalStatus = st; bucket.finalReason = detail.failure_reason ?? detail.reason ?? null; bucket.finalPhases = (detail.phases ?? []).map((p) => ({ name: p.name, status: p.status, })); break; } } } // Final detail snapshot. if (!bucket.finalStatus && bucket.eventId) { const detail = await fetchEventDetail(bucket.eventId); if (detail) { bucket.finalStatus = String(detail.status ?? ""); bucket.finalReason = detail.failure_reason ?? detail.reason ?? null; bucket.finalPhases = (detail.phases ?? []).map((p) => ({ name: p.name, status: p.status, })); } } // Try to expand the Phase 2 (USDC Auction) accordion before scraping so // its bid rows are present in the DOM. The accordion buttons are rendered // by EventTimeline; we try common selectors and fall back to clicking any // header whose text contains "USDC Auction". try { const auctionToggle = page.getByRole("button", { name: /USDC Auction|Phase 2/i }).first(); if ((await auctionToggle.count()) > 0) { await auctionToggle.click({ timeout: 3_000 }).catch(() => {}); await sleep(500); await shot(page, bucket, "98-phase2-expanded.png"); } } catch {} // Scrape bid tx hashes from the rendered DOM. bucket.bidTxHashes = await scrapeBidTxHashes(page); pushTimeline(bucket, `tx hashes found in DOM: ${JSON.stringify(bucket.bidTxHashes)}`); await shot(page, bucket, "99-final.png"); } await ctx.close(); } async function runMock(browser) { const bucket = report.mock; bucket.timeline = []; const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); page.on("console", (msg) => { if (msg.type() === "error") bucket.consoleErrors.push(msg.text()); }); console.log("\n=== MOCK RUN ==="); await clearStateAndGoto(page, `${UI}/?mode=mock`); await page.waitForLoadState("networkidle").catch(() => {}); // Mode badge should pick up mock from ?mode=mock query. await page.waitForTimeout(800); const triggerBtn = await findTriggerButton(page, "mock"); // Fallback: any "Trigger" button. const btn = (await triggerBtn.count()) > 0 ? triggerBtn.first() : page.getByRole("button", { name: /Trigger/i }).first(); await page.screenshot({ path: path.join(SHOT_DIR, "10-home-mock.png") }); bucket.screenshots.push("10-home-mock.png"); bucket.clickT0 = now(); pushTimeline(bucket, "CLICK Trigger mock demo"); await btn.click(); // Wait for URL nav (mock should be <5s). const navDeadline = now() + 30_000; while (now() < navDeadline) { const url = page.url(); if (/\/events\/(\d+)/.test(url)) { bucket.urlNavMs = now() - bucket.clickT0; bucket.eventId = url.match(/\/events\/(\d+)/)[1]; pushTimeline(bucket, `URL nav -> /events/${bucket.eventId}`); break; } await sleep(200); } await page.screenshot({ path: path.join(SHOT_DIR, "11-mock-after-nav.png") }); bucket.screenshots.push("11-mock-after-nav.png"); // Wait for backend status SUBMITTED, max 30s. const stDeadline = now() + 30_000; while (now() < stDeadline) { const d = bucket.eventId ? await fetchEventDetail(bucket.eventId) : null; if (d && String(d.status).toUpperCase() === "SUBMITTED") { bucket.submittedMs = now() - bucket.clickT0; bucket.finalStatus = "SUBMITTED"; pushTimeline(bucket, `mock SUBMITTED at ${bucket.submittedMs}ms`); break; } await sleep(500); } await page.screenshot({ path: path.join(SHOT_DIR, "12-mock-final.png") }); bucket.screenshots.push("12-mock-final.png"); await ctx.close(); } async function writeManifest() { const lines = []; lines.push(`# W15-UI Findings`); lines.push(`Started: ${report.startedAt}`); lines.push(``); lines.push(`## LIVE`); const L = report.live; lines.push(`- event_id: \`${L.eventId ?? "n/a"}\``); lines.push(`- click → URL nav: **${L.urlNavMs ?? "(no nav within 30s)"} ms**`); lines.push(`- SSE /sse/events?event_id=… opened: ${L.sseOpenMs ?? "(never)"} ms after click`); lines.push(`- final status: \`${L.finalStatus ?? "(unknown)"}\``); if (L.finalReason) lines.push(`- final reason: \`${L.finalReason}\``); if (L.finalPhases?.length) { lines.push(`- phases:`); L.finalPhases.forEach((p) => lines.push(` - ${p.name}: ${p.status}`)); } lines.push(`- bid tx hashes scraped from Phase 2 DOM: ${JSON.stringify(L.bidTxHashes)}`); lines.push(``); lines.push(`### Progressive labels (LIVE)`); if (L.progressiveLabels.length === 0) lines.push(`_(no label changes observed before nav)_`); L.progressiveLabels.forEach((s) => lines.push(`- +${String(s.atMs).padStart(5, " ")}ms — "${s.label}"`), ); lines.push(``); lines.push(`### Console errors (LIVE)`); if (L.consoleErrors.length === 0) lines.push(`_(none)_`); L.consoleErrors.slice(0, 30).forEach((e) => lines.push(`- ${e.slice(0, 220)}`)); lines.push(``); lines.push(`### Console warnings (LIVE)`); if (L.consoleWarns.length === 0) lines.push(`_(none)_`); L.consoleWarns.slice(0, 20).forEach((e) => lines.push(`- ${e.slice(0, 220)}`)); lines.push(``); lines.push(`### HTTP 4xx/5xx (LIVE)`); if (L.httpErrors.length === 0) lines.push(`_(none)_`); L.httpErrors.slice(0, 20).forEach((e) => lines.push(`- ${e}`)); lines.push(``); lines.push(`### Timeline (LIVE)`); L.timeline.forEach((t) => lines.push(` ${t}`)); lines.push(``); lines.push(`## MOCK (control)`); const M = report.mock; lines.push(`- event_id: \`${M.eventId ?? "n/a"}\``); lines.push(`- click → URL nav: **${M.urlNavMs ?? "(no nav)"} ms**`); lines.push(`- click → SUBMITTED: **${M.submittedMs ?? "(timeout)"} ms**`); lines.push(`- final status: \`${M.finalStatus ?? "(unknown)"}\``); lines.push(`- console errors:`); if (M.consoleErrors.length === 0) lines.push(` _(none)_`); M.consoleErrors.slice(0, 10).forEach((e) => lines.push(` - ${e.slice(0, 220)}`)); lines.push(``); lines.push(`### Timeline (MOCK)`); M.timeline?.forEach?.((t) => lines.push(` ${t}`)); lines.push(``); lines.push(`## Screenshots`); L.screenshots.forEach((s) => lines.push(`- live: \`screenshots/w15-ui/${s}\``)); M.screenshots.forEach((s) => lines.push(`- mock: \`screenshots/w15-ui/${s}\``)); await fs.writeFile(MANIFEST, lines.join("\n"), "utf8"); console.log(`\nmanifest written: ${MANIFEST}`); } async function main() { await fs.mkdir(SHOT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true }); try { await runLive(browser); } catch (e) { console.error("live run error:", e); report.live.consoleErrors.push(`SCRIPT EXC: ${e?.message ?? e}`); } try { await runMock(browser); } catch (e) { console.error("mock run error:", e); report.mock.consoleErrors.push(`SCRIPT EXC: ${e?.message ?? e}`); } await browser.close(); await writeManifest(); } await main();