// UI/UX Score Loop — pass 1 driver. // Loads each route at three sizes in Chrome Canary, captures screenshots, // logs console errors, runs a small interaction probe per flow, and // writes a JSON report. Does not modify any source files. import puppeteer from "puppeteer-core"; import { mkdir, writeFile } from "node:fs/promises"; const BASE = "http://127.0.0.1:5173"; const SHOTS = "/tmp/cyclone-uiux/shots"; const REPORT = "/tmp/cyclone-uiux/report.json"; const SIZES = [ { name: "desktop", w: 1440, h: 900 }, { name: "tablet", w: 768, h: 1024 }, { name: "mobile", w: 375, h: 812 }, ]; // Routes to load. path = the route; name = the flow label; ready = a // selector we wait for to consider the page "rendered". const FLOWS = [ { name: "dashboard", path: "/", ready: "aside nav, h1, h2" }, { name: "upload", path: "/upload", ready: "section[aria-label='File upload']" }, { name: "inbox", path: "/inbox", ready: "main, section[aria-label='Queue summary']" }, { name: "claims", path: "/claims", ready: "table, [data-testid='claims-page-body']" }, { name: "claims-denied", path: "/claims?status=denied", ready: "table, [data-testid='claims-page-body']" }, { name: "remittances", path: "/remittances", ready: "main, table" }, { name: "providers", path: "/providers", ready: "main, table" }, { name: "reconciliation",path: "/reconciliation", ready: "main" }, { name: "acks", path: "/acks", ready: "main, table" }, { name: "batches", path: "/batches", ready: "main, table" }, { name: "batch-diff", path: "/batch-diff", ready: "main" }, { name: "activity", path: "/activity", ready: "main" }, { name: "404", path: "/does-not-exist", ready: "main" }, ]; async function setupViewports(browser) { const pages = []; for (const size of SIZES) { const page = await browser.newPage(); await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 }); pages.push({ page, size }); } return pages; } async function probeFlow(page, flow) { const consoleErrors = []; const pageErrors = []; const failedRequests = []; const onConsole = (msg) => { if (msg.type() === "error") consoleErrors.push(msg.text()); }; const onPageError = (err) => pageErrors.push(err.message); const onRequestFailed = (req) => failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText}`); page.on("console", onConsole); page.on("pageerror", onPageError); page.on("requestfailed", onRequestFailed); const t0 = Date.now(); let rendered = false; let readyError = null; try { await page.goto(`${BASE}${flow.path}`, { waitUntil: "networkidle2", timeout: 15000 }); if (flow.ready) { try { await page.waitForSelector(flow.ready, { timeout: 5000 }); rendered = true; } catch (e) { readyError = e.message; } } else { rendered = true; } } catch (e) { readyError = e.message; } const loadMs = Date.now() - t0; page.off("console", onConsole); page.off("pageerror", onPageError); page.off("requestfailed", onRequestFailed); return { rendered, loadMs, readyError, consoleErrors, pageErrors, failedRequests }; } async function probeInteractions(page, flow) { const findings = []; // Generic a11y / structural probes per flow. try { // Sidebar visible? (md+ shows it; < md hides it) const aside = await page.$("aside"); findings.push({ check: "sidebar-present", pass: !!aside }); } catch (e) { findings.push({ check: "sidebar-present", pass: false, err: e.message }); } try { // Top bar present? const main = await page.$("main#main-content"); findings.push({ check: "main-present", pass: !!main }); } catch (e) { findings.push({ check: "main-present", pass: false, err: e.message }); } try { // H1 or page heading? const heading = await page.evaluate(() => { const h = document.querySelector("h1, h2"); return h ? h.textContent?.trim().slice(0, 60) : null; }); findings.push({ check: "heading-present", pass: !!heading, value: heading }); } catch (e) { findings.push({ check: "heading-present", pass: false, err: e.message }); } // Flow-specific probes. if (flow.name === "claims" || flow.name === "claims-denied") { try { const chips = await page.$$("[role='radio'], button[role='radio']"); findings.push({ check: "status-chips", pass: chips.length >= 1, count: chips.length }); } catch (e) { findings.push({ check: "status-chips", pass: false, err: e.message }); } try { const search = await page.$("input[placeholder*='Search']"); findings.push({ check: "search-input", pass: !!search }); } catch (e) { findings.push({ check: "search-input", pass: false, err: e.message }); } } if (flow.name === "upload") { try { const dropzone = await page.$("section[aria-label='File upload']"); findings.push({ check: "dropzone-present", pass: !!dropzone }); const selects = await page.$$("button[role='combobox']"); findings.push({ check: "payer-kind-selects", pass: selects.length >= 2, count: selects.length }); } catch (e) { findings.push({ check: "upload-elements", pass: false, err: e.message }); } } if (flow.name === "inbox") { try { const lanes = await page.$$("main > div > div"); findings.push({ check: "lane-cards", pass: lanes.length >= 1, count: lanes.length }); } catch (e) { findings.push({ check: "lane-cards", pass: false, err: e.message }); } } if (flow.name === "404") { try { const text = await page.evaluate(() => document.body.innerText); findings.push({ check: "404-text", pass: text.includes("404") || text.toLowerCase().includes("doesn't exist") }); } catch (e) { findings.push({ check: "404-text", pass: false, err: e.message }); } } return findings; } async function main() { await mkdir(SHOTS, { recursive: true }); const browser = await puppeteer.launch({ executablePath: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", headless: "new", args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const startedAt = new Date().toISOString(); const results = []; for (const size of SIZES) { const page = await browser.newPage(); await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 }); for (const flow of FLOWS) { const probe = await probeFlow(page, flow); const interactions = await probeInteractions(page, flow); const shot = `${SHOTS}/${flow.name}--${size.name}.png`; try { await page.screenshot({ path: shot, fullPage: false }); } catch (e) { // ignore — recording in result } results.push({ flow: flow.name, path: flow.path, size: size.name, viewport: { w: size.w, h: size.h }, probe, interactions, shot, }); console.log( `${size.name.padEnd(7)} ${flow.name.padEnd(20)} ` + `render=${probe.rendered} load=${probe.loadMs}ms ` + `consoleErr=${probe.consoleErrors.length} pageErr=${probe.pageErrors.length}` ); } await page.close(); } await browser.close(); // Aggregate. const summary = { startedAt, endedAt: new Date().toISOString(), flows: results.length, sizes: SIZES.map((s) => s.name), renderedOk: results.filter((r) => r.probe.rendered).length, withConsoleErrors: results.filter((r) => r.probe.consoleErrors.length > 0).length, withPageErrors: results.filter((r) => r.probe.pageErrors.length > 0).length, withFailedRequests: results.filter((r) => r.probe.failedRequests.length > 0).length, results, }; await writeFile(REPORT, JSON.stringify(summary, null, 2)); console.log("\nSummary:", JSON.stringify({ flows: summary.flows, renderedOk: summary.renderedOk, withConsoleErrors: summary.withConsoleErrors, withPageErrors: summary.withPageErrors, withFailedRequests: summary.withFailedRequests, }, null, 2)); console.log("\nReport:", REPORT); console.log("Shots:", SHOTS); } main().catch((e) => { console.error("FATAL", e); process.exit(1); });