SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
3 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
19.8 KB · 377 lines javascript
Raw Blame History
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