designpaca/apps/site/tools/site-e2e.mjs
2026-08-30 17:52:54 +09:00

2206 lines
105 KiB
JavaScript

// designpaca promotion-site browser gate.
//
// This intentionally stays independent from package scripts and the showcase
// baselines. It serves the built Astro output, writes only ignored evidence,
// and never updates a checked-in report or screenshot.
//
// npm run build
// node tools/site-e2e.mjs --root=dist
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import url from "node:url";
import { createRequire } from "node:module";
import puppeteer from "puppeteer-core";
import { PNG } from "pngjs";
const require = createRequire(import.meta.url);
const axePath = require.resolve("axe-core/axe.min.js");
const here = path.dirname(url.fileURLToPath(import.meta.url));
const siteRoot = path.resolve(here, "..");
const ROUTES = [
{ key: "home-ko", path: "/", lang: "ko", translationPath: "/en/" },
{ key: "home-en", path: "/en/", lang: "en", translationPath: "/" },
{ key: "showcase-ko", path: "/showcase/", lang: "ko", translationPath: "/en/showcase/" },
{ key: "showcase-en", path: "/en/showcase/", lang: "en", translationPath: "/showcase/" },
];
const GEOMETRY_WIDTHS = [320, 390, 768, 1024, 1440, 1920, 2560];
const CAPTURE_WIDTHS = new Set([390, 1440]);
const HEIGHTS = new Map([[320, 844], [390, 844], [768, 1024], [1024, 900], [1440, 1000], [1920, 1080], [2560, 1440]]);
const AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
const EXPECTED_SCENARIOS = 80;
const EXPECTED_ASSERTIONS = 995;
const EXPECTED_CAPTURES = 8;
function parseArguments(argv) {
const options = { root: "dist", output: path.join("tools", ".gen", "site-e2e") };
for (let index = 2; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--root") options.root = argv[++index];
else if (argument.startsWith("--root=")) options.root = argument.slice("--root=".length);
else if (argument === "--output") options.output = argv[++index];
else if (argument.startsWith("--output=")) options.output = argument.slice("--output=".length);
else if (argument === "--help" || argument === "-h") options.help = true;
else throw new Error(`Unknown argument: ${argument}`);
}
if (!options.root) throw new Error("--root needs a directory");
if (!options.output) throw new Error("--output needs a directory");
return options;
}
function showHelp() {
console.log(`designpaca site E2E
Usage:
node tools/site-e2e.mjs [--root=dist] [--output=tools/.gen/site-e2e]
The root is resolved from apps/site. Evidence is stored in a unique run folder.`);
}
function resolveChrome() {
const candidates = [
process.env.CHROME_PATH,
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
];
const chrome = candidates.find((candidate) => candidate && fs.existsSync(candidate));
if (!chrome) {
throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH or install Chrome/Chromium.");
}
return chrome;
}
const MIME = new Map([
[".avif", "image/avif"],
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".ico", "image/x-icon"],
[".jpeg", "image/jpeg"],
[".jpg", "image/jpeg"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".map", "application/json; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".webmanifest", "application/manifest+json; charset=utf-8"],
[".webp", "image/webp"],
[".woff", "font/woff"],
[".woff2", "font/woff2"],
]);
function isInside(root, candidate) {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}
function existingFileForRequest(serveRoot, pathname) {
const candidate = path.resolve(serveRoot, `.${pathname}`);
if (!isInside(serveRoot, candidate)) return null;
const candidates = [
candidate,
path.join(candidate, "index.html"),
`${candidate}.html`,
];
return candidates.find((file) => {
try { return fs.statSync(file).isFile(); } catch { return false; }
}) || null;
}
async function startStaticServer(serveRoot) {
const server = http.createServer((request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname);
const file = existingFileForRequest(serveRoot, pathname);
if (!file) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("not found");
return;
}
response.setHeader("Content-Type", MIME.get(path.extname(file).toLowerCase()) || "application/octet-stream");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
fs.createReadStream(file).pipe(response);
} catch {
response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end("bad request");
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return {
origin: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolve) => server.close(resolve)),
};
}
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
function display(value) {
if (typeof value === "string") return value;
if (value === undefined) return "undefined";
try { return JSON.stringify(value); } catch { return String(value); }
}
class Results {
constructor() {
this.assertions = [];
this.scenarios = [];
}
check(scope, name, pass, { expected = true, actual = pass, detail = "" } = {}) {
this.assertions.push({ scope, name, pass: Boolean(pass), expected: display(expected), actual: display(actual), detail });
}
equal(scope, name, expected, actual, detail = "") {
this.check(scope, name, Object.is(actual, expected), { expected, actual, detail });
}
empty(scope, name, actual, detail = "") {
this.check(scope, name, Array.isArray(actual) && actual.length === 0, { expected: "[]", actual, detail });
}
async scenario(scope, run) {
const before = this.assertions.length;
try {
await run();
} catch (error) {
this.check(scope, "scenario completed", false, {
expected: "no exception",
actual: error?.message || String(error),
detail: error?.stack || "",
});
}
const assertions = this.assertions.slice(before);
const failures = assertions.filter((item) => !item.pass);
this.scenarios.push({ scope, assertions: assertions.length, failures: failures.length });
console.log(`${failures.length ? "FAIL" : "PASS"} ${scope} (${assertions.length} checks)`);
}
printFailures() {
const failures = this.assertions.filter((item) => !item.pass);
if (!failures.length) return;
console.error("\nFailures");
for (const item of failures) {
console.error(`\n[${item.scope}] ${item.name}`);
console.error(` expected: ${item.expected}`);
console.error(` actual: ${item.actual}`);
if (item.detail) console.error(` detail: ${item.detail}`);
}
}
}
async function waitForPageReady(page) {
const fontsReady = await Promise.race([
page.evaluate(() => document.fonts?.ready || Promise.resolve()).then(() => true),
sleep(10000).then(() => false),
]);
await page.evaluate(async () => {
const images = [...document.images].filter((image) => {
const rect = image.getBoundingClientRect();
return image.loading !== "lazy" || image.complete || (rect.top < innerHeight + 200 && rect.bottom > -200);
});
await Promise.all(images.map(async (image) => {
if (!image.complete) {
await new Promise((resolve) => {
const timer = window.setTimeout(resolve, 10000);
image.addEventListener("load", () => { window.clearTimeout(timer); resolve(); }, { once: true });
image.addEventListener("error", () => { window.clearTimeout(timer); resolve(); }, { once: true });
});
}
await Promise.race([
image.decode().catch(() => undefined),
new Promise((resolve) => window.setTimeout(resolve, 5000)),
]);
}));
});
await sleep(350);
return { fontsReady };
}
async function openPage(browser, origin, route, {
width = 1024,
reducedMotion = false,
monitorRaf = false,
javascript = true,
clipboardMode = null,
abortExternalFonts = false,
monitorPerformance = false,
} = {}) {
const page = await browser.newPage();
const runtime = [];
const network = { requests: new Map(), completed: [] };
const height = HEIGHTS.get(width) || 900;
await page.setJavaScriptEnabled(javascript);
if (clipboardMode) {
await page.evaluateOnNewDocument((mode) => {
const clipboard = {
writeText: async (text) => {
if (mode === "failure") throw new DOMException("Denied by site E2E", "NotAllowedError");
window.__siteE2eClipboard = String(text);
},
};
Object.defineProperty(Navigator.prototype, "clipboard", {
configurable: true,
get: () => clipboard,
});
}, clipboardMode);
}
if (monitorRaf) {
await page.evaluateOnNewDocument(() => {
const nativeRaf = window.requestAnimationFrame.bind(window);
let count = 0;
Object.defineProperty(window, "__siteE2eRafCount", { get: () => count, configurable: false });
window.requestAnimationFrame = (callback) => {
count += 1;
return nativeRaf(callback);
};
});
}
if (monitorPerformance) {
await page.evaluateOnNewDocument(() => {
const vitals = { lcp: 0, cls: 0 };
const observers = [];
try {
const lcp = new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries.at(-1);
if (last) vitals.lcp = last.startTime || last.renderTime || last.loadTime || 0;
});
lcp.observe({ type: "largest-contentful-paint", buffered: true });
observers.push(lcp);
} catch { /* unsupported vital remains zero and is reported as unavailable */ }
try {
const cls = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) if (!entry.hadRecentInput) vitals.cls += entry.value || 0;
});
cls.observe({ type: "layout-shift", buffered: true });
observers.push(cls);
} catch { /* unsupported vital remains zero and is reported as unavailable */ }
window.__siteE2eVitals = vitals;
window.__siteE2eVitalObservers = observers;
});
const session = await page.createCDPSession();
await session.send("Network.enable");
session.on("Network.requestWillBeSent", (event) => {
network.requests.set(event.requestId, { url: event.request.url, type: event.type || "Other" });
});
session.on("Network.loadingFinished", (event) => {
const request = network.requests.get(event.requestId);
if (request) network.completed.push({ ...request, bytes: Math.round(event.encodedDataLength || 0) });
});
}
if (abortExternalFonts) {
await page.setRequestInterception(true);
page.on("request", (request) => {
let hostname = "";
try { hostname = new URL(request.url()).hostname; } catch { /* malformed request continues */ }
if (["cdn.jsdelivr.net", "fonts.googleapis.com", "fonts.gstatic.com", "api.fontshare.com"].includes(hostname)) {
request.abort("blockedbyclient");
} else {
request.continue();
}
});
}
await page.setCacheEnabled(false);
await page.setViewport({
width,
height,
deviceScaleFactor: 1,
isMobile: width <= 390,
hasTouch: width <= 768,
});
if (reducedMotion) {
await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
}
page.on("pageerror", (error) => runtime.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() !== "error") return;
if (abortExternalFonts && /Failed to load resource/i.test(message.text())) return;
runtime.push(`console: ${message.text()}`);
});
page.on("response", (response) => {
if (response.url().startsWith(origin) && response.status() >= 400) {
runtime.push(`response: ${response.status()} ${response.url()}`);
}
});
page.on("requestfailed", (request) => {
if (request.url().startsWith(origin)) {
runtime.push(`requestfailed: ${request.failure()?.errorText || "unknown"} ${request.url()}`);
}
});
const response = await page.goto(`${origin}${route.path}`, { waitUntil: "domcontentloaded", timeout: 30000 });
const readiness = await waitForPageReady(page);
if (!readiness.fontsReady) runtime.push("fonts: document.fonts.ready timed out after 10s");
return { page, status: response?.status() || 0, runtime, width, height, network };
}
async function closePage(page) {
if (page && !page.isClosed()) await page.close();
}
async function runAxe(page) {
await page.addScriptTag({ path: axePath });
const result = await page.evaluate(async (tags) => {
const report = await window.axe.run(document, {
runOnly: { type: "tag", values: tags },
resultTypes: ["violations"],
});
return report.violations.map((violation) => ({
id: violation.id,
impact: violation.impact,
help: violation.help,
nodes: violation.nodes.slice(0, 4).map((node) => ({
target: node.target.join(" "),
summary: node.failureSummary?.replace(/\s+/g, " ").trim() || "",
})),
}));
}, AXE_TAGS);
return result;
}
async function collectPerformance(opened) {
await sleep(900);
const vitals = await opened.page.evaluate(() => ({
lcpMs: Math.round((window.__siteE2eVitals?.lcp || 0) * 10) / 10,
cls: Math.round((window.__siteE2eVitals?.cls || 0) * 10000) / 10000,
}));
const completed = opened.network.completed.filter((item) => !item.url.startsWith("data:"));
return {
...vitals,
transferBytes: completed.reduce((sum, item) => sum + item.bytes, 0),
fontTransferBytes: completed.filter((item) => item.type === "Font" || /\.(?:woff2?|otf|ttf)(?:$|\?)/i.test(item.url)).reduce((sum, item) => sum + item.bytes, 0),
requestCount: completed.length,
};
}
async function inspectSeo(page, route, origin) {
return page.evaluate(async ({ expectedPath, translationPath, expectedLang, siteOrigin }) => {
const metas = (selector, attribute = "content") => [...document.querySelectorAll(selector)].map((element) => element.getAttribute(attribute) || "");
const titleElements = [...document.querySelectorAll("head > title")];
const descriptions = metas("meta[name='description']");
const canonicals = metas("link[rel='canonical']", "href");
const ogTitles = metas("meta[property='og:title']");
const ogDescriptions = metas("meta[property='og:description']");
const ogUrls = metas("meta[property='og:url']");
const ogImages = metas("meta[property='og:image']");
const twitterCards = metas("meta[name='twitter:card']");
const alternates = [...document.querySelectorAll("link[rel='alternate'][hreflang]")].map((element) => ({
lang: (element.getAttribute("hreflang") || "").toLowerCase(),
href: element.href,
}));
const alternateLanguages = alternates.map((item) => item.lang);
const duplicateHreflang = alternateLanguages.filter((lang, index) => alternateLanguages.indexOf(lang) !== index);
const translated = alternates.find((item) => item.lang === (expectedLang === "ko" ? "en" : "ko"));
const metadataOrigin = canonicals.length === 1 ? new URL(canonicals[0], siteOrigin).origin : null;
const alternateOriginProblems = alternates.filter((item) => {
try { return !metadataOrigin || new URL(item.href).origin !== metadataOrigin; } catch { return true; }
});
const ogImageAbsolute = ogImages.length === 1 && (() => {
try { return Boolean(new URL(ogImages[0])); } catch { return false; }
})();
let ogImageLoaded = false;
if (ogImages.length === 1 && ogImages[0]) {
ogImageLoaded = await new Promise((resolve) => {
const image = new Image();
const timer = window.setTimeout(() => resolve(false), 5000);
image.onload = () => { window.clearTimeout(timer); resolve(image.naturalWidth > 0 && image.naturalHeight > 0); };
image.onerror = () => { window.clearTimeout(timer); resolve(false); };
const metadataImage = new URL(ogImages[0], location.href);
image.src = metadataImage.origin === location.origin
? metadataImage.href
: new URL(`${metadataImage.pathname}${metadataImage.search}`, location.origin).href;
});
}
const normalized = (value) => new URL(value, siteOrigin).pathname.replace(/\/+$/, "") || "/";
return {
titleCount: titleElements.length,
title: document.title.trim(),
descriptions,
canonicals,
canonicalMatches: canonicals.length === 1 && normalized(canonicals[0]) === normalized(expectedPath),
ogTitles,
ogDescriptions,
ogUrls,
ogUrlMatches: ogUrls.length === 1 && normalized(ogUrls[0]) === normalized(expectedPath),
ogImages,
ogImageAbsolute,
ogImageLoaded,
twitterCards,
alternates,
duplicateHreflang,
translatedMatches: Boolean(translated && normalized(translated.href) === normalized(translationPath)),
alternateOriginProblems,
};
}, {
expectedPath: route.path,
translationPath: route.translationPath,
expectedLang: route.lang,
siteOrigin: origin,
});
}
async function inspectLayout(page) {
return page.evaluate(() => {
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
};
const selectorFor = (element) => {
if (element.id) return `#${CSS.escape(element.id)}`;
const classes = [...element.classList].slice(0, 2).map((name) => `.${CSS.escape(name)}`).join("");
return `${element.tagName.toLowerCase()}${classes}`;
};
const clippedByAncestor = (element, rect) => {
for (let parent = element; parent && parent !== document.documentElement; parent = parent.parentElement) {
const style = getComputedStyle(parent);
if (!/(hidden|clip|auto|scroll)/.test(style.overflowX)) continue;
const parentRect = parent.getBoundingClientRect();
if (rect.left < parentRect.left - 1 || rect.right > parentRect.right + 1) return true;
}
return false;
};
const rangeRects = (root) => {
const rects = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
if (!node.textContent?.trim() || !node.parentElement) continue;
if (node.parentElement.closest("script,style,noscript,svg,canvas,[aria-hidden='true'],.sr-only,dialog:not([open]),details:not([open]) :not(summary)")) continue;
if (!visible(node.parentElement)) continue;
const range = document.createRange();
range.selectNodeContents(node);
for (const rect of range.getClientRects()) {
if (rect.width > 1 && rect.height > 3) {
rects.push({
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
selector: selectorFor(node.parentElement),
clippedByScrollOwner: clippedByAncestor(node.parentElement, rect),
});
}
}
}
return rects;
};
const union = (rects) => rects.reduce((result, rect) => ({
left: Math.min(result.left, rect.left),
right: Math.max(result.right, rect.right),
top: Math.min(result.top, rect.top),
bottom: Math.max(result.bottom, rect.bottom),
}), { left: Infinity, right: -Infinity, top: Infinity, bottom: -Infinity });
const lineCount = (rects) => {
const tops = rects.map((rect) => rect.top).sort((a, b) => a - b);
const unique = [];
for (const top of tops) if (!unique.length || top - unique.at(-1) > 4) unique.push(top);
return unique.length;
};
const documentWidth = Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0);
const protrusions = [...document.body.querySelectorAll("*")].flatMap((element) => {
if (!visible(element)) return [];
const rect = element.getBoundingClientRect();
if (rect.left >= -1 && rect.right <= innerWidth + 1) return [];
if (clippedByAncestor(element, rect)) return [];
return [{ selector: selectorFor(element), left: Math.round(rect.left), right: Math.round(rect.right), width: Math.round(rect.width) }];
}).slice(0, 12);
const edgeText = [];
const leftInsetProblems = [];
for (const element of document.querySelectorAll("main, footer")) {
for (const rect of rangeRects(element)) {
if (rect.clippedByScrollOwner) continue;
if (rect.left < 12) {
leftInsetProblems.push({ selector: rect.selector, left: Math.round(rect.left * 10) / 10 });
}
if (rect.left < 6 || rect.right > innerWidth - 6) {
edgeText.push({
selector: rect.selector,
left: Math.round(rect.left),
right: Math.round(rect.right),
});
}
if (edgeText.length >= 12 && leftInsetProblems.length >= 12) break;
}
if (edgeText.length >= 12 && leftInsetProblems.length >= 12) break;
}
const controlProblems = [];
const touchTargetProblems = [];
const buttonInsetProblems = [];
const controls = [...document.querySelectorAll("button, summary, input, select, textarea, [role='button'], a[href]")];
for (const element of controls) {
if (!visible(element)) continue;
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
const classes = String(element.className || "");
const independent = element.matches("button,summary,input,select,textarea,[role='button']") ||
/(^|\s|[-_])(btn|button|cta|action|copy|lang|repo|archive|card-open|dialog)([-_]|\s|$)/i.test(classes);
if (independent && (rect.width < 44 || rect.height < 44)) {
touchTargetProblems.push({
selector: selectorFor(element),
width: Math.round(rect.width * 10) / 10,
height: Math.round(rect.height * 10) / 10,
});
}
const text = element.innerText?.replace(/\s+/g, " ").trim() || "";
if (!text || text.length > 120) continue;
const rects = rangeRects(element);
if (!rects.length) continue;
const bounds = union(rects);
const lines = lineCount(rects);
const clips = bounds.left < rect.left - 1 || bounds.right > rect.right + 1 || bounds.top < rect.top - 1 || bounds.bottom > rect.bottom + 1;
const leftInset = bounds.left - rect.left;
const rightInset = rect.right - bounds.right;
const wrapsTooFar = (element.closest("header") && lines > 1) || (independent && lines > 2);
if (independent && (leftInset < 8 || rightInset < 8)) {
buttonInsetProblems.push({
selector: selectorFor(element),
text: text.slice(0, 48),
leftInset: Math.round(leftInset * 10) / 10,
rightInset: Math.round(rightInset * 10) / 10,
});
}
if (clips || wrapsTooFar) {
controlProblems.push({
selector: selectorFor(element),
text: text.slice(0, 48),
clips,
leftInset: Math.round(leftInset * 10) / 10,
rightInset: Math.round(rightInset * 10) / 10,
lines,
});
}
if (controlProblems.length >= 12 && touchTargetProblems.length >= 12 && buttonInsetProblems.length >= 12) break;
}
const h1 = document.querySelector("h1");
const h1Rects = h1 ? rangeRects(h1) : [];
const bodyFont = getComputedStyle(document.body).fontFamily;
const firstFamily = bodyFont.split(",")[0].trim().replace(/^['"]|['"]$/g, "");
const genericFirst = /^(Arial|Helvetica|Times New Roman|serif|sans-serif|monospace|system-ui|-apple-system|Segoe UI)$/i.test(firstFamily);
return {
viewportWidth: innerWidth,
documentWidth,
documentHeight: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0),
protrusions,
edgeText: edgeText.slice(0, 12),
leftInsetProblems: leftInsetProblems.slice(0, 12),
controlProblems: controlProblems.slice(0, 12),
touchTargetProblems: touchTargetProblems.slice(0, 12),
buttonInsetProblems: buttonInsetProblems.slice(0, 12),
imageFailures: [...document.images].filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src),
imageAltFailures: [...document.images].filter((image) => !image.hasAttribute("alt")).map((image) => image.currentSrc || image.src || selectorFor(image)),
h1Count: document.querySelectorAll("h1").length,
h1Lines: lineCount(h1Rects),
h1Text: h1?.innerText.replace(/\s+/g, " ").trim() || "",
mainCount: document.querySelectorAll("main").length,
mainTextLength: document.querySelector("main")?.innerText.replace(/\s+/g, " ").trim().length || 0,
lang: document.documentElement.lang,
bodyFont,
genericFirst,
};
});
}
async function primeLazyImages(page) {
await page.bringToFront();
await page.addStyleTag({ content: "html { scroll-behavior: auto !important; }" });
await page.evaluate(() => {
for (const image of document.querySelectorAll('img[loading="lazy"]')) {
if (!image.closest("details:not([open])")) image.loading = "eager";
}
});
// Eager promotion starts the requests; finish resource load and decoding
// before the tiled evidence capture paints each viewport.
await page.waitForFunction(() => [...document.images]
.filter((image) => !image.closest("details:not([open])"))
.every((image) => image.complete && image.naturalWidth > 0 && Boolean(image.currentSrc)), { timeout: 20000 })
.catch(() => undefined);
await page.evaluate(async () => {
const images = [...document.images].filter((image) => !image.closest("details:not([open])"));
await Promise.all(images.map((image) => Promise.race([
image.decode().catch(() => undefined),
new Promise((resolve) => window.setTimeout(resolve, 5000)),
])));
});
await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: "instant" }));
await sleep(180);
const integrity = await page.evaluate(() => {
const visible = (image) => {
if (image.closest("details:not([open])")) return false;
const style = getComputedStyle(image);
const rect = image.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) > 0.01 && rect.width > 0 && rect.height > 0;
};
const images = [...document.images].filter(visible);
const failures = images.filter((image) => !image.complete || image.naturalWidth === 0 || !image.currentSrc).map((image) => ({
src: image.currentSrc || image.src,
complete: image.complete,
naturalWidth: image.naturalWidth,
}));
const placeholders = [];
for (const image of images) {
const rect = image.getBoundingClientRect();
if (!image.complete || image.naturalWidth < 80 || image.naturalHeight < 80 || rect.width < 80 || rect.height < 60) continue;
try {
const canvas = document.createElement("canvas");
canvas.width = 24;
canvas.height = 18;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) continue;
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
const values = [];
for (let index = 0; index < data.length; index += 4) {
if (data[index + 3] < 16) continue;
values.push(0.2126 * data[index] + 0.7152 * data[index + 1] + 0.0722 * data[index + 2]);
}
if (!values.length) continue;
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length;
if (Math.sqrt(variance) < 1.5) placeholders.push({ src: image.currentSrc, luminanceDeviation: Math.round(Math.sqrt(variance) * 100) / 100 });
} catch {
// Cross-origin images cannot be sampled; load/decode integrity above still applies.
}
}
return { imageCount: images.length, failures, placeholders };
});
return integrity;
}
async function freezeForScreenshot(page) {
await page.evaluate(() => {
for (const animation of document.getAnimations({ subtree: true })) {
try { animation.pause(); } catch { /* best-effort evidence stabilization */ }
}
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
});
await page.addStyleTag({ content: `
*, *::before, *::after {
animation-play-state: paused !important;
transition-property: none !important;
caret-color: transparent !important;
}
html { scroll-behavior: auto !important; }
.site-header { position: static !important; }
::-webkit-scrollbar { display: none !important; }
` });
await sleep(100);
}
async function captureFullPageTiles(page) {
const dimensions = await page.evaluate(() => ({
width: innerWidth,
viewportHeight: innerHeight,
documentHeight: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0),
}));
const composed = new PNG({ width: dimensions.width, height: dimensions.documentHeight });
const maxScroll = Math.max(0, dimensions.documentHeight - dimensions.viewportHeight);
const positions = [];
for (let top = 0; top < maxScroll; top += dimensions.viewportHeight) positions.push(top);
positions.push(maxScroll);
for (const requestedTop of [...new Set(positions)]) {
await page.evaluate((top) => window.scrollTo({ top, left: 0, behavior: "instant" }), requestedTop);
await sleep(100);
const actualTop = Math.round(await page.evaluate(() => scrollY));
const tileBuffer = await page.screenshot({ type: "png", fullPage: false, captureBeyondViewport: false });
const tile = PNG.sync.read(tileBuffer);
const copyWidth = Math.min(composed.width, tile.width);
const copyHeight = Math.min(tile.height, composed.height - actualTop);
const rowBytes = copyWidth * 4;
for (let row = 0; row < copyHeight; row += 1) {
tile.data.copy(composed.data, ((actualTop + row) * composed.width) * 4, row * tile.width * 4, row * tile.width * 4 + rowBytes);
}
}
await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: "instant" }));
return PNG.sync.write(composed);
}
async function capture(page, evidenceRoot, name, { fullPage = true } = {}) {
const imageRects = await page.evaluate(() => [...document.images]
.filter((image) => !image.closest("details:not([open])"))
.map((image) => {
const rect = image.getBoundingClientRect();
return {
src: image.currentSrc || image.src,
x: rect.left + scrollX,
y: rect.top + scrollY,
width: rect.width,
height: rect.height,
};
})
.filter((rect) => rect.width >= 80 && rect.height >= 60));
await fs.promises.mkdir(evidenceRoot, { recursive: true });
const file = path.join(evidenceRoot, `${name}.png`);
const buffer = fullPage
? await captureFullPageTiles(page)
: await page.screenshot({ type: "png", fullPage: false, captureBeyondViewport: false });
await fs.promises.writeFile(file, buffer);
const png = PNG.sync.read(buffer);
const flatImageFrames = imageRects.flatMap((rect) => {
const values = [];
for (let row = 1; row <= 10; row += 1) {
for (let column = 1; column <= 14; column += 1) {
const x = Math.max(0, Math.min(png.width - 1, Math.round(rect.x + rect.width * column / 15)));
const y = Math.max(0, Math.min(png.height - 1, Math.round(rect.y + rect.height * row / 11)));
const offset = (y * png.width + x) * 4;
if (png.data[offset + 3] < 16) continue;
values.push(0.2126 * png.data[offset] + 0.7152 * png.data[offset + 1] + 0.0722 * png.data[offset + 2]);
}
}
if (!values.length) return [];
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
const deviation = Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length);
return deviation < 1.5 ? [{ src: rect.src, luminanceDeviation: Math.round(deviation * 100) / 100 }] : [];
});
return {
name,
file,
width: png.width,
height: png.height,
bytes: buffer.byteLength,
sha256: crypto.createHash("sha256").update(buffer).digest("hex"),
flatImageFrames,
};
}
async function firstVisibleHandle(page, selector) {
const handles = await page.$$(selector);
for (const handle of handles) {
const isVisible = await handle.evaluate((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
});
if (isVisible) return handle;
}
return null;
}
async function menuSnapshot(page) {
return page.evaluate(() => {
const visible = (element) => {
if (!element) return false;
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
const siteHeader = document.querySelector("body > header");
if (!siteHeader) return null;
const candidates = [
...siteHeader.querySelectorAll("details > summary"),
...siteHeader.querySelectorAll("button[aria-expanded]"),
...siteHeader.querySelectorAll("[role='button'][aria-expanded]"),
...siteHeader.querySelectorAll("button[aria-haspopup]"),
];
const trigger = candidates.find(visible);
if (!trigger) return null;
const details = trigger.closest("details");
const controlled = trigger.getAttribute("aria-controls");
const menu = details || (controlled && document.getElementById(controlled)) || siteHeader.querySelector("nav");
const links = menu ? [...menu.querySelectorAll("a[href]")].filter(visible) : [];
const open = details ? details.open : trigger.getAttribute("aria-expanded") === "true";
return {
open,
triggerTag: trigger.tagName.toLowerCase(),
expanded: trigger.getAttribute("aria-expanded"),
hasPopup: trigger.getAttribute("aria-haspopup"),
linkCount: links.length,
focusInside: Boolean(menu && menu.contains(document.activeElement) && document.activeElement !== trigger),
triggerFocused: document.activeElement === trigger,
};
});
}
async function menuTrigger(page) {
return firstVisibleHandle(page, "body > header details > summary, body > header button[aria-expanded], body > header [role='button'][aria-expanded], body > header button[aria-haspopup]");
}
async function pressMenuTrigger(page, key = "Enter") {
const trigger = await menuTrigger(page);
if (!trigger) throw new Error("No visible semantic mobile-menu trigger found in header");
await trigger.focus();
await page.keyboard.press(key);
await sleep(180);
return trigger;
}
async function clickOutsideMenu(page) {
const point = await page.evaluate(() => {
const candidates = [
[4, innerHeight - 4],
[innerWidth - 4, innerHeight - 4],
[innerWidth / 2, innerHeight - 4],
[4, innerHeight / 2],
];
const trigger = [...document.querySelectorAll("body > header details > summary, body > header button[aria-expanded], body > header [role='button'][aria-expanded], body > header button[aria-haspopup]")]
.find((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
const details = trigger?.closest("details");
const controlled = trigger?.getAttribute("aria-controls");
const menu = details || (controlled && document.getElementById(controlled));
const hit = candidates.find(([x, y]) => {
const element = document.elementFromPoint(x, y);
return element && !menu?.contains(element) && !trigger?.contains(element);
});
return hit || null;
});
if (point) await page.mouse.click(point[0], point[1]);
else await page.evaluate(() => document.body.click());
await sleep(180);
}
async function visibleMenuHashAnchor(page) {
const handles = await page.$$("body > header a[href*='#']");
for (const handle of handles) {
const data = await handle.evaluate((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const target = new URL(element.href, location.href);
const visible = style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
const local = target.pathname === location.pathname && target.hash.length > 1;
return visible && local ? { id: target.hash.slice(1), href: element.getAttribute("href") } : null;
});
if (data) return { handle, ...data };
}
return null;
}
async function inspectAnchorLanding(page, id) {
return page.evaluate((targetId) => {
const target = document.getElementById(targetId);
const siteHeader = document.querySelector("body > header");
const headerStyle = siteHeader ? getComputedStyle(siteHeader) : null;
const headerRect = siteHeader?.getBoundingClientRect();
const headerBottom = siteHeader && headerRect && (headerStyle?.position === "fixed" || headerStyle?.position === "sticky") && headerRect.width > 0 && headerRect.height > 0
? headerRect.bottom
: 0;
return {
hash: location.hash,
path: location.pathname,
targetExists: Boolean(target),
targetTop: target ? Math.round(target.getBoundingClientRect().top * 10) / 10 : null,
headerBottom: Math.round(headerBottom * 10) / 10,
};
}, id);
}
async function waitForScrollSettle(page, { timeout = 2500, initialDelay = 120 } = {}) {
await sleep(initialDelay);
const started = Date.now();
let previous = await page.evaluate(() => scrollY);
let stableSamples = 0;
while (Date.now() - started < timeout) {
await sleep(80);
const current = await page.evaluate(() => scrollY);
if (Math.abs(current - previous) < 0.5) stableSamples += 1;
else stableSamples = 0;
previous = current;
if (stableSamples >= 3) return;
}
}
async function clickLocale(page, hreflang) {
let link = await firstVisibleHandle(page, `a[hreflang='${hreflang}']`);
if (!link) {
await pressMenuTrigger(page);
link = await firstVisibleHandle(page, `a[hreflang='${hreflang}']`);
}
if (!link) throw new Error(`No visible hreflang=${hreflang} link`);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 15000 }),
link.click(),
]);
await waitForPageReady(page);
}
async function focusProbe(page) {
return page.evaluate(() => {
const element = document.activeElement;
if (!(element instanceof HTMLElement)) return null;
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const details = element.closest("details");
const closedDetailsChild = Boolean(details && !details.open && element !== details.querySelector(":scope > summary"));
const siteHeader = document.querySelector("body > header");
const headerStyle = siteHeader ? getComputedStyle(siteHeader) : null;
const headerRect = siteHeader?.getBoundingClientRect();
const headerBottom = siteHeader && headerRect && (headerStyle?.position === "fixed" || headerStyle?.position === "sticky") && headerRect.width > 0 && headerRect.height > 0
? headerRect.bottom
: 0;
const inHeader = Boolean(siteHeader?.contains(element));
const isSkip = element.matches("a[href='#main']");
const hasIndicator = (style.outlineStyle !== "none" && parseFloat(style.outlineWidth) > 0) ||
(style.boxShadow !== "none") || (style.textDecorationLine !== "none");
return {
tag: element.tagName.toLowerCase(),
id: element.id,
classes: String(element.className || "").slice(0, 80),
href: element.getAttribute("href"),
label: (element.getAttribute("aria-label") || element.innerText || "").replace(/\s+/g, " ").trim().slice(0, 60),
focusVisible: element.matches(":focus-visible"),
hasIndicator,
opacityVisible: Number(style.opacity) > 0.05,
inViewport: rect.bottom > 1 && rect.top < innerHeight - 1 && rect.right > 1 && rect.left < innerWidth - 1,
occludedByHeader: !inHeader && !isSkip && rect.top < headerBottom - 1,
closedDetailsChild,
isBody: element === document.body,
rect: { left: Math.round(rect.left), top: Math.round(rect.top), right: Math.round(rect.right), bottom: Math.round(rect.bottom) },
viewport: { width: innerWidth, height: innerHeight },
};
});
}
async function inspectDirectMobileNav(page) {
return page.evaluate(() => {
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) > 0.05 && rect.width > 0 && rect.height > 0;
};
const siteHeader = document.querySelector("body > header");
const nav = siteHeader ? [...siteHeader.querySelectorAll("nav")].find((candidate) => visible(candidate) && candidate.querySelector("a[href^='#']")) : null;
if (!nav) return null;
const links = [...nav.querySelectorAll("a[href^='#']")].filter(visible);
const metrics = links.map((link) => {
const rect = link.getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(link);
const textRects = [...range.getClientRects()].filter((item) => item.width > 1 && item.height > 3);
const tops = textRects.map((item) => item.top).sort((a, b) => a - b);
const lines = [];
for (const top of tops) if (!lines.length || top - lines.at(-1) > 4) lines.push(top);
const textLeft = textRects.length ? Math.min(...textRects.map((item) => item.left)) : rect.left;
const textRight = textRects.length ? Math.max(...textRects.map((item) => item.right)) : rect.right;
return {
href: link.getAttribute("href"),
label: link.innerText.replace(/\s+/g, " ").trim(),
top: Math.round(rect.top * 10) / 10,
height: Math.round(rect.height * 10) / 10,
lines: lines.length,
textClipped: textLeft < rect.left - 1 || textRight > rect.right + 1,
};
});
const navRect = nav.getBoundingClientRect();
const headerRect = nav.closest("header")?.getBoundingClientRect();
const tops = metrics.map((item) => item.top);
return {
links: metrics,
topSpread: tops.length ? Math.max(...tops) - Math.min(...tops) : null,
navWidth: Math.round(navRect.width * 10) / 10,
navScrollWidth: nav.scrollWidth,
header: headerRect ? {
left: Math.round(headerRect.left * 10) / 10,
right: Math.round(headerRect.right * 10) / 10,
height: Math.round(headerRect.height * 10) / 10,
} : null,
viewport: innerWidth,
documentWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0),
};
});
}
async function exerciseCopy(page) {
return page.evaluate(async () => {
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
const button = [...document.querySelectorAll("button[data-copy]")].find(visible);
if (!(button instanceof HTMLButtonElement)) return null;
const shell = button.closest(".cmd-shell");
const label = button.querySelector(".cmd-copy-label");
const status = shell?.querySelector(".copy-status");
const before = {
command: button.getAttribute("data-copy") || "",
copiedLabel: button.getAttribute("data-copied") || "",
originalLabel: label?.textContent?.trim() || "",
};
button.click();
const started = performance.now();
while (!status?.textContent?.trim() && performance.now() - started < 1400) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
return {
...before,
label: label?.textContent?.trim() || "",
status: status?.textContent?.trim() || "",
selection: getSelection()?.toString() || "",
clipboard: window.__siteE2eClipboard || "",
};
});
}
async function runCopyVariant(browser, origin, route, mode) {
const opened = await openPage(browser, origin, route, {
width: 390,
clipboardMode: mode,
abortExternalFonts: true,
});
try {
return { state: await exerciseCopy(opened.page), runtime: [...opened.runtime] };
} finally {
await closePage(opened.page);
}
}
async function disableEffects(page) {
await page.addStyleTag({ content: `
*, *::before, *::after {
animation: none !important;
transition: none !important;
filter: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
mix-blend-mode: normal !important;
text-shadow: none !important;
}
canvas { visibility: hidden !important; }
html { scroll-behavior: auto !important; }
` });
await sleep(120);
}
async function settleTwoAnimationFrames(page) {
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
}
async function heroStackSnapshot(page) {
return page.evaluate(() => {
const figure = document.querySelector(".system-graphic");
const svg = figure?.querySelector("svg[role='img']") || null;
const layers = figure ? [...figure.querySelectorAll(".stage-layer")] : [];
const signature = (element) => {
const style = getComputedStyle(element);
let matrix = [1, 0, 0, 1, 0, 0];
if (style.transform && style.transform !== "none") {
try {
const value = new DOMMatrixReadOnly(style.transform);
matrix = [value.a, value.b, value.c, value.d, value.e, value.f];
} catch { /* malformed computed transform remains the identity */ }
}
return {
opacity: Number.parseFloat(style.opacity) || 0,
matrix: matrix.map((value) => Math.round(value * 1000) / 1000),
animationName: style.animationName,
display: style.display,
visibility: style.visibility,
};
};
const rect = figure?.getBoundingClientRect() || null;
const animations = figure
? document.getAnimations({ subtree: true }).filter((animation) => {
const target = animation.effect?.target;
return target instanceof Element && figure.contains(target);
})
: [];
const labelledBy = (svg?.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean);
const labelledNodesExist = labelledBy.length >= 2 && labelledBy.every((id) => Boolean(document.getElementById(id)));
return {
exists: Boolean(figure && svg),
semantic: Boolean(
svg?.querySelector("title")?.textContent?.trim() &&
svg?.querySelector("desc")?.textContent?.trim() &&
figure?.querySelector("figcaption")?.textContent?.trim() &&
labelledNodesExist
),
layerCount: layers.length,
layers: layers.map(signature),
visibleLayerCount: layers.filter((layer) => {
const style = getComputedStyle(layer);
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) > 0.05;
}).length,
heroAnimationCount: animations.length,
supportsScrollTimeline: CSS.supports("animation-timeline: scroll()"),
heroTop: rect ? Math.round(scrollY + rect.top) : 0,
heroHeight: rect ? Math.round(rect.height) : 0,
maxScroll: Math.max(0, document.documentElement.scrollHeight - innerHeight),
scrollY: Math.round(scrollY),
};
});
}
async function scrollHeroAndSnapshot(page, y) {
await page.evaluate((top) => window.scrollTo(0, top), y);
await waitForScrollSettle(page);
await settleTwoAnimationFrames(page);
return heroStackSnapshot(page);
}
function layerSignaturesEqual(a, b, tolerance = 0.05) {
if (!a || !b || a.matrix.length !== b.matrix.length) return false;
if (Math.abs(a.opacity - b.opacity) > tolerance) return false;
return a.matrix.every((value, index) => Math.abs(value - b.matrix[index]) <= tolerance);
}
function changedLayerCount(a, b) {
const count = Math.min(a?.layers?.length || 0, b?.layers?.length || 0);
let changed = 0;
for (let index = 0; index < count; index += 1) {
if (!layerSignaturesEqual(a.layers[index], b.layers[index])) changed += 1;
}
return changed;
}
function restoredLayerCount(initial, restored) {
const count = Math.min(initial?.layers?.length || 0, restored?.layers?.length || 0);
let matched = 0;
for (let index = 0; index < count; index += 1) {
if (layerSignaturesEqual(initial.layers[index], restored.layers[index])) matched += 1;
}
return matched;
}
function uniqueLayerSignatureCount(snapshot) {
return new Set((snapshot?.layers || []).map((layer) => JSON.stringify({
opacity: Math.round(layer.opacity * 100) / 100,
matrix: layer.matrix.map((value) => Math.round(value * 20) / 20),
}))).size;
}
const LOWER_MOTION_EXPECTED_COUNTS = Object.freeze({
direction: 5,
type: 4,
proof: 7,
process: 7,
});
// The 1440 viewport is the proof specimen's fixed registration frame. The two
// smaller viewports resolve against it, so six of seven proof graphics move.
const LOWER_MOTION_EXPECTED_CHANGES = Object.freeze({
direction: 5,
type: 4,
proof: 6,
process: 7,
});
async function lowerMotionSnapshot(page) {
return page.evaluate(() => {
const contracts = [
{ key: "direction", selector: ".direction-field-inner, .direction-vector, .direction-node" },
{ key: "type", selector: ".type-glyphs, .token-sample" },
{ key: "proof", selector: ".viewport, .check-register svg" },
{ key: "process", selector: ".stage-list li", pseudo: "::after" },
];
const rounded = (value) => Math.round(value * 1000) / 1000;
const numericPair = (value, fallback) => {
if (!value || value === "none") return [...fallback];
const values = value.match(/-?(?:\d+\.?\d*|\.\d+)/g)?.map(Number) || [];
if (!values.length) return [...fallback];
return [rounded(values[0]), rounded(values[1] ?? values[0])];
};
const signature = (element, pseudo = null) => {
const style = getComputedStyle(element, pseudo);
let matrix = [1, 0, 0, 1, 0, 0];
if (style.transform && style.transform !== "none") {
try {
const value = new DOMMatrixReadOnly(style.transform);
matrix = [value.a, value.b, value.c, value.d, value.e, value.f].map(rounded);
} catch { /* malformed computed transform remains the identity */ }
}
const translate = numericPair(style.translate, [0, 0]);
const scale = numericPair(style.scale, [1, 1]);
const opacity = rounded(Number.parseFloat(style.opacity) || 0);
const final = opacity >= 0.99
&& translate.every((value) => Math.abs(value) <= 0.05)
&& scale.every((value) => Math.abs(value - 1) <= 0.05)
&& matrix.every((value, index) => Math.abs(value - [1, 0, 0, 1, 0, 0][index]) <= 0.05);
return {
animationName: style.animationName,
final,
opacity,
translate,
scale,
matrix,
};
};
const groups = {};
for (const contract of contracts) {
const elements = [...document.querySelectorAll(contract.selector)];
const items = elements.map((element) => signature(element, contract.pseudo || null));
const animationObjects = elements.flatMap((element) => element.getAnimations({ subtree: false }));
groups[contract.key] = {
count: items.length,
animationObjectCount: new Set(animationObjects).size,
items,
};
}
return {
groups,
supportsViewTimeline: CSS.supports("animation-timeline: view()"),
maxScroll: Math.max(0, document.documentElement.scrollHeight - innerHeight),
scrollY: Math.round(scrollY),
};
});
}
async function scrollLowerMotionAndSnapshot(page, key, { phase = "past", settle = true } = {}) {
const targetGeometry = await page.evaluate((motionKey, motionPhase) => {
const selector = motionKey === "direction" ? ".specimen-direction"
: motionKey === "type" ? ".specimen-tokens"
: motionKey === "proof" ? ".specimen-proof"
: ".stage-list";
const target = document.querySelector(selector);
if (!target) return null;
const rect = target.getBoundingClientRect();
const absoluteTop = scrollY + rect.top;
const edgeMargin = 32;
const desired = motionPhase === "before"
? absoluteTop - innerHeight - edgeMargin
: motionKey === "process"
? absoluteTop + rect.height + edgeMargin
: absoluteTop + edgeMargin;
const max = Math.max(0, document.documentElement.scrollHeight - innerHeight);
const scrollTop = Math.max(0, Math.min(max, desired));
window.scrollTo({ top: scrollTop, behavior: "instant" });
return { edgeMargin, key: motionKey, phase: motionPhase, scrollTop, selector };
}, key, phase);
if (!targetGeometry) throw new Error(`Lower-motion target is missing for ${key}`);
const geometryAtTarget = async () => page.evaluate(({ selector, phase: motionPhase, edgeMargin, key: motionKey }) => {
const rect = document.querySelector(selector)?.getBoundingClientRect();
if (!rect) return null;
const beforeEntry = rect.top >= innerHeight + edgeMargin - 1;
const pastRange = motionKey === "process"
? rect.bottom <= edgeMargin * -1 + 1
: rect.top <= edgeMargin * -1 + 1;
return {
beforeEntry,
bottom: Math.round(rect.bottom * 10) / 10,
pastRange,
phase: motionPhase,
scrollY: Math.round(scrollY),
top: Math.round(rect.top * 10) / 10,
viewportHeight: innerHeight,
};
}, targetGeometry);
const timeoutAt = Date.now() + 1500;
let measuredGeometry = null;
while (Date.now() < timeoutAt) {
measuredGeometry = await geometryAtTarget();
const reached = phase === "before" ? measuredGeometry?.beforeEntry : measuredGeometry?.pastRange;
if (reached) break;
await sleep(20);
}
const reached = phase === "before" ? measuredGeometry?.beforeEntry : measuredGeometry?.pastRange;
if (!reached) {
throw new Error(`Lower-motion ${key} failed to reach ${phase} geometry: ${JSON.stringify(measuredGeometry)}`);
}
if (settle) await settleTwoAnimationFrames(page);
else await sleep(60);
const snapshot = await lowerMotionSnapshot(page);
snapshot.targetGeometry = await geometryAtTarget();
return snapshot;
}
function motionSignaturesEqual(a, b, tolerance = 0.05) {
if (!a || !b) return false;
if (Math.abs(a.opacity - b.opacity) > tolerance) return false;
for (const key of ["translate", "scale", "matrix"]) {
if (!Array.isArray(a[key]) || a[key].length !== b[key]?.length) return false;
if (a[key].some((value, index) => Math.abs(value - b[key][index]) > tolerance)) return false;
}
return true;
}
function changedMotionCount(a, b) {
const count = Math.min(a?.items?.length || 0, b?.items?.length || 0);
let changed = 0;
for (let index = 0; index < count; index += 1) {
if (!motionSignaturesEqual(a.items[index], b.items[index])) changed += 1;
}
return changed;
}
function restoredMotionCount(initial, restored) {
const count = Math.min(initial?.items?.length || 0, restored?.items?.length || 0);
let matched = 0;
for (let index = 0; index < count; index += 1) {
if (motionSignaturesEqual(initial.items[index], restored.items[index])) matched += 1;
}
return matched;
}
function lowerMotionSummary(snapshot) {
const groups = snapshot?.groups || {};
return Object.fromEntries(Object.entries(groups).map(([key, group]) => [key, {
animationNames: [...new Set(group.items.map((item) => item.animationName))],
animationObjectCount: group.animationObjectCount,
count: group.count,
finalCount: group.items.filter((item) => item.final).length,
}]));
}
async function main() {
const options = parseArguments(process.argv);
if (options.help) {
showHelp();
return;
}
const serveRoot = path.resolve(siteRoot, options.root);
const outputRoot = path.resolve(siteRoot, options.output);
if (!fs.existsSync(serveRoot) || !fs.statSync(serveRoot).isDirectory()) {
throw new Error(`Built root does not exist: ${serveRoot}\nRun \"npm run build\" from apps/site first.`);
}
for (const route of ROUTES) {
const pathname = new URL(route.path, "http://127.0.0.1").pathname;
if (!existingFileForRequest(serveRoot, pathname)) {
throw new Error(`Built route is missing: ${route.path} under ${serveRoot}`);
}
}
if (!fs.existsSync(axePath)) throw new Error(`axe-core asset is missing: ${axePath}`);
const runId = new Date().toISOString().replace(/[:.]/g, "-");
const evidenceRoot = path.join(outputRoot, runId);
const results = new Results();
const captures = [];
const lowerMotionGeometryEvidence = [];
const performanceSamples = [];
let server;
let browser;
try {
server = await startStaticServer(serveRoot);
browser = await puppeteer.launch({
executablePath: resolveChrome(),
headless: true,
args: [
"--disable-dev-shm-usage",
"--force-device-scale-factor=1",
"--hide-scrollbars",
"--use-angle=swiftshader",
],
});
console.log(`Serving ${serveRoot}`);
console.log(`Evidence ${evidenceRoot}\n`);
// Four routes across the complete local preflight width matrix. Only the
// 390/1440 states create visual evidence; every width gets the full geometry gate.
for (const route of ROUTES) {
for (const width of GEOMETRY_WIDTHS) {
const scope = `responsive ${route.key} ${width}px`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width, monitorPerformance: width === 390 });
try {
const layout = await inspectLayout(opened.page);
const axe = await runAxe(opened.page);
results.equal(scope, "HTTP status", 200, opened.status);
results.equal(scope, "html lang", route.lang, layout.lang);
results.equal(scope, "one main landmark", 1, layout.mainCount);
results.equal(scope, "one h1", 1, layout.h1Count);
results.check(scope, "marketing h1 uses one or two real text lines", layout.h1Lines >= 1 && layout.h1Lines <= 2, {
expected: "1..2 text-node line rects",
actual: { lines: layout.h1Lines, text: layout.h1Text },
});
results.check(scope, "no horizontal document overflow", layout.documentWidth <= layout.viewportWidth + 1, {
expected: `<= ${layout.viewportWidth + 1}`,
actual: layout.documentWidth,
});
results.empty(scope, "no unclipped element protrudes past viewport", layout.protrusions);
results.empty(scope, "readable text keeps at least 12px left inset", layout.leftInsetProblems);
results.empty(scope, "readable text stays inside both viewport edges", layout.edgeText);
results.empty(scope, "control labels are unclipped and reasonably wrapped", layout.controlProblems);
results.empty(scope, "independent controls are at least 44px by 44px", layout.touchTargetProblems);
results.empty(scope, "button text keeps at least 8px left/right inset", layout.buttonInsetProblems);
results.empty(scope, "all images decode", layout.imageFailures);
results.empty(scope, "every image has an alt attribute", layout.imageAltFailures);
results.empty(scope, "no runtime/local-resource errors", opened.runtime);
results.check(scope, "body starts with a non-generic font family", !layout.genericFirst, {
expected: "custom first font family",
actual: layout.bodyFont,
});
results.empty(scope, "axe WCAG A/AA violations", axe, axe.length ? "Inspect violation targets in actual output." : "");
if (width === 390) {
performanceSamples.push({ route: route.path, width, ...(await collectPerformance(opened)) });
}
if (CAPTURE_WIDTHS.has(width)) {
// Freeze style/motion before the raster walk. Mutating the page
// after priming can invalidate Chrome's offscreen paint surfaces.
await freezeForScreenshot(opened.page);
const primed = await primeLazyImages(opened.page);
results.empty(scope, "viewport walk and capture load every visible image", primed.failures);
const shot = await capture(opened.page, evidenceRoot, `${route.key}-${width}`);
results.empty(scope, "captured images are not flat placeholder frames", shot.flatImageFrames);
captures.push(shot);
results.equal(scope, "screenshot width", width, shot.width);
results.check(scope, "screenshot covers at least one viewport", shot.height >= opened.height, {
expected: `>= ${opened.height}`,
actual: shot.height,
});
results.check(scope, "screenshot is non-empty PNG evidence", shot.bytes > 10000 && /^[a-f0-9]{64}$/.test(shot.sha256), {
expected: "> 10000 bytes and SHA-256",
actual: { bytes: shot.bytes, sha256: shot.sha256 },
});
}
} finally {
await closePage(opened.page);
}
});
}
}
// The home compiler stack is scroll-driven in capable Chromium builds.
// Exercise a real forward and reverse trip; a load-only animation is not
// sufficient evidence for this contract.
for (const route of ROUTES.slice(0, 2)) {
const scope = `hero scroll ${route.key}`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width: 1440 });
try {
const top = await scrollHeroAndSnapshot(opened.page, 0);
const midY = Math.min(top.maxScroll, Math.round(top.heroTop + top.heroHeight * 0.55));
const mid = await scrollHeroAndSnapshot(opened.page, midY);
const restored = await scrollHeroAndSnapshot(opened.page, 0);
const changed = changedLayerCount(top, mid);
const uniqueMid = uniqueLayerSignatureCount(mid);
const restoredCount = restoredLayerCount(top, restored);
results.equal(scope, "semantic hero figure exists", true, top.exists);
results.equal(scope, "SVG title, description, and caption form an accessible name", true, top.semantic);
results.equal(scope, "compiler stack has exactly seven layers", 7, top.layerCount);
results.equal(scope, "Chromium supports the scroll animation timeline", true, top.supportsScrollTimeline);
results.check(scope, "top-to-mid scroll changes at least five layers", changed >= 5, {
expected: ">= 5 changed layers",
actual: { changed, midY, top: top.scrollY, mid: mid.scrollY },
});
results.check(scope, "mid-scroll layers keep distinct spatial signatures", uniqueMid >= 3, {
expected: ">= 3 distinct signatures",
actual: uniqueMid,
});
results.equal(scope, "reverse scroll restores all seven initial layer states", 7, restoredCount);
results.empty(scope, "hero scroll trip has no runtime/local-resource errors", opened.runtime);
} finally {
await closePage(opened.page);
}
});
}
// Below the hero, each authored graphic uses a native view timeline. Walk
// the page forward in reading order, then reverse to the initial state.
// The process rail is a pseudo-element; its signature is read from
// getComputedStyle(li, "::after") rather than inferred from the row text.
for (const route of ROUTES.slice(0, 2)) {
const scope = `lower graphic scroll ${route.key}`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width: 1440 });
try {
const before = {};
const forward = {};
const reverse = {};
for (const key of Object.keys(LOWER_MOTION_EXPECTED_COUNTS)) {
const beforeSnapshot = await scrollLowerMotionAndSnapshot(opened.page, key, { phase: "before" });
const forwardSnapshot = await scrollLowerMotionAndSnapshot(opened.page, key, { phase: "past" });
const reverseSnapshot = await scrollLowerMotionAndSnapshot(opened.page, key, { phase: "before" });
before[key] = beforeSnapshot.groups[key];
forward[key] = forwardSnapshot.groups[key];
reverse[key] = reverseSnapshot.groups[key];
lowerMotionGeometryEvidence.push({
before: beforeSnapshot.targetGeometry,
forward: forwardSnapshot.targetGeometry,
key,
reverse: reverseSnapshot.targetGeometry,
route: route.key,
});
}
const initial = await lowerMotionSnapshot(opened.page);
results.equal(scope, "Chromium supports native view timelines", true, initial.supportsViewTimeline);
for (const [key, expectedCount] of Object.entries(LOWER_MOTION_EXPECTED_COUNTS)) {
const group = initial.groups[key];
results.equal(scope, `${key} required animated element count`, expectedCount, group?.count);
}
for (const [key, expectedCount] of Object.entries(LOWER_MOTION_EXPECTED_COUNTS)) {
const group = initial.groups[key];
const namesPresent = group?.items.every((item) => item.animationName && item.animationName !== "none");
const objectsPresent = key === "process" || group.animationObjectCount >= expectedCount;
results.check(scope, `${key} owns CSS animation names and objects`, Boolean(namesPresent && objectsPresent), {
expected: {
named: expectedCount,
animationObjects: key === "process" ? "pseudo-element names are authoritative" : `>= ${expectedCount}`,
},
actual: lowerMotionSummary(initial)[key],
});
}
for (const [key, expectedCount] of Object.entries(LOWER_MOTION_EXPECTED_CHANGES)) {
const changed = changedMotionCount(before[key], forward[key]);
results.equal(scope, `${key} forward scroll changes required graphic signatures`, expectedCount, changed);
}
for (const [key, expectedCount] of Object.entries(LOWER_MOTION_EXPECTED_COUNTS)) {
const matched = restoredMotionCount(before[key], reverse[key]);
results.equal(scope, `${key} reverse scroll restores every initial signature`, expectedCount, matched);
}
results.empty(scope, "lower graphic forward/reverse trip has no runtime/local-resource errors", opened.runtime);
} finally {
await closePage(opened.page);
}
});
}
for (const key of Object.keys(LOWER_MOTION_EXPECTED_COUNTS)) {
const samples = lowerMotionGeometryEvidence.filter((item) => item.key === key);
results.check("lower graphic geometry hard lock", `${key} samples cross before-entry and past-range geometry in both locales`, samples.length === 2 && samples.every((item) => (
item.before?.beforeEntry
&& item.forward?.pastRange
&& item.reverse?.beforeEntry
)), {
expected: "KO and EN: beforeEntry -> pastRange -> beforeEntry",
actual: samples,
});
}
results.equal("lower graphic geometry hard lock", "every locale and lower graphic key is sampled", 8, lowerMotionGeometryEvidence.length);
for (const route of ROUTES) {
const scope = `metadata ${route.key}`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width: 1024 });
try {
const seo = await inspectSeo(opened.page, route, server.origin);
results.equal(scope, "HTTP status", 200, opened.status);
results.equal(scope, "one title element", 1, seo.titleCount);
results.check(scope, "title is non-empty", seo.title.length >= 8, { actual: seo.title });
results.check(scope, "one non-empty meta description", seo.descriptions.length === 1 && seo.descriptions[0].trim().length >= 24, { actual: seo.descriptions });
results.equal(scope, "one canonical URL", 1, seo.canonicals.length);
results.equal(scope, "canonical points to current route", true, seo.canonicalMatches);
results.check(scope, "one non-empty og:title", seo.ogTitles.length === 1 && seo.ogTitles[0].trim().length > 0, { actual: seo.ogTitles });
results.check(scope, "one non-empty og:description", seo.ogDescriptions.length === 1 && seo.ogDescriptions[0].trim().length > 0, { actual: seo.ogDescriptions });
results.check(scope, "one route-correct og:url", seo.ogUrls.length === 1 && seo.ogUrlMatches, { actual: seo.ogUrls });
results.check(scope, "one absolute og:image", seo.ogImages.length === 1 && seo.ogImageAbsolute, { actual: seo.ogImages });
results.equal(scope, "og:image asset loads from current build", true, seo.ogImageLoaded);
results.check(scope, "one valid twitter:card", seo.twitterCards.length === 1 && ["summary", "summary_large_image"].includes(seo.twitterCards[0]), { actual: seo.twitterCards });
results.check(scope, "hreflang values are present and unique", seo.alternates.length >= 2 && seo.duplicateHreflang.length === 0, { actual: seo.alternates });
results.equal(scope, "paired translation hreflang is correct", true, seo.translatedMatches);
results.empty(scope, "hreflang URLs share the canonical origin", seo.alternateOriginProblems);
results.empty(scope, "metadata page has no runtime/local-resource errors", opened.runtime);
} finally {
await closePage(opened.page);
}
});
}
const homeAnchorIds = new Map();
// Discover the current meaningful navigation IDs instead of coupling this
// gate to yesterday's labels or section names.
for (const route of ROUTES.slice(0, 2)) {
const inventoryScope = `anchor inventory ${route.key}`;
await results.scenario(inventoryScope, async () => {
const opened = await openPage(browser, server.origin, route, { width: 1024 });
try {
const inventory = await opened.page.evaluate(() => {
const ids = [...new Set([...document.querySelectorAll("body > header nav a[href]")]
.filter((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
})
.map((element) => element.getAttribute("href"))
.filter((href) => href?.startsWith("#") && href.length > 1)
.map((href) => href.slice(1)))];
const output = document.getElementById("outputs");
const specimenGroup = output?.querySelector('.output-specimens[role="group"][aria-labelledby]');
const specimens = specimenGroup
? [...specimenGroup.querySelectorAll(':scope > article[aria-labelledby]')]
: [];
const groupLabelId = specimenGroup?.getAttribute("aria-labelledby") || "";
const groupLabel = groupLabelId ? document.getElementById(groupLabelId) : null;
const labelledSpecimenCount = specimens.filter((article) => {
const labelId = article.getAttribute("aria-labelledby");
const label = labelId ? document.getElementById(labelId) : null;
return Boolean(label?.textContent?.trim());
}).length;
return {
ids,
missing: ids.filter((id) => !document.getElementById(id)),
outputSurface: {
exists: Boolean(output),
imageCount: output?.querySelectorAll("img").length ?? -1,
specimenCount: specimens.length,
labelledSpecimenCount,
groupLabelled: Boolean(groupLabel?.textContent?.trim()),
},
};
});
homeAnchorIds.set(route.key, inventory.ids);
results.equal(inventoryScope, "header exposes exactly four meaningful local section anchors", 4, inventory.ids.length);
results.empty(inventoryScope, "every linked section exists", inventory.missing, {
expected: "all anchor targets exist",
actual: inventory.missing,
});
const output = inventory.outputSurface;
results.check(
inventoryScope,
"homepage output uses three labelled semantic specimens and no project images",
output.exists
&& output.imageCount === 0
&& output.specimenCount === 3
&& output.labelledSpecimenCount === 3
&& output.groupLabelled,
{
expected: {
exists: true,
imageCount: 0,
specimenCount: 3,
labelledSpecimenCount: 3,
groupLabelled: true,
},
actual: output,
},
);
} finally {
await closePage(opened.page);
}
});
const scope = `anchor clicks ${route.key}`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width: 1024 });
try {
const ids = homeAnchorIds.get(route.key) || [];
const missingLinks = [];
const wrongHashes = [];
const missingTargets = [];
const coveredTargets = [];
for (const id of ids) {
const anchor = await firstVisibleHandle(opened.page, `body > header a[href='#${id}']`);
if (!anchor) {
missingLinks.push(id);
continue;
}
await anchor.click();
await waitForScrollSettle(opened.page);
const landing = await inspectAnchorLanding(opened.page, id);
if (landing.hash !== `#${id}`) wrongHashes.push({ id, actual: landing.hash });
if (!landing.targetExists) missingTargets.push(id);
if (!landing.targetExists || landing.targetTop < landing.headerBottom - 1) coveredTargets.push({ id, ...landing });
}
results.equal(scope, "four anchors are exercised", 4, ids.length);
results.empty(scope, "every semantic header link is visible", missingLinks);
results.empty(scope, "every click updates the expected hash", wrongHashes);
results.empty(scope, "every clicked target exists", missingTargets);
results.empty(scope, "sticky header covers no clicked target", coveredTargets);
} finally {
await closePage(opened.page);
}
});
}
// The four-item mobile GNB intentionally remains direct navigation. It must
// stay one row at 320px, keep 44px-class hit targets, and land every anchor
// below the sticky header. If a collapsible menu is introduced later, these
// direct-nav checks yield to the conditional menu contract below.
for (const route of ROUTES.slice(0, 2)) {
for (const width of [320, 390, 768]) {
const scope = `direct mobile nav ${route.key} ${width}px`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width });
try {
const collapsible = await menuSnapshot(opened.page);
if (collapsible) {
for (const name of ["semantic nav", "link inventory", "single row", "no wrapping", "no clipping", "touch height", "internal overflow", "header bounds", "document overflow"]) {
results.check(scope, `${name} handled by collapsible-menu contract`, true, { actual: collapsible.triggerTag });
}
return;
}
const nav = await inspectDirectMobileNav(opened.page);
results.check(scope, "visible semantic direct nav exists", Boolean(nav), { actual: nav });
if (!nav) return;
results.check(scope, "all meaningful section links stay visible", nav.links.length >= 3, {
expected: ">= 3 links",
actual: nav.links,
});
results.check(scope, "nav links share one row top", nav.topSpread <= 1, {
expected: "<= 1px top spread",
actual: nav.topSpread,
});
results.empty(scope, "nav labels do not wrap", nav.links.filter((item) => item.lines !== 1));
results.empty(scope, "nav labels are not clipped", nav.links.filter((item) => item.textClipped));
results.empty(scope, "each nav link has at least a 44px hit height", nav.links.filter((item) => item.height < 44));
results.check(scope, "nav row has no internal overflow", nav.navScrollWidth <= nav.navWidth + 1, {
expected: `<= ${nav.navWidth + 1}`,
actual: nav.navScrollWidth,
});
results.check(scope, "header stays inside viewport", Boolean(nav.header) && nav.header.left >= -1 && nav.header.right <= nav.viewport + 1, {
expected: `0..${nav.viewport}`,
actual: nav.header,
});
results.check(scope, "320-class document has no horizontal overflow", nav.documentWidth <= nav.viewport + 1, {
expected: `<= ${nav.viewport + 1}`,
actual: nav.documentWidth,
});
} finally {
await closePage(opened.page);
}
});
}
const clickScope = `direct mobile nav clicks ${route.key} 320px`;
await results.scenario(clickScope, async () => {
const opened = await openPage(browser, server.origin, route, { width: 320 });
try {
if (await menuSnapshot(opened.page)) {
for (const name of ["inventory", "visible links", "hashes", "targets", "sticky clearance"]) {
results.check(clickScope, `${name} handled by collapsible-menu contract`, true);
}
return;
}
const ids = homeAnchorIds.get(route.key) || [];
const missingLinks = [];
const wrongHashes = [];
const missingTargets = [];
const coveredTargets = [];
for (const id of ids) {
const anchor = await firstVisibleHandle(opened.page, `body > header nav a[href='#${id}']`);
if (!anchor) {
missingLinks.push(id);
continue;
}
await anchor.click();
await waitForScrollSettle(opened.page);
const landing = await inspectAnchorLanding(opened.page, id);
if (landing.hash !== `#${id}`) wrongHashes.push({ id, actual: landing.hash });
if (!landing.targetExists) missingTargets.push(id);
if (!landing.targetExists || landing.targetTop < landing.headerBottom - 1) coveredTargets.push({ id, ...landing });
}
results.equal(clickScope, "four mobile anchors are exercised", 4, ids.length);
results.empty(clickScope, "every mobile anchor is visible", missingLinks);
results.empty(clickScope, "every mobile click updates hash", wrongHashes);
results.empty(clickScope, "every mobile target exists", missingTargets);
results.empty(clickScope, "sticky mobile header covers no target", coveredTargets);
} finally {
await closePage(opened.page);
}
});
}
for (const route of ROUTES.slice(2)) {
const scope = `anchor ${route.key} to home`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width: 1024 });
try {
const link = await opened.page.evaluate(() => {
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
const anchor = [...document.querySelectorAll("body > header nav a[href*='#']")].find(visible);
if (!anchor) return null;
const target = new URL(anchor.href, location.href);
return { href: anchor.getAttribute("href"), id: target.hash.slice(1), path: target.pathname };
});
const anchor = link ? await firstVisibleHandle(opened.page, `body > header nav a[href='${link.href}']`) : null;
let landing = null;
if (anchor && link?.id) {
await Promise.all([
opened.page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 15000 }),
anchor.click(),
]);
await waitForPageReady(opened.page);
await waitForScrollSettle(opened.page);
landing = await inspectAnchorLanding(opened.page, link.id);
}
const expectedHome = route.lang === "ko" ? "/" : "/en/";
results.check(scope, "visible cross-route anchor exists", Boolean(anchor && link?.id), { actual: link });
results.equal(scope, "destination path is localized home", expectedHome, landing?.path);
results.equal(scope, "destination hash matches clicked anchor", link?.id ? `#${link.id}` : "missing", landing?.hash);
results.check(scope, "target is not covered", Boolean(landing?.targetExists && landing.targetTop >= landing.headerBottom - 1), { actual: landing });
} finally {
await closePage(opened.page);
}
});
}
// The same mobile menu contract is required on the compact phone and tablet layouts.
for (const width of [390, 768]) {
const scope = `mobile menu ${width}px`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, ROUTES[0], { width });
try {
const before = await menuSnapshot(opened.page);
if (!before) {
for (const name of ["trigger", "initial state", "Enter", "links", "Tab", "Escape", "focus return", "outside click", "local link", "link close", "hash", "sticky clearance"]) {
results.check(scope, `${name} not applicable; direct-nav contract applies`, true);
}
return;
}
results.check(scope, "semantic menu trigger exists", true, { actual: before });
results.equal(scope, "menu starts closed", false, before.open);
await pressMenuTrigger(opened.page, "Enter");
const enterOpen = await menuSnapshot(opened.page);
results.equal(scope, "Enter opens menu", true, enterOpen?.open);
results.check(scope, "open menu exposes navigation links", (enterOpen?.linkCount || 0) >= 3, {
expected: ">= 3",
actual: enterOpen?.linkCount,
});
await opened.page.keyboard.press("Tab");
const menuFocus = await menuSnapshot(opened.page);
results.equal(scope, "Tab enters open menu", true, menuFocus?.focusInside);
await opened.page.keyboard.press("Escape");
await sleep(180);
const escapeClosed = await menuSnapshot(opened.page);
results.equal(scope, "Escape closes menu", false, escapeClosed?.open);
results.equal(scope, "Escape restores trigger focus", true, escapeClosed?.triggerFocused);
await pressMenuTrigger(opened.page, "Enter");
await clickOutsideMenu(opened.page);
const outsideClosed = await menuSnapshot(opened.page);
results.equal(scope, "outside pointer click closes menu", false, outsideClosed?.open);
await pressMenuTrigger(opened.page, "Enter");
const link = await visibleMenuHashAnchor(opened.page);
results.check(scope, "visible local section link exists inside menu", Boolean(link), { actual: link ? { id: link.id, href: link.href } : null });
let linkClosed = null;
let landing = null;
if (link) {
await link.handle.click();
await waitForScrollSettle(opened.page);
linkClosed = await menuSnapshot(opened.page);
landing = await inspectAnchorLanding(opened.page, link.id);
}
results.equal(scope, "menu link closes menu", false, linkClosed?.open);
results.equal(scope, "menu link updates hash", link ? `#${link.id}` : "missing", landing?.hash);
results.check(scope, "menu anchor is not covered", Boolean(landing?.targetExists && landing.targetTop >= landing.headerBottom - 1), { actual: landing });
} finally {
await closePage(opened.page);
}
});
}
// KO -> EN -> KO must work both with the compact menu and desktop header.
for (const width of [390, 1024]) {
const scope = `locale round trip ${width}px`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, ROUTES[0], { width });
try {
await clickLocale(opened.page, "en");
const english = await opened.page.evaluate(() => ({ path: location.pathname, lang: document.documentElement.lang }));
results.equal(scope, "KO to EN path", "/en/", english.path);
results.equal(scope, "KO to EN html lang", "en", english.lang);
await clickLocale(opened.page, "ko");
const korean = await opened.page.evaluate(() => ({ path: location.pathname, lang: document.documentElement.lang }));
results.equal(scope, "EN to KO path", "/", korean.path);
results.equal(scope, "EN to KO html lang", "ko", korean.lang);
} finally {
await closePage(opened.page);
}
});
}
// Keyboard navigation is sampled on every route in compact and desktop modes.
for (const route of ROUTES) {
for (const width of [390, 1024]) {
const scope = `keyboard ${route.key} ${width}px`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width });
try {
await opened.page.evaluate(() => {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
window.scrollTo(0, 0);
});
await opened.page.keyboard.press("Tab");
await sleep(400);
const first = await focusProbe(opened.page);
results.equal(scope, "first Tab reaches skip link", "#main", first?.href);
results.check(scope, "skip link has visible focus treatment", first?.focusVisible && first?.hasIndicator, { actual: first });
await opened.page.keyboard.press("Enter");
await sleep(120);
const skipLanding = await opened.page.evaluate(() => ({ hash: location.hash, main: Boolean(document.querySelector("main#main")) }));
results.equal(scope, "skip link targets main", "#main", skipLanding.hash);
results.equal(scope, "skip target exists", true, skipLanding.main);
await opened.page.reload({ waitUntil: "domcontentloaded" });
await waitForPageReady(opened.page);
const focusableCount = await opened.page.evaluate(() => {
const candidates = [...document.querySelectorAll("a[href],button:not([disabled]),summary,input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1'])")];
return candidates.filter((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const details = element.closest("details");
if (details && !details.open && element !== details.querySelector(":scope > summary")) return false;
if (element.closest("dialog:not([open])")) return false;
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
}).length;
});
const sequence = [];
for (let index = 0; index < Math.min(10, focusableCount); index += 1) {
await opened.page.keyboard.press("Tab");
// Focus scrolling follows the document's scroll-behavior. Sample the
// settled position, not the first frame of a smooth scroll.
await waitForScrollSettle(opened.page, { timeout: 1800, initialDelay: 100 });
await opened.page.waitForFunction(() => {
const element = document.activeElement;
if (!(element instanceof HTMLElement)) return false;
const rect = element.getBoundingClientRect();
return rect.bottom > 1 && rect.top < innerHeight - 1 && rect.right > 1 && rect.left < innerWidth - 1;
}, { timeout: 900 }).catch(() => undefined);
const probe = await focusProbe(opened.page);
if (probe) sequence.push(probe);
}
const unique = new Set(sequence.map((item) => `${item.tag}|${item.id}|${item.classes}|${item.href}|${item.label}`));
const badFocus = sequence.filter((item) => item.isBody || !item.focusVisible || !item.hasIndicator || !item.opacityVisible || !item.inViewport || item.occludedByHeader || item.closedDetailsChild);
results.check(scope, "Tab sequence reaches distinct controls", unique.size >= 4, {
expected: ">= 4 unique controls",
actual: [...unique],
});
results.empty(scope, "focused controls stay visible, indicated, and outside closed menu", badFocus);
} finally {
await closePage(opened.page);
}
});
}
}
// Reduced-motion pages should settle rather than continuing a canvas/rAF loop.
for (const route of ROUTES) {
const scope = `reduced motion ${route.key}`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width: 1024, reducedMotion: true, monitorRaf: true });
try {
const before = await opened.page.evaluate(() => window.__siteE2eRafCount || 0);
await sleep(500);
const reduced = await opened.page.evaluate(() => ({
matches: matchMedia("(prefers-reduced-motion: reduce)").matches,
rafCount: window.__siteE2eRafCount || 0,
runningAnimations: document.getAnimations({ subtree: true })
.filter((animation) => animation.playState === "running")
.map((animation) => {
const target = animation.effect?.target;
return {
target: target instanceof Element ? `${target.tagName.toLowerCase()}.${String(target.className || "").replace(/\s+/g, ".")}` : "unknown",
duration: animation.effect?.getComputedTiming()?.duration,
};
}),
scrollBehavior: getComputedStyle(document.documentElement).scrollBehavior,
}));
results.equal(scope, "reduced-motion media query is active", true, reduced.matches);
results.empty(scope, "no CSS/Web Animation keeps running", reduced.runningAnimations);
results.check(scope, "no continuous requestAnimationFrame loop", reduced.rafCount - before <= 4, {
expected: "<= 4 callbacks in 500ms after settle",
actual: reduced.rafCount - before,
});
results.check(scope, "reduced mode does not force smooth scrolling", reduced.scrollBehavior !== "smooth", {
expected: "not smooth",
actual: reduced.scrollBehavior,
});
if (route.key.startsWith("home-")) {
const top = await scrollHeroAndSnapshot(opened.page, 0);
const midY = Math.min(top.maxScroll, Math.round(top.heroTop + top.heroHeight * 0.55));
const mid = await scrollHeroAndSnapshot(opened.page, midY);
const restored = await scrollHeroAndSnapshot(opened.page, 0);
const changed = changedLayerCount(top, mid);
const restoredCount = restoredLayerCount(top, restored);
results.equal(scope, "reduced-motion compiler stack has seven layers", 7, top.layerCount);
results.equal(scope, "reduced-motion keeps every compiler layer visible", 7, top.visibleLayerCount);
results.equal(scope, "reduced-motion creates no hero animation objects", 0, top.heroAnimationCount);
results.check(scope, "reduced-motion stack stays static through scroll and reverse", changed === 0 && restoredCount === 7, {
expected: { changed: 0, restored: 7 },
actual: { changed, restored: restoredCount, midY },
});
const lowerInitial = await lowerMotionSnapshot(opened.page);
const lowerForward = await scrollLowerMotionAndSnapshot(opened.page, "process");
await opened.page.evaluate(() => window.scrollTo(0, 0));
await waitForScrollSettle(opened.page);
await settleTwoAnimationFrames(opened.page);
const lowerRestored = await lowerMotionSnapshot(opened.page);
const summary = lowerMotionSummary(lowerInitial);
const totalExpected = Object.values(LOWER_MOTION_EXPECTED_COUNTS).reduce((sum, count) => sum + count, 0);
const totalCount = Object.values(lowerInitial.groups).reduce((sum, group) => sum + group.count, 0);
const totalFinal = Object.values(lowerInitial.groups).reduce((sum, group) => sum + group.items.filter((item) => item.final).length, 0);
const hasAnimation = Object.values(lowerInitial.groups).some((group) => (
group.animationObjectCount > 0 || group.items.some((item) => item.animationName !== "none")
));
const lowerChanged = Object.keys(LOWER_MOTION_EXPECTED_COUNTS).reduce((sum, key) => (
sum + changedMotionCount(lowerInitial.groups[key], lowerForward.groups[key])
), 0);
const lowerRestoredCount = Object.keys(LOWER_MOTION_EXPECTED_COUNTS).reduce((sum, key) => (
sum + restoredMotionCount(lowerInitial.groups[key], lowerRestored.groups[key])
), 0);
results.equal(scope, "reduced-motion lower graphic contract has every required element", totalExpected, totalCount);
results.check(scope, "reduced-motion lower graphics have no animation objects or names", !hasAnimation, {
expected: "all animationName none and animation object count 0",
actual: summary,
});
results.equal(scope, "reduced-motion lower graphics render the static final state", totalExpected, totalFinal);
results.check(scope, "reduced-motion lower graphics stay invariant through scroll and reverse", lowerChanged === 0 && lowerRestoredCount === totalExpected, {
expected: { changed: 0, restored: totalExpected },
actual: { changed: lowerChanged, restored: lowerRestoredCount },
});
results.empty(scope, "reduced-motion lower graphic trip has no runtime/local-resource errors", opened.runtime);
}
} finally {
await closePage(opened.page);
}
});
}
for (const route of ROUTES.slice(0, 2)) {
const scope = `copy fallback ${route.key}`;
await results.scenario(scope, async () => {
const success = await runCopyVariant(browser, server.origin, route, "success");
const failure = await runCopyVariant(browser, server.origin, route, "failure");
const successState = success.state;
const failureState = failure.state;
results.check(scope, "success-path copy button exists", Boolean(successState), { actual: successState });
results.check(scope, "copy command is non-empty", (successState?.command.length || 0) >= 4, { actual: successState?.command });
results.equal(scope, "clipboard receives exact command", successState?.command, successState?.clipboard);
results.equal(scope, "success label changes to copied label", successState?.copiedLabel, successState?.label);
results.check(scope, "success is announced through live status", (successState?.status.length || 0) >= 8, { actual: successState?.status });
results.check(scope, "failure-path copy button exists", Boolean(failureState), { actual: failureState });
results.check(scope, "failure selects the complete command", Boolean(failureState?.command && failureState.selection.includes(failureState.command)), { actual: failureState });
results.equal(scope, "failure keeps original button label", failureState?.originalLabel, failureState?.label);
results.check(scope, "failure gives localized keyboard-copy guidance", (failureState?.status.length || 0) >= 12, { actual: failureState?.status });
results.empty(scope, "copy success/failure paths have no runtime errors", [...success.runtime, ...failure.runtime]);
});
}
for (const route of ROUTES) {
for (const width of CAPTURE_WIDTHS) {
const scope = `effects off ${route.key} ${width}px`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width, javascript: false });
try {
await disableEffects(opened.page);
const layout = await inspectLayout(opened.page);
const visibleBlocks = await opened.page.evaluate(() => [...document.querySelectorAll("main section, main article")].filter((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) > 0.05 && rect.width > 0 && rect.height > 0;
}).length);
const staticHero = route.key.startsWith("home-") ? await heroStackSnapshot(opened.page) : null;
const staticLower = route.key.startsWith("home-") ? await lowerMotionSnapshot(opened.page) : null;
results.equal(scope, "HTTP status", 200, opened.status);
results.equal(scope, "one h1 survives", 1, layout.h1Count);
results.check(scope, "h1 remains at most two lines", layout.h1Lines >= 1 && layout.h1Lines <= 2, { actual: { lines: layout.h1Lines, text: layout.h1Text } });
results.check(scope, "main copy survives without effects or JavaScript", layout.mainTextLength >= 120, { actual: layout.mainTextLength });
results.check(scope, "multiple semantic content blocks survive", visibleBlocks >= 2, { actual: visibleBlocks });
results.check(scope, "effects-off document has no horizontal overflow", layout.documentWidth <= layout.viewportWidth + 1, { actual: layout.documentWidth });
results.empty(scope, "effects-off readable text keeps left inset", layout.leftInsetProblems);
results.empty(scope, "effects-off images still decode", layout.imageFailures);
results.empty(scope, "effects-off route has no runtime/local-resource errors", opened.runtime);
if (staticHero) {
results.equal(scope, "effects-off compiler stack has seven layers", 7, staticHero.layerCount);
results.equal(scope, "effects-off SVG retains title, description, and caption", true, staticHero.semantic);
results.equal(scope, "effects-off keeps every compiler layer visible", 7, staticHero.visibleLayerCount);
results.check(scope, "effects-off compiler layers have no active CSS animation", staticHero.heroAnimationCount === 0 && staticHero.layers.every((layer) => layer.animationName === "none"), {
expected: { animationObjects: 0, animationNames: ["none"] },
actual: {
animationObjects: staticHero.heroAnimationCount,
animationNames: [...new Set(staticHero.layers.map((layer) => layer.animationName))],
},
});
const lowerForward = await scrollLowerMotionAndSnapshot(opened.page, "process", { settle: false });
await opened.page.evaluate(() => window.scrollTo(0, 0));
await sleep(60);
const lowerRestored = await lowerMotionSnapshot(opened.page);
const totalExpected = Object.values(LOWER_MOTION_EXPECTED_COUNTS).reduce((sum, count) => sum + count, 0);
const totalCount = Object.values(staticLower.groups).reduce((sum, group) => sum + group.count, 0);
const totalFinal = Object.values(staticLower.groups).reduce((sum, group) => sum + group.items.filter((item) => item.final).length, 0);
const hasAnimation = Object.values(staticLower.groups).some((group) => (
group.animationObjectCount > 0 || group.items.some((item) => item.animationName !== "none")
));
const lowerChanged = Object.keys(LOWER_MOTION_EXPECTED_COUNTS).reduce((sum, key) => (
sum + changedMotionCount(staticLower.groups[key], lowerForward.groups[key])
), 0);
const lowerRestoredCount = Object.keys(LOWER_MOTION_EXPECTED_COUNTS).reduce((sum, key) => (
sum + restoredMotionCount(staticLower.groups[key], lowerRestored.groups[key])
), 0);
results.equal(scope, "effects-off lower graphic contract has every required element", totalExpected, totalCount);
results.check(scope, "effects-off lower graphics have no animation objects or names", !hasAnimation, {
expected: "all animationName none and animation object count 0",
actual: lowerMotionSummary(staticLower),
});
results.equal(scope, "effects-off lower graphics render the static final state", totalExpected, totalFinal);
results.check(scope, "effects-off lower graphics stay invariant through scroll and reverse", lowerChanged === 0 && lowerRestoredCount === totalExpected, {
expected: { changed: 0, restored: totalExpected },
actual: { changed: lowerChanged, restored: lowerRestoredCount },
});
}
} finally {
await closePage(opened.page);
}
});
}
}
for (const route of ROUTES.slice(0, 2)) {
for (const width of [320, 1440]) {
const scope = `font fallback ${route.key} ${width}px`;
await results.scenario(scope, async () => {
const opened = await openPage(browser, server.origin, route, { width, abortExternalFonts: true });
try {
const layout = await inspectLayout(opened.page);
results.equal(scope, "HTTP status", 200, opened.status);
results.equal(scope, "html lang", route.lang, layout.lang);
results.equal(scope, "one h1 remains readable", 1, layout.h1Count);
results.check(scope, "fallback h1 stays at most two lines", layout.h1Lines >= 1 && layout.h1Lines <= 2, { actual: { lines: layout.h1Lines, text: layout.h1Text } });
results.check(scope, "font fallback has no horizontal overflow", layout.documentWidth <= layout.viewportWidth + 1, { actual: layout.documentWidth });
results.check(scope, "font fallback retains readable main copy", layout.mainTextLength >= 120, { actual: layout.mainTextLength });
results.empty(scope, "font fallback retains left inset", layout.leftInsetProblems);
results.empty(scope, "font fallback has no runtime/local-resource errors", opened.runtime);
} finally {
await closePage(opened.page);
}
});
}
}
const contractScenarioCount = results.scenarios.length;
const contractAssertionCount = results.assertions.length;
const duplicateHashes = [...new Set(captures.map((item) => item.sha256).filter((hash, index, all) => all.indexOf(hash) !== index))];
const expectedCaptureNames = ROUTES.flatMap((route) => [...CAPTURE_WIDTHS].map((width) => `${route.key}-${width}`)).sort();
const actualCaptureNames = captures.map((item) => item.name).sort();
results.equal("preflight hard lock", "scenario count", EXPECTED_SCENARIOS, contractScenarioCount);
results.equal("preflight hard lock", "assertion count before hard-lock checks", EXPECTED_ASSERTIONS, contractAssertionCount);
results.equal("preflight hard lock", "visual capture count", EXPECTED_CAPTURES, captures.length);
results.equal("preflight hard lock", "exact visual capture state names", JSON.stringify(expectedCaptureNames), JSON.stringify(actualCaptureNames));
results.empty("preflight hard lock", "all captured states are visually distinct", duplicateHashes);
console.log("\nPerformance (390px local lab, cache disabled)");
for (const sample of performanceSamples) console.log(JSON.stringify(sample));
results.printFailures();
const failed = results.assertions.filter((item) => !item.pass).length;
const passed = results.assertions.length - failed;
const failedScenarios = results.scenarios.filter((item) => item.failures > 0).length;
console.log(`\n${failed ? "SITE E2E FAILED" : "SITE E2E PASSED"}`);
console.log(`Scenarios: ${results.scenarios.length - failedScenarios} passed, ${failedScenarios} failed`);
console.log(`Assertions: ${passed} passed, ${failed} failed`);
console.log(`Screenshots: ${captures.length} (${evidenceRoot})`);
process.exitCode = failed ? 1 : 0;
} finally {
if (browser) await browser.close();
if (server) await server.close();
}
}
main().catch((error) => {
console.error(`SITE E2E INFRASTRUCTURE FAILURE\n${error?.stack || error}`);
process.exitCode = 1;
});