// Full UX QA sweep: node apps/web/qa/sweep.mjs (Playwright from ~/Desktop/uqo-eval) // env: BASE (default http://localhost:8314), OUT (default qa/sweep), PAGES=name,name (subset), WIDTHS=…, QUICK=1 (390+1280 only) // Writes screenshots + results.json; prints a page × width matrix and every finding. import { createRequire } from "node:module"; import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const require = createRequire("/Users/simon-pierreboucher/Desktop/uqo-eval/package.json"); const { chromium } = require("playwright"); const here = dirname(fileURLToPath(import.meta.url)); const OUT = process.env.OUT ?? resolve(here, "sweep"); mkdirSync(OUT, { recursive: true }); const BASE = process.env.BASE ?? "http://localhost:8314"; export const PAGES = [ ["home", "/"], ["map", "/map"], ["search", "/search?q=montreal"], ["live", "/live"], ["datacenters", "/datacenters"], ["fac-operator-mw", "/datacenters/databank-downtown-minneapolis-data-center-msp4"], ["fac-osm", "/datacenters/shelter-modernizacion"], ["fac-google", "/datacenters/google-the-dalles-oregon-data-center"], ["fac-wikidata", "/datacenters/foulum-data-center"], ["operators", "/operators"], ["operator-equinix", "/operators/equinix"], ["countries", "/countries"], ["country-us", "/countries/united-states"], ["country-ca", "/countries/canada"], ["markets", "/datacenters/markets"], ["market-nova", "/datacenters/markets/northern-virginia"], ["projects", "/projects"], ["project", "/projects/sydney-data-center-project"], ["rankings", "/rankings"], ["ranking", "/rankings/countries_facilities"], ["cloud-regions", "/cloud-regions"], ["cloud-region", "/cloud-regions/amazon-web-services-ca-central-1"], ["ixps", "/ixps"], ["sources", "/sources"], ["methodology", "/methodology"], ["about", "/about"], ["bot", "/bot"], // intelligence graph (2026-09-12) ["ixp", "/ixps/de-cix-new-york"], ["ai", "/ai-infrastructure"], ["pulse", "/pulse"], ["pulse-30d", "/pulse?window=30d"], ["explore", "/explore?entity=facilities&country=US&view=charts"], ["explore-projects", "/explore?entity=projects&view=table"], ["compare", "/compare/operators?slugs=equinix,digital-realty"], ["compare-empty", "/compare/operators"], ["coverage", "/coverage"], ["coverage-sources", "/coverage/sources"], ["power", "/power"], ["connectivity", "/connectivity"], ["download", "/download"], ["developers", "/developers"], ["watchlist", "/watchlist"], ["404", "/this-page-does-not-exist"], ]; // EXTRA_PAGES="name=/path,name2=/path2" appends ad-hoc pages (QA of a route before it joins the list above) for (const pair of (process.env.EXTRA_PAGES ?? "").split(",").filter(Boolean)) { const i = pair.indexOf("="); if (i > 0) PAGES.push([pair.slice(0, i), pair.slice(i + 1)]); } const ONLY = process.env.PAGES ? new Set(process.env.PAGES.split(",")) : null; const LIGHT_W = (process.env.WIDTHS ?? (process.env.QUICK ? "390,1280" : "320,360,375,390,414,430,768,1280")).split(",").map(Number); const VARIANTS = [...LIGHT_W.map((w) => ({ w, theme: "light" })), { w: 390, theme: "dark" }, { w: 1280, theme: "dark" }]; const FULL_SHOT = new Set([390, 1280]); /* ---------------------------------------------------------------- in-page audit */ function audit({ mobile, dark }) { const doc = document.documentElement; const vw = doc.clientWidth; const vis = (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; // visually-hidden (sr-only) content is not rendered text return !(cs.clip === "rect(0px, 0px, 0px, 0px)" && cs.position === "absolute"); }; const tag = (el) => `${el.tagName.toLowerCase()}${el.id ? "#" + el.id : ""}.${(typeof el.className === "string" ? el.className : "").split(" ").filter(Boolean).slice(0, 3).join(".")}`; // 1. horizontal overflow const wide = []; for (const el of document.querySelectorAll("body *")) { const r = el.getBoundingClientRect(); if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== "fixed") wide.push(`${tag(el)}:+${Math.round(r.right - vw)}`); } const overflow = doc.scrollWidth > doc.clientWidth ? { scrollWidth: doc.scrollWidth, clientWidth: doc.clientWidth, wide: wide.slice(0, 6) } : null; // 2. clipped text (overflow hidden without ellipsis) const clipped = []; for (const el of document.querySelectorAll("body *")) { if (!el.childNodes.length || clipped.length > 8) continue; const own = Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent.trim()); if (!own || !vis(el)) continue; const cs = getComputedStyle(el); const hid = ["hidden", "clip"].includes(cs.overflowX) || ["hidden", "clip"].includes(cs.overflowY); if (!hid || cs.textOverflow === "ellipsis" || cs.webkitLineClamp !== "none") continue; if (el.scrollWidth > el.clientWidth + 2 || el.scrollHeight > el.clientHeight + 4) clipped.push(`${tag(el)} "${el.textContent.trim().slice(0, 40)}" ${el.scrollWidth}>${el.clientWidth}`); } // 3. tap targets (mobile) — non-inline interactive elements below 40 px const small = []; if (mobile) { for (const el of document.querySelectorAll("a, button, [role=button], input, select, [role=tab]")) { if (!vis(el)) continue; const cs = getComputedStyle(el); if (cs.display === "inline" || cs.display === "contents") continue; // inline text links are exempt (WCAG 2.5.8 inline exception) const r = el.getBoundingClientRect(); if (r.height < 40 || r.width < 40) small.push(`${tag(el)} ${Math.round(r.width)}×${Math.round(r.height)} "${(el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 24)}"`); } } // 4. sticky header must not cover the h1 at load const header = document.querySelector("header"); const h1 = document.querySelector("main h1, h1"); const hb = header ? header.getBoundingClientRect().bottom : 0; const h1Top = h1 ? h1.getBoundingClientRect().top : null; const h1Covered = h1 && getComputedStyle(header).position === "sticky" ? h1Top < hb - 1 : false; // 5. a11y basics const lang = doc.getAttribute("lang"); const metaDesc = document.querySelector('meta[name="description"]')?.getAttribute("content") ?? null; const hs = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")).filter(vis).map((h) => Number(h.tagName[1])); const h1Count = hs.filter((n) => n === 1).length; let skipped = []; for (let i = 1; i < hs.length; i++) if (hs[i] > hs[i - 1] + 1) skipped.push(`h${hs[i - 1]}→h${hs[i]}`); const unnamed = []; for (const b of document.querySelectorAll("button, a")) { if (!vis(b)) continue; const name = (b.getAttribute("aria-label") || b.getAttribute("title") || b.textContent || "").trim(); const labelled = b.getAttribute("aria-labelledby") || b.querySelector("img[alt], svg[aria-label], [role=img][aria-label]"); if (!name && !labelled) unnamed.push(tag(b)); } const ariaCurrent = document.querySelectorAll("nav a[aria-current=page]").length; const text = document.body.innerText; const footerOk = text.includes("Simon-Pierre Boucher") && text.includes("contact@spboucher.ai") && text.includes("MacLustr"); // 6. contrast (dark only, small text < 18 px) — effective background from the nearest opaque ancestor const contrast = []; let contrastChecked = 0; if (dark) { const parse = (c) => { const m = c.match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(",").map((x) => parseFloat(x)); return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; }; const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const bgOf = (el) => { let e = el; while (e && e !== document.documentElement) { const c = parse(getComputedStyle(e).backgroundColor); if (c && c.a >= 0.9) return c; e = e.parentElement; } return parse(getComputedStyle(document.body).backgroundColor) || { r: 11, g: 15, b: 20, a: 1 }; }; const seen = new Map(); for (const el of document.querySelectorAll("main *, footer *, header *")) { if (contrastChecked > 1500) break; const own = Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent.trim().length > 1); if (!own || !vis(el) || el.closest("[aria-hidden=true], svg, kbd, .maplibregl-map")) continue; const cs = getComputedStyle(el); const fs = parseFloat(cs.fontSize); if (fs >= 18 || (fs >= 14 && parseInt(cs.fontWeight) >= 700)) continue; const fg = parse(cs.color); if (!fg || fg.a < 0.5) continue; const bg = bgOf(el); const l1 = lum(fg), l2 = lum(bg); const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); contrastChecked++; if (ratio < 4.5) { const key = `${cs.color}|${tag(el).split(".").slice(1, 3).join(".")}`; if (!seen.has(key)) seen.set(key, { ratio: Math.round(ratio * 100) / 100, color: cs.color, bg: `rgb(${bg.r},${bg.g},${bg.b})`, sample: `${tag(el)} "${el.textContent.trim().slice(0, 30)}"`, n: 0 }); seen.get(key).n++; } } contrast.push(...seen.values()); } // 7. bottom nav / footer visibility after scrolling to the end (mobile) return { overflow, clipped, small: small.slice(0, 12), smallCount: small.length, h1Covered, h1Top: h1Top === null ? null : Math.round(h1Top), headerBottom: Math.round(hb), lang, metaDesc: metaDesc ? metaDesc.length : null, h1Count, skipped, unnamed: unnamed.slice(0, 6), ariaCurrent, footerOk, contrast: contrast.sort((a, b) => a.ratio - b.ratio).slice(0, 8), contrastChecked, height: doc.scrollHeight, title: document.title }; } function bottomCheck() { window.scrollTo(0, document.documentElement.scrollHeight); const nav = document.querySelector('nav[aria-label="Mobile"]'); const foot = document.querySelector("footer p:last-of-type") || document.querySelector("footer"); if (!nav || !foot) return { navVisible: !!nav && getComputedStyle(nav).display !== "none", covered: false }; const nr = nav.getBoundingClientRect(); const fr = foot.getBoundingClientRect(); const navVisible = getComputedStyle(nav).display !== "none"; return { navVisible, covered: navVisible && fr.bottom > nr.top + 1, gap: Math.round(nr.top - fr.bottom) }; } function mapControls() { const nav = document.querySelector('nav[aria-label="Mobile"]'); const navTop = nav && getComputedStyle(nav).display !== "none" ? nav.getBoundingClientRect().top : window.innerHeight; const header = document.querySelector("header"); const hb = header ? header.getBoundingClientRect().bottom : 0; const btns = Array.from(document.querySelectorAll(".maplibregl-ctrl button, [data-map-control] button, [data-map-control]")).filter((b) => b.getBoundingClientRect().width > 0); const bad = btns.filter((b) => { const r = b.getBoundingClientRect(); return r.bottom > navTop || r.top < hb || r.right > window.innerWidth || r.left < 0; }); return { controls: btns.length, unreachable: bad.length, canvas: !!document.querySelector("canvas.maplibregl-canvas, .maplibregl-canvas") }; } /* ---------------------------------------------------------------- run */ const browser = await chromium.launch(); const results = []; const ttfb = {}; for (const v of VARIANTS) { const mobile = v.w < 700; const ctx = await browser.newContext({ viewport: { width: v.w, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: v.theme, locale: "en-CA", timezoneId: "America/Toronto" }); await ctx.addInitScript((theme) => { try { window.localStorage.setItem("theme", theme); } catch {} }, v.theme); const page = await ctx.newPage(); for (const [name, path] of PAGES) { if (ONLY && !ONLY.has(name)) continue; const errors = []; const bad = []; const onConsole = (m) => { if (m.type() === "error") errors.push(m.text().slice(0, 240)); }; const onErr = (e) => errors.push("pageerror " + String(e).slice(0, 240)); const onResp = (r) => { if (r.status() >= 400 && !(name === "404" && r.request().resourceType() === "document")) bad.push(`${r.status()} ${r.request().resourceType()} ${r.url().replace(BASE, "").slice(0, 100)}`); }; page.on("console", onConsole); page.on("pageerror", onErr); page.on("response", onResp); const row = { name, path, w: v.w, theme: v.theme }; try { const resp = await page.goto(`${BASE}${path}`, { waitUntil: "load", timeout: 90000 }); row.status = resp?.status(); await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); await page.waitForTimeout(name === "map" ? 3000 : 700); const nav = await page.evaluate(() => { const n = performance.getEntriesByType("navigation")[0]; return n ? Math.round(n.responseStart - n.requestStart) : null; }); (ttfb[path] ??= []).push(nav); row.ttfb = nav; Object.assign(row, await page.evaluate(audit, { mobile, dark: v.theme === "dark" })); if (name === "map") row.map = await page.evaluate(mapControls); if (mobile && name !== "map") row.bottom = await page.evaluate(bottomCheck); await page.evaluate(() => window.scrollTo(0, 0)); await page.waitForTimeout(150); const shot = resolve(OUT, `${name}-${v.w}-${v.theme}.png`); await page.screenshot({ path: shot, fullPage: FULL_SHOT.has(v.w) && name !== "map" && row.height < 20000 }); // interactions (390 + 1280 light only) if (v.theme === "light" && FULL_SHOT.has(v.w)) { const inter = {}; if (name === "home") { await page.keyboard.press(process.platform === "darwin" ? "Meta+k" : "Control+k"); const dialog = page.locator('[role=dialog][aria-label="Search the index"]'); await dialog.waitFor({ state: "visible", timeout: 2000 }).catch(() => {}); inter.paletteOpens = await dialog.isVisible().catch(() => false); await page.waitForTimeout(150); // input receives focus ~10 ms after open await page.keyboard.press("Escape"); await dialog.waitFor({ state: "hidden", timeout: 2000 }).catch(() => {}); inter.paletteCloses = !(await dialog.isVisible().catch(() => false)); if (!inter.paletteCloses) await page.getByRole("button", { name: "Close search" }).click().catch(() => {}); const before = await page.evaluate(() => document.documentElement.className); await page.locator('button[aria-label*="theme" i]').first().click(); await page.waitForTimeout(200); const after = await page.evaluate(() => document.documentElement.className); inter.themeToggles = before !== after; await page.locator('button[aria-label*="theme" i]').first().click(); // focus ring await page.keyboard.press("Tab"); await page.keyboard.press("Tab"); inter.focusVisible = await page.evaluate(() => { const a = document.activeElement; if (!a || a === document.body) return null; const cs = getComputedStyle(a); return { el: a.tagName.toLowerCase() + (a.getAttribute("aria-label") ? `[${a.getAttribute("aria-label")}]` : ""), outline: cs.outlineStyle !== "none" && parseFloat(cs.outlineWidth) > 0, ring: cs.boxShadow !== "none" }; }); } if (name === "datacenters" || name === "live" || name === "projects") { const btn = page.getByRole("button", { name: /filters/i }).first(); if (await btn.count()) { await btn.click(); inter.filterSheetOpens = await page.locator("[role=dialog]").first().isVisible({ timeout: 2000 }).catch(() => false); await page.keyboard.press("Escape"); await page.waitForTimeout(300); inter.filterSheetCloses = !(await page.locator("[role=dialog]").first().isVisible().catch(() => false)); } } if (name === "datacenters" || name === "operators" || name === "projects") { const next = page.locator('a[aria-label="Next page"]').first(); if (await next.count()) { await next.click(); await page.waitForURL(/page=2/, { timeout: 15000 }).catch(() => {}); inter.paginationPage2 = /page=2/.test(page.url()); await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {}); inter.page2Rows = await page.locator("table tbody tr").count(); } } if (name === "country-us" && mobile) { const show = page.locator("#facilities").getByRole("button", { name: /show all/i }).first(); if (await show.count()) { await show.scrollIntoViewIfNeeded(); const before = await page.locator("#facilities tbody tr:visible").count().catch(() => null); await show.click(); await page.waitForTimeout(200); const after = await page.locator("#facilities tbody tr:visible").count().catch(() => null); inter.showAll = { before, after, expanded: (await show.getAttribute("aria-expanded")) === "true" }; } } const tabs = page.locator("[role=tablist] [role=tab]"); if ((await tabs.count()) >= 2) { await tabs.nth(1).click(); await page.waitForTimeout(200); inter.tabSwitch = (await tabs.nth(1).getAttribute("aria-selected")) === "true"; } if (name === "map") { const zoomIn = page.locator(".maplibregl-ctrl-zoom-in").first(); inter.zoomVisible = (await zoomIn.count()) ? await zoomIn.isVisible() : null; const searchBtn = page.getByRole("button", { name: /search|filter/i }).first(); if (await searchBtn.count()) { await searchBtn.click().catch(() => {}); await page.waitForTimeout(500); inter.mapPanelOpens = await page.locator("[role=dialog]").first().isVisible().catch(() => false); await page.keyboard.press("Escape"); } } if (Object.keys(inter).length) row.inter = inter; } } catch (e) { row.error = String(e).slice(0, 200); } row.errors = [...new Set(errors)]; row.badResponses = [...new Set(bad)]; page.off("console", onConsole); page.off("pageerror", onErr); page.off("response", onResp); results.push(row); const flags = []; if (row.overflow) flags.push("OVERFLOW"); if (row.clipped?.length) flags.push(`clipped:${row.clipped.length}`); if (row.smallCount) flags.push(`tap<40:${row.smallCount}`); if (row.h1Covered) flags.push("h1-covered"); if (row.errors.length) flags.push(`console:${row.errors.length}`); if (row.badResponses.length) flags.push(`http4xx5xx:${row.badResponses.length}`); if (row.bottom?.covered) flags.push("footer-under-nav"); if (row.contrast?.length) flags.push(`contrast<4.5:${row.contrast.length}`); if (row.footerOk === false && name !== "map") flags.push("no-footer-credits"); if (row.h1Count !== 1) flags.push(`h1×${row.h1Count}`); if (row.skipped?.length) flags.push(`heading-skip:${row.skipped.join(",")}`); if (row.unnamed?.length) flags.push(`unnamed:${row.unnamed.length}`); if (row.metaDesc === null) flags.push("no-meta-desc"); if (row.error) flags.push("ERROR"); console.log(`${String(v.w).padStart(4)} ${v.theme.padEnd(5)} ${name.padEnd(16)} ${String(row.status ?? "").padEnd(3)} h=${String(row.height ?? "").padStart(5)} ttfb=${String(row.ttfb ?? "").padStart(4)}ms ${flags.length ? flags.join(" ") : "ok"}`); } await ctx.close(); } await browser.close(); writeFileSync(resolve(OUT, "results.json"), JSON.stringify({ base: BASE, at: new Date().toISOString(), results }, null, 1)); console.log(`\nwrote ${OUT}/results.json (${results.length} rows)`);