Files
HMDesign/packages/sw/tests/property-panel-comparison.browser.mjs
T
shkim4 b50e95c325 test(sw): align legacy storybook suites and add header composition fixture
- dropdown-consolidation, storybook-cleanup-stage1, detail-buttons, property-panel-comparison: assert their recorded
  state and accept today only the approved later Story changes (storybook-inventory.mjs helpers).
- header-composition.browser.mjs + fixtures/header-composition.tsx: the removed Composition Story presets, hidden
  controls, invalid menuOrder / hiddenControls and live changes, on a test-only page using the public API only.
- storybook-unused-removal: header comment lists which older scripts run on current Stories again.
2026-10-21 06:08:23 +09:00

212 lines
18 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.
// 2026-10-20 (test alignment): `verify` is a current regression again. The preservation checks of the Stories retired by
// the unused demo removal (5faa1f1) run on current public Stories (Overview for the Header-connected panel; every other
// current SW/Header Story renders), and the inventory accepts only the approved changes in storybook-inventory.mjs.
// `before` stays the recorded capture of the five retired property panel Stories (historical; needs a build before c8e5fbe).
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import http from 'node:http'
import { swStoryEntries, retiredPropertyPanelIds, retiredUnusedDemoIds, approvedAddedStoryIds, headerInventoryIssues } from './storybook-inventory.mjs'
import { loadPlaywright } from './playwright-runtime.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 } = loadPlaywright()
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])))
// 2026-10-20 (test alignment): 24 -> 19 / 343 -> 338 is asserted on the run recorded at c8e5fbe (read from Git); today
// the index may differ from it only by the approved changes of storybook-inventory.mjs (nine unused-demo removals, the
// approved additions, the verification tags and the Popup move).
const recorded = JSON.parse(execFileSync('git', ['show', 'c8e5fbeebc26fba92998fc5ddcd28c6c110e452d:docs/sw/property-panel-comparison-qa/verify/result.json'], { encoding: 'utf8', maxBuffer: 1 << 26 })).data.inventory
const now = headerInventoryIssues(before.sw, index)
report.data.inventory.recorded = { total: recorded.total, sw: recorded.sw }
check('inventory: 24 -> 19 SW Stories, 343 -> 338 total, only 5 approved retirements (recorded at c8e5fbe); now only approved later changes', before.sw.length === 24 && recorded.sw === 19 && before.total === 343 && recorded.total === 338 &&
JSON.stringify(recorded.ids.map(([id]) => id).sort()) === JSON.stringify(expected.map(e => e.id).sort()) && retiredPropertyPanelIds.every(id => !index.entries[id]) &&
stories.length === 338 - retiredUnusedDemoIds.length + approvedAddedStoryIds.length && now.unexpected.length === 0 && now.missing.length === 0 && now.retiredPresent.length === 0, { before: [before.total, before.sw.length], recorded: [recorded.total, recorded.sw], after: [stories.length, sw.length], ...now })
check('inventory: retained SW Story IDs, names, titles, tags unchanged (recorded at c8e5fbe); now only the approved tags and Popup move', JSON.stringify(recorded.ids) === JSON.stringify(expected.map(e => [e.id, e.name])) && now.changed.length === 0, { retained: sw.length, changed: now.changed })
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)
// 2026-10-20: the Property Panel page was removed (5faa1f1); the Header-connected panel is the one of Overview.
await goto('overview')
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, Overview) still opens; Escape closes', opened === 1 && await page.locator('.hm-sw-property').count() === 0, { opened })
// Every other current SW/Header Story renders (the original list named the then-retained Stories; four were removed in 5faa1f1).
for (const id of sw.map(e => e.id.replace('sw-header--', '')).filter(id => !['overview', 'composed-controls', 'window-move-motion'].includes(id))) {
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)