SW package 0.2.0 integrated improvement (Task A, steps 1-8). All new props are
optional; existing SW Stories are pixel-identical to the previous build (28/28).
- Build reproducibility: dist text normalized to LF, dist/build-info.json
(commit, dirty, tool versions, no timestamp), packages/sw/.gitattributes
(eol=lf). LF checkouts give identical dist and tarball.
- SWHeader: documentOpenPlacement ("menu" default | "header": Open right of
the logo in the existing document-button look, reusing onDocumentOpen and
documentOpenLabel, no duplicate Open in the dropdown), leading slot,
hiddenControls sub-items documentNew / documentOpen / layerFreeze /
layerLock. Open moves into More on measured overflow (with hysteresis).
- showNew / showOpen (SWHeaderDocumentTitle), showFreeze / showLock (layer
menu, field, property window list).
- SWToolButton (text / pill / document) and SWToolGroup (radio selection),
reusing the existing toggle, pill and file-name looks.
- SWPopup / SWPopupSection / SWPopupRow on the shared property window frame;
SWHeaderPropertyPanel gains children, title, autoFocus and optional
selection / properties / groups. Escape stack for several popups.
- Field dropdowns inside a popup use the shared fixed overlay
(useSWFixedDropdownPosition), so the scrolling body no longer clips them.
- AI guide, examples E/F, README, Storybook (SW/Header/Options, SW/Tool,
SW/Popup) and verification record docs/sw/sw-integrated-improvement-2026-10-19.md.
- Regression scripts rescoped for the intended changes (unused-removal 81/81,
overview 52/54 with the two pre-existing historical assertions).
Known limitation (unresolved): tools / leading content (incl. SWToolGroup)
does not move into More. With a wide tool bar the configured fields shrink
instead; in Example F the document name and layer field reach 0px at 390px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
11 KiB
JavaScript
146 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 { 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", ".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())
|
|
await e.locator(".hm-sw-header__field--layer").click()
|
|
const menu = e.locator(".hm-sw-header__layer-menu")
|
|
check(`${mode} E: layer dropdown 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())
|
|
|
|
// 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
|