feat: strengthen design skill and refresh all showcases
Some checks failed
ci / build (push) Has been cancelled
Some checks failed
ci / build (push) Has been cancelled
This commit is contained in:
parent
69e5cc1efd
commit
e09d4efb1c
257 changed files with 8196 additions and 2374 deletions
384
apps/site/tools/archive-showcases-e2e.mjs
Normal file
384
apps/site/tools/archive-showcases-e2e.mjs
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
#!/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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue