2537 lines
122 KiB
JavaScript
2537 lines
122 KiB
JavaScript
// designpaca promotion-site browser gate.
|
|
//
|
|
// This intentionally stays independent from package scripts and the showcase
|
|
// baselines. It serves the built Astro output, writes only ignored evidence,
|
|
// and never updates a checked-in report or screenshot.
|
|
//
|
|
// npm run build
|
|
// node tools/site-e2e.mjs --root=dist
|
|
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import url from "node:url";
|
|
import { createRequire } from "node:module";
|
|
|
|
import puppeteer from "puppeteer-core";
|
|
import { PNG } from "pngjs";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const axePath = require.resolve("axe-core/axe.min.js");
|
|
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
const siteRoot = path.resolve(here, "..");
|
|
|
|
const ROUTES = [
|
|
{ key: "home-ko", path: "/", lang: "ko", translationPath: "/en/" },
|
|
{ key: "home-en", path: "/en/", lang: "en", translationPath: "/" },
|
|
{ key: "showcase-ko", path: "/showcase/", lang: "ko", translationPath: "/en/showcase/" },
|
|
{ key: "showcase-en", path: "/en/showcase/", lang: "en", translationPath: "/showcase/" },
|
|
];
|
|
const GEOMETRY_WIDTHS = [320, 390, 768, 1024, 1440, 1920, 2560];
|
|
const CAPTURE_WIDTHS = new Set([390, 1440]);
|
|
const HEIGHTS = new Map([[320, 844], [390, 844], [768, 1024], [1024, 900], [1440, 1000], [1920, 1080], [2560, 1440]]);
|
|
const AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
|
|
const EXPECTED_SCENARIOS = 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;
|
|
});
|