spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1// Full UX QA sweep: node apps/web/qa/sweep.mjs (Playwright from ~/Desktop/uqo-eval)2// env: BASE (default http://localhost:8314), OUT (default qa/sweep), PAGES=name,name (subset), WIDTHS=…, QUICK=1 (390+1280 only)3// Writes screenshots + results.json; prints a page × width matrix and every finding.4import { createRequire } from "node:module";5import { mkdirSync, writeFileSync } from "node:fs";6import { dirname, resolve } from "node:path";7import { fileURLToPath } from "node:url";89const require = createRequire("/Users/simon-pierreboucher/Desktop/uqo-eval/package.json");10const { chromium } = require("playwright");11const here = dirname(fileURLToPath(import.meta.url));12const OUT = process.env.OUT ?? resolve(here, "sweep");13mkdirSync(OUT, { recursive: true });14const BASE = process.env.BASE ?? "http://localhost:8314";1516export const PAGES = [17 ["home", "/"],18 ["map", "/map"],19 ["search", "/search?q=montreal"],20 ["live", "/live"],21 ["datacenters", "/datacenters"],22 ["fac-operator-mw", "/datacenters/databank-downtown-minneapolis-data-center-msp4"],23 ["fac-osm", "/datacenters/shelter-modernizacion"],24 ["fac-google", "/datacenters/google-the-dalles-oregon-data-center"],25 ["fac-wikidata", "/datacenters/foulum-data-center"],26 ["operators", "/operators"],27 ["operator-equinix", "/operators/equinix"],28 ["countries", "/countries"],29 ["country-us", "/countries/united-states"],30 ["country-ca", "/countries/canada"],31 ["markets", "/datacenters/markets"],32 ["market-nova", "/datacenters/markets/northern-virginia"],33 ["projects", "/projects"],34 ["project", "/projects/sydney-data-center-project"],35 ["rankings", "/rankings"],36 ["ranking", "/rankings/countries_facilities"],37 ["cloud-regions", "/cloud-regions"],38 ["cloud-region", "/cloud-regions/amazon-web-services-ca-central-1"],39 ["ixps", "/ixps"],40 ["sources", "/sources"],41 ["methodology", "/methodology"],42 ["about", "/about"],43 ["bot", "/bot"],44 // intelligence graph (2026-09-12)45 ["ixp", "/ixps/de-cix-new-york"],46 ["ai", "/ai-infrastructure"],47 ["pulse", "/pulse"],48 ["pulse-30d", "/pulse?window=30d"],49 ["explore", "/explore?entity=facilities&country=US&view=charts"],50 ["explore-projects", "/explore?entity=projects&view=table"],51 ["compare", "/compare/operators?slugs=equinix,digital-realty"],52 ["compare-empty", "/compare/operators"],53 ["coverage", "/coverage"],54 ["coverage-sources", "/coverage/sources"],55 ["power", "/power"],56 ["connectivity", "/connectivity"],57 ["download", "/download"],58 ["developers", "/developers"],59 ["watchlist", "/watchlist"],60 ["404", "/this-page-does-not-exist"],61];62// EXTRA_PAGES="name=/path,name2=/path2" appends ad-hoc pages (QA of a route before it joins the list above)63for (const pair of (process.env.EXTRA_PAGES ?? "").split(",").filter(Boolean)) {64 const i = pair.indexOf("=");65 if (i > 0) PAGES.push([pair.slice(0, i), pair.slice(i + 1)]);66}67const ONLY = process.env.PAGES ? new Set(process.env.PAGES.split(",")) : null;68const LIGHT_W = (process.env.WIDTHS ?? (process.env.QUICK ? "390,1280" : "320,360,375,390,414,430,768,1280")).split(",").map(Number);69const VARIANTS = [...LIGHT_W.map((w) => ({ w, theme: "light" })), { w: 390, theme: "dark" }, { w: 1280, theme: "dark" }];70const FULL_SHOT = new Set([390, 1280]);7172/* ---------------------------------------------------------------- in-page audit */73function audit({ mobile, dark }) {74 const doc = document.documentElement;75 const vw = doc.clientWidth;76 const vis = (el) => {77 const r = el.getBoundingClientRect();78 if (r.width <= 0 || r.height <= 0) return false;79 const cs = getComputedStyle(el);80 if (cs.visibility === "hidden" || cs.display === "none") return false;81 // visually-hidden (sr-only) content is not rendered text82 return !(cs.clip === "rect(0px, 0px, 0px, 0px)" && cs.position === "absolute");83 };84 const tag = (el) => `${el.tagName.toLowerCase()}${el.id ? "#" + el.id : ""}.${(typeof el.className === "string" ? el.className : "").split(" ").filter(Boolean).slice(0, 3).join(".")}`;8586 // 1. horizontal overflow87 const wide = [];88 for (const el of document.querySelectorAll("body *")) {89 const r = el.getBoundingClientRect();90 if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== "fixed") wide.push(`${tag(el)}:+${Math.round(r.right - vw)}`);91 }92 const overflow = doc.scrollWidth > doc.clientWidth ? { scrollWidth: doc.scrollWidth, clientWidth: doc.clientWidth, wide: wide.slice(0, 6) } : null;9394 // 2. clipped text (overflow hidden without ellipsis)95 const clipped = [];96 for (const el of document.querySelectorAll("body *")) {97 if (!el.childNodes.length || clipped.length > 8) continue;98 const own = Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent.trim());99 if (!own || !vis(el)) continue;100 const cs = getComputedStyle(el);101 const hid = ["hidden", "clip"].includes(cs.overflowX) || ["hidden", "clip"].includes(cs.overflowY);102 if (!hid || cs.textOverflow === "ellipsis" || cs.webkitLineClamp !== "none") continue;103 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}`);104 }105106 // 3. tap targets (mobile) — non-inline interactive elements below 40 px107 const small = [];108 if (mobile) {109 for (const el of document.querySelectorAll("a, button, [role=button], input, select, [role=tab]")) {110 if (!vis(el)) continue;111 const cs = getComputedStyle(el);112 if (cs.display === "inline" || cs.display === "contents") continue; // inline text links are exempt (WCAG 2.5.8 inline exception)113 const r = el.getBoundingClientRect();114 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)}"`);115 }116 }117118 // 4. sticky header must not cover the h1 at load119 const header = document.querySelector("header");120 const h1 = document.querySelector("main h1, h1");121 const hb = header ? header.getBoundingClientRect().bottom : 0;122 const h1Top = h1 ? h1.getBoundingClientRect().top : null;123 const h1Covered = h1 && getComputedStyle(header).position === "sticky" ? h1Top < hb - 1 : false;124125 // 5. a11y basics126 const lang = doc.getAttribute("lang");127 const metaDesc = document.querySelector('meta[name="description"]')?.getAttribute("content") ?? null;128 const hs = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")).filter(vis).map((h) => Number(h.tagName[1]));129 const h1Count = hs.filter((n) => n === 1).length;130 let skipped = [];131 for (let i = 1; i < hs.length; i++) if (hs[i] > hs[i - 1] + 1) skipped.push(`h${hs[i - 1]}→h${hs[i]}`);132 const unnamed = [];133 for (const b of document.querySelectorAll("button, a")) {134 if (!vis(b)) continue;135 const name = (b.getAttribute("aria-label") || b.getAttribute("title") || b.textContent || "").trim();136 const labelled = b.getAttribute("aria-labelledby") || b.querySelector("img[alt], svg[aria-label], [role=img][aria-label]");137 if (!name && !labelled) unnamed.push(tag(b));138 }139 const ariaCurrent = document.querySelectorAll("nav a[aria-current=page]").length;140 const text = document.body.innerText;141 const footerOk = text.includes("Simon-Pierre Boucher") && text.includes("contact@spboucher.ai") && text.includes("MacLustr");142143 // 6. contrast (dark only, small text < 18 px) — effective background from the nearest opaque ancestor144 const contrast = [];145 let contrastChecked = 0;146 if (dark) {147 const parse = (c) => {148 const m = c.match(/rgba?\(([^)]+)\)/);149 if (!m) return null;150 const p = m[1].split(",").map((x) => parseFloat(x));151 return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };152 };153 const lum = ({ r, g, b }) => {154 const f = (v) => {155 v /= 255;156 return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;157 };158 return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);159 };160 const bgOf = (el) => {161 let e = el;162 while (e && e !== document.documentElement) {163 const c = parse(getComputedStyle(e).backgroundColor);164 if (c && c.a >= 0.9) return c;165 e = e.parentElement;166 }167 return parse(getComputedStyle(document.body).backgroundColor) || { r: 11, g: 15, b: 20, a: 1 };168 };169 const seen = new Map();170 for (const el of document.querySelectorAll("main *, footer *, header *")) {171 if (contrastChecked > 1500) break;172 const own = Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent.trim().length > 1);173 if (!own || !vis(el) || el.closest("[aria-hidden=true], svg, kbd, .maplibregl-map")) continue;174 const cs = getComputedStyle(el);175 const fs = parseFloat(cs.fontSize);176 if (fs >= 18 || (fs >= 14 && parseInt(cs.fontWeight) >= 700)) continue;177 const fg = parse(cs.color);178 if (!fg || fg.a < 0.5) continue;179 const bg = bgOf(el);180 const l1 = lum(fg), l2 = lum(bg);181 const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);182 contrastChecked++;183 if (ratio < 4.5) {184 const key = `${cs.color}|${tag(el).split(".").slice(1, 3).join(".")}`;185 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 });186 seen.get(key).n++;187 }188 }189 contrast.push(...seen.values());190 }191192 // 7. bottom nav / footer visibility after scrolling to the end (mobile)193 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 };194}195196function bottomCheck() {197 window.scrollTo(0, document.documentElement.scrollHeight);198 const nav = document.querySelector('nav[aria-label="Mobile"]');199 const foot = document.querySelector("footer p:last-of-type") || document.querySelector("footer");200 if (!nav || !foot) return { navVisible: !!nav && getComputedStyle(nav).display !== "none", covered: false };201 const nr = nav.getBoundingClientRect();202 const fr = foot.getBoundingClientRect();203 const navVisible = getComputedStyle(nav).display !== "none";204 return { navVisible, covered: navVisible && fr.bottom > nr.top + 1, gap: Math.round(nr.top - fr.bottom) };205}206207function mapControls() {208 const nav = document.querySelector('nav[aria-label="Mobile"]');209 const navTop = nav && getComputedStyle(nav).display !== "none" ? nav.getBoundingClientRect().top : window.innerHeight;210 const header = document.querySelector("header");211 const hb = header ? header.getBoundingClientRect().bottom : 0;212 const btns = Array.from(document.querySelectorAll(".maplibregl-ctrl button, [data-map-control] button, [data-map-control]")).filter((b) => b.getBoundingClientRect().width > 0);213 const bad = btns.filter((b) => {214 const r = b.getBoundingClientRect();215 return r.bottom > navTop || r.top < hb || r.right > window.innerWidth || r.left < 0;216 });217 return { controls: btns.length, unreachable: bad.length, canvas: !!document.querySelector("canvas.maplibregl-canvas, .maplibregl-canvas") };218}219220/* ---------------------------------------------------------------- run */221const browser = await chromium.launch();222const results = [];223const ttfb = {};224for (const v of VARIANTS) {225 const mobile = v.w < 700;226 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" });227 await ctx.addInitScript((theme) => {228 try {229 window.localStorage.setItem("theme", theme);230 } catch {}231 }, v.theme);232 const page = await ctx.newPage();233 for (const [name, path] of PAGES) {234 if (ONLY && !ONLY.has(name)) continue;235 const errors = [];236 const bad = [];237 const onConsole = (m) => {238 if (m.type() === "error") errors.push(m.text().slice(0, 240));239 };240 const onErr = (e) => errors.push("pageerror " + String(e).slice(0, 240));241 const onResp = (r) => {242 if (r.status() >= 400 && !(name === "404" && r.request().resourceType() === "document")) bad.push(`${r.status()} ${r.request().resourceType()} ${r.url().replace(BASE, "").slice(0, 100)}`);243 };244 page.on("console", onConsole);245 page.on("pageerror", onErr);246 page.on("response", onResp);247 const row = { name, path, w: v.w, theme: v.theme };248 try {249 const resp = await page.goto(`${BASE}${path}`, { waitUntil: "load", timeout: 90000 });250 row.status = resp?.status();251 await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});252 await page.waitForTimeout(name === "map" ? 3000 : 700);253 const nav = await page.evaluate(() => {254 const n = performance.getEntriesByType("navigation")[0];255 return n ? Math.round(n.responseStart - n.requestStart) : null;256 });257 (ttfb[path] ??= []).push(nav);258 row.ttfb = nav;259 Object.assign(row, await page.evaluate(audit, { mobile, dark: v.theme === "dark" }));260 if (name === "map") row.map = await page.evaluate(mapControls);261 if (mobile && name !== "map") row.bottom = await page.evaluate(bottomCheck);262 await page.evaluate(() => window.scrollTo(0, 0));263 await page.waitForTimeout(150);264 const shot = resolve(OUT, `${name}-${v.w}-${v.theme}.png`);265 await page.screenshot({ path: shot, fullPage: FULL_SHOT.has(v.w) && name !== "map" && row.height < 20000 });266267 // interactions (390 + 1280 light only)268 if (v.theme === "light" && FULL_SHOT.has(v.w)) {269 const inter = {};270 if (name === "home") {271 await page.keyboard.press(process.platform === "darwin" ? "Meta+k" : "Control+k");272 const dialog = page.locator('[role=dialog][aria-label="Search the index"]');273 await dialog.waitFor({ state: "visible", timeout: 2000 }).catch(() => {});274 inter.paletteOpens = await dialog.isVisible().catch(() => false);275 await page.waitForTimeout(150); // input receives focus ~10 ms after open276 await page.keyboard.press("Escape");277 await dialog.waitFor({ state: "hidden", timeout: 2000 }).catch(() => {});278 inter.paletteCloses = !(await dialog.isVisible().catch(() => false));279 if (!inter.paletteCloses) await page.getByRole("button", { name: "Close search" }).click().catch(() => {});280 const before = await page.evaluate(() => document.documentElement.className);281 await page.locator('button[aria-label*="theme" i]').first().click();282 await page.waitForTimeout(200);283 const after = await page.evaluate(() => document.documentElement.className);284 inter.themeToggles = before !== after;285 await page.locator('button[aria-label*="theme" i]').first().click();286 // focus ring287 await page.keyboard.press("Tab");288 await page.keyboard.press("Tab");289 inter.focusVisible = await page.evaluate(() => {290 const a = document.activeElement;291 if (!a || a === document.body) return null;292 const cs = getComputedStyle(a);293 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" };294 });295 }296 if (name === "datacenters" || name === "live" || name === "projects") {297 const btn = page.getByRole("button", { name: /filters/i }).first();298 if (await btn.count()) {299 await btn.click();300 inter.filterSheetOpens = await page.locator("[role=dialog]").first().isVisible({ timeout: 2000 }).catch(() => false);301 await page.keyboard.press("Escape");302 await page.waitForTimeout(300);303 inter.filterSheetCloses = !(await page.locator("[role=dialog]").first().isVisible().catch(() => false));304 }305 }306 if (name === "datacenters" || name === "operators" || name === "projects") {307 const next = page.locator('a[aria-label="Next page"]').first();308 if (await next.count()) {309 await next.click();310 await page.waitForURL(/page=2/, { timeout: 15000 }).catch(() => {});311 inter.paginationPage2 = /page=2/.test(page.url());312 await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {});313 inter.page2Rows = await page.locator("table tbody tr").count();314 }315 }316 if (name === "country-us" && mobile) {317 const show = page.locator("#facilities").getByRole("button", { name: /show all/i }).first();318 if (await show.count()) {319 await show.scrollIntoViewIfNeeded();320 const before = await page.locator("#facilities tbody tr:visible").count().catch(() => null);321 await show.click();322 await page.waitForTimeout(200);323 const after = await page.locator("#facilities tbody tr:visible").count().catch(() => null);324 inter.showAll = { before, after, expanded: (await show.getAttribute("aria-expanded")) === "true" };325 }326 }327 const tabs = page.locator("[role=tablist] [role=tab]");328 if ((await tabs.count()) >= 2) {329 await tabs.nth(1).click();330 await page.waitForTimeout(200);331 inter.tabSwitch = (await tabs.nth(1).getAttribute("aria-selected")) === "true";332 }333 if (name === "map") {334 const zoomIn = page.locator(".maplibregl-ctrl-zoom-in").first();335 inter.zoomVisible = (await zoomIn.count()) ? await zoomIn.isVisible() : null;336 const searchBtn = page.getByRole("button", { name: /search|filter/i }).first();337 if (await searchBtn.count()) {338 await searchBtn.click().catch(() => {});339 await page.waitForTimeout(500);340 inter.mapPanelOpens = await page.locator("[role=dialog]").first().isVisible().catch(() => false);341 await page.keyboard.press("Escape");342 }343 }344 if (Object.keys(inter).length) row.inter = inter;345 }346 } catch (e) {347 row.error = String(e).slice(0, 200);348 }349 row.errors = [...new Set(errors)];350 row.badResponses = [...new Set(bad)];351 page.off("console", onConsole);352 page.off("pageerror", onErr);353 page.off("response", onResp);354 results.push(row);355 const flags = [];356 if (row.overflow) flags.push("OVERFLOW");357 if (row.clipped?.length) flags.push(`clipped:${row.clipped.length}`);358 if (row.smallCount) flags.push(`tap<40:${row.smallCount}`);359 if (row.h1Covered) flags.push("h1-covered");360 if (row.errors.length) flags.push(`console:${row.errors.length}`);361 if (row.badResponses.length) flags.push(`http4xx5xx:${row.badResponses.length}`);362 if (row.bottom?.covered) flags.push("footer-under-nav");363 if (row.contrast?.length) flags.push(`contrast<4.5:${row.contrast.length}`);364 if (row.footerOk === false && name !== "map") flags.push("no-footer-credits");365 if (row.h1Count !== 1) flags.push(`h1×${row.h1Count}`);366 if (row.skipped?.length) flags.push(`heading-skip:${row.skipped.join(",")}`);367 if (row.unnamed?.length) flags.push(`unnamed:${row.unnamed.length}`);368 if (row.metaDesc === null) flags.push("no-meta-desc");369 if (row.error) flags.push("ERROR");370 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"}`);371 }372 await ctx.close();373}374await browser.close();375writeFileSync(resolve(OUT, "results.json"), JSON.stringify({ base: BASE, at: new Date().toISOString(), results }, null, 1));376console.log(`\nwrote ${OUT}/results.json (${results.length} rows)`);377