// Focused browser instrumentation for five interactive showcase routes. // This records the implemented browser behaviour; it is not a visual-accuracy test. import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import url from "node:url"; import puppeteer from "puppeteer-core"; import { resolveChrome, startPublicServer } from "./capture-stable.mjs"; const here = path.dirname(url.fileURLToPath(import.meta.url)); const siteRoot = path.resolve(here, ".."); const publicRoot = path.resolve(process.env.SHOWCASE_INSTRUMENT_PUBLIC_ROOT || path.join(siteRoot, "public")); const reportPath = path.resolve(process.env.SHOWCASE_INSTRUMENT_REPORT || path.join(siteRoot, "instrument-showcases-e2e.json")); const showcases = [ { id: "lumina", route: "/work/lumina-optics/" }, { id: "orbit", route: "/work/orbit-aerospace/" }, { id: "synapse", route: "/work/synapse-bci/" }, { id: "overtone", route: "/work/overtone-sound/" }, { id: "pulse", route: "/work/pulsegate/" }, ]; const viewports = [ { name: "desktop", width: 1440, height: 1000 }, { name: "mobile", width: 390, height: 844 }, ]; function writePartial(report) { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); } function attachEvents(page) { const events = { consoleErrors: [], pageErrors: [], httpErrors: [], requestFailures: [] }; page.on("console", (message) => { if (message.type() === "error") events.consoleErrors.push(message.text()); }); page.on("pageerror", (error) => events.pageErrors.push(error.message)); page.on("response", (response) => { if (response.status() >= 400) events.httpErrors.push(`${response.status()} ${response.url()}`); }); page.on("requestfailed", (request) => { events.requestFailures.push(`${request.url()} ${request.failure()?.errorText || "request failed"}`); }); return events; } async function settle(page, milliseconds = 300) { const assets = await page.evaluate(async () => { await document.fonts.ready; const images = [...document.images]; for (const image of images) image.loading = "eager"; const decodeFailures = []; for (const image of images) { try { await image.decode(); } catch (error) { decodeFailures.push({ src: image.currentSrc || image.src, message: String(error) }); } } return { brokenImages: images.filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src), decodeFailures, }; }); await new Promise((resolve) => setTimeout(resolve, milliseconds)); return assets; } async function waitForScrollSettled(page, timeout = 3000) { await page.evaluate(async (limit) => { const startedAt = performance.now(); let stableFrames = 0; let previous = { x: scrollX, y: scrollY }; await new Promise((resolve, reject) => { const tick = () => { const current = { x: scrollX, y: scrollY }; stableFrames = current.x === previous.x && current.y === previous.y ? stableFrames + 1 : 0; previous = current; if (stableFrames >= 5) return resolve(); if (performance.now() - startedAt > limit) return reject(new Error("scroll did not settle")); requestAnimationFrame(tick); }; requestAnimationFrame(tick); }); }, timeout); } function digest(data) { return crypto.createHash("sha256").update(data).digest("hex"); } async function captureCanvasSamples(page, selector, count) { const dataUrls = []; for (let index = 0; index < count; index += 1) { const result = await page.$eval(selector, (canvas) => { if (!(canvas instanceof HTMLCanvasElement)) throw new Error("target is not a canvas"); return canvas.toDataURL(); }); dataUrls.push(result); if (index < count - 1) await new Promise((resolve) => setTimeout(resolve, 100)); } return dataUrls.map((dataUrl) => ({ sha256: digest(dataUrl), bytes: Buffer.byteLength(dataUrl) })); } async function prepareCanvasObservation(page, selector) { await page.$eval(selector, (canvas) => canvas.scrollIntoView({ block: "center", behavior: "instant" })); await waitForScrollSettled(page); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await new Promise((resolve) => setTimeout(resolve, 100)); } async function sampleCanvasMode(page, selector) { await prepareCanvasObservation(page, selector); const visible = await captureCanvasSamples(page, selector, 3); const offscreen = await page.$eval(selector, (canvas) => { const rect = canvas.getBoundingClientRect(); window.scrollTo({ top: scrollY + rect.bottom + 80, behavior: "instant" }); return { beforeTop: rect.top, beforeBottom: rect.bottom }; }); await waitForScrollSettled(page); await new Promise((resolve) => setTimeout(resolve, 100)); const offscreenState = await page.$eval(selector, (canvas) => { const rect = canvas.getBoundingClientRect(); return { top: Math.round(rect.top), bottom: Math.round(rect.bottom), isOffscreen: rect.bottom <= 0 || rect.top >= innerHeight }; }); const stationary = await captureCanvasSamples(page, selector, 2); return { visibleDataUrlSamples: visible, visibleDistinctFrames: new Set(visible.map((sample) => sample.sha256)).size, offscreenStart: offscreen, offscreen: offscreenState, offscreenDataUrlSamples: stationary, offscreenDistinctFrames: new Set(stationary.map((sample) => sample.sha256)).size, }; } async function runLumina(page, reduced) { const state = await page.evaluate(() => ({ reduced: matchMedia("(prefers-reduced-motion: reduce)").matches, canvasPresent: Boolean(document.querySelector("#gl")), fallbackPresent: Boolean(document.querySelector(".bench-fallback")), note: document.querySelector("#hud-note")?.textContent.trim() || null, })); if (reduced) { return { state, staticFallbackPresent: state.reduced && !state.canvasPresent && state.fallbackPresent && /Motion reduced/i.test(state.note || ""), }; } const controls = []; for (const [input, output] of [["#ior", "#ior-out"], ["#disp", "#disp-out"]]) { const before = await page.evaluate(([inputSelector, outputSelector]) => ({ input: document.querySelector(inputSelector)?.value || null, output: document.querySelector(outputSelector)?.textContent.trim() || null, }), [input, output]); let after = null; if (state.canvasPresent) { await page.focus(input); await page.keyboard.press("ArrowRight"); after = await page.evaluate(([inputSelector, outputSelector]) => ({ input: document.querySelector(inputSelector)?.value || null, output: document.querySelector(outputSelector)?.textContent.trim() || null, }), [input, output]); } controls.push({ input, output, before, after, outputChanged: after ? after.output !== before.output : null }); } return { state, webglAvailable: state.canvasPresent, controls, controlsTested: state.canvasPresent, }; } async function runCanvasContract(page, selector, reduced) { const canvasPresent = await page.$(selector); if (!canvasPresent) return { canvasPresent: false, mode: reduced ? "reduced" : "normal" }; const samples = await sampleCanvasMode(page, selector); return { canvasPresent: true, mode: reduced ? "reduced" : "normal", ...samples }; } async function readAnchorAndTableContract(page) { const anchorData = await page.evaluate(() => [...document.querySelectorAll('a[href^="#"]')] .map((anchor, index) => ({ index, href: anchor.getAttribute("href"), text: anchor.textContent.trim() })) .filter((anchor) => anchor.href && anchor.href.length > 1)); const tabReach = []; await page.evaluate(() => document.activeElement?.blur()); for (let index = 0; index < 40; index += 1) { await page.keyboard.press("Tab"); const focus = await page.evaluate(() => { const active = document.activeElement; return active instanceof HTMLAnchorElement && active.getAttribute("href")?.startsWith("#") ? { href: active.getAttribute("href"), text: active.textContent.trim() } : null; }); if (focus) tabReach.push(focus); } const anchors = []; for (const anchor of anchorData) { const target = await page.evaluate((hash) => { const target = document.getElementById(hash.slice(1)); return target ? { exists: true, tag: target.tagName, text: target.textContent.trim().slice(0, 120) } : { exists: false }; }, anchor.href); await page.evaluate((anchorIndex) => document.querySelectorAll('a[href^="#"]')[anchorIndex]?.focus(), anchor.index); await page.keyboard.press("Enter"); await waitForScrollSettled(page); const navigation = await page.evaluate((hash) => ({ hash: location.hash, scrollY: Math.round(scrollY), targetTop: document.getElementById(hash.slice(1))?.getBoundingClientRect().top ?? null, }), anchor.href); anchors.push({ ...anchor, target, reachedByTab: tabReach.some((entry) => entry.href === anchor.href), navigation }); } const tables = await page.evaluate(() => [...document.querySelectorAll("table, [role=table]")].map((table) => { const headers = [...table.querySelectorAll("thead th, [role=columnheader]")].map((cell) => cell.textContent.trim()).filter(Boolean); const rows = [...table.querySelectorAll("tbody tr, [role=row]")].map((row) => [...row.querySelectorAll("th, td, [role=cell], [role=rowheader]")] .map((cell) => cell.textContent.trim()).filter(Boolean)).filter((row) => row.length); return { caption: table.querySelector("caption")?.textContent.trim() || null, headers, firstRows: rows.slice(0, 3), readable: headers.length > 0 && rows.length > 0, }; })); return { anchors, tabReach, tables }; } function hardFailures(row) { const failures = []; for (const [mode, events] of Object.entries(row.events)) { if (events.consoleErrors.length) failures.push(`${mode}: console errors ${events.consoleErrors.length}`); if (events.pageErrors.length) failures.push(`${mode}: page errors ${events.pageErrors.length}`); if (events.httpErrors.length) failures.push(`${mode}: HTTP responses >=400 ${events.httpErrors.length}`); if (events.requestFailures.length) failures.push(`${mode}: request failures ${events.requestFailures.length}`); } if (row.assets.brokenImages.length) failures.push(`broken images ${row.assets.brokenImages.length}`); if (row.observations.executionError) return [...failures, "runner: interaction observation did not complete"]; if (row.id === "lumina") { if (!row.observations.reduced.staticFallbackPresent) failures.push("lumina: reduced-motion static fallback missing"); if (row.observations.normal.controlsTested && row.observations.normal.controls.some((control) => !control.outputChanged)) failures.push("lumina: range input did not update its output"); } if (row.id === "synapse" || row.id === "overtone") { const normal = row.observations.normal.canvas; const reduced = row.observations.reduced.canvas; if (!normal.canvasPresent) failures.push(`${row.id}: normal canvas missing`); else { if (normal.visibleDistinctFrames < 2) failures.push(`${row.id}: normal canvas did not change across visible samples`); if (normal.offscreen.isOffscreen && normal.offscreenDistinctFrames > 1) failures.push(`${row.id}: offscreen canvas continued changing`); } if (!reduced.canvasPresent) failures.push(`${row.id}: reduced-motion canvas missing`); else if (reduced.visibleDistinctFrames > 1 || (reduced.offscreen.isOffscreen && reduced.offscreenDistinctFrames > 1)) failures.push(`${row.id}: reduced-motion canvas continued changing`); } if (row.id === "orbit" || row.id === "pulse") { const { anchors, tables } = row.observations.normal.content; if (anchors.some((anchor) => !anchor.target.exists)) failures.push(`${row.id}: anchor target missing`); if (anchors.some((anchor) => !anchor.reachedByTab)) failures.push(`${row.id}: internal anchor not reached by Tab`); if (!tables.length || tables.some((table) => !table.readable)) failures.push(`${row.id}: table is not readable`); } return failures; } async function visit(browser, origin, showcase, viewport, reduced) { const page = await browser.newPage(); const events = attachEvents(page); try { await page.setViewport({ width: viewport.width, height: viewport.height, isMobile: viewport.width < 500, hasTouch: viewport.width < 500 }); if (reduced) await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]); await page.goto(`${origin}${showcase.route}`, { waitUntil: "networkidle0", timeout: 30000 }); const assets = await settle(page); let observation; if (showcase.id === "lumina") observation = await runLumina(page, reduced); else if (showcase.id === "synapse") observation = { canvas: await runCanvasContract(page, "#raster", reduced) }; else if (showcase.id === "overtone") observation = { canvas: await runCanvasContract(page, "#wave", reduced) }; else observation = { content: await readAnchorAndTableContract(page) }; return { events, assets, observation }; } finally { await page.close(); } } const report = { generatedAt: new Date().toISOString(), publicRoot, routes: showcases, viewports, rows: [], runnerErrors: [], }; let server; let browser; try { if (!fs.existsSync(publicRoot)) throw new Error(`public root not found: ${publicRoot}`); server = await startPublicServer(publicRoot); browser = await puppeteer.launch({ executablePath: resolveChrome(), headless: "new", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"], }); for (const showcase of showcases) { for (const viewport of viewports) { console.log(`RUN ${showcase.id} ${viewport.name}`); const row = { id: showcase.id, route: showcase.route, viewport, events: {}, assets: { brokenImages: [], decodeFailures: [] }, observations: {}, hardFailures: [] }; try { const normal = await visit(browser, server.origin, showcase, viewport, false); const reduced = await visit(browser, server.origin, showcase, viewport, true); row.events = { normal: normal.events, reduced: reduced.events }; row.assets = { brokenImages: [...new Set([...normal.assets.brokenImages, ...reduced.assets.brokenImages])], decodeFailures: [...normal.assets.decodeFailures, ...reduced.assets.decodeFailures], }; row.observations = { normal: normal.observation, reduced: reduced.observation }; } catch (error) { row.events = { runner: { consoleErrors: [], pageErrors: [error.stack || error.message], httpErrors: [], requestFailures: [] } }; row.observations = { executionError: error.stack || error.message }; } row.hardFailures = hardFailures(row); report.rows.push(row); writePartial(report); console.log(`${row.hardFailures.length ? "FAIL" : "PASS"} ${showcase.id} ${viewport.name}${row.hardFailures.length ? ` — ${row.hardFailures.join(" · ")}` : ""}`); } } } catch (error) { report.runnerErrors.push(error.stack || error.message); writePartial(report); throw error; } finally { if (browser) await browser.close(); if (server) await server.close(); } const failureCount = report.rows.reduce((count, row) => count + row.hardFailures.length, 0); console.log(`SUMMARY rows=${report.rows.length} hardFailures=${failureCount}`); if (failureCount || report.runnerErrors.length) process.exitCode = 1;