Files
HMDesign/packages/sw/tests/property-panel-comparison.browser.mjs
T
shkim4andClaude Opus 5.5 5faa1f1ffe refactor(storybook): remove unused SW header demo stories
Remove nine SW/Header demo pages (property-fields, property-shape-slot,
composition, colorize, dynamic-controls, property-bar, additional-hover,
property-panel, layer-detail-input) and the demo code and Storybook-only
CSS used only by them. SW Stories 19 -> 10, total 338 -> 329.

The package runtime, public API, AI template and the retained Stories
(Overview, Dropdown, Property Panel 4-state comparison, Window Move
Motion and others) are unchanged. Add a current regression script and
mark the older scripts that open the retired IDs as historical evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:10:34 +09:00

199 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (property-panel and four others in verify). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
import { createRequire } from 'node:module'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
import { swStoryEntries, retiredPropertyPanelIds } from './storybook-inventory.mjs'
// Property panel comparison consolidation (2026-10-19).
// Usage: node packages/sw/tests/property-panel-comparison.browser.mjs before|verify <evidence-dir> [base-url|static]
const mode = process.argv[2] ?? 'verify'
const out = path.resolve(process.argv[3] ?? 'docs/sw/property-panel-comparison-qa/' + mode)
let base = process.argv[4] ?? 'static'
let server
if (base === 'static') {
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' }
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)); base = `http://127.0.0.1:${server.address().port}`
}
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 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: 1440, height: 900 } })
const report = { mode, base: process.argv[4] ?? 'static', 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 }); if (!ok) console.error('FAIL', name, JSON.stringify(data)) }
const goto = async id => {
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)
}
const STATES = [['none', 256], ['all', 361], ['circle', 658], ['expanded', 754]]
const geometry = () => page.evaluate(() => {
const r = e => { const b = e.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height, right: b.right, bottom: b.bottom } }
const scroller = document.querySelector('[data-compare-scroll]')
return {
cells: [...document.querySelectorAll('[data-compare]')].map(c => ({ id: c.dataset.compare, cell: r(c), panel: r(c.querySelector('.hm-sw-property')), caption: c.querySelector('figcaption strong')?.textContent })),
scroller: scroller && { ...r(scroller), scrollW: scroller.scrollWidth, clientW: scroller.clientWidth, scrollH: scroller.scrollHeight, clientH: scroller.clientHeight },
doc: { scrollW: document.documentElement.scrollWidth, clientW: document.documentElement.clientWidth, scrollH: document.documentElement.scrollHeight, clientH: document.documentElement.clientHeight },
}
})
try {
const index = await (await fetch(`${base}/index.json`)).json()
const stories = Object.values(index.entries).filter(e => e.type === 'story')
const sw = swStoryEntries(index)
report.data.inventory = { total: stories.length, sw: sw.length, ids: sw.map(e => [e.id, e.name]) }
if (mode === 'before') {
await writeFile(path.join(out, 'story-inventory-before.json'), JSON.stringify({ total: stories.length, sw: sw.map(e => ({ id: e.id, name: e.name, title: e.title, tags: e.tags })) }, null, 2) + '\n')
await goto('property-panel-compare')
report.data.compare = await geometry()
await page.screenshot({ path: path.join(out, 'before-compare-1440.png'), fullPage: true })
for (const id of retiredPropertyPanelIds) {
await goto(id.replace('sw-header--', ''))
await page.screenshot({ path: path.join(out, `before-${id.replace('sw-header--', '')}.png`), fullPage: true })
}
} else {
// ---- Inventory
const before = JSON.parse(await readFile(path.resolve(process.env.SW_BEFORE_INVENTORY ?? 'docs/sw/property-panel-comparison-qa/before/story-inventory-before.json'), 'utf8'))
const expected = before.sw.filter(e => !retiredPropertyPanelIds.includes(e.id))
const signature = list => JSON.stringify(list.map(e => [e.id, e.name, e.title, e.tags]).sort((a, b) => a[0].localeCompare(b[0])))
check('inventory: 24 -> 19 SW Stories, 343 -> 338 total, only 5 approved retirements', before.sw.length === 24 && sw.length === 19 && before.total === 343 && stories.length === 338 && retiredPropertyPanelIds.every(id => !index.entries[id]), { before: [before.total, before.sw.length], after: [stories.length, sw.length] })
check('inventory: retained SW Story IDs, names, titles, tags unchanged', signature(expected) === signature(sw), { retained: sw.length })
check('inventory: compare Story keeps its ID and name', index.entries['sw-header--property-panel-compare']?.name === 'Property Panel · 4개 비교')
check('inventory: Dropdown Story keeps ID / name / title', index.entries['sw-header--composed-controls']?.name === 'Dropdown' && index.entries['sw-header--composed-controls']?.title === 'SW/Header')
// ---- Layout at four widths
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 })
await goto('property-panel-compare')
const g = await geometry(); report.data[`layout${width}`] = g
const [a, b, c, d] = g.cells
const sizes = g.cells.map(cell => [cell.id, cell.panel.w, cell.panel.h])
check(`${width}: four states in original order with captions`, JSON.stringify(g.cells.map(cell => cell.id)) === JSON.stringify(STATES.map(s => s[0])) && g.cells.every(cell => cell.caption), g.cells.map(cell => [cell.id, cell.caption]))
check(`${width}: original panel sizes 314 × 256 / 361 / 658 / 754`, STATES.every(([id, h], i) => sizes[i][0] === id && sizes[i][1] === 314 && sizes[i][2] === h), sizes)
const colGap = b.cell.x - a.cell.right, colGap2 = d.cell.x - c.cell.right, rowGap = c.cell.y - Math.max(a.cell.bottom, b.cell.bottom)
report.data[`gaps${width}`] = { colGap, colGap2, rowGap }
check(`${width}: 2 columns × 2 rows by coordinates`, Math.abs(a.cell.y - b.cell.y) < 0.5 && Math.abs(c.cell.y - d.cell.y) < 0.5 && Math.abs(a.cell.x - c.cell.x) < 0.5 && Math.abs(b.cell.x - d.cell.x) < 0.5 && b.cell.x >= a.cell.right && c.cell.y >= Math.max(a.cell.bottom, b.cell.bottom), { a: a.cell, b: b.cell, c: c.cell, d: d.cell })
check(`${width}: consistent 24px column and row gaps`, [colGap, colGap2, rowGap].every(v => Math.abs(v - 24) < 0.5), { colGap, colGap2, rowGap })
check(`${width}: page itself does not scroll (no document overflow)`, g.doc.scrollW <= g.doc.clientW && g.doc.scrollH <= g.doc.clientH, g.doc)
check(`${width}: Inner scroller scrolls vertically to the 754px panel`, g.scroller.scrollH > g.scroller.clientH, g.scroller)
await page.screenshot({ path: path.join(out, `compare-${width}-top.png`) })
// Scroll to the very end: every panel must be reachable with its bottom inside the scroller.
const end = await page.evaluate(() => {
const s = document.querySelector('[data-compare-scroll]'); s.scrollTop = s.scrollHeight; s.scrollLeft = s.scrollWidth
const sr = s.getBoundingClientRect()
return { scrollTop: s.scrollTop, scrollLeft: s.scrollLeft, panels: [...s.querySelectorAll('.hm-sw-property')].map(p => { const r = p.getBoundingClientRect(); return { bottom: r.bottom, right: r.right } }), sBottom: sr.bottom, sRight: sr.right }
})
report.data[`end${width}`] = end
const last = end.panels[3], right = end.panels[1]
check(`${width}: bottom of the 754px panel reachable, not clipped`, last.bottom <= end.sBottom + 0.5 && last.right <= end.sRight + 0.5, end)
if (g.scroller.scrollW > g.scroller.clientW) check(`${width}: narrow Inner scrolls horizontally, keeping 2 columns`, end.scrollLeft > 0 && right.right <= end.sRight + 0.5, { scrollW: g.scroller.scrollW, clientW: g.scroller.clientW, end })
else check(`${width}: both columns fit without horizontal scroll`, g.scroller.scrollW <= g.scroller.clientW && b.cell.right <= g.scroller.right, g.scroller)
await page.screenshot({ path: path.join(out, `compare-${width}-end.png`) })
}
// ---- Behaviour (1440 × 900)
await page.setViewportSize({ width: 1440, height: 900 })
await goto('property-panel-compare')
const cell = id => page.locator(`[data-compare="${id}"]`)
const log = () => page.locator('[data-property-log]').textContent()
const input = (id, prop) => cell(id).locator(`[data-property-id="${prop}"] input.hm-sw-header__input`).first()
// Input: Enter commits through onPropertyChange; Escape restores; other panels unaffected.
const radius = input('circle', 'radius')
await radius.scrollIntoViewIfNeeded(); await radius.click(); await radius.fill('5.5'); await radius.press('Enter')
const committed = { circle: await radius.inputValue(), expanded: await input('expanded', 'radius').inputValue(), log: await log() }
check('Input Enter commits via callback; only that panel changes', committed.circle.startsWith('5.5') && committed.expanded === '4.0526' && committed.log.includes('circle: property(radius, 5.5'), committed)
await radius.click(); await radius.fill('9'); await radius.press('Escape')
check('Input Escape cancels the edit', (await radius.inputValue()).startsWith('5.5'), await radius.inputValue())
const focusOk = await radius.evaluate(e => document.activeElement === e || !!e.closest('.hm-sw-property')?.contains(document.activeElement))
check('Focus stays inside the edited panel after Escape', focusOk)
await page.keyboard.press('Tab')
check('Tab moves focus within the page (no trap / loss to body)', await page.evaluate(() => document.activeElement !== document.body))
// Dropdown (layer picker) in a top panel and in a bottom-row panel after scrolling.
const openPicker = async id => {
const trigger = cell(id).locator('[data-property-id="layer"] button[aria-haspopup], [data-property-id="layer"] [aria-expanded]').first()
await trigger.scrollIntoViewIfNeeded(); await trigger.click()
const drop = page.locator('.hm-sw-property__drop').first(); await drop.waitFor({ timeout: 2000 })
const info = await drop.evaluate(e => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, bottom: r.bottom, right: r.right, vw: innerWidth, vh: innerHeight, items: e.querySelectorAll('button, [role="option"], li').length } })
return { trigger, drop, info }
}
for (const id of ['all', 'expanded']) {
const { trigger, drop, info } = await openPicker(id)
report.data[`drop-${id}`] = info
check(`Dropdown ${id}: opens, has items, fully inside the viewport`, info.items > 0 && info.x >= 0 && info.y >= 0 && info.right <= info.vw + 0.5 && info.bottom <= info.vh + 0.5, info)
await page.screenshot({ path: path.join(out, `dropdown-${id}.png`) })
if (id === 'expanded') {
// Scroll the Inner while open: the fixed overlay must stay attached to its field (below or flipped above).
const moved = await page.locator('[data-compare-scroll]').evaluate(s => { const t = s.scrollTop; s.scrollTop += 120; return s.scrollTop - t })
await page.waitForTimeout(150)
const after = await page.evaluate(() => {
const d = document.querySelector('.hm-sw-property__drop')?.getBoundingClientRect()
const f = document.querySelector('[data-compare="expanded"] [data-property-id="layer"] [aria-expanded="true"]')?.getBoundingClientRect()
return d && f ? { dTop: d.top, dBottom: d.bottom, fTop: f.top, fBottom: f.bottom } : null
})
report.data.dropScroll = { moved, before: info, after }
check('Dropdown follows the Inner scroller (stays attached to its field)', moved > 0 && after && (Math.abs(after.dTop - after.fBottom) < 1.5 || Math.abs(after.dBottom - after.fTop) < 1.5), report.data.dropScroll)
}
await page.keyboard.press('Escape')
check(`Dropdown ${id}: Escape closes and returns focus to its field`, await page.locator('.hm-sw-property__drop').count() === 0 && await trigger.evaluate(e => document.activeElement === e || e.contains(document.activeElement)))
}
// Select a layer value: callback updates only that panel.
{
const { drop } = await openPicker('all')
const option = drop.getByRole('button', { name: 'CC-D GNL', exact: true }).first()
if (await option.count()) {
await option.click()
check('Dropdown selection commits via callback in its own panel only', (await log()).includes('all: property(layer, CC-D GNL)'), await log())
} else check('Dropdown selection commits via callback in its own panel only', false, 'option not found')
if (await page.locator('.hm-sw-property__drop').count()) await page.keyboard.press('Escape')
}
// Tool toggle, group expand, close button — each panel keeps its own state.
const tool = cell('none').locator('.hm-sw-property__tool')
await tool.scrollIntoViewIfNeeded(); await tool.click()
check('Tool toggle in one panel does not affect the others', await tool.getAttribute('aria-pressed') === 'true' && await cell('all').locator('.hm-sw-property__tool').getAttribute('aria-pressed') !== 'true')
await tool.click()
const expand = cell('none').locator('.hm-sw-property__expand').first()
if (await expand.count()) {
const h0 = (await cell('none').locator('.hm-sw-property').boundingBox()).height
await expand.click(); const h1 = (await cell('none').locator('.hm-sw-property').boundingBox()).height
const allH = (await cell('all').locator('.hm-sw-property').boundingBox()).height
await expand.click(); const h2 = (await cell('none').locator('.hm-sw-property').boundingBox()).height
check('Group expand is per panel and collapses back to 256', h1 > h0 && allH === 361 && h2 === 256, { h0, h1, h2, allH })
}
const close = cell('circle').getByRole('button', { name: '속성창 닫기', exact: true })
await close.scrollIntoViewIfNeeded(); await close.click()
check('Close button calls onClose of that panel', (await log()).includes('circle: close'), await log())
await page.screenshot({ path: path.join(out, 'compare-after-interaction.png') })
// ---- Preservation of other Stories
await page.setViewportSize({ width: 1920, height: 1000 })
await goto('overview')
check('Overview: Header 49px and Stage render', await page.locator('header.hm-sw-header').first().evaluate(h => h.getBoundingClientRect().height) === 49 && await page.locator('.sw-overview-stage').count() === 1)
await goto('composed-controls')
check('Dropdown Story renders composed controls', await page.locator('.sw-composed-controls').count() > 0)
await goto('window-move-motion')
check('Window Move Motion Story renders', await page.locator('[data-sw-window-move]').count() > 0)
await goto('property-panel')
const trigger = page.locator('.hm-sw-header [data-property-panel-trigger]')
await trigger.click(); const opened = await page.locator('.hm-sw-property').count()
await page.keyboard.press('Escape'); await page.keyboard.press('Escape')
check('Property Panel (Header-connected) still opens; Escape closes', opened === 1 && await page.locator('.hm-sw-property').count() === 0, { opened })
for (const id of ['property-shape-slot', 'property-fields', 'property-bar', 'layer-detail-input', 'k-guide']) {
await goto(id); check(`render ${id}`, !(await page.locator('.sb-errordisplay').isVisible()))
}
check('no browser console or page errors', report.errors.length === 0, report.errors)
}
} finally {
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2) + '\n')
await browser.close(); server?.close()
}
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)