337 lines
15 KiB
JavaScript
337 lines
15 KiB
JavaScript
// 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;
|