// 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 = 85; const EXPECTED_ASSERTIONS = 1089; 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, }); const PERCEPTUAL_MOTION_WIDTHS = [390, 1440]; const PERCEPTUAL_MOTION_STEP = 110; const PERCEPTUAL_MOTION_CONTRACTS = Object.freeze({ direction: { articleSelector: ".specimen-direction", stageSelector: ".direction-stage", childSelector: ".direction-field-inner, .direction-vector, .direction-node", expectedCount: 5, }, type: { articleSelector: ".specimen-tokens", stageSelector: ".type-stage", childSelector: ".type-glyphs, .token-sample", expectedCount: 4, }, }); 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 perceptualMotionSnapshot(page, key) { return page.evaluate((motionKey, contracts) => { const contract = contracts[motionKey]; const article = document.querySelector(contract.articleSelector); const stage = document.querySelector(contract.stageSelector); const children = [...document.querySelectorAll(contract.childSelector)]; const rounded = (value) => Math.round(value * 1000) / 1000; const verticalVisibility = (rect) => { if (!rect.height) return 0; const visible = Math.max(0, Math.min(rect.bottom, innerHeight) - Math.max(rect.top, 0)); return rounded(visible / Math.min(rect.height, innerHeight)); }; const childState = (element, index) => { const rect = element.getBoundingClientRect(); const animations = element.getAnimations({ subtree: false }); const animation = animations.find((candidate) => candidate.effect?.getComputedTiming) || null; const rawProgress = animation?.effect?.getComputedTiming()?.progress; return { index, label: element.classList.contains("type-glyphs") ? "glyphs" : element.classList.contains("token-sample") ? `token-${index}` : element.classList.contains("direction-field-inner") ? "plane" : element.classList.contains("direction-vector") ? "vector" : `node-${Math.max(1, index - 1)}`, animationName: getComputedStyle(element).animationName, progress: Number.isFinite(rawProgress) ? rounded(rawProgress) : null, top: rounded(rect.top), bottom: rounded(rect.bottom), visibility: verticalVisibility(rect), }; }; const articleRect = article?.getBoundingClientRect() || null; const stageRect = stage?.getBoundingClientRect() || null; return { article: articleRect ? { top: rounded(articleRect.top), bottom: rounded(articleRect.bottom), } : null, children: children.map(childState), stage: stageRect ? { top: rounded(stageRect.top), bottom: rounded(stageRect.bottom), visibility: verticalVisibility(stageRect), } : null, scrollY: rounded(scrollY), supportsViewTimeline: CSS.supports("animation-timeline: view()"), viewportHeight: innerHeight, }; }, key, PERCEPTUAL_MOTION_CONTRACTS); } async function collectPerceptualMotionTrip(page, key) { const contract = PERCEPTUAL_MOTION_CONTRACTS[key]; const geometry = await page.evaluate((selector, step) => { const article = document.querySelector(selector); if (!article) return null; const rect = article.getBoundingClientRect(); const absoluteTop = scrollY + rect.top; const maxScroll = Math.max(0, document.documentElement.scrollHeight - innerHeight); const startY = Math.max(0, Math.min(maxScroll, absoluteTop - innerHeight - 48)); const steps = Math.ceil((innerHeight + 300) / step); window.scrollTo({ top: startY, behavior: "instant" }); return { startY, steps }; }, contract.articleSelector, PERCEPTUAL_MOTION_STEP); if (!geometry) throw new Error(`Perceptual-motion target is missing for ${key}`); await settleTwoAnimationFrames(page); const viewport = page.viewport(); await page.mouse.move(Math.round(viewport.width / 2), Math.round(viewport.height / 2)); const forward = [await perceptualMotionSnapshot(page, key)]; for (let index = 0; index < geometry.steps; index += 1) { await page.mouse.wheel({ deltaY: PERCEPTUAL_MOTION_STEP }); await settleTwoAnimationFrames(page); forward.push(await perceptualMotionSnapshot(page, key)); } const reverse = []; for (let index = 0; index < geometry.steps; index += 1) { await page.mouse.wheel({ deltaY: PERCEPTUAL_MOTION_STEP * -1 }); await settleTwoAnimationFrames(page); reverse.push(await perceptualMotionSnapshot(page, key)); } return { forward, reverse, step: PERCEPTUAL_MOTION_STEP }; } function progressIsMonotonic(samples, direction, tolerance = 0.05) { const count = samples[0]?.children?.length || 0; for (let childIndex = 0; childIndex < count; childIndex += 1) { const values = samples.map((sample) => sample.children[childIndex]?.progress).filter(Number.isFinite); for (let index = 1; index < values.length; index += 1) { if (direction === "forward" && values[index] + tolerance < values[index - 1]) return false; if (direction === "reverse" && values[index] - tolerance > values[index - 1]) return false; } } return true; } function wheelDeltasStayDiscrete(samples, direction) { const deltas = []; for (let index = 1; index < samples.length; index += 1) { deltas.push(samples[index].scrollY - samples[index - 1].scrollY); } return { deltas, pass: deltas.every((delta) => direction === "forward" ? delta >= 90 && delta <= 125 : delta <= -90 && delta >= -125), }; } function motionRestoreSummary(trip, tolerance = 0.05) { const initial = trip.forward[0]?.children || []; const restored = trip.reverse.at(-1)?.children || []; const deltas = initial.map((child, index) => ({ label: child.label, delta: Number.isFinite(child.progress) && Number.isFinite(restored[index]?.progress) ? Math.abs(child.progress - restored[index].progress) : Number.POSITIVE_INFINITY, })); return { deltas, pass: deltas.every((item) => item.delta <= tolerance) }; } function offscreenProgressViolations(samples, limit = 0.1) { return samples.flatMap((sample, sampleIndex) => sample.children .filter((child) => child.top >= sample.viewportHeight - 0.5 && Number.isFinite(child.progress) && child.progress > limit) .map((child) => ({ sample: sampleIndex, scrollY: sample.scrollY, ...child }))); } function longestMatchingRun(samples, predicate) { let best = { count: 0, span: 0 }; let start = -1; for (let index = 0; index <= samples.length; index += 1) { const matches = index < samples.length && predicate(samples[index], index); if (matches && start < 0) start = index; if ((!matches || index === samples.length) && start >= 0) { const end = index - 1; const run = { count: end - start + 1, span: Math.abs(samples[end].scrollY - samples[start].scrollY), }; if (run.count > best.count || (run.count === best.count && run.span > best.span)) best = run; start = -1; } } return best; } function directionPerceptualSummary(samples) { const partial = (child) => child.visibility >= 0.5 && child.progress >= 0.15 && child.progress <= 0.85; const dwell = longestMatchingRun(samples, (sample) => ( sample.stage?.visibility >= 0.5 && sample.children.filter(partial).length >= 3 )); let maxPhaseBuckets = 0; let phaseSample = null; for (const sample of samples.filter((item) => item.stage?.visibility >= 0.5)) { const progresses = sample.children.map((child) => child.progress).filter(Number.isFinite).sort((a, b) => a - b); const buckets = []; for (const progress of progresses) { if (!buckets.length || progress - buckets.at(-1) >= 0.08) buckets.push(progress); } if (buckets.length > maxPhaseBuckets) { maxPhaseBuckets = buckets.length; phaseSample = { buckets, scrollY: sample.scrollY, stageVisibility: sample.stage.visibility }; } } return { dwell, maxPhaseBuckets, phaseSample }; } function typePerceptualSummary(samples) { const tokenIndexes = [1, 2, 3]; const visibleRuns = tokenIndexes.map((childIndex) => ({ label: samples[0]?.children?.[childIndex]?.label, ...longestMatchingRun(samples, (sample) => { const child = sample.children[childIndex]; return child?.visibility >= 0.5 && child.progress >= 0.15 && child.progress <= 0.85; }), })); const premature = samples.flatMap((sample, sampleIndex) => tokenIndexes .map((childIndex) => sample.children[childIndex]) .filter((child) => child && child.top >= 0 && child.visibility < 0.5 && Number.isFinite(child.progress) && child.progress > 0.25) .map((child) => ({ sample: sampleIndex, scrollY: sample.scrollY, ...child }))); const staggerSamples = samples.flatMap((sample, sampleIndex) => { const tokens = tokenIndexes.map((index) => sample.children[index]); if (!tokens.every((child) => child?.visibility >= 0.5 && child.progress >= 0.15 && child.progress <= 0.85)) return []; const gaps = [tokens[0].progress - tokens[1].progress, tokens[1].progress - tokens[2].progress]; return [{ gaps, pass: gaps.every((gap) => gap >= 0.08), progress: tokens.map((child) => child.progress), sample: sampleIndex, scrollY: sample.scrollY }]; }); return { premature, staggerSamples, visibleRuns }; } 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`); const notFoundScope = "404 artifact and recovery"; await results.scenario(notFoundScope, async () => { const artifact = path.join(serveRoot, "404.html"); results.equal(notFoundScope, "top-level 404 artifact exists", true, fs.existsSync(artifact)); const opened = await openPage(browser, server.origin, { path: "/404.html" }, { width: 390 }); try { const state = await opened.page.evaluate(() => { const robots = [...document.querySelectorAll("meta[name='robots']")].map((element) => element.getAttribute("content") || ""); const h1s = [...document.querySelectorAll("h1")].map((element) => element.textContent?.replace(/\s+/g, " ").trim() || ""); const englishCopy = [...document.querySelectorAll("[lang='en']")].some((element) => /Page not found\./.test(element.textContent || "")); const links = [...document.querySelectorAll("main a[href]")].map((element) => ({ href: element.getAttribute("href"), lang: element.getAttribute("lang"), text: element.textContent?.replace(/\s+/g, " ").trim() || "", })); const executableScripts = [...document.scripts].filter((script) => { const type = (script.getAttribute("type") || "").trim().toLowerCase(); return !["application/ld+json", "application/json", "importmap"].includes(type); }).length; return { lang: document.documentElement.lang, mainCount: document.querySelectorAll("main").length, h1s, robots, englishCopy, links, executableScripts, viewportWidth: innerWidth, documentWidth: document.documentElement.scrollWidth, }; }); const axe = await runAxe(opened.page); results.equal(notFoundScope, "local artifact route responds", 200, opened.status); results.equal(notFoundScope, "document language is Korean", "ko", state.lang); results.equal(notFoundScope, "one main landmark", 1, state.mainCount); results.equal(notFoundScope, "one unique h1", 1, state.h1s.length); results.equal(notFoundScope, "h1 identifies the missing page", "페이지를 찾을 수 없습니다", state.h1s[0]); results.equal(notFoundScope, "English explanation is language tagged", true, state.englishCopy); results.equal(notFoundScope, "one robots directive", 1, state.robots.length); results.check(notFoundScope, "robots directive prevents indexing", state.robots[0]?.split(",").some((value) => value.trim().toLowerCase() === "noindex"), { expected: "noindex", actual: state.robots, }); results.check(notFoundScope, "Korean home recovery link exists", state.links.some((link) => link.href === "/" && link.text.endsWith("한국어 홈")), { actual: state.links }); results.check(notFoundScope, "English home recovery link exists and is language tagged", state.links.some((link) => link.href === "/en/" && link.lang === "en" && link.text === "EN English home"), { actual: state.links }); results.equal(notFoundScope, "no executable scripts", 0, state.executableScripts); results.check(notFoundScope, "no horizontal document overflow", state.documentWidth <= state.viewportWidth + 1, { expected: `<= ${state.viewportWidth + 1}`, actual: state.documentWidth, }); results.empty(notFoundScope, "axe WCAG A/AA violations", axe); } finally { await closePage(opened.page); } }); // 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); } }); } // Endpoint signatures prove that CSS is connected, but they can still pass // when the authored motion finishes before its artwork becomes visible. // Follow direction and type through their actual viewport interval with // discrete wheel input, including a reverse trip. The 390/1440 pair keeps // this perceptual gate focused without duplicating the 320..2560 geometry // matrix above. for (const route of ROUTES.slice(0, 2)) { for (const width of PERCEPTUAL_MOTION_WIDTHS) { const scope = `perceptual lower motion ${route.key} ${width}px`; await results.scenario(scope, async () => { const opened = await openPage(browser, server.origin, route, { width }); try { const direction = await collectPerceptualMotionTrip(opened.page, "direction"); const type = await collectPerceptualMotionTrip(opened.page, "type"); const directionInitial = direction.forward[0]; const typeInitial = type.forward[0]; results.equal(scope, "HTTP status", 200, opened.status); results.equal(scope, "Chromium supports native view timelines", true, Boolean(directionInitial.supportsViewTimeline && typeInitial.supportsViewTimeline)); for (const [key, trip] of [["direction", direction], ["type", type]]) { const expected = PERCEPTUAL_MOTION_CONTRACTS[key].expectedCount; const forwardWheel = wheelDeltasStayDiscrete(trip.forward, "forward"); const reverseWheel = wheelDeltasStayDiscrete(trip.reverse, "reverse"); const restored = motionRestoreSummary(trip); const offscreen = offscreenProgressViolations(trip.forward); results.equal(scope, `${key} perceptual target has every animated child`, expected, trip.forward[0]?.children?.length); results.check(scope, `${key} uses 110px discrete wheel steps in both directions`, forwardWheel.pass && reverseWheel.pass, { expected: "every forward/reverse delta has magnitude 90..125px", actual: { forward: forwardWheel.deltas, reverse: reverseWheel.deltas }, }); results.equal(scope, `${key} progress is monotonic through forward wheel input`, true, progressIsMonotonic(trip.forward, "forward")); results.equal(scope, `${key} progress is monotonic through reverse wheel input`, true, progressIsMonotonic(trip.reverse, "reverse")); results.check(scope, `${key} reverse wheel trip restores every initial progress`, restored.pass, { expected: "each progress delta <= 0.05", actual: restored.deltas, }); results.empty(scope, `${key} children do not exceed 10% progress while still below the viewport`, offscreen); } const directionPerception = directionPerceptualSummary(direction.forward); results.check(scope, "direction motion visibly dwells across at least three wheel samples and 220px", directionPerception.dwell.count >= 3 && directionPerception.dwell.span >= 220, { expected: { count: ">= 3", span: ">= 220" }, actual: directionPerception.dwell, }); results.check(scope, "direction exposes at least three independent progress phases while visible", directionPerception.maxPhaseBuckets >= 3, { expected: ">= 3 progress buckets separated by 0.08", actual: directionPerception, }); const typePerception = typePerceptualSummary(type.forward); results.empty(scope, "tokens do not exceed 25% progress before becoming half visible", typePerception.premature); results.check(scope, "every token visibly dwells in a partial state for two wheel samples and 110px", typePerception.visibleRuns.every((run) => run.count >= 2 && run.span >= 110), { expected: "each token: count >= 2 and span >= 110", actual: typePerception.visibleRuns, }); results.check(scope, "visible token samples preserve the authored stagger", typePerception.staggerSamples.some((sample) => sample.pass), { expected: "at least one all-visible partial sample with both progress gaps >= 0.08", actual: typePerception.staggerSamples, }); results.empty(scope, "perceptual lower-motion wheel trips have 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; });