375 lines
20 KiB
JavaScript
375 lines
20 KiB
JavaScript
// 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-stamp 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);
|