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

150 lines
11 KiB
JavaScript

// Browser check of the consumer built by integrated-consumer.mjs (2026-10-19): both stylesheet orders, examples
// E (custom popup) and F (Open + tool bar), HM UI inside an SW popup, Escape stack, computed-style identity.
// Usage: node packages/sw/tests/integrated-consumer.browser.mjs <consumer-dir> <evidence-dir>
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", ".ttf": "font/ttf" }
const server = createServer(async (request, response) => {
const pathname = decodeURIComponent(new URL(request.url, "http://x").pathname)
const file = path.join(dist, pathname.endsWith("/") ? `${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 report = { browser: browser.version(), checks: [], errors: [], styles: {} }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? "PASS" : "FAIL", data }); console.log(ok ? "PASS" : "FAIL", name, ok ? "" : JSON.stringify(data ?? "")) }
// Computed styles of representative SW and UI elements; must not depend on the stylesheet order.
const snapshot = page => page.evaluate(() => {
const pick = (selector, props) => { const e = document.querySelector(selector); if (!e) return null; const s = getComputedStyle(e), r = e.getBoundingClientRect(); return { w: Math.round(r.width * 10) / 10, h: Math.round(r.height * 10) / 10, ...Object.fromEntries(props.map(p => [p, s.getPropertyValue(p)])) } }
const box = ["background-color", "background-image", "border-top", "border-radius", "box-shadow", "color", "font-family", "font-size", "font-weight", "padding", "margin"]
return {
header: pick('[data-example="a"] .hm-sw-header', box),
field: pick('[data-example="a"] .hm-sw-header__field--layer', box),
documentButton: pick('[data-example="a"] .hm-sw-header__document-button', box),
toggle: pick('[data-example="a"] .hm-sw-header__toggle', box),
open: pick('[data-example="f"] [data-document-open]', box),
toolText: pick('[data-example="f"] .hm-sw-tool__text-button', box),
toolPill: pick('[data-example="f"] .hm-sw-tool__pill-button', box),
popup: pick('[data-example="e"] .hm-sw-property', box),
popupTitle: pick('[data-example="e"] .hm-sw-property__title', box),
popupRow: pick('[data-example="e"] .hm-sw-property__row', box),
popupInput: pick('[data-example="e"] .hm-sw-property__value .hm-sw-header__input', box),
searchInput: pick('[data-example="d"] .hm-sw-header__input', box),
uiButton: pick('[data-example="ui"] [data-slot="button"]', box),
uiSwitch: pick('[data-example="ui"] [data-slot="switch"]', box),
uiSelect: pick('[data-example="ui"] [data-slot="select-trigger"]', box),
}
})
for (const mode of ["sw-first", "ui-first"]) {
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } })
page.on("pageerror", error => report.errors.push(`${mode}: ${error.message}`))
page.on("console", message => { if (message.type() === "error") report.errors.push(`${mode}: ${message.text()}`) })
const events = async () => (await page.locator("#events").textContent()).split("\n").filter(Boolean)
const last = async () => (await events()).at(-1)
const section = id => page.locator(`[data-example="${id}"]`)
await page.goto(`${base}${mode}/`)
await section("f").locator("header.hm-sw-header").waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
const assets = await page.evaluate(() => [...document.images].map(image => image.complete && image.naturalWidth > 0))
check(`${mode}: every SVG/PNG asset decodes`, assets.length > 0 && assets.every(Boolean), assets.length)
report.styles[mode] = await snapshot(page)
await page.screenshot({ path: path.join(out, `${mode}-1920.png`), fullPage: true })
// F: Open next to the logo, no + New, no freeze / lock, tool bar.
const f = section("f")
const open = f.locator("[data-document-open]")
check(`${mode} F: Open next to the logo with documentOpenLabel`, await open.textContent() === "파일 열기" && await open.evaluate(e => e.closest(".hm-sw-header__leading")?.previousElementSibling?.classList.contains("hm-sw-header__home") === true))
await open.click()
check(`${mode} F: Open calls onDocumentOpen`, await last() === "document:open", await last())
await f.locator(".hm-sw-header__control[data-control=\"document\"] .hm-sw-header__document-button").click()
check(`${mode} F: document dropdown without + New and Open (both hidden or placed)`, await f.locator(".hm-sw-header__document-row").count() === 2 && await f.locator(".hm-sw-header__document-footer").count() === 0)
await page.keyboard.press("Escape")
await f.locator(".hm-sw-header__field--layer").click()
check(`${mode} F: layer rows have no freeze / lock buttons`, await f.locator(".hm-sw-header__layer-toggle--freeze, .hm-sw-header__layer-toggle--lock").count() === 0 && await f.locator(".hm-sw-header__layer-row [data-empty]").count() === 8)
await page.keyboard.press("Escape")
await f.getByRole("radio", { name: "Model" }).focus()
await page.keyboard.press("ArrowRight")
check(`${mode} F: Model / Layout radio group by keyboard`, await last() === "view:layout" && await f.getByRole("radio", { name: "Layout" }).getAttribute("aria-checked") === "true", await last())
await f.getByRole("button", { name: "2.5D" }).click()
check(`${mode} F: 2.5D toggle`, await last() === "2.5d:true" && await f.getByRole("button", { name: "2.5D" }).getAttribute("aria-pressed") === "true")
await f.getByRole("button", { name: "화면 맞춤" }).click()
check(`${mode} F: Fit pill`, await last() === "fit")
await page.setViewportSize({ width: 640, height: 1080 })
await page.waitForTimeout(200)
const narrow = await f.locator("header.hm-sw-header").evaluate(h => ({ open: h.querySelectorAll("[data-document-open]").length, overflow: h.querySelector(".hm-sw-header__bar").scrollWidth - h.querySelector(".hm-sw-header__bar").clientWidth, height: h.getBoundingClientRect().height }))
await f.locator(".hm-sw-header__more-trigger").click()
const moreOpen = f.locator(".hm-sw-header__more-item", { hasText: "파일 열기" })
const inMore = await moreOpen.isVisible()
if (inMore) await moreOpen.click()
check(`${mode} F 640px: Open moved into More (measured), works there, no overflow`, narrow.open === 0 && inMore && await last() === "document:open" && narrow.overflow <= 1 && narrow.height === 49, { narrow, inMore })
await page.setViewportSize({ width: 1920, height: 1080 })
await page.waitForTimeout(200)
check(`${mode} F 1920px again: Open returns next to the logo`, await f.locator("[data-document-open]").count() === 1)
// E: custom popup.
const e = section("e")
const frame = await e.locator(".hm-sw-property").evaluate(el => { const s = getComputedStyle(el); return { w: el.getBoundingClientRect().width, bg: s.backgroundColor, border: s.borderTopColor } })
check(`${mode} E: SW popup frame (314, #E4DBC9, #A67D03)`, frame.w === 314 && frame.bg === "rgb(228, 219, 201)" && frame.border === "rgb(166, 125, 3)", frame)
const name = e.getByLabel("이름")
await name.click(); await name.fill("평면도-02"); await name.press("Enter")
check(`${mode} E: row input commits`, await last() === "name:평면도-02", await last())
// 2026-10-20: popup layer = the property window picker (SWPopupPropertyRow); no SWHeader field dropdown in a popup.
check(`${mode} E: no SWHeader field dropdown inside the popup`, await e.locator(".hm-sw-header__field").count() === 0)
await e.locator('[data-property-id="layer"] .hm-sw-property__trigger').click()
const menu = page.locator('.hm-sw-property__drop[data-picker="layer"]')
check(`${mode} E: layer picker inside the popup is a fixed overlay`, await menu.evaluate(el => getComputedStyle(el).position) === "fixed")
await menu.locator(".hm-sw-header__layer-item", { hasText: "DIM" }).click()
check(`${mode} E: layer selection callback`, await last() === "layer:dim", await last())
await e.getByLabel("단위").selectOption("cm")
check(`${mode} E: SWSelect unit callback`, await last() === "unit:cm", await last())
// HM UI inside an SW popup.
const ui = section("ui")
await ui.getByRole("switch", { name: "스냅" }).click()
check(`${mode} UI in popup: Switch`, await last() === "ui:snap:false", await last())
await ui.getByRole("combobox", { name: "단위" }).click()
await page.getByRole("option", { name: "cm" }).click()
check(`${mode} UI in popup: Select (portal)`, await last() === "ui:unit:cm", await last())
await ui.getByRole("slider", { name: "투명도" }).focus()
await page.keyboard.press("ArrowRight")
await page.waitForTimeout(100)
check(`${mode} UI in popup: Slider`, /ui:opacity:70/.test(await last()), await last())
await ui.getByRole("button", { name: "적용" }).click()
check(`${mode} UI in popup: Button`, await last() === "ui:apply", await last())
// Escape stack: the UI popup was used last, so Escape outside closes it only; then the example E popup.
await page.mouse.click(1900, 1060)
await page.keyboard.press("Escape")
check(`${mode} Escape outside closes only the last used popup`, await last() === "ui:close" && await e.locator(".hm-sw-property").count() === 1, await last())
await e.getByLabel("X").click()
await page.keyboard.press("Escape")
check(`${mode} Escape inside a popup closes that popup`, await last() === "popup:close", await last())
await page.close()
}
const keys = Object.keys(report.styles["sw-first"])
const differences = keys.filter(key => JSON.stringify(report.styles["sw-first"][key]) !== JSON.stringify(report.styles["ui-first"][key]))
check("stylesheet order (SW first / UI first) changes no computed SW or UI style", differences.length === 0 && keys.every(key => report.styles["sw-first"][key] !== null), { differences, missing: keys.filter(key => report.styles["sw-first"][key] === null) })
check("no page/console errors", report.errors.length === 0, report.errors)
await writeFile(path.join(out, "integrated-consumer-result.json"), JSON.stringify(report, null, 2) + "\n")
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