Files
HMDesign/packages/sw/tests/ai-template-consumer.browser.mjs
T

181 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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