feat(showcase): 트웬티온스 사이트를 전면 개편한다
All checks were successful
ci / build (push) Successful in 33s

This commit is contained in:
Yun Chan 2026-08-25 03:50:28 +09:00
parent cb3c323c9f
commit 9529e3747c
29 changed files with 8906 additions and 1326 deletions

View 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);