Files
HMDesign/packages/sw/tests/property-panel-comparison.browser.mjs
shkim4 6c61edadc1 fix(storybook): refine responsive page inner width
- Story page Inner side margin: 16px up to a 480px viewport, linear to 60px at 768px, 60px beyond (no width jump)
- Follow the 60px margins: Responsive 1920 row from a 2040px page (was 2000px); property compare 3 columns from 1125px, 2 columns from 787px (was 1080 / 720)
- SW browser tests: drop the removed --sb-page-max-width override, compare thresholds 787 / 1125
2026-11-05 15:59:41 +09:00

213 lines
18 KiB
JavaScript
Raw Permalink 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')
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 }
// 2026-10-22 (user request): no Inner scroller; the page scrolls. 2 columns × 2 rows, one column below 720px.
// 2026-10-26 (user request): from 1080px, 3 columns — none/all stacked in column 1, circle and expanded beside them.
// 2026-11-05: the Story page Inner keeps 60px on each side (story-page.css), so the steps moved to 787px / 1125px
// (sw-stories.css .sw-story-compare--grid). It keeps 16px up to 480px (growing to 60px at 768px), so 390px has 358px.
const oneColumn = width < 787, threeColumns = width >= 1125
if (threeColumns) {
const gaps = { stack: b.cell.y - a.cell.bottom, col1: c.cell.x - Math.max(a.cell.right, b.cell.right), col2: d.cell.x - c.cell.right }
report.data[`gaps${width}`] = gaps
check(`${width}: 3 columns — none/all stacked, circle and expanded side by side`, Math.abs(a.cell.x - b.cell.x) < 0.5 && Math.abs(a.cell.y - c.cell.y) < 0.5 && Math.abs(a.cell.y - d.cell.y) < 0.5 && c.cell.x >= a.cell.right && d.cell.x >= c.cell.right, { a: a.cell, b: b.cell, c: c.cell, d: d.cell })
check(`${width}: consistent 24px stack and column gaps`, Object.values(gaps).every(v => Math.abs(v - 24) < 0.5), gaps)
} else if (oneColumn) check(`${width}: one column in order`, [a, b, c, d].every((cell, i, all) => i === 0 || (Math.abs(cell.cell.x - a.cell.x) < 0.5 && cell.cell.y >= all[i - 1].cell.bottom)), { a: a.cell, b: b.cell, c: c.cell, d: d.cell })
else {
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}: no Inner scroll (wrapper does not scroll)`, g.scroller.scrollW <= g.scroller.clientW && g.scroller.scrollH <= g.scroller.clientH, g.scroller)
check(`${width}: no horizontal page scroll; panels inside the page width`, g.doc.scrollW <= g.doc.clientW && g.cells.every(cell => cell.panel.right <= g.doc.clientW + 0.5), g.doc)
await page.screenshot({ path: path.join(out, `compare-${width}-top.png`), fullPage: true })
}
// ---- 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 page while open (2026-10-22: no Inner scroller): the fixed overlay must stay attached to its field.
const moved = await page.evaluate(() => { const t = scrollY; scrollBy(0, 120); return scrollY - 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 page scroll (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)