/** * QA sweep: every route at 390×844 (mobile) and 1440×900 (desktop), dark and light — HTTP status, console errors, * failed requests (404/5xx), horizontal overflow, small tap targets (mobile), screenshot into qa/screens/ (git-ignored). * Also checks that the homepage counters match GET /api/v1/stats and that the SSE feed prepends a row. * Run: node qa/screens.mjs [BASE_URL] [API_URL] (defaults http://localhost:8370, http://127.0.0.1:8371) * Env: MOBILE_ONLY=1 · WIDTHS=360 · THEMES=dark · ONLY=/company/stripe */ import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; import { mkdirSync } from 'node:fs'; const BASE = process.argv[2] ?? 'http://localhost:8370'; const API = process.argv[3] ?? 'http://127.0.0.1:8371'; const OUT = new URL('./screens/', import.meta.url).pathname; mkdirSync(OUT, { recursive: true }); async function j(path) { const r = await fetch(`${API}/api/v1${path}`); if (!r.ok) throw new Error(`${path} → ${r.status}`); return r.json(); } // discover real ids from the API so provenance routes render let ids = { event: null, sensor: null, snapshot: null, snapshot2: null, change: null }; try { const live = await j('/live?limit=5'); const items = Array.isArray(live) ? live : live.items; const e = items.find((x) => x.change_id && x.sensor_id) ?? items[0]; ids.event = e?.id ?? null; ids.change = e?.change_id ?? null; ids.sensor = e?.sensor_id ?? null; if (ids.sensor) { const snaps = await j(`/sensors/${ids.sensor}/snapshots?limit=2`); ids.snapshot = snaps.items?.[0]?.id ?? null; ids.snapshot2 = snaps.items?.[1]?.id ?? null; } } catch (e) { console.log(`WARN could not discover ids from the API: ${e.message}`); } const PAGES = [ '/', '/live', '/live?event_type=PRICING&min_importance=0.5', '/companies', '/companies?country=CA&sort=hiring', '/company/stripe', '/company/stripe?tab=timeline', '/company/stripe?tab=timeline&filter=jobs', '/company/stripe?tab=signals', '/company/stripe?tab=jobs', '/company/stripe?tab=jobs&ai=1&status=removed', '/company/stripe?tab=products', '/company/stripe?tab=pricing', '/company/stripe?tab=locations', '/company/stripe?tab=leadership', '/company/stripe?tab=sources', '/company/stripe?tab=history', '/company/nvidia', '/company/sony', '/company/sony?tab=leadership', '/company/cohere', '/company/cabify', '/company/anthropic?tab=products', '/company/compass', '/company/compare?companies=stripe,adyen,block', '/company/compare', '/events', '/events?event_type=HIRING&min_confidence=0.7&sort=importance', ids.event ? `/events/${ids.event}` : null, ids.change ? `/change/${ids.change}` : null, ids.sensor ? `/sensor/${ids.sensor}` : null, ids.snapshot ? `/snapshot/${ids.snapshot}` : null, ids.snapshot && ids.snapshot2 ? `/snapshot/${ids.snapshot2}/diff/${ids.snapshot}` : null, '/rankings', '/rankings?kind=hiring_decline&window=7d', '/industry', '/industry/fintech', '/country', '/country/ca', '/search?q=stripe', '/search?q=companies%20hiring%20AI%20engineers%20in%20Canada', '/watchlist', '/system', '/about', '/methodology', '/api', '/bot', '/admin', '/company/does-not-exist', '/does-not-exist', ].filter(Boolean); const ONLY = process.env.ONLY; const pages = ONLY ? PAGES.filter((p) => p.startsWith(ONLY)) : PAGES; const WIDTHS = process.env.WIDTHS ? process.env.WIDTHS.split(',').map(Number) : process.env.MOBILE_ONLY ? [390] : [390, 1440]; const THEMES = process.env.THEMES ? process.env.THEMES.split(',') : ['dark', 'light']; const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); const browser = await chromium.launch(); let failures = 0; const filterErrors = (errors, expected) => errors.filter((e) => !/favicon|the server responded with a status of 404 \(Not Found\)/.test(e) || expected !== 404).filter((e) => !/Encountered a script tag while rendering React component/.test(e)); /** Interactive controls smaller than 44 px on mobile (text links in prose/tables/lists are exempt). */ const smallTargets = () => { const root = document.querySelector('[data-palette], [role="dialog"]') ?? document; return [...root.querySelectorAll('button, [role="button"], input, select, a[href]')] .filter((el) => { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return false; const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || cs.display === 'none') return false; if (el.closest('.sr-only, p, dd, dt, .prose-atlas, td, th, .kv, .data-table, footer, li, summary, h1, h2, h3, table, svg')) return false; if (el.classList.contains('chip-btn')) return false; // 32 px chips are intentionally compact and spaced if (el.tagName === 'A') { const inNav = !!el.closest('nav, [role="menu"], [role="listbox"]'); if (!inNav && el.textContent.trim().length > 0) return false; } return Math.min(r.width, r.height) < 40; }) .map((el) => `${el.tagName.toLowerCase()}.${[...el.classList].slice(0, 2).join('.')} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 22)}" ${Math.round(el.getBoundingClientRect().width)}×${Math.round(el.getBoundingClientRect().height)}`); }; for (const theme of THEMES) { for (const width of WIDTHS) { const mobile = width < 768; const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme }); await ctx.addInitScript((t) => localStorage.setItem('ca-theme', t), theme); const page = await ctx.newPage(); for (const path of pages) { const errors = []; const badReqs = []; const onErr = (e) => errors.push(String(e)); const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); }; const onResp = (r) => { const s = r.status(); if ((s === 404 || s >= 500) && !/favicon/.test(r.url())) badReqs.push(`${s} ${r.url().replace(BASE, '')}`); }; page.on('pageerror', onErr); page.on('console', onCon); page.on('response', onResp); const t0 = Date.now(); const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` })); await page.waitForTimeout(700); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1); const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null); const small = mobile ? await page.evaluate(smallTargets).catch(() => []) : []; const expected = /does-not-exist/.test(path) ? 404 : 200; const status = res.status(); const filtered = filterErrors(errors, expected); const bad = badReqs.filter((b) => !(expected === 404 && b.startsWith('404 ' + path)) && !/\/api\/v1\/(watchlist|alerts)/.test(b)); const ok = status === expected && overflow <= 0 && filtered.length === 0 && bad.length === 0 && applied === theme && small.length === 0; if (!ok) failures++; console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} bad=${bad.length} small=${small.length} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}${bad.length ? ' :: ' + bad.slice(0, 2).join(' | ') : ''}${small.length ? ' :: ' + small.slice(0, 3).join(' | ') : ''}`); await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined); page.off('pageerror', onErr); page.off('console', onCon); page.off('response', onResp); } await ctx.close(); } } // counters must come from /api/v1/stats if (!ONLY) { try { const t0 = Date.now(); const before = await j('/stats'); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); await page.goto(BASE + '/', { waitUntil: 'networkidle' }); await page.waitForTimeout(6000); // client refresh of /stats (1.5 s) + count-up; also long enough to measure the ingestion rate const text = await page.evaluate(() => document.querySelector('[data-live-counters]')?.innerText ?? ''); const after = await j('/stats'); const nums = [...text.matchAll(/\d{1,3}(?:,\d{3})*/g)].map((m) => Number(m[0].replace(/,/g, ''))); // the page is ISR-cached (60 s) while the API keeps ingesting: accept values within 120 s of observed growth const elapsed = Math.max(1, (Date.now() - t0) / 1000); for (const k of ['companies', 'sensors', 'observations', 'changes', 'events']) { const rate = Math.max(0, after[k] - before[k]) / elapsed; const lo = Math.min(before[k], after[k]) - Math.ceil(rate * 120) - 5; const hi = Math.max(before[k], after[k]) + 5; const hit = nums.some((n) => n >= lo && n <= hi); if (!hit) failures++; console.log(`${hit ? 'OK ' : 'FAIL'} counter ${k}: page shows a value in [${fmt0.format(lo)}, ${fmt0.format(hi)}]`); } // SSE: a new row should appear within ~12 s on /live await page.goto(BASE + '/live', { waitUntil: 'networkidle' }); const first = await page.evaluate(() => document.querySelector('[data-live-feed] [data-event-id]')?.getAttribute('data-event-id')); await page.mouse.move(5, 5); let changed = false; for (let i = 0; i < 16 && !changed; i++) { await page.waitForTimeout(1000); changed = (await page.evaluate(() => document.querySelector('[data-live-feed] [data-event-id]')?.getAttribute('data-event-id'))) !== first; } if (!changed) failures++; const rows = await page.evaluate(() => document.querySelectorAll('[data-live-feed] [data-event-id]').length); const status = await page.evaluate(() => document.querySelector('[data-live-feed] .dot + span')?.textContent ?? '?'); console.log(`${changed ? 'OK ' : 'FAIL'} SSE live feed prepended a new event (rows=${rows}, status=${status})`); await ctx.close(); } catch (e) { console.log(`SKIP counters/SSE check: ${e.message}`); } } await browser.close(); console.log(failures ? `\n${failures} failure(s)` : '\nall checks OK'); process.exit(failures ? 1 : 0);