designpaca/apps/site/tools/recent-showcases-e2e.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

375 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// RFS CFD + 새벽청과 L6 — upgraded showcase journeys in real Chromium.
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";
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 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"],
[".svg", "image/svg+xml"],
[".webp", "image/webp"],
[".png", "image/png"],
[".jpg", "image/jpeg"],
[".jpeg", "image/jpeg"],
[".woff2", "font/woff2"],
]);
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 origin = `http://127.0.0.1:${address.port}`;
const urls = {
rfs: `${origin}/work/rfs-cfd/`,
market: `${origin}/work/saebyeok-cheonggwa/`,
};
let pass = 0;
let fail = 0;
const suites = new Map();
const failures = [];
const browserErrors = [];
const assert = (condition, suite, name, detail = "") => {
const state = suites.get(suite) || { pass: 0, fail: 0 };
if (condition) {
pass += 1;
state.pass += 1;
} else {
fail += 1;
state.fail += 1;
failures.push(`${suite} · ${name}${detail ? ` — ${detail}` : ""}`);
}
suites.set(suite, state);
};
const assertEvery = (items, predicate, suite, name, describe = (item) => JSON.stringify(item)) => {
for (const item of items) assert(predicate(item), suite, name, describe(item));
};
const browser = await puppeteer.launch({
executablePath: chrome,
headless: "new",
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
});
async function newPage({ javascript = true } = {}) {
const page = await browser.newPage();
await page.setCacheEnabled(false);
await page.setJavaScriptEnabled(javascript);
if (javascript) {
await page.evaluateOnNewDocument(() => {
window.__recentShowcasePerf = { cls: 0, lcp: 0 };
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) window.__recentShowcasePerf.cls += entry.value;
}
}).observe({ type: "layout-shift", buffered: true });
new PerformanceObserver((list) => {
const entries = list.getEntries();
if (entries.length) window.__recentShowcasePerf.lcp = entries.at(-1).startTime;
}).observe({ type: "largest-contentful-paint", buffered: true });
});
}
page.on("pageerror", (error) => browserErrors.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") browserErrors.push(`console: ${message.text()}`);
});
page.on("response", (response) => {
if (response.status() >= 400) browserErrors.push(`response: ${response.status()} ${response.url()}`);
});
return page;
}
async function goto(page, target) {
await page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForNetworkIdle({ idleTime: 250, timeout: 5000 }).catch(() => {});
await Promise.race([
page.evaluate(() => document.fonts.ready),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
}
function expectedKoreanStamp() {
const parts = Object.fromEntries(
new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.formatToParts(new Date())
.filter((part) => part.type !== "literal")
.map((part) => [part.type, part.value]),
);
return {
month: Number(parts.month),
text: `${parts.month}월 ${parts.day}일 02:00`,
datetime: `${parts.year}-${parts.month}-${parts.day}T02:00+09:00`,
};
}
try {
const page = await newPage();
// 1. Both pages remain structurally complete throughout the target width matrix.
for (const [site, target] of Object.entries(urls)) {
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, target);
if (site === "rfs") {
await page.waitForFunction(
() => document.querySelector("[data-field-shell]")?.dataset.fieldState !== "loading",
{ timeout: 8000 },
);
}
const geometry = await page.evaluate((siteName) => {
const h1 = document.querySelector("h1");
const lead = document.querySelector(".hero-lead, .hero-intro");
const targetSelector = siteName === "rfs"
? ".scenario-btn, .vis-tab"
: ".board-select, [data-season-dir], #copyOrder, #orderItem, #orderQty";
const targets = [...document.querySelectorAll(targetSelector)]
.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) => {
const rect = element.getBoundingClientRect();
return { label: element.textContent.trim().slice(0, 24), width: Math.round(rect.width), height: Math.round(rect.height) };
});
return {
width: innerWidth,
scrollWidth: document.documentElement.scrollWidth,
h1Count: document.querySelectorAll("h1").length,
h1Inset: Math.round(h1.getBoundingClientRect().left),
leadInset: lead ? Math.round(lead.getBoundingClientRect().left) : null,
targets,
brokenImages: [...document.images].filter((image) => image.complete && image.naturalWidth === 0).length,
fieldState: document.querySelector("[data-field-shell]")?.dataset.fieldState || "n/a",
};
}, site);
const suite = `${site} responsive ${width}`;
assert(geometry.scrollWidth <= geometry.width, suite, "no horizontal overflow", `${geometry.scrollWidth}/${geometry.width}`);
assert(geometry.h1Count === 1, suite, "one h1", String(geometry.h1Count));
assert(geometry.h1Inset >= 12, suite, "h1 left inset >=12px", String(geometry.h1Inset));
assert(geometry.leadInset === null || Math.abs(geometry.h1Inset - geometry.leadInset) <= 10, suite, "hero copy shares one left axis", `${geometry.h1Inset}/${geometry.leadInset}`);
assert(geometry.brokenImages === 0, suite, "all images decode", String(geometry.brokenImages));
if (width < 500) assertEvery(geometry.targets, (item) => item.height >= 44, suite, "new touch target height >=44px", (item) => `${item.label} ${item.width}x${item.height}`);
if (site === "rfs") assert(["live", "static"].includes(geometry.fieldState), suite, "field exits loading", geometry.fieldState);
}
}
// 2. RFS presets interpret the operating state, manual edits clear the preset, and render views switch.
await page.setViewport({ width: 1440, height: 900 });
await goto(page, urls.rfs);
await page.waitForFunction(() => document.querySelector("[data-field-shell]")?.dataset.fieldState !== "loading", { timeout: 8000 });
assert((await page.$$eval("[data-field-preset]", (items) => items.length)) === 3, "RFS controls", "three operating presets");
await page.click('[data-field-preset="overload"]');
const overload = await page.evaluate(() => ({
load: document.querySelector("#ctlLoad").value,
supply: document.querySelector("#ctlSupply").value,
selected: document.querySelector('[data-field-preset="overload"]').getAttribute("aria-pressed"),
scene: document.querySelector("#fieldScene").textContent.trim(),
state: document.querySelector("#fieldState").textContent.trim(),
watch: document.querySelector("#fieldWatch").textContent.trim(),
action: document.querySelector("#fieldAction").textContent.trim(),
}));
assert(overload.load === "95" && overload.supply === "50", "RFS controls", "overload preset sets both inputs", `${overload.load}/${overload.supply}`);
assert(overload.selected === "true" && overload.scene === "랙 과부하", "RFS controls", "selected preset is announced", `${overload.selected}/${overload.scene}`);
assert(overload.state.includes("발열이 급기보다 크게"), "RFS controls", "output interprets the imbalance", overload.state);
assert(overload.watch.includes("천장 배기") && overload.action.includes("급기 상향"), "RFS controls", "output gives observation and next action", `${overload.watch}/${overload.action}`);
await page.$eval("#ctlLoad", (input) => {
input.value = "80";
input.dispatchEvent(new Event("input", { bubbles: true }));
});
const manual = await page.evaluate(() => ({
scene: document.querySelector("#fieldScene").textContent.trim(),
pressed: [...document.querySelectorAll("[data-field-preset]")].map((button) => button.getAttribute("aria-pressed")),
out: document.querySelector("#outLoad").textContent.trim(),
}));
assert(manual.scene === "수동 조정", "RFS controls", "manual slider clears named scene", manual.scene);
assert(manual.pressed.every((value) => value === "false"), "RFS controls", "manual slider clears every preset", manual.pressed.join(","));
assert(manual.out === "80%", "RFS controls", "manual slider output stays synchronized", manual.out);
await page.click('[data-vis-target="side"]');
const view = await page.evaluate(() => ({
topHidden: document.querySelector('[data-vis-panel="top"]').hidden,
sideHidden: document.querySelector('[data-vis-panel="side"]').hidden,
sidePressed: document.querySelector('[data-vis-target="side"]').getAttribute("aria-pressed"),
visibleFigures: [...document.querySelectorAll("[data-vis-panel]")].filter((figure) => !figure.hidden).length,
}));
assert(view.topHidden && !view.sideHidden && view.sidePressed === "true", "RFS visual output", "side view replaces top view", JSON.stringify(view));
assert(view.visibleFigures === 1, "RFS visual output", "only one render occupies the viewer", String(view.visibleFigures));
await page.focus('[data-field-preset="baseline"]');
await page.keyboard.press("Tab");
assert(await page.$eval('[data-field-preset="overload"]', (button) => button === document.activeElement), "RFS keyboard", "preset order follows visual order");
await page.keyboard.press("Enter");
assert(await page.$eval('[data-field-preset="overload"]', (button) => button.getAttribute("aria-pressed") === "true"), "RFS keyboard", "preset activates from keyboard");
// 3. Reduced-motion keeps RFS interpretation interactive without starting the simulation.
await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
await goto(page, urls.rfs);
await page.click('[data-field-preset="supply-loss"]');
const reduced = await page.evaluate(() => ({
fieldState: document.querySelector("[data-field-shell]").dataset.fieldState,
scene: document.querySelector("#fieldScene").textContent.trim(),
load: document.querySelector("#ctlLoad").value,
supply: document.querySelector("#ctlSupply").value,
}));
assert(reduced.fieldState === "static", "RFS reduced motion", "simulation is static", reduced.fieldState);
assert(reduced.scene === "급기 저하" && reduced.load === "65" && reduced.supply === "25", "RFS reduced motion", "controls and interpretation remain available", JSON.stringify(reduced));
await page.emulateMediaFeatures([]);
// 4. 새벽청과 uses the current Korean market date and turns every price row into a readable lot.
await goto(page, urls.market);
const stamp = expectedKoreanStamp();
const date = await page.$eval(".hero-meta time", (time) => ({ text: time.textContent.trim(), datetime: time.dateTime }));
assert(date.text === stamp.text && date.datetime === stamp.datetime, "새벽청과 date", "market stamp is current in Korea", `${date.text}/${date.datetime}`);
assert((await page.$$eval("[data-market-row]", (items) => items.length)) === 6, "새벽청과 lot board", "six selectable lots");
await page.click('[data-market-row][data-name="무화과"]');
const fig = await page.evaluate(() => ({
selected: document.querySelector('[data-market-row][data-name="무화과"]').getAttribute("aria-pressed"),
name: document.querySelector("#lotName").textContent.trim(),
origin: document.querySelector("#lotOrigin").textContent.trim(),
grade: document.querySelector("#lotGrade").textContent.trim(),
pack: document.querySelector("#lotPack").textContent.trim(),
price: document.querySelector("#lotPrice").textContent.trim(),
orderItem: document.querySelector("#orderItem").value,
}));
assert(fig.selected === "true" && fig.name === "무화과" && fig.origin === "전남 영암", "새벽청과 lot board", "selected lot identity is synchronized", JSON.stringify(fig));
assert(fig.grade.includes("당도 표기 대상 아님") && fig.pack === "1.2 kg" && fig.price === "24,000원", "새벽청과 lot board", "selected lot facts are explicit", JSON.stringify(fig));
assert(fig.orderItem === "무화과", "새벽청과 lot board", "selected lot carries into order memo", fig.orderItem);
// 5. Season browsing and order memo calculations have explicit, asserted outcomes.
const beforeMonth = await page.$eval("#seasonMonth", (output) => Number.parseInt(output.textContent, 10));
await page.click('[data-season-dir="1"]');
const season = await page.evaluate(() => ({
month: Number.parseInt(document.querySelector("#seasonMonth").textContent, 10),
count: document.querySelector("#seasonCount").textContent.trim(),
selectedHeads: document.querySelectorAll(".season thead .is-focus").length,
}));
const expectedNextMonth = (beforeMonth % 12) + 1;
assert(season.month === expectedNextMonth, "새벽청과 season", "next month wraps correctly", `${beforeMonth}->${season.month}`);
assert(/^\d+품목 · /.test(season.count) && season.selectedHeads === 1, "새벽청과 season", "month has one highlighted header and a named list", JSON.stringify(season));
await page.select("#orderItem", "샤인머스캣");
await page.$eval("#orderQty", (input) => {
input.value = "4";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const order = await page.evaluate(() => ({
pack: document.querySelector("#orderPack").textContent.trim(),
total: document.querySelector("#orderTotal").textContent.trim(),
quantity: document.querySelector("#orderQty").value,
}));
assert(order.pack === "2 kg × 4상자" && order.total === "106,000원", "새벽청과 order", "pack and auction example total recalculate", JSON.stringify(order));
assert(order.quantity === "4", "새벽청과 order", "valid quantity is preserved", order.quantity);
await page.$eval("#orderQty", (input) => {
input.value = "1";
input.dispatchEvent(new Event("change", { bubbles: true }));
});
assert((await page.$eval("#orderQty", (input) => input.value)) === "3", "새벽청과 order", "quantity clamps to the stated minimum");
await browser.defaultBrowserContext().overridePermissions(origin, ["clipboard-read", "clipboard-write"]);
await page.$eval("#orderQty", (input) => {
input.value = "4";
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await page.click("#copyOrder");
await page.waitForFunction(() => document.querySelector("#orderStatus")?.textContent.includes("복사했습니다"));
const copied = await page.evaluate(async () => ({
status: document.querySelector("#orderStatus").textContent.trim(),
text: await navigator.clipboard.readText(),
}));
assert(copied.status === "계산 내용을 복사했습니다.", "새벽청과 order", "clipboard action reports success", copied.status);
assert(copied.text.includes("품목: 샤인머스캣") && copied.text.includes("수량: 4상자") && copied.text.includes("106,000원"), "새벽청과 order", "clipboard contains the actual selection", copied.text.replaceAll("\n", " / "));
// 6. Axe scans both upgraded surfaces, and content survives JavaScript loss.
const axePath = require.resolve("axe-core/axe.min.js");
for (const [site, target] of Object.entries(urls)) {
await goto(page, target);
await page.addScriptTag({ path: axePath });
const axe = await page.evaluate(async () => axe.run(document, { resultTypes: ["violations"] }));
const detail = axe.violations.map((item) => `${item.id}: ${item.nodes.map((node) => node.target.join(" ")).join(" | ")}`).join("; ");
assert(axe.violations.length === 0, `${site} axe`, "zero WCAG violations", detail);
const perf = await page.evaluate(() => ({
cls: window.__recentShowcasePerf?.cls ?? -1,
lcp: window.__recentShowcasePerf?.lcp ?? -1,
sameOriginBytes: performance.getEntriesByType("resource")
.filter((entry) => new URL(entry.name).origin === location.origin)
.reduce((sum, entry) => sum + (entry.encodedBodySize || entry.transferSize || 0), 0),
}));
assert(perf.cls >= 0 && perf.cls <= 0.1, `${site} performance`, "CLS <=0.1", String(perf.cls));
assert(perf.lcp > 0 && perf.lcp <= 3500, `${site} performance`, "local LCP <=3.5s", String(perf.lcp));
assert(perf.sameOriginBytes > 0 && perf.sameOriginBytes <= 2_000_000, `${site} performance`, "same-origin first load <=2MB", String(perf.sameOriginBytes));
}
for (const [site, target] of Object.entries(urls)) {
const noJs = await newPage({ javascript: false });
await noJs.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await goto(noJs, target);
const fallback = await noJs.evaluate((siteName) => ({
h1: document.querySelector("h1")?.textContent.trim() || "",
bodyLength: document.body.innerText.trim().length,
coreContent: siteName === "rfs"
? document.querySelector("#problem")?.textContent.trim().length || 0
: document.querySelectorAll("[data-market-row]").length,
overflow: document.documentElement.scrollWidth > innerWidth,
}), site);
assert(fallback.h1.length > 0 && fallback.bodyLength > 1000, `${site} no-JS`, "primary content remains readable", `${fallback.h1}/${fallback.bodyLength}`);
assert(fallback.coreContent > 0 && !fallback.overflow, `${site} no-JS`, "core evidence survives without horizontal overflow", JSON.stringify(fallback));
await noJs.close();
}
assert(browserErrors.length === 0, "browser runtime", "zero page, console, and response errors", browserErrors.join(" | "));
await page.close();
} finally {
await browser.close();
await new Promise((resolve) => server.close(resolve));
}
for (const [suite, result] of suites) {
console.log(`${result.fail ? "FAIL" : "PASS"} ${suite} — ${result.pass}/${result.pass + result.fail}`);
}
if (failures.length) {
console.error("\nFailures:");
for (const failure of failures) console.error(`- ${failure}`);
}
console.log(`\nPASS ${pass} / FAIL ${fail}`);
process.exit(fail ? 1 : 0);