/** * QA for the explore-agent routes (indicators / regions / explore / changes / data / sources / methodology / api / * admin): screenshots at 320/375/390/430/1280/1440 → qa/screens/explore/, plus automated checks per render: * - horizontal overflow (scrollWidth > clientWidth) + the offending elements * - tap targets < 44 px (both dimensions) on phone widths * - console errors / page errors * - 404 status for unknown slugs * - the indicator map year slider reacts to touch (390 px, hasTouch) and a country tap shows the label * Run: node qa/shots-explore.mjs [BASE_URL] (Playwright from ~/Desktop/uqo-eval via absolute import) */ import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; import { mkdirSync, writeFileSync } from 'node:fs'; const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:8290'; const OUT = new URL('./screens/explore/', import.meta.url).pathname; mkdirSync(OUT, { recursive: true }); const PAGES = ['/indicators', '/indicators?topic=health', '/indicators/life-expectancy', '/indicators/gdp-per-capita', '/regions', '/regions/oecd', '/explore', '/changes', '/data', '/sources', '/sources/worldbank', '/methodology', '/api', '/admin/login']; const NOT_FOUND = ['/indicators/nope', '/regions/nope', '/sources/nope']; const WIDTHS = (process.env.WIDTHS ?? '320,375,390,430,1280,1440').split(',').map(Number); const report = []; const browser = await chromium.launch(); // --- 404s for (const p of NOT_FOUND) { const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } }); const page = await ctx.newPage(); const res = await page.goto(BASE + p, { waitUntil: 'domcontentloaded' }); report.push({ path: p, width: 390, status: res?.status(), check: '404' }); console.log(`${String(res?.status()).padStart(4)} ${p}`); await ctx.close(); } // --- screenshots + checks 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: 'light' }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(String(e))); page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); for (const path of PAGES) { const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 180_000 }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(500); const metrics = await page.evaluate(() => { const de = document.documentElement; const overflow = de.scrollWidth - de.clientWidth; const wide = [...document.querySelectorAll('body *')] .filter((el) => { const r = el.getBoundingClientRect(); return r.right > de.clientWidth + 1 && r.width > 0 && getComputedStyle(el).position !== 'fixed'; }) .slice(0, 6) .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`); const small = [...document.querySelectorAll('a,button,[role=button],input,select,summary')] .filter((el) => { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return false; const cs = getComputedStyle(el); if (cs.visibility === 'hidden') return false; if (el.closest('.sr-only') || el.classList.contains('sr-only')) return false; return r.height < 44 && r.width < 44; }) .map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`); return { overflow, wide, small, docH: de.scrollHeight }; }); const name = `${path.slice(1).replace(/[/?=]/g, '_')}-${width}.png`; await page.screenshot({ path: OUT + name, fullPage: true }); const r = { path, width, status: res?.status(), overflow: metrics.overflow, wide: metrics.wide, small: mobile ? metrics.small.slice(0, 10) : [], docH: metrics.docH, errors: errors.splice(0), file: name }; report.push(r); const flags = [r.overflow > 0 ? `OVERFLOW +${r.overflow}px` : 'ok', r.small.length ? `${r.small.length} small targets` : '', r.errors.length ? `${r.errors.length} console errors` : ''].filter(Boolean).join(' · '); console.log(`${String(width).padStart(4)} ${path.padEnd(34)} ${flags}`); if (r.overflow > 0) console.log(' wide:', r.wide.join(' | ')); if (r.small.length) console.log(' small:', r.small.slice(0, 6).join(' | ')); if (r.errors.length) console.log(' errors:', r.errors.slice(0, 3).join(' | ')); } await ctx.close(); } // --- touch interaction: year slider + country tap on the indicator map (390 px) { const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); const page = await ctx.newPage(); await page.goto(BASE + '/indicators/life-expectancy', { waitUntil: 'networkidle', timeout: 180_000 }); const slider = page.locator('input[type=range]').first(); const before = await slider.evaluate((el) => el.getAttribute('aria-valuetext')); const box = await slider.boundingBox(); await page.touchscreen.tap(box.x + box.width * 0.35, box.y + box.height / 2); await page.waitForTimeout(900); const after = await slider.evaluate((el) => el.getAttribute('aria-valuetext')); const legendTitle = await page.locator('#map figure title').first().textContent(); const mapOk = before !== after && legendTitle?.includes(after ?? ''); console.log(`slider touch: ${before} → ${after} · legend "${legendTitle}" · ${mapOk ? 'OK' : 'FAIL'}`); // country tap → sticky label with "Open" link const can = page.locator('#map path[aria-label^="Canada"]').first(); const cb = await can.boundingBox(); await page.touchscreen.tap(cb.x + cb.width / 2, cb.y + cb.height / 2); await page.waitForTimeout(400); const label = await page.locator('#map .pointer-events-none').first().textContent().catch(() => null); console.log(`country tap label: ${label ? label.slice(0, 60) : 'none'} · ${label?.includes('Canada') ? 'OK' : 'FAIL'}`); await page.screenshot({ path: OUT + 'indicators_life-expectancy-390-touch.png', fullPage: false }); report.push({ path: '/indicators/life-expectancy', width: 390, check: 'touch', sliderBefore: before, sliderAfter: after, legendTitle, label }); await ctx.close(); } // --- dark mode sample { const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme: 'dark', isMobile: true, hasTouch: true }); const page = await ctx.newPage(); await page.goto(BASE + '/indicators/gdp-per-capita', { waitUntil: 'networkidle', timeout: 180_000 }); await page.screenshot({ path: OUT + 'indicators_gdp-per-capita-390-dark.png', fullPage: true }); await ctx.close(); } await browser.close(); writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));