181 lines
14 KiB
JavaScript
181 lines
14 KiB
JavaScript
// Browser check of the independent consumer built by ai-template-consumer.mjs.
|
||
// Usage: node packages/sw/tests/ai-template-consumer.browser.mjs <consumer-dir> <evidence-dir>
|
||
// Serves <consumer-dir>/dist and checks examples A–D (rendering, assets, dropdowns, callbacks, focus, styles).
|
||
import { createServer } from "node:http"
|
||
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||
import path from "node:path"
|
||
import { loadPlaywright } from "./playwright-runtime.mjs"
|
||
|
||
const dist = path.resolve(process.argv[2], "dist")
|
||
const out = path.resolve(process.argv[3])
|
||
const { chromium } = loadPlaywright()
|
||
await mkdir(out, { recursive: true })
|
||
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css" }
|
||
const server = createServer(async (request, response) => {
|
||
const pathname = decodeURIComponent(new URL(request.url, "http://x").pathname)
|
||
const file = path.join(dist, pathname === "/" ? "/index.html" : pathname)
|
||
let body
|
||
try { body = await readFile(file) } catch { response.writeHead(404); response.end(); return }
|
||
response.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" })
|
||
response.end(body)
|
||
}).listen(0, "127.0.0.1")
|
||
await new Promise(resolve => server.once("listening", resolve))
|
||
const base = `http://127.0.0.1:${server.address().port}/`
|
||
|
||
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" })
|
||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } })
|
||
const report = { checks: [], errors: [], warnings: [] }
|
||
page.on("pageerror", error => report.errors.push(error.message))
|
||
page.on("console", message => { if (message.type() === "error") report.errors.push(message.text()); if (message.type() === "warning") report.warnings.push(message.text()) })
|
||
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? "PASS" : "FAIL", data }); console.log(ok ? "PASS" : "FAIL", name, JSON.stringify(data ?? "")) }
|
||
const events = async () => (await page.locator("#events").textContent()).split("\n").filter(Boolean)
|
||
const lastEvent = async () => (await events()).at(-1)
|
||
const section = id => page.locator(`[data-example="${id}"]`)
|
||
const rect = (locator) => locator.evaluate(node => { const r = node.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })
|
||
|
||
await page.goto(base)
|
||
await page.locator('[data-example="d"] .app-sw-search').waitFor()
|
||
await page.evaluate(() => document.fonts.ready)
|
||
|
||
// 1. Assets and CSS
|
||
const assets = await page.evaluate(() => [...document.images].map(image => ({ src: image.currentSrc.slice(0, 30), ok: image.complete && image.naturalWidth > 0 })))
|
||
check("1 all SVG/PNG assets decode (data URI, no external path)", assets.length > 0 && assets.every(item => item.ok && item.src.startsWith("data:")), { count: assets.length, broken: assets.filter(item => !item.ok) })
|
||
const tokens = await page.evaluate(() => { const s = getComputedStyle(document.querySelector('[data-example="b"] .hm-sw-theme')); return { height: s.getPropertyValue("--hm-sw-header-height").trim(), font: s.fontFamily } })
|
||
check("1 styles.css loaded; SWTheme provides tokens", tokens.height === "49px" && tokens.font.includes("Noto Sans KR"), tokens)
|
||
|
||
// 2. Example A: default header unchanged (Figma 1920 coordinates)
|
||
const a = section("a")
|
||
const geometry = {
|
||
header: await rect(a.locator(".hm-sw-header")), home: await rect(a.locator(".hm-sw-header__home")),
|
||
document: await rect(a.locator(".hm-sw-header__document")), object: await rect(a.locator(".hm-sw-header__object-area")),
|
||
window: await rect(a.locator(".hm-sw-header__window")),
|
||
}
|
||
check("2 A default header 1920 geometry = Figma (49 / 0·80 / 65·310 / 377·1301 / 1680·240)",
|
||
geometry.header.h === 49 && geometry.home.x === 0 && geometry.home.w === 80 && geometry.document.x === 65 && geometry.document.w === 310
|
||
&& geometry.object.x === 377 && geometry.object.w === 1301 && geometry.window.x === 1680 && geometry.window.w === 240, geometry)
|
||
await a.locator(".hm-sw-header").screenshot({ path: path.join(out, "A-default-1920.png") })
|
||
await a.locator(".hm-sw-header__field--layer").click()
|
||
await a.getByRole("button", { name: "CENTER" }).first().click()
|
||
check("2 A layer dropdown select callback", (await lastEvent()) === "layer:center", await lastEvent())
|
||
await a.getByRole("button", { name: "종료" }).click()
|
||
check("2 A close callback", (await lastEvent()) === "close", await lastEvent())
|
||
|
||
// 3. Example B: Logo → File → Layer → Color → K
|
||
const b = section("b")
|
||
const order = await b.locator(".hm-sw-theme").evaluate(bar => [...bar.querySelectorAll(".hm-sw-header__home, .hm-sw-header__document, .hm-sw-header__field, .hm-sw-header__action")].map(node => node.getAttribute("aria-label") ?? node.className.split(" ")[0]))
|
||
check("3 B order Logo → File → Layer → Color → K (DOM)", JSON.stringify(order) === JSON.stringify(["뷰어 홈", "hm-sw-header__document", "레이어", "색상", "팝업 가이드"]), order)
|
||
const bGeo = { bar: await rect(b.locator(".hm-sw-theme")), home: await rect(b.locator(".hm-sw-header__home")), document: await rect(b.locator(".hm-sw-header__document")), layer: await rect(b.locator(".hm-sw-header__field--layer")), color: await rect(b.locator(".hm-sw-header__field--color")) }
|
||
check("3 B sizes keep SW rules (bar 49, home 80 overlapping file 15px, fields 234/192 x 38)", bGeo.bar.h === 49 && bGeo.home.w === 80 && bGeo.document.x - bGeo.home.x === 65 && bGeo.layer.w === 234 && bGeo.layer.h === 38 && bGeo.color.w === 192, bGeo)
|
||
await b.locator(".hm-sw-header__document-button").click()
|
||
await b.getByRole("menuitem", { name: /New/ }).or(b.getByRole("button", { name: /New/ })).first().click()
|
||
check("3 B document dropdown New callback", (await lastEvent()) === "document:new", await lastEvent())
|
||
const layerField = b.locator(".hm-sw-header__field--layer")
|
||
await layerField.click()
|
||
const layerPanel = b.locator(".hm-sw-header__layer [id], .hm-sw-header__layer-menu").first()
|
||
check("3 B layer dropdown opens under the field", await layerField.getAttribute("aria-expanded") === "true", await layerField.getAttribute("aria-expanded"))
|
||
await page.screenshot({ path: path.join(out, "B-layer-open.png"), clip: { x: 0, y: (await rect(b)).y, width: 1000, height: 360 } })
|
||
await page.keyboard.press("Escape")
|
||
check("3 B Escape closes and returns focus to the field", await layerField.getAttribute("aria-expanded") === "false" && await layerField.evaluate(node => node === document.activeElement), null)
|
||
void layerPanel
|
||
await b.locator(".hm-sw-header__field--color").click()
|
||
await b.getByRole("button", { name: "빨강" }).click()
|
||
check("3 B color chip callback (dropdown stays open)", (await lastEvent()) === "color:red" && await b.locator(".hm-sw-header__field--color").getAttribute("aria-expanded") === "true", await lastEvent())
|
||
await page.mouse.click(1800, 900)
|
||
const k = b.getByRole("button", { name: "팝업 가이드" })
|
||
await k.hover()
|
||
await page.waitForTimeout(500)
|
||
const guide = await page.locator(".hm-sw-k-guide__panel").first().isVisible()
|
||
check("3 B K guide preview on hover (animation frame advances)", guide && Number(await page.locator(".hm-sw-k-guide__panel").first().getAttribute("data-frame")) >= 1, { guide })
|
||
await page.waitForTimeout(1000)
|
||
await page.screenshot({ path: path.join(out, "B-k-guide.png"), clip: { x: 1300, y: (await rect(b)).y, width: 620, height: 340 } })
|
||
await k.click()
|
||
check("3 B K click callback", (await lastEvent()) === "popupGuide", await lastEvent())
|
||
await page.mouse.move(1, 1)
|
||
await b.locator(".hm-sw-theme").screenshot({ path: path.join(out, "B-viewer-1920.png") })
|
||
|
||
// 4. Example C: File → Line → Text → Dimension → Print
|
||
const c = section("c")
|
||
const cOrder = await c.locator(".hm-sw-theme").evaluate(bar => [...bar.querySelectorAll(".hm-sw-header__document, .hm-sw-header__field, .hm-sw-header__action")].map(node => node.getAttribute("aria-label") ?? "file"))
|
||
check("4 C order File → 선 → 문자 → 치수 → 인쇄", JSON.stringify(cOrder) === JSON.stringify(["file", "선스타일", "문자스타일", "치수스타일", "인쇄"]), cOrder)
|
||
const fileEdge = await c.locator(".app-sw-file-edge").evaluate(node => getComputedStyle(node).borderLeftWidth)
|
||
check("4 C first file well has a left edge (consumer wrapper)", fileEdge === "1px", fileEdge)
|
||
await c.locator(".hm-sw-header__field--line-style").click()
|
||
await c.getByRole("button", { name: "CENTER" }).first().click()
|
||
check("4 C line type callback + field value", (await lastEvent()) === "lineType:center" && (await c.locator(".hm-sw-header__field--line-style").textContent()).includes("CENTER"), await lastEvent())
|
||
await page.keyboard.press("Escape")
|
||
await c.locator(".hm-sw-header__field--text-style").click()
|
||
await c.getByRole("button", { name: "Dotum" }).click()
|
||
check("4 C text style callback + field value", (await lastEvent()) === "textStyle:dotum" && (await c.locator(".hm-sw-header__field--text-style").textContent()).includes("Dotum"), await lastEvent())
|
||
await c.locator(".hm-sw-header__field--dimension-style").click()
|
||
await c.getByRole("button", { name: "ISO-25" }).click()
|
||
check("4 C dimension style callback", (await lastEvent()) === "dimensionStyle:iso", await lastEvent())
|
||
await c.getByRole("button", { name: "인쇄" }).click()
|
||
check("4 C print callback", (await lastEvent()) === "print", await lastEvent())
|
||
await page.mouse.move(1, 1)
|
||
await c.locator(".hm-sw-theme").screenshot({ path: path.join(out, "C-feature-1920.png") })
|
||
|
||
// 5. Example D: existing layer / color + new SWSearchControl
|
||
const d = section("d")
|
||
const style = await d.locator(".hm-sw-theme").evaluate(bar => {
|
||
const pick = node => { const s = getComputedStyle(node), r = node.getBoundingClientRect(); return { h: Math.round(r.height), top: Math.round(r.top), border: `${s.borderTopWidth} ${s.borderTopColor}`, radius: s.borderTopLeftRadius, bg: s.backgroundImage.slice(0, 40), shadow: s.boxShadow } }
|
||
return { layer: pick(bar.querySelector(".hm-sw-header__field--layer")), search: pick(bar.querySelector(".app-sw-search")),
|
||
label: getComputedStyle(bar.querySelector(".app-sw-search__label")).fontSize, fieldLabel: getComputedStyle(bar.querySelector(".hm-sw-header__field-label")).fontSize }
|
||
})
|
||
const same = ["h", "top", "border", "radius", "bg", "shadow"].every(key => style.layer[key] === style.search[key])
|
||
check("5 D new search field matches the SW field surface (height/top/border/radius/background/shadow/label)", same && style.label === style.fieldLabel, style)
|
||
// Pixels of an element screenshot: the inside must not change on hover, only the 1px edge turns yellow.
|
||
const samplePixels = async (locator) => {
|
||
const buffer = await locator.screenshot()
|
||
return page.evaluate(async b64 => {
|
||
const image = new Image(); image.src = `data:image/png;base64,${b64}`; await image.decode()
|
||
const canvas = document.createElement("canvas"); canvas.width = image.width; canvas.height = image.height
|
||
const context = canvas.getContext("2d"); context.drawImage(image, 0, 0)
|
||
const at = (x, y) => [...context.getImageData(x, y, 1, 1).data].slice(0, 3)
|
||
return { inside: at(Math.round(image.width * 0.85), Math.round(image.height / 2)), edge: at(Math.round(image.width / 2), 0) }
|
||
}, buffer.toString("base64"))
|
||
}
|
||
const searchField = d.locator(".app-sw-search")
|
||
await page.mouse.move(1, 1)
|
||
const before = await samplePixels(searchField)
|
||
await searchField.hover()
|
||
const ring = await searchField.evaluate(node => getComputedStyle(node, "::after").opacity)
|
||
const after = await samplePixels(searchField)
|
||
const near = (p, q, tolerance = 12) => p.every((value, index) => Math.abs(value - q[index]) <= tolerance)
|
||
const yellow = rgb => rgb[0] > 150 && rgb[1] > 100 && rgb[2] < 90
|
||
const layerRing = async () => { await d.locator(".hm-sw-header__field--layer").hover(); return d.locator(".hm-sw-header__field--layer").evaluate(node => getComputedStyle(node, "::after").opacity) }
|
||
check("5 D hover ring like existing fields (edge turns yellow, inside unchanged)", ring === "1" && near(before.inside, after.inside) && yellow(after.edge) && !yellow(before.edge) && (await layerRing()) === "1", { ring, before, after })
|
||
await d.locator(".app-sw-search").hover()
|
||
await page.screenshot({ path: path.join(out, "D-search-hover.png"), clip: { x: 0, y: (await rect(d)).y, width: 760, height: 60 } })
|
||
const input = d.getByRole("textbox", { name: "검색어" })
|
||
await input.click()
|
||
await input.fill("WALL-01")
|
||
await page.keyboard.press("Enter")
|
||
check("5 D Enter commits through SWHeaderInput → search callback", (await lastEvent()) === "search:WALL-01", await lastEvent())
|
||
await d.getByRole("button", { name: "검색", exact: true }).click()
|
||
check("5 D search button callback uses committed value", (await lastEvent()) === "search:WALL-01", await lastEvent())
|
||
await input.click()
|
||
await input.fill("TMP")
|
||
await page.keyboard.press("Escape")
|
||
check("5 D Escape restores the committed value", await input.inputValue() === "WALL-01", await input.inputValue())
|
||
await page.keyboard.press("Tab")
|
||
await d.locator(".hm-sw-header__field--color").click()
|
||
await d.getByRole("button", { name: "파랑" }).click()
|
||
check("5 D existing color dropdown in the same bar", (await lastEvent()) === "color:blue", await lastEvent())
|
||
await page.mouse.click(1800, 900)
|
||
await page.mouse.move(1, 1)
|
||
await d.locator(".hm-sw-theme").screenshot({ path: path.join(out, "D-search-1920.png") })
|
||
|
||
// 6. Keyboard order follows DOM order in B
|
||
await b.locator(".hm-sw-header__home").focus()
|
||
const tabs = []
|
||
for (let index = 0; index < 4; index++) { await page.keyboard.press("Tab"); tabs.push(await page.evaluate(() => document.activeElement?.getAttribute("aria-label") ?? document.activeElement?.textContent?.trim().slice(0, 12))) }
|
||
check("6 B Tab order = display order", tabs.includes("레이어") && tabs.indexOf("레이어") < tabs.indexOf("색상") && tabs.indexOf("색상") < tabs.indexOf("팝업 가이드"), tabs)
|
||
|
||
check("7 no page/console errors", report.errors.length === 0, report.errors)
|
||
await writeFile(path.join(out, "report.json"), JSON.stringify(report, null, 2))
|
||
await browser.close()
|
||
server.close()
|
||
const failed = report.checks.filter(item => item.status === "FAIL").length
|
||
console.log(`${report.checks.length - failed}/${report.checks.length} PASS`)
|
||
process.exitCode = failed ? 1 : 0
|