Files
HMDesign/packages/sw/tests/dropdown-consolidation.browser.mjs
T

272 lines
27 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 { createRequire } from 'node:module'
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 } from './storybook-inventory.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')
check('28 -> 24 SW Stories, exactly four approved Dropdown retirements', beforeSW.length === 28 && afterSW.length === 24 &&
JSON.stringify(inventorySignature(retainedInventory(beforeSW))) === JSON.stringify(inventorySignature(afterSW)))
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/')))
check('menu correction keeps all 24 IDs; only composed Story name/title change', previousSW.length === 24 && afterSW.length === 24 && JSON.stringify(inventorySignature(retainedInventory(previousSW))) === JSON.stringify(inventorySignature(afterSW)) && JSON.stringify(previousSW.filter(e => e.id !== 'sw-header--composed-controls')) === JSON.stringify(afterSW.filter(e => e.id !== 'sw-header--composed-controls')))
const nonSW = index => Object.values(index.entries).filter(e => !e.title.startsWith('SW/Header'))
check('all non-SW index entries identical', JSON.stringify(nonSW(before)) === JSON.stringify(nonSW(after)))
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']
check('all retained declarations identical outside imports, meta and ComposedDemo', Object.entries(oldDeclarations).filter(([key]) => ![...removed, 'ImportDeclaration', 'meta', 'ComposedDemo'].includes(key)).every(([key, value]) => currentDeclarations[key] === value))
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']
check('runtime, API, AI template, Overview, existing CSS/assets and Storybook configuration untouched', !execFileSync('git', ['diff', report.baseCommit, '--', ...untouched], { encoding: 'utf8' }))
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 } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
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')
await page.waitForFunction(() => document.querySelector('.hm-sw-property-bar [data-property-id="radius"] input')?.value === '12.5')
const barRadius = await page.getByRole('region', { name: '속성Bar', exact: true }).getByRole('textbox', { name: '반지름', exact: true }).inputValue()
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'))
await page.goto(`${base}/?path=/story/sw-header--composed-controls`)
await page.locator('#storybook-preview-iframe').waitFor()
const dropdownLink = page.getByRole('link', { name: 'Dropdown', exact: true })
await dropdownLink.waitFor()
await dropdownLink.scrollIntoViewIfNeeded()
check('manager shows a single Dropdown link directly under Header, no Composed Controls submenu', await dropdownLink.count() === 1 && await dropdownLink.getAttribute('href') === '/?path=/story/sw-header--composed-controls' && await page.getByRole('link', { name: 'Composed Controls', exact: true }).count() === 0)
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
}