spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1/**2 * QA sweep: every route at 390×844 (mobile) and 1440×900 (desktop), dark and light — HTTP status, console errors,3 * failed requests (404/5xx), horizontal overflow, small tap targets (mobile), screenshot into qa/screens/ (git-ignored).4 * Also checks that the homepage counters match GET /api/v1/stats and that the SSE feed prepends a row.5 * Run: node qa/screens.mjs [BASE_URL] [API_URL] (defaults http://localhost:8370, http://127.0.0.1:8371)6 * Env: MOBILE_ONLY=1 · WIDTHS=360 · THEMES=dark · ONLY=/company/stripe7 */8import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';9import { mkdirSync } from 'node:fs';1011const BASE = process.argv[2] ?? 'http://localhost:8370';12const API = process.argv[3] ?? 'http://127.0.0.1:8371';13const OUT = new URL('./screens/', import.meta.url).pathname;14mkdirSync(OUT, { recursive: true });1516async function j(path) {17 const r = await fetch(`${API}/api/v1${path}`);18 if (!r.ok) throw new Error(`${path} → ${r.status}`);19 return r.json();20}21// discover real ids from the API so provenance routes render22let ids = { event: null, sensor: null, snapshot: null, snapshot2: null, change: null };23try {24 const live = await j('/live?limit=5');25 const items = Array.isArray(live) ? live : live.items;26 const e = items.find((x) => x.change_id && x.sensor_id) ?? items[0];27 ids.event = e?.id ?? null;28 ids.change = e?.change_id ?? null;29 ids.sensor = e?.sensor_id ?? null;30 if (ids.sensor) {31 const snaps = await j(`/sensors/${ids.sensor}/snapshots?limit=2`);32 ids.snapshot = snaps.items?.[0]?.id ?? null;33 ids.snapshot2 = snaps.items?.[1]?.id ?? null;34 }35} catch (e) {36 console.log(`WARN could not discover ids from the API: ${e.message}`);37}3839const PAGES = [40 '/',41 '/live',42 '/live?event_type=PRICING&min_importance=0.5',43 '/companies',44 '/companies?country=CA&sort=hiring',45 '/company/stripe',46 '/company/stripe?tab=timeline',47 '/company/stripe?tab=timeline&filter=jobs',48 '/company/stripe?tab=signals',49 '/company/stripe?tab=jobs',50 '/company/stripe?tab=jobs&ai=1&status=removed',51 '/company/stripe?tab=products',52 '/company/stripe?tab=pricing',53 '/company/stripe?tab=locations',54 '/company/stripe?tab=leadership',55 '/company/stripe?tab=sources',56 '/company/stripe?tab=history',57 '/company/nvidia',58 '/company/sony',59 '/company/sony?tab=leadership',60 '/company/cohere',61 '/company/cabify',62 '/company/anthropic?tab=products',63 '/company/compass',64 '/company/compare?companies=stripe,adyen,block',65 '/company/compare',66 '/events',67 '/events?event_type=HIRING&min_confidence=0.7&sort=importance',68 ids.event ? `/events/${ids.event}` : null,69 ids.change ? `/change/${ids.change}` : null,70 ids.sensor ? `/sensor/${ids.sensor}` : null,71 ids.snapshot ? `/snapshot/${ids.snapshot}` : null,72 ids.snapshot && ids.snapshot2 ? `/snapshot/${ids.snapshot2}/diff/${ids.snapshot}` : null,73 '/rankings',74 '/rankings?kind=hiring_decline&window=7d',75 '/industry',76 '/industry/fintech',77 '/country',78 '/country/ca',79 '/search?q=stripe',80 '/search?q=companies%20hiring%20AI%20engineers%20in%20Canada',81 '/watchlist',82 '/system',83 '/about',84 '/methodology',85 '/api',86 '/bot',87 '/admin',88 '/company/does-not-exist',89 '/does-not-exist',90].filter(Boolean);91const ONLY = process.env.ONLY;92const pages = ONLY ? PAGES.filter((p) => p.startsWith(ONLY)) : PAGES;93const WIDTHS = process.env.WIDTHS ? process.env.WIDTHS.split(',').map(Number) : process.env.MOBILE_ONLY ? [390] : [390, 1440];94const THEMES = process.env.THEMES ? process.env.THEMES.split(',') : ['dark', 'light'];95const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });9697const browser = await chromium.launch();98let failures = 0;99const 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));100101/** Interactive controls smaller than 44 px on mobile (text links in prose/tables/lists are exempt). */102const smallTargets = () => {103 const root = document.querySelector('[data-palette], [role="dialog"]') ?? document;104 return [...root.querySelectorAll('button, [role="button"], input, select, a[href]')]105 .filter((el) => {106 const r = el.getBoundingClientRect();107 if (r.width === 0 || r.height === 0) return false;108 const cs = getComputedStyle(el);109 if (cs.visibility === 'hidden' || cs.display === 'none') return false;110 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;111 if (el.classList.contains('chip-btn')) return false; // 32 px chips are intentionally compact and spaced112 if (el.tagName === 'A') {113 const inNav = !!el.closest('nav, [role="menu"], [role="listbox"]');114 if (!inNav && el.textContent.trim().length > 0) return false;115 }116 return Math.min(r.width, r.height) < 40;117 })118 .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)}`);119};120121for (const theme of THEMES) {122 for (const width of WIDTHS) {123 const mobile = width < 768;124 const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });125 await ctx.addInitScript((t) => localStorage.setItem('ca-theme', t), theme);126 const page = await ctx.newPage();127 for (const path of pages) {128 const errors = [];129 const badReqs = [];130 const onErr = (e) => errors.push(String(e));131 const onCon = (m) => {132 if (m.type() === 'error') errors.push(m.text());133 };134 const onResp = (r) => {135 const s = r.status();136 if ((s === 404 || s >= 500) && !/favicon/.test(r.url())) badReqs.push(`${s} ${r.url().replace(BASE, '')}`);137 };138 page.on('pageerror', onErr);139 page.on('console', onCon);140 page.on('response', onResp);141 const t0 = Date.now();142 const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));143 await page.waitForTimeout(700);144 const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);145 const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null);146 const small = mobile ? await page.evaluate(smallTargets).catch(() => []) : [];147 const expected = /does-not-exist/.test(path) ? 404 : 200;148 const status = res.status();149 const filtered = filterErrors(errors, expected);150 const bad = badReqs.filter((b) => !(expected === 404 && b.startsWith('404 ' + path)) && !/\/api\/v1\/(watchlist|alerts)/.test(b));151 const ok = status === expected && overflow <= 0 && filtered.length === 0 && bad.length === 0 && applied === theme && small.length === 0;152 if (!ok) failures++;153 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(' | ') : ''}`);154 await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined);155 page.off('pageerror', onErr);156 page.off('console', onCon);157 page.off('response', onResp);158 }159 await ctx.close();160 }161}162163// counters must come from /api/v1/stats164if (!ONLY) {165 try {166 const t0 = Date.now();167 const before = await j('/stats');168 const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });169 const page = await ctx.newPage();170 await page.goto(BASE + '/', { waitUntil: 'networkidle' });171 await page.waitForTimeout(6000); // client refresh of /stats (1.5 s) + count-up; also long enough to measure the ingestion rate172 const text = await page.evaluate(() => document.querySelector('[data-live-counters]')?.innerText ?? '');173 const after = await j('/stats');174 const nums = [...text.matchAll(/\d{1,3}(?:,\d{3})*/g)].map((m) => Number(m[0].replace(/,/g, '')));175 // the page is ISR-cached (60 s) while the API keeps ingesting: accept values within 120 s of observed growth176 const elapsed = Math.max(1, (Date.now() - t0) / 1000);177 for (const k of ['companies', 'sensors', 'observations', 'changes', 'events']) {178 const rate = Math.max(0, after[k] - before[k]) / elapsed;179 const lo = Math.min(before[k], after[k]) - Math.ceil(rate * 120) - 5;180 const hi = Math.max(before[k], after[k]) + 5;181 const hit = nums.some((n) => n >= lo && n <= hi);182 if (!hit) failures++;183 console.log(`${hit ? 'OK ' : 'FAIL'} counter ${k}: page shows a value in [${fmt0.format(lo)}, ${fmt0.format(hi)}]`);184 }185 // SSE: a new row should appear within ~12 s on /live186 await page.goto(BASE + '/live', { waitUntil: 'networkidle' });187 const first = await page.evaluate(() => document.querySelector('[data-live-feed] [data-event-id]')?.getAttribute('data-event-id'));188 await page.mouse.move(5, 5);189 let changed = false;190 for (let i = 0; i < 16 && !changed; i++) {191 await page.waitForTimeout(1000);192 changed = (await page.evaluate(() => document.querySelector('[data-live-feed] [data-event-id]')?.getAttribute('data-event-id'))) !== first;193 }194 if (!changed) failures++;195 const rows = await page.evaluate(() => document.querySelectorAll('[data-live-feed] [data-event-id]').length);196 const status = await page.evaluate(() => document.querySelector('[data-live-feed] .dot + span')?.textContent ?? '?');197 console.log(`${changed ? 'OK ' : 'FAIL'} SSE live feed prepended a new event (rows=${rows}, status=${status})`);198 await ctx.close();199 } catch (e) {200 console.log(`SKIP counters/SSE check: ${e.message}`);201 }202}203204await browser.close();205console.log(failures ? `\n${failures} failure(s)` : '\nall checks OK');206process.exit(failures ? 1 : 0);207