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