// 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 (). 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 }