- 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.
212 lines
18 KiB
JavaScript
212 lines
18 KiB
JavaScript
// 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)
|