Upgrade RFS and Saebyeok showcases
Some checks failed
ci / build (push) Has been cancelled

This commit is contained in:
Yun Chan 2026-08-25 04:41:51 +09:00
parent 9529e3747c
commit 413486958c
13 changed files with 1410 additions and 88 deletions

View file

@ -0,0 +1,375 @@
// 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);