Files
HMDesign/packages/sw/tests/property-panel-comparison.browser.mjs
T
shkim4 258e0a4b68
Deploy Storybook / deploy (push) Successful in 1m11s
docs(storybook): tidy SW Storybook gauges, responsive rows and page titles
- SWPopup Custom Content and the SW Viewer options panel use SWSlider style B
  (appearance="property") only; the A/B comparison, the style select and the
  green panel gauge are gone (SWViewerSlider itself is unchanged).
- SW Header / Viewer / Footer Responsive More: the 1920px row shows only when the
  content Inner is 1920px or wider (container query); Footer rows fit the page
  width without scrollers so ▲ More handles narrow widths.
- Logo pages fit without scrollers; SW Header Logo Slot is "Logo" in the sidebar
  with the title "SW Header — 로고" (ID sw-header--logo-slot kept).
- Property Panel (was "Property Panel · 4개 비교"): no Inner scroller, the page
  scrolls, 2 × 2 grid with one column below 720px.
- SW Footer drops the "실제 Footer 조합 (동작)" heading; SW Viewer Header page is
  titled "SW Viewer Header" with 기본형 and 최소화 · 닫기 버튼 only.
- Browser tests, inventory and README follow these changes; visual baselines are
  not updated.
2026-10-22 19:42:20 +09:00

205 lines
17 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')
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.
const oneColumn = width < 720
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)