Files
HMDesign/packages/sw/tests/header-composition.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

123 lines
12 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.
// SWHeader hiddenControls / menuOrder regression on a test-only fixture (2026-10-20). The Composition Story that showed
// these props was removed in 5faa1f1 (approved); this suite keeps its eleven presets × seven widths (detail-buttons
// composition-history) and adds the documented edge cases: unknown / duplicate ids, every hideable part, runtime changes
// that keep host values and call no callback. Build the package first: npm run build -w @hm-design/sw
// Usage: node packages/sw/tests/header-composition.browser.mjs <new-evidence-dir>
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import http from 'node:http'
import { fileURLToPath } from 'node:url'
import { loadPlaywright } from './playwright-runtime.mjs'
const out = path.resolve(process.argv[2] ?? '')
if (!process.argv[2]) throw new Error('Pass a new evidence directory')
const site = path.join(out, 'fixture')
await mkdir(site, { recursive: true })
const require = createRequire(import.meta.url)
await require('esbuild').build({
entryPoints: [fileURLToPath(new URL('./fixtures/header-composition.tsx', import.meta.url))], bundle: true, outdir: site, format: 'esm', jsx: 'automatic', minify: true,
loader: { '.ttf': 'file', '.woff': 'file', '.woff2': 'file', '.svg': 'file', '.png': 'file' }, define: { 'process.env.NODE_ENV': '"production"' }, logLevel: 'warning',
})
await writeFile(path.join(site, 'index.html'), '<!doctype html><html lang="ko"><head><meta charset="utf-8"><link rel="icon" href="data:,"><link rel="stylesheet" href="header-composition.css"></head><body style="margin:0"><div id="root"></div><script type="module" src="header-composition.js"></script></body></html>')
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(site, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(site, 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))
const report = { checks: [], errors: [] }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); if (!ok) console.error('FAIL', name, JSON.stringify(data)) }
const { chromium } = loadPlaywright()
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
report.browser = browser.version()
const page = await browser.newPage({ viewport: { width: 1920, height: 900 } })
page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const header = page.locator('[data-composition-fixture] header.hm-sw-header')
const set = async composition => {
await page.evaluate(next => window.__setComposition(next), composition)
await page.waitForFunction(next => document.querySelector('[data-composition-fixture]')?.dataset.order === (next.order?.join(',') ?? 'default') && document.querySelector('[data-composition-fixture]')?.dataset.hidden === (next.hidden ?? []).join(','), composition)
// The fields measure themselves (ResizeObserver); wait until the field boxes hold for three frames.
await header.evaluate(h => new Promise(resolve => { let last = '', stable = 0; const tick = () => { const now = [...h.querySelectorAll('.hm-sw-header__field, .hm-sw-header__more-trigger')].map(e => { const r = e.getBoundingClientRect(); return [r.x, r.width].join() }).join('|'); stable = now === last ? stable + 1 : 0; last = now; stable >= 3 ? resolve() : requestAnimationFrame(tick) }; requestAnimationFrame(tick) }))
}
// Header height, overflow and the menus inside its fields in DOM order (detail-buttons' reading, plus bare field buttons:
// without dropdown data a menu renders as the field itself, not inside a wrapper).
const composedMenus = h => ({ height: h.getBoundingClientRect().height, overflow: h.scrollWidth - h.clientWidth, menus: [...h.querySelectorAll('.hm-sw-header__fields > *')].filter(e => e.matches('.hm-sw-header__field') || e.querySelector('.hm-sw-header__field')).map(e => e.dataset.control ?? ({ layer: 'layer', color: 'color', 'line-style': 'lineStyle', 'text-style': 'textStyle', 'dimension-style': 'dimensionStyle' }[e.dataset.menu] ?? (e.classList.contains('hm-sw-header__layer') ? 'layer' : null))) })
const ALL = ['layer', 'color', 'lineStyle', 'textStyle', 'dimensionStyle']
// Every hideable part except the menus (checked through composedMenus) and the sub-items (Hidden Sub Controls Story).
const PARTS = {
home: h => h.locator('button.hm-sw-header__home').count(), document: h => h.locator('.hm-sw-header__document').count(), drawingMode: h => h.locator('.hm-sw-header__mode').count(),
details: h => h.getByRole('group', { name: '상세보기', exact: true }).count(), propertyBar: h => h.getByRole('button', { name: '속성Bar', exact: true }).count(), propertyPanel: h => h.getByRole('button', { name: '속성창', exact: true }).count(),
print: h => h.locator('.hm-sw-header__action--print').count(), popupGuide: h => h.locator('.hm-sw-header__action--guide').count(), windowMove: h => h.locator('[data-sw-window-move]').count(),
minimize: h => h.locator('.hm-sw-header__window-button--minimize').count(), close: h => h.locator('.hm-sw-header__window-button--close').count(),
}
const present = async () => Object.fromEntries(await Promise.all(Object.entries(PARTS).map(async ([id, count]) => [id, await count(header) > 0])))
try {
await page.goto(`http://127.0.0.1:${server.address().port}/`)
await header.waitFor(); await page.evaluate(() => document.fonts.ready)
await set({})
const defaults = await present()
check('default: every part and the five menus in the approved order', Object.values(defaults).every(Boolean) && JSON.stringify((await header.evaluate(composedMenus)).menus) === JSON.stringify(ALL), { defaults, ...(await header.evaluate(composedMenus)) })
// 1. The eleven Composition presets of 2026-10-19 at seven widths (expected menus unchanged from that Story).
const hideable = [...Object.keys(PARTS).slice(0, 3), ...ALL, ...Object.keys(PARTS).slice(3)]
const presets = [
['기본 전체', []], ['구성 A', ['drawingMode', 'textStyle', 'dimensionStyle', 'details', 'propertyBar', 'propertyPanel', 'print', 'popupGuide', 'windowMove', 'minimize', 'close']],
['구성 B (레이어·선스타일)', hideable.filter(id => id !== 'layer' && id !== 'lineStyle')], ['구성 C (로고만)', hideable],
['파일 목록 제거', ['document']], ['레이어 제거', ['layer']], ['색상 제거', ['color']], ['스타일 그룹 제거', ['lineStyle', 'textStyle', 'dimensionStyle']],
['여러 메뉴 제거', ['document', 'color', 'textStyle', 'print', 'minimize']],
['순서 변경', [], ['color', 'layer', 'dimensionStyle', 'lineStyle', 'textStyle']], ['숨김 + 순서 변경', ['layer', 'textStyle'], ['dimensionStyle', 'textStyle', 'color', 'layer']],
]
const orders = [...Array(9).fill(ALL), ['color', 'layer', 'dimensionStyle', 'lineStyle', 'textStyle'], ['dimensionStyle', 'textStyle', 'color', 'layer', 'lineStyle']]
for (const [p, [name, hidden, order]] of presets.entries()) for (const width of [1920, 1440, 1080, 900, 768, 640, 390]) {
await page.setViewportSize({ width, height: 900 }); await set({ hidden, order })
const actual = await header.evaluate(composedMenus), expected = orders[p].filter(id => !hidden.includes(id))
check(`preset ${name} @${width}: menus, 49px, no overflow`, actual.height === 49 && actual.overflow === 0 && JSON.stringify(actual.menus) === JSON.stringify(expected), { actual, expected })
const more = header.locator('.hm-sw-header__more-trigger')
if (await more.isVisible()) { await more.click(); const items = await header.locator('.hm-sw-header__more-item').allTextContents(); check(`preset ${name} @${width}: nonempty More`, items.length > 0, items); await page.keyboard.press('Escape') }
if (width === 1920) {
const parts = await present(), wrong = Object.entries(parts).filter(([id, shown]) => shown === hidden.includes(id)).map(([id]) => id)
check(`preset ${name} @1920: exactly the listed parts removed`, wrong.length === 0, { wrong, parts })
if (hidden.includes('home')) check(`preset ${name}: hidden home keeps the logo as a static image`, await header.locator('.hm-sw-header__home').count() === 1 && await header.locator('button.hm-sw-header__home').count() === 0)
}
}
await page.setViewportSize({ width: 1920, height: 900 })
// 2. Invalid settings: unknown / duplicate menuOrder ids are ignored, missing menus follow in the default order.
await set({ order: ['color', 'color', 'unknown', 'layer'] })
check('menuOrder: duplicate and unknown ids ignored, missing menus follow in default order', JSON.stringify((await header.evaluate(composedMenus)).menus) === JSON.stringify(['color', 'layer', 'lineStyle', 'textStyle', 'dimensionStyle']), await header.evaluate(composedMenus))
await set({ order: ['dimensionStyle', 'drawingMode', 'print'] })
check('menuOrder: non-menu ids ignored; other parts never move', JSON.stringify((await header.evaluate(composedMenus)).menus) === JSON.stringify(['dimensionStyle', 'layer', 'color', 'lineStyle', 'textStyle']) && Object.values(await present()).every(Boolean))
await set({ order: [] })
check('menuOrder: empty list keeps the default order', JSON.stringify((await header.evaluate(composedMenus)).menus) === JSON.stringify(ALL))
await set({ hidden: ['bogus', 'color', 'color'] })
check('hiddenControls: unknown and duplicate ids ignored', JSON.stringify((await header.evaluate(composedMenus)).menus) === JSON.stringify(['layer', 'lineStyle', 'textStyle', 'dimensionStyle']) && Object.values(await present()).every(Boolean))
// 3. Runtime changes keep host values and call no callback; a reordered menu still opens its own dropdown.
await set({})
await header.getByRole('button', { name: '레이어', exact: true }).click()
await page.locator('.hm-sw-header__layer-item').filter({ hasText: 'WALL' }).first().click()
const selected = await page.evaluate(() => [...window.__callbacks])
check('layer selection reaches the host once', selected.filter(line => line === 'onLayerSelect(wall)').length === 1, selected)
for (const composition of [{ order: ['dimensionStyle', 'color', 'layer'] }, { hidden: ['layer'] }, { hidden: ['layer'], order: ['color'] }, {}]) {
await set(composition)
const field = header.getByRole('button', { name: '레이어', exact: true })
check(`change to ${JSON.stringify(composition)}: no callback; layer value kept while shown`, (await page.evaluate(() => window.__callbacks.length)) === selected.length && (composition.hidden?.includes('layer') ? await field.count() === 0 : (await field.textContent()).includes('WALL')))
}
await set({ order: ['dimensionStyle', 'color', 'layer'] })
await header.getByRole('button', { name: '레이어', exact: true }).click()
check('reordered layer field opens its own dropdown and calls only onLayerOpen', await page.locator('.hm-sw-header__layer-panel, .hm-sw-header__layer-menu').count() > 0 && (await page.evaluate(() => window.__callbacks.slice(-1)[0])) === 'onLayerOpen')
await page.keyboard.press('Escape')
check('Escape closes it and returns focus to the moved field', await page.locator('.hm-sw-header__layer-panel, .hm-sw-header__layer-menu').count() === 0 && await header.getByRole('button', { name: '레이어', exact: true }).evaluate(e => e === document.activeElement))
await page.screenshot({ path: path.join(out, 'reordered-1920.png') })
check('no browser console or page errors', report.errors.length === 0, report.errors)
} catch (error) { check('suite completed', false, error.stack) }
finally {
await browser.close(); server.close()
report.status = report.checks.every(c => c.status === 'PASS') ? 'PASS' : 'FAIL'
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2) + '\n')
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)
process.exitCode = report.status === 'PASS' ? 0 : 1
}