SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
8.4 KB · 153 lines javascript
Raw Blame History
1#!/usr/bin/env node2/**3 * Responsive QA sweep — screenshots + automated checks for every main screen at the phone widths from the brief4 * (375×812, 390×844, 393×852, 430×932), a tablet and a desktop size.5 *6 * Checks per page: horizontal document overflow, elements extending past the right edge, tap targets < 40 px on7 * touch viewports, fixed elements covering the composer, console/page errors.8 *9 *   node qa/responsive-qa.mjs [--base http://localhost:3000] [--only chat,arena] [--widths 390,430]10 * Session: reuses qa/.e2e-session.json (Playwright storageState) or logs in with QA_EMAIL / QA_PASSWORD.11 */12import fs from "node:fs";13import path from "node:path";14import { chromium } from "@playwright/test";1516const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, "").split("=")).map(([k, v]) => [k, v ?? "1"]));17const BASE = args.base ?? process.env.QA_BASE ?? "http://localhost:3000";18const OUT = path.resolve("qa/out");19const VIEWPORTS = [20  { name: "375", width: 375, height: 812, mobile: true },21  { name: "390", width: 390, height: 844, mobile: true },22  { name: "393", width: 393, height: 852, mobile: true },23  { name: "430", width: 430, height: 932, mobile: true },24  { name: "tablet", width: 820, height: 1180, mobile: true },25  { name: "desktop", width: 1440, height: 900, mobile: false },26].filter((v) => !args.widths || args.widths.split(",").includes(v.name));2728const SCREENS = [29  { key: "home", path: "/", public: true },30  { key: "models-public", path: "/models", public: true },31  { key: "security", path: "/security", public: true },32  { key: "login", path: "/login", public: true, loggedOut: true },33  { key: "signup", path: "/signup", public: true, loggedOut: true },34  { key: "chat", path: "/app/chat" },35  { key: "arena", path: "/app/arena" },36  { key: "scoreboard", path: "/app/arena/scoreboard" },37  { key: "models", path: "/app/models" },38  { key: "prompts", path: "/app/prompts" },39  { key: "projects", path: "/app/projects" },40  { key: "library", path: "/app/library" },41  { key: "usage", path: "/app/usage" },42  { key: "settings-account", path: "/app/settings/account" },43  { key: "settings-providers", path: "/app/settings/providers" },44  { key: "settings-endpoints", path: "/app/settings/endpoints" },45  { key: "onboarding", path: "/app/onboarding" },46].filter((s) => !args.only || args.only.split(",").includes(s.key));4748const AUDIT = `(() => {49  const vw = window.innerWidth;50  const doc = document.documentElement;51  const out = { docOverflow: Math.max(doc.scrollWidth, document.body.scrollWidth) - vw, overflowing: [], smallTargets: [], clipped: [] };52  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; };53  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; };54  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, ' ') + '"');55  for (const el of document.querySelectorAll('body *')) {56    if (!visible(el)) continue;57    const r = el.getBoundingClientRect();58    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) });59  }60  const coarse = matchMedia('(pointer: coarse)').matches || vw < 768;61  if (coarse) {62    for (const el of document.querySelectorAll('button, a[href], [role="button"], input[type="checkbox"], input[type="radio"], [role="tab"], [role="switch"]')) {63      if (!visible(el)) continue;64      const r = el.getBoundingClientRect();65      const s = getComputedStyle(el);66      // .tap pseudo-element expands the hit area; count it as ok67      const hasTap = el.classList.contains('tap');68      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) });69    }70  }71  for (const el of document.querySelectorAll('h1,h2,h3,p,span,a,button,td,th,li')) {72    if (!visible(el)) continue;73    const s = getComputedStyle(el);74    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 });75  }76  out.overflowing = out.overflowing.slice(0, 12); out.smallTargets = out.smallTargets.slice(0, 20); out.clipped = out.clipped.slice(0, 12);77  return out;78})()`;7980async function ensureSession(context) {81  const stateFile = "qa/.e2e-session.json";82  const email = process.env.QA_EMAIL;83  const password = process.env.QA_PASSWORD;84  const page = await context.newPage();85  await page.goto(`${BASE}/app/chat`, { waitUntil: "networkidle" });86  if (/\/login/.test(page.url())) {87    if (!email || !password) throw new Error("Session expired: set QA_EMAIL and QA_PASSWORD to log in.");88    await page.getByLabel(/email/i).fill(email);89    await page.getByLabel(/^password$/i).fill(password);90    await page.getByRole("button", { name: /sign in/i }).first().click();91    const t0 = Date.now();92    while (!/\/app/.test(page.url()) && Date.now() - t0 < 30_000) await page.waitForTimeout(500);93    if (!/\/app/.test(page.url())) throw new Error(`Login did not reach /app (url=${page.url()})`);94    await page.waitForTimeout(800);95    await context.storageState({ path: stateFile });96  }97  await page.close();98}99100const report = [];101const browser = await chromium.launch();102fs.mkdirSync(OUT, { recursive: true });103const stateFile = fs.existsSync("qa/.e2e-session.json") ? "qa/.e2e-session.json" : undefined;104105for (const vp of VIEWPORTS) {106  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" });107  await ensureSession(context);108  for (const screen of SCREENS) {109    const page = await context.newPage();110    const errors = [];111    page.on("console", (m) => m.type() === "error" && !/favicon|Download the React DevTools|hydrat/i.test(m.text()) && errors.push(m.text().slice(0, 200)));112    page.on("pageerror", (e) => errors.push(e.message.slice(0, 200)));113    const t0 = Date.now();114    let status = "ok";115    try {116      const res = await page.goto(`${BASE}${screen.path}`, { waitUntil: "networkidle", timeout: 45_000 });117      if (!res || res.status() >= 400) status = `http ${res?.status()}`;118      await page.waitForTimeout(600);119    } catch (e) {120      status = `error: ${e.message.slice(0, 120)}`;121    }122    const dir = path.join(OUT, vp.name);123    fs.mkdirSync(dir, { recursive: true });124    const file = path.join(dir, `${screen.key}.png`);125    try {126      await page.screenshot({ path: file, fullPage: false });127    } catch {128      /* ignore */129    }130    let audit = null;131    try {132      audit = await page.evaluate(AUDIT);133    } catch {134      /* ignore */135    }136    const row = { viewport: vp.name, screen: screen.key, url: page.url().replace(BASE, ""), status, ms: Date.now() - t0, errors: errors.slice(0, 5), ...audit };137    report.push(row);138    const flags = [];139    if (audit?.docOverflow > 1) flags.push(`OVERFLOW +${audit.docOverflow}px`);140    if (audit?.overflowing?.length) flags.push(`${audit.overflowing.length} el. past edge`);141    if (audit?.smallTargets?.length) flags.push(`${audit.smallTargets.length} small targets`);142    if (audit?.clipped?.length) flags.push(`${audit.clipped.length} clipped`);143    if (errors.length) flags.push(`${errors.length} console errors`);144    console.log(`${vp.name.padEnd(8)} ${screen.key.padEnd(20)} ${status.padEnd(10)} ${String(row.ms).padStart(5)}ms  ${flags.join(" · ")}`);145    await page.close();146  }147  await context.close();148}149await browser.close();150fs.writeFileSync(path.join(OUT, "report.json"), JSON.stringify(report, null, 2));151const bad = report.filter((r) => r.docOverflow > 1 || r.overflowing?.length || r.smallTargets?.length || r.clipped?.length || r.errors?.length || r.status !== "ok");152console.log(`\n${report.length} screens audited, ${bad.length} with findings → qa/out/report.json`);153