Files
HMDesign/packages/sw/tests/dropdown-consolidation.browser.mjs
T
shkim4 2fc1b3e108 fix(sw): 2026-10-21 SW design review and responsive header without More from 760px
User-approved design review (2026-10-21):
- SW Header: from 760px up nothing moves into More; the five menus stay with their
  titles (titles and values ellipsize), file name shrinks to 96px. Open next to the
  logo no longer blocks field shrinking above 760px (760/768 overflow fixed, also for
  configured `controls` headers). Dropdown yellow ring inside the button, black seam.
- Property Bar values as read-only red text; property popup "=" labels, read-only
  black values, per-entry hover with pointer.
- Home Menu recent files (3 rows, path + name caption, empty preview), Footer + slot
  guide off by default, Viewer file list without icons, Storybook widths 1920-760.
- Tests: old More expectations (integrated-improvement, keyboard-dismiss,
  footer-popup-viewer-refinement, storybook-cleanup-stage1) moved to the new rule;
  functional checks kept.
2026-10-21 17:29:25 +09:00

306 lines
31 KiB
JavaScript

// Build Storybook first. Historical QA is preserved; use a new evidence directory for every run.
// Usage: node packages/sw/tests/dropdown-consolidation.browser.mjs [evidence-dir]
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import http from 'node:http'
import { parse } from '@babel/parser'
import { retiredDropdownIds, swStoryEntries, retainedInventory, inventorySignature, laterRetiredHeaderIds, headerInventoryIssues, withApprovedEntryChanges, approvedAddedStoryIds, movedToPopupIds, headerSourceIssues, approvedMainCommit, integratedChangedFiles } from './storybook-inventory.mjs'
import { loadPlaywright } from './playwright-runtime.mjs'
import { sourceChanges, unapprovedChanges } from './source-changes.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-dropdown-page-qa')
await mkdir(out, { recursive: true })
const report = { baseCommit: 'c165753bbe24de6581c66704bde4ba3246049ec2', checks: [], errors: [], callbacks: [], geometry: [] }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ` ${JSON.stringify(data)}`}`) }
const before = JSON.parse(await readFile('docs/sw/storybook-dropdown-consolidation-qa/before-full-index.json', 'utf8'))
const after = JSON.parse(await readFile('storybook-static/index.json', 'utf8'))
const beforeSW = swStoryEntries(before), afterSW = swStoryEntries(after)
const previous = JSON.parse(await readFile('docs/sw/storybook-dropdown-page-qa/before-full-index.json', 'utf8'))
const previousSW = swStoryEntries(previous)
report.menuCorrectionBaseCommit = '9482d45f340981c4b4ad6537cb678bf4afcdc681'
await writeFile(path.join(out, 'after-full-index.json'), JSON.stringify(after, null, 2) + '\n')
await writeFile(path.join(out, 'story-inventory.json'), JSON.stringify({ before: beforeSW, after: afterSW, removed: beforeSW.filter(e => retiredDropdownIds.includes(e.id)) }, null, 2) + '\n')
// 2026-10-20 (test alignment): each inventory and source check asserts the consolidation on its recorded state (the
// index of the c705a1f smoke run and the c705a1f source, read from Git) and, for today, only the approved later
// changes listed in storybook-inventory.mjs. The browser checks below run unchanged on the current Storybook.
report.consolidationCommit = 'c705a1fc59fe8ecbce663bf4601638fde43553a1'
const git = (...args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 26 })
const recorded = JSON.parse(git('show', `${report.consolidationCommit}:docs/sw/storybook-dropdown-page-qa/smoke/after-full-index.json`))
const recordedSW = swStoryEntries(recorded)
const signature = list => JSON.stringify(inventorySignature(list))
const now = headerInventoryIssues(beforeSW, after)
check('28 -> 24 SW Stories, exactly four approved Dropdown retirements (recorded at c705a1f); now only approved later changes', beforeSW.length === 28 && recordedSW.length === 24 &&
signature(retainedInventory(beforeSW)) === signature(recordedSW) && now.ok, { recorded: recordedSW.length, ...now })
check('Dropdown is one Story directly under Header with original ID, no Dropdown folder', afterSW.filter(e => e.name === 'Dropdown').length === 1 && after.entries['sw-header--composed-controls']?.title === 'SW/Header' && !afterSW.some(e => e.title.startsWith('SW/Header/')))
const otherRetained = list => list.filter(e => e.id !== 'sw-header--composed-controls')
const keptNow = otherRetained(previousSW).filter(e => !laterRetiredHeaderIds.includes(e.id))
check('menu correction keeps all 24 IDs; only composed Story name/title change (recorded at c705a1f); the other entries now differ only by approved changes',
previousSW.length === 24 && recordedSW.length === 24 && signature(retainedInventory(previousSW)) === signature(recordedSW) && JSON.stringify(otherRetained(previousSW)) === JSON.stringify(otherRetained(recordedSW)) &&
JSON.stringify(keptNow.map(withApprovedEntryChanges)) === JSON.stringify(keptNow.map(e => after.entries[e.id])), keptNow.filter(e => JSON.stringify(withApprovedEntryChanges(e)) !== JSON.stringify(after.entries[e.id])).map(e => e.id))
const nonSW = index => Object.values(index.entries).filter(e => !e.title.startsWith('SW/Header'))
// Today: entries outside SW keep their values and order; SW entries outside SW/Header are exactly the approved additions and the Popup move.
const outsideSW = index => JSON.stringify(Object.values(index.entries).filter(e => !e.id.startsWith('sw-')))
const otherSW = { actual: nonSW(after).filter(e => e.id.startsWith('sw-')).map(e => e.id).sort(), expected: [...approvedAddedStoryIds.filter(id => !id.startsWith('sw-header--')), ...movedToPopupIds].sort() }
check('all non-SW index entries identical (recorded at c705a1f and now); other SW titles hold only approved additions and the Popup move', JSON.stringify(nonSW(before)) === JSON.stringify(nonSW(recorded)) &&
outsideSW(before) === outsideSW(after) && JSON.stringify(otherSW.actual) === JSON.stringify(otherSW.expected), otherSW)
const sourcePath = 'src/sw/sw-header.stories.tsx'
const original = execFileSync('git', ['show', `${report.baseCommit}:${sourcePath}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n')
const source = (await readFile(sourcePath, 'utf8')).replaceAll('\r\n', '\n')
const declarations = text => Object.fromEntries(parse(text, { sourceType: 'module', plugins: ['typescript', 'jsx'] }).program.body.map(n => {
const d = n.type === 'ExportNamedDeclaration' ? n.declaration : n
return [d.type === 'VariableDeclaration' ? d.declarations[0].id.name : d.id?.name ?? d.type, text.slice(n.start, n.end)]
}))
const oldDeclarations = declarations(original), currentDeclarations = declarations(source)
const removed = ['LayerDropdown', 'ColorDropdown', 'LineStyleDropdown', 'TextDimensionStyleDropdown', 'ComposedControls', 'IconMatrix', 'ICON_LAYERS', 'NARROW_LAYER_WIDTHS', 'MENU_BUTTON_HOVER']
const ignored = [...removed, 'ImportDeclaration', 'meta', 'ComposedDemo']
const declarationsAt = commit => declarations(git('show', `${commit}:${sourcePath}`).replaceAll('\r\n', '\n'))
const consolidated = declarationsAt(report.consolidationCommit)
const sinceConsolidation = headerSourceIssues([...new Set([...Object.keys(consolidated), ...Object.keys(currentDeclarations)])].filter(key => !ignored.includes(key)), consolidated, currentDeclarations, declarationsAt)
check('all retained declarations identical outside imports, meta and ComposedDemo (at c705a1f); later changes only by approved commits at their approved content',
Object.entries(oldDeclarations).filter(([key]) => !ignored.includes(key)).every(([key, value]) => consolidated[key] === value) && sinceConsolidation.length === 0, sinceConsolidation)
const untouched = ['packages/sw/src', 'packages/sw/ai', 'packages/sw/package.json', 'src/sw/sw-header-overview.tsx', 'src/sw/sw-stories.css', 'src/sw/assets', '.storybook']
// Today the same paths may differ from the approved main merge only in the integrated-improvement files (as in overview.browser.mjs).
// Untracked new files count as changes (source-changes.mjs).
const sourceDiff = { consolidation: git('diff', '--name-only', report.baseCommit, report.consolidationCommit, '--', ...untouched).trim(),
unapproved: unapprovedChanges(approvedMainCommit, untouched, integratedChangedFiles),
sinceBaseline: sourceChanges(report.baseCommit, untouched).length }
check('runtime, API, AI template, Overview, existing CSS/assets and Storybook configuration untouched by the consolidation; unchanged since the approved main merge', sourceDiff.consolidation === '' && sourceDiff.unapproved.length === 0, sourceDiff)
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.png': 'image/png' }
const server = http.createServer(async (req, res) => {
try {
const pathname = new URL(req.url, 'http://localhost').pathname
const file = path.resolve(root, '.' + decodeURIComponent(pathname === '/' ? '/index.html' : pathname))
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))
const base = `http://127.0.0.1:${server.address().port}`
const { chromium } = loadPlaywright()
let browser
try {
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: 1440, height: 1000 } })
page.setDefaultTimeout(6000)
page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()); if (m.type() === 'info' && m.text().startsWith('[SWHeader Composed] ')) report.callbacks.push(m.text().slice(20)) })
const go = async (id = 'composed-controls') => {
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
await page.locator('.hm-sw-theme, .hm-sw-header').first().waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
}
for (const entry of process.argv[3] === 'followup' ? [] : afterSW) {
await go(entry.id.replace('sw-header--', ''))
check(`render ${entry.id}`, !await page.locator('.sb-errordisplay').isVisible())
}
await go()
const host = page.locator('[data-composed-controls]')
const field = name => host.getByRole('button', { name, exact: true })
const menu = () => page.locator('[data-composed-controls] .hm-sw-header__menu-panel, [data-composed-controls] .hm-sw-header__layer-menu')
const events = name => report.callbacks.filter(line => line.startsWith(name)).length
const once = async (name, action) => { const previous = events(name); await action(); check(`${name} callback exactly once`, events(name) === previous + 1, report.callbacks.slice(-5)) }
const labels = ['레이어', '색상', '선스타일', '문자스타일', '치수스타일']
check('Dropdown page contains the Composed Controls group', await page.getByRole('heading', { name: 'Dropdown', level: 1, exact: true }).count() === 1 && await page.getByRole('heading', { name: 'Composed Controls', level: 2, exact: true }).count() === 1 && await page.getByRole('group', { name: 'Composed Controls', exact: true }).count() === 1)
if (process.argv[3] !== 'followup') {
check('one copy of five Dropdown fields and the details group', await host.locator('.hm-sw-header__field').count() === 5 && await host.getByRole('group', { name: '상세보기', exact: true }).count() === 1 && await field('속성Bar').count() === 1 && await field('속성창').count() === 1)
const hitTest = locator => locator.evaluate(e => {
const r = e.getBoundingClientRect()
return { x: r.x, y: r.y, w: r.width, h: r.height, right: r.right, bottom: r.bottom, hit: e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) }
})
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1000 })
const geometry = await host.evaluate(e => ({ overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
fields: [...e.querySelectorAll('.hm-sw-header__field')].map(f => ({ width: f.getBoundingClientRect().width, height: f.getBoundingClientRect().height })) }))
report.geometry.push({ width, ...geometry })
check(`composition wraps without page overflow or scaling at ${width}`, geometry.overflow === 0 && geometry.fields.every(f => f.height === 38) && JSON.stringify(geometry.fields.map(f => f.width)) === JSON.stringify([234, 192, 284, 164, 164]), geometry)
for (const label of labels) {
const trigger = field(label)
await trigger.hover()
check(`${label} Hover does not open at ${width}`, await menu().count() === 0)
await trigger.click()
const box = await hitTest(menu())
const footer = page.getByRole('button', { name: `${label} 상세`, exact: true })
check(`${label} overlay in viewport, not clipped or covered at ${width}`, box.x >= 0 && box.right <= width && box.hit && (await hitTest(footer)).hit, box)
check(`${label} selected and expanded at ${width}`, await trigger.getAttribute('aria-expanded') === 'true' && await trigger.getAttribute('data-selected') === 'true')
if (width === 390 || width === 1440) await page.screenshot({ path: path.join(out, `overlay-${labels.indexOf(label)}-${width}.png`), fullPage: true })
await page.keyboard.press('Escape')
check(`${label} Escape closes and restores focus at ${width}`, await menu().count() === 0 && await trigger.evaluate(e => e === document.activeElement))
await trigger.click(); await trigger.click()
check(`${label} trigger toggles closed at ${width}`, await menu().count() === 0)
await trigger.click(); await page.getByRole('heading', { name: 'Dropdown', level: 1, exact: true }).click()
check(`${label} outside click closes at ${width}`, await menu().count() === 0)
}
await page.screenshot({ path: path.join(out, `composed-${width}.png`), fullPage: true })
}
await page.setViewportSize({ width: 1440, height: 1000 })
await field('레이어').focus(); await page.keyboard.press('ArrowDown')
check('keyboard opens layer menu and focuses current row', await page.locator('.hm-sw-header__layer-menu').isVisible() && await page.locator('.hm-sw-header__layer-item').first().evaluate(e => e === document.activeElement))
await page.keyboard.press('ArrowDown')
check('keyboard moves to next layer row', await page.locator('.hm-sw-header__layer-item').nth(1).evaluate(e => e === document.activeElement))
await once('onLayerSelect', () => page.keyboard.press('Enter'))
check('layer selection appears, closes and restores focus', (await field('레이어').textContent()).includes('Sample-Center') && await menu().count() === 0 && await field('레이어').evaluate(e => e === document.activeElement))
await field('레이어').click()
const active = page.locator('.hm-sw-header__layer-row[data-active]')
await active.locator('.hm-sw-header__layer-item').hover()
check('selected layer retains original row Hover background', await active.evaluate(e => getComputedStyle(e).backgroundColor === 'rgb(98, 83, 61)'))
for (const [i, name] of ['onLayerVisibilityChange', 'onLayerFreezeChange', 'onLayerLockChange'].entries()) {
const toggle = active.locator('.hm-sw-header__layer-toggle').nth(i)
const pressed = await toggle.getAttribute('aria-pressed')
await once(name, () => toggle.click())
check(`${name} toggles only its own state, menu remains open`, await toggle.getAttribute('aria-pressed') !== pressed && await menu().count() === 1)
}
const disabledLayer = page.locator('.hm-sw-header__layer-row[data-disabled]')
check('disabled layer selection and toggles blocked', await disabledLayer.locator('button:disabled').count() === 4)
await page.keyboard.press('Escape')
const beforeStyles = await page.locator('[data-style-state]').textContent()
await field('색상').click()
check('full color menu: seven palette chips, two user colors, modes, RGB and pick', await page.locator('.hm-sw-header__color-option').count() === 9 && await page.getByRole('button', { name: 'ByLayer', exact: true }).count() === 1 && await page.getByRole('textbox', { name: '색상값 (R,G,B)', exact: true }).count() === 1 && await page.getByRole('button', { name: '색상 추출', exact: true }).count() === 1)
await once('onColorSelect', () => page.locator('.hm-sw-header__color-option').nth(1).click())
check('color palette updates displayed RGB and leaves menu open', (await field('색상').textContent()).includes('241,4,4') && await menu().count() === 1)
await once('onColorModeChange', () => page.getByRole('button', { name: 'ByBlock', exact: true }).click())
check('ByBlock On and displayed mode', (await field('색상').textContent()).includes('ByBlock') && await page.getByRole('button', { name: 'ByBlock', exact: true }).getAttribute('aria-pressed') === 'true')
const rgb = page.locator('.hm-sw-header__color-input input')
await rgb.fill('255,0,0'); await once('onColorInput', () => rgb.press('Enter'))
check('RGB commit clears mode and updates chip', (await field('색상').textContent()).includes('255,0,0') && await field('색상').locator('.hm-sw-header__chip span').evaluate(e => getComputedStyle(e).backgroundColor === 'rgb(255, 0, 0)'))
const count = events('onColorInput'); await rgb.fill('256,0,0'); await rgb.press('Enter')
check('invalid RGB restores without callback', await rgb.inputValue() === '255,0,0' && events('onColorInput') === count)
await once('onColorPick', () => page.getByRole('button', { name: '색상 추출', exact: true }).click())
check('color pick closes and restores focus', await menu().count() === 0 && await field('색상').evaluate(e => e === document.activeElement))
check('color changes leave layer, line, text and dimension values intact', (await page.locator('[data-style-state]').textContent()).replace(/색상: .*? · 선:/, '색상: _ · 선:') === beforeStyles.replace(/색상: .*? · 선:/, '색상: _ · 선:'))
await field('선스타일').click()
check('all original line rows and weights retained', await page.locator('.hm-sw-header__line-column--type .hm-sw-header__menu-row').count() === 5 && await page.locator('.hm-sw-header__line-column').nth(1).locator('.hm-sw-header__menu-row').count() === 4)
await once('onLineTypeSelect', () => page.getByRole('button', { name: 'CENTER', exact: true }).click())
await page.getByRole('button', { name: 'CENTER', exact: true }).hover()
check('selected line retains original row Hover and preview', await page.locator('.hm-sw-header__line-column--type .hm-sw-header__menu-row[data-active]').evaluate(e => getComputedStyle(e).backgroundColor === 'rgb(98, 83, 61)' && !!e.querySelector('.hm-sw-header__line-preview')))
await once('onLineWeightSelect', () => page.getByRole('button', { name: /User/ }).click())
const userWeight = page.locator('.hm-sw-header__line-column').nth(1).getByRole('textbox')
await userWeight.fill('0.5'); await once('onLineWeightValueChange', () => userWeight.press('Enter'))
const scale = page.getByRole('textbox', { name: '스케일', exact: true })
await scale.fill('2'); await once('onLineScaleChange', () => scale.press('Enter'))
check('line type, editable weight and scale update independently; menu stays open', (await field('선스타일').textContent()).includes('CENTER') && (await field('선스타일').textContent()).includes('0.5') && await scale.inputValue() === '2' && await menu().count() === 1)
const scaleCount = events('onLineScaleChange'); await scale.fill('0'); await scale.press('Enter')
check('invalid scale restores, no callback', await scale.inputValue() === '2' && events('onLineScaleChange') === scaleCount)
await page.keyboard.press('Escape')
for (const [label, item, callback] of [['문자스타일', 'Dotum', 'onTextStyleSelect'], ['치수스타일', 'ISO - 25', 'onDimensionStyleSelect']]) {
await field(label).click()
check(`${label} full two-item list retained`, await menu().locator('.hm-sw-header__menu-row').count() === 2)
await once(callback, () => menu().getByRole('button', { name: new RegExp(item) }).click())
check(`${label} selection displayed, closes, focus returns`, (await field(label).textContent()).includes(item) && await menu().count() === 0 && await field(label).evaluate(e => e === document.activeElement))
}
for (const [label, callback] of labels.map((label, i) => [label, ['Layer', 'Color', 'LineStyle', 'TextStyle', 'DimensionStyle'][i]])) {
await field(label).click()
const button = page.getByRole('button', { name: `${label} 상세`, exact: true })
await button.hover()
check(`${label} detail Hover uses existing text opacity`, await button.locator('[data-detail-label]').evaluate(e => getComputedStyle(e).opacity === '0.35'))
await once(`on${callback}Detail`, () => button.click())
check(`${label} detail closes, focus returns, no fabricated popup`, await menu().count() === 0 && await field(label).evaluate(e => e === document.activeElement) && await page.getByRole('dialog').count() === 0)
}
await field('레이어').click(); await field('색상').click()
check('opening another control leaves exactly one menu and preserves selection', await menu().count() === 1 && await field('레이어').getAttribute('aria-expanded') === 'false' && (await field('레이어').textContent()).includes('Sample-Center'))
await page.keyboard.press('Escape')
await field('색상').focus(); await page.keyboard.press('Enter'); await page.keyboard.press('Tab')
check('keyboard Tab enters the open color controls', await menu().evaluate(e => e.contains(document.activeElement)))
await page.keyboard.press('Escape')
} else {
// Recheck only line-weight behavior affected by the shared property-state correction.
await field('선스타일').click()
await page.getByRole('button', { name: /User/ }).click()
const userWeight = page.locator('.hm-sw-header__line-column').nth(1).getByRole('textbox')
await userWeight.fill('0.5'); await userWeight.press('Enter')
await page.waitForFunction(() => document.querySelector('[data-composed-controls] .hm-sw-header__field--line-style')?.textContent.includes('0.5'))
check('User line weight still updates displayed numeric value', (await field('선스타일').textContent()).includes('0.5'))
await page.locator('.hm-sw-header__line-column').nth(1).getByRole('button', { name: 'ByLayer', exact: true }).click()
check('weight mode label preserves the shared numeric value', (await field('선스타일').textContent()).includes('ByLayer') && (await page.locator('[data-style-state]').textContent()).includes('/ 0.5 /'))
await page.locator('.hm-sw-header__line-column').nth(1).getByRole('button', { name: /ByDefault/ }).click()
await page.keyboard.press('Escape')
}
await field('속성Bar').click()
check('details opens existing 28px property Bar', await page.getByRole('region', { name: '속성Bar', exact: true }).evaluate(e => e.getBoundingClientRect().height === 28))
await field('속성창').click()
await page.waitForFunction(() => document.activeElement?.getAttribute('role') === 'dialog')
const panelGeometry = await page.getByRole('dialog').evaluate(e => ({ width: e.getBoundingClientRect().width, focused: e === document.activeElement, active: document.activeElement?.outerHTML }))
check('details opens existing 314px property panel with focus', panelGeometry.width === 314 && panelGeometry.focused, panelGeometry)
await page.screenshot({ path: path.join(out, 'composed-property-panel.png'), fullPage: true })
const radius = page.getByRole('dialog').getByRole('textbox', { name: '반지름', exact: true })
await radius.fill('12.5'); await radius.press('Enter')
// 2026-10-21 (user decision): the Bar shows the value as read-only text (<output>).
await page.waitForFunction(() => document.querySelector('.hm-sw-property-bar [data-property-id="radius"] output')?.textContent === '12.5')
const barRadius = await page.getByRole('region', { name: '속성Bar', exact: true }).locator('[data-property-id="radius"] output').textContent()
check('property Bar and panel share edited state', Number(barRadius) === 12.5, barRadius)
const panelWeight = page.getByRole('dialog').getByRole('textbox', { name: '선가중치', exact: true })
await panelWeight.fill('0.75'); await panelWeight.press('Enter')
await page.waitForFunction(() => document.querySelector('[data-composed-controls] .hm-sw-header__field--line-style')?.textContent.includes('0.75'))
check('property panel weight edit appears in composed line field', (await field('선스타일').textContent()).includes('0.75'))
await page.getByRole('button', { name: '속성창 닫기', exact: true }).click()
check('property panel close returns focus', await page.getByRole('dialog').count() === 0 && await field('속성창').evaluate(e => e === document.activeElement))
await field('속성창').click(); await page.keyboard.press('Escape')
check('property panel Escape returns focus', await page.getByRole('dialog').count() === 0 && await field('속성창').evaluate(e => e === document.activeElement))
await page.setViewportSize({ width: 390, height: 844 }); await field('속성창').click()
await page.waitForFunction(() => document.activeElement?.getAttribute('role') === 'dialog')
const narrowPanel = await page.getByRole('dialog').evaluate(e => { const r = e.getBoundingClientRect(); return { width: r.width, left: r.left, right: r.right, bottom: r.bottom, overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth } })
check('narrow details panel keeps natural width and fits viewport', narrowPanel.width === 314 && narrowPanel.left >= 0 && narrowPanel.right <= 390 && narrowPanel.bottom <= 844 && narrowPanel.overflow === 0, narrowPanel)
await page.screenshot({ path: path.join(out, 'composed-property-panel-390.png'), fullPage: true })
await page.keyboard.press('Escape'); await page.setViewportSize({ width: 1440, height: 1000 })
await field('속성Bar').click()
await page.getByText('시연 설정', { exact: true }).click()
await page.getByLabel('컨트롤 Disabled', { exact: true }).check()
check('all seven actionable controls Disabled', await host.locator('button:disabled').count() === 7 && await menu().count() === 0)
const disabledCount = report.callbacks.length
for (const button of await host.locator('button:disabled').all()) { const box = await button.boundingBox(); await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) }
check('Disabled controls block callbacks and overlays', report.callbacks.length === disabledCount && await menu().count() === 0 && await page.getByRole('dialog').count() === 0)
await page.getByLabel('컨트롤 Disabled', { exact: true }).uncheck()
await page.getByLabel('레이어 상태 표시 전용', { exact: true }).check(); await field('레이어').click()
check('callback-free layer icons become readonly status images', await menu().locator('button.hm-sw-header__layer-toggle').count() === 0 && await menu().getByRole('img').count() > 0)
await page.keyboard.press('Escape'); await page.getByLabel('레이어 상태 표시 전용', { exact: true }).uncheck()
await page.getByLabel('메뉴 상세 Callback 연결', { exact: true }).uncheck(); await field('색상').click()
const noDetailCount = events('onColorDetail')
await page.getByRole('button', { name: '색상 상세', exact: true }).click()
check('missing detail callback keeps safe no-op and menu open', events('onColorDetail') === noDetailCount && await menu().count() === 1)
await page.keyboard.press('Escape'); await page.getByLabel('메뉴 상세 Callback 연결', { exact: true }).check()
for (const scenario of ['long', 'many', 'empty']) {
await page.getByRole('combobox', { name: /목록 데이터/ }).selectOption(scenario); await field('레이어').click()
const rows = page.locator('.hm-sw-header__layer-row')
check(`retained ${scenario} layer scenario`, await rows.count() === ({ long: 3, many: 20, empty: 0 })[scenario])
if (scenario === 'many') check('long layer list scrolls internally', await page.locator('.hm-sw-header__layer-list').evaluate(e => e.scrollHeight > e.clientHeight))
if (scenario === 'long') {
await rows.first().getByRole('button').last().click()
check('long layer name preserved with full title', (await field('레이어').getAttribute('title'))?.includes('0123456789') || (await field('레이어').locator('[title]').first().getAttribute('title'))?.includes('0123456789'))
} else await page.keyboard.press('Escape')
if (scenario === 'many') {
await field('색상').click()
check('all 40 user colors accessible with palette scroll', await page.locator('.hm-sw-header__color-option').count() === 47 && await page.locator('.hm-sw-header__color-palette').evaluate(e => e.scrollHeight > e.clientHeight))
await page.keyboard.press('Escape')
}
}
await page.setViewportSize({ width: 1440, height: 1000 }); await go('overview')
check('Overview keeps one Header at 49px and 16:9 Stage', await page.locator('header.hm-sw-header').count() === 1 && await page.locator('[data-preview-stage]').evaluate(e => Math.abs(e.getBoundingClientRect().width / e.getBoundingClientRect().height - 16 / 9) < 0.001) && await page.locator('header.hm-sw-header').evaluate(e => e.getBoundingClientRect().height === 49))
await go('window-move-motion')
check('Window Move Motion retains only nine 3x steps', JSON.stringify(await page.locator('section > header h2').allTextContents()) === JSON.stringify(['단계별 진행 (3배 확대)']) && await page.locator('[data-motion-time]').count() === 9 && await page.locator('.sw-story-zoom').first().evaluate(e => getComputedStyle(e).zoom === '3'))
// 2026-10-21 (SW Storybook menu cleanup, user request): Dropdown left the sidebar by the verification tag (storybook-inventory
// verificationTaggedIds); its ID and direct URL stay. Read the sidebar with the Header group open (SW Header page),
// since opening the hidden Story itself leaves the group collapsed and would make an absent link trivially true.
await page.goto(`${base}/?path=/story/sw-header--overview`)
await page.locator('a[href="/?path=/story/sw-header--overview"]').waitFor()
const headerLinks = await page.locator('a[href^="/?path=/story/sw-header--"]').evaluateAll(list => list.map(a => a.getAttribute('href')))
check('manager hides the Dropdown link under the open Header group, no Composed Controls submenu', headerLinks.length > 0 && !headerLinks.includes('/?path=/story/sw-header--composed-controls') && await page.getByRole('link', { name: 'Dropdown', exact: true }).count() === 0 && await page.getByRole('link', { name: 'Composed Controls', exact: true }).count() === 0, headerLinks)
await page.goto(`${base}/?path=/story/sw-header--composed-controls`)
await page.locator('#storybook-preview-iframe').waitFor()
await page.frameLocator('#storybook-preview-iframe').locator('[data-composed-controls]').waitFor()
check('Dropdown direct URL still opens the Story (ID sw-header--composed-controls)', await page.frameLocator('#storybook-preview-iframe').locator('[data-composed-controls]').count() === 1)
check('retired Dropdown links absent in manager', await page.locator(retiredDropdownIds.map(id => `a[href*="${id}"]`).join(',')).count() === 0)
await page.screenshot({ path: path.join(out, 'composed-manager.png'), fullPage: true })
check('no browser console or page errors', report.errors.length === 0, report.errors)
} catch (error) { check('suite completes', false, error.stack) }
finally {
if (browser) await browser.close()
await new Promise(resolve => server.close(resolve))
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(JSON.stringify({ status: report.status, checks: report.checks.length, errors: report.errors }))
process.exitCode = report.status === 'PASS' ? 0 : 1
}