feat(sw): integrated improvement 0.2.0 (popup content, header open, tool buttons)
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>
This commit is contained in:
1 parent
5faa1f1ffe
commit
55179b4577
138 files changed
+17194
-162
No files matched your search
@@ -0,0 +1,98 @@
|
||||
// Build reproducibility of @hm-design/sw (2026-10-19).
|
||||
// Copies the current packages/sw source (+ root tsconfig.app.json it extends) into three directories
|
||||
// outside the repository — two LF at different depths and one CRLF copy (as made outside git) — links
|
||||
// the repository node_modules (same locked tools), builds each twice and packs it.
|
||||
// dist must be byte-identical everywhere; the tarball must be identical for every LF checkout.
|
||||
// Usage: node packages/sw/tests/build-reproducibility.mjs <empty-dir-outside-the-repo> <evidence-dir>
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { cp, mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
|
||||
const repoRoot = path.resolve(packageRoot, "../..")
|
||||
const outside = dir => { const relative = path.relative(repoRoot, dir); return relative.startsWith("..") || path.isAbsolute(relative) }
|
||||
const work = path.resolve(process.argv[2] ?? "")
|
||||
const evidence = path.resolve(process.argv[3] ?? "")
|
||||
if (!process.argv[2] || !process.argv[3] || !outside(work)) throw new Error("Pass <empty dir outside the repo> <evidence dir>")
|
||||
await mkdir(work, { recursive: true })
|
||||
if ((await readdir(work)).length) throw new Error(`${work} is not empty`)
|
||||
await mkdir(evidence, { recursive: true })
|
||||
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim()
|
||||
const env = { ...process.env, SW_BUILD_COMMIT: `${commit}+reproducibility-check` }
|
||||
const sha = data => createHash("sha256").update(data).digest("hex")
|
||||
// SVG assets are excluded: they are inlined as base64, so their bytes are fixed at checkout by
|
||||
// packages/sw/.gitattributes (eol=lf) and checked separately with `git ls-files --eol`.
|
||||
const textFile = name => /\.(tsx?|css|mjs|json|md)$/.test(name)
|
||||
|
||||
async function copySource(target, crlf) {
|
||||
const sw = path.join(target, "packages/sw")
|
||||
for (const entry of ["src", "ai", "README.md", "build.mjs", "package.json", "tsconfig.json", "tsconfig.build.json"]) {
|
||||
await cp(path.join(packageRoot, entry), path.join(sw, entry), { recursive: true })
|
||||
}
|
||||
await cp(path.join(repoRoot, "tsconfig.app.json"), path.join(target, "tsconfig.app.json"))
|
||||
if (crlf) {
|
||||
const convert = async directory => {
|
||||
for (const item of await readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, item.name)
|
||||
if (item.isDirectory()) await convert(file)
|
||||
else if (textFile(item.name)) await writeFile(file, (await readFile(file, "utf8")).replace(/\r?\n/g, "\r\n"))
|
||||
}
|
||||
}
|
||||
await convert(sw)
|
||||
}
|
||||
// Same locked tools: a directory junction to the repository node_modules (removed with rmdir semantics below).
|
||||
await symlink(path.join(repoRoot, "node_modules"), path.join(target, "node_modules"), "junction")
|
||||
return sw
|
||||
}
|
||||
|
||||
async function hashes(sw) {
|
||||
const out = {}
|
||||
const walk = async directory => {
|
||||
for (const item of await readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, item.name)
|
||||
if (item.isDirectory()) await walk(file)
|
||||
else out[path.relative(sw, file).replaceAll("\\", "/")] = sha(await readFile(file))
|
||||
}
|
||||
}
|
||||
await walk(path.join(sw, "dist"))
|
||||
return out
|
||||
}
|
||||
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
const runs = []
|
||||
const layouts = [["lf", false], ["deeper/path/lf", false], ["crlf-copy", true]]
|
||||
for (const [name, crlf] of layouts) {
|
||||
const target = path.join(work, name)
|
||||
const sw = await copySource(target, crlf)
|
||||
for (const attempt of [1, 2]) {
|
||||
execFileSync(process.execPath, ["build.mjs"], { cwd: sw, env, stdio: "pipe" })
|
||||
const files = await hashes(sw)
|
||||
const packDir = path.join(target, `pack-${attempt}`)
|
||||
await mkdir(packDir)
|
||||
execFileSync(npm, ["pack", "--ignore-scripts", "--pack-destination", packDir], { cwd: sw, env, stdio: "pipe", shell: process.platform === "win32" })
|
||||
const tarball = (await readdir(packDir)).find(file => file.endsWith(".tgz"))
|
||||
runs.push({ checkout: crlf ? "CRLF" : "LF", path: target, attempt, tarball, tarballSha256: sha(await readFile(path.join(packDir, tarball))), files })
|
||||
}
|
||||
}
|
||||
// Leave the junctions behind as junctions only (never recurse into the repository node_modules).
|
||||
for (const [name] of layouts) await rm(path.join(work, name, "node_modules"), { force: true })
|
||||
|
||||
const reference = runs[0]
|
||||
const differences = runs.slice(1).map(run => ({
|
||||
run: `${run.checkout} #${run.attempt}`,
|
||||
tarballSame: run.tarballSha256 === reference.tarballSha256,
|
||||
differentFiles: Object.keys({ ...reference.files, ...run.files }).filter(file => reference.files[file] !== run.files[file]),
|
||||
}))
|
||||
// Index and working tree of every package file must be LF (or binary) so a fresh checkout matches.
|
||||
const eol = execFileSync("git", ["ls-files", "--eol", "--", "."], { cwd: packageRoot, encoding: "utf8" }).trim().split("\n")
|
||||
const notLf = eol.filter(line => !/^i\/(lf|-text)\s+w\/(lf|-text)\s/.test(line))
|
||||
// dist must match everywhere; the tarball also ships ai/ and README.md verbatim, so it must match for LF
|
||||
// checkouts (guaranteed by .gitattributes). A CRLF copy made outside git only has to keep dist identical.
|
||||
const pass = differences.every(item => item.differentFiles.length === 0 && (item.run.startsWith("CRLF") || item.tarballSame)) && notLf.length === 0
|
||||
const report = { status: pass ? "PASS" : "FAIL", commit, notLf, buildCommit: env.SW_BUILD_COMMIT, runs: runs.map(({ files, ...run }) => ({ ...run, fileCount: Object.keys(files).length })), referenceFiles: reference.files, differences }
|
||||
await writeFile(path.join(evidence, "build-reproducibility-result.json"), JSON.stringify(report, null, 2) + "\n")
|
||||
console.log(report.status, JSON.stringify(differences))
|
||||
if (!pass) process.exitCode = 1
|
||||
@@ -0,0 +1,145 @@
|
||||
// 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
|
||||
@@ -0,0 +1,118 @@
|
||||
// Independent consumer of the 2026-10-19 SW integrated improvement: React 19 + TypeScript + Vite + Tailwind CSS 4
|
||||
// with BOTH tarballs (@hm-design/sw and @hm-design/ui). Two builds load the same app with the stylesheets in both
|
||||
// orders (SW first / UI first) to show that they do not change each other. The shipped ai/examples (A–F) are
|
||||
// copied from node_modules, plus a popup holding HM UI components.
|
||||
// Usage: node packages/sw/tests/integrated-consumer.mjs <empty-dir-outside-the-repo>
|
||||
// Build first: npm run build -w @hm-design/sw && npm run build -w @hm-design/ui
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { cp, mkdir, readdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
|
||||
const repoRoot = path.resolve(packageRoot, "../..")
|
||||
const out = path.resolve(process.argv[2] ?? "")
|
||||
const relative = path.relative(repoRoot, out)
|
||||
if (!process.argv[2] || !(relative.startsWith("..") || path.isAbsolute(relative))) throw new Error("Pass an empty directory outside the repository")
|
||||
await mkdir(out, { recursive: true })
|
||||
if ((await readdir(out)).length) throw new Error(`${out} is not empty`)
|
||||
|
||||
const npm = (args, cwd) => execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { cwd, stdio: "inherit", shell: process.platform === "win32" })
|
||||
npm(["pack", "--ignore-scripts", "--pack-destination", out], packageRoot)
|
||||
npm(["pack", "--ignore-scripts", "--pack-destination", out], path.join(repoRoot, "packages/ui"))
|
||||
const tarballs = (await readdir(out)).filter(name => name.endsWith(".tgz"))
|
||||
|
||||
const app = `import { useState } from "react"
|
||||
import { SWHeaderInput, SWPopup, SWPopupRow, SWPopupSection, SWTheme } from "@hm-design/sw"
|
||||
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Slider, Switch } from "@hm-design/ui"
|
||||
import { ExampleADefaultHeader } from "./examples/ExampleA-DefaultHeader"
|
||||
import { ExampleBViewerHeader } from "./examples/ExampleB-ViewerHeader"
|
||||
import { ExampleCFeatureHeader } from "./examples/ExampleC-FeatureHeader"
|
||||
import { ExampleDSearchExtension } from "./examples/ExampleD-SearchExtension"
|
||||
import { ExampleECustomPopup } from "./examples/ExampleE-CustomPopup"
|
||||
import { ExampleFViewerToolbar } from "./examples/ExampleF-ViewerToolbar"
|
||||
|
||||
const examples = { a: ExampleADefaultHeader, b: ExampleBViewerHeader, c: ExampleCFeatureHeader, d: ExampleDSearchExtension, e: ExampleECustomPopup, f: ExampleFViewerToolbar }
|
||||
|
||||
function UiPopup({ onEvent }: { onEvent: (name: string) => void }) {
|
||||
const [snap, setSnap] = useState(true)
|
||||
const [unit, setUnit] = useState("mm")
|
||||
const [opacity, setOpacity] = useState([60])
|
||||
return (
|
||||
<SWTheme>
|
||||
<SWPopup title="HM UI in SW Popup" onClose={() => onEvent("ui:close")}>
|
||||
<SWPopupSection title="SW">
|
||||
<SWPopupRow label="메모"><SWHeaderInput aria-label="메모" value="A" onCommit={next => onEvent("ui:memo:" + next)} /></SWPopupRow>
|
||||
</SWPopupSection>
|
||||
<SWPopupSection title="HM UI">
|
||||
<SWPopupRow label="스냅" field="none"><Switch aria-label="스냅" checked={snap} onCheckedChange={next => { setSnap(next); onEvent("ui:snap:" + next) }} /></SWPopupRow>
|
||||
<SWPopupRow label="단위" field="none">
|
||||
<Select value={unit} onValueChange={next => { setUnit(next); onEvent("ui:unit:" + next) }}>
|
||||
<SelectTrigger aria-label="단위"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{["mm", "cm", "m"].map(item => <SelectItem key={item} value={item}>{item}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</SWPopupRow>
|
||||
<SWPopupRow label="투명도" field="none"><Slider aria-label="투명도" value={opacity} min={0} max={100} step={10} onValueChange={setOpacity} onValueCommit={next => onEvent("ui:opacity:" + next[0])} /></SWPopupRow>
|
||||
</SWPopupSection>
|
||||
<Button size="sm" onClick={() => onEvent("ui:apply")}>적용</Button>
|
||||
</SWPopup>
|
||||
</SWTheme>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [events, setEvents] = useState<string[]>([])
|
||||
const onEvent = (name: string) => setEvents(list => [...list, name])
|
||||
return (
|
||||
<>
|
||||
{Object.entries(examples).map(([id, Example]) => (
|
||||
<section key={id} data-example={id} style={{ marginBottom: 360 }}><Example onEvent={onEvent} /></section>
|
||||
))}
|
||||
<section data-example="ui" style={{ padding: 16, background: "#3a3a3a" }}><UiPopup onEvent={onEvent} /></section>
|
||||
<pre id="events">{events.join("\\n")}</pre>
|
||||
</>
|
||||
)
|
||||
}
|
||||
`
|
||||
// The bundler merges the CSS of pages that share modules, so the two stylesheet orders are two builds
|
||||
// (--mode sw-first / ui-first): "app-order.css" is aliased to the stylesheet entry of that order.
|
||||
const entry = `import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import "app-order.css"
|
||||
import { App } from "./App"
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>)
|
||||
`
|
||||
const viteConfig = `import { fileURLToPath } from "node:url"
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: "./",
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: { alias: { "app-order.css": fileURLToPath(new URL(mode === "ui-first" ? "./src/ui-first.css" : "./src/sw-first.css", import.meta.url)) } },
|
||||
build: { outDir: mode === "ui-first" ? "dist/ui-first" : "dist/sw-first" },
|
||||
}))
|
||||
`
|
||||
const ui = `@import "tailwindcss";\n@import "@hm-design/ui/tailwind.css";\n@import "@hm-design/ui/theme.css";\n`
|
||||
const sw = `@import "@hm-design/sw/styles.css";\n`
|
||||
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>SW consumer</title><link rel="icon" href="data:," /><style>body { margin: 0; }</style></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>\n`
|
||||
const files = {
|
||||
"package.json": JSON.stringify({ name: "sw-integrated-consumer", private: true, type: "module", scripts: { typecheck: "tsc -p tsconfig.json", build: "vite build --mode sw-first && vite build --mode ui-first" } }, null, 2),
|
||||
"tsconfig.json": JSON.stringify({ compilerOptions: { target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: false, isolatedModules: true, noUnusedLocals: true, noUnusedParameters: true, types: ["vite/client"] }, include: ["src"] }, null, 2),
|
||||
"vite.config.ts": viteConfig,
|
||||
"index.html": html,
|
||||
"src/sw-first.css": sw + ui,
|
||||
"src/ui-first.css": ui + sw,
|
||||
"src/App.tsx": app,
|
||||
"src/main.tsx": entry,
|
||||
}
|
||||
for (const [name, text] of Object.entries(files)) {
|
||||
await mkdir(path.dirname(path.join(out, name)), { recursive: true })
|
||||
await writeFile(path.join(out, name), text)
|
||||
}
|
||||
npm(["install", "--no-audit", "--no-fund", ...tarballs.map(name => `./${name}`), "react@19.3.0", "react-dom@19.3.0", "tailwindcss@4.3.3"], out)
|
||||
npm(["install", "--no-audit", "--no-fund", "-D", "typescript@7.0.2", "vite@8.3.0", "@vitejs/plugin-react@6.1.1", "@tailwindcss/vite@4.3.3", "@types/react@19.3.0", "@types/react-dom@19.3.0"], out)
|
||||
await cp(path.join(out, "node_modules/@hm-design/sw/ai/examples"), path.join(out, "src/examples"), { recursive: true })
|
||||
npm(["run", "typecheck"], out)
|
||||
npm(["run", "build"], out)
|
||||
console.log(`Consumer ready: ${out}. Next: node packages/sw/tests/integrated-consumer.browser.mjs ${out} <evidence-dir>`)
|
||||
@@ -0,0 +1,202 @@
|
||||
// Functional checks of the 2026-10-19 SW integrated improvement on the built Storybook (storybook-static).
|
||||
// Usage: node packages/sw/tests/integrated-improvement.browser.mjs <evidence-dir> [section ...]
|
||||
// Sections: options, tool, popup (default: all). Screenshots and result JSON go to <evidence-dir>.
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
|
||||
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
|
||||
const require = createRequire(path.join(runtime, 'index.js'))
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/integrated-improvement-qa/verify')
|
||||
const sections = process.argv.slice(3).length ? process.argv.slice(3) : ['options', 'tool', 'popup']
|
||||
await mkdir(out, { recursive: true })
|
||||
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
|
||||
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
const { chromium } = require('playwright')
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } })
|
||||
const report = { browser: browser.version(), checks: [], errors: [], data: {} }
|
||||
page.on('pageerror', e => report.errors.push(e.message))
|
||||
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
|
||||
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, ok || data === undefined ? '' : JSON.stringify(data)) }
|
||||
const goto = async id => {
|
||||
await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`)
|
||||
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.mouse.move(1, 1)
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
const log = () => page.locator('[data-callback-log]').first().textContent()
|
||||
const rect = locator => locator.evaluate(e => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, right: r.right } })
|
||||
|
||||
if (sections.includes('options')) {
|
||||
await goto('sw-header-options--open-placement')
|
||||
const frames = page.locator('.sw-story-frame')
|
||||
// Default placement unchanged: no header Open, dropdown footer keeps + New / Open.
|
||||
const plain = frames.nth(0).locator('header.hm-sw-header')
|
||||
check('menu (default): no Open next to the logo', await plain.locator('[data-document-open]').count() === 0)
|
||||
await plain.locator('.hm-sw-header__document-button').click()
|
||||
check('menu (default): dropdown footer keeps + New and Open', JSON.stringify(await plain.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New","Open"]')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const plainHome = await rect(plain.locator('.hm-sw-header__home')), plainFile = await rect(plain.locator('.hm-sw-header__document-button'))
|
||||
const junction = plainFile.x - plainHome.right // the file name's place after the logo in the unchanged header
|
||||
report.data.junction = junction
|
||||
const header = page.locator('[data-open-placement="header"] header.hm-sw-header')
|
||||
const open = header.locator('[data-document-open]')
|
||||
const home = await rect(header.locator('.hm-sw-header__home')), openBox = await rect(open), docBox = await rect(header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button'))
|
||||
check('header: Open takes the file name place right of the logo (same junction offset, 48px)', Math.abs(openBox.x - home.right - junction) < 0.6 && openBox.h === 48, { home, openBox, junction })
|
||||
check('header: file name follows Open without overlap', docBox.x >= openBox.right - 0.5, { openBox, docBox })
|
||||
const styles = await open.evaluate(e => { const s = getComputedStyle(e); return { cls: e.className, bg: s.backgroundImage.slice(0, 40), color: s.color, weight: s.fontWeight, padL: s.paddingLeft, borderL: s.borderLeftWidth, borderR: s.borderRightWidth } })
|
||||
const fileStyles = await header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button').evaluate(e => { const s = getComputedStyle(e); return { bg: s.backgroundImage.slice(0, 40), color: s.color, weight: s.fontWeight, padL: s.paddingLeft } })
|
||||
check('header: Open uses hm-sw-header__document-button (same surface, color, weight as the file name)', styles.cls.includes('hm-sw-header__document-button') && styles.bg === fileStyles.bg && styles.color === fileStyles.color && styles.weight === fileStyles.weight && styles.padL === '5px', { styles, fileStyles })
|
||||
check('D-1: file name after Open starts with 12px padding', fileStyles.padL === '12px', fileStyles)
|
||||
await open.hover()
|
||||
check('header: Open hover turns only the text #FFC600', await open.locator('.hm-sw-header__document-name').evaluate(e => getComputedStyle(e).color) === 'rgb(255, 198, 0)')
|
||||
await page.mouse.move(1, 1)
|
||||
await page.locator('[data-open-placement="header"]').screenshot({ path: path.join(out, 'open-placement-header.png') })
|
||||
await header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button').click()
|
||||
check('header: dropdown footer has + New only (no duplicate Open)', JSON.stringify(await header.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New"]')
|
||||
await page.keyboard.press('Escape')
|
||||
await open.click()
|
||||
check('header: Open calls onDocumentOpen', (await log()).includes('onDocumentOpen'))
|
||||
check('documentOpenLabel is the Open text', await frames.nth(2).locator('[data-document-open]').textContent() === '파일 열기')
|
||||
|
||||
// Responsive: measured room. No overflow anywhere; Open is either shown or reachable in More.
|
||||
const widths = []
|
||||
for (const item of await page.locator('[data-responsive-open] > [data-width]').all()) {
|
||||
const width = Number(await item.getAttribute('data-width'))
|
||||
const h = item.locator('header.hm-sw-header')
|
||||
const state = await h.evaluate(header => {
|
||||
const bar = header.querySelector(':scope > .hm-sw-header__bar'), area = header.querySelector('.hm-sw-header__object-area')
|
||||
const open = header.querySelector('[data-document-open]'), doc = header.querySelector('.hm-sw-header__bar > .hm-sw-header__document')
|
||||
const layer = header.querySelector('.hm-sw-header__field--layer')
|
||||
const parts = [...bar.children].filter(e => getComputedStyle(e).display !== 'none').map(e => e.getBoundingClientRect())
|
||||
const overlap = parts.some((r, i) => i > 1 && r.left < parts[i - 1].right - 0.5)
|
||||
return {
|
||||
height: header.getBoundingClientRect().height, barOverflow: bar.scrollWidth - bar.clientWidth, areaOverflow: area.scrollWidth - area.clientWidth,
|
||||
headerRight: header.getBoundingClientRect().right, barRight: Math.max(...parts.map(r => r.right)), overlap,
|
||||
open: open ? open.getBoundingClientRect().width : 0, doc: doc ? doc.getBoundingClientRect().width : 0, layer: layer ? layer.getBoundingClientRect().width : 0,
|
||||
more: !!header.querySelector('.hm-sw-header__more') && getComputedStyle(header.querySelector('.hm-sw-header__more')).display !== 'none',
|
||||
}
|
||||
})
|
||||
let inMore = null
|
||||
if (!state.open) {
|
||||
await h.locator('.hm-sw-header__more-trigger').click()
|
||||
const itemOpen = h.locator('.hm-sw-header__more-item', { hasText: /^Open$/ })
|
||||
inMore = await itemOpen.isVisible()
|
||||
if (inMore) { await itemOpen.click(); inMore = (await log()).endsWith('onDocumentOpen') } else await page.keyboard.press('Escape')
|
||||
}
|
||||
widths.push({ width, ...state, inMore })
|
||||
check(`responsive ${width}: 49px, no overflow or overlap, nothing past the edge`, state.height === 49 && state.barOverflow <= 1 && state.areaOverflow <= 1 && !state.overlap && state.barRight <= state.headerRight + 0.5, state)
|
||||
check(`responsive ${width}: Open ${state.open ? 'shown next to the logo' : 'moved into More and works there'}`, state.open > 0 || inMore === true, { open: state.open, inMore })
|
||||
if (width >= 520) check(`responsive ${width}: layer field keeps its Figma width (234) while Open is shown`, !state.open || Math.abs(state.layer - 234) < 0.6 || state.layer === 0, state.layer)
|
||||
}
|
||||
report.data.responsive = widths
|
||||
await page.locator('[data-responsive-open]').screenshot({ path: path.join(out, 'open-placement-responsive.png') })
|
||||
// Hysteresis: shrink a header below the collapse width, then grow it back; Open returns, never flickers in between.
|
||||
const probe = page.locator('[data-responsive-open] > [data-width="520"] .sw-story-frame')
|
||||
const shown = async () => probe.locator('[data-document-open]').count()
|
||||
const trace = []
|
||||
for (const w of [520, 560, 600, 640, 680, 720, 760, 820, 900, 1080]) { await probe.evaluate((e, w) => { e.style.width = `${w}px` }, w); await page.waitForTimeout(80); trace.push([w, await shown()]) }
|
||||
report.data.hysteresis = trace
|
||||
const flips = trace.slice(1).filter((step, index) => step[1] !== trace[index][1]).length
|
||||
check('resize: collapsed Open returns once when the header grows (measured, with hysteresis)', trace[0][1] === 0 && trace.at(-1)[1] === 1 && flips === 1, trace)
|
||||
|
||||
await goto('sw-header-options--hidden-sub-controls')
|
||||
const onlyOpen = page.locator('[data-hidden="new"] .hm-sw-header__document-footer')
|
||||
const footer = await rect(onlyOpen), action = await rect(onlyOpen.locator('.hm-sw-header__document-action'))
|
||||
check('hidden documentNew: footer keeps Open on its right side', JSON.stringify(await onlyOpen.locator('.hm-sw-header__document-action').allTextContents()) === '["Open"]' && Math.abs(footer.right - 8 - action.right) < 1, { footer, action })
|
||||
check('hidden documentNew + documentOpen: no footer', await page.locator('[data-hidden="new-open"] .hm-sw-header__document-footer').count() === 0 && await page.locator('[data-hidden="new-open"] .hm-sw-header__document-row').count() === 2)
|
||||
const layer = page.locator('[data-hidden="layer-states"]')
|
||||
const icons = await layer.locator('.hm-sw-header__field--layer .hm-sw-header__layer-icons > *').evaluateAll(list => list.map(e => ({ empty: e.hasAttribute('data-empty'), w: e.getBoundingClientRect().width })))
|
||||
check('hidden layerFreeze / layerLock: field draws the bulb only, two empty 20px places', icons.length === 3 && !icons[0].empty && icons[1].empty && icons[2].empty && icons.every(i => i.w === 20), icons)
|
||||
const rows = await layer.locator('.hm-sw-header__layer-row').evaluateAll(list => list.map(row => ({ buttons: [...row.querySelectorAll('button')].map(b => b.className.split(' ').at(-1)), empty: row.querySelectorAll('[data-empty]').length, name: row.querySelector('.hm-sw-header__layer-name').getBoundingClientRect().x })))
|
||||
check('hidden layerFreeze / layerLock: rows have no snowflake / lock button, places kept', rows.every(r => r.empty === 2 && !r.buttons.some(b => /freeze|lock/.test(b))), rows)
|
||||
await layer.locator('.hm-sw-header__layer-toggle--visibility').first().click()
|
||||
check('hidden layer states: the bulb still reports its callback', (await log()).includes('onLayerVisibilityChange(0, false)'))
|
||||
await page.locator('[data-hidden="layer-states"]').screenshot({ path: path.join(out, 'hidden-layer-states.png') })
|
||||
await page.locator('[data-hidden="new"]').screenshot({ path: path.join(out, 'hidden-document-new.png') })
|
||||
|
||||
await goto('sw-header-options--composed-open')
|
||||
const bar = page.locator('[data-composed-open]')
|
||||
const cHome = await rect(bar.locator('.hm-sw-header__home')), cOpen = await rect(bar.locator('.hm-sw-header__document[data-tool] button')), cDoc = bar.locator('.hm-sw-header__document:not([data-tool]) .hm-sw-header__document-button')
|
||||
check('composed: Open right of the logo at the same junction offset as SWHeader', Math.abs(cOpen.x - cHome.right - report.data.junction) < 0.6 && cOpen.h === 48, { cHome, cOpen })
|
||||
check('composed: file name after Open has 12px padding', await cDoc.evaluate(e => getComputedStyle(e).paddingLeft) === '12px')
|
||||
await bar.locator('.hm-sw-header__document[data-tool] button').click()
|
||||
check('composed: Open callback', (await log()).includes('onDocumentOpen'))
|
||||
await cDoc.click()
|
||||
check('composed: showOpen={false} removes Open from the dropdown', JSON.stringify(await bar.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New"]')
|
||||
await page.keyboard.press('Escape')
|
||||
await bar.screenshot({ path: path.join(out, 'composed-open.png') })
|
||||
}
|
||||
|
||||
if (sections.includes('options') || sections.includes('composed')) {
|
||||
await goto('sw-header-options--composed-responsive')
|
||||
const rows = []
|
||||
for (const item of await page.locator('[data-composed-responsive] > [data-width]').all()) {
|
||||
const width = Number(await item.getAttribute('data-width'))
|
||||
const h = item.locator('header.hm-sw-header')
|
||||
const state = await h.evaluate(header => {
|
||||
const bar = header.querySelector(':scope > .hm-sw-header__bar'), area = header.querySelector('.hm-sw-header__object-area')
|
||||
const visible = selector => [...header.querySelectorAll(selector)].filter(e => e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden').length
|
||||
const clipped = [...header.querySelectorAll('.hm-sw-header__control:not([data-hidden]) .hm-sw-header__field')].some(f => f.getBoundingClientRect().width < 40)
|
||||
return { height: header.getBoundingClientRect().height, barOverflow: bar.scrollWidth - bar.clientWidth, areaOverflow: area.scrollWidth - area.clientWidth, open: visible('[data-document-open]'), more: visible('.hm-sw-header__more-trigger'), clipped, right: Math.max(...[...bar.children].map(e => e.getBoundingClientRect().right)) - header.getBoundingClientRect().right }
|
||||
})
|
||||
rows.push({ width, ...state })
|
||||
check(`composed responsive ${width}: 49px, no overflow, nothing past the edge`, state.height === 49 && state.barOverflow <= 1 && state.areaOverflow <= 1 && state.right <= 0.5, state)
|
||||
if (!state.open) {
|
||||
await h.locator('.hm-sw-header__more-trigger').click()
|
||||
const entry = h.locator('.hm-sw-header__more-item', { hasText: '파일 열기' })
|
||||
const ok = await entry.isVisible()
|
||||
if (ok) await entry.click(); else await page.keyboard.press('Escape')
|
||||
check(`composed responsive ${width}: 파일 열기 reachable in More`, ok && (await log()).endsWith('onDocumentOpen'))
|
||||
}
|
||||
}
|
||||
report.data.composedResponsive = rows
|
||||
await page.locator('[data-composed-responsive]').screenshot({ path: path.join(out, 'composed-responsive.png') })
|
||||
}
|
||||
|
||||
if (sections.includes('tool')) {
|
||||
await goto('sw-tool--tool-buttons')
|
||||
const group = page.locator('[data-view-group]').first()
|
||||
check('tool: Model / Layout is a radio group', await group.getAttribute('role') === 'radiogroup' && await group.locator('[role="radio"]').count() === 2)
|
||||
await group.locator('[role="radio"]').first().focus()
|
||||
await page.keyboard.press('ArrowRight')
|
||||
check('tool: arrow key moves focus and selects Layout', await group.locator('[role="radio"]').nth(1).getAttribute('aria-checked') === 'true' && (await log()).includes('view(layout)'))
|
||||
const depth = page.locator('[data-display-group] button').first()
|
||||
await depth.click()
|
||||
check('tool: 2.5D toggle reports and shows pressed (#FFC600)', await depth.getAttribute('aria-pressed') === 'true' && await depth.evaluate(e => getComputedStyle(e).color) === 'rgb(255, 198, 0)')
|
||||
const text = await page.locator('[data-tool-states] .hm-sw-header__toggle').first().evaluate(e => { const s = getComputedStyle(e); return { weight: s.fontWeight, h: e.getBoundingClientRect().height } })
|
||||
const ref = await page.locator('[data-display-group]').first().evaluate(e => getComputedStyle(e.querySelector('.hm-sw-header__properties-caption')).fontSize)
|
||||
check('tool: text buttons are the 속성Bar toggle look (700, 19px row, 12px caption)', text.weight === '700' && text.h === 19 && ref === '12px', { text, ref })
|
||||
const pill = page.locator('[data-fit-group] button').first()
|
||||
const pillBox = await rect(pill)
|
||||
await pill.hover()
|
||||
const hover = await pill.evaluate(e => ({ ring: getComputedStyle(e, '::after').opacity, icon: getComputedStyle(e.querySelector('.hm-sw-header__icon')).visibility, hoverIcon: getComputedStyle(e.querySelector('.hm-sw-header__icon-hover')).visibility }))
|
||||
check('tool: pill button is 40 x 38 with the yellow ring and hover icon', pillBox.w === 40 && pillBox.h === 38 && hover.ring === '1' && hover.icon === 'hidden' && hover.hoverIcon === 'visible', { pillBox, hover })
|
||||
await page.mouse.move(1, 1)
|
||||
await pill.click()
|
||||
check('tool: pill click callback', (await log()).includes('fit'))
|
||||
check('tool: pill accessible name from its text', await pill.evaluate(e => e.textContent.trim()) === '화면 맞춤')
|
||||
const toolBar = page.locator('[data-tool-bar]')
|
||||
const tHome = await rect(toolBar.locator('.hm-sw-header__home')), tOpen = await rect(toolBar.locator('.hm-sw-header__document[data-tool] button'))
|
||||
check('tool: document look keeps the logo junction in a composed bar', Math.abs(tOpen.x - tHome.right - (report.data.junction ?? -15)) < 0.6, { tHome, tOpen })
|
||||
for (const [name, locator] of [['tool-in-header', page.locator('.sb-page-section').nth(0)], ['tool-composed', page.locator('.sb-page-section').nth(1)], ['tool-states', page.locator('.sb-page-section').nth(2)]]) await locator.screenshot({ path: path.join(out, `${name}.png`) })
|
||||
}
|
||||
|
||||
if (sections.includes('popup')) {
|
||||
const { popupChecks } = await import('./integrated-popup.checks.mjs')
|
||||
await popupChecks({ page, goto, check, log, rect, out, report })
|
||||
}
|
||||
|
||||
check('no browser console or page errors', report.errors.length === 0, report.errors)
|
||||
const failed = report.checks.filter(c => c.status === 'FAIL').length
|
||||
report.status = failed ? 'FAIL' : 'PASS'
|
||||
await writeFile(path.join(out, 'integrated-improvement-result.json'), JSON.stringify(report, null, 2) + '\n')
|
||||
console.log(`TOTAL ${report.checks.length} FAIL ${failed}`)
|
||||
await browser.close(); server.close()
|
||||
if (failed) process.exitCode = 1
|
||||
@@ -0,0 +1,92 @@
|
||||
// SW/Popup checks for integrated-improvement.browser.mjs (2026-10-19): frame identity, free content (SW + HM UI),
|
||||
// callbacks, dropdown clipping, Escape / focus with several popups, scrolling.
|
||||
import path from 'node:path'
|
||||
|
||||
export async function popupChecks({ page, goto, check, log, rect, out, report }) {
|
||||
await goto('sw-popup--custom-content')
|
||||
const frames = page.locator('[data-popups] .hm-sw-property')
|
||||
const frame = await frames.evaluateAll(list => list.map(e => { const s = getComputedStyle(e), t = getComputedStyle(e.querySelector('.hm-sw-property__title')); return { w: e.getBoundingClientRect().width, bg: s.backgroundColor, border: s.borderTopColor, radius: s.borderRadius, shadow: s.boxShadow, title: t.color, weight: t.fontWeight, titlebar: e.querySelector('.hm-sw-property__titlebar').getBoundingClientRect().height, role: e.getAttribute('role'), modal: e.getAttribute('aria-modal') } }))
|
||||
report.data.popupFrames = frame
|
||||
const same = frame.every(f => JSON.stringify(f) === JSON.stringify(frame[2]))
|
||||
check('popup: SWPopup frames match the property window frame (314, surface, border, radius, title)', frame.length === 3 && same && frame[0].w === 314 && frame[0].bg === 'rgb(228, 219, 201)' && frame[0].border === 'rgb(166, 125, 3)' && frame[0].radius === '4px' && frame[0].title === 'rgb(255, 198, 0)' && frame[0].titlebar === 30 && frame[0].role === 'dialog' && frame[0].modal === 'false', frame)
|
||||
const settings = page.locator('[data-popup="settings"]')
|
||||
const commit = async (locator, value) => { await locator.click(); await locator.fill(value); await locator.press('Enter') }
|
||||
await commit(settings.getByLabel('이름'), '평면도-02')
|
||||
await commit(settings.getByLabel('축척'), '50')
|
||||
check('popup content: SWHeaderInput rows commit through the host callbacks', (await log()).includes('name=평면도-02') && (await log()).includes('scale=50'))
|
||||
const rowStyle = await settings.locator('.hm-sw-property__row').first().evaluate(e => ({ label: getComputedStyle(e.querySelector('label')).width, minH: getComputedStyle(e).minHeight, box: getComputedStyle(e.querySelector('.hm-sw-property__value')).height }))
|
||||
check('popup content: SWPopupRow is the property row (72px label, 32px row, 28px box)', rowStyle.label === '72px' && rowStyle.minH === '32px' && rowStyle.box === '28px', rowStyle)
|
||||
await settings.locator('[data-ui-switch]').click()
|
||||
check('popup content: HM UI Switch works inside', (await log()).includes('snap=false'))
|
||||
await settings.locator('[data-ui-select]').click()
|
||||
await page.getByRole('option', { name: 'cm' }).click()
|
||||
check('popup content: HM UI Select (portal) works inside', (await log()).includes('unit=cm'))
|
||||
await settings.locator('[data-ui-slider] [role="slider"]').focus()
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await page.waitForTimeout(100)
|
||||
check('popup content: HM UI Slider works inside', /opacity=70/.test(await log()), await log())
|
||||
await settings.locator('[data-ui-apply]').click()
|
||||
check('popup content: HM UI Button works inside', (await log()).includes('apply'))
|
||||
const layerField = settings.locator('[data-popup-layer] button.hm-sw-header__field')
|
||||
await layerField.click()
|
||||
const fieldBox = await rect(layerField), menu = settings.locator('.hm-sw-header__layer-menu')
|
||||
const menuBox = await rect(menu)
|
||||
const hit = await menu.locator('.hm-sw-header__layer-row').last().evaluate(row => { const r = row.getBoundingClientRect(); const e = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return row.contains(e) })
|
||||
check('popup dropdown: SW layer dropdown opens right under its field (fixed overlay) and is not clipped', await menu.evaluate(e => getComputedStyle(e).position) === 'fixed' && Math.abs(menuBox.y - (fieldBox.y + fieldBox.h)) < 1 && Math.abs(menuBox.x - fieldBox.x) < 1 && hit, { fieldBox, menuBox, hit })
|
||||
await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-dropdown.png') })
|
||||
await menu.locator('.hm-sw-header__layer-item', { hasText: 'DIM' }).click()
|
||||
check('popup dropdown: selection callback, list closes, popup stays', (await log()).includes('layer=dim') && await settings.locator('.hm-sw-header__layer-menu').count() === 0 && await settings.isVisible())
|
||||
await settings.locator('[data-popup-color] button.hm-sw-header__field').click()
|
||||
const colorPanel = settings.locator('.hm-sw-header__menu-panel')
|
||||
check('popup dropdown: SW color dropdown becomes a fixed overlay', await colorPanel.evaluate(e => getComputedStyle(e).position) === 'fixed')
|
||||
await colorPanel.getByLabel('초록').click()
|
||||
check('popup dropdown: color chip callback', (await log()).includes('color=green'))
|
||||
await page.keyboard.press('Escape')
|
||||
check('popup dropdown: Escape closes the dropdown only', await settings.locator('.hm-sw-header__menu-panel').count() === 0 && await settings.isVisible())
|
||||
|
||||
const object = page.locator('[data-popup="object"]')
|
||||
check('object popup: read-only rows', await object.getByLabel('이름').evaluate(e => e.readOnly) && await object.getByLabel('길이').evaluate(e => e.readOnly))
|
||||
await commit(object.getByLabel('X'), '1500')
|
||||
check('object popup: number row commits', (await log()).includes('x=1500'))
|
||||
const compact = await object.getByLabel('Y').evaluate(e => ({ cls: e.className, w: e.getBoundingClientRect().width }))
|
||||
check('object popup: compact row uses the 41px hug field', compact.cls.includes('hm-sw-property__compact') && compact.w < 60, compact)
|
||||
const panel = page.locator('[data-popups] .hm-sw-property').nth(2)
|
||||
const order = await panel.locator('.hm-sw-property__body > *').evaluateAll(list => list.map(e => e.getAttribute('aria-label')))
|
||||
check('property panel + children: children follow the property groups in the same body', JSON.stringify(order) === '["공통","메모"]', order)
|
||||
await commit(panel.getByLabel('설명'), '내벽')
|
||||
check('property panel + children: child content callback', (await log()).includes('memo=내벽'))
|
||||
await settings.locator('.hm-sw-property__close').click()
|
||||
check('popup: close button calls onClose', (await log()).includes('settings.onClose'))
|
||||
await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-content.png') })
|
||||
|
||||
await goto('sw-popup--multiple-popups')
|
||||
const popup = id => page.locator(`[data-popup="${id}"]`)
|
||||
const b = popup('b')
|
||||
await b.locator('[data-popup-layer] button.hm-sw-header__field').click()
|
||||
const bMenu = b.locator('.hm-sw-header__layer-menu'), bHit = await bMenu.locator('.hm-sw-header__layer-footer').evaluate(row => { const r = row.getBoundingClientRect(); return row.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) })
|
||||
const bBox = await rect(b), menuB = await rect(bMenu)
|
||||
check('short popup: dropdown extends past the popup and stays fully visible (no clipping)', menuB.y + menuB.h > bBox.y + bBox.h && bHit, { bBox, menuB, bHit })
|
||||
await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-short-dropdown.png') })
|
||||
await page.keyboard.press('Escape')
|
||||
check('several popups: Escape in a dropdown closes only the dropdown', await bMenu.count() === 0 && await popup('a').count() === 1 && await b.count() === 1 && await popup('c').count() === 1)
|
||||
const aInput = popup('a').getByLabel('A 값')
|
||||
await aInput.click(); await aInput.fill('A2'); await aInput.press('Escape')
|
||||
check('several popups: Escape on an edited input cancels the edit only', await aInput.inputValue() === 'A' && await popup('a').count() === 1)
|
||||
await aInput.press('Escape')
|
||||
check('several popups: Escape inside a popup closes that popup only', (await log()).endsWith('close(a)') && await b.count() === 1 && await popup('c').count() === 1, await log())
|
||||
await page.locator('body').click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.press('Escape')
|
||||
// B was used last (its dropdown), so B is the top popup, not C (opened last).
|
||||
check('several popups: Escape outside closes only the top popup (last used: B)', (await log()).endsWith('close(b)') && await popup('c').count() === 1, await log())
|
||||
await page.locator('[data-reopen]').click()
|
||||
await popup('a').locator('.hm-sw-property__titlebar').click()
|
||||
await page.locator('body').click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.press('Escape')
|
||||
check('several popups: the popup used last becomes the top', (await log()).endsWith('close(a)'), await log())
|
||||
const c = popup('c').locator('.hm-sw-property__body')
|
||||
const scroll = await c.evaluate(e => ({ sh: e.scrollHeight, ch: e.clientHeight, overflow: getComputedStyle(e).overflowY }))
|
||||
await c.evaluate(e => { e.scrollTop = e.scrollHeight })
|
||||
const lastVisible = await popup('c').getByLabel('필드 30').evaluate(e => { const r = e.getBoundingClientRect(); return e.closest('.hm-sw-property__row').contains(document.elementFromPoint(r.x + 4, r.y + r.height / 2)) })
|
||||
check('long content: the body scrolls inside the frame and the last row is reachable', scroll.sh > scroll.ch && scroll.overflow === 'auto' && lastVisible && (await rect(popup('c'))).h === 360, { scroll, lastVisible })
|
||||
await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-stack.png') })
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Visual preservation for the SW integrated improvement (2026-10-19).
|
||||
// capture: screenshots every existing SW Story (1920) and the Header Stories at 10 widths from storybook-static.
|
||||
// compare: pixel-diffs two capture directories and writes compare-result.json.
|
||||
// Usage: node packages/sw/tests/integrated-visual.browser.mjs capture <dir>
|
||||
// node packages/sw/tests/integrated-visual.browser.mjs compare <before-dir> <after-dir> <out-dir>
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
|
||||
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
|
||||
const require = createRequire(path.join(runtime, 'index.js'))
|
||||
const [mode, a, b, c] = process.argv.slice(2)
|
||||
|
||||
// Stories that existed before the improvement; IDs are fixed so new Stories never enter the comparison.
|
||||
export const baselineStories = ['overview', 'detail-buttons', 'interactive-states', 'window-move-motion', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls']
|
||||
const widthStories = ['overview', 'responsive-more']
|
||||
const widths = [1920, 1800, 1440, 1180, 1080, 900, 768, 640, 520, 390]
|
||||
|
||||
if (mode === 'capture') {
|
||||
const out = path.resolve(a)
|
||||
await mkdir(out, { recursive: true })
|
||||
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
|
||||
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
const { chromium } = require('playwright')
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 }, reducedMotion: 'reduce' })
|
||||
const errors = []
|
||||
page.on('pageerror', e => errors.push(e.message))
|
||||
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
|
||||
const shots = []
|
||||
const shoot = async (id, width) => {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
|
||||
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.mouse.move(1, 1)
|
||||
await page.addStyleTag({ content: '*,*::before,*::after{animation-play-state:paused!important;animation-delay:0s!important;transition:none!important;caret-color:transparent!important}' })
|
||||
await page.waitForTimeout(150)
|
||||
const file = `${id}-${width}.png`
|
||||
await page.screenshot({ path: path.join(out, file), fullPage: true })
|
||||
shots.push(file)
|
||||
}
|
||||
for (const id of baselineStories) await shoot(id, 1920)
|
||||
for (const id of widthStories) for (const width of widths.slice(1)) await shoot(id, width)
|
||||
await writeFile(path.join(out, 'capture.json'), JSON.stringify({ browser: browser.version(), shots, errors }, null, 2))
|
||||
await browser.close(); server.close()
|
||||
console.log(`captured ${shots.length}, errors ${errors.length}`)
|
||||
} else if (mode === 'compare') {
|
||||
const { PNG } = require('pngjs')
|
||||
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
|
||||
const before = path.resolve(a), after = path.resolve(b), out = path.resolve(c)
|
||||
await mkdir(out, { recursive: true })
|
||||
const results = []
|
||||
for (const file of (await readdir(before)).filter(name => name.endsWith('.png'))) {
|
||||
let next
|
||||
try { next = PNG.sync.read(await readFile(path.join(after, file))) } catch { results.push({ file, status: 'MISSING' }); continue }
|
||||
const prev = PNG.sync.read(await readFile(path.join(before, file)))
|
||||
if (prev.width !== next.width || prev.height !== next.height) { results.push({ file, status: 'SIZE', before: [prev.width, prev.height], after: [next.width, next.height] }); continue }
|
||||
const diff = new PNG({ width: prev.width, height: prev.height })
|
||||
const changed = pixelmatch(prev.data, next.data, diff.data, prev.width, prev.height, { threshold: 0 })
|
||||
if (changed) await writeFile(path.join(out, `diff-${file}`), PNG.sync.write(diff))
|
||||
results.push({ file, status: changed ? 'DIFF' : 'SAME', changed })
|
||||
}
|
||||
const summary = { total: results.length, same: results.filter(r => r.status === 'SAME').length, results }
|
||||
await writeFile(path.join(out, 'compare-result.json'), JSON.stringify(summary, null, 2))
|
||||
console.log(`${summary.same}/${summary.total} identical`)
|
||||
for (const r of results.filter(r => r.status !== 'SAME')) console.log(r.status, r.file, r.changed ?? '')
|
||||
} else throw new Error('mode: capture | compare')
|
||||
@@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
import { parse } from '@babel/parser'
|
||||
import { swStoryEntries, retainedInventory, inventorySignature, retiredDropdownIds } from './storybook-inventory.mjs'
|
||||
import { swStoryEntries, retainedInventory, inventorySignature, retiredDropdownIds, integratedChangedFiles } from './storybook-inventory.mjs'
|
||||
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-overview-qa')
|
||||
await mkdir(out, { recursive: true })
|
||||
@@ -24,7 +24,9 @@ const oldStories = stories(baseline), newStories = stories(source)
|
||||
const retiredExports = ['LayerDropdown','ColorDropdown','LineStyleDropdown','TextDimensionStyleDropdown','ComposedControls']
|
||||
check('retained non-Overview declarations byte-identical; relocated composition separately verified', Object.keys(oldStories).filter(k => k !== 'Overview' && !retiredExports.includes(k)).every(k => oldStories[k] === newStories[k]))
|
||||
check('retired Dropdown IDs absent', retiredDropdownIds.every(id => !after.some(e => e.id === id)))
|
||||
check('package production source untouched', !execFileSync('git', ['diff', report.baseCommit, '--', 'packages/sw/src'], { encoding:'utf8' }))
|
||||
// Since 2026-10-19 the production source changes only in the integrated-improvement files (their own suites verify them).
|
||||
const sourceDiff = execFileSync('git', ['diff', '--name-only', report.baseCommit, '--', 'packages/sw/src'], { encoding:'utf8' }).trim().split(/\r?\n/).filter(Boolean)
|
||||
check('package production source untouched except the integrated-improvement files', sourceDiff.every(file => integratedChangedFiles.includes(file)), sourceDiff.filter(file => !integratedChangedFiles.includes(file)))
|
||||
|
||||
const root = path.resolve('storybook-static'), mime = { '.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.svg':'image/svg+xml','.ttf':'font/ttf','.png':'image/png' }
|
||||
const server = http.createServer(async(req,res) => {
|
||||
|
||||
@@ -10,3 +10,13 @@ export const swStoryEntries = index => Object.values(index.entries).filter(entry
|
||||
export const retainedInventory = entries => entries.filter(entry => ![...retiredStage1Ids, ...retiredDropdownIds].includes(entry.id))
|
||||
.map(entry => entry.id === 'sw-header--composed-controls' ? { ...entry, name: 'Dropdown', title: 'SW/Header' } : entry)
|
||||
export const inventorySignature = entries => entries.map(entry => [entry.id, entry.name, entry.tags]).sort((a, b) => a[0].localeCompare(b[0]))
|
||||
// 2026-10-19 SW integrated improvement (0.2.0): the only Stories added after the unused-demo removal, under their
|
||||
// own titles (SW/Header keeps its 10 Stories), and the only files of the package it changed.
|
||||
export const integratedStoryIds = ['sw-header-options--open-placement', 'sw-header-options--hidden-sub-controls', 'sw-header-options--composed-open', 'sw-header-options--composed-responsive', 'sw-tool--tool-buttons', 'sw-popup--custom-content', 'sw-popup--multiple-popups']
|
||||
export const integratedChangedFiles = [
|
||||
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
|
||||
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
|
||||
'packages/sw/src/index.ts', 'packages/sw/src/theme/sw.css',
|
||||
'packages/sw/src/components/sw-header/SWHeader.tsx', 'packages/sw/src/components/sw-header/index.ts', 'packages/sw/src/components/sw-header/types.ts',
|
||||
...['SWHeaderDocumentTitle', 'SWHeaderDropdown', 'SWHeaderFields', 'SWHeaderLayerMenu', 'SWHeaderLineStyleMenu', 'SWHeaderMore', 'SWHeaderPropertyFields', 'SWHeaderPropertyPanel'].map(name => `packages/sw/src/components/sw-header/components/${name}.tsx`),
|
||||
]
|
||||
@@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
import { parse } from '@babel/parser'
|
||||
import { swStoryEntries, retiredUnusedDemoIds } from './storybook-inventory.mjs'
|
||||
import { swStoryEntries, retiredUnusedDemoIds, integratedStoryIds, integratedChangedFiles } from './storybook-inventory.mjs'
|
||||
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-unused-story-removal-qa/verify')
|
||||
const qa = 'docs/sw/storybook-unused-story-removal-qa/before'
|
||||
@@ -26,10 +26,12 @@ const beforeSW = swStoryEntries(beforeIndex), afterSW = swStoryEntries(afterInde
|
||||
const expected = beforeSW.filter(e => !retiredUnusedDemoIds.includes(e.id))
|
||||
report.data.inventory = { before: [stories(beforeIndex).length, beforeSW.length], after: [stories(afterIndex).length, afterSW.length], sw: afterSW.map(e => [e.id, e.name]) }
|
||||
await writeFile(path.join(out, 'story-inventory-after.json'), JSON.stringify({ total: stories(afterIndex).length, sw: afterSW.map(e => ({ id: e.id, name: e.name, title: e.title, tags: e.tags })) }, null, 2) + '\n')
|
||||
check('inventory: 19 -> 10 SW Stories, 338 -> 329 total', beforeSW.length === 19 && afterSW.length === 10 && stories(beforeIndex).length === 338 && stories(afterIndex).length === 329, report.data.inventory)
|
||||
// 2026-10-19 integrated improvement: 329 + its 7 Stories under SW/Header/Options, SW/Tool and SW/Popup (SW/Header stays 10).
|
||||
const added = stories(afterIndex).filter(e => !beforeIndex.entries[e.id])
|
||||
check('inventory: 19 -> 10 SW/Header Stories, 338 -> 329 total plus only the 7 integrated-improvement Stories', beforeSW.length === 19 && afterSW.length === 10 && stories(beforeIndex).length === 338 && stories(afterIndex).length === 329 + integratedStoryIds.length && JSON.stringify(added.map(e => e.id).sort()) === JSON.stringify([...integratedStoryIds].sort()), { ...report.data.inventory, added: added.map(e => e.id) })
|
||||
check('inventory: the nine requested IDs existed before and are absent now', retiredUnusedDemoIds.length === 9 && retiredUnusedDemoIds.every(id => beforeIndex.entries[id] && !afterIndex.entries[id]))
|
||||
check('inventory: retained SW IDs, names, titles, tags and order unchanged', sig(expected) === sig(afterSW), afterSW.map(e => e.id))
|
||||
const others = index => Object.values(index.entries).filter(e => !retiredUnusedDemoIds.includes(e.id))
|
||||
const others = index => Object.values(index.entries).filter(e => !retiredUnusedDemoIds.includes(e.id) && !integratedStoryIds.includes(e.id) && !/^sw-(header-options|tool|popup)--docs$/.test(e.id))
|
||||
check('inventory: every non-removed index entry identical (docs and other packages included)', JSON.stringify(others(beforeIndex)) === JSON.stringify(others(afterIndex)))
|
||||
check('inventory: Dropdown keeps sw-header--composed-controls / Dropdown / SW/Header', afterIndex.entries['sw-header--composed-controls']?.name === 'Dropdown' && afterIndex.entries['sw-header--composed-controls']?.title === 'SW/Header')
|
||||
check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === 'Property Panel · 4개 비교')
|
||||
@@ -44,9 +46,12 @@ const removedExports = ['PropertyFields', 'PropertyShapeSlot', 'Composition', 'C
|
||||
check('source: the nine Story exports are removed', removedExports.every(name => oldExports[name] && !newExports[name]))
|
||||
const changed = Object.keys(oldExports).filter(name => !removedExports.includes(name) && oldExports[name] !== newExports[name])
|
||||
check('source: retained exports (Stories and ComposedDemo) byte-identical', changed.length === 0 && Object.keys(newExports).every(name => oldExports[name]), changed)
|
||||
// The SW package may change only in the files of the 2026-10-19 integrated improvement (verified by its own suites);
|
||||
// other packages, Overview, Dropdown CSF, assets and Storybook config stay untouched.
|
||||
const untouched = ['packages/sw/src', 'packages/sw/ai', 'packages/sw/package.json', 'packages/sw/README.md', 'packages/ui', 'packages/org-chart', 'packages/org-chart-react', 'packages/center-graphic', 'src/sw/sw-header-overview.tsx', 'src/sw/sw-header-dropdown.stories.tsx', 'src/sw/sw-composed-controls.css', 'src/sw/assets', '.storybook']
|
||||
const diff = execFileSync('git', ['diff', '--name-only', baseCommit, '--', ...untouched], { encoding: 'utf8' }).trim()
|
||||
check('source: runtime, public API, AI template, other packages, Overview, Dropdown CSF, assets and Storybook config untouched', diff === '', diff)
|
||||
const diff = execFileSync('git', ['diff', '--name-only', baseCommit, '--', ...untouched], { encoding: 'utf8' }).trim().split(/\r?\n/).filter(Boolean)
|
||||
const unexpected = diff.filter(file => !integratedChangedFiles.includes(file))
|
||||
check('source: other packages, Overview, Dropdown CSF, assets and Storybook config untouched; SW package only within the integrated-improvement files', unexpected.length === 0, { unexpected, integrated: diff.length })
|
||||
|
||||
// ---- Browser
|
||||
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.gif': 'image/gif', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
|
||||
|
||||
Reference in new issue
Block a user