This commit is contained in:
parent
cb3c323c9f
commit
9529e3747c
29 changed files with 8906 additions and 1326 deletions
415
apps/site/tools/twentyoz-e2e.mjs
Normal file
415
apps/site/tools/twentyoz-e2e.mjs
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
// 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue