1352 lines
88 KiB
JavaScript
1352 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("저장 행동 차단과 이유", "disabled / choose another store", await opened.page.$eval("#save-builder", (element) => ({ disabled: element.disabled, text: element.textContent.trim() })), (actual) => actual.disabled && 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.click("#save-builder");
|
|
const snapshot = await opened.page.evaluate(() => JSON.parse(sessionStorage.getItem("griddle-club-demo-selection")));
|
|
t.check("현재 탭에 조합 스냅샷 저장", "classic / patty 2 / seongsu", snapshot, (actual) => actual?.flavor === "classic" && actual?.patty === "2" && actual?.store === "seongsu");
|
|
t.includes("실제 주문 미전송 안내", "실제 주문으로 전송되지 않습니다", await textOf(opened.page, "#save-status"));
|
|
t.equal("성공 상태 오류 스타일 없음", false, await opened.page.$eval("#save-status", (element) => element.classList.contains("is-error")));
|
|
} finally { await closePage(opened.page); }
|
|
});
|
|
|
|
defineScenario("철판클럽 공개 기능", "조합 저장 실패와 입력 보존", 3, async (t) => {
|
|
const opened = await openChromium(burgerPublic, { query: "?fixture=save-fail" });
|
|
try {
|
|
await opened.page.select('select[name="onion"]', "extra");
|
|
await opened.page.click("#save-builder");
|
|
t.includes("저장 실패 안내", "남기지 못했어요", await textOf(opened.page, "#save-status"));
|
|
t.equal("저장 실패 오류 스타일", true, await opened.page.$eval("#save-status", (element) => element.classList.contains("is-error")));
|
|
t.check("실패 뒤 조합과 저장소 상태", "onion extra retained / no snapshot", await opened.page.evaluate(() => ({ onion: document.querySelector('select[name="onion"]')?.value, stored: sessionStorage.getItem("griddle-club-demo-selection") })), (actual) => actual.onion === "extra" && actual.stored === null);
|
|
} 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("조사관 제목과 식별자 동기화", "한남 생활권 정원 주거 / DEMO-HJ-101", await opened.page.evaluate(() => ({
|
|
title: document.querySelector("[data-inspector-title]")?.textContent.trim(),
|
|
id: document.querySelector("[data-inspector-id]")?.textContent.trim(),
|
|
})), (actual) => actual.title === "한남 생활권 정원 주거" && actual.id.startsWith("DEMO-HJ-101"));
|
|
t.check("초기 대기열 원장", "6 rows / 6개 예시", await opened.page.evaluate(() => ({
|
|
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();
|