designpaca/apps/site/tools/showcase-duo-e2e.mjs
Yun Chan e09d4efb1c
Some checks failed
ci / build (push) Has been cancelled
feat: strengthen design skill and refresh all showcases
2026-09-12 18:16:33 +09:00

1351 lines
88 KiB
JavaScript

// 한남자리 공개·관리자 + 철판클럽 공개·가맹 전용 실제 브라우저 게이트.
// source와 Astro 산출물을 같은 계약으로 검증한다.
// node tools/showcase-duo-e2e.mjs --root=public
// node tools/showcase-duo-e2e.mjs --root=dist
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import url from "node:url";
import { createRequire } from "node:module";
import puppeteer from "puppeteer-core";
import { webkit } from "playwright";
import { PNG } from "pngjs";
const require = createRequire(import.meta.url);
const axePath = require.resolve("axe-core/axe.min.js");
const here = path.dirname(url.fileURLToPath(import.meta.url));
const siteRoot = path.resolve(here, "..");
const EXPECTED_SCENARIOS = 90;
const EXPECTED_ASSERTIONS = 350;
function parseRoot(argv) {
let value = "public";
for (let index = 2; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--root") value = argv[++index];
else if (argument.startsWith("--root=")) value = argument.slice(7);
else throw new Error(`Unknown argument: ${argument}`);
}
if (!value || !["public", "dist"].includes(value)) throw new Error("--root must be public or dist");
return value;
}
const rootName = parseRoot(process.argv);
const serveRoot = path.join(siteRoot, rootName);
const reportFile = path.join(here, "showcase-duo-e2e-report.md");
const evidenceRoot = path.join(here, ".gen", "showcase-duo");
if (!fs.existsSync(serveRoot)) throw new Error(`${rootName} root does not exist: ${serveRoot}`);
if (!fs.existsSync(axePath)) throw new Error(`axe-core asset does not exist: ${axePath}`);
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 ROUTES = [
{ key: "estate-public", label: "한남자리 공개", path: "/work/hannam-jari/", main: "#main-content", fallback: ".no-script" },
{ key: "estate-admin", label: "한남자리 관리자", path: "/work/hannam-jari/admin/", main: "#admin-main", fallback: ".admin-noscript" },
{ key: "burger-public", label: "철판클럽 공개", path: "/work/griddle-club/", main: "#main", fallback: "#menu" },
{ key: "burger-franchise", label: "철판클럽 가맹", path: "/work/griddle-club/franchise/", main: "#main", fallback: "#model" },
];
const WIDTHS = [320, 390, 768, 1440];
const VISUAL_WIDTHS = new Set([390, 1440]);
const evidence = [];
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"],
]);
function inside(root, candidate) {
const relative = path.relative(root, 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(serveRoot, `.${pathname}`);
if (!inside(serveRoot, candidate)) return void response.writeHead(403).end("forbidden");
const file = fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() ? path.join(candidate, "index.html") : candidate;
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return void response.writeHead(404).end("not found");
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");
}
});
const scenarios = [];
function defineScenario(suite, name, expectedAssertions, run) {
scenarios.push({ suite, name, expectedAssertions, run });
}
function display(value) {
if (typeof value === "string") return value;
if (value === undefined) return "undefined";
try { return JSON.stringify(value); } catch { return String(value); }
}
class ScenarioContext {
constructor(definition) {
this.definition = definition;
this.assertions = [];
}
check(name, expected, actual, predicate) {
let pass = false;
try { pass = Boolean(predicate(actual, expected)); } catch { pass = false; }
this.assertions.push({ name, expected: display(expected), actual: display(actual), pass });
}
equal(name, expected, actual) { this.check(name, expected, actual, (a, e) => Object.is(a, e)); }
ok(name, actual, expected = true) { this.check(name, expected, actual, (a) => Boolean(a)); }
includes(name, expected, actual) { this.check(name, `contains ${expected}`, actual, (a) => String(a).includes(expected)); }
}
let origin = "";
let chromiumBrowser;
let webkitBrowser;
const pageErrors = new WeakMap();
async function settle(page, { eagerImages = false } = {}) {
await Promise.race([
page.evaluate(() => document.fonts?.ready || Promise.resolve()),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
if (eagerImages) {
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, 240));
}
async function openChromium(route, { width = 1440, height = width < 500 ? 844 : 900, javascript = true, reduced = false, query = "" } = {}) {
const page = await chromiumBrowser.newPage();
const errors = [];
pageErrors.set(page, errors);
await page.setJavaScriptEnabled(javascript);
await page.setCacheEnabled(false);
await page.setViewport({ width, height, deviceScaleFactor: 1, isMobile: width < 500, hasTouch: width < 500 });
if (reduced) await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
page.on("console", (message) => { if (message.type() === "error") errors.push(`console: ${message.text()}`); });
page.on("response", (response) => {
if (response.url().startsWith(origin) && response.status() >= 400) errors.push(`response: ${response.status()} ${response.url()}`);
});
const response = await page.goto(`${origin}${route.path}${query}`, { waitUntil: "domcontentloaded", timeout: 30000 });
await settle(page);
return { page, status: response?.status() ?? 0, errors };
}
async function closePage(page) {
if (page && !page.isClosed()) await page.close();
}
async function setValue(page, selector, value, eventName = "input") {
await page.$eval(selector, (element, next, eventType) => {
element.value = next;
element.dispatchEvent(new Event(eventType, { bubbles: true }));
}, String(value), eventName);
}
async function choose(page, selector) {
await page.$eval(selector, (element) => {
element.checked = true;
element.dispatchEvent(new Event("change", { bubbles: true }));
});
}
async function textOf(page, selector) {
return page.$eval(selector, (element) => element.textContent.trim());
}
async function visible(page, selector) {
return page.$eval(selector, (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return !element.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
}).catch(() => false);
}
async function captureEvidence(page, name, width, height, { focusSelector = null } = {}) {
await settle(page, { eagerImages: true });
const integrity = await page.evaluate(() => ({
broken: [...document.images].filter((image) => image.complete && image.naturalWidth === 0).map((image) => image.currentSrc || image.src),
width: innerWidth,
height: innerHeight,
}));
const errors = pageErrors.get(page) || [];
if (integrity.broken.length || errors.length) return { pass: false, ...integrity, errors };
await page.addStyleTag({ content: "*{animation:none!important;transition:none!important;caret-color:transparent!important}html{scroll-behavior:auto!important}::-webkit-scrollbar{display:none!important}" });
if (focusSelector) {
await page.$eval(focusSelector, (element) => element.scrollIntoView({ block: "center", inline: "nearest" }));
} else {
await page.evaluate(() => window.scrollTo(0, 0));
}
await new Promise((resolve) => setTimeout(resolve, 80));
await fs.promises.mkdir(evidenceRoot, { recursive: true });
const file = path.join(evidenceRoot, `${name}.png`);
const buffer = await page.screenshot({ path: file, type: "png", fullPage: false });
const png = PNG.sync.read(buffer);
const hash = crypto.createHash("sha256").update(buffer).digest("hex");
const item = { name, file: path.relative(siteRoot, file).replaceAll("\\", "/"), exists: fs.existsSync(file), width: png.width, height: png.height, bytes: buffer.byteLength, sha256: hash };
evidence.push(item);
return { pass: item.exists && png.width === width && png.height === height && buffer.byteLength > 10000 && /^[a-f0-9]{64}$/.test(hash), ...item };
}
function localFileBytes(resourceUrl) {
const parsed = new URL(resourceUrl);
if (parsed.origin !== origin) return 0;
const candidate = path.resolve(serveRoot, `.${decodeURIComponent(parsed.pathname)}`);
return inside(serveRoot, candidate) && fs.existsSync(candidate) && fs.statSync(candidate).isFile() ? fs.statSync(candidate).size : 0;
}
const GEOMETRY_PROBE = () => {
const visible = (el) => { const cs = getComputedStyle(el); const r = el.getBoundingClientRect(); return cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0; };
const h1 = document.querySelector('h1');
const lines = h1 ? (() => { const range = document.createRange(); range.selectNodeContents(h1); const tops = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4).map((r) => r.top).sort((a,b) => a-b); const unique=[]; for (const top of tops) if (!unique.length || top-unique.at(-1)>5) unique.push(top); return unique.length; })() : 0;
const textNodes=[]; const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT); let n;
while((n=walker.nextNode())) { if(!n.textContent.trim()||!n.parentElement||!visible(n.parentElement)) continue; const range=document.createRange(); range.selectNodeContents(n); const r=range.getBoundingClientRect(); if(r.width>1&&r.height>4&&r.left>=0) textNodes.push(r.left); }
return { viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth, h1Count: document.querySelectorAll('h1').length, h1Lines: lines, minInset: textNodes.length ? Math.round(Math.min(...textNodes)) : null, broken: [...document.images].filter((image)=>image.complete&&image.naturalWidth===0).map((image)=>image.currentSrc||image.src), documentHeight: document.documentElement.scrollHeight };
};
const CONTRAST_PROBE = () => {
const rgb=(s)=>{const m=String(s).match(/[\\d.]+/g);return m?{r:+m[0],g:+m[1],b:+m[2],a:m[3]===undefined?1:+m[3]}:null};
const lum=(c)=>{const f=(v)=>{v/=255;return v<=.03928?v/12.92:((v+.055)/1.055)**2.4};return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b)};
const visible=(el)=>{const cs=getComputedStyle(el),r=el.getBoundingClientRect();return cs.display!=='none'&&cs.visibility!=='hidden'&&+cs.opacity!==0&&r.width>0&&r.height>0};
const out=[];
document.querySelectorAll('p,span,a,li,dt,dd,h1,h2,h3,h4,label,button,strong,small,th,td').forEach((el)=>{
if(!visible(el)||![...el.childNodes].some((n)=>n.nodeType===3&&n.textContent.trim()))return;
const cs=getComputedStyle(el); const fg=rgb(cs.color); if(!fg)return; let node=el,bg=null;
while(node&&node!==document.documentElement){const c=rgb(getComputedStyle(node).backgroundColor);if(c&&c.a>=.95){bg=c;break}node=node.parentElement} if(!bg)bg=rgb(getComputedStyle(document.body).backgroundColor)||{r:255,g:255,b:255,a:1};
const ratio=(Math.max(lum(fg),lum(bg))+.05)/(Math.min(lum(fg),lum(bg))+.05); const size=parseFloat(cs.fontSize),bold=+cs.fontWeight>=700,need=size>=24||(size>=18.66&&bold)?3:4.5;
if(ratio+0.005<need)out.push({text:el.textContent.trim().slice(0,18),ratio:+ratio.toFixed(2),need});
}); return out.slice(0,12);
};
// Chromium 반응형 16시나리오 · 112단언. 390/1440은 기본 시각 증거 8장을 남긴다.
for (const route of ROUTES) {
for (const width of WIDTHS) {
defineScenario("Chromium 반응형", `${route.label} ${width}px`, 7, async (t) => {
const opened = await openChromium(route, { width });
try {
await settle(opened.page, { eagerImages: true });
const geometry = await opened.page.evaluate(GEOMETRY_PROBE);
const contrast = await opened.page.evaluate(CONTRAST_PROBE);
t.equal("HTTP 상태", 200, opened.status);
t.check("가로 오버플로 없음", `<= ${geometry.viewport}`, geometry.scrollWidth, (actual) => actual <= geometry.viewport);
t.equal("문서 h1 정확히 하나", 1, geometry.h1Count);
t.check("h1 최대 두 줄", "1~2 lines", geometry.h1Lines, (actual) => actual >= 1 && actual <= 2);
t.check("보이는 텍스트 대비 AA", "0 violations", contrast, (actual) => actual.length === 0);
t.check("이미지와 런타임 무결성", "0 broken / 0 errors", { broken: geometry.broken, errors: opened.errors }, (actual) => actual.broken.length === 0 && actual.errors.length === 0);
if (VISUAL_WIDTHS.has(width)) {
const shot = await captureEvidence(opened.page, `${route.key}-${width}`, width, width < 500 ? 844 : 900);
if (route.key === "burger-franchise") {
const heroActions = await opened.page.evaluate(() => [...document.querySelectorAll(".franchise-hero .hero-actions > a")].map((element) => {
const rect = element.getBoundingClientRect();
return { selector: element.className, height: rect.height, centerY: rect.top + rect.height / 2 };
}));
t.check("기본 시각 증거와 영웅 행동 정렬", `${width}x${width < 500 ? 844 : 900}, controls >=44px, center diff <=2px`, { shot, heroActions }, (actual) => actual.shot.pass
&& actual.heroActions.length === 2
&& actual.heroActions.every((item) => item.height >= 44)
&& Math.abs(actual.heroActions[0].centerY - actual.heroActions[1].centerY) <= 2);
} else {
t.check("기본 상태 시각 증거", `${width}x${width < 500 ? 844 : 900}, >10KB, sha256`, shot, (actual) => actual.pass);
}
} else {
t.check("문서가 뷰포트를 채움", `height >= ${width < 500 ? 844 : 900}`, geometry.documentHeight, (actual) => actual >= (width < 500 ? 844 : 900));
}
} finally { await closePage(opened.page); }
});
}
}
// WebKit 8시나리오 · 32단언.
for (const route of ROUTES) {
for (const width of [390, 1440]) {
defineScenario("WebKit", `${route.label} ${width}px`, 4, async (t) => {
const page = await webkitBrowser.newPage({ viewport: { width, height: width < 500 ? 844 : 900 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); });
try {
const response = await page.goto(origin + route.path, { waitUntil: "domcontentloaded" });
await page.evaluate(() => document.fonts?.ready || Promise.resolve());
await page.waitForTimeout(180);
const geometry = await page.evaluate(GEOMETRY_PROBE);
t.equal("WebKit HTTP 상태", 200, response?.status() ?? 0);
t.check("WebKit 가로 오버플로 없음", `<= ${geometry.viewport}`, geometry.scrollWidth, (actual) => actual <= geometry.viewport);
t.check("WebKit 제목 구조", "1 h1 / 1~2 lines", { count: geometry.h1Count, lines: geometry.h1Lines }, (actual) => actual.count === 1 && actual.lines >= 1 && actual.lines <= 2);
t.check("WebKit 이미지와 런타임", "0 broken / 0 errors", { broken: geometry.broken, errors }, (actual) => actual.broken.length === 0 && actual.errors.length === 0);
} finally { await page.close(); }
});
}
}
// axe 8시나리오 · 16단언.
for (const route of ROUTES) {
for (const width of [390, 1440]) {
defineScenario("axe 접근성", `${route.label} ${width}px`, 2, async (t) => {
const opened = await openChromium(route, { width });
try {
await opened.page.addScriptTag({ path: axePath });
const violations = await opened.page.evaluate(async () => (await axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"] } })).violations.map((item) => ({
id: item.id,
impact: item.impact,
nodes: item.nodes.map((node) => ({
target: node.target,
html: node.html,
failureSummary: node.failureSummary,
checks: [...node.any, ...node.all, ...node.none].map((check) => ({ id: check.id, message: check.message, data: check.data })),
})),
})));
t.check("중대 접근성 위반 없음", "0 critical/serious", violations.filter((item) => ["critical", "serious"].includes(item.impact)), (actual) => actual.length === 0);
t.check("보통 접근성 위반 없음", "0 moderate", violations.filter((item) => item.impact === "moderate"), (actual) => actual.length === 0);
} finally { await closePage(opened.page); }
});
}
}
// 성능·자산 8시나리오 · 32단언.
for (const route of ROUTES) {
for (const width of [390, 1440]) {
defineScenario("성능과 자산", `${route.label} ${width}px`, 4, async (t) => {
const page = await chromiumBrowser.newPage();
const errors = [];
pageErrors.set(page, errors);
await page.setViewport({ width, height: width < 500 ? 844 : 900, deviceScaleFactor: 1, isMobile: width < 500, hasTouch: width < 500 });
await page.evaluateOnNewDocument(() => {
window.__duoCls = 0;
new PerformanceObserver((list) => { for (const entry of list.getEntries()) if (!entry.hadRecentInput) window.__duoCls += entry.value; }).observe({ type: "layout-shift", buffered: true });
});
try {
await page.goto(origin + route.path, { waitUntil: "domcontentloaded", timeout: 30000 });
await settle(page);
const metrics = await page.evaluate(() => ({
cls: window.__duoCls || 0,
images: [...document.images].filter((image) => { const r=image.getBoundingClientRect(); return r.bottom>0&&r.top<innerHeight&&image.currentSrc; }).map((image) => image.currentSrc),
scripts: performance.getEntriesByType("resource").filter((entry) => entry.initiatorType === "script").map((entry) => entry.name),
styles: performance.getEntriesByType("resource").filter((entry) => entry.initiatorType === "css" || entry.initiatorType === "link").map((entry) => entry.name),
}));
const imageBytes = [...new Set(metrics.images)].reduce((sum, item) => sum + localFileBytes(item), 0);
const scriptBytes = [...new Set(metrics.scripts)].reduce((sum, item) => sum + localFileBytes(item), 0);
const styleBytes = [...new Set(metrics.styles)].reduce((sum, item) => sum + localFileBytes(item), 0);
const imageBudget = width < 500 ? 260 * 1024 : 350 * 1024;
t.check("CLS 예산", "<= 0.1", +metrics.cls.toFixed(4), (actual) => actual <= 0.1);
t.check("첫 화면 이미지 예산", `0..${imageBudget} bytes`, imageBytes, (actual) => actual >= 0 && actual <= imageBudget);
t.check("로컬 자바스크립트 예산", "1..180KB", scriptBytes, (actual) => actual > 0 && actual <= 180 * 1024);
t.check("로컬 CSS 예산", "1..180KB", styleBytes, (actual) => actual > 0 && actual <= 180 * 1024);
} finally { await closePage(page); }
});
}
}
async function reducedAction(page, route) {
if (route.key === "estate-public") {
await page.click('[data-area-unit="pyeong"]');
return textOf(page, "[data-matrix-area]");
}
if (route.key === "estate-admin") {
await page.click('[data-tab="legal"]');
return visible(page, '#inspector-legal');
}
if (route.key === "burger-public") {
await page.click('[data-menu-filter="side"]');
return page.$$eval('[data-menu-category]:not([hidden])', (items) => items.length);
}
await page.click("[data-next]");
return textOf(page, "#demo-name-error");
}
// 무자바스크립트 4 + 감소 모션 4 = 8시나리오 · 32단언.
for (const route of ROUTES) {
defineScenario("점진 향상", `${route.label} JavaScript 없음`, 4, async (t) => {
const opened = await openChromium(route, { width: 390, javascript: false });
try {
await settle(opened.page, { eagerImages: true });
const state = await opened.page.evaluate((mainSelector, fallbackSelector) => {
const h1=document.querySelector('h1'),main=document.querySelector(mainSelector),fallback=document.querySelector(fallbackSelector);
const vis=(el)=>{if(!el)return false;const cs=getComputedStyle(el),r=el.getBoundingClientRect();return cs.display!=='none'&&cs.visibility!=='hidden'&&r.width>0&&r.height>0};
return { h1:vis(h1), main:vis(main), fallback:vis(fallback), broken:[...document.images].filter((image)=>image.complete&&image.naturalWidth===0).length };
}, route.main, route.fallback);
t.equal("무자바스크립트 HTTP 상태", 200, opened.status);
t.check("핵심 문서 구조 유지", "visible h1 and main", { h1: state.h1, main: state.main }, (actual) => actual.h1 && actual.main);
t.ok("읽기 가능한 정적 대안", state.fallback);
t.equal("무자바스크립트 깨진 이미지", 0, state.broken);
} finally { await closePage(opened.page); }
});
defineScenario("점진 향상", `${route.label} 감소 모션`, 4, async (t) => {
const opened = await openChromium(route, { width: 390, reduced: true });
try {
const motion = await opened.page.evaluate(() => {
const durations=[...document.querySelectorAll('body *')].filter((el)=>{const r=el.getBoundingClientRect();return r.width>0&&r.height>0}).flatMap((el)=>getComputedStyle(el).animationDuration.split(',').map((v)=>parseFloat(v)||0));
return { matches: matchMedia('(prefers-reduced-motion: reduce)').matches, scroll: getComputedStyle(document.documentElement).scrollBehavior, maxAnimation: durations.length?Math.max(...durations):0 };
});
const action = await reducedAction(opened.page, route);
t.equal("감소 모션 미디어 일치", true, motion.matches);
t.equal("부드러운 스크롤 해제", "auto", motion.scroll);
t.check("반복 애니메이션 제거", "<= 0.01s", motion.maxAnimation, (actual) => actual <= 0.01);
t.check("감소 모션에서도 핵심 작동", "route-specific state changed", action, (actual) => actual === true || Number(actual) > 0 || String(actual).length > 0);
} finally { await closePage(opened.page); }
});
}
const estatePublic = ROUTES.find((route) => route.key === "estate-public");
const estateAdmin = ROUTES.find((route) => route.key === "estate-admin");
const burgerPublic = ROUTES.find((route) => route.key === "burger-public");
const burgerFranchise = ROUTES.find((route) => route.key === "burger-franchise");
// 한남자리 공개 8시나리오 · 24단언. H04 개인 브리핑·비교 계약.
const ESTATE_DECISION_FIELDS = ["budget", "minArea", "moveIn", "parking", "trade"];
async function setEstateConditions(page, { trade, budget, minArea, moveIn, parking }) {
await page.select('[name="trade"]', trade);
await setValue(page, '[name="budget"]', budget);
await setValue(page, '[name="minArea"]', minArea);
await setValue(page, '[name="moveIn"]', moveIn);
await page.select('[name="parking"]', String(parking));
}
async function submitEstate(page) {
await page.click("[data-condition-form] button[type='submit']");
await new Promise((resolve) => setTimeout(resolve, 180));
}
defineScenario("한남자리 공개 기능", "분석 전 다섯 조건과 범위 무결성", 3, async (t) => {
const opened = await openChromium(estatePublic);
try {
const fields = await opened.page.$eval("[data-condition-form]", (form) => [...form.elements]
.filter((element) => element.name)
.map((element) => ({ name: element.name, visible: element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0 })));
t.check("보이는 의사결정 필드 집합", ESTATE_DECISION_FIELDS, fields, (actual, expected) => actual.length === 5
&& actual.every((item) => item.visible)
&& actual.map((item) => item.name).sort().join(",") === expected.join(","));
t.check("분석 전 briefing matrix dossier 미실행 상태", "empty visible / groups hidden / five 분석 후 notes / no 적합 claim", await opened.page.evaluate(() => ({
emptyVisible: !document.querySelector("[data-analysis-empty]")?.hidden,
groupsHidden: document.querySelector("[data-analysis-groups]")?.hidden,
listItems: document.querySelectorAll("[data-fit-list] li, [data-adjust-list] li, [data-check-list] li").length,
analysisHome: document.querySelector("[data-analysis-home]")?.textContent.trim(),
analysisText: document.querySelector("[data-analysis-result]")?.textContent,
matrixNotes: [...document.querySelectorAll("[data-matrix-trade-note], [data-matrix-price-note], [data-matrix-area-note], [data-matrix-parking-note], [data-matrix-move-note]")].map((item) => item.textContent.trim()),
dossierFit: document.querySelector("[data-dossier-fit]")?.textContent.trim(),
})), (actual) => actual.emptyVisible
&& actual.groupsHidden
&& actual.listItems === 0
&& actual.analysisHome === "아직 분석하지 않았습니다"
&& !actual.analysisText.includes("적합")
&& actual.matrixNotes.length === 5
&& actual.matrixNotes.every((item) => item.startsWith("분석 후"))
&& actual.dossierFit.includes("브리핑을 실행하면"));
t.check("공개 범위 고지와 금지 토큰", "scoped 예시 labels / every image 연출 / footer limits / no legacy tokens", await opened.page.evaluate(() => {
const markup = document.documentElement.outerHTML;
const forbidden = ["data-map", "map-marker", "PUBLIC_REAL_AD", "fixture", "demo-0.3", "DEMO-HJ"].filter((token) => markup.includes(token));
const imageFigures = [...document.querySelectorAll("figure[data-image-frame]")].filter((figure) => figure.querySelector("img"));
return {
forbidden,
candidateScopes: [...document.querySelectorAll("#homes .eyebrow")].filter((item) => item.textContent.includes("예시")).length,
dossierScopes: [...document.querySelectorAll("#dossier .eyebrow")].filter((item) => item.textContent.includes("선택한 예시")).length,
imageCount: imageFigures.length,
imageProvenance: imageFigures.map((figure) => figure.querySelector("figcaption")?.textContent.trim()),
demoStrip: document.querySelector(".demo-strip")?.textContent.trim(),
limitItems: document.querySelectorAll(".masthead-note > div").length,
footer: document.querySelector(".site-footer p")?.textContent.trim(),
};
}), (actual) => actual.forbidden.length === 0
&& actual.candidateScopes === 1
&& actual.dossierScopes === 1
&& actual.imageCount >= 4
&& actual.imageProvenance.every((item) => item?.includes("연출 이미지"))
&& actual.demoStrip.includes("상담은 접수되지 않습니다")
&& actual.limitItems === 2
&& actual.footer.includes("거래 서비스를 제공하지 않습니다"));
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 공개 기능", "매매 다섯 조건 briefing compare와 시각 증거", 3, async (t) => {
const opened = await openChromium(estatePublic);
try {
await setEstateConditions(opened.page, { trade: "sale", budget: 20, minArea: 130, moveIn: "2026-12-01", parking: 3 });
await submitEstate(opened.page);
t.check("브리핑 세 묶음 생성", "empty hidden / groups visible / all lists populated", await opened.page.evaluate(() => ({
emptyHidden: document.querySelector("[data-analysis-empty]")?.hidden,
groupsVisible: !document.querySelector("[data-analysis-groups]")?.hidden,
counts: ["[data-fit-list]", "[data-adjust-list]", "[data-check-list]"].map((selector) => document.querySelector(selector)?.children.length),
home: document.querySelector("[data-analysis-home]")?.textContent.trim(),
})), (actual) => actual.emptyHidden && actual.groupsVisible && actual.counts.every((count) => count > 0) && actual.home === "정원형 연립주택 브리핑");
t.check("선택 후보와 다섯 조건 compare dossier 동기화", "home-1 / 5 resolved matrix notes / dossier recomputed", await opened.page.evaluate(() => ({
selected: document.querySelector('[data-select-listing][aria-pressed="true"]')?.dataset.selectListing,
matrixValues: [...document.querySelectorAll("[data-matrix-trade], [data-matrix-price], [data-matrix-area], [data-matrix-parking], [data-matrix-move]")].map((item) => item.textContent.trim()),
matrixNotes: [...document.querySelectorAll("[data-matrix-trade-note], [data-matrix-price-note], [data-matrix-area-note], [data-matrix-parking-note], [data-matrix-move-note]")].map((item) => item.textContent.trim()),
dossier: document.querySelector("[data-dossier-title]")?.textContent.trim(),
dossierFit: document.querySelector("[data-dossier-fit]")?.textContent.trim(),
})), (actual) => actual.selected === "home-1"
&& actual.matrixValues.length === 5
&& actual.matrixNotes.length === 5
&& actual.matrixNotes.every((item) => !item.startsWith("분석 후"))
&& actual.dossier === "정원형 연립주택"
&& !actual.dossierFit.includes("브리핑을 실행하면"));
const shot = await captureEvidence(opened.page, "estate-ai-adjusted-1440", 1440, 900, { focusSelector: ".briefing-sheet" });
t.check("새 briefing compare 시각 증거", "exists / 1440x900 / >10KB / sha256", shot, (actual) => actual.pass);
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 공개 기능", "전세 예산 면적 입주 주차 변형", 3, async (t) => {
const opened = await openChromium(estatePublic);
try {
await setEstateConditions(opened.page, { trade: "jeonse", budget: 21, minArea: 145, moveIn: "2026-12-01", parking: 3 });
await submitEstate(opened.page);
t.check("전세 후보와 서류철 선택", "home-2 / 복층형 공동주택", await opened.page.evaluate(() => ({
selected: document.querySelector('[data-select-listing][aria-pressed="true"]')?.dataset.selectListing,
analysis: document.querySelector("[data-analysis-home]")?.textContent.trim(),
dossier: document.querySelector("[data-dossier-title]")?.textContent.trim(),
})), (actual) => actual.selected === "home-2" && actual.analysis === "복층형 공동주택 브리핑" && actual.dossier === "복층형 공동주택");
t.check("다섯 입력만 의사결정 주장에 반영", "trade/price/area/parking/move and no commute/sunlight", await opened.page.evaluate(() => ({
titles: [...document.querySelectorAll("[data-fit-list] strong, [data-adjust-list] strong")].map((item) => item.textContent.trim()),
text: document.querySelector("[data-analysis-groups]")?.textContent,
})), (actual) => ["거래 형태", "가격 상한", "전용면적", "주차", "입주 시점"].every((title) => actual.titles.includes(title))
&& !/통근|이동 시간|일조|남향/.test(actual.text));
t.check("전세 다섯 조건 matrix 판정", "same / +1억 / -2.20㎡ / -1 car / late", await opened.page.$$eval("[data-matrix-trade-note], [data-matrix-price-note], [data-matrix-area-note], [data-matrix-parking-note], [data-matrix-move-note]", (items) => items.map((item) => item.textContent.trim())), (actual) => actual.length === 5
&& actual[0] === "입력과 같음"
&& actual[1].includes("예산보다 1억 높음")
&& actual[2].includes("2.20㎡ 작음")
&& actual[3].includes("1대 부족")
&& actual[4] === "희망일보다 늦음");
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 공개 기능", "월세 보증금과 월 납부 분리", 3, async (t) => {
const opened = await openChromium(estatePublic);
try {
await setEstateConditions(opened.page, { trade: "monthly", budget: 6, minArea: 120, moveIn: "2026-12-20", parking: 1 });
await submitEstate(opened.page);
t.check("월세 후보와 서류철 선택", "home-3 / 테라스형 다세대주택", await opened.page.evaluate(() => ({
selected: document.querySelector('[data-select-listing][aria-pressed="true"]')?.dataset.selectListing,
analysis: document.querySelector("[data-analysis-home]")?.textContent.trim(),
dossier: document.querySelector("[data-dossier-title]")?.textContent.trim(),
})), (actual) => actual.selected === "home-3" && actual.analysis === "테라스형 다세대주택 브리핑" && actual.dossier === "테라스형 다세대주택");
t.check("보증금만으로 가격 적합 처리 금지", "no price fit / 월 납부 조건 in adjust", await opened.page.evaluate(() => ({
fitTitles: [...document.querySelectorAll("[data-fit-list] strong")].map((item) => item.textContent.trim()),
adjustTitles: [...document.querySelectorAll("[data-adjust-list] strong")].map((item) => item.textContent.trim()),
adjustText: document.querySelector("[data-adjust-list]")?.textContent,
})), (actual) => !actual.fitTitles.some((title) => /가격|보증금/.test(title))
&& actual.adjustTitles.includes("월 납부 조건")
&& actual.adjustText.includes("월 1,200만 원을 별도로"));
t.check("월세 matrix dossier에 별도 확인 유지", "deposit + monthly / monthly note in matrix and dossier", await opened.page.evaluate(() => ({
price: document.querySelector("[data-matrix-price]")?.textContent.trim(),
note: document.querySelector("[data-matrix-price-note]")?.textContent.trim(),
dossierRisk: document.querySelector("[data-dossier-risk]")?.textContent,
})), (actual) => actual.price.includes("보증금 5억")
&& actual.price.includes("월 1,200만")
&& actual.note.includes("월 납부 별도")
&& actual.dossierRisk.includes("월 1,200만 원을 별도로"));
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 공개 기능", "세 후보 장면 matrix dossier 상담 동기화", 3, async (t) => {
const opened = await openChromium(estatePublic);
const expectedHomes = [
{ id: "home-1", name: "정원형 연립주택", image: "hannam-interior-1200.webp", matrix: ["매매", "38억 원", "118.42㎡", "2대", "2026.12.20 이후 협의"] },
{ id: "home-2", name: "복층형 공동주택", image: "hannam-evening-1600.webp", matrix: ["전세", "22억 원", "142.80㎡", "2대", "2027.01.15 이후 협의"] },
{ id: "home-3", name: "테라스형 다세대주택", image: "hannam-interior-1200.webp", matrix: ["월세", "보증금 5억 · 월 1,200만", "140.84㎡", "1대", "2026.11.01 이후 협의"] },
];
try {
await submitEstate(opened.page);
for (const expected of expectedHomes) {
await opened.page.click(`[data-select-listing="${expected.id}"]`);
const snapshot = await opened.page.evaluate(() => ({
selected: document.querySelector('[data-select-listing][aria-pressed="true"]')?.dataset.selectListing,
selectedCount: document.querySelectorAll('[data-select-listing][aria-pressed="true"]').length,
scene: document.querySelector("[data-compare-name]")?.textContent.trim(),
image: document.querySelector("[data-compare-image]")?.getAttribute("src"),
imageAlt: document.querySelector("[data-compare-image]")?.getAttribute("alt"),
matrix: [...document.querySelectorAll("[data-matrix-trade], [data-matrix-price], [data-matrix-area], [data-matrix-parking], [data-matrix-move]")].map((item) => item.textContent.trim()),
dossier: document.querySelector("[data-dossier-title]")?.textContent.trim(),
specs: [...document.querySelectorAll("[data-spec-strip] dd")].map((item) => item.textContent.trim()),
consult: document.querySelector("[data-consult-home]")?.textContent.trim(),
}));
t.check(`${expected.name} 전체 표면 동기화`, `${expected.id} / image / five matrix / dossier / consult`, { snapshot, expected }, (actual) => actual.snapshot.selected === actual.expected.id
&& actual.snapshot.selectedCount === 1
&& actual.snapshot.scene === actual.expected.name
&& actual.snapshot.image.endsWith(actual.expected.image)
&& actual.snapshot.imageAlt.includes("표현한 이미지")
&& actual.snapshot.matrix.join("|") === actual.expected.matrix.join("|")
&& actual.snapshot.dossier === actual.expected.name
&& actual.snapshot.specs.join("|") === actual.expected.matrix.join("|")
&& actual.snapshot.consult === actual.expected.name);
}
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 공개 기능", "면적과 가격 단위 왕복", 3, async (t) => {
const opened = await openChromium(estatePublic);
try {
await opened.page.click('[data-area-unit="pyeong"]');
t.check("평 단위 전체 비교 표면", "pressed / 3 candidate areas / matrix and dossier 35.8평", await opened.page.evaluate(() => ({
pressed: document.querySelector('[data-area-unit="pyeong"]')?.getAttribute("aria-pressed"),
candidates: [...document.querySelectorAll(".candidate-content small")].map((item) => item.textContent.trim()),
matrix: document.querySelector("[data-matrix-area]")?.textContent.trim(),
spec: [...document.querySelectorAll("[data-spec-strip] dd")][2]?.textContent.trim(),
})), (actual) => actual.pressed === "true"
&& actual.candidates.length === 3
&& actual.candidates.every((item) => item.includes("평"))
&& actual.matrix === "35.8평"
&& actual.spec === "35.8평");
await opened.page.click('[data-price-unit="unit"]');
t.check("면적 단가 matrix dossier 반영", "unit pressed / both price values are per-pyeong", await opened.page.evaluate(() => ({
pressed: document.querySelector('[data-price-unit="unit"]')?.getAttribute("aria-pressed"),
matrix: document.querySelector("[data-matrix-price]")?.textContent.trim(),
spec: [...document.querySelectorAll("[data-spec-strip] dd")][1]?.textContent.trim(),
})), (actual) => actual.pressed === "true" && actual.matrix.includes("평당") && actual.spec.includes("평당"));
await opened.page.click('[data-area-unit="m2"]');
await opened.page.click('[data-price-unit="total"]');
t.check("총액과 제곱미터 복원", "m2 and total pressed / 118.42㎡ / 38억 원", await opened.page.evaluate(() => ({
areaPressed: document.querySelector('[data-area-unit="m2"]')?.getAttribute("aria-pressed"),
pricePressed: document.querySelector('[data-price-unit="total"]')?.getAttribute("aria-pressed"),
area: document.querySelector("[data-matrix-area]")?.textContent.trim(),
price: document.querySelector("[data-matrix-price]")?.textContent.trim(),
})), (actual) => actual.areaPressed === "true" && actual.pricePressed === "true" && actual.area === "118.42㎡" && actual.price === "38억 원");
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 공개 기능", "서류철 다섯 탭 클릭과 키보드", 3, async (t) => {
const opened = await openChromium(estatePublic);
const tabState = () => opened.page.evaluate(() => ({
selected: document.querySelector('[data-dossier-tab][aria-selected="true"]')?.dataset.dossierTab,
selectedCount: document.querySelectorAll('[data-dossier-tab][aria-selected="true"]').length,
visible: [...document.querySelectorAll("[data-dossier-panel]")].filter((panel) => !panel.hidden).map((panel) => panel.dataset.dossierPanel),
focus: document.activeElement?.dataset?.dossierTab || null,
tabStops: [...document.querySelectorAll("[data-dossier-tab]")].filter((tab) => tab.tabIndex === 0).map((tab) => tab.dataset.dossierTab),
}));
try {
await opened.page.click('[data-dossier-tab="price"]');
t.check("가격 탭 클릭 상태", "price selected/focused/sole visible/tabstop", await tabState(), (actual) => actual.selected === "price"
&& actual.focus === "price"
&& actual.selectedCount === 1
&& actual.visible.join() === "price"
&& actual.tabStops.join() === "price");
const trail = [];
for (const key of ["ArrowRight", "ArrowRight", "ArrowRight", "Home"]) {
await opened.page.keyboard.press(key);
trail.push(await tabState());
}
t.check("화살표와 Home 키보드 순환", "rights → location → facility → basic with focus", trail, (actual) => actual.map((item) => item.selected).join(",") === "rights,location,facility,basic"
&& actual.every((item) => item.focus === item.selected && item.visible.join() === item.selected && item.tabStops.join() === item.selected));
t.check("다섯 탭 aria-controls 관계", "5 unique tabs map to 5 panels", await opened.page.$$eval("[data-dossier-tab]", (tabs) => tabs.map((tab) => ({
name: tab.dataset.dossierTab,
controls: tab.getAttribute("aria-controls"),
panel: document.querySelector(`#${tab.getAttribute("aria-controls")}`)?.dataset.dossierPanel,
role: tab.getAttribute("role"),
}))), (actual) => actual.length === 5
&& new Set(actual.map((item) => item.name)).size === 5
&& actual.every((item) => item.role === "tab" && item.panel === item.name));
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 공개 기능", "다섯 조건 저장과 상담 무부작용", 3, async (t) => {
const opened = await openChromium(estatePublic);
const externalMutations = [];
opened.page.on("request", (request) => {
if (!["GET", "HEAD"].includes(request.method()) || ["fetch", "xhr", "websocket"].includes(request.resourceType())) externalMutations.push(`${request.method()} ${request.url()}`);
});
try {
await setEstateConditions(opened.page, { trade: "monthly", budget: 31, minArea: 135, moveIn: "2027-02-01", parking: 1 });
await opened.page.click("[data-save-condition]");
const saved = await opened.page.evaluate(() => JSON.parse(localStorage.getItem("hannam-jari-conditions-v2")));
t.check("로컬 저장은 다섯 필드만", "exact fields and typed values / no network mutation", { saved, externalMutations }, (actual) => Object.keys(actual.saved || {}).sort().join(",") === ESTATE_DECISION_FIELDS.join(",")
&& actual.saved.trade === "monthly"
&& actual.saved.budget === 31
&& actual.saved.minArea === 135
&& actual.saved.moveIn === "2027-02-01"
&& actual.saved.parking === 1
&& actual.externalMutations.length === 0);
await opened.page.reload({ waitUntil: "domcontentloaded" });
await settle(opened.page);
t.check("새로고침 뒤 다섯 입력 복원", "monthly / 31 / 135 / 2027-02-01 / 1", await opened.page.$eval("[data-condition-form]", (form) => ({
trade: form.elements.trade.value,
budget: form.elements.budget.value,
minArea: form.elements.minArea.value,
moveIn: form.elements.moveIn.value,
parking: form.elements.parking.value,
})), (actual) => actual.trade === "monthly" && actual.budget === "31" && actual.minArea === "135" && actual.moveIn === "2027-02-01" && actual.parking === "1");
await opened.page.click("[data-open-consult]");
await opened.page.click("[data-finish-consult]");
const finished = await textOf(opened.page, "[data-consult-content]");
await opened.page.$eval("[data-consult-dialog]", (element) => element.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await new Promise((resolve) => setTimeout(resolve, 80));
const outside = await opened.page.evaluate(() => ({ open: document.querySelector("[data-consult-dialog]")?.open, locked: document.body.classList.contains("is-locked"), focus: document.activeElement?.matches("[data-open-consult]") }));
await opened.page.click("[data-open-consult]");
await opened.page.keyboard.press("Escape");
await new Promise((resolve) => setTimeout(resolve, 80));
const escape = await opened.page.evaluate(() => ({
open: document.querySelector("[data-consult-dialog]")?.open,
locked: document.body.classList.contains("is-locked"),
focus: document.activeElement?.matches("[data-open-consult]"),
storage: localStorage.getItem("hannam-jari-conditions-v2"),
}));
t.check("상담 완료 바깥 클릭 Escape와 초점 복귀", "preview only / both close / trigger focus / storage unchanged / no requests", { finished, outside, escape, saved: JSON.stringify(saved), externalMutations }, (actual) => actual.finished.includes("준비할 순서")
&& !actual.outside.open
&& !actual.outside.locked
&& actual.outside.focus
&& !actual.escape.open
&& !actual.escape.locked
&& actual.escape.focus
&& actual.escape.storage === actual.saved
&& actual.externalMutations.length === 0);
} finally { await closePage(opened.page); }
});
// 철판클럽 공개 10시나리오 · 30단언.
defineScenario("철판클럽 공개 기능", "메뉴 분류 필터", 3, async (t) => {
const opened = await openChromium(burgerPublic);
try {
await opened.page.click('[data-menu-filter="side"]');
t.equal("곁들임 필터 선택", "true", await opened.page.$eval('[data-menu-filter="side"]', (element) => element.getAttribute("aria-pressed")));
t.check("곁들임 두 항목만 노출", "2 side items", await opened.page.$$eval('[data-menu-category]:not([hidden])', (items) => items.map((item) => item.dataset.menuCategory)), (actual) => actual.length === 2 && actual.every((item) => item === "side"));
t.equal("필터 결과가 있어 빈 상태 숨김", true, await opened.page.$eval("#menu-empty", (element) => element.hidden));
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 공개 기능", "메뉴 빈 상태와 전체 복구", 3, async (t) => {
const opened = await openChromium(burgerPublic, { query: "?fixture=menu-empty" });
try {
t.ok("메뉴 빈 상태 노출", await visible(opened.page, "#menu-empty"));
t.equal("빈 상태에서 메뉴 0개", 0, await opened.page.$$eval('[data-menu-category]:not([hidden])', (items) => items.length));
await opened.page.click("[data-show-all-menu]");
await new Promise((resolve) => setTimeout(resolve, 80));
t.check("모든 메뉴 보기로 복구", "fixture removed and six items visible", await opened.page.evaluate(() => ({ query: location.search, count: document.querySelectorAll('[data-menu-category]:not([hidden])').length })), (actual) => !actual.query.includes("fixture") && actual.count === 6);
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 공개 기능", "매운 더블 프리셋", 3, async (t) => {
const opened = await openChromium(burgerPublic);
try {
await opened.page.click('[data-preset="spicy"]');
t.equal("매운 맛과 더블 동시 선택", 2, await opened.page.$$eval('input[name="flavor"][value="spicy"]:checked, input[name="patty"][value="2"]:checked', (items) => items.length));
t.equal("프리셋 이름 반영", "고추 피클 더블", await textOf(opened.page, "#summary-heading"));
t.includes("매운 조합 알레르기 반영", "토마토", await textOf(opened.page, "#summary-allergens"));
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 공개 기능", "세트 조합과 시각 증거", 3, async (t) => {
const opened = await openChromium(burgerPublic);
try {
await choose(opened.page, 'input[name="serving"][value="set"]');
t.check("세트 선택 뒤 곁들임 입력 활성", "side and drink enabled", await opened.page.$$eval("#set-options select", (items) => items.map((item) => item.disabled)), (actual) => actual.length === 2 && actual.every((item) => !item));
t.equal("세트 예시 총액 계산", "13,000원", await textOf(opened.page, "#summary-total"));
const shot = await captureEvidence(opened.page, "burger-builder-valid-1440", 1440, 900, { focusSelector: ".builder-layout" });
t.check("유효 버거 조합 시각 증거", "1440x900, >10KB, sha256", shot, (actual) => actual.pass);
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 공개 기능", "재료 조정과 알레르기 재계산", 3, async (t) => {
const opened = await openChromium(burgerPublic);
try {
await opened.page.select("#cheese", "0");
await opened.page.select('select[name="onion"]', "extra");
await opened.page.select('select[name="sauce"]', "side");
t.check("재료 문장 동기화", "no cheese / extra onion / sauce side", await textOf(opened.page, "#summary-lines"), (actual) => actual.includes("치즈 없음") && actual.includes("양파 많이") && actual.includes("소스 따로"));
t.check("우유 알레르기 제거", "does not contain 우유", await textOf(opened.page, "#summary-allergens"), (actual) => !actual.includes("우유"));
t.equal("재료 변경 총액 계산", "8,500원", await textOf(opened.page, "#summary-total"));
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 공개 기능", "영업 종료 매장 선택 경계", 3, async (t) => {
const opened = await openChromium(burgerPublic);
try {
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("가상 매장 안내 행동", "#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); }
});
defineScenario("철판클럽 공개 기능", "매장 검색 일치", 3, async (t) => {
const opened = await openChromium(burgerPublic);
try {
await setValue(opened.page, "#store-search", "연남");
t.equal("검색 결과 한 매장", 1, await opened.page.$$eval('[data-store]:not([hidden])', (items) => items.length));
t.equal("검색 결과와 지도 핀 일치", "yeonnam", await opened.page.$eval('[data-map-store]:not([hidden])', (element) => element.dataset.mapStore));
t.equal("검색어가 있어 지우기 노출", false, await opened.page.$eval("#clear-store-search", (element) => element.hidden));
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 공개 기능", "매장 검색 빈 결과와 복구", 3, async (t) => {
const opened = await openChromium(burgerPublic);
try {
await setValue(opened.page, "#store-search", "없는동네");
t.ok("매장 빈 상태 노출", await visible(opened.page, "#store-empty"));
t.equal("빈 검색 결과 0개", 0, await opened.page.$$eval('[data-store]:not([hidden])', (items) => items.length));
await opened.page.click("#reset-store-search");
t.check("검색 조건 초기화로 세 매장 복구", "blank query / 3 stores", await opened.page.evaluate(() => ({ query: document.querySelector("#store-search")?.value, count: document.querySelectorAll('[data-store]:not([hidden])').length })), (actual) => actual.query === "" && actual.count === 3);
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 공개 기능", "조합 결과 즉시 반영과 미저장 안내", 3, async (t) => {
const opened = await openChromium(burgerPublic);
try {
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);
try {
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); }
});
async function fillFranchiseStepOne(page) {
await setValue(page, "#demo-name", "예시점주");
await page.select("#region", "서울 동쪽");
await choose(page, 'input[name="areaKind"][value="주거지"]');
await page.click('[data-step="1"] [data-next]');
await new Promise((resolve) => setTimeout(resolve, 60));
}
async function fillFranchiseStepTwo(page, { possession = "협의 중", area = "99.2" } = {}) {
await choose(page, `input[name="storePossession"][value="${possession}"]`);
if (["협의 중", "보유함"].includes(possession)) await setValue(page, "#exclusive-area", area);
await page.select("#opening-window", "석 달에서 여섯 달");
await choose(page, 'input[name="experience"][value="없음"]');
await page.click('[data-step="2"] [data-next]');
await new Promise((resolve) => setTimeout(resolve, 60));
}
// 철판클럽 가맹 8시나리오 · 24단언.
defineScenario("철판클럽 가맹 기능", "첫 단계 필수값 오류", 3, async (t) => {
const opened = await openChromium(burgerFranchise);
try {
await opened.page.click('[data-step="1"] [data-next]');
t.check("첫 단계 오류 세 가지", "name / region / area kind", await opened.page.evaluate(() => ["#demo-name-error", "#region-error", "#area-kind-error"].map((selector) => document.querySelector(selector)?.textContent.trim())), (actual) => actual.length === 3 && actual.every(Boolean));
t.equal("첫 오류 입력으로 초점", "demo-name", await opened.page.evaluate(() => document.activeElement?.id));
t.equal("오류 뒤 첫 단계 유지", "1", await opened.page.$eval(".form-step.is-current", (element) => element.dataset.step));
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 가맹 기능", "첫 단계 정상 전환", 3, async (t) => {
const opened = await openChromium(burgerFranchise);
try {
await fillFranchiseStepOne(opened.page);
t.equal("두 번째 단계 노출", "2", await opened.page.$eval(".form-step.is-current", (element) => element.dataset.step));
t.equal("단계 표시 aria-current", "2", await opened.page.$eval('[data-step-indicator][aria-current="step"]', (element) => element.dataset.stepIndicator));
t.equal("두 번째 제목으로 초점 이동", "step-two-heading", await opened.page.evaluate(() => document.activeElement?.id));
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 가맹 기능", "점포 면적 조건부 환산", 3, async (t) => {
const opened = await openChromium(burgerFranchise);
try {
await fillFranchiseStepOne(opened.page);
await choose(opened.page, 'input[name="storePossession"][value="협의 중"]');
t.ok("협의 중이면 면적 입력 노출", await visible(opened.page, "#area-field"));
t.equal("면적 입력 필수 전환", true, await opened.page.$eval("#exclusive-area", (element) => element.required));
await setValue(opened.page, "#exclusive-area", "99.2");
t.includes("제곱미터와 평 환산 병기", "약 30평", await textOf(opened.page, "#area-conversion"));
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 가맹 기능", "두 번째 단계 오류와 복구", 3, async (t) => {
const opened = await openChromium(burgerFranchise);
try {
await fillFranchiseStepOne(opened.page);
await opened.page.click('[data-step="2"] [data-next]');
t.check("두 번째 단계 누락 오류", "possession / opening / experience", await opened.page.evaluate(() => ["#store-possession-error", "#opening-window-error", "#experience-error"].map((selector) => document.querySelector(selector)?.textContent.trim())), (actual) => actual.length === 3 && actual.every(Boolean));
await choose(opened.page, 'input[name="storePossession"][value="아직 없음"]');
await opened.page.select("#opening-window", "아직 모름");
await choose(opened.page, 'input[name="experience"][value="있음"]');
t.check("입력 뒤 오류 속성 해제", "all aria-invalid removed", await opened.page.$$eval('[data-step="2"] [aria-invalid="true"]', (items) => items.length), (actual) => actual === 0);
await opened.page.click('[data-step="2"] [data-next]');
t.equal("복구 뒤 검토 단계 진입", "3", await opened.page.$eval(".form-step.is-current", (element) => element.dataset.step));
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 가맹 기능", "검토 값과 시각 증거", 3, async (t) => {
const opened = await openChromium(burgerFranchise);
try {
await fillFranchiseStepOne(opened.page);
await fillFranchiseStepTwo(opened.page);
const review = await opened.page.evaluate(() => Object.fromEntries([...document.querySelectorAll("[data-review]")].map((element) => [element.dataset.review, element.textContent.trim()])));
t.check("검토 원장에 입력값 반영", "name / region / possession", review, (actual) => actual.demoName === "예시점주" && actual.region === "서울 동쪽" && actual.storePossession === "협의 중");
t.includes("검토 원장에 면적 환산", "약 30평", review.exclusiveArea);
const shot = await captureEvidence(opened.page, "burger-franchise-review-1440", 1440, 900, { focusSelector: '[data-step="3"]' });
t.check("가맹 검토 단계 시각 증거", "1440x900, >10KB, sha256", shot, (actual) => actual.pass);
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 가맹 기능", "문의 체험 동의 오류", 3, async (t) => {
const opened = await openChromium(burgerFranchise);
try {
await fillFranchiseStepOne(opened.page);
await fillFranchiseStepTwo(opened.page, { possession: "아직 없음" });
await opened.page.click("#submit-inquiry");
t.equal("동의 입력 aria-invalid", "true", await opened.page.$eval("#demo-consent", (element) => element.getAttribute("aria-invalid")));
t.includes("동의 오류 이유", "확인해 주세요", await textOf(opened.page, "#demo-consent-error"));
t.equal("동의 입력으로 초점", "demo-consent", await opened.page.evaluate(() => document.activeElement?.id));
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 가맹 기능", "문의 준비 완료와 다시 시작", 3, async (t) => {
const opened = await openChromium(burgerFranchise);
try {
await fillFranchiseStepOne(opened.page);
await fillFranchiseStepTwo(opened.page, { possession: "아직 없음" });
await opened.page.click("#demo-consent");
await opened.page.click("#submit-inquiry");
await new Promise((resolve) => setTimeout(resolve, 520));
t.check("성공 패널로 전환", "success visible / form hidden", await opened.page.evaluate(() => ({ success: !document.querySelector("#inquiry-success")?.hidden, form: document.querySelector("#franchise-form")?.hidden })), (actual) => actual.success && actual.form);
await opened.page.click("#review-again");
t.equal("입력 내용 다시 보기 3단계", "3", await opened.page.$eval(".form-step.is-current", (element) => element.dataset.step));
await opened.page.click("#submit-inquiry");
await new Promise((resolve) => setTimeout(resolve, 520));
await opened.page.click("#restart-form");
t.check("처음부터 보기 입력 초기화", "step 1 / blank name", await opened.page.evaluate(() => ({ step: document.querySelector(".form-step.is-current")?.dataset.step, name: document.querySelector("#demo-name")?.value })), (actual) => actual.step === "1" && actual.name === "");
} finally { await closePage(opened.page); }
});
defineScenario("철판클럽 가맹 기능", "문의 준비 실패와 재시도", 3, async (t) => {
const opened = await openChromium(burgerFranchise, { query: "?fixture=inquiry-fail" });
try {
await fillFranchiseStepOne(opened.page);
await fillFranchiseStepTwo(opened.page, { possession: "아직 없음" });
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.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); }
});
// 한남자리 관리자 16시나리오 · 48단언.
defineScenario("한남자리 관리자 기능", "초기 선택과 조사관 동기화", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
t.check("첫 행 선택 상태", "DEMO-HJ-101 selected / aria-selected true", await opened.page.evaluate(() => {
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("상세 정보 제목과 식별자 동기화", "한남 생활권 정원 주거 / 자료번호 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("자료번호 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건");
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "행 선택 전환", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await opened.page.click("[data-close-inspector]");
const closed = await opened.page.evaluate(() => {
const workbench = document.querySelector(".admin-workbench")?.getBoundingClientRect();
const queue = document.querySelector("[data-queue-panel]")?.getBoundingClientRect();
return {
inspectorOpen: document.querySelector("[data-inspector]")?.classList.contains("is-open"),
selectedCount: document.querySelectorAll("[data-queue-row].is-selected").length,
rightEdgeDiff: workbench && queue ? Math.abs(workbench.right - queue.right) : null,
};
});
await opened.page.click('[data-queue-row][data-id="DEMO-HJ-102"] [data-select-row]');
const restored = await opened.page.evaluate(() => {
const workbench = document.querySelector(".admin-workbench")?.getBoundingClientRect();
const queue = document.querySelector("[data-queue-panel]")?.getBoundingClientRect();
const inspector = document.querySelector("[data-inspector]")?.getBoundingClientRect();
return {
inspectorOpen: document.querySelector("[data-inspector]")?.classList.contains("is-open"),
gap: queue && inspector ? inspector.left - queue.right : null,
inspectorRightDiff: workbench && inspector ? Math.abs(workbench.right - inspector.right) : null,
};
});
t.check("닫힌 단일 대기열과 행 선택 뒤 분할 복구", "closed edge <=2px / open split 0..20px", { closed, restored }, (actual) => !actual.closed.inspectorOpen
&& actual.closed.selectedCount === 0
&& actual.closed.rightEdgeDiff <= 2
&& actual.restored.inspectorOpen
&& actual.restored.gap >= 0
&& actual.restored.gap <= 20
&& actual.restored.inspectorRightDiff <= 2);
t.check("새 행만 선택", "102 true / 101 false", await opened.page.evaluate(() => ({
next: document.querySelector('[data-queue-row][data-id="DEMO-HJ-102"]')?.getAttribute("aria-selected"),
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(() => ({
title: document.querySelector("[data-inspector-title]")?.textContent.trim(),
status: document.querySelector("[data-inspector-status]")?.textContent.trim(),
})), (actual) => actual.title === "한강 가까운 복층 주거" && actual.status === "예시 공개");
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "검색 일치 결과", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
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(() => ({
count: document.querySelector("[data-result-count]")?.textContent.trim(),
empty: document.querySelector("[data-queue-empty]")?.hidden,
})), (actual) => actual.count === "1건" && actual.empty);
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "검색 빈 상태와 초기화 복구", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await setValue(opened.page, "[data-queue-search]", "없는 매물");
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("현재 조건"));
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(() => ({
query: document.querySelector("[data-queue-search]")?.value,
status: document.querySelector("[data-status-filter]")?.value,
rows: document.querySelectorAll("[data-queue-row]:not(.is-hidden)").length,
})), (actual) => actual.query === "" && actual.status === "all" && actual.rows === 6);
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "검수 필요 상태 필터", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await opened.page.select("[data-status-filter]", "review");
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]"));
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "매물명 오름차순 정렬", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await opened.page.select("[data-sort-select]", "title-asc");
const order = await opened.page.$$eval("[data-queue-row]", (rows) => rows.map((row) => row.dataset.title));
t.equal("정렬 뒤 원장 보존", 6, order.length);
t.check("한국어 매물명 오름차순", "every previous <= next", order, (actual) => actual.every((title, index) => index === 0 || actual[index - 1].localeCompare(title, "ko") <= 0));
t.includes("정렬 변경 라이브 안내", "정렬을 바꿨습니다", await textOf(opened.page, "[data-admin-live]"));
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "불러오는 중과 정상 복구", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await opened.page.select("[data-state-simulator]", "loading");
t.check("대기열 로딩 상태", "aria-busy true / loading visible", await opened.page.evaluate(() => ({
busy: document.querySelector("[data-queue-panel]")?.getAttribute("aria-busy"),
loading: !document.querySelector("[data-queue-loading]")?.hidden,
})), (actual) => actual.busy === "true" && actual.loading);
t.includes("로딩 라이브 안내", "불러오는 중", await textOf(opened.page, "[data-admin-live]"));
await opened.page.select("[data-state-simulator]", "normal");
t.check("정상 선택으로 로딩 종료", "aria-busy false / loading hidden / 6 rows", await opened.page.evaluate(() => ({
busy: document.querySelector("[data-queue-panel]")?.getAttribute("aria-busy"),
loading: document.querySelector("[data-queue-loading]")?.hidden,
rows: document.querySelectorAll("[data-queue-row]:not(.is-hidden)").length,
})), (actual) => actual.busy === "false" && actual.loading && actual.rows === 6);
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "강제 빈 대기열과 복구", 3, async (t) => {
const opened = await openChromium(estateAdmin);
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]"));
await opened.page.select("[data-state-simulator]", "normal");
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건");
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "불러오기 실패와 마지막 자료 복구", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await opened.page.select("[data-state-simulator]", "error");
t.check("불러오기 실패 배너", "visible / 대기열 일부", await opened.page.evaluate(() => ({
visible: !document.querySelector("[data-system-banner]")?.hidden,
title: document.querySelector("[data-banner-title]")?.textContent.trim(),
})), (actual) => actual.visible && actual.title === "대기열 일부를 불러오지 못했습니다.");
t.equal("실패 중 마지막 여섯 행 유지", 6, await opened.page.$$eval("[data-queue-row]:not(.is-hidden)", (rows) => rows.length));
await opened.page.select("[data-state-simulator]", "normal");
t.check("정상 선택으로 실패 배너 해제", "banner hidden / normal state", await opened.page.evaluate(() => ({
hidden: document.querySelector("[data-system-banner]")?.hidden,
state: document.querySelector("[data-state-simulator]")?.value,
})), (actual) => actual.hidden && actual.state === "normal");
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "자료 오래됨과 정상 복구", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await opened.page.select("[data-state-simulator]", "stale");
t.check("자료 오래됨 차단 상태", "body stale / banner visible", await opened.page.evaluate(() => ({
stale: document.body.classList.contains("is-stale-demo"),
banner: !document.querySelector("[data-system-banner]")?.hidden,
})), (actual) => actual.stale && actual.banner);
t.equal("조사관 오래됨 상태", "자료 오래됨", await textOf(opened.page, "[data-inspector-status]"));
await opened.page.select("[data-state-simulator]", "normal");
t.check("정상 선택으로 오래됨 전역 상태 해제", "body normal / banner hidden", await opened.page.evaluate(() => ({
stale: document.body.classList.contains("is-stale-demo"),
hidden: document.querySelector("[data-system-banner]")?.hidden,
})), (actual) => !actual.stale && actual.hidden);
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "버전 충돌 저장 실패와 정상 재시도", 3, async (t) => {
const opened = await openChromium(estateAdmin);
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.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]"));
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "권한 제한과 정상 복구", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await opened.page.select("[data-state-simulator]", "permission");
t.check("권한 제한 행동 비활성", "review/save/draft disabled", await opened.page.$$eval("[data-review-ai], [data-simulate-save], [data-new-draft]", (buttons) => buttons.map((button) => button.disabled)), (actual) => actual.length === 3 && actual.every(Boolean));
t.check("권한 제한 근거 배너", "body limited / permission title", await opened.page.evaluate(() => ({
limited: document.body.classList.contains("is-permission-limited"),
title: document.querySelector("[data-banner-title]")?.textContent.trim(),
})), (actual) => actual.limited && actual.title.includes("권한"));
await opened.page.select("[data-state-simulator]", "normal");
t.check("정상 선택으로 행동 복구", "body normal / all enabled", await opened.page.evaluate(() => ({
limited: document.body.classList.contains("is-permission-limited"),
disabled: [...document.querySelectorAll("[data-review-ai], [data-simulate-save], [data-new-draft]")].filter((button) => button.disabled).length,
})), (actual) => !actual.limited && actual.disabled === 0);
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "키보드 탭과 첫 누락 초점", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await opened.page.focus("#tab-basic");
await opened.page.keyboard.press("ArrowRight");
t.check("오른쪽 화살표로 표시 광고 탭 활성", "legal selected / focused", await opened.page.evaluate(() => ({
selected: document.querySelector("#tab-legal")?.getAttribute("aria-selected"),
focused: document.activeElement?.id,
})), (actual) => actual.selected === "true" && actual.focused === "tab-legal");
t.check("탭과 패널 상태 동기화", "legal visible / basic hidden", await opened.page.evaluate(() => ({
legal: document.querySelector("#inspector-legal")?.hidden,
basic: document.querySelector("#inspector-basic")?.hidden,
})), (actual) => !actual.legal && actual.basic);
await opened.page.click("[data-focus-missing]");
await new Promise((resolve) => setTimeout(resolve, 100));
t.equal("첫 누락 입력으로 초점", "missing-orientation", await opened.page.evaluate(() => document.activeElement?.id));
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "실제 광고 차단 대화상자와 시각 증거", 3, async (t) => {
const opened = await openChromium(estateAdmin, { width: 1440, height: 900 });
try {
await opened.page.click("[data-block-publish]");
t.check("게시 차단 대화상자와 배경 잠금", "dialog open / body locked / inspector open", await opened.page.evaluate(() => ({
open: document.querySelector("[data-block-dialog]")?.open,
locked: document.body.classList.contains("is-locked"),
inspector: document.querySelector("[data-inspector]")?.classList.contains("is-open"),
})), (actual) => actual.open && actual.locked && actual.inspector);
const shot = await captureEvidence(opened.page, "estate-admin-block-dialog-1440", 1440, 900);
t.check("게시 차단 조사관 시각 증거", "exists / 1440x900 / >10KB / sha256", shot, (actual) => actual.pass);
await opened.page.keyboard.press("Escape");
await new Promise((resolve) => setTimeout(resolve, 100));
t.check("대화상자 닫힘과 선택 상태 동시 해제", "dialog closed / unlocked / inspector closed / no selected row", await opened.page.evaluate(() => ({
open: document.querySelector("[data-block-dialog]")?.open,
locked: document.body.classList.contains("is-locked"),
inspector: document.querySelector("[data-inspector]")?.classList.contains("is-open"),
selectedRows: document.querySelectorAll("[data-queue-row].is-selected").length,
})), (actual) => !actual.open && !actual.locked && !actual.inspector && actual.selectedRows === 0);
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "AI 주장 검토 완료", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await opened.page.click('[data-tab="ai"]');
await opened.page.click('[data-claim-action="edit"]');
t.check("지원되지 않은 주장 수정 반영", "disabled / 수정 반영", await opened.page.$eval('[data-claim-action="edit"]', (button) => ({ disabled: button.disabled, text: button.textContent.trim() })), (actual) => actual.disabled && actual.text === "수정 반영");
await opened.page.click("[data-review-ai]");
t.check("사람 검토 완료 상태", "disabled / 수정 후 승인됨 / pass badge", await opened.page.evaluate(() => ({
disabled: document.querySelector("[data-review-ai]")?.disabled,
text: document.querySelector("[data-review-ai]")?.textContent.trim(),
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]"));
} finally { await closePage(opened.page); }
});
defineScenario("한남자리 관리자 기능", "새 예시 초안 생성", 3, async (t) => {
const opened = await openChromium(estateAdmin);
try {
await opened.page.click("[data-new-draft]");
t.check("초안 행 한 번만 추가", "7 rows / DEMO-HJ-NEW once", await opened.page.evaluate(() => ({
rows: document.querySelectorAll("[data-queue-row]").length,
drafts: document.querySelectorAll('[data-queue-row][data-id="DEMO-HJ-NEW"]').length,
})), (actual) => actual.rows === 7 && actual.drafts === 1);
t.check("새 초안 선택과 조사관 유지", "draft selected / inspector open", await opened.page.evaluate(() => ({
selected: document.querySelector('[data-queue-row][data-id="DEMO-HJ-NEW"]')?.getAttribute("aria-selected"),
inspector: document.querySelector("[data-inspector]")?.classList.contains("is-open"),
})), (actual) => actual.selected === "true" && actual.inspector);
t.check("새 초안 조사관 기본값", "title / 입력 전 / 7개 예시", await opened.page.evaluate(() => ({
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건");
} finally { await closePage(opened.page); }
});
function markdownCell(value) {
return String(value).replaceAll("|", "\\|").replaceAll("\n", "<br>");
}
function makeReport({ results, startedAt, finishedAt, setupError, contract }) {
const failedScenarios = results.filter((result) => !result.pass).length;
const passedAssertions = results.flatMap((result) => result.assertions).filter((assertion) => assertion.pass).length;
const actualAssertions = results.reduce((sum, result) => sum + result.assertions.length, 0);
const lines = [
"# Showcase Duo E2E report",
"",
`- root: \`${rootName}\``,
`- started: ${startedAt}`,
`- finished: ${finishedAt}`,
`- scenarios: ${results.length}/${EXPECTED_SCENARIOS}`,
`- assertions: ${actualAssertions}/${EXPECTED_ASSERTIONS} (${passedAssertions} passed)`,
`- failed scenarios: ${failedScenarios}`,
`- visual evidence: ${evidence.length}/12`,
`- contract: ${contract ? "PASS" : "FAIL"}`,
];
if (setupError) lines.push(`- setup error: ${markdownCell(setupError.stack || setupError.message || setupError)}`);
lines.push("", "## Scenario results", "", "| result | suite | scenario | assertions |", "| --- | --- | --- | ---: |");
for (const result of results) {
lines.push(`| ${result.pass ? "PASS" : "FAIL"} | ${markdownCell(result.suite)} | ${markdownCell(result.name)} | ${result.assertions.length}/${result.expectedAssertions} |`);
}
lines.push("", "## Visual evidence", "", "| name | file | geometry | bytes | sha256 |", "| --- | --- | ---: | ---: | --- |");
for (const item of evidence) lines.push(`| ${item.name} | \`${item.file}\` | ${item.width}x${item.height} | ${item.bytes} | \`${item.sha256}\` |`);
lines.push("", "## Assertion ledger");
for (const result of results) {
lines.push("", `### ${result.pass ? "PASS" : "FAIL"} · ${result.suite} · ${result.name}`, "");
if (result.error) lines.push(`- runtime error: ${markdownCell(result.error.stack || result.error.message || result.error)}`);
for (const assertion of result.assertions) {
lines.push(`- ${assertion.pass ? "PASS" : "FAIL"} · ${assertion.name}`);
lines.push(` - expected: ${markdownCell(assertion.expected)}`);
lines.push(` - actual: ${markdownCell(assertion.actual)}`);
}
}
return `${lines.join("\n")}\n`;
}
async function listen() {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("Could not resolve local test server address.");
origin = `http://127.0.0.1:${address.port}`;
}
async function run() {
const startedAt = new Date().toISOString();
const results = [];
let setupError = null;
let contract = false;
try {
const plannedAssertions = scenarios.reduce((sum, scenario) => sum + scenario.expectedAssertions, 0);
if (scenarios.length !== EXPECTED_SCENARIOS || plannedAssertions !== EXPECTED_ASSERTIONS) {
throw new Error(`Definition contract mismatch: ${scenarios.length}/${EXPECTED_SCENARIOS} scenarios, ${plannedAssertions}/${EXPECTED_ASSERTIONS} assertions.`);
}
await fs.promises.mkdir(evidenceRoot, { recursive: true });
await listen();
chromiumBrowser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ["--disable-dev-shm-usage", "--no-sandbox"] });
webkitBrowser = await webkit.launch({ headless: true });
for (let index = 0; index < scenarios.length; index += 1) {
const definition = scenarios[index];
const context = new ScenarioContext(definition);
let error = null;
try {
await definition.run(context);
} catch (caught) {
error = caught;
}
const assertionCountPass = context.assertions.length === definition.expectedAssertions;
const pass = !error && assertionCountPass && context.assertions.every((assertion) => assertion.pass);
results.push({ ...definition, assertions: context.assertions, assertionCountPass, error, pass });
console.log(`${String(index + 1).padStart(2, "0")}/${EXPECTED_SCENARIOS} ${pass ? "PASS" : "FAIL"} ${definition.suite} · ${definition.name}`);
}
const assertions = results.flatMap((result) => result.assertions);
const expectedEvidenceNames = new Set([
...ROUTES.flatMap((route) => [390, 1440].map((width) => `${route.key}-${width}`)),
"estate-ai-adjusted-1440",
"estate-admin-block-dialog-1440",
"burger-builder-valid-1440",
"burger-franchise-review-1440",
]);
const evidenceNames = new Set(evidence.map((item) => item.name));
const evidenceByName = new Map(evidence.map((item) => [item.name, item]));
const interactionDiffs = [
["estate-ai-adjusted-1440", "estate-public-1440"],
["estate-admin-block-dialog-1440", "estate-admin-1440"],
["burger-builder-valid-1440", "burger-public-1440"],
["burger-franchise-review-1440", "burger-franchise-1440"],
].every(([interaction, base]) => evidenceByName.get(interaction)?.sha256 !== evidenceByName.get(base)?.sha256);
const evidencePass = evidence.length === 12
&& evidenceNames.size === 12
&& [...expectedEvidenceNames].every((name) => evidenceNames.has(name))
&& evidence.every((item) => item.exists && item.width > 0 && item.height > 0 && item.bytes > 10000 && /^[a-f0-9]{64}$/.test(item.sha256))
&& interactionDiffs;
contract = results.length === EXPECTED_SCENARIOS
&& assertions.length === EXPECTED_ASSERTIONS
&& results.every((result) => result.pass)
&& evidencePass;
} catch (caught) {
setupError = caught;
} finally {
await webkitBrowser?.close().catch(() => {});
await chromiumBrowser?.close().catch(() => {});
if (server.listening) await new Promise((resolve) => server.close(resolve));
const report = makeReport({ results, startedAt, finishedAt: new Date().toISOString(), setupError, contract });
await fs.promises.writeFile(reportFile, report, "utf8");
}
if (setupError) console.error(setupError);
console.log(`Showcase duo E2E: ${contract ? "PASS" : "FAIL"} · ${results.length}/${EXPECTED_SCENARIOS} scenarios · ${results.reduce((sum, result) => sum + result.assertions.length, 0)}/${EXPECTED_ASSERTIONS} assertions · ${evidence.length}/12 captures`);
console.log(`Report: ${reportFile}`);
if (!contract) process.exitCode = 1;
}
await run();