#!/usr/bin/env node /** * Responsive QA sweep — screenshots + automated checks for every main screen at the phone widths from the brief * (375×812, 390×844, 393×852, 430×932), a tablet and a desktop size. * * Checks per page: horizontal document overflow, elements extending past the right edge, tap targets < 40 px on * touch viewports, fixed elements covering the composer, console/page errors. * * node qa/responsive-qa.mjs [--base http://localhost:3000] [--only chat,arena] [--widths 390,430] * Session: reuses qa/.e2e-session.json (Playwright storageState) or logs in with QA_EMAIL / QA_PASSWORD. */ import fs from "node:fs"; import path from "node:path"; import { chromium } from "@playwright/test"; const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, "").split("=")).map(([k, v]) => [k, v ?? "1"])); const BASE = args.base ?? process.env.QA_BASE ?? "http://localhost:3000"; const OUT = path.resolve("qa/out"); const VIEWPORTS = [ { name: "375", width: 375, height: 812, mobile: true }, { name: "390", width: 390, height: 844, mobile: true }, { name: "393", width: 393, height: 852, mobile: true }, { name: "430", width: 430, height: 932, mobile: true }, { name: "tablet", width: 820, height: 1180, mobile: true }, { name: "desktop", width: 1440, height: 900, mobile: false }, ].filter((v) => !args.widths || args.widths.split(",").includes(v.name)); const SCREENS = [ { key: "home", path: "/", public: true }, { key: "models-public", path: "/models", public: true }, { key: "security", path: "/security", public: true }, { key: "login", path: "/login", public: true, loggedOut: true }, { key: "signup", path: "/signup", public: true, loggedOut: true }, { key: "chat", path: "/app/chat" }, { key: "arena", path: "/app/arena" }, { key: "scoreboard", path: "/app/arena/scoreboard" }, { key: "models", path: "/app/models" }, { key: "prompts", path: "/app/prompts" }, { key: "projects", path: "/app/projects" }, { key: "library", path: "/app/library" }, { key: "usage", path: "/app/usage" }, { key: "settings-account", path: "/app/settings/account" }, { key: "settings-providers", path: "/app/settings/providers" }, { key: "settings-endpoints", path: "/app/settings/endpoints" }, { key: "onboarding", path: "/app/onboarding" }, ].filter((s) => !args.only || args.only.split(",").includes(s.key)); const AUDIT = `(() => { const vw = window.innerWidth; const doc = document.documentElement; const out = { docOverflow: Math.max(doc.scrollWidth, document.body.scrollWidth) - vw, overflowing: [], smallTargets: [], clipped: [] }; const inScroller = (el) => { let p = el.parentElement; while (p && p !== document.body) { const o = getComputedStyle(p).overflowX; if (o === 'auto' || o === 'scroll' || o === 'hidden' || o === 'clip') return true; p = p.parentElement; } return false; }; const visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.opacity !== '0' && r.bottom > 0 && r.top < window.innerHeight; }; const desc = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.getAttribute('aria-label') ? '[' + el.getAttribute('aria-label') + ']' : '') + ' "' + (el.textContent || '').trim().slice(0, 40).replace(/\\s+/g, ' ') + '"'); for (const el of document.querySelectorAll('body *')) { if (!visible(el)) continue; const r = el.getBoundingClientRect(); if (r.right > vw + 1 && r.left < vw && !el.closest('.snap-row, .marquee, [data-allow-overflow]') && !inScroller(el)) out.overflowing.push({ el: desc(el), right: Math.round(r.right) }); } const coarse = matchMedia('(pointer: coarse)').matches || vw < 768; if (coarse) { for (const el of document.querySelectorAll('button, a[href], [role="button"], input[type="checkbox"], input[type="radio"], [role="tab"], [role="switch"]')) { if (!visible(el)) continue; const r = el.getBoundingClientRect(); const s = getComputedStyle(el); // .tap pseudo-element expands the hit area; count it as ok const hasTap = el.classList.contains('tap'); if (!hasTap && (r.width < 40 || r.height < 40) && !(r.width >= 24 && r.height >= 24 && r.width * r.height >= 1400 && el.closest('nav,[role=tablist]'))) out.smallTargets.push({ el: desc(el), w: Math.round(r.width), h: Math.round(r.height) }); } } for (const el of document.querySelectorAll('h1,h2,h3,p,span,a,button,td,th,li')) { if (!visible(el)) continue; const s = getComputedStyle(el); if (s.overflow === 'hidden' && s.textOverflow !== 'ellipsis' && el.scrollWidth > el.clientWidth + 2 && el.children.length === 0) out.clipped.push({ el: desc(el), sw: el.scrollWidth, cw: el.clientWidth }); } out.overflowing = out.overflowing.slice(0, 12); out.smallTargets = out.smallTargets.slice(0, 20); out.clipped = out.clipped.slice(0, 12); return out; })()`; async function ensureSession(context) { const stateFile = "qa/.e2e-session.json"; const email = process.env.QA_EMAIL; const password = process.env.QA_PASSWORD; const page = await context.newPage(); await page.goto(`${BASE}/app/chat`, { waitUntil: "networkidle" }); if (/\/login/.test(page.url())) { if (!email || !password) throw new Error("Session expired: set QA_EMAIL and QA_PASSWORD to log in."); await page.getByLabel(/email/i).fill(email); await page.getByLabel(/^password$/i).fill(password); await page.getByRole("button", { name: /sign in/i }).first().click(); const t0 = Date.now(); while (!/\/app/.test(page.url()) && Date.now() - t0 < 30_000) await page.waitForTimeout(500); if (!/\/app/.test(page.url())) throw new Error(`Login did not reach /app (url=${page.url()})`); await page.waitForTimeout(800); await context.storageState({ path: stateFile }); } await page.close(); } const report = []; const browser = await chromium.launch(); fs.mkdirSync(OUT, { recursive: true }); const stateFile = fs.existsSync("qa/.e2e-session.json") ? "qa/.e2e-session.json" : undefined; for (const vp of VIEWPORTS) { const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, deviceScaleFactor: 2, isMobile: vp.mobile, hasTouch: vp.mobile, storageState: stateFile, colorScheme: args.dark ? "dark" : "light" }); await ensureSession(context); for (const screen of SCREENS) { const page = await context.newPage(); const errors = []; page.on("console", (m) => m.type() === "error" && !/favicon|Download the React DevTools|hydrat/i.test(m.text()) && errors.push(m.text().slice(0, 200))); page.on("pageerror", (e) => errors.push(e.message.slice(0, 200))); const t0 = Date.now(); let status = "ok"; try { const res = await page.goto(`${BASE}${screen.path}`, { waitUntil: "networkidle", timeout: 45_000 }); if (!res || res.status() >= 400) status = `http ${res?.status()}`; await page.waitForTimeout(600); } catch (e) { status = `error: ${e.message.slice(0, 120)}`; } const dir = path.join(OUT, vp.name); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, `${screen.key}.png`); try { await page.screenshot({ path: file, fullPage: false }); } catch { /* ignore */ } let audit = null; try { audit = await page.evaluate(AUDIT); } catch { /* ignore */ } const row = { viewport: vp.name, screen: screen.key, url: page.url().replace(BASE, ""), status, ms: Date.now() - t0, errors: errors.slice(0, 5), ...audit }; report.push(row); const flags = []; if (audit?.docOverflow > 1) flags.push(`OVERFLOW +${audit.docOverflow}px`); if (audit?.overflowing?.length) flags.push(`${audit.overflowing.length} el. past edge`); if (audit?.smallTargets?.length) flags.push(`${audit.smallTargets.length} small targets`); if (audit?.clipped?.length) flags.push(`${audit.clipped.length} clipped`); if (errors.length) flags.push(`${errors.length} console errors`); console.log(`${vp.name.padEnd(8)} ${screen.key.padEnd(20)} ${status.padEnd(10)} ${String(row.ms).padStart(5)}ms ${flags.join(" · ")}`); await page.close(); } await context.close(); } await browser.close(); fs.writeFileSync(path.join(OUT, "report.json"), JSON.stringify(report, null, 2)); const bad = report.filter((r) => r.docOverflow > 1 || r.overflowing?.length || r.smallTargets?.length || r.clipped?.length || r.errors?.length || r.status !== "ok"); console.log(`\n${report.length} screens audited, ${bad.length} with findings → qa/out/report.json`);