#!/usr/bin/env node // Focused browser gate for the five archived showcase pages. It deliberately // exercises the static pages as shipped and leaves every production file alone. import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import puppeteer from "puppeteer-core"; import { collectPageFailures, prepareStillPage, resolveChrome, startPublicServer, waitForStableVisual, } from "./capture-stable.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const siteRoot = path.resolve(here, ".."); const VIEWPORTS = [ { width: 390, height: 844 }, { width: 1440, height: 1000 }, ]; function parseArguments(argv) { const options = { root: "public" }; for (let index = 2; index < argv.length; index += 1) { const argument = argv[index]; if (argument === "--root") options.root = argv[++index]; else if (argument.startsWith("--root=")) options.root = argument.slice("--root=".length); else if (argument === "--help" || argument === "-h") options.help = true; else throw new Error(`Unknown argument: ${argument}`); } if (!options.root) throw new Error("--root needs a directory"); return options; } function showHelp() { console.log(`Archived showcase E2E Usage: ARCHIVE_SHOWCASES_E2E_REPORT=tools/.gen/archive-showcases-e2e.json node tools/archive-showcases-e2e.mjs --root=public The report path is controlled by ARCHIVE_SHOWCASES_E2E_REPORT. The script uses the shared local startPublicServer helper and writes partial results on failure.`); } const options = parseArguments(process.argv); if (options.help) { showHelp(); process.exit(0); } const publicRoot = path.resolve(siteRoot, options.root); const reportPath = path.resolve(process.env.ARCHIVE_SHOWCASES_E2E_REPORT || path.join(here, ".gen", "archive-showcases-e2e.json")); const report = { schemaVersion: 1, root: publicRoot, reportPath, startedAt: new Date().toISOString(), partial: true, assertions: [], fatalError: null, }; function writeReport(partial = true) { report.partial = partial; report.finishedAt = new Date().toISOString(); const passed = report.assertions.filter((assertion) => assertion.passed).length; report.summary = { passed, failed: report.assertions.length - passed, total: report.assertions.length, }; fs.mkdirSync(path.dirname(reportPath), { recursive: true }); const temporary = `${reportPath}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(report, null, 2)}\n`, "utf8"); fs.renameSync(temporary, reportPath); } function check(name, passed, details = {}) { report.assertions.push({ name, passed: Boolean(passed), ...details }); } function fail(name, error, details = {}) { check(name, false, { ...details, error: error instanceof Error ? error.message : String(error) }); } async function openPage(browser, origin, route, viewport) { const page = await browser.newPage(); await page.setViewport(viewport); await prepareStillPage(page); const pageFailures = collectPageFailures(page, origin); const consoleErrors = []; const mutations = []; page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); page.on("request", (request) => { if (request.method() !== "GET") mutations.push({ method: request.method(), url: request.url() }); }); const response = await page.goto(`${origin}${route}`, { waitUntil: "domcontentloaded", timeout: 30000 }); await waitForStableVisual(page, { settleMs: 120 }); return { page, response, pageFailures, consoleErrors, mutations }; } async function checkPageHealth(context, route, viewport, focusSelector) { const status = context.response?.status() || 0; check(`${route} ${viewport.width}px HTTP 200`, status === 200, { route, viewport: viewport.width, actual: status }); try { await context.page.waitForSelector(focusSelector, { visible: true, timeout: 5000 }); await context.page.$eval(focusSelector, (element) => element.scrollIntoView({ block: "center", inline: "nearest" })); await context.page.focus(focusSelector); const geometry = await context.page.evaluate((selector) => { const element = document.querySelector(selector); if (!element) return null; const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { active: document.activeElement === element, visible: style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0, insideViewport: rect.left >= 0 && rect.right <= innerWidth && rect.top >= 0 && rect.bottom <= innerHeight, documentFits: document.documentElement.scrollWidth <= innerWidth, outline: `${style.outlineStyle} ${style.outlineWidth}`, }; }, focusSelector); check(`${route} ${viewport.width}px focus and visible geometry`, Boolean(geometry?.active && geometry.visible && geometry.insideViewport && geometry.documentFits), { route, viewport: viewport.width, selector: focusSelector, actual: geometry, }); } catch (error) { fail(`${route} ${viewport.width}px focus and visible geometry`, error, { route, viewport: viewport.width, selector: focusSelector }); } } function checkRuntimeHealth(context, route, viewport) { check(`${route} ${viewport.width}px local requests and page errors`, context.pageFailures.length === 0, { route, viewport: viewport.width, actual: context.pageFailures, }); check(`${route} ${viewport.width}px console errors`, context.consoleErrors.length === 0, { route, viewport: viewport.width, actual: context.consoleErrors, }); } async function visitEachViewport(browser, origin, route, focusSelector, exercise) { for (const viewport of VIEWPORTS) { let context; try { context = await openPage(browser, origin, route, viewport); await checkPageHealth(context, route, viewport, focusSelector); await exercise(context.page, context, viewport); } catch (error) { fail(`${route} ${viewport.width}px interaction suite`, error, { route, viewport: viewport.width }); } finally { if (context) { checkRuntimeHealth(context, route, viewport); await context.page.close(); } writeReport(true); } } } async function runSuite(name, callback) { try { await callback(); } catch (error) { fail(name, error); } writeReport(true); } async function cheongon(browser, origin) { await visitEachViewport(browser, origin, "/work/cheongon-care/", "#pyeongSlider", async (page, context, viewport) => { const before = await page.$eval("#sumTotal", (element) => element.textContent.trim()); await page.$eval("#pyeongSlider", (element) => { element.value = element.max; element.dispatchEvent(new Event("input", { bubbles: true })); }); const afterArea = await page.$eval("#sumTotal", (element) => element.textContent.trim()); check(`Cheongon area changes total ${viewport.width}px`, before !== afterArea, { before, after: afterArea }); const typeButton = await page.$(".type-btn[data-type='premium']") || await page.$(".type-btn[data-base='14500']"); if (!typeButton) throw new Error("Space-type control was not found"); await typeButton.click(); const afterType = await page.$eval("#sumTotal", (element) => element.textContent.trim()); const activeType = await page.$eval(".type-btn.is-active", (element) => element.getAttribute("data-base")); check(`Cheongon space type changes total ${viewport.width}px`, afterArea !== afterType && activeType === "14500", { before: afterArea, after: afterType, activeType }); await page.click("#btnModeAdmin"); const admin = await page.$eval("body", (element) => element.getAttribute("data-view")); await page.click("#btnModeClient"); const client = await page.$eval("body", (element) => element.getAttribute("data-view")); check(`Cheongon operational modes switch ${viewport.width}px`, admin === "admin" && client === "client", { admin, client }); }); const calculatorLink = ".card a[href='../index.html#calculator']"; await visitEachViewport(browser, origin, "/work/cheongon-care/protocol/", calculatorLink, async (page, context, viewport) => { await Promise.all([ page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 10000 }), page.click(calculatorLink), ]); const anchorState = await page.evaluate(() => ({ pathname: location.pathname, hash: location.hash, target: Boolean(document.querySelector("#calculator")), })); check(`Cheongon protocol returns to calculator anchor ${viewport.width}px`, anchorState.pathname === "/work/cheongon-care/index.html" && anchorState.hash === "#calculator" && anchorState.target, { actual: anchorState }); }); } async function cask(browser, origin) { await visitEachViewport(browser, origin, "/work/cask-archive/", "#caskSearch", async (page, context, viewport) => { const visibleRows = () => page.$$eval("#caskTableBody tr", (rows) => rows.filter((row) => getComputedStyle(row).display !== "none").length); const allRows = await visibleRows(); await page.type("#caskSearch", "laphroaig"); const searchedRows = await visibleRows(); await page.click(".filter-btn[data-filter='sherry']"); const filteredRows = await visibleRows(); const activeFilter = await page.$eval(".filter-btn.is-active", (element) => element.getAttribute("data-filter")); check(`Cask search filters ledger rows ${viewport.width}px`, allRows > searchedRows && searchedRows > 0, { allRows, searchedRows }); check(`Cask flavor filter applies ${viewport.width}px`, activeFilter === "sherry" && filteredRows === 0, { activeFilter, filteredRows }); }); await visitEachViewport(browser, origin, "/work/cask-archive/docent/", "#courseSelect", async (page, context, viewport) => { const initialCourse = await page.$eval("#courseSelect", (element) => element.value); await page.select("#courseSelect", "courseA"); const selectedCourse = await page.$eval("#courseSelect", (element) => element.value); check(`Cask docent course selection applies ${viewport.width}px`, initialCourse !== selectedCourse && selectedCourse === "courseA", { initialCourse, selectedCourse }); const formValidity = await page.$eval("form", (form) => form.checkValidity()); check(`Cask docent form blocks incomplete reservation ${viewport.width}px`, formValidity === false, { formValidity }); await page.$eval("#resDate", (element) => { element.value = "2026-10-01"; element.dispatchEvent(new Event("input", { bubbles: true })); }); await page.select("#resTime", "19:00 (1부)"); await page.type("#userName", "검증 사용자"); await page.type("#userPhone", "01012345678"); const dialogPromise = new Promise((resolve) => page.once("dialog", async (dialog) => { const message = dialog.message(); await dialog.accept(); resolve(message); })); await page.click("form button[type='submit']"); const dialogMessage = await dialogPromise; const currentUrl = page.url(); check(`Cask docent virtual reservation stays local ${viewport.width}px`, dialogMessage.includes("실제 예약은 접수되지 않았") && currentUrl === `${origin}/work/cask-archive/docent/` && context.mutations.length === 0, { dialogMessage, currentUrl, mutationRequests: context.mutations, }); }); } async function materia(browser, origin) { await visitEachViewport(browser, origin, "/work/materia-interior/", "#btnAmbianceNight", async (page, context, viewport) => { const dayHeader = await page.$eval(".site-head", (element) => getComputedStyle(element).backgroundColor); await page.click("#btnAmbianceNight"); const night = await page.evaluate(() => ({ ambiance: document.body.getAttribute("data-ambiance"), active: document.querySelector("#btnAmbianceNight")?.classList.contains("is-active"), header: getComputedStyle(document.querySelector(".site-head")).backgroundColor, })); await page.click("#btnAmbianceDay"); const day = await page.evaluate(() => ({ ambiance: document.body.getAttribute("data-ambiance"), active: document.querySelector("#btnAmbianceDay")?.classList.contains("is-active"), header: getComputedStyle(document.querySelector(".site-head")).backgroundColor, })); check(`Materia Day/Night state and CSS lighting switch ${viewport.width}px`, night.ambiance === "night" && night.active && night.header !== dayHeader && day.ambiance === "day" && day.active && day.header === dayHeader, { dayHeader, night, day }); }); await visitEachViewport(browser, origin, "/work/materia-interior/projects/hannam-duplex/", ".gallery-card-action", async (page, context, viewport) => { const galleryCount = await page.$$eval(".gallery-card", (cards) => cards.length); const actionCount = await page.$$eval(".gallery-card-action", (actions) => actions.length); check(`Materia gallery has 24 accessible entries ${viewport.width}px`, galleryCount === 24 && actionCount === 24, { galleryCount, actionCount }); await page.focus(".gallery-card-action"); await page.keyboard.press("Enter"); const opened = await page.$eval("#lightboxDialog", (dialog) => ({ open: dialog.open, index: document.querySelector("#lightboxIndex")?.textContent.trim(), src: document.querySelector("#lightboxImg")?.currentSrc, })); await page.click("button[onclick='nextLightbox()']"); const advanced = await page.$eval("#lightboxDialog", (dialog) => ({ open: dialog.open, index: document.querySelector("#lightboxIndex")?.textContent.trim(), src: document.querySelector("#lightboxImg")?.currentSrc, })); check(`Materia gallery opens and advances lightbox ${viewport.width}px`, opened.open && opened.index === "1 / 24" && advanced.open && advanced.index === "2 / 24" && opened.src !== advanced.src, { opened, advanced }); await page.keyboard.press("Escape"); const closed = await page.$eval("#lightboxDialog", (dialog) => ({ open: dialog.open, focusReturned: document.activeElement?.classList.contains("gallery-card-action") || false, })); check(`Materia lightbox Escape restores trigger focus ${viewport.width}px`, !closed.open && closed.focusReturned, { closed }); }); await visitEachViewport(browser, origin, "/work/materia-interior/hannam-duplex.html", ".gallery-card-action", async (page, context, viewport) => { const destination = page.url(); check(`Materia legacy detail reaches project detail ${viewport.width}px`, destination === `${origin}/work/materia-interior/projects/hannam-duplex/index.html`, { destination }); }); } async function form(browser, origin) { await visitEachViewport(browser, origin, "/work/form-architecture/", ".index a", async (page, context, viewport) => { const anchors = await page.$$eval(".index a", (links) => links.map((link) => ({ href: link.getAttribute("href"), targetExists: Boolean(document.querySelector(link.getAttribute("href"))), }))); check(`FORM exposes six project anchors ${viewport.width}px`, anchors.length === 6 && anchors.every((anchor) => anchor.href === "#drawing" && anchor.targetExists), { anchors }); }); } async function font(browser, origin) { await visitEachViewport(browser, origin, "/work/font-foundry/", "#ctl-font", async (page, context, viewport) => { const comparison = await page.$$eval(".specimen", (elements) => elements.map((element) => { const style = getComputedStyle(element); return { width: element.getBoundingClientRect().width, fontSize: style.fontSize, lineHeight: style.lineHeight }; })); const equalComparison = comparison.length === 2 && Math.abs(comparison[0].width - comparison[1].width) < 0.1 && comparison.every((item) => item.fontSize === "22px" && Math.abs(parseFloat(item.lineHeight) - 39.6) < 0.2); check(`Font comparison keeps equal 22px / 1.8 widths ${viewport.width}px`, equalComparison, { comparison }); await page.select("#ctl-font", "body"); await page.$eval("#ctl-size", (element) => { element.value = "22"; element.dispatchEvent(new Event("input", { bubbles: true })); }); await page.$eval("#ctl-tracking", (element) => { element.value = "5"; element.dispatchEvent(new Event("input", { bubbles: true })); }); await page.$eval("#ctl-leading", (element) => { element.value = "2.2"; element.dispatchEvent(new Event("input", { bubbles: true })); }); const adjusted = await page.$eval("#bench-sample", (element) => { const style = getComputedStyle(element); return { fontFamily: style.fontFamily, fontSize: style.fontSize, letterSpacing: style.letterSpacing, lineHeight: style.lineHeight }; }); check(`Font controls apply three ranges and selection ${viewport.width}px`, adjusted.fontFamily.includes("Pretendard") && adjusted.fontSize === "22px" && Math.abs(parseFloat(adjusted.letterSpacing) - 1.1) < 0.1 && Math.abs(parseFloat(adjusted.lineHeight) - 48.4) < 0.2, { adjusted }); await page.click("#ctl-reset"); const reset = await page.evaluate(() => ({ font: document.querySelector("#ctl-font")?.value, size: document.querySelector("#ctl-size")?.value, tracking: document.querySelector("#ctl-tracking")?.value, leading: document.querySelector("#ctl-leading")?.value, sample: getComputedStyle(document.querySelector("#bench-sample")).lineHeight, })); check(`Font reset restores shipped defaults ${viewport.width}px`, reset.font === "display" && reset.size === "17" && reset.tracking === "0" && reset.leading === "1.8" && Math.abs(parseFloat(reset.sample) - 30.6) < 0.2, { reset }); }); } let browser; let server; try { if (!fs.existsSync(publicRoot) || !fs.statSync(publicRoot).isDirectory()) throw new Error(`Serve root does not exist: ${publicRoot}`); writeReport(true); server = await startPublicServer(publicRoot); browser = await puppeteer.launch({ executablePath: resolveChrome(), headless: true, args: ["--disable-dev-shm-usage", "--no-sandbox"], }); await runSuite("Cheongon suite", () => cheongon(browser, server.origin)); await runSuite("Cask suite", () => cask(browser, server.origin)); await runSuite("Materia suite", () => materia(browser, server.origin)); await runSuite("FORM suite", () => form(browser, server.origin)); await runSuite("Font suite", () => font(browser, server.origin)); } catch (error) { report.fatalError = error instanceof Error ? error.message : String(error); writeReport(true); } finally { if (browser) await browser.close(); if (server) await server.close(); } const failed = report.assertions.filter((assertion) => !assertion.passed).length; writeReport(Boolean(report.fatalError) || failed > 0); console.log(`archive-showcases-e2e: ${report.summary.passed}/${report.summary.total} assertions passed; report ${reportPath}`); if (report.fatalError || failed > 0) process.exitCode = 1;