// Browser check of the independent consumer built by ai-template-consumer.mjs. // Usage: node packages/sw/tests/ai-template-consumer.browser.mjs // Serves /dist and checks examples A–D (rendering, assets, dropdowns, callbacks, focus, styles). import { createRequire } from "node:module" import { createServer } from "node:http" import { mkdir, readFile, writeFile } from "node:fs/promises" import path from "node:path" const dist = path.resolve(process.argv[2], "dist") const out = path.resolve(process.argv[3]) const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? "C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright") 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