|
1 |
+/** |
|
2 |
+ * Final integration sweep (production build + real API): every public route × 320/375/390/430/768/1440/1920, |
|
3 |
+ * light (plus dark at 390 and 1440). Asserts: HTTP 200, no horizontal overflow, no console errors, no failed |
|
4 |
+ * API requests, no "undefined/NaN/null" text, footer credits present, tap targets ≥ 44 px on phones (excluding |
|
5 |
+ * sr-only/inputs inside 44 px labels), and every internal link on the page resolves (HEAD/GET once per URL). |
|
6 |
+ * |
|
7 |
+ * node qa/final-sweep.mjs [BASE_URL] [--quick] |
|
8 |
+ */ |
|
9 |
+import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; |
|
10 |
+import { mkdirSync, writeFileSync } from 'node:fs'; |
|
11 |
+ |
|
12 |
+const args = process.argv.slice(2); |
|
13 |
+const quick = args.includes('--quick'); |
|
14 |
+const BASE = args.find((a) => a.startsWith('http')) ?? 'http://localhost:8290'; |
|
15 |
+const OUT = new URL('./screens/final/', import.meta.url).pathname; |
|
16 |
+mkdirSync(OUT, { recursive: true }); |
|
17 |
+ |
|
18 |
+const ROUTES = [ |
|
19 |
+ '/', '/explore', '/explore?indicator=life-expectancy&year=1990', '/trajectories', '/scatter', '/finder?f=gdp-per-capita:gt:40000&f=population:gt:10000000', '/extremes', |
|
20 |
+ '/peers', '/stories', '/stories/the-world-is-getting-older', '/download', '/updates', '/api', '/countries', '/countries/canada', '/countries/canada/economy', |
|
21 |
+ '/countries/nigeria', '/compare', '/compare/canada/australia', '/compare/canada/united-states/france?tab=economy', '/rankings', '/rankings/gdp-per-capita', |
|
22 |
+ '/rankings/life-expectancy?group=oecd&view=map', '/indicators', '/indicators/life-expectancy', '/indicators/inflation', '/regions', '/regions/g7', '/regions/compare', |
|
23 |
+ '/changes', '/sources', '/sources/worldbank', '/methodology', |
|
24 |
+]; |
|
25 |
+const WIDTHS = quick ? [390, 1440] : [320, 375, 390, 430, 768, 1440, 1920]; |
|
26 |
+const slug = (p) => (p === '/' ? 'home' : p.slice(1).replace(/[/?=&:]+/g, '_')); |
|
27 |
+const report = []; |
|
28 |
+const linkStatus = new Map(); |
|
29 |
+const browser = await chromium.launch(); |
|
30 |
+ |
|
31 |
+async function checkLinks(page, hrefs) { |
|
32 |
+ const bad = []; |
|
33 |
+ for (const h of hrefs) { |
|
34 |
+ if (linkStatus.has(h)) { |
|
35 |
+ if (linkStatus.get(h) >= 400) bad.push(`${h} → ${linkStatus.get(h)}`); |
|
36 |
+ continue; |
|
37 |
+ } |
|
38 |
+ try { |
|
39 |
+ const r = await page.request.get(BASE + h, { maxRedirects: 3, timeout: 60_000 }); |
|
40 |
+ linkStatus.set(h, r.status()); |
|
41 |
+ if (r.status() >= 400) bad.push(`${h} → ${r.status()}`); |
|
42 |
+ } catch (e) { |
|
43 |
+ linkStatus.set(h, 599); |
|
44 |
+ bad.push(`${h} → ERR ${String(e).slice(0, 60)}`); |
|
45 |
+ } |
|
46 |
+ } |
|
47 |
+ return bad; |
|
48 |
+} |
|
49 |
+ |
|
50 |
+for (const width of WIDTHS) { |
|
51 |
+ const themes = width === 390 || width === 1440 ? ['light', 'dark'] : ['light']; |
|
52 |
+ for (const theme of themes) { |
|
53 |
+ const mobile = width < 768; |
|
54 |
+ const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme }); |
|
55 |
+ const page = await ctx.newPage(); |
|
56 |
+ const errors = []; |
|
57 |
+ const failed = []; |
|
58 |
+ page.on('pageerror', (e) => errors.push(`pageerror: ${String(e).slice(0, 200)}`)); |
|
59 |
+ page.on('console', (m) => { |
|
60 |
+ if (m.type() === 'error') errors.push(m.text().slice(0, 200)); |
|
61 |
+ }); |
|
62 |
+ page.on('response', (r) => { |
|
63 |
+ if (r.url().includes('/api/v1/') && r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(BASE.length, BASE.length + 100)}`); |
|
64 |
+ }); |
|
65 |
+ for (const path of ROUTES) { |
|
66 |
+ let status = 0; |
|
67 |
+ try { |
|
68 |
+ const resp = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90_000 }); |
|
69 |
+ status = resp?.status() ?? 0; |
|
70 |
+ } catch (e) { |
|
71 |
+ report.push({ path, width, theme, status: 'ERR', error: String(e).slice(0, 200) }); |
|
72 |
+ continue; |
|
73 |
+ } |
|
74 |
+ await page.evaluate(() => document.fonts.ready); |
|
75 |
+ await page.waitForTimeout(500); |
|
76 |
+ const full = await page.evaluate(() => document.querySelector('main')?.getAttribute('data-layout') === 'full'); |
|
77 |
+ if (!full) { |
|
78 |
+ await page.evaluate(async () => { |
|
79 |
+ const h = document.documentElement.scrollHeight; |
|
80 |
+ for (let y = 0; y < h; y += 800) { |
|
81 |
+ window.scrollTo(0, y); |
|
82 |
+ await new Promise((r) => setTimeout(r, 40)); |
|
83 |
+ } |
|
84 |
+ window.scrollTo(0, 0); |
|
85 |
+ }); |
|
86 |
+ await page.waitForLoadState('networkidle').catch(() => {}); |
|
87 |
+ await page.waitForTimeout(400); |
|
88 |
+ } |
|
89 |
+ const m = await page.evaluate( |
|
90 |
+ ({ mobile }) => { |
|
91 |
+ const de = document.documentElement; |
|
92 |
+ const overflow = de.scrollWidth - de.clientWidth; |
|
93 |
+ const wide = [...document.querySelectorAll('body *')] |
|
94 |
+ .filter((el) => { |
|
95 |
+ const r = el.getBoundingClientRect(); |
|
96 |
+ if (!(r.right > de.clientWidth + 1 && r.width > 0)) return false; |
|
97 |
+ // inside an intentional horizontal scroller? |
|
98 |
+ let p = el.parentElement; |
|
99 |
+ while (p) { |
|
100 |
+ const cs = getComputedStyle(p); |
|
101 |
+ if (/(auto|scroll)/.test(cs.overflowX)) return false; |
|
102 |
+ p = p.parentElement; |
|
103 |
+ } |
|
104 |
+ return true; |
|
105 |
+ }) |
|
106 |
+ .slice(0, 5) |
|
107 |
+ .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`); |
|
108 |
+ const vis = (el) => { |
|
109 |
+ const r = el.getBoundingClientRect(); |
|
110 |
+ if (r.width === 0 || r.height === 0) return false; |
|
111 |
+ const cs = getComputedStyle(el); |
|
112 |
+ return cs.visibility !== 'hidden' && cs.display !== 'none'; |
|
113 |
+ }; |
|
114 |
+ const small = mobile |
|
115 |
+ ? [...document.querySelectorAll('a,button,[role=button],select,summary,[role=radio],[role=tab]')] |
|
116 |
+ .filter(vis) |
|
117 |
+ .filter((el) => { |
|
118 |
+ const r = el.getBoundingClientRect(); |
|
119 |
+ if (!(r.height < 40 && r.width < 40)) return false; |
|
120 |
+ if (el.closest('svg')) return false; |
|
121 |
+ return true; |
|
122 |
+ }) |
|
123 |
+ .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)}`) |
|
124 |
+ : []; |
|
125 |
+ const text = document.body.innerText || ''; |
|
126 |
+ const bad = []; |
|
127 |
+ for (const re of [/\bundefined\b/g, /\bNaN\b/g, /(?<![\w"':/])null(?![\w"':])/g, /\[object Object\]/g]) { |
|
128 |
+ let mm; |
|
129 |
+ let n = 0; |
|
130 |
+ while ((mm = re.exec(text)) && n < 3) { |
|
131 |
+ bad.push(`${mm[0]} @ "${text.slice(Math.max(0, mm.index - 40), mm.index + 30).replace(/\s+/g, ' ')}"`); |
|
132 |
+ n++; |
|
133 |
+ } |
|
134 |
+ } |
|
135 |
+ const footer = document.querySelector('footer'); |
|
136 |
+ const ft = footer ? footer.innerText : ''; |
|
137 |
+ const credits = /Simon-Pierre Boucher/.test(ft) && /contact@spboucher\.ai/.test(ft) && /MacLustr/.test(ft) && !!footer?.querySelector('a[href="https://www.maclustr.io"]'); |
|
138 |
+ const hrefs = [...new Set([...document.querySelectorAll('a[href^="/"]')].map((a) => a.getAttribute('href')).filter((h) => h && !h.startsWith('/api/v1/') && !h.startsWith('//')))]; |
|
139 |
+ const keys = new Set(); |
|
140 |
+ return { overflow, wide, small: small.slice(0, 8), nSmall: small.length, bad, credits, hrefs: hrefs.slice(0, 80), title: document.title, docH: de.scrollHeight, full: !!document.querySelector('main[data-layout="full"]') }; |
|
141 |
+ }, |
|
142 |
+ { mobile }, |
|
143 |
+ ); |
|
144 |
+ const badLinks = theme === 'light' && (width === 1440 || width === 390) ? await checkLinks(page, m.hrefs) : []; |
|
145 |
+ const file = `${slug(path)}-${width}-${theme}.png`; |
|
146 |
+ await page.screenshot({ path: OUT + file, fullPage: !m.full }).catch(() => {}); |
|
147 |
+ report.push({ path, width, theme, status, ...m, hrefs: undefined, badLinks, errors: errors.splice(0), failed: failed.splice(0), file }); |
|
148 |
+ writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2)); |
|
149 |
+ } |
|
150 |
+ await ctx.close(); |
|
151 |
+ } |
|
152 |
+} |
|
153 |
+await browser.close(); |
|
154 |
+ |
|
155 |
+let fails = 0; |
|
156 |
+for (const r of report) { |
|
157 |
+ const flags = []; |
|
158 |
+ if (r.status !== 200) flags.push(`HTTP ${r.status}`); |
|
159 |
+ if (r.overflow > 0) flags.push(`OVERFLOW +${r.overflow}px`); |
|
160 |
+ if (r.wide?.length) flags.push(`${r.wide.length} wide`); |
|
161 |
+ if (r.nSmall) flags.push(`${r.nSmall} small`); |
|
162 |
+ if (r.bad?.length) flags.push(`BAD TEXT ${r.bad.length}`); |
|
163 |
+ if (r.errors?.length) flags.push(`${r.errors.length} console`); |
|
164 |
+ if (r.failed?.length) flags.push(`${r.failed.length} failed API`); |
|
165 |
+ if (r.credits === false) flags.push('NO CREDITS'); |
|
166 |
+ if (r.badLinks?.length) flags.push(`${r.badLinks.length} broken links`); |
|
167 |
+ if (flags.length) fails++; |
|
168 |
+ console.log(`${String(r.width).padStart(4)} ${(r.theme || '').padEnd(5)} ${r.path.padEnd(60)} ${flags.join(' · ') || 'ok'}`); |
|
169 |
+ if (r.wide?.length) console.log(' wide:', r.wide.join(' | ')); |
|
170 |
+ if (r.small?.length) console.log(' small:', r.small.slice(0, 4).join(' | ')); |
|
171 |
+ if (r.bad?.length) console.log(' bad:', r.bad.join(' | ')); |
|
172 |
+ if (r.errors?.length) console.log(' errors:', r.errors.slice(0, 3).join(' | ')); |
|
173 |
+ if (r.failed?.length) console.log(' failed:', r.failed.slice(0, 3).join(' | ')); |
|
174 |
+ if (r.badLinks?.length) console.log(' links:', r.badLinks.slice(0, 5).join(' | ')); |
|
175 |
+} |
|
176 |
+console.log(`\n${report.length} renders, ${fails} with flags, ${linkStatus.size} internal links checked`); |