- 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.
123 lines
12 KiB
JavaScript
123 lines
12 KiB
JavaScript
// 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
|
||
}
|