designpaca/apps/site/tools/showcase-stress.mjs
Yun Chan e09d4efb1c
Some checks failed
ci / build (push) Has been cancelled
feat: strengthen design skill and refresh all showcases
2026-09-12 18:16:33 +09:00

248 lines
13 KiB
JavaScript

// Showcase typography stress diagnostic. This records browser geometry; it is not a WCAG conformance certification.
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
import puppeteer from "puppeteer-core";
import { startPublicServer, routeForFile } from "./capture-stable.mjs";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const siteRoot = path.resolve(here, "..");
const publicRoot = path.resolve(process.env.SHOWCASE_STRESS_PUBLIC_ROOT || path.join(siteRoot, "public"));
const showcaseRoot = path.join(publicRoot, "work");
const reportPath = path.resolve(process.env.SHOWCASE_STRESS_REPORT || path.join(siteRoot, "showcase-stress-report.json"));
const outputDirectory = path.resolve(process.env.SHOWCASE_STRESS_OUTPUT || path.join(siteRoot, "showcase-stress-artifacts"));
const expectedRouteCount = Number.parseInt(process.env.SHOWCASE_STRESS_EXPECTED_ROUTES || "28", 10);
const modes = [
{ name: "normal-390", width: 390, height: 844, stress: "normal" },
{ name: "text200-390", width: 390, height: 844, stress: "text200" },
{ name: "spacing-390", width: 390, height: 844, stress: "spacing" },
{ name: "reduced-motion-1440", width: 1440, height: 1000, stress: "reduced-motion" },
];
const chrome = [
process.env.CHROME_PATH,
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
].find((candidate) => candidate && fs.existsSync(candidate));
if (!chrome) throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH.");
if (!Number.isInteger(expectedRouteCount) || expectedRouteCount < 1) throw new Error("SHOWCASE_STRESS_EXPECTED_ROUTES must be a positive integer.");
fs.mkdirSync(outputDirectory, { recursive: true });
function collectHtmlFiles(directory) {
const files = [];
const walk = (current) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const candidate = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name !== "assets") walk(candidate);
} else if (entry.name.endsWith(".html")) {
files.push(candidate);
}
}
};
walk(directory);
return files.sort();
}
function attachBrowserEvents(page) {
const events = { consoleErrors: [], pageErrors: [], requestFailures: [], httpErrors: [] };
page.on("console", (message) => {
if (message.type() === "error") events.consoleErrors.push(message.text());
});
page.on("pageerror", (error) => events.pageErrors.push(error.message));
page.on("requestfailed", (request) => events.requestFailures.push(`${request.url()} ${request.failure()?.errorText || ""}`));
page.on("response", (response) => {
if (response.status() >= 400) events.httpErrors.push(`${response.status()} ${response.url()}`);
});
return events;
}
async function settle(page) {
const result = await page.evaluate(async () => {
await document.fonts.ready;
const images = [...document.images];
for (const image of images) image.loading = "eager";
const decodeFailures = [];
for (const image of images) {
try {
await image.decode();
} catch (error) {
decodeFailures.push(`${image.currentSrc || image.src}: ${error}`);
}
}
return {
imageFailures: [...images.filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src), ...decodeFailures],
fontErrors: [...document.fonts].filter((face) => face.status === "error").map((face) => ({ family: face.family, weight: face.weight, status: face.status })),
};
});
await new Promise((resolve) => setTimeout(resolve, 300));
return result;
}
async function applyStressMode(page, mode) {
if (mode.stress === "reduced-motion") return;
if (mode.stress === "normal") return;
await page.evaluate((stress) => {
if (stress === "text200") {
// Snapshot every baseline computed size before changing the root. Applying those doubled values inline
// makes rem/em and fixed-px text exactly 2x, rather than multiplying root-scaled text twice.
const baseline = [...document.body.querySelectorAll("*")].map((element) => ({ element, fontSize: parseFloat(getComputedStyle(element).fontSize) }));
document.documentElement.style.fontSize = "200%";
for (const { element, fontSize } of baseline) {
if (Number.isFinite(fontSize) && fontSize > 0) element.style.setProperty("font-size", `${fontSize * 2}px`, "important");
}
return;
}
const style = document.createElement("style");
style.id = "showcase-stress-spacing";
style.textContent = "body * { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-block: 2em !important; }";
document.head.append(style);
}, mode.stress);
}
async function sampleTabFocus(page) {
await page.evaluate(() => document.activeElement?.blur());
const sequence = [];
for (let index = 0; index < 10; index += 1) {
await page.keyboard.press("Tab");
sequence.push(await page.evaluate(() => {
const active = document.activeElement;
const rect = active?.getBoundingClientRect();
const style = active ? getComputedStyle(active) : null;
return {
tag: active?.tagName || null,
id: active?.id || null,
className: active?.className || "",
rect: rect ? { left: Math.round(rect.left), top: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) } : null,
outlineStyle: style?.outlineStyle || null,
outlineWidth: style?.outlineWidth || null,
};
}));
}
return sequence;
}
async function collectGeometry(page) {
return page.evaluate(() => {
const isHiddenModal = (element) => {
const dialog = element.closest("dialog");
return Boolean(dialog && !dialog.open) || Boolean(element.closest("[hidden], [aria-hidden=\"true\"]"));
};
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 && !isHiddenModal(element);
};
const excluded = (element) => element.matches("input, textarea, canvas") || Boolean(element.closest("input, textarea, canvas"));
const selector = (element) => element.id ? `#${element.id}` : `${element.tagName.toLowerCase()}${[...element.classList].slice(0, 2).map((name) => `.${name}`).join("")}`;
const textElements = [...document.querySelectorAll("p,h1,h2,h3,h4,h5,h6,li,dt,dd,a,button,label,output,figcaption,td,th,span")]
.filter((element) => visible(element) && !excluded(element) && element.childNodes.length && [...element.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim()));
const clipping = textElements.map((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
const clipped = (style.overflowX === "hidden" || style.overflowX === "clip" || style.textOverflow !== "clip") && element.scrollWidth > element.clientWidth + 1;
return clipped ? { selector: selector(element), text: element.textContent.trim().slice(0, 80), rect: { left: Math.round(rect.left), right: Math.round(rect.right), top: Math.round(rect.top), bottom: Math.round(rect.bottom) }, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, overflowX: style.overflowX, whiteSpace: style.whiteSpace } : null;
}).filter(Boolean).slice(0, 30);
const overlaps = [];
for (let first = 0; first < textElements.length; first += 1) {
const a = textElements[first];
const aRect = a.getBoundingClientRect();
for (let second = first + 1; second < textElements.length; second += 1) {
const b = textElements[second];
if (a.contains(b) || b.contains(a)) continue;
const bRect = b.getBoundingClientRect();
const horizontal = Math.min(aRect.right, bRect.right) - Math.max(aRect.left, bRect.left);
const vertical = Math.min(aRect.bottom, bRect.bottom) - Math.max(aRect.top, bRect.top);
if (horizontal > 2 && vertical > 2) overlaps.push({ first: selector(a), second: selector(b), overlap: { width: Math.round(horizontal), height: Math.round(vertical) } });
if (overlaps.length >= 30) break;
}
if (overlaps.length >= 30) break;
}
return {
wholePage: { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1 },
textClippingCandidates: clipping,
textOverlapCandidates: overlaps,
brokenImages: [...document.images].filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src),
};
});
}
function hardFailures(row) {
const failures = [];
if (row.geometry.brokenImages.length) failures.push(`broken images ${row.geometry.brokenImages.length}`);
if (row.events.consoleErrors.length) failures.push(`console errors ${row.events.consoleErrors.length}`);
if (row.events.pageErrors.length) failures.push(`page errors ${row.events.pageErrors.length}`);
if (row.events.requestFailures.length) failures.push(`network failures ${row.events.requestFailures.length}`);
if (row.events.httpErrors.length) failures.push(`HTTP responses >=400 ${row.events.httpErrors.length}`);
if (row.fontErrors.length) failures.push(`font status errors ${row.fontErrors.length}`);
return failures;
}
function screenshotPath(route, mode) {
return path.join(outputDirectory, `${route.replace(/^\//, "").replace(/[\\/]/g, "__").replace(/\.html$/, "")}-${mode}.png`);
}
function writePartial(report) {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
}
const report = { generatedAt: new Date().toISOString(), publicRoot, expectedRouteCount, routes: [], modes: modes.map((mode) => mode.name), rows: [], runnerErrors: [] };
let server;
let browser;
try {
if (!fs.existsSync(showcaseRoot) || !fs.statSync(showcaseRoot).isDirectory()) throw new Error(`showcase root not found: ${showcaseRoot}`);
const files = collectHtmlFiles(showcaseRoot);
report.routes = files.map((file) => routeForFile(publicRoot, file));
if (report.routes.length !== expectedRouteCount) throw new Error(`route count ${report.routes.length}; expected ${expectedRouteCount}`);
server = await startPublicServer(publicRoot);
browser = await puppeteer.launch({ executablePath: chrome, headless: "new", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"] });
for (const route of report.routes) {
for (const mode of modes) {
console.log(`RUN ${route} ${mode.name}`);
const page = await browser.newPage();
const row = { route, mode: mode.name, width: mode.width, rawObservations: null, focusOrder: [], events: null, hardFailures: [] };
const events = attachBrowserEvents(page);
try {
await page.setViewport({ width: mode.width, height: mode.height, isMobile: mode.width < 500, hasTouch: mode.width < 500 });
await page.emulateMediaFeatures(mode.stress === "reduced-motion" ? [{ name: "prefers-reduced-motion", value: "reduce" }] : []);
await page.goto(`${server.origin}${route}`, { waitUntil: "networkidle0", timeout: 30000 });
await applyStressMode(page, mode);
const settled = await settle(page);
row.geometry = await collectGeometry(page);
row.geometry.brokenImages = [...new Set([...row.geometry.brokenImages, ...settled.imageFailures])];
row.fontErrors = settled.fontErrors;
row.focusOrder = await sampleTabFocus(page);
if (mode.stress === "text200" || mode.stress === "spacing") {
const file = screenshotPath(route, mode.name);
await page.screenshot({ path: file, fullPage: true });
row.screenshot = file;
}
} catch (error) {
events.pageErrors.push(`stress runner: ${error.stack || error.message}`);
row.geometry = { wholePage: null, textClippingCandidates: [], textOverlapCandidates: [], brokenImages: [] };
row.fontErrors = [];
} finally {
await page.close();
}
row.events = events;
row.fontErrors ||= [];
row.rawObservations = { wholePage: row.geometry.wholePage, textClippingCandidates: row.geometry.textClippingCandidates, textOverlapCandidates: row.geometry.textOverlapCandidates };
row.hardFailures = hardFailures(row);
report.rows.push(row);
writePartial(report);
console.log(`${row.hardFailures.length ? "FAIL" : "PASS"} ${route} ${mode.name}${row.hardFailures.length ? ` — ${row.hardFailures.join(" · ")}` : ""}`);
}
}
} catch (error) {
report.runnerErrors.push(error.stack || error.message);
} finally {
if (browser) await browser.close();
if (server) await server.close();
writePartial(report);
const failedRows = report.rows.filter((row) => row.hardFailures.length);
console.log(`\n${failedRows.length || report.runnerErrors.length ? "✗" : "✓"} typography stress diagnostic — rows ${report.rows.length}, hard failures ${failedRows.length}`);
process.exitCode = failedRows.length || report.runnerErrors.length ? 1 : 0;
}