// 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 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'), '
') 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 }