// 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 [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)