feat: strengthen design skill and refresh all showcases
Some checks failed
ci / build (push) Has been cancelled
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 51 KiB |
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;
|
||||
|
|
@ -21,6 +21,16 @@ const jclick = (sel) => page.evaluate((s) => { const el = document.querySelector
|
|||
const over = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
const gotoFile = async (app) =>
|
||||
page.goto(`file:///${path.join(root, "public/work", app, "index.html")}`, { waitUntil: "networkidle0" });
|
||||
const gotoWorkFile = async (...parts) =>
|
||||
page.goto(`file:///${path.join(root, "public/work", ...parts)}`, { waitUntil: "networkidle0" });
|
||||
const centeredDialog = (selector) => page.$eval(selector, (dialog) => {
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
return {
|
||||
deltaX: Math.abs(rect.left + rect.width / 2 - innerWidth / 2),
|
||||
deltaY: Math.abs(rect.top + rect.height / 2 - innerHeight / 2),
|
||||
inViewport: rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight,
|
||||
};
|
||||
});
|
||||
|
||||
// ══════════ 가온 학적부 (8 뷰) ══════════
|
||||
await gotoFile("gaon-lms");
|
||||
|
|
@ -253,6 +263,103 @@ for (const w of [320, 390]) {
|
|||
pass(`두레 ${w}px`, od <= 0, `+${od}px`);
|
||||
}
|
||||
|
||||
// ── 사용자 보고 회귀: 팝업·모달은 좌상단에 붙거나 화면 밖으로 잘리면 안 된다 ──
|
||||
await page.setViewport({ width: 390, height: 320 });
|
||||
await gotoFile("gaon-lms");
|
||||
await jclick("#bell");
|
||||
const bellRect = await page.$eval("#bell-menu", (menu) => {
|
||||
const r = menu.getBoundingClientRect();
|
||||
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, clientHeight: menu.clientHeight, scrollHeight: menu.scrollHeight };
|
||||
});
|
||||
pass("가온 짧은 화면 알림판 뷰포트·스크롤", bellRect.left >= 0 && bellRect.top >= 0 && bellRect.right <= 390 && bellRect.bottom <= 320 && bellRect.clientHeight > 0 && bellRect.scrollHeight >= bellRect.clientHeight, JSON.stringify(bellRect));
|
||||
|
||||
for (const viewport of [{ width: 390, height: 844 }, { width: 1440, height: 900 }, { width: 2560, height: 900 }]) {
|
||||
await page.setViewport(viewport);
|
||||
await gotoFile("materia-interior");
|
||||
await jclick('[onclick^="openProjectModal"]');
|
||||
const projectPosition = await centeredDialog("#projectModal");
|
||||
pass(`마테리아 프로젝트 모달 중앙 ${viewport.width}px`, projectPosition.inViewport && projectPosition.deltaX <= 1 && projectPosition.deltaY <= 1, JSON.stringify(projectPosition));
|
||||
const projectScrollLock = await page.$eval("html", (root) => getComputedStyle(root).overflow);
|
||||
pass(`마테리아 프로젝트 모달 배경 잠금 ${viewport.width}px`, projectScrollLock === "hidden", projectScrollLock);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await gotoWorkFile("materia-interior", "projects", "hannam-duplex", "index.html");
|
||||
await page.waitForSelector(".gallery-card-action");
|
||||
await jclick(".gallery-card-action");
|
||||
const lightboxPosition = await centeredDialog("#lightboxDialog");
|
||||
pass(`마테리아 갤러리 모달 중앙 ${viewport.width}px`, lightboxPosition.inViewport && lightboxPosition.deltaX <= 1 && lightboxPosition.deltaY <= 1, JSON.stringify(lightboxPosition));
|
||||
const lightboxScrollLock = await page.$eval("html", (root) => getComputedStyle(root).overflow);
|
||||
pass(`마테리아 갤러리 모달 배경 잠금 ${viewport.width}px`, lightboxScrollLock === "hidden", lightboxScrollLock);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
await page.setViewport({ width: 390, height: 320 });
|
||||
await gotoFile("hannam-jari");
|
||||
await jclick(".menu-button");
|
||||
const mobileNav = await page.$eval(".site-nav", (nav) => {
|
||||
const r = nav.getBoundingClientRect();
|
||||
return { top: r.top, bottom: r.bottom, clientHeight: nav.clientHeight, scrollHeight: nav.scrollHeight };
|
||||
});
|
||||
pass("한남자리 짧은 화면 메뉴 뷰포트·스크롤", mobileNav.top >= 0 && mobileNav.bottom <= 320 && mobileNav.clientHeight > 0 && mobileNav.scrollHeight >= mobileNav.clientHeight, JSON.stringify(mobileNav));
|
||||
|
||||
await gotoWorkFile("hannam-jari", "admin", "index.html");
|
||||
await jclick("[data-select-row]");
|
||||
const inspectorScroll = await page.$eval(".inspector-scroll", (panel) => ({ clientHeight: panel.clientHeight, scrollHeight: panel.scrollHeight }));
|
||||
pass("한남자리 짧은 화면 조사관 내부 스크롤", inspectorScroll.clientHeight > 0 && inspectorScroll.scrollHeight >= inspectorScroll.clientHeight, JSON.stringify(inspectorScroll));
|
||||
|
||||
// ── 사용자 보고 회귀: 2560px에서 사진 한 장이 본문 밀도를 압도하지 않는다 ──
|
||||
await page.setViewport({ width: 2560, height: 900 });
|
||||
await gotoFile("griddle-club");
|
||||
const griddleWide = await page.$eval(".hero", (hero) => {
|
||||
const rect = (selector, scope = hero) => {
|
||||
const r = scope.querySelector(selector).getBoundingClientRect();
|
||||
return { left: r.left, width: r.width, right: r.right };
|
||||
};
|
||||
const visualLineCount = (node) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
return new Set([...range.getClientRects()].map((r) => Math.round(r.top))).size;
|
||||
};
|
||||
const heroRect = hero.getBoundingClientRect();
|
||||
const rail = document.querySelector(".menu-grid").getBoundingClientRect();
|
||||
const process = document.querySelector(".process-grid");
|
||||
const processRect = process.getBoundingClientRect();
|
||||
const processHeading = process.querySelector(".process-copy h2");
|
||||
return {
|
||||
hero: { left: heroRect.left, width: heroRect.width, right: heroRect.right },
|
||||
copy: rect(".hero-copy"),
|
||||
media: rect(".hero-media"),
|
||||
contentRail: { left: rail.left, width: rail.width, right: rail.right },
|
||||
process: {
|
||||
rail: { left: processRect.left, width: processRect.width, right: processRect.right },
|
||||
copy: rect(".process-copy", process),
|
||||
media: rect(".process-media", process),
|
||||
heading: rect(".process-copy h2", process),
|
||||
headingLines: visualLineCount(processHeading),
|
||||
},
|
||||
};
|
||||
});
|
||||
const heroCentered = Math.abs(griddleWide.hero.left + griddleWide.hero.width / 2 - 1280) <= 1;
|
||||
pass("철판클럽 2560px 히어로·이미지 비율", heroCentered
|
||||
&& griddleWide.hero.width >= 2140
|
||||
&& griddleWide.hero.width <= 2162
|
||||
&& griddleWide.copy.width >= 840
|
||||
&& griddleWide.copy.width <= 902
|
||||
&& griddleWide.media.width >= 1050
|
||||
&& griddleWide.media.width <= 1121
|
||||
&& griddleWide.media.width <= griddleWide.copy.width * 1.3, JSON.stringify(griddleWide));
|
||||
pass("철판클럽 2560px 후속 콘텐츠 레일", griddleWide.contentRail.width >= 1200 && griddleWide.contentRail.width <= 1482 && griddleWide.contentRail.left >= 0 && griddleWide.contentRail.right <= 2560, JSON.stringify(griddleWide.contentRail));
|
||||
const processCentered = Math.abs(griddleWide.process.rail.left + griddleWide.process.rail.width / 2 - 1280) <= 1;
|
||||
pass("철판클럽 2560px 과정 텍스트·이미지 분할", processCentered
|
||||
&& griddleWide.process.rail.width >= 1678
|
||||
&& griddleWide.process.rail.width <= 1682
|
||||
&& griddleWide.process.copy.width >= 810
|
||||
&& griddleWide.process.copy.width <= 835
|
||||
&& griddleWide.process.media.width >= 840
|
||||
&& griddleWide.process.media.width <= 870
|
||||
&& griddleWide.process.heading.width >= 620
|
||||
&& griddleWide.process.headingLines <= 2, JSON.stringify(griddleWide.process));
|
||||
|
||||
// ── CSS 중괄호 균형 (v11 교훈) ──
|
||||
import fs2 from "node:fs";
|
||||
for (const f of ["gaon-lms/styles/main.css", "dure-enrollment/styles/main.css", "gaon-lms/styles/tokens.css", "dure-enrollment/styles/tokens.css"]) {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,19 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
import {
|
||||
collectPageFailures,
|
||||
prepareStillPage,
|
||||
resolveChrome,
|
||||
routeForFile,
|
||||
startPublicServer,
|
||||
waitForStableVisual,
|
||||
} from "./capture-stable.mjs";
|
||||
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, "..");
|
||||
const shots = path.join(root, "src", "assets", "shots");
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const publicRoot = path.join(root, "public");
|
||||
|
||||
function requestedSlugs(argv) {
|
||||
const slugs = new Set();
|
||||
|
|
@ -31,17 +40,6 @@ function requestedSlugs(argv) {
|
|||
return slugs;
|
||||
}
|
||||
|
||||
const CHROME = [
|
||||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||
process.env.CHROME_PATH,
|
||||
].filter(Boolean).find((p) => fs.existsSync(p));
|
||||
|
||||
if (!CHROME) {
|
||||
console.error("크롬을 찾지 못했다.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const JOBS = [
|
||||
{
|
||||
slug: "rfs-cfd",
|
||||
|
|
@ -68,42 +66,56 @@ const unknown = [...requested].filter((slug) => !known.has(slug));
|
|||
if (unknown.length) throw new Error(`Unknown showcase slug(s): ${unknown.join(", ")}`);
|
||||
const selectedJobs = requested.size ? JOBS.filter((job) => requested.has(job.slug)) : JOBS;
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME,
|
||||
headless: "new",
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--use-angle=swiftshader",
|
||||
"--enable-unsafe-swiftshader",
|
||||
"--force-device-scale-factor=1",
|
||||
],
|
||||
defaultViewport: { width: 1440, height: 900 },
|
||||
});
|
||||
const server = await startPublicServer(publicRoot);
|
||||
let browser;
|
||||
|
||||
for (const job of selectedJobs) {
|
||||
const page = await browser.newPage();
|
||||
await page.setCacheEnabled(false);
|
||||
const file = path.join(root, "public/work", job.slug, "index.html");
|
||||
await page.goto(url.pathToFileURL(file).href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await sleep(job.settle);
|
||||
try {
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: resolveChrome(),
|
||||
headless: "new",
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--use-angle=swiftshader",
|
||||
"--enable-unsafe-swiftshader",
|
||||
"--force-device-scale-factor=1",
|
||||
],
|
||||
defaultViewport: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
||||
});
|
||||
|
||||
const main = path.join(shots, `work-${job.slug}.png`);
|
||||
await page.screenshot({ path: main });
|
||||
await page.screenshot({ path: path.join(root, "public/work", job.slug, "og.png") });
|
||||
for (const job of selectedJobs) {
|
||||
const page = await browser.newPage();
|
||||
const failures = collectPageFailures(page, server.origin);
|
||||
try {
|
||||
await prepareStillPage(page);
|
||||
const file = path.join(publicRoot, "work", job.slug, "index.html");
|
||||
const response = await page.goto(server.origin + routeForFile(publicRoot, file), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
if (!response?.ok()) throw new Error(`${job.slug} returned ${response?.status() ?? "no response"}`);
|
||||
await waitForStableVisual(page, { settleMs: job.settle });
|
||||
if (failures.length) throw new Error(failures.join(" · "));
|
||||
|
||||
await page.evaluate((sel) => {
|
||||
document.querySelector(sel)?.scrollIntoView({ behavior: "instant", block: "start" });
|
||||
}, job.second.scrollTo);
|
||||
await sleep(job.second.wait);
|
||||
const main = path.join(shots, `work-${job.slug}.png`);
|
||||
await page.screenshot({ path: main });
|
||||
await page.screenshot({ path: path.join(publicRoot, "work", job.slug, "og.png") });
|
||||
|
||||
const alt = path.join(shots, `work-${job.slug}-2.png`);
|
||||
await page.screenshot({ path: alt });
|
||||
await page.close();
|
||||
await page.evaluate((selector) => {
|
||||
document.querySelector(selector)?.scrollIntoView({ behavior: "auto", block: "start" });
|
||||
}, job.second.scrollTo);
|
||||
await new Promise((resolve) => setTimeout(resolve, job.second.wait));
|
||||
|
||||
const kb = (p) => Math.round(fs.statSync(p).size / 1024);
|
||||
console.log(`${job.slug}: 본컷 ${kb(main)}KB · 2컷 ${kb(alt)}KB`);
|
||||
const alt = path.join(shots, `work-${job.slug}-2.png`);
|
||||
await page.screenshot({ path: alt });
|
||||
const kb = (filePath) => Math.round(fs.statSync(filePath).size / 1024);
|
||||
console.log(`${job.slug}: 본컷 ${kb(main)}KB · 2컷 ${kb(alt)}KB`);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("촬영 완료");
|
||||
console.log("정착 상태 촬영 완료");
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import puppeteer from "puppeteer-core";
|
||||
|
||||
import {
|
||||
collectPageFailures,
|
||||
prepareStillPage,
|
||||
resolveChrome,
|
||||
routeForFile,
|
||||
startPublicServer,
|
||||
waitForStableVisual,
|
||||
} from "./capture-stable.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const chromePath = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
||||
const shotsDir = path.join(rootDir, "src", "assets", "shots");
|
||||
const publicRoot = path.join(rootDir, "public");
|
||||
|
||||
function requestedSlugs(argv) {
|
||||
const slugs = new Set();
|
||||
|
|
@ -110,6 +119,24 @@ const targets = [
|
|||
altUrl: path.join(rootDir, "public", "work", "dure-enrollment", "index.html"),
|
||||
altOut: path.join(shotsDir, "work-dure-enrollment-2.png"),
|
||||
},
|
||||
{
|
||||
url: path.join(rootDir, "public", "work", "cheongon-care", "index.html"),
|
||||
out: path.join(shotsDir, "work-cheongon-care.png"),
|
||||
altUrl: path.join(rootDir, "public", "work", "cheongon-care", "protocol", "index.html"),
|
||||
altOut: path.join(shotsDir, "work-cheongon-care-2.png"),
|
||||
},
|
||||
{
|
||||
url: path.join(rootDir, "public", "work", "cask-archive", "index.html"),
|
||||
out: path.join(shotsDir, "work-cask-archive.png"),
|
||||
altUrl: path.join(rootDir, "public", "work", "cask-archive", "docent", "index.html"),
|
||||
altOut: path.join(shotsDir, "work-cask-archive-2.png"),
|
||||
},
|
||||
{
|
||||
url: path.join(rootDir, "public", "work", "materia-interior", "index.html"),
|
||||
out: path.join(shotsDir, "work-materia-interior.png"),
|
||||
altUrl: path.join(rootDir, "public", "work", "materia-interior", "projects", "hannam-duplex", "index.html"),
|
||||
altOut: path.join(shotsDir, "work-materia-interior-2.png"),
|
||||
},
|
||||
];
|
||||
|
||||
const requested = requestedSlugs(process.argv);
|
||||
|
|
@ -119,39 +146,49 @@ const unknown = [...requested].filter((slug) => !known.has(slug));
|
|||
if (unknown.length) throw new Error(`Unknown showcase slug(s): ${unknown.join(", ")}`);
|
||||
const selectedTargets = requested.size ? targets.filter((target) => requested.has(slugOf(target))) : targets;
|
||||
|
||||
console.log("📸 Capturing high-resolution screenshots...");
|
||||
console.log("📸 Capturing settled, high-resolution screenshots...");
|
||||
|
||||
const shot = (t, url, out) => {
|
||||
const flags = t.webgl
|
||||
? ["--no-sandbox", "--use-angle=swiftshader"] // --disable-gpu 는 WebGL 을 죽인다
|
||||
: ["--disable-gpu", "--no-sandbox"];
|
||||
execFileSync(chromePath, [
|
||||
"--headless=new",
|
||||
"--hide-scrollbars",
|
||||
...flags,
|
||||
"--force-device-scale-factor=1",
|
||||
"--window-size=1440,900",
|
||||
`--screenshot=${out}`,
|
||||
`file://${url}`,
|
||||
]);
|
||||
};
|
||||
const server = await startPublicServer(publicRoot);
|
||||
let browser;
|
||||
|
||||
for (const t of selectedTargets) {
|
||||
console.log(`- Capturing ${path.basename(t.out)}`);
|
||||
async function shot(file, output) {
|
||||
const page = await browser.newPage();
|
||||
const failures = collectPageFailures(page, server.origin);
|
||||
try {
|
||||
shot(t, t.url, t.out);
|
||||
} catch (e) {
|
||||
console.error(`Error capturing ${t.out}:`, e.message);
|
||||
}
|
||||
|
||||
if (t.altOut) {
|
||||
console.log(`- Capturing ${path.basename(t.altOut)}`);
|
||||
try {
|
||||
shot(t, t.altUrl, t.altOut);
|
||||
} catch (e) {
|
||||
console.error(`Error capturing ${t.altOut}:`, e.message);
|
||||
}
|
||||
await prepareStillPage(page);
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
const route = routeForFile(publicRoot, file);
|
||||
const response = await page.goto(server.origin + route, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
if (!response?.ok()) throw new Error(`${route} returned ${response?.status() ?? "no response"}`);
|
||||
await waitForStableVisual(page);
|
||||
if (failures.length) throw new Error(failures.join(" · "));
|
||||
await page.screenshot({ path: output, type: "png", fullPage: false });
|
||||
const size = Math.round(fs.statSync(output).size / 1024);
|
||||
console.log(`CAPTURE ${path.basename(output)} (${size}KB)`);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ All screenshots captured successfully!");
|
||||
try {
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: resolveChrome(),
|
||||
headless: "new",
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--use-angle=swiftshader",
|
||||
"--enable-unsafe-swiftshader",
|
||||
"--force-device-scale-factor=1",
|
||||
],
|
||||
defaultViewport: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
||||
});
|
||||
for (const target of selectedTargets) {
|
||||
await shot(target.url, target.out);
|
||||
if (target.altOut) await shot(target.altUrl, target.altOut);
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
|
||||
console.log("✅ All requested screenshots captured after visual settling.");
|
||||
|
|
|
|||
|
|
@ -11,46 +11,24 @@
|
|||
// 공개 자산과 같은 HTTP 경로로 상대 링크를 검증하고, 폰트·이미지가 준비되지 않으면
|
||||
// 불완전한 화면을 저장하지 않고 실패한다.
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
import puppeteer from "puppeteer-core";
|
||||
|
||||
import {
|
||||
collectPageFailures,
|
||||
prepareStillPage,
|
||||
resolveChrome,
|
||||
startPublicServer,
|
||||
waitForStableVisual,
|
||||
} from "./capture-stable.mjs";
|
||||
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, "..");
|
||||
const publicRoot = path.join(root, "public");
|
||||
const shotsRoot = path.join(root, "src", "assets", "shots");
|
||||
|
||||
const chrome = [
|
||||
process.env.CHROME_PATH,
|
||||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium",
|
||||
].find((candidate) => candidate && fs.existsSync(candidate));
|
||||
|
||||
if (!chrome) {
|
||||
throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH or install Chrome/Chromium.");
|
||||
}
|
||||
|
||||
const MIME = new Map([
|
||||
[".avif", "image/avif"],
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".ico", "image/x-icon"],
|
||||
[".jpeg", "image/jpeg"],
|
||||
[".jpg", "image/jpeg"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".png", "image/png"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
||||
[".webp", "image/webp"],
|
||||
[".woff", "font/woff"],
|
||||
[".woff2", "font/woff2"],
|
||||
]);
|
||||
|
||||
const jobs = [
|
||||
{
|
||||
label: "한남자리 공개",
|
||||
|
|
@ -87,80 +65,19 @@ const ogJobs = [
|
|||
},
|
||||
];
|
||||
|
||||
function inside(rootDirectory, candidate) {
|
||||
const relative = path.relative(rootDirectory, candidate);
|
||||
return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
try {
|
||||
const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname);
|
||||
const candidate = path.resolve(publicRoot, `.${pathname}`);
|
||||
if (!inside(publicRoot, candidate)) {
|
||||
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);
|
||||
} catch {
|
||||
response.writeHead(400).end("bad request");
|
||||
}
|
||||
});
|
||||
|
||||
await fs.promises.mkdir(shotsRoot, { recursive: true });
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
const server = await startPublicServer(publicRoot);
|
||||
let browser;
|
||||
|
||||
async function waitForReady(page) {
|
||||
await Promise.race([
|
||||
page.evaluate(() => document.fonts?.ready || Promise.resolve()),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("Font readiness timed out")), 10000)),
|
||||
]);
|
||||
await page.evaluate(async () => {
|
||||
const images = [...document.images];
|
||||
for (const image of images) image.loading = "eager";
|
||||
await Promise.allSettled(images.map((image) => image.decode()));
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
const broken = await page.evaluate(() => [...document.images]
|
||||
.filter((image) => image.complete && image.naturalWidth === 0)
|
||||
.map((image) => image.currentSrc || image.src));
|
||||
if (broken.length) throw new Error(`Broken images: ${broken.join(", ")}`);
|
||||
await page.addStyleTag({
|
||||
content: "*{animation:none!important;transition:none!important;caret-color:transparent!important}html{scroll-behavior:auto!important}::-webkit-scrollbar{display:none!important}",
|
||||
});
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
}
|
||||
|
||||
async function capture(job, viewport) {
|
||||
const page = await browser.newPage();
|
||||
const failures = [];
|
||||
page.on("pageerror", (error) => failures.push(`pageerror ${error.message}`));
|
||||
page.on("response", (response) => {
|
||||
if (response.url().startsWith(origin) && response.status() >= 400) {
|
||||
failures.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
const failures = collectPageFailures(page, server.origin);
|
||||
try {
|
||||
await page.setCacheEnabled(false);
|
||||
await prepareStillPage(page);
|
||||
await page.setViewport(viewport);
|
||||
const response = await page.goto(origin + job.route, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
const response = await page.goto(server.origin + job.route, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
if (!response?.ok()) throw new Error(`${job.route} returned ${response?.status() ?? "no response"}`);
|
||||
await waitForReady(page);
|
||||
await waitForStableVisual(page);
|
||||
if (failures.length) throw new Error(failures.join(" · "));
|
||||
await fs.promises.mkdir(path.dirname(job.output), { recursive: true });
|
||||
await page.screenshot({ path: job.output, type: "png", fullPage: false });
|
||||
|
|
@ -175,7 +92,7 @@ try {
|
|||
console.log("OVERWRITE SCOPE");
|
||||
for (const job of [...jobs, ...ogJobs]) console.log(`- ${path.relative(root, job.output)}`);
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: chrome,
|
||||
executablePath: resolveChrome(),
|
||||
headless: "new",
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
|
|
@ -188,7 +105,7 @@ try {
|
|||
for (const job of ogJobs) await capture(job, { width: 1200, height: 630, deviceScaleFactor: 1 });
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await server.close();
|
||||
}
|
||||
|
||||
console.log("쇼케이스 듀오 카드 4장과 OG 2장 캡처 완료");
|
||||
|
|
|
|||
195
apps/site/tools/capture-stable.mjs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// Stable, reproducible showcase captures.
|
||||
//
|
||||
// A thumbnail must show the composed page, not a font fallback, an unloaded image,
|
||||
// a transition frame, or the next frame of a canvas loop. Keep the local HTTP
|
||||
// server and settling contract shared by every capture entrypoint.
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
|
||||
export const MIME = new Map([
|
||||
[".avif", "image/avif"],
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".ico", "image/x-icon"],
|
||||
[".jpeg", "image/jpeg"],
|
||||
[".jpg", "image/jpeg"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".png", "image/png"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".webmanifest", "application/manifest+json"],
|
||||
[".webp", "image/webp"],
|
||||
[".woff", "font/woff"],
|
||||
[".woff2", "font/woff2"],
|
||||
]);
|
||||
|
||||
export function resolveChrome() {
|
||||
const candidates = [
|
||||
process.env.CHROME_PATH,
|
||||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium",
|
||||
];
|
||||
const chrome = candidates.find((candidate) => candidate && fs.existsSync(candidate));
|
||||
if (!chrome) throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH or install Chrome/Chromium.");
|
||||
return chrome;
|
||||
}
|
||||
|
||||
function isInside(root, candidate) {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export async function startPublicServer(publicRoot) {
|
||||
const server = http.createServer((request, response) => {
|
||||
try {
|
||||
const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname);
|
||||
const candidate = path.resolve(publicRoot, `.${pathname}`);
|
||||
if (!isInside(publicRoot, candidate)) {
|
||||
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);
|
||||
} catch {
|
||||
response.writeHead(400).end("bad request");
|
||||
}
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
return {
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
};
|
||||
}
|
||||
|
||||
export function routeForFile(publicRoot, file) {
|
||||
const relative = path.relative(publicRoot, file).split(path.sep).join("/");
|
||||
if (relative === "index.html") return "/";
|
||||
return `/${relative.replace(/(?:^|\/)index\.html$/, "/")}`.replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
export async function prepareStillPage(page) {
|
||||
await page.setCacheEnabled(false);
|
||||
await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
|
||||
}
|
||||
|
||||
export function collectPageFailures(page, origin) {
|
||||
const failures = [];
|
||||
page.on("pageerror", (error) => failures.push(`pageerror ${error.message}`));
|
||||
page.on("response", (response) => {
|
||||
if (response.url().startsWith(origin) && response.status() >= 400) {
|
||||
failures.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
return failures;
|
||||
}
|
||||
|
||||
async function waitForFonts(page) {
|
||||
await Promise.race([
|
||||
page.evaluate(() => document.fonts?.ready || Promise.resolve()),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("Font readiness timed out")), 10000)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function waitForImages(page) {
|
||||
await page.evaluate(async () => {
|
||||
const images = [...document.images];
|
||||
for (const image of images) image.loading = "eager";
|
||||
await Promise.all(images.map(async (image) => {
|
||||
if (!image.complete) {
|
||||
await new Promise((resolve) => {
|
||||
const timer = window.setTimeout(resolve, 10000);
|
||||
image.addEventListener("load", () => {
|
||||
window.clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
image.addEventListener("error", () => {
|
||||
window.clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
await image.decode().catch(() => undefined);
|
||||
}));
|
||||
});
|
||||
const broken = await page.evaluate(() => [...document.images]
|
||||
.filter((image) => image.complete && image.naturalWidth === 0)
|
||||
.map((image) => image.currentSrc || image.src));
|
||||
if (broken.length) throw new Error(`Broken images: ${broken.join(", ")}`);
|
||||
}
|
||||
|
||||
async function stopTimeBasedMedia(page) {
|
||||
await page.evaluate(async () => {
|
||||
const media = [...document.querySelectorAll("video, audio")];
|
||||
for (const item of media) {
|
||||
item.pause();
|
||||
if (item instanceof HTMLVideoElement && Number.isFinite(item.duration) && item.duration > 0) {
|
||||
await new Promise((resolve) => {
|
||||
const timer = window.setTimeout(resolve, 500);
|
||||
item.addEventListener("seeked", () => {
|
||||
window.clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
item.currentTime = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function freezeAnimations(page) {
|
||||
await page.evaluate(() => {
|
||||
for (const animation of document.getAnimations({ subtree: true })) {
|
||||
const timing = animation.effect?.getComputedTiming();
|
||||
try {
|
||||
if (timing?.iterations === Infinity) animation.pause();
|
||||
else animation.finish();
|
||||
} catch {
|
||||
animation.pause();
|
||||
}
|
||||
}
|
||||
// Existing rAF loops schedule their next frame after this callback completes.
|
||||
// Replacing it here preserves the visually settled canvas while preventing a
|
||||
// different frame from being captured on each run.
|
||||
window.requestAnimationFrame = () => 0;
|
||||
document.documentElement.dataset.thumbnailCapture = "settled";
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||
});
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
html[data-thumbnail-capture="settled"] *,
|
||||
html[data-thumbnail-capture="settled"] *::before,
|
||||
html[data-thumbnail-capture="settled"] *::after {
|
||||
animation-play-state: paused !important;
|
||||
transition-property: none !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
html[data-thumbnail-capture="settled"] { scroll-behavior: auto !important; }
|
||||
html[data-thumbnail-capture="settled"] ::-webkit-scrollbar { display: none !important; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForStableVisual(page, { settleMs = 900 } = {}) {
|
||||
await waitForFonts(page);
|
||||
await waitForImages(page);
|
||||
// Let the page apply its data and initial layout before freezing its final frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
||||
await stopTimeBasedMedia(page);
|
||||
await freezeAnimations(page);
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
|
|
@ -4,11 +4,19 @@ import puppeteer from "puppeteer-core";
|
|||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
import {
|
||||
collectPageFailures,
|
||||
prepareStillPage,
|
||||
resolveChrome,
|
||||
routeForFile,
|
||||
startPublicServer,
|
||||
waitForStableVisual,
|
||||
} from "./capture-stable.mjs";
|
||||
|
||||
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe";
|
||||
const shots = path.join(root, "src", "assets", "shots");
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const publicRoot = path.join(root, "public");
|
||||
|
||||
function requestedSlugs(argv) {
|
||||
const slugs = new Set();
|
||||
|
|
@ -34,42 +42,65 @@ const unknown = [...requested].filter((slug) => !known.has(slug));
|
|||
if (unknown.length) throw new Error(`Unknown app slug(s): ${unknown.join(", ")}`);
|
||||
const selected = requested.size ? requested : known;
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME,
|
||||
headless: "new",
|
||||
args: ["--no-sandbox", "--disable-gpu", "--force-device-scale-factor=1"],
|
||||
defaultViewport: { width: 1440, height: 900 },
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setCacheEnabled(false);
|
||||
const open = async (app) => {
|
||||
await page.goto(url.pathToFileURL(path.join(root, "public/work", app, "index.html")).href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await sleep(250);
|
||||
};
|
||||
const server = await startPublicServer(publicRoot);
|
||||
let browser;
|
||||
|
||||
if (selected.has("gaon-lms")) {
|
||||
// 가온: 대시보드 본컷 + 학적 카드(수강생 모달) -2컷
|
||||
await open("gaon-lms");
|
||||
await page.screenshot({ path: path.join(shots, "work-gaon-lms.png") });
|
||||
await page.screenshot({ path: path.join(root, "public/work/gaon-lms/og.png") });
|
||||
await page.evaluate(() => document.querySelector('[data-view="students"]').click());
|
||||
await page.evaluate(() => document.querySelector("#stu-table [data-stu]").click());
|
||||
await page.waitForSelector("#stu-dialog[open]");
|
||||
await sleep(350); // 모달 등장 모션 200ms + 여유
|
||||
await page.screenshot({ path: path.join(shots, "work-gaon-lms-2.png") });
|
||||
async function openSettled(app) {
|
||||
const page = await browser.newPage();
|
||||
const failures = collectPageFailures(page, server.origin);
|
||||
await prepareStillPage(page);
|
||||
const file = path.join(publicRoot, "work", app, "index.html");
|
||||
const response = await page.goto(server.origin + routeForFile(publicRoot, file), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
if (!response?.ok()) throw new Error(`${app} returned ${response?.status() ?? "no response"}`);
|
||||
await waitForStableVisual(page);
|
||||
if (failures.length) throw new Error(failures.join(" · "));
|
||||
return page;
|
||||
}
|
||||
|
||||
if (selected.has("dure-enrollment")) {
|
||||
// 두레: 과목 찾기 본컷 + 강의계획(과목 모달) -2컷
|
||||
await open("dure-enrollment");
|
||||
await page.screenshot({ path: path.join(shots, "work-dure-enrollment.png") });
|
||||
await page.screenshot({ path: path.join(root, "public/work/dure-enrollment/og.png") });
|
||||
await page.evaluate(() => document.querySelector(".course-row").click());
|
||||
await page.waitForSelector("#course-dialog[open]");
|
||||
await sleep(350);
|
||||
await page.screenshot({ path: path.join(shots, "work-dure-enrollment-2.png") });
|
||||
try {
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: resolveChrome(),
|
||||
headless: "new",
|
||||
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
|
||||
defaultViewport: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
||||
});
|
||||
|
||||
if (selected.has("gaon-lms")) {
|
||||
// 가온: 대시보드 본컷 + 학적 카드(수강생 모달) -2컷
|
||||
const page = await openSettled("gaon-lms");
|
||||
try {
|
||||
await page.screenshot({ path: path.join(shots, "work-gaon-lms.png") });
|
||||
await page.screenshot({ path: path.join(publicRoot, "work/gaon-lms/og.png") });
|
||||
await page.evaluate(() => document.querySelector('[data-view="students"]').click());
|
||||
await page.evaluate(() => document.querySelector("#stu-table [data-stu]").click());
|
||||
await page.waitForSelector("#stu-dialog[open]");
|
||||
await waitForStableVisual(page, { settleMs: 80 });
|
||||
await page.screenshot({ path: path.join(shots, "work-gaon-lms-2.png") });
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.has("dure-enrollment")) {
|
||||
// 두레: 과목 찾기 본컷 + 강의계획(과목 모달) -2컷
|
||||
const page = await openSettled("dure-enrollment");
|
||||
try {
|
||||
await page.screenshot({ path: path.join(shots, "work-dure-enrollment.png") });
|
||||
await page.screenshot({ path: path.join(publicRoot, "work/dure-enrollment/og.png") });
|
||||
await page.evaluate(() => document.querySelector(".course-row").click());
|
||||
await page.waitForSelector("#course-dialog[open]");
|
||||
await waitForStableVisual(page, { settleMs: 80 });
|
||||
await page.screenshot({ path: path.join(shots, "work-dure-enrollment-2.png") });
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("work shots + og.png 갱신 완료");
|
||||
console.log("정착 상태 work shots + og.png 갱신 완료");
|
||||
|
|
|
|||
337
apps/site/tools/instrument-showcases-e2e.mjs
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
// Focused browser instrumentation for five interactive showcase routes.
|
||||
// This records the implemented browser behaviour; it is not a visual-accuracy test.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { resolveChrome, startPublicServer } from "./capture-stable.mjs";
|
||||
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const siteRoot = path.resolve(here, "..");
|
||||
const publicRoot = path.resolve(process.env.SHOWCASE_INSTRUMENT_PUBLIC_ROOT || path.join(siteRoot, "public"));
|
||||
const reportPath = path.resolve(process.env.SHOWCASE_INSTRUMENT_REPORT || path.join(siteRoot, "instrument-showcases-e2e.json"));
|
||||
|
||||
const showcases = [
|
||||
{ id: "lumina", route: "/work/lumina-optics/" },
|
||||
{ id: "orbit", route: "/work/orbit-aerospace/" },
|
||||
{ id: "synapse", route: "/work/synapse-bci/" },
|
||||
{ id: "overtone", route: "/work/overtone-sound/" },
|
||||
{ id: "pulse", route: "/work/pulsegate/" },
|
||||
];
|
||||
const viewports = [
|
||||
{ name: "desktop", width: 1440, height: 1000 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
];
|
||||
|
||||
function writePartial(report) {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
function attachEvents(page) {
|
||||
const events = { consoleErrors: [], pageErrors: [], httpErrors: [], requestFailures: [] };
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") events.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => events.pageErrors.push(error.message));
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) events.httpErrors.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
events.requestFailures.push(`${request.url()} ${request.failure()?.errorText || "request failed"}`);
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
async function settle(page, milliseconds = 300) {
|
||||
const assets = await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const images = [...document.images];
|
||||
for (const image of images) image.loading = "eager";
|
||||
const decodeFailures = [];
|
||||
for (const image of images) {
|
||||
try {
|
||||
await image.decode();
|
||||
} catch (error) {
|
||||
decodeFailures.push({ src: image.currentSrc || image.src, message: String(error) });
|
||||
}
|
||||
}
|
||||
return {
|
||||
brokenImages: images.filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src),
|
||||
decodeFailures,
|
||||
};
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
return assets;
|
||||
}
|
||||
|
||||
async function waitForScrollSettled(page, timeout = 3000) {
|
||||
await page.evaluate(async (limit) => {
|
||||
const startedAt = performance.now();
|
||||
let stableFrames = 0;
|
||||
let previous = { x: scrollX, y: scrollY };
|
||||
await new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const current = { x: scrollX, y: scrollY };
|
||||
stableFrames = current.x === previous.x && current.y === previous.y ? stableFrames + 1 : 0;
|
||||
previous = current;
|
||||
if (stableFrames >= 5) return resolve();
|
||||
if (performance.now() - startedAt > limit) return reject(new Error("scroll did not settle"));
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
function digest(data) {
|
||||
return crypto.createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
async function captureCanvasSamples(page, selector, count) {
|
||||
const dataUrls = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const result = await page.$eval(selector, (canvas) => {
|
||||
if (!(canvas instanceof HTMLCanvasElement)) throw new Error("target is not a canvas");
|
||||
return canvas.toDataURL();
|
||||
});
|
||||
dataUrls.push(result);
|
||||
if (index < count - 1) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return dataUrls.map((dataUrl) => ({ sha256: digest(dataUrl), bytes: Buffer.byteLength(dataUrl) }));
|
||||
}
|
||||
|
||||
async function prepareCanvasObservation(page, selector) {
|
||||
await page.$eval(selector, (canvas) => canvas.scrollIntoView({ block: "center", behavior: "instant" }));
|
||||
await waitForScrollSettled(page);
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
async function sampleCanvasMode(page, selector) {
|
||||
await prepareCanvasObservation(page, selector);
|
||||
const visible = await captureCanvasSamples(page, selector, 3);
|
||||
const offscreen = await page.$eval(selector, (canvas) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
window.scrollTo({ top: scrollY + rect.bottom + 80, behavior: "instant" });
|
||||
return { beforeTop: rect.top, beforeBottom: rect.bottom };
|
||||
});
|
||||
await waitForScrollSettled(page);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const offscreenState = await page.$eval(selector, (canvas) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return { top: Math.round(rect.top), bottom: Math.round(rect.bottom), isOffscreen: rect.bottom <= 0 || rect.top >= innerHeight };
|
||||
});
|
||||
const stationary = await captureCanvasSamples(page, selector, 2);
|
||||
return {
|
||||
visibleDataUrlSamples: visible,
|
||||
visibleDistinctFrames: new Set(visible.map((sample) => sample.sha256)).size,
|
||||
offscreenStart: offscreen,
|
||||
offscreen: offscreenState,
|
||||
offscreenDataUrlSamples: stationary,
|
||||
offscreenDistinctFrames: new Set(stationary.map((sample) => sample.sha256)).size,
|
||||
};
|
||||
}
|
||||
|
||||
async function runLumina(page, reduced) {
|
||||
const state = await page.evaluate(() => ({
|
||||
reduced: matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
canvasPresent: Boolean(document.querySelector("#gl")),
|
||||
fallbackPresent: Boolean(document.querySelector(".bench-fallback")),
|
||||
note: document.querySelector("#hud-note")?.textContent.trim() || null,
|
||||
}));
|
||||
|
||||
if (reduced) {
|
||||
return {
|
||||
state,
|
||||
staticFallbackPresent: state.reduced && !state.canvasPresent && state.fallbackPresent && /Motion reduced/i.test(state.note || ""),
|
||||
};
|
||||
}
|
||||
|
||||
const controls = [];
|
||||
for (const [input, output] of [["#ior", "#ior-out"], ["#disp", "#disp-out"]]) {
|
||||
const before = await page.evaluate(([inputSelector, outputSelector]) => ({
|
||||
input: document.querySelector(inputSelector)?.value || null,
|
||||
output: document.querySelector(outputSelector)?.textContent.trim() || null,
|
||||
}), [input, output]);
|
||||
let after = null;
|
||||
if (state.canvasPresent) {
|
||||
await page.focus(input);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
after = await page.evaluate(([inputSelector, outputSelector]) => ({
|
||||
input: document.querySelector(inputSelector)?.value || null,
|
||||
output: document.querySelector(outputSelector)?.textContent.trim() || null,
|
||||
}), [input, output]);
|
||||
}
|
||||
controls.push({ input, output, before, after, outputChanged: after ? after.output !== before.output : null });
|
||||
}
|
||||
return {
|
||||
state,
|
||||
webglAvailable: state.canvasPresent,
|
||||
controls,
|
||||
controlsTested: state.canvasPresent,
|
||||
};
|
||||
}
|
||||
|
||||
async function runCanvasContract(page, selector, reduced) {
|
||||
const canvasPresent = await page.$(selector);
|
||||
if (!canvasPresent) return { canvasPresent: false, mode: reduced ? "reduced" : "normal" };
|
||||
const samples = await sampleCanvasMode(page, selector);
|
||||
return { canvasPresent: true, mode: reduced ? "reduced" : "normal", ...samples };
|
||||
}
|
||||
|
||||
async function readAnchorAndTableContract(page) {
|
||||
const anchorData = await page.evaluate(() => [...document.querySelectorAll('a[href^="#"]')]
|
||||
.map((anchor, index) => ({ index, href: anchor.getAttribute("href"), text: anchor.textContent.trim() }))
|
||||
.filter((anchor) => anchor.href && anchor.href.length > 1));
|
||||
const tabReach = [];
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
const focus = await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
return active instanceof HTMLAnchorElement && active.getAttribute("href")?.startsWith("#")
|
||||
? { href: active.getAttribute("href"), text: active.textContent.trim() }
|
||||
: null;
|
||||
});
|
||||
if (focus) tabReach.push(focus);
|
||||
}
|
||||
|
||||
const anchors = [];
|
||||
for (const anchor of anchorData) {
|
||||
const target = await page.evaluate((hash) => {
|
||||
const target = document.getElementById(hash.slice(1));
|
||||
return target ? { exists: true, tag: target.tagName, text: target.textContent.trim().slice(0, 120) } : { exists: false };
|
||||
}, anchor.href);
|
||||
await page.evaluate((anchorIndex) => document.querySelectorAll('a[href^="#"]')[anchorIndex]?.focus(), anchor.index);
|
||||
await page.keyboard.press("Enter");
|
||||
await waitForScrollSettled(page);
|
||||
const navigation = await page.evaluate((hash) => ({
|
||||
hash: location.hash,
|
||||
scrollY: Math.round(scrollY),
|
||||
targetTop: document.getElementById(hash.slice(1))?.getBoundingClientRect().top ?? null,
|
||||
}), anchor.href);
|
||||
anchors.push({ ...anchor, target, reachedByTab: tabReach.some((entry) => entry.href === anchor.href), navigation });
|
||||
}
|
||||
const tables = await page.evaluate(() => [...document.querySelectorAll("table, [role=table]")].map((table) => {
|
||||
const headers = [...table.querySelectorAll("thead th, [role=columnheader]")].map((cell) => cell.textContent.trim()).filter(Boolean);
|
||||
const rows = [...table.querySelectorAll("tbody tr, [role=row]")].map((row) => [...row.querySelectorAll("th, td, [role=cell], [role=rowheader]")]
|
||||
.map((cell) => cell.textContent.trim()).filter(Boolean)).filter((row) => row.length);
|
||||
return {
|
||||
caption: table.querySelector("caption")?.textContent.trim() || null,
|
||||
headers,
|
||||
firstRows: rows.slice(0, 3),
|
||||
readable: headers.length > 0 && rows.length > 0,
|
||||
};
|
||||
}));
|
||||
return { anchors, tabReach, tables };
|
||||
}
|
||||
|
||||
function hardFailures(row) {
|
||||
const failures = [];
|
||||
for (const [mode, events] of Object.entries(row.events)) {
|
||||
if (events.consoleErrors.length) failures.push(`${mode}: console errors ${events.consoleErrors.length}`);
|
||||
if (events.pageErrors.length) failures.push(`${mode}: page errors ${events.pageErrors.length}`);
|
||||
if (events.httpErrors.length) failures.push(`${mode}: HTTP responses >=400 ${events.httpErrors.length}`);
|
||||
if (events.requestFailures.length) failures.push(`${mode}: request failures ${events.requestFailures.length}`);
|
||||
}
|
||||
if (row.assets.brokenImages.length) failures.push(`broken images ${row.assets.brokenImages.length}`);
|
||||
if (row.observations.executionError) return [...failures, "runner: interaction observation did not complete"];
|
||||
if (row.id === "lumina") {
|
||||
if (!row.observations.reduced.staticFallbackPresent) failures.push("lumina: reduced-motion static fallback missing");
|
||||
if (row.observations.normal.controlsTested && row.observations.normal.controls.some((control) => !control.outputChanged)) failures.push("lumina: range input did not update its output");
|
||||
}
|
||||
if (row.id === "synapse" || row.id === "overtone") {
|
||||
const normal = row.observations.normal.canvas;
|
||||
const reduced = row.observations.reduced.canvas;
|
||||
if (!normal.canvasPresent) failures.push(`${row.id}: normal canvas missing`);
|
||||
else {
|
||||
if (normal.visibleDistinctFrames < 2) failures.push(`${row.id}: normal canvas did not change across visible samples`);
|
||||
if (normal.offscreen.isOffscreen && normal.offscreenDistinctFrames > 1) failures.push(`${row.id}: offscreen canvas continued changing`);
|
||||
}
|
||||
if (!reduced.canvasPresent) failures.push(`${row.id}: reduced-motion canvas missing`);
|
||||
else if (reduced.visibleDistinctFrames > 1 || (reduced.offscreen.isOffscreen && reduced.offscreenDistinctFrames > 1)) failures.push(`${row.id}: reduced-motion canvas continued changing`);
|
||||
}
|
||||
if (row.id === "orbit" || row.id === "pulse") {
|
||||
const { anchors, tables } = row.observations.normal.content;
|
||||
if (anchors.some((anchor) => !anchor.target.exists)) failures.push(`${row.id}: anchor target missing`);
|
||||
if (anchors.some((anchor) => !anchor.reachedByTab)) failures.push(`${row.id}: internal anchor not reached by Tab`);
|
||||
if (!tables.length || tables.some((table) => !table.readable)) failures.push(`${row.id}: table is not readable`);
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
async function visit(browser, origin, showcase, viewport, reduced) {
|
||||
const page = await browser.newPage();
|
||||
const events = attachEvents(page);
|
||||
try {
|
||||
await page.setViewport({ width: viewport.width, height: viewport.height, isMobile: viewport.width < 500, hasTouch: viewport.width < 500 });
|
||||
if (reduced) await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
|
||||
await page.goto(`${origin}${showcase.route}`, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
const assets = await settle(page);
|
||||
let observation;
|
||||
if (showcase.id === "lumina") observation = await runLumina(page, reduced);
|
||||
else if (showcase.id === "synapse") observation = { canvas: await runCanvasContract(page, "#raster", reduced) };
|
||||
else if (showcase.id === "overtone") observation = { canvas: await runCanvasContract(page, "#wave", reduced) };
|
||||
else observation = { content: await readAnchorAndTableContract(page) };
|
||||
return { events, assets, observation };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
publicRoot,
|
||||
routes: showcases,
|
||||
viewports,
|
||||
rows: [],
|
||||
runnerErrors: [],
|
||||
};
|
||||
let server;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(publicRoot)) throw new Error(`public root not found: ${publicRoot}`);
|
||||
server = await startPublicServer(publicRoot);
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: resolveChrome(),
|
||||
headless: "new",
|
||||
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
|
||||
});
|
||||
for (const showcase of showcases) {
|
||||
for (const viewport of viewports) {
|
||||
console.log(`RUN ${showcase.id} ${viewport.name}`);
|
||||
const row = { id: showcase.id, route: showcase.route, viewport, events: {}, assets: { brokenImages: [], decodeFailures: [] }, observations: {}, hardFailures: [] };
|
||||
try {
|
||||
const normal = await visit(browser, server.origin, showcase, viewport, false);
|
||||
const reduced = await visit(browser, server.origin, showcase, viewport, true);
|
||||
row.events = { normal: normal.events, reduced: reduced.events };
|
||||
row.assets = {
|
||||
brokenImages: [...new Set([...normal.assets.brokenImages, ...reduced.assets.brokenImages])],
|
||||
decodeFailures: [...normal.assets.decodeFailures, ...reduced.assets.decodeFailures],
|
||||
};
|
||||
row.observations = { normal: normal.observation, reduced: reduced.observation };
|
||||
} catch (error) {
|
||||
row.events = { runner: { consoleErrors: [], pageErrors: [error.stack || error.message], httpErrors: [], requestFailures: [] } };
|
||||
row.observations = { executionError: error.stack || error.message };
|
||||
}
|
||||
row.hardFailures = hardFailures(row);
|
||||
report.rows.push(row);
|
||||
writePartial(report);
|
||||
console.log(`${row.hardFailures.length ? "FAIL" : "PASS"} ${showcase.id} ${viewport.name}${row.hardFailures.length ? ` — ${row.hardFailures.join(" · ")}` : ""}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report.runnerErrors.push(error.stack || error.message);
|
||||
writePartial(report);
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
}
|
||||
|
||||
const failureCount = report.rows.reduce((count, row) => count + row.hardFailures.length, 0);
|
||||
console.log(`SUMMARY rows=${report.rows.length} hardFailures=${failureCount}`);
|
||||
if (failureCount || report.runnerErrors.length) process.exitCode = 1;
|
||||
|
|
@ -317,7 +317,7 @@ try {
|
|||
status: document.querySelector("#orderStatus").textContent.trim(),
|
||||
text: await navigator.clipboard.readText(),
|
||||
}));
|
||||
assert(copied.status === "발주 메모를 복사했습니다.", "새벽청과 order", "clipboard action reports success", copied.status);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
// 쇼케이스 실렌더 감사 — 전 폭 × 전 페이지에서 하드 게이트를 기계로 판정한다.
|
||||
// 쇼케이스 실렌더 감사 — 전 폭 × 전 HTML 경로에서 규범·기능 하드 게이트를 판정한다.
|
||||
//
|
||||
// 검사 항목
|
||||
// 1. 가로 스크롤 (하드 게이트 5)
|
||||
// 2. 뷰포트를 넘는 요소 (오버플로)
|
||||
// 3. 버튼·내비 라벨 2줄 접힘 (하드 게이트 6)
|
||||
// 3. 버튼·내비 라벨 2줄 접힘 (시각 관찰)
|
||||
// 4. 텍스트 대비 AA (하드 게이트 7) — 조상 배경을 걷어 올려 실제 배경으로 잰다
|
||||
// 5. 왼쪽 인셋 최소값 (가장자리에 붙는 제목)
|
||||
// 6. h1 개수 · 히어로 헤드라인 줄 수
|
||||
// 5. 왼쪽 인셋 최소값 (시각 관찰)
|
||||
// 6. 프로젝트 oneH1 계약 · 히어로 헤드라인 줄 수(시각 관찰)
|
||||
// 7. 콘솔 에러 · 깨진 이미지
|
||||
//
|
||||
// 사용: node tools/showcase-audit.mjs [slug ...]
|
||||
// 인자가 없으면 public/work 아래 전부를 돈다.
|
||||
// 인자가 없으면 public 아래 라이선스 자산을 제외한 모든 HTML을 돈다.
|
||||
|
||||
import puppeteer from "puppeteer-core";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import { startPublicServer, routeForFile } from "./capture-stable.mjs";
|
||||
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, "..");
|
||||
const workRoot = path.join(root, "public", "work");
|
||||
const publicRoot = path.resolve(process.env.SHOWCASE_AUDIT_PUBLIC_ROOT || path.join(root, "public"));
|
||||
const reportPath = path.resolve(process.env.SHOWCASE_AUDIT_REPORT || path.join(root, "showcase-audit-report.json"));
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
|
|
@ -33,37 +35,67 @@ if (!chrome) {
|
|||
process.exit(2);
|
||||
}
|
||||
|
||||
const WIDTHS = [320, 390, 768, 1440];
|
||||
// 넓은 모니터에서 최대 폭이 풀려 카드·카피가 늘어지는 결함은 1440px 검사만으로
|
||||
// 발견할 수 없다. 기본 감사에는 1920/2560까지 포함하고, 긴급 진단은 환경변수로
|
||||
// 필요한 폭만 좁혀 실행할 수 있게 둔다.
|
||||
const DEFAULT_WIDTHS = [320, 390, 768, 1440, 1920, 2560];
|
||||
const WIDTHS = process.env.SHOWCASE_AUDIT_WIDTHS
|
||||
? process.env.SHOWCASE_AUDIT_WIDTHS.split(",")
|
||||
.map((value) => Number.parseInt(value.trim(), 10))
|
||||
.filter(Number.isFinite)
|
||||
: DEFAULT_WIDTHS;
|
||||
|
||||
if (!WIDTHS.length || WIDTHS.some((width) => width < 1)) {
|
||||
console.error("SHOWCASE_AUDIT_WIDTHS는 쉼표로 구분한 양의 정수 폭이어야 한다.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const slugs = args.length
|
||||
? args
|
||||
: fs
|
||||
.readdirSync(workRoot, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
.sort();
|
||||
|
||||
/** 한 쇼케이스가 가진 모든 index.html (하위 상세 페이지 포함) */
|
||||
function pagesOf(slug) {
|
||||
/** assets 아래 라이선스 HTML은 쇼케이스 경로가 아니므로 제외하고, legacy 상세까지 모두 수집한다. */
|
||||
function htmlPagesOf(directory) {
|
||||
const out = [];
|
||||
const walk = (dir) => {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (e.name === "index.html") out.push(p);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name !== "assets") walk(p);
|
||||
} else if (e.name.endsWith(".html")) out.push(p);
|
||||
}
|
||||
};
|
||||
walk(path.join(workRoot, slug));
|
||||
walk(directory);
|
||||
return out;
|
||||
}
|
||||
|
||||
function selectedPages() {
|
||||
if (!args.length) return htmlPagesOf(publicRoot);
|
||||
return args.flatMap((slug) => {
|
||||
const workCandidate = path.join(publicRoot, "work", slug);
|
||||
const directCandidate = path.resolve(publicRoot, slug);
|
||||
const selected = fs.existsSync(workCandidate) ? workCandidate : directCandidate;
|
||||
if (!fs.existsSync(selected) || !fs.statSync(selected).isDirectory()) throw new Error(`감사 경로를 찾지 못했다: ${slug}`);
|
||||
return htmlPagesOf(selected);
|
||||
});
|
||||
}
|
||||
|
||||
/* 페이지 안에서 도는 판정기. 여기서 나온 수치가 판정의 근거다. */
|
||||
const PROBE = `() => {
|
||||
// getComputedStyle는 color-mix()를 rgb()가 아닌 CSS Color 4 color(srgb …)
|
||||
// 문자열로 돌려줄 수 있다. 그 0~1 채널을 0~255 rgb로 오독하면 대비 게이트가
|
||||
// 거짓 RED를 낸다. 모든 색 합성·휘도 계산이 같은 파서를 공유해야 한다.
|
||||
const parseColor = (c) => {
|
||||
const srgb = String(c || '').match(/^color\\(srgb\\s+([\\d.]+)\\s+([\\d.]+)\\s+([\\d.]+)(?:\\s*\\/\\s*([\\d.]+))?\\)$/i);
|
||||
if (srgb) {
|
||||
return { rgb: srgb.slice(1, 4).map((v) => Number(v) * 255), a: srgb[4] === undefined ? 1 : Number(srgb[4]) };
|
||||
}
|
||||
const m = String(c || '').match(/[\\d.]+/g);
|
||||
if (!m) return null;
|
||||
return { rgb: m.slice(0, 3).map(Number), a: m.length > 3 ? Number(m[3]) : 1 };
|
||||
};
|
||||
const lum = (c) => {
|
||||
const m = (c || '').match(/[\\d.]+/g);
|
||||
if (!m) return 1;
|
||||
const [r, g, b] = m.slice(0, 3).map(Number).map((v) => {
|
||||
const parsed = parseColor(c);
|
||||
if (!parsed) return 1;
|
||||
const [r, g, b] = parsed.rgb.map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
|
|
@ -75,12 +107,21 @@ const PROBE = `() => {
|
|||
let n = el, acc = null;
|
||||
while (n && n.nodeType === 1) {
|
||||
const c = getComputedStyle(n).backgroundColor;
|
||||
const m = (c || '').match(/[\\d.]+/g);
|
||||
if (m) {
|
||||
const a = m.length > 3 ? Number(m[3]) : 1;
|
||||
const parsed = parseColor(c);
|
||||
if (parsed) {
|
||||
const { rgb, a } = parsed;
|
||||
if (a > 0.001) {
|
||||
const rgb = m.slice(0, 3).map(Number);
|
||||
acc = acc === null ? { rgb, a } : { rgb: acc.rgb.map((v, i) => v * acc.a + rgb[i] * (1 - acc.a)), a: 1 };
|
||||
if (acc === null) {
|
||||
acc = { rgb, a };
|
||||
} else {
|
||||
// 현재 acc(자식)를 새 부모 배경 위에 합성한다. 이전 구현은 부모 alpha를
|
||||
// (1-a)에 써서 반투명 color-mix 층의 실제 색을 왜곡했다.
|
||||
const outA = acc.a + a * (1 - acc.a);
|
||||
acc = {
|
||||
rgb: acc.rgb.map((v, i) => (v * acc.a + rgb[i] * a * (1 - acc.a)) / outA),
|
||||
a: outA,
|
||||
};
|
||||
}
|
||||
if (acc.a >= 0.999) return 'rgb(' + acc.rgb.map(Math.round).join(',') + ')';
|
||||
}
|
||||
}
|
||||
|
|
@ -140,15 +181,114 @@ const PROBE = `() => {
|
|||
if (r.right > window.innerWidth + 1.5) overflow.push({ tag: el.tagName, cls: String(el.className || '').slice(0, 30), right: Math.round(r.right) });
|
||||
});
|
||||
|
||||
// 기능은 정상이어도 글자가 테두리에 닿으면 조작감이 거칠어 보인다. 실제 텍스트
|
||||
// 렌더 범위를 컨트롤의 테두리 상자와 비교한다. 아이콘만 있는 컨트롤은 텍스트
|
||||
// 여백 검사의 대상이 아니며, 입력·선택 상자는 계산 스타일의 inline padding을 잰다.
|
||||
const textRect = (el) => {
|
||||
// selectNodeContents는 sr-only 안내, aria-hidden 장식, 빈 행 오버레이의
|
||||
// element box까지 모두 묶는다. 실제 보이는 text node만 합쳐야 셀과 아이콘
|
||||
// 컨트롤의 시각 인셋을 측정할 수 있다.
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
const rects = [];
|
||||
let textNodes = 0;
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (!node.nodeValue.trim()) continue;
|
||||
const owner = node.parentElement;
|
||||
if (!owner || owner.closest('.sr-only, [aria-hidden="true"], [hidden]')) continue;
|
||||
const ownerStyle = getComputedStyle(owner);
|
||||
if (ownerStyle.display === 'none' || ownerStyle.visibility === 'hidden') continue;
|
||||
const range = document.createRange();
|
||||
range.selectNode(node);
|
||||
const nodeRects = [...range.getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5);
|
||||
if (nodeRects.length) textNodes += 1;
|
||||
rects.push(...nodeRects);
|
||||
}
|
||||
if (!rects.length) return null;
|
||||
return {
|
||||
left: Math.min(...rects.map((r) => r.left)),
|
||||
right: Math.max(...rects.map((r) => r.right)),
|
||||
top: Math.min(...rects.map((r) => r.top)),
|
||||
bottom: Math.max(...rects.map((r) => r.bottom)),
|
||||
nodes: textNodes,
|
||||
};
|
||||
};
|
||||
|
||||
const wrapped = [];
|
||||
document.querySelectorAll('nav a, .btn, button, [role=button]').forEach((el) => {
|
||||
if (!visible(el) || !el.textContent.trim()) return;
|
||||
const cs = getComputedStyle(el);
|
||||
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.25;
|
||||
const textRange = document.createRange();
|
||||
textRange.selectNodeContents(el);
|
||||
const textHeight = textRange.getBoundingClientRect().height;
|
||||
if (textHeight > lh * 1.6) wrapped.push(el.textContent.trim().slice(0, 26));
|
||||
const content = textRect(el);
|
||||
// 이름·기한·상태처럼 여러 정보를 수직으로 쌓은 행은 두 줄 버튼이 아니라
|
||||
// 작은 정보 카드다. 한 개의 라벨 텍스트가 의도치 않게 접힌 경우만 실패로
|
||||
// 잡아 내비와 일반 CTA의 좁은 폭 결함은 유지한다.
|
||||
if (content && content.nodes === 1 && content.bottom - content.top > lh * 1.6) {
|
||||
wrapped.push(el.textContent.trim().slice(0, 26));
|
||||
}
|
||||
});
|
||||
|
||||
const controlInsets = [];
|
||||
const controls = new Set(document.querySelectorAll('button, a.btn, .btn, [role=button], input:not([type=hidden]), select, textarea'));
|
||||
controls.forEach((el) => {
|
||||
if (!visible(el) || el.disabled || el.getBoundingClientRect().width < 28) return;
|
||||
// range/checkbox/radio는 텍스트 입력 상자가 아니라 고유 트랙·표식이라
|
||||
// padding 0이 정상이다. 해당 값은 인접 label에서 별도로 읽힌다.
|
||||
if (el.matches('input[type=range], input[type=checkbox], input[type=radio], input[type=color], input[type=file]')) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
const isField = el.matches('input, select, textarea');
|
||||
const left = isField ? parseFloat(cs.paddingInlineStart) || 0 : textRect(el)?.left - rect.left;
|
||||
const right = isField ? parseFloat(cs.paddingInlineEnd) || 0 : rect.right - textRect(el)?.right;
|
||||
if (!Number.isFinite(left) || !Number.isFinite(right)) return;
|
||||
const inset = Math.min(left, right);
|
||||
if (inset < 8) {
|
||||
controlInsets.push({
|
||||
tag: el.tagName,
|
||||
cls: String(el.className || '').slice(0, 30),
|
||||
inset: +inset.toFixed(1),
|
||||
text: (el.getAttribute('aria-label') || el.textContent || el.value || '').trim().slice(0, 30),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 입력 상자는 서로 포개지면 클릭 대상과 값의 관계가 사라진다. 다른 뷰에 숨은
|
||||
// 요소는 visible()에서 제외하고, 실제로 면적을 공유할 때만 실패로 낸다.
|
||||
const inputOverlaps = [];
|
||||
const fields = [...document.querySelectorAll('input:not([type=hidden]), select, textarea')].filter(visible);
|
||||
for (let i = 0; i < fields.length; i += 1) {
|
||||
const a = fields[i].getBoundingClientRect();
|
||||
for (let j = i + 1; j < fields.length; j += 1) {
|
||||
const b = fields[j].getBoundingClientRect();
|
||||
const width = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const height = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (width > 4 && height > 4) {
|
||||
inputOverlaps.push({
|
||||
first: fields[i].name || fields[i].id || fields[i].type,
|
||||
second: fields[j].name || fields[j].id || fields[j].type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 표의 셀은 flex/grid로 그렸는지와 무관하게 읽히는 텍스트가 양 옆 경계에
|
||||
// 붙지 않아야 한다. 역할을 알 수 있는 native/ARIA table 셀만 검사해 그리드
|
||||
// 레이아웃 자체를 테이블로 오인하는 오탐을 피한다.
|
||||
const crowdedCells = [];
|
||||
document.querySelectorAll('th, td, [role=cell], [role=gridcell]').forEach((el) => {
|
||||
if (!visible(el) || !el.textContent.trim()) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const content = textRect(el);
|
||||
if (!content || rect.width < 32) return;
|
||||
const inset = Math.min(content.left - rect.left, rect.right - content.right);
|
||||
if (inset < 8) {
|
||||
crowdedCells.push({
|
||||
tag: el.tagName,
|
||||
cls: String(el.className || '').slice(0, 30),
|
||||
inset: +inset.toFixed(1),
|
||||
text: el.textContent.trim().slice(0, 30),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const insets = [...document.querySelectorAll('h1,h2,h3,p,li')]
|
||||
|
|
@ -170,7 +310,7 @@ const PROBE = `() => {
|
|||
docWidth: document.documentElement.scrollWidth,
|
||||
winWidth: window.innerWidth,
|
||||
horizScroll: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
contrast, overflow, wrapped,
|
||||
contrast, overflow, wrapped, controlInsets, inputOverlaps, crowdedCells,
|
||||
minInset: insets.length ? Math.min(...insets) : null,
|
||||
h1Count: h1.length,
|
||||
h1Lines,
|
||||
|
|
@ -178,73 +318,114 @@ const PROBE = `() => {
|
|||
};
|
||||
}`;
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: chrome,
|
||||
headless: "new",
|
||||
// WebGL 을 쓰는 페이지가 있으므로 --disable-gpu 를 쓰지 않는다.
|
||||
// swiftshader 로 소프트웨어 렌더를 강제해 헤드리스에서도 캔버스가 뜬다
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--allow-file-access-from-files",
|
||||
"--use-angle=swiftshader",
|
||||
"--enable-unsafe-swiftshader",
|
||||
"--force-device-scale-factor=1",
|
||||
],
|
||||
});
|
||||
|
||||
let totalFail = 0;
|
||||
const lines = [];
|
||||
|
||||
for (const slug of slugs) {
|
||||
for (const file of pagesOf(slug)) {
|
||||
const rel = path.relative(workRoot, file).replace(/\\/g, "/");
|
||||
for (const width of WIDTHS) {
|
||||
console.log(`RUN ${rel} @${width}`);
|
||||
const page = await browser.newPage();
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (e) => consoleErrors.push(String(e).slice(0, 120)));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") consoleErrors.push(m.text().slice(0, 120));
|
||||
});
|
||||
await page.setViewport({ width, height: 900 });
|
||||
await page.goto(url.pathToFileURL(file).href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await new Promise((r) => setTimeout(r, 260));
|
||||
|
||||
// 문자열을 넘기면 puppeteer 는 그것을 '식'으로 평가한다.
|
||||
// 화살표 함수만 넘기면 함수 객체가 그대로 직렬화돼 빈 객체가 돌아온다 — 즉시호출로 감싼다
|
||||
const r = await page.evaluate(`(${PROBE})()`);
|
||||
await page.close();
|
||||
|
||||
const problems = [];
|
||||
if (r.horizScroll) problems.push(`가로스크롤 ${r.docWidth}>${r.winWidth}`);
|
||||
if (r.overflow.length) problems.push(`오버플로 ${r.overflow.length} (${r.overflow.slice(0, 2).map((o) => o.tag + "." + o.cls).join(", ")})`);
|
||||
if (r.wrapped.length) problems.push(`컨트롤 줄바꿈 ${r.wrapped.length} (${r.wrapped.slice(0, 2).join(", ")})`);
|
||||
if (r.contrast.length) problems.push(`대비미달 ${r.contrast.length} (최악 ${Math.min(...r.contrast.map((c) => c.ratio))}:1 — ${r.contrast[0].cls || r.contrast[0].tag})`);
|
||||
if (r.minInset !== null && r.minInset < 12) problems.push(`왼쪽 인셋 ${r.minInset}px`);
|
||||
if (r.h1Count !== 1) problems.push(`h1 ${r.h1Count}개`);
|
||||
if (r.h1Lines > 2) problems.push(`히어로 헤드라인 ${r.h1Lines}줄`);
|
||||
if (r.brokenImages.length) problems.push(`깨진 이미지 ${r.brokenImages.length}`);
|
||||
if (consoleErrors.length) problems.push(`콘솔에러 ${consoleErrors.length} (${consoleErrors[0]})`);
|
||||
|
||||
if (problems.length) {
|
||||
totalFail++;
|
||||
lines.push(`FAIL ${rel} @${width} — ${problems.join(" · ")}`);
|
||||
if (r.contrast.length) {
|
||||
for (const c of r.contrast.slice(0, 4)) {
|
||||
lines.push(` ↳ ${c.ratio}:1 (${c.need} 필요) ${c.size}px ${c.tag}.${c.cls} "${c.text}"`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(`PASS ${rel} @${width}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function hardFailuresFor(rawProbe, browserFailures) {
|
||||
const failures = [];
|
||||
// 이 목록만 WCAG·기능·프로젝트 계약의 하드 실패다. 줄 수·여백 등은 아래 observations에 남겨
|
||||
// 실제 렌더의 과업·언어·브리프와 함께 판정한다.
|
||||
if (rawProbe.horizScroll) failures.push(`document horizontal overflow ${rawProbe.docWidth}>${rawProbe.winWidth}`);
|
||||
if (rawProbe.overflow.length) failures.push(`visible element overflow ${rawProbe.overflow.length}`);
|
||||
if (rawProbe.contrast.length) failures.push(`contrast below required ratio ${rawProbe.contrast.length}`);
|
||||
if (rawProbe.inputOverlaps.length) failures.push(`input overlap ${rawProbe.inputOverlaps.length}`);
|
||||
if (rawProbe.brokenImages.length) failures.push(`broken images ${rawProbe.brokenImages.length}`);
|
||||
if (rawProbe.h1Count !== 1) failures.push(`project oneH1 contract: ${rawProbe.h1Count}`);
|
||||
if (browserFailures.consoleErrors.length) failures.push(`browser console errors ${browserFailures.consoleErrors.length}`);
|
||||
if (browserFailures.pageErrors.length) failures.push(`browser page errors ${browserFailures.pageErrors.length}`);
|
||||
if (browserFailures.requestFailures.length) failures.push(`network failures ${browserFailures.requestFailures.length}`);
|
||||
if (browserFailures.httpErrors.length) failures.push(`HTTP responses >=400 ${browserFailures.httpErrors.length}`);
|
||||
return failures;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
function observationsFor(rawProbe) {
|
||||
// 스타일 휴리스틱은 기계의 합격/불합격이 아니라 시각 검토자가 확인할 관찰값이다.
|
||||
return {
|
||||
h1Lines: rawProbe.h1Lines,
|
||||
wrappedControls: rawProbe.wrapped,
|
||||
minInset: rawProbe.minInset,
|
||||
controlInsets: rawProbe.controlInsets,
|
||||
crowdedCells: rawProbe.crowdedCells,
|
||||
};
|
||||
}
|
||||
|
||||
for (const l of lines) console.log(l);
|
||||
const passCount = lines.filter((l) => l.startsWith("PASS")).length;
|
||||
console.log(`\n${totalFail ? "✗" : "✓"} 실렌더 감사 — PASS ${passCount} / FAIL ${totalFail}`);
|
||||
process.exit(totalFail ? 1 : 0);
|
||||
function writePartialReport(report) {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
function attachBrowserFailureCollection(page) {
|
||||
const result = { consoleErrors: [], pageErrors: [], requestFailures: [], httpErrors: [] };
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") result.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => result.pageErrors.push(error.message));
|
||||
page.on("requestfailed", (request) => result.requestFailures.push(`${request.url()} ${request.failure()?.errorText || ""}`));
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) result.httpErrors.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
publicRoot,
|
||||
widths: WIDTHS,
|
||||
rows: [],
|
||||
runnerErrors: [],
|
||||
};
|
||||
let browser;
|
||||
let server;
|
||||
|
||||
try {
|
||||
const pages = selectedPages().sort();
|
||||
if (!pages.length) throw new Error(`감사할 HTML이 없다: ${publicRoot}`);
|
||||
server = await startPublicServer(publicRoot);
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: chrome,
|
||||
headless: "new",
|
||||
// WebGL 페이지도 HTTP 환경에서 실제 렌더한다. capture-stable의 freeze helper는 사용하지 않는다.
|
||||
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
|
||||
});
|
||||
|
||||
for (const file of pages) {
|
||||
const route = routeForFile(publicRoot, file);
|
||||
for (const width of WIDTHS) {
|
||||
console.log(`RUN ${route} @${width}`);
|
||||
const row = { route, width, rawProbe: null, hardFailures: [], observations: null, consoleErrors: [], pageErrors: [], networkErrors: [] };
|
||||
const page = await browser.newPage();
|
||||
const browserFailures = attachBrowserFailureCollection(page);
|
||||
try {
|
||||
await page.setViewport({ width, height: 900, isMobile: width < 500, hasTouch: width < 500 });
|
||||
await page.goto(`${server.origin}${route}`, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await new Promise((resolve) => setTimeout(resolve, 260));
|
||||
// PROBE는 기존 수집 범위를 보존한다. 분류는 hardFailuresFor/observationsFor에서만 바꾼다.
|
||||
row.rawProbe = await page.evaluate(`(${PROBE})()`);
|
||||
} catch (error) {
|
||||
browserFailures.pageErrors.push(`audit runner: ${error.stack || error.message}`);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
row.consoleErrors = browserFailures.consoleErrors;
|
||||
row.pageErrors = browserFailures.pageErrors;
|
||||
row.networkErrors = [...browserFailures.requestFailures, ...browserFailures.httpErrors];
|
||||
if (row.rawProbe) {
|
||||
row.hardFailures = hardFailuresFor(row.rawProbe, browserFailures);
|
||||
row.observations = observationsFor(row.rawProbe);
|
||||
} else {
|
||||
row.hardFailures = ["no probe result"];
|
||||
row.observations = {};
|
||||
}
|
||||
report.rows.push(row);
|
||||
writePartialReport(report);
|
||||
console.log(`${row.hardFailures.length ? "FAIL" : "PASS"} ${route} @${width}${row.hardFailures.length ? ` — ${row.hardFailures.join(" · ")}` : ""}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report.runnerErrors.push(error.stack || error.message);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
writePartialReport(report);
|
||||
const failedRows = report.rows.filter((row) => row.hardFailures.length);
|
||||
console.log(`\n${failedRows.length || report.runnerErrors.length ? "✗" : "✓"} 실렌더 감사 — PASS ${report.rows.length - failedRows.length} / FAIL ${failedRows.length}`);
|
||||
process.exitCode = failedRows.length || report.runnerErrors.length ? 1 : 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -769,7 +769,7 @@ defineScenario("철판클럽 공개 기능", "영업 종료 매장 선택 경계
|
|||
await opened.page.click('[data-select-store="yeonnam"]');
|
||||
t.check("목록과 지도 연남 선택", "yeonnam selected in both", await opened.page.evaluate(() => ({ card: document.querySelector('[data-store].is-selected')?.dataset.store, pin: document.querySelector('[data-map-store].is-active')?.dataset.mapStore })), (actual) => actual.card === "yeonnam" && actual.pin === "yeonnam");
|
||||
t.includes("종료 상태 요약", "영업 종료", await textOf(opened.page, "#summary-store-state"));
|
||||
t.check("저장 행동 차단과 이유", "disabled / choose another store", await opened.page.$eval("#save-builder", (element) => ({ disabled: element.disabled, text: element.textContent.trim() })), (actual) => actual.disabled && actual.text.includes("다른 매장"));
|
||||
t.check("가상 매장 안내 행동", "#stores / 가상 매장 안내 보기", await opened.page.$eval("#check-store-link", (element) => ({ href: element.getAttribute("href"), text: element.textContent.trim() })), (actual) => actual.href === "#stores" && actual.text.includes("가상 매장 안내"));
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
|
|
@ -794,25 +794,24 @@ defineScenario("철판클럽 공개 기능", "매장 검색 빈 결과와 복구
|
|||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
defineScenario("철판클럽 공개 기능", "조합 저장 성공", 3, async (t) => {
|
||||
defineScenario("철판클럽 공개 기능", "조합 결과 즉시 반영과 미저장 안내", 3, async (t) => {
|
||||
const opened = await openChromium(burgerPublic);
|
||||
try {
|
||||
await opened.page.click("#save-builder");
|
||||
const snapshot = await opened.page.evaluate(() => JSON.parse(sessionStorage.getItem("griddle-club-demo-selection")));
|
||||
t.check("현재 탭에 조합 스냅샷 저장", "classic / patty 2 / seongsu", snapshot, (actual) => actual?.flavor === "classic" && actual?.patty === "2" && actual?.store === "seongsu");
|
||||
t.includes("실제 주문 미전송 안내", "실제 주문으로 전송되지 않습니다", await textOf(opened.page, "#save-status"));
|
||||
t.equal("성공 상태 오류 스타일 없음", false, await opened.page.$eval("#save-status", (element) => element.classList.contains("is-error")));
|
||||
await opened.page.select('select[name="onion"]', "extra");
|
||||
t.includes("조합 결과 즉시 반영", "양파 많이", await textOf(opened.page, "#summary-lines"));
|
||||
t.equal("선택 내용은 브라우저에 저장하지 않음", null, await opened.page.evaluate(() => sessionStorage.getItem("griddle-club-demo-selection")));
|
||||
t.includes("주문 미제공 안내", "주문과 결제는 진행되지 않습니다", await textOf(opened.page, ".truth-inline"));
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
defineScenario("철판클럽 공개 기능", "조합 저장 실패와 입력 보존", 3, async (t) => {
|
||||
const opened = await openChromium(burgerPublic, { query: "?fixture=save-fail" });
|
||||
defineScenario("철판클럽 공개 기능", "선택 결과에서 가상 매장 안내로 이동", 3, async (t) => {
|
||||
const opened = await openChromium(burgerPublic);
|
||||
try {
|
||||
await opened.page.select('select[name="onion"]', "extra");
|
||||
await opened.page.click("#save-builder");
|
||||
t.includes("저장 실패 안내", "남기지 못했어요", await textOf(opened.page, "#save-status"));
|
||||
t.equal("저장 실패 오류 스타일", true, await opened.page.$eval("#save-status", (element) => element.classList.contains("is-error")));
|
||||
t.check("실패 뒤 조합과 저장소 상태", "onion extra retained / no snapshot", await opened.page.evaluate(() => ({ onion: document.querySelector('select[name="onion"]')?.value, stored: sessionStorage.getItem("griddle-club-demo-selection") })), (actual) => actual.onion === "extra" && actual.stored === null);
|
||||
await opened.page.click("#check-store-link");
|
||||
await opened.page.waitForFunction(() => window.location.hash === "#stores");
|
||||
t.equal("가상 매장 안내 앵커 이동", "#stores", await opened.page.evaluate(() => window.location.hash));
|
||||
t.ok("가상 매장 안내 섹션이 보임", await visible(opened.page, "#stores"));
|
||||
t.equal("이동 뒤에도 선택 내용을 저장하지 않음", null, await opened.page.evaluate(() => sessionStorage.getItem("griddle-club-demo-selection")));
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
|
|
@ -932,7 +931,7 @@ defineScenario("철판클럽 가맹 기능", "문의 준비 실패와 재시도"
|
|||
await opened.page.click("#demo-consent");
|
||||
await opened.page.click("#submit-inquiry");
|
||||
await new Promise((resolve) => setTimeout(resolve, 520));
|
||||
t.includes("제출 실패 이유", "바꾸지 못했어요", await textOf(opened.page, "#submit-state"));
|
||||
t.includes("제출 실패 이유", "확인할 수 없어요", await textOf(opened.page, "#submit-state"));
|
||||
t.check("재시도 버튼 복구", "enabled / 다시 시도하기", await opened.page.$eval("#submit-inquiry", (element) => ({ disabled: element.disabled, text: element.textContent.trim() })), (actual) => !actual.disabled && actual.text === "다시 시도하기");
|
||||
t.check("실패 뒤 입력과 단계 보존", "step 3 / name retained", await opened.page.evaluate(() => ({ step: document.querySelector(".form-step.is-current")?.dataset.step, name: document.querySelector("#demo-name")?.value })), (actual) => actual.step === "3" && actual.name === "예시점주");
|
||||
} finally { await closePage(opened.page); }
|
||||
|
|
@ -946,14 +945,14 @@ defineScenario("한남자리 관리자 기능", "초기 선택과 조사관 동
|
|||
const row = document.querySelector('[data-queue-row].is-selected');
|
||||
return { id: row?.dataset.id, selected: row?.getAttribute("aria-selected") };
|
||||
}), (actual) => actual.id === "DEMO-HJ-101" && actual.selected === "true");
|
||||
t.check("조사관 제목과 식별자 동기화", "한남 생활권 정원 주거 / DEMO-HJ-101", await opened.page.evaluate(() => ({
|
||||
t.check("상세 정보 제목과 식별자 동기화", "한남 생활권 정원 주거 / 자료번호 101", await opened.page.evaluate(() => ({
|
||||
title: document.querySelector("[data-inspector-title]")?.textContent.trim(),
|
||||
id: document.querySelector("[data-inspector-id]")?.textContent.trim(),
|
||||
})), (actual) => actual.title === "한남 생활권 정원 주거" && actual.id.startsWith("DEMO-HJ-101"));
|
||||
t.check("초기 대기열 원장", "6 rows / 6개 예시", await opened.page.evaluate(() => ({
|
||||
})), (actual) => actual.title === "한남 생활권 정원 주거" && actual.id.startsWith("자료번호 101"));
|
||||
t.check("초기 대기열 원장", "6 rows / 6건", await opened.page.evaluate(() => ({
|
||||
rows: document.querySelectorAll("[data-queue-row]").length,
|
||||
count: document.querySelector("[data-result-count]")?.textContent.trim(),
|
||||
})), (actual) => actual.rows === 6 && actual.count === "6개 예시");
|
||||
})), (actual) => actual.rows === 6 && actual.count === "6건");
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
|
|
@ -993,10 +992,10 @@ defineScenario("한남자리 관리자 기능", "행 선택 전환", 3, async (t
|
|||
previous: document.querySelector('[data-queue-row][data-id="DEMO-HJ-101"]')?.getAttribute("aria-selected"),
|
||||
selectedCount: document.querySelectorAll("[data-queue-row].is-selected").length,
|
||||
})), (actual) => actual.next === "true" && actual.previous === "false" && actual.selectedCount === 1);
|
||||
t.check("선택 제목과 상태 조사관 반영", "한강 가까운 복층 주거 / 쇼케이스 공개", await opened.page.evaluate(() => ({
|
||||
t.check("선택 제목과 상태 상세 정보 반영", "한강 가까운 복층 주거 / 예시 공개", await opened.page.evaluate(() => ({
|
||||
title: document.querySelector("[data-inspector-title]")?.textContent.trim(),
|
||||
status: document.querySelector("[data-inspector-status]")?.textContent.trim(),
|
||||
})), (actual) => actual.title === "한강 가까운 복층 주거" && actual.status === "쇼케이스 공개");
|
||||
})), (actual) => actual.title === "한강 가까운 복층 주거" && actual.status === "예시 공개");
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
|
|
@ -1006,10 +1005,10 @@ defineScenario("한남자리 관리자 기능", "검색 일치 결과", 3, async
|
|||
await setValue(opened.page, "[data-queue-search]", "남산");
|
||||
t.equal("검색 결과 한 행", 1, await opened.page.$$eval("[data-queue-row]:not(.is-hidden)", (rows) => rows.length));
|
||||
t.equal("검색 결과 식별자", "DEMO-HJ-105", await opened.page.$eval("[data-queue-row]:not(.is-hidden)", (row) => row.dataset.id));
|
||||
t.check("검색 결과 수와 빈 상태", "1개 예시 / empty hidden", await opened.page.evaluate(() => ({
|
||||
t.check("검색 결과 수와 빈 상태", "1건 / empty hidden", await opened.page.evaluate(() => ({
|
||||
count: document.querySelector("[data-result-count]")?.textContent.trim(),
|
||||
empty: document.querySelector("[data-queue-empty]")?.hidden,
|
||||
})), (actual) => actual.count === "1개 예시" && actual.empty);
|
||||
})), (actual) => actual.count === "1건" && actual.empty);
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
|
|
@ -1017,11 +1016,11 @@ defineScenario("한남자리 관리자 기능", "검색 빈 상태와 초기화
|
|||
const opened = await openChromium(estateAdmin);
|
||||
try {
|
||||
await setValue(opened.page, "[data-queue-search]", "없는 매물");
|
||||
t.check("검색 빈 상태 원인 안내", "0개 예시 / 현재 조건", await opened.page.evaluate(() => ({
|
||||
t.check("검색 빈 상태 원인 안내", "0건 / 현재 조건", await opened.page.evaluate(() => ({
|
||||
count: document.querySelector("[data-result-count]")?.textContent.trim(),
|
||||
title: document.querySelector("[data-empty-title]")?.textContent.trim(),
|
||||
visible: !document.querySelector("[data-queue-empty]")?.hidden,
|
||||
})), (actual) => actual.count === "0개 예시" && actual.visible && actual.title.includes("현재 조건"));
|
||||
})), (actual) => actual.count === "0건" && actual.visible && actual.title.includes("현재 조건"));
|
||||
t.equal("빈 검색에서 보이는 행 없음", 0, await opened.page.$$eval("[data-queue-row]:not(.is-hidden)", (rows) => rows.length));
|
||||
await opened.page.click("[data-reset-filter]");
|
||||
t.check("필터 초기화로 전체 복구", "blank / all / 6 rows", await opened.page.evaluate(() => ({
|
||||
|
|
@ -1039,7 +1038,7 @@ defineScenario("한남자리 관리자 기능", "검수 필요 상태 필터", 3
|
|||
const result = await opened.page.$$eval("[data-queue-row]:not(.is-hidden)", (rows) => rows.map((row) => row.dataset.id));
|
||||
t.equal("검수 필요 결과 수", 2, result.length);
|
||||
t.check("검수 필요 식별자 집합", "DEMO-HJ-101, DEMO-HJ-106", result, (actual) => actual.join(",") === "DEMO-HJ-101,DEMO-HJ-106");
|
||||
t.equal("상태 필터 결과 문구", "2개 예시", await textOf(opened.page, "[data-result-count]"));
|
||||
t.equal("상태 필터 결과 문구", "2건", await textOf(opened.page, "[data-result-count]"));
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
|
|
@ -1077,12 +1076,12 @@ defineScenario("한남자리 관리자 기능", "강제 빈 대기열과 복구"
|
|||
try {
|
||||
await opened.page.select("[data-state-simulator]", "empty");
|
||||
t.ok("강제 빈 대기열 노출", await visible(opened.page, "[data-queue-empty]"));
|
||||
t.includes("강제 빈 상태 설명", "아직 접수된", await textOf(opened.page, "[data-empty-title]"));
|
||||
t.includes("강제 빈 상태 설명", "아직 표시할", await textOf(opened.page, "[data-empty-title]"));
|
||||
await opened.page.select("[data-state-simulator]", "normal");
|
||||
t.check("정상 선택으로 빈 상태 해제", "empty hidden / 6개 예시", await opened.page.evaluate(() => ({
|
||||
t.check("정상 선택으로 빈 상태 해제", "empty hidden / 6건", await opened.page.evaluate(() => ({
|
||||
empty: document.querySelector("[data-queue-empty]")?.hidden,
|
||||
count: document.querySelector("[data-result-count]")?.textContent.trim(),
|
||||
})), (actual) => actual.empty && actual.count === "6개 예시");
|
||||
})), (actual) => actual.empty && actual.count === "6건");
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
|
|
@ -1125,11 +1124,11 @@ defineScenario("한남자리 관리자 기능", "버전 충돌 저장 실패와
|
|||
try {
|
||||
await opened.page.select("[data-state-simulator]", "conflict");
|
||||
await opened.page.$eval("[data-simulate-save]", (button) => button.click());
|
||||
t.equal("충돌 저장 실패 배너", "버전 충돌로 저장하지 않았습니다.", await textOf(opened.page, "[data-banner-title]"));
|
||||
t.equal("충돌 저장 실패 배너", "최신 자료와 차이가 있어 저장하지 않았습니다.", await textOf(opened.page, "[data-banner-title]"));
|
||||
t.includes("충돌 저장 실패 라이브 안내", "저장하지 않았습니다", await textOf(opened.page, "[data-admin-live]"));
|
||||
await opened.page.select("[data-state-simulator]", "normal");
|
||||
await opened.page.$eval("[data-simulate-save]", (button) => button.click());
|
||||
t.includes("정상 재시도는 외부 저장 없음", "실제 저장하지 않았습니다", await textOf(opened.page, "[data-admin-live]"));
|
||||
t.includes("정상 재시도는 외부 저장 없음", "실제로 저장되거나 전송되지는 않습니다", await textOf(opened.page, "[data-admin-live]"));
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
|
|
@ -1204,7 +1203,7 @@ defineScenario("한남자리 관리자 기능", "AI 주장 검토 완료", 3, as
|
|||
badge: document.querySelector("#inspector-ai .status-badge")?.textContent.trim(),
|
||||
pass: document.querySelector("#inspector-ai .status-badge")?.classList.contains("status-pass"),
|
||||
})), (actual) => actual.disabled && actual.text === "수정 후 승인됨" && actual.badge === "수정 후 승인" && actual.pass);
|
||||
t.includes("AI 검토 라이브 안내", "데모 외부에는 게시하지 않았습니다", await textOf(opened.page, "[data-admin-live]"));
|
||||
t.includes("AI 검토 라이브 안내", "실제 광고에는 사용되지 않습니다", await textOf(opened.page, "[data-admin-live]"));
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
|
|
@ -1224,7 +1223,7 @@ defineScenario("한남자리 관리자 기능", "새 예시 초안 생성", 3, a
|
|||
title: document.querySelector("[data-inspector-title]")?.textContent.trim(),
|
||||
price: document.querySelector("[data-basic-grid] dd")?.textContent.trim(),
|
||||
count: document.querySelector("[data-result-count]")?.textContent.trim(),
|
||||
})), (actual) => actual.title === "제목 없는 새 예시 초안" && actual.price === "미선택" && actual.count === "7개 예시");
|
||||
})), (actual) => actual.title === "제목 없는 새 항목" && actual.price === "미선택" && actual.count === "7건");
|
||||
} finally { await closePage(opened.page); }
|
||||
});
|
||||
|
||||
|
|
|
|||
248
apps/site/tools/showcase-stress.mjs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
// Showcase typography stress diagnostic. This records browser geometry; it is not a WCAG conformance certification.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { startPublicServer, routeForFile } from "./capture-stable.mjs";
|
||||
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const siteRoot = path.resolve(here, "..");
|
||||
const publicRoot = path.resolve(process.env.SHOWCASE_STRESS_PUBLIC_ROOT || path.join(siteRoot, "public"));
|
||||
const showcaseRoot = path.join(publicRoot, "work");
|
||||
const reportPath = path.resolve(process.env.SHOWCASE_STRESS_REPORT || path.join(siteRoot, "showcase-stress-report.json"));
|
||||
const outputDirectory = path.resolve(process.env.SHOWCASE_STRESS_OUTPUT || path.join(siteRoot, "showcase-stress-artifacts"));
|
||||
const expectedRouteCount = Number.parseInt(process.env.SHOWCASE_STRESS_EXPECTED_ROUTES || "28", 10);
|
||||
const modes = [
|
||||
{ name: "normal-390", width: 390, height: 844, stress: "normal" },
|
||||
{ name: "text200-390", width: 390, height: 844, stress: "text200" },
|
||||
{ name: "spacing-390", width: 390, height: 844, stress: "spacing" },
|
||||
{ name: "reduced-motion-1440", width: 1440, height: 1000, stress: "reduced-motion" },
|
||||
];
|
||||
|
||||
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/Chromium executable not found. Set CHROME_PATH.");
|
||||
if (!Number.isInteger(expectedRouteCount) || expectedRouteCount < 1) throw new Error("SHOWCASE_STRESS_EXPECTED_ROUTES must be a positive integer.");
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
|
||||
function collectHtmlFiles(directory) {
|
||||
const files = [];
|
||||
const walk = (current) => {
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const candidate = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== "assets") walk(candidate);
|
||||
} else if (entry.name.endsWith(".html")) {
|
||||
files.push(candidate);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(directory);
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function attachBrowserEvents(page) {
|
||||
const events = { consoleErrors: [], pageErrors: [], requestFailures: [], httpErrors: [] };
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") events.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => events.pageErrors.push(error.message));
|
||||
page.on("requestfailed", (request) => events.requestFailures.push(`${request.url()} ${request.failure()?.errorText || ""}`));
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) events.httpErrors.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
async function settle(page) {
|
||||
const result = await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const images = [...document.images];
|
||||
for (const image of images) image.loading = "eager";
|
||||
const decodeFailures = [];
|
||||
for (const image of images) {
|
||||
try {
|
||||
await image.decode();
|
||||
} catch (error) {
|
||||
decodeFailures.push(`${image.currentSrc || image.src}: ${error}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
imageFailures: [...images.filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src), ...decodeFailures],
|
||||
fontErrors: [...document.fonts].filter((face) => face.status === "error").map((face) => ({ family: face.family, weight: face.weight, status: face.status })),
|
||||
};
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return result;
|
||||
}
|
||||
|
||||
async function applyStressMode(page, mode) {
|
||||
if (mode.stress === "reduced-motion") return;
|
||||
if (mode.stress === "normal") return;
|
||||
await page.evaluate((stress) => {
|
||||
if (stress === "text200") {
|
||||
// Snapshot every baseline computed size before changing the root. Applying those doubled values inline
|
||||
// makes rem/em and fixed-px text exactly 2x, rather than multiplying root-scaled text twice.
|
||||
const baseline = [...document.body.querySelectorAll("*")].map((element) => ({ element, fontSize: parseFloat(getComputedStyle(element).fontSize) }));
|
||||
document.documentElement.style.fontSize = "200%";
|
||||
for (const { element, fontSize } of baseline) {
|
||||
if (Number.isFinite(fontSize) && fontSize > 0) element.style.setProperty("font-size", `${fontSize * 2}px`, "important");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.id = "showcase-stress-spacing";
|
||||
style.textContent = "body * { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-block: 2em !important; }";
|
||||
document.head.append(style);
|
||||
}, mode.stress);
|
||||
}
|
||||
|
||||
async function sampleTabFocus(page) {
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
const sequence = [];
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
sequence.push(await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
const rect = active?.getBoundingClientRect();
|
||||
const style = active ? getComputedStyle(active) : null;
|
||||
return {
|
||||
tag: active?.tagName || null,
|
||||
id: active?.id || null,
|
||||
className: active?.className || "",
|
||||
rect: rect ? { left: Math.round(rect.left), top: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) } : null,
|
||||
outlineStyle: style?.outlineStyle || null,
|
||||
outlineWidth: style?.outlineWidth || null,
|
||||
};
|
||||
}));
|
||||
}
|
||||
return sequence;
|
||||
}
|
||||
|
||||
async function collectGeometry(page) {
|
||||
return page.evaluate(() => {
|
||||
const isHiddenModal = (element) => {
|
||||
const dialog = element.closest("dialog");
|
||||
return Boolean(dialog && !dialog.open) || Boolean(element.closest("[hidden], [aria-hidden=\"true\"]"));
|
||||
};
|
||||
const visible = (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0 && !isHiddenModal(element);
|
||||
};
|
||||
const excluded = (element) => element.matches("input, textarea, canvas") || Boolean(element.closest("input, textarea, canvas"));
|
||||
const selector = (element) => element.id ? `#${element.id}` : `${element.tagName.toLowerCase()}${[...element.classList].slice(0, 2).map((name) => `.${name}`).join("")}`;
|
||||
const textElements = [...document.querySelectorAll("p,h1,h2,h3,h4,h5,h6,li,dt,dd,a,button,label,output,figcaption,td,th,span")]
|
||||
.filter((element) => visible(element) && !excluded(element) && element.childNodes.length && [...element.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim()));
|
||||
const clipping = textElements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const clipped = (style.overflowX === "hidden" || style.overflowX === "clip" || style.textOverflow !== "clip") && element.scrollWidth > element.clientWidth + 1;
|
||||
return clipped ? { selector: selector(element), text: element.textContent.trim().slice(0, 80), rect: { left: Math.round(rect.left), right: Math.round(rect.right), top: Math.round(rect.top), bottom: Math.round(rect.bottom) }, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, overflowX: style.overflowX, whiteSpace: style.whiteSpace } : null;
|
||||
}).filter(Boolean).slice(0, 30);
|
||||
const overlaps = [];
|
||||
for (let first = 0; first < textElements.length; first += 1) {
|
||||
const a = textElements[first];
|
||||
const aRect = a.getBoundingClientRect();
|
||||
for (let second = first + 1; second < textElements.length; second += 1) {
|
||||
const b = textElements[second];
|
||||
if (a.contains(b) || b.contains(a)) continue;
|
||||
const bRect = b.getBoundingClientRect();
|
||||
const horizontal = Math.min(aRect.right, bRect.right) - Math.max(aRect.left, bRect.left);
|
||||
const vertical = Math.min(aRect.bottom, bRect.bottom) - Math.max(aRect.top, bRect.top);
|
||||
if (horizontal > 2 && vertical > 2) overlaps.push({ first: selector(a), second: selector(b), overlap: { width: Math.round(horizontal), height: Math.round(vertical) } });
|
||||
if (overlaps.length >= 30) break;
|
||||
}
|
||||
if (overlaps.length >= 30) break;
|
||||
}
|
||||
return {
|
||||
wholePage: { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1 },
|
||||
textClippingCandidates: clipping,
|
||||
textOverlapCandidates: overlaps,
|
||||
brokenImages: [...document.images].filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function hardFailures(row) {
|
||||
const failures = [];
|
||||
if (row.geometry.brokenImages.length) failures.push(`broken images ${row.geometry.brokenImages.length}`);
|
||||
if (row.events.consoleErrors.length) failures.push(`console errors ${row.events.consoleErrors.length}`);
|
||||
if (row.events.pageErrors.length) failures.push(`page errors ${row.events.pageErrors.length}`);
|
||||
if (row.events.requestFailures.length) failures.push(`network failures ${row.events.requestFailures.length}`);
|
||||
if (row.events.httpErrors.length) failures.push(`HTTP responses >=400 ${row.events.httpErrors.length}`);
|
||||
if (row.fontErrors.length) failures.push(`font status errors ${row.fontErrors.length}`);
|
||||
return failures;
|
||||
}
|
||||
|
||||
function screenshotPath(route, mode) {
|
||||
return path.join(outputDirectory, `${route.replace(/^\//, "").replace(/[\\/]/g, "__").replace(/\.html$/, "")}-${mode}.png`);
|
||||
}
|
||||
|
||||
function writePartial(report) {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
const report = { generatedAt: new Date().toISOString(), publicRoot, expectedRouteCount, routes: [], modes: modes.map((mode) => mode.name), rows: [], runnerErrors: [] };
|
||||
let server;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(showcaseRoot) || !fs.statSync(showcaseRoot).isDirectory()) throw new Error(`showcase root not found: ${showcaseRoot}`);
|
||||
const files = collectHtmlFiles(showcaseRoot);
|
||||
report.routes = files.map((file) => routeForFile(publicRoot, file));
|
||||
if (report.routes.length !== expectedRouteCount) throw new Error(`route count ${report.routes.length}; expected ${expectedRouteCount}`);
|
||||
server = await startPublicServer(publicRoot);
|
||||
browser = await puppeteer.launch({ executablePath: chrome, headless: "new", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"] });
|
||||
for (const route of report.routes) {
|
||||
for (const mode of modes) {
|
||||
console.log(`RUN ${route} ${mode.name}`);
|
||||
const page = await browser.newPage();
|
||||
const row = { route, mode: mode.name, width: mode.width, rawObservations: null, focusOrder: [], events: null, hardFailures: [] };
|
||||
const events = attachBrowserEvents(page);
|
||||
try {
|
||||
await page.setViewport({ width: mode.width, height: mode.height, isMobile: mode.width < 500, hasTouch: mode.width < 500 });
|
||||
await page.emulateMediaFeatures(mode.stress === "reduced-motion" ? [{ name: "prefers-reduced-motion", value: "reduce" }] : []);
|
||||
await page.goto(`${server.origin}${route}`, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
await applyStressMode(page, mode);
|
||||
const settled = await settle(page);
|
||||
row.geometry = await collectGeometry(page);
|
||||
row.geometry.brokenImages = [...new Set([...row.geometry.brokenImages, ...settled.imageFailures])];
|
||||
row.fontErrors = settled.fontErrors;
|
||||
row.focusOrder = await sampleTabFocus(page);
|
||||
if (mode.stress === "text200" || mode.stress === "spacing") {
|
||||
const file = screenshotPath(route, mode.name);
|
||||
await page.screenshot({ path: file, fullPage: true });
|
||||
row.screenshot = file;
|
||||
}
|
||||
} catch (error) {
|
||||
events.pageErrors.push(`stress runner: ${error.stack || error.message}`);
|
||||
row.geometry = { wholePage: null, textClippingCandidates: [], textOverlapCandidates: [], brokenImages: [] };
|
||||
row.fontErrors = [];
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
row.events = events;
|
||||
row.fontErrors ||= [];
|
||||
row.rawObservations = { wholePage: row.geometry.wholePage, textClippingCandidates: row.geometry.textClippingCandidates, textOverlapCandidates: row.geometry.textOverlapCandidates };
|
||||
row.hardFailures = hardFailures(row);
|
||||
report.rows.push(row);
|
||||
writePartial(report);
|
||||
console.log(`${row.hardFailures.length ? "FAIL" : "PASS"} ${route} ${mode.name}${row.hardFailures.length ? ` — ${row.hardFailures.join(" · ")}` : ""}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report.runnerErrors.push(error.stack || error.message);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
writePartial(report);
|
||||
const failedRows = report.rows.filter((row) => row.hardFailures.length);
|
||||
console.log(`\n${failedRows.length || report.runnerErrors.length ? "✗" : "✓"} typography stress diagnostic — rows ${report.rows.length}, hard failures ${failedRows.length}`);
|
||||
process.exitCode = failedRows.length || report.runnerErrors.length ? 1 : 0;
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@ const catalogUrl = `http://127.0.0.1:${address.port}/work/twentyoz-corp/products
|
|||
let pass = 0;
|
||||
let fail = 0;
|
||||
const failures = [];
|
||||
const observations = [];
|
||||
const suites = new Map();
|
||||
const assert = (condition, suite, name, detail = "") => {
|
||||
const current = suites.get(suite) || { pass: 0, fail: 0 };
|
||||
|
|
@ -124,12 +125,46 @@ try {
|
|||
const h1 = document.querySelector("h1");
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(h1).lineHeight);
|
||||
const navTops = [...document.querySelectorAll(".nav a")].map((link) => Math.round(link.getBoundingClientRect().top));
|
||||
const navLinks = [...document.querySelectorAll(".nav a")].map((link) => {
|
||||
const rect = link.getBoundingClientRect();
|
||||
const style = getComputedStyle(link);
|
||||
return {
|
||||
text: link.textContent.trim(),
|
||||
visible: style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0,
|
||||
height: Math.round(rect.height),
|
||||
insideDocument: rect.left >= 0 && rect.right <= document.documentElement.clientWidth
|
||||
&& rect.top >= 0 && rect.bottom <= document.documentElement.scrollHeight,
|
||||
};
|
||||
});
|
||||
const headSiblings = [...document.querySelectorAll(".head-inner > *")].map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
name: element.className || element.tagName.toLowerCase(),
|
||||
visible: visible(element),
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
};
|
||||
});
|
||||
const headOverlaps = [];
|
||||
for (let index = 0; index < headSiblings.length; index += 1) {
|
||||
for (let other = index + 1; other < headSiblings.length; other += 1) {
|
||||
const first = headSiblings[index];
|
||||
const second = headSiblings[other];
|
||||
if (first.visible && second.visible && first.left < second.right && first.right > second.left && first.top < second.bottom && first.bottom > second.top) {
|
||||
headOverlaps.push(`${first.name}/${second.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
h1Count: document.querySelectorAll("h1").length,
|
||||
h1Lines: Math.round(h1.getBoundingClientRect().height / lineHeight),
|
||||
navTopCount: new Set(navTops).size,
|
||||
navLinks,
|
||||
headOverlaps,
|
||||
checked: document.querySelector('input[name="field"]:checked')?.value,
|
||||
visiblePanels: [...document.querySelectorAll(".field-panel")].filter(visible).map((panel) => panel.dataset.panel),
|
||||
fieldTargets: [...document.querySelectorAll(".field-tabs label")].map((label) => {
|
||||
|
|
@ -147,7 +182,11 @@ try {
|
|||
assert(layout.scrollWidth <= layout.width, suite, "no horizontal document overflow", `${layout.scrollWidth}/${layout.width}`);
|
||||
assert(layout.h1Count === 1, suite, "one h1", String(layout.h1Count));
|
||||
assert(layout.h1Lines <= 2, suite, "hero title at most two lines", String(layout.h1Lines));
|
||||
assert(layout.navTopCount === 1, suite, "navigation remains one row", String(layout.navTopCount));
|
||||
observations.push(`${suite} · navigation rows observed: ${layout.navTopCount}`);
|
||||
assertEvery(layout.navLinks, (link) => link.visible, suite, "navigation link visible", (link) => link.text);
|
||||
assertEvery(layout.navLinks, (link) => link.height >= 24, suite, "navigation link height >=24", (link) => `${link.text} ${link.height}px`);
|
||||
assertEvery(layout.navLinks, (link) => link.insideDocument, suite, "navigation link stays inside document", (link) => link.text);
|
||||
assert(layout.headOverlaps.length === 0, suite, "header siblings do not overlap", layout.headOverlaps.join(","));
|
||||
assert(layout.checked === "edu", suite, "default field is education", String(layout.checked));
|
||||
assert(layout.visiblePanels.length === 1 && layout.visiblePanels[0] === "edu", suite, "one matching panel visible", layout.visiblePanels.join(","));
|
||||
assertEvery(layout.fieldTargets, (target) => target.height >= 44, suite, "field target height >=44", (target) => `${target.width}x${target.height}`);
|
||||
|
|
@ -280,9 +319,9 @@ try {
|
|||
|
||||
const coreLayoutCases = [
|
||||
{ width: 390, height: 844, mobile: true, tier: "mobile", offset: 0, minRatio: 0.495, maxRatio: 0.505 },
|
||||
{ width: 768, height: 900, mobile: false, tier: "tablet", offset: 0.9, minRatio: 0.72, maxRatio: 0.75 },
|
||||
{ width: 1024, height: 768, mobile: false, tier: "compact", offset: 0.75, minRatio: 0.61, maxRatio: 0.64 },
|
||||
{ width: 1440, height: 900, mobile: false, tier: "wide", offset: 0.72, minRatio: 0.58, maxRatio: 0.62 },
|
||||
{ width: 768, height: 900, mobile: false, tier: "tablet", offset: 0.9 },
|
||||
{ width: 1024, height: 768, mobile: false, tier: "compact", offset: 0.75 },
|
||||
{ width: 1440, height: 900, mobile: false, tier: "wide", offset: 0.72 },
|
||||
];
|
||||
for (const expected of coreLayoutCases) {
|
||||
await glPage.setViewport({ width: expected.width, height: expected.height, isMobile: expected.mobile, hasTouch: expected.mobile });
|
||||
|
|
@ -291,22 +330,37 @@ try {
|
|||
{ timeout: 10000 },
|
||||
expected.tier,
|
||||
);
|
||||
const placement = await glPage.$eval("#coreCanvas", (canvas) => ({
|
||||
tier: canvas.dataset.layoutTier,
|
||||
offset: Number(canvas.dataset.coreOffsetX),
|
||||
ratio: Number(canvas.dataset.coreScreenRatio),
|
||||
overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
actionRows: new Set(
|
||||
[...document.querySelectorAll(".hero-actions > *")]
|
||||
.filter((element) => getComputedStyle(element).display !== "none")
|
||||
.map((element) => Math.round(element.getBoundingClientRect().top)),
|
||||
).size,
|
||||
motionHeight: document.querySelector("#motionToggle").getBoundingClientRect().height,
|
||||
}));
|
||||
const placement = await glPage.$eval("#coreCanvas", (canvas) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const heading = document.querySelector(".hero h1").getBoundingClientRect();
|
||||
const ratio = Number(canvas.dataset.coreScreenRatio);
|
||||
return {
|
||||
tier: canvas.dataset.layoutTier,
|
||||
offset: Number(canvas.dataset.coreOffsetX),
|
||||
ratio,
|
||||
centerX: rect.left + ratio * rect.width,
|
||||
canvasLeft: rect.left,
|
||||
canvasRight: rect.right,
|
||||
heroHeadingRight: heading.right,
|
||||
overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
actionRows: new Set(
|
||||
[...document.querySelectorAll(".hero-actions > *")]
|
||||
.filter((element) => getComputedStyle(element).display !== "none")
|
||||
.map((element) => Math.round(element.getBoundingClientRect().top)),
|
||||
).size,
|
||||
motionHeight: document.querySelector("#motionToggle").getBoundingClientRect().height,
|
||||
};
|
||||
});
|
||||
const suite = `WebGL layout ${expected.width}`;
|
||||
assert(placement.tier === expected.tier, suite, "responsive layout tier is selected", placement.tier);
|
||||
assert(Math.abs(placement.offset - expected.offset) <= 0.01, suite, "core offset matches composition", String(placement.offset));
|
||||
assert(placement.ratio >= expected.minRatio && placement.ratio <= expected.maxRatio, suite, "core screen position fills the hero center", String(placement.ratio));
|
||||
assert(Number.isFinite(placement.ratio) && placement.ratio >= 0 && placement.ratio <= 1, suite, "core screen ratio stays inside canvas", String(placement.ratio));
|
||||
assert(placement.centerX >= placement.canvasLeft && placement.centerX <= placement.canvasRight, suite, "core projection center stays inside canvas", `${placement.centerX}/${placement.canvasLeft}-${placement.canvasRight}`);
|
||||
if (expected.mobile) {
|
||||
assert(placement.ratio >= expected.minRatio && placement.ratio <= expected.maxRatio, suite, "mobile core screen position stays centered", String(placement.ratio));
|
||||
} else {
|
||||
assert(placement.centerX > placement.heroHeadingRight, suite, "desktop core projection clears hero heading", `${placement.centerX}/${placement.heroHeadingRight}`);
|
||||
}
|
||||
assert(placement.overflow <= 0, suite, "visible 3D controls create no horizontal overflow", String(placement.overflow));
|
||||
assert(placement.actionRows === 1, suite, "hero actions stay on one row", String(placement.actionRows));
|
||||
assert(placement.motionHeight >= 44, suite, "motion control keeps a 44px target", String(placement.motionHeight));
|
||||
|
|
@ -403,7 +457,7 @@ const lines = [...suites.entries()].map(([name, result]) => `${result.fail ? "FA
|
|||
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
|
||||
fs.writeFileSync(
|
||||
path.join(here, "twentyoz-e2e-report.md"),
|
||||
[`# TWENTYOZ E2E — ${stamp}`, "", fail ? `**${fail} failed · deploy blocked**` : `**${pass} assertions passed · GREEN**`, "", ...lines.map((line) => `- ${line}`), ...(failures.length ? ["", "## Failures", "", ...failures.map((item) => `- ${item}`)] : [])].join("\n") + "\n",
|
||||
[`# TWENTYOZ E2E — ${stamp}`, "", fail ? `**${fail} failed · deploy blocked**` : `**${pass} assertions passed · GREEN**`, "", ...lines.map((line) => `- ${line}`), ...(observations.length ? ["", "## Observations", "", ...observations.map((item) => `- ${item}`)] : []), ...(failures.length ? ["", "## Failures", "", ...failures.map((item) => `- ${item}`)] : [])].join("\n") + "\n",
|
||||
);
|
||||
|
||||
console.log(lines.join("\n"));
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ const steps = [
|
|||
cmd: () => spawnSync(process.execPath, ["tools/integrity.mjs"], { cwd: root, encoding: "utf8" }),
|
||||
},
|
||||
{ layer: "L1-단위/계산로직", cmd: () => spawnSync(process.execPath, ["tools/unit/calc.test.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
{
|
||||
// 최솟폭만으로는 초광폭에서 측정 폭이 풀리는 결함, 정적 검사만으로는 입력·표 셀의
|
||||
// 실제 여백과 겹침을 놓친다. showcase-audit 기본 폭(320~2560)과 렌더 기하를
|
||||
// verify 체인에 고정해 사용자 보고가 다시 배포로 새지 않게 한다.
|
||||
layer: "L2-쇼케이스/반응형·여백",
|
||||
cmd: () => spawnSync(process.execPath, ["tools/showcase-audit.mjs"], { cwd: root, encoding: "utf8" }),
|
||||
},
|
||||
{ layer: "L2-기능/감사", cmd: () => spawnSync(process.execPath, ["tools/audit.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L2-불변식/렌더", cmd: () => spawnSync(process.execPath, ["tools/invariants.mjs"], { cwd: root, encoding: "utf8" }) },
|
||||
{ layer: "L2-디자인 게이트/메인", cmd: () => spawnSync(process.execPath, ["tools/design-gate.mjs", "--config=gate.config.json"], { cwd: root, encoding: "utf8" }) },
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ const BASE = path.join(SHOTS, "baseline");
|
|||
const CURR = path.join(SHOTS, "current");
|
||||
const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe";
|
||||
const UPDATE = process.argv.includes("--update-baseline");
|
||||
const scopedUpdates = new Set(
|
||||
process.argv
|
||||
.filter((arg) => arg.startsWith("--update-baseline="))
|
||||
.flatMap((arg) => arg.slice("--update-baseline=".length).split(","))
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const APPS = {
|
||||
"gaon-lms": ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"],
|
||||
|
|
@ -54,9 +61,10 @@ const rows = [];
|
|||
for (const name of shots) {
|
||||
const cur = PNG.sync.read(fs.readFileSync(path.join(CURR, name)));
|
||||
const baseFile = path.join(BASE, name);
|
||||
if (UPDATE || !fs.existsSync(baseFile)) {
|
||||
const updateThisBaseline = UPDATE || scopedUpdates.has(name);
|
||||
if (updateThisBaseline || !fs.existsSync(baseFile)) {
|
||||
fs.writeFileSync(baseFile, PNG.sync.write(cur));
|
||||
rows.push({ name, status: UPDATE ? "갱신" : "신규기준", diff: "—" });
|
||||
rows.push({ name, status: updateThisBaseline ? "갱신" : "신규기준", diff: "—" });
|
||||
continue;
|
||||
}
|
||||
const base = PNG.sync.read(fs.readFileSync(baseFile));
|
||||
|
|
@ -76,6 +84,12 @@ for (const name of shots) {
|
|||
rows.push({ name, status: bad ? "FAIL" : "PASS", diff: ratio.toFixed(3) + "%" });
|
||||
}
|
||||
|
||||
const unknownScopedUpdates = [...scopedUpdates].filter((name) => !shots.includes(name));
|
||||
if (unknownScopedUpdates.length) {
|
||||
console.error(`Unknown baseline name(s): ${unknownScopedUpdates.join(", ")}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
for (const r of rows) console.log(`${r.status.padEnd(4)} ${r.name} ${r.diff}`);
|
||||
console.log(`\nVISUAL: ${fails ? `${fails} FAIL (기준 대비 회귀)` : UPDATE ? "기준 갱신 완료" : "ALL MATCH"}`);
|
||||
process.exit(fails ? 1 : 0);
|
||||
|
|
|
|||