415 lines
25 KiB
JavaScript
415 lines
25 KiB
JavaScript
// TWENTYOZ L6 — actual Chromium scenarios for the standalone showcase.
|
|
import puppeteer from "puppeteer-core";
|
|
import { createRequire } from "node:module";
|
|
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import url from "node:url";
|
|
import zlib from "node:zlib";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
const root = path.resolve(here, "..");
|
|
const publicRoot = path.join(root, "public");
|
|
const targetRoot = path.join(publicRoot, "work", "twentyoz-corp");
|
|
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 executable not found");
|
|
|
|
const MIME = new Map([
|
|
[".html", "text/html; charset=utf-8"],
|
|
[".css", "text/css; charset=utf-8"],
|
|
[".js", "text/javascript; charset=utf-8"],
|
|
[".webp", "image/webp"],
|
|
[".png", "image/png"],
|
|
[".woff2", "font/woff2"],
|
|
[".glb", "model/gltf-binary"],
|
|
[".txt", "text/plain; charset=utf-8"],
|
|
]);
|
|
|
|
const server = http.createServer((request, response) => {
|
|
const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname);
|
|
const candidate = path.resolve(publicRoot, `.${pathname}`);
|
|
if (!candidate.startsWith(publicRoot + path.sep)) {
|
|
response.writeHead(403).end("forbidden");
|
|
return;
|
|
}
|
|
const file = fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() ? path.join(candidate, "index.html") : candidate;
|
|
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
response.writeHead(404).end("not found");
|
|
return;
|
|
}
|
|
response.setHeader("Content-Type", MIME.get(path.extname(file).toLowerCase()) || "application/octet-stream");
|
|
response.setHeader("Cache-Control", "no-store");
|
|
fs.createReadStream(file).pipe(response);
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
const baseUrl = `http://127.0.0.1:${address.port}/work/twentyoz-corp/index.html`;
|
|
const catalogUrl = `http://127.0.0.1:${address.port}/work/twentyoz-corp/products.html`;
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
const failures = [];
|
|
const suites = new Map();
|
|
const assert = (condition, suite, name, detail = "") => {
|
|
const current = suites.get(suite) || { pass: 0, fail: 0 };
|
|
if (condition) {
|
|
pass += 1;
|
|
current.pass += 1;
|
|
} else {
|
|
fail += 1;
|
|
current.fail += 1;
|
|
failures.push(`${suite} · ${name}${detail ? ` — ${detail}` : ""}`);
|
|
}
|
|
suites.set(suite, current);
|
|
};
|
|
const assertEvery = (items, predicate, suite, name, describe = (item) => JSON.stringify(item)) => {
|
|
for (const item of items) assert(predicate(item), suite, name, describe(item));
|
|
};
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const browser = await puppeteer.launch({
|
|
executablePath: chrome,
|
|
headless: "new",
|
|
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
|
|
});
|
|
|
|
const errors = [];
|
|
async function newPage() {
|
|
const page = await browser.newPage();
|
|
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") {
|
|
const location = message.location();
|
|
errors.push(`console: ${message.text()}${location.url ? ` @ ${location.url}` : ""}`);
|
|
}
|
|
});
|
|
page.on("response", (response) => {
|
|
if (response.status() >= 400) errors.push(`response: ${response.status()} ${response.url()}`);
|
|
});
|
|
await page.evaluateOnNewDocument(() => {
|
|
window.__twentyozPerf = { cls: 0, lcp: 0 };
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) if (!entry.hadRecentInput) window.__twentyozPerf.cls += entry.value;
|
|
}).observe({ type: "layout-shift", buffered: true });
|
|
new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
if (entries.length) window.__twentyozPerf.lcp = entries.at(-1).startTime;
|
|
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
});
|
|
return page;
|
|
}
|
|
|
|
async function goto(page, suffix = "") {
|
|
await page.goto(baseUrl + suffix, { waitUntil: "networkidle0", timeout: 30000 });
|
|
await page.evaluate(() => document.fonts.ready);
|
|
}
|
|
|
|
try {
|
|
// 1. Semantic structure, progressive fallback and responsive geometry.
|
|
const page = await newPage();
|
|
for (const width of [320, 390, 768, 1440]) {
|
|
await page.setViewport({ width, height: width < 500 ? 844 : 900, isMobile: width < 500, hasTouch: width < 500 });
|
|
await goto(page);
|
|
const layout = await 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 h1 = document.querySelector("h1");
|
|
const lineHeight = Number.parseFloat(getComputedStyle(h1).lineHeight);
|
|
const navTops = [...document.querySelectorAll(".nav a")].map((link) => Math.round(link.getBoundingClientRect().top));
|
|
return {
|
|
width: innerWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
h1Count: document.querySelectorAll("h1").length,
|
|
h1Lines: Math.round(h1.getBoundingClientRect().height / lineHeight),
|
|
navTopCount: new Set(navTops).size,
|
|
checked: document.querySelector('input[name="field"]:checked')?.value,
|
|
visiblePanels: [...document.querySelectorAll(".field-panel")].filter(visible).map((panel) => panel.dataset.panel),
|
|
fieldTargets: [...document.querySelectorAll(".field-tabs label")].map((label) => {
|
|
const rect = label.getBoundingClientRect();
|
|
return { width: Math.round(rect.width), height: Math.round(rect.height) };
|
|
}),
|
|
brokenImages: [...document.images].filter((image) => image.complete && image.naturalWidth === 0).length,
|
|
webgl: document.querySelector(".hero").dataset.webgl,
|
|
fallbackOpacity: Number.parseFloat(getComputedStyle(document.querySelector(".core-poster")).opacity),
|
|
panelWidth: Math.round(document.querySelector(".field-panels").getBoundingClientRect().width),
|
|
tabsWidth: Math.round(document.querySelector(".field-tabs").getBoundingClientRect().width),
|
|
};
|
|
});
|
|
const suite = `responsive ${width}`;
|
|
assert(layout.scrollWidth <= layout.width, suite, "no horizontal document overflow", `${layout.scrollWidth}/${layout.width}`);
|
|
assert(layout.h1Count === 1, suite, "one h1", String(layout.h1Count));
|
|
assert(layout.h1Lines <= 2, suite, "hero title at most two lines", String(layout.h1Lines));
|
|
assert(layout.navTopCount === 1, suite, "navigation remains one row", String(layout.navTopCount));
|
|
assert(layout.checked === "edu", suite, "default field is education", String(layout.checked));
|
|
assert(layout.visiblePanels.length === 1 && layout.visiblePanels[0] === "edu", suite, "one matching panel visible", layout.visiblePanels.join(","));
|
|
assertEvery(layout.fieldTargets, (target) => target.height >= 44, suite, "field target height >=44", (target) => `${target.width}x${target.height}`);
|
|
assert(layout.brokenImages === 0, suite, "all images decode", String(layout.brokenImages));
|
|
assert(layout.webgl === "fallback" && layout.fallbackOpacity === 1, suite, "automation fallback is complete", `${layout.webgl}/${layout.fallbackOpacity}`);
|
|
assert(Math.abs(layout.panelWidth - layout.tabsWidth) <= 1, suite, "field panel fills the full tab width", `${layout.panelWidth}/${layout.tabsWidth}`);
|
|
}
|
|
|
|
// 2. All field controls: mouse and native keyboard radio behavior.
|
|
await page.setViewport({ width: 1440, height: 900 });
|
|
await goto(page);
|
|
const fieldCases = [
|
|
["field-edu", "edu", "VIVEN"],
|
|
["field-dc", "dc", "XD Hub"],
|
|
["field-gov", "gov", "EduBill"],
|
|
["field-plant", "plant", "ACS"],
|
|
];
|
|
for (const [id, value, proof] of fieldCases) {
|
|
await page.click(`label[for="${id}"]`);
|
|
const state = await page.evaluate((value) => {
|
|
const panel = document.querySelector(`[data-panel="${value}"]`);
|
|
return {
|
|
checked: document.querySelector('input[name="field"]:checked')?.value,
|
|
display: getComputedStyle(panel).display,
|
|
text: panel.textContent,
|
|
coreState: document.querySelector(".hero").dataset.coreState,
|
|
};
|
|
}, value);
|
|
assert(state.checked === value, "field interactions", `${value} radio checked`, state.checked);
|
|
assert(state.display !== "none", "field interactions", `${value} panel visible`, state.display);
|
|
assert(state.text.includes(proof), "field interactions", `${value} proof content`, state.text.slice(0, 80));
|
|
assert(state.coreState === value, "field interactions", `${value} core state synced`, state.coreState);
|
|
}
|
|
await page.focus("#field-edu");
|
|
await page.keyboard.press("ArrowRight");
|
|
assert(await page.$eval("#field-dc", (input) => input.checked), "field interactions", "arrow key changes native radio");
|
|
|
|
// 3. Archive disclosure and anchor navigation.
|
|
assert(!(await page.$eval("#career", (details) => details.open)), "content controls", "career starts closed");
|
|
await page.click("#career summary");
|
|
assert(await page.$eval("#career", (details) => details.open), "content controls", "career opens by summary");
|
|
assert((await page.$$eval(".jobs li", (items) => items.length)) === 5, "content controls", "five closed job records remain available");
|
|
assert((await page.$$eval(".record-list > li", (items) => items.length)) === 7, "official content", "seven current official records remain available");
|
|
assert((await page.$$eval('.record-list a[href="https://twentyoz.kr/news/111"]', (items) => items.length)) === 1, "official content", "latest official award record links to its source");
|
|
await page.click('.nav a[href="#ledger"]');
|
|
await wait(120);
|
|
assert((await page.evaluate(() => location.hash)) === "#ledger", "content controls", "work anchor updates URL");
|
|
assert((await page.evaluate(() => scrollY)) > 0, "content controls", "work anchor scrolls page");
|
|
|
|
// 4. Axe accessibility in light and dark fallback modes.
|
|
const axePath = require.resolve("axe-core/axe.min.js");
|
|
for (const theme of ["light", "dark"]) {
|
|
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
|
|
await goto(page);
|
|
await page.addScriptTag({ path: axePath });
|
|
const axe = await page.evaluate(async () => axe.run(document, { resultTypes: ["violations"] }));
|
|
const axeDetail = axe.violations
|
|
.map((item) => `${item.id}: ${item.nodes.map((node) => node.target.join(" ")).join(" | ")}`)
|
|
.join("; ");
|
|
assert(axe.violations.length === 0, `axe ${theme}`, "zero WCAG violations", axeDetail);
|
|
const themeProof = await page.evaluate(() => ({
|
|
background: getComputedStyle(document.body).backgroundColor,
|
|
poster: document.querySelector(".core-poster img").currentSrc,
|
|
}));
|
|
assert(theme === "light" ? themeProof.background === "rgb(233, 231, 226)" : themeProof.background === "rgb(13, 15, 15)", `axe ${theme}`, "theme background role applied", themeProof.background);
|
|
assert(theme === "light" ? themeProof.poster.includes("core-light.webp") : themeProof.poster.includes("core-dark.webp"), `axe ${theme}`, "matching poster selected", themeProof.poster);
|
|
}
|
|
await page.emulateMediaFeatures([]);
|
|
|
|
// 5. Reduced motion and explicit WebGL failure stay complete.
|
|
const reducedPage = await newPage();
|
|
await reducedPage.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
|
|
await reducedPage.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
|
await goto(reducedPage, "?webgl=1");
|
|
const reduced = await reducedPage.evaluate(() => ({
|
|
webgl: document.querySelector(".hero").dataset.webgl,
|
|
reason: document.querySelector(".hero").dataset.webglReason,
|
|
canvasDisplay: getComputedStyle(document.querySelector("#coreCanvas")).display,
|
|
posterOpacity: getComputedStyle(document.querySelector(".core-poster")).opacity,
|
|
toggleHidden: document.querySelector("#motionToggle").hidden,
|
|
}));
|
|
assert(reduced.webgl === "fallback" && reduced.reason === "reduced-motion", "fallbacks", "reduced motion blocks WebGL", `${reduced.webgl}/${reduced.reason}`);
|
|
assert(reduced.canvasDisplay === "none" && reduced.posterOpacity === "1", "fallbacks", "reduced motion shows static poster", `${reduced.canvasDisplay}/${reduced.posterOpacity}`);
|
|
assert(reduced.toggleHidden, "fallbacks", "motion control hidden when no motion");
|
|
await reducedPage.close();
|
|
|
|
const failedGlPage = await newPage();
|
|
await failedGlPage.evaluateOnNewDocument(() => {
|
|
const original = HTMLCanvasElement.prototype.getContext;
|
|
HTMLCanvasElement.prototype.getContext = function getContext(type, ...args) {
|
|
if (type === "webgl2") return null;
|
|
return original.call(this, type, ...args);
|
|
};
|
|
});
|
|
await goto(failedGlPage, "?webgl=1");
|
|
const noGl = await failedGlPage.evaluate(() => ({
|
|
webgl: document.querySelector(".hero").dataset.webgl,
|
|
reason: document.querySelector(".hero").dataset.webglReason,
|
|
title: document.querySelector("h1").textContent.trim(),
|
|
poster: getComputedStyle(document.querySelector(".core-poster")).opacity,
|
|
}));
|
|
assert(noGl.webgl === "fallback" && noGl.reason === "no-webgl2", "fallbacks", "WebGL2 creation failure falls back", `${noGl.webgl}/${noGl.reason}`);
|
|
assert(noGl.title === "네 현장을 하나로" && noGl.poster === "1", "fallbacks", "DOM and poster survive WebGL failure", `${noGl.title}/${noGl.poster}`);
|
|
await failedGlPage.close();
|
|
|
|
// 6. Explicit real WebGL path, state sync, pause/replay, budgets and context loss.
|
|
const glPage = await newPage();
|
|
await glPage.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
|
await goto(glPage, "?webgl=1");
|
|
await glPage.waitForSelector('.hero[data-webgl="ready"]', { timeout: 45000 });
|
|
await glPage.waitForSelector('.hero[data-model="ready"]', { timeout: 45000 });
|
|
const glReady = await glPage.evaluate(() => ({
|
|
canvasOpacity: getComputedStyle(document.querySelector("#coreCanvas")).opacity,
|
|
posterOpacity: getComputedStyle(document.querySelector(".core-poster")).opacity,
|
|
drawCalls: Number(document.querySelector("#coreCanvas").dataset.drawCalls),
|
|
dpr: Number(document.querySelector("#coreCanvas").dataset.dpr),
|
|
toggleHidden: document.querySelector("#motionToggle").hidden,
|
|
paused: document.querySelector("#motionToggle").getAttribute("aria-pressed"),
|
|
model: document.querySelector("#coreCanvas").dataset.model,
|
|
effects: document.querySelector("#coreCanvas").dataset.effects,
|
|
glowRings: document.querySelector("#coreCanvas").dataset.glowRings,
|
|
}));
|
|
assert(glReady.canvasOpacity === "1" && glReady.posterOpacity === "0", "WebGL", "canvas replaces poster after first frame", `${glReady.canvasOpacity}/${glReady.posterOpacity}`);
|
|
assert(Number.isFinite(glReady.drawCalls) && glReady.drawCalls <= 60, "WebGL", "mobile draw calls <=60", String(glReady.drawCalls));
|
|
assert(Number.isFinite(glReady.dpr) && glReady.dpr <= 1.35, "WebGL", "mobile DPR <=1.35", String(glReady.dpr));
|
|
assert(!glReady.toggleHidden && glReady.paused === "true", "WebGL", "automation begins paused with control", `${glReady.toggleHidden}/${glReady.paused}`);
|
|
assert(glReady.model === "ready", "WebGL", "licensed hard-surface GLB is rendered", glReady.model);
|
|
assert(glReady.effects.includes("field-shader") && glReady.effects.includes("bloom") && glReady.effects.includes("grain"), "WebGL", "shader and post-processing stack is active", glReady.effects);
|
|
assert(glReady.glowRings === "2", "WebGL", "two depth-separated glow rings are active", glReady.glowRings);
|
|
|
|
const coreLayoutCases = [
|
|
{ width: 390, height: 844, mobile: true, tier: "mobile", offset: 0, minRatio: 0.495, maxRatio: 0.505 },
|
|
{ width: 768, height: 900, mobile: false, tier: "tablet", offset: 0.9, minRatio: 0.72, maxRatio: 0.75 },
|
|
{ width: 1024, height: 768, mobile: false, tier: "compact", offset: 0.75, minRatio: 0.61, maxRatio: 0.64 },
|
|
{ width: 1440, height: 900, mobile: false, tier: "wide", offset: 0.72, minRatio: 0.58, maxRatio: 0.62 },
|
|
];
|
|
for (const expected of coreLayoutCases) {
|
|
await glPage.setViewport({ width: expected.width, height: expected.height, isMobile: expected.mobile, hasTouch: expected.mobile });
|
|
await glPage.waitForFunction(
|
|
(tier) => document.querySelector("#coreCanvas")?.dataset.layoutTier === tier,
|
|
{ timeout: 10000 },
|
|
expected.tier,
|
|
);
|
|
const placement = await glPage.$eval("#coreCanvas", (canvas) => ({
|
|
tier: canvas.dataset.layoutTier,
|
|
offset: Number(canvas.dataset.coreOffsetX),
|
|
ratio: Number(canvas.dataset.coreScreenRatio),
|
|
overflow: document.documentElement.scrollWidth - innerWidth,
|
|
actionRows: new Set(
|
|
[...document.querySelectorAll(".hero-actions > *")]
|
|
.filter((element) => getComputedStyle(element).display !== "none")
|
|
.map((element) => Math.round(element.getBoundingClientRect().top)),
|
|
).size,
|
|
motionHeight: document.querySelector("#motionToggle").getBoundingClientRect().height,
|
|
}));
|
|
const suite = `WebGL layout ${expected.width}`;
|
|
assert(placement.tier === expected.tier, suite, "responsive layout tier is selected", placement.tier);
|
|
assert(Math.abs(placement.offset - expected.offset) <= 0.01, suite, "core offset matches composition", String(placement.offset));
|
|
assert(placement.ratio >= expected.minRatio && placement.ratio <= expected.maxRatio, suite, "core screen position fills the hero center", String(placement.ratio));
|
|
assert(placement.overflow <= 0, suite, "visible 3D controls create no horizontal overflow", String(placement.overflow));
|
|
assert(placement.actionRows === 1, suite, "hero actions stay on one row", String(placement.actionRows));
|
|
assert(placement.motionHeight >= 44, suite, "motion control keeps a 44px target", String(placement.motionHeight));
|
|
}
|
|
|
|
await glPage.click('label[for="field-plant"]');
|
|
await glPage.waitForFunction(() => document.querySelector("#coreCanvas").dataset.coreState === "plant");
|
|
assert(await glPage.$eval("#coreCanvas", (canvas) => canvas.dataset.coreState === "plant"), "WebGL", "field selection reaches 3D state");
|
|
assert(await glPage.$eval("#coreStatus", (status) => status.textContent === "선택한 현장: 공장과 설비 현장" && status.classList.contains("sr-only")), "WebGL", "field state remains explicit for assistive technology");
|
|
await glPage.click("#motionToggle");
|
|
assert((await glPage.$eval("#motionToggle", (button) => button.getAttribute("aria-pressed"))) === "false", "WebGL", "motion replay control works");
|
|
assert((await glPage.$eval("#motionToggle", (button) => button.textContent.trim())) === "3D 모션 끄기", "WebGL", "motion control names the available action");
|
|
await wait(180);
|
|
await glPage.click("#motionToggle");
|
|
assert((await glPage.$eval("#motionToggle", (button) => button.getAttribute("aria-pressed"))) === "true", "WebGL", "motion pause control works");
|
|
|
|
await glPage.evaluate(() => {
|
|
const gl = document.querySelector("#coreCanvas").getContext("webgl2");
|
|
gl.getExtension("WEBGL_lose_context").loseContext();
|
|
});
|
|
await glPage.waitForFunction(() => document.querySelector(".hero").dataset.webgl === "fallback", { timeout: 5000 });
|
|
const lost = await glPage.evaluate(() => ({
|
|
reason: document.querySelector(".hero").dataset.webglReason,
|
|
poster: getComputedStyle(document.querySelector(".core-poster")).opacity,
|
|
hidden: document.querySelector("#motionToggle").hidden,
|
|
}));
|
|
assert(lost.reason === "context-lost" && lost.poster === "1" && lost.hidden, "WebGL", "context loss restores complete fallback", JSON.stringify(lost));
|
|
await glPage.close();
|
|
|
|
// 7. Product catalog: all four detailed systems, responsive layout and accessibility.
|
|
const catalogPage = await newPage();
|
|
for (const width of [320, 1440]) {
|
|
await catalogPage.setViewport({ width, height: width < 500 ? 844 : 900, isMobile: width < 500, hasTouch: width < 500 });
|
|
await catalogPage.goto(catalogUrl, { waitUntil: "networkidle0", timeout: 30000 });
|
|
await catalogPage.evaluate(() => document.fonts.ready);
|
|
const catalog = await catalogPage.evaluate(() => ({
|
|
width: innerWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
h1: document.querySelectorAll("h1").length,
|
|
specimens: [...document.querySelectorAll(".specimen")].map((item) => item.id),
|
|
capabilities: document.querySelectorAll(".capability-list li").length,
|
|
steps: document.querySelectorAll(".operation-flow li").length,
|
|
mailto: [...document.querySelectorAll('.specimen-action a[href^="mailto:"]')].length,
|
|
text: document.body.textContent,
|
|
}));
|
|
const suite = `catalog ${width}`;
|
|
assert(catalog.scrollWidth <= catalog.width, suite, "no horizontal overflow", `${catalog.scrollWidth}/${catalog.width}`);
|
|
assert(catalog.h1 === 1, suite, "one h1", String(catalog.h1));
|
|
assert(catalog.specimens.join(",") === "viven,edubill,xd-hub,xd-works", suite, "four product details are present", catalog.specimens.join(","));
|
|
assert(catalog.capabilities === 16, suite, "sixteen verified capabilities", String(catalog.capabilities));
|
|
assert(catalog.steps === 16, suite, "sixteen operating steps", String(catalog.steps));
|
|
assert(catalog.mailto === 4, suite, "four product inquiry actions", String(catalog.mailto));
|
|
assert(catalog.text.includes("1,000,000개") && catalog.text.includes("단일 공간 최대 100명") && catalog.text.includes("다통화 · 다과금 정책"), suite, "published product specifications remain available");
|
|
}
|
|
for (const theme of ["light", "dark"]) {
|
|
await catalogPage.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
|
|
await catalogPage.goto(catalogUrl, { waitUntil: "networkidle0", timeout: 30000 });
|
|
await catalogPage.addScriptTag({ path: axePath });
|
|
const axe = await catalogPage.evaluate(async () => axe.run(document, { resultTypes: ["violations"] }));
|
|
const axeDetail = axe.violations.map((item) => `${item.id}: ${item.nodes.map((node) => node.target.join(" ")).join(" | ")}`).join("; ");
|
|
assert(axe.violations.length === 0, `catalog axe ${theme}`, "zero WCAG violations", axeDetail);
|
|
}
|
|
await catalogPage.close();
|
|
|
|
// 8. Runtime and transfer budgets.
|
|
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "light" }]);
|
|
await page.setViewport({ width: 1440, height: 900 });
|
|
await goto(page);
|
|
await wait(900);
|
|
const perf = await page.evaluate(() => window.__twentyozPerf);
|
|
assert(perf.cls <= 0.1, "performance", "CLS <=0.1", String(perf.cls));
|
|
assert(perf.lcp > 0 && perf.lcp <= 2500, "performance", "LCP <=2.5s", String(perf.lcp));
|
|
|
|
const gzip = (relative) => zlib.gzipSync(fs.readFileSync(path.join(targetRoot, relative)), { level: 9 }).byteLength;
|
|
const initialAssets = ["index.html", "styles/main.css", "styles/tokens.css", "scripts/core-boot.js", "assets/core-light.webp", "assets/pretendard-twentyoz.woff2"];
|
|
const firstViewportBytes = initialAssets.reduce((sum, relative) => sum + fs.statSync(path.join(targetRoot, relative)).size, 0);
|
|
const coreGzip = gzip("assets/core.iife.js");
|
|
const modelBytes = fs.statSync(path.join(targetRoot, "assets/twentyoz-field-core.glb")).size;
|
|
const bootGzip = gzip("scripts/core-boot.js");
|
|
assert(firstViewportBytes <= 1024 * 1024, "performance", "first viewport assets <=1MB", `${firstViewportBytes}B`);
|
|
assert(coreGzip <= 200 * 1024, "performance", "WebGL bundle <=200KB gzip", `${coreGzip}B`);
|
|
assert(modelBytes <= 750 * 1024, "performance", "optimized GLB <=750KB", `${modelBytes}B`);
|
|
assert(coreGzip + modelBytes <= 1024 * 1024, "performance", "lazy 3D payload <=1MB", `${coreGzip + modelBytes}B`);
|
|
assert(bootGzip <= 35 * 1024, "performance", "hero bootstrap <=35KB gzip", `${bootGzip}B`);
|
|
|
|
assert(errors.length === 0, "runtime", "zero console and page errors", errors.join(" | "));
|
|
await page.close();
|
|
} finally {
|
|
await browser.close();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
|
|
const lines = [...suites.entries()].map(([name, result]) => `${result.fail ? "FAIL" : "PASS"} ${name} — ${result.pass} passed${result.fail ? ` · ${result.fail} failed` : ""}`);
|
|
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
|
|
fs.writeFileSync(
|
|
path.join(here, "twentyoz-e2e-report.md"),
|
|
[`# TWENTYOZ E2E — ${stamp}`, "", fail ? `**${fail} failed · deploy blocked**` : `**${pass} assertions passed · GREEN**`, "", ...lines.map((line) => `- ${line}`), ...(failures.length ? ["", "## Failures", "", ...failures.map((item) => `- ${item}`)] : [])].join("\n") + "\n",
|
|
);
|
|
|
|
console.log(lines.join("\n"));
|
|
if (failures.length) {
|
|
console.log("\nFailures:");
|
|
failures.forEach((failure) => console.log(`FAIL ${failure}`));
|
|
}
|
|
console.log(`\nTWENTYOZ E2E — PASS ${pass} / FAIL ${fail}`);
|
|
process.exit(fail ? 1 : 0);
|