// 한남자리 공개·관리자 + 철판클럽 공개·가맹 전용 실제 브라우저 게이트. // 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 { 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 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", "
"); } 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();