Files
HMDesign/packages/sw/tests/storybook-unused-removal.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

240 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Current regression after removing nine unused SW Header demo pages (2026-10-19, 19 -> 10 SW Stories).
// Build Storybook first. Usage: node packages/sw/tests/storybook-unused-removal.browser.mjs [evidence-dir]
// Older scripts that open the retired IDs (followup-four, property-close, property-fields, detail-buttons
// composition-history, property-panel-comparison before) are historical evidence for their runs. Since 2026-10-20
// detail-buttons, property-panel-comparison verify and storybook-cleanup-stage1 run on current public Stories again.
import { mkdir, readFile, writeFile } 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 { swStoryEntries, retiredUnusedDemoIds, integratedChangedFiles, movedToPopupIds, approvedMainCommit, approvedAddedStoryIds, withApprovedEntryChanges, withApprovedOrder, approvedHeaderDeclarations, movedHeaderDeclarations } 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-unused-story-removal-qa/verify')
const qa = 'docs/sw/storybook-unused-story-removal-qa/before'
const baseCommit = 'c8e5fbeebc26fba92998fc5ddcd28c6c110e452d'
await mkdir(out, { recursive: true })
const report = { baseCommit, checks: [], errors: [], data: {} }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); if (!ok) console.error('FAIL', name, JSON.stringify(data)) }
// ---- Inventory: exactly the nine requested IDs leave; every other entry keeps ID, name, title, tags and order.
const beforeIndex = JSON.parse(await readFile(path.join(qa, 'full-index-before.json'), 'utf8'))
const afterIndex = JSON.parse(await readFile('storybook-static/index.json', 'utf8'))
const stories = index => Object.values(index.entries).filter(e => e.type === 'story')
const sig = list => JSON.stringify(list.map(e => [e.id, e.name, e.title, e.tags]))
const beforeSW = swStoryEntries(beforeIndex), afterSW = swStoryEntries(afterIndex)
const expected = beforeSW.filter(e => !retiredUnusedDemoIds.includes(e.id))
report.data.inventory = { before: [stories(beforeIndex).length, beforeSW.length], after: [stories(afterIndex).length, afterSW.length], sw: afterSW.map(e => [e.id, e.name]) }
await writeFile(path.join(out, 'story-inventory-after.json'), JSON.stringify({ total: stories(afterIndex).length, sw: afterSW.map(e => ({ id: e.id, name: e.name, title: e.title, tags: e.tags })) }, null, 2) + '\n')
// 2026-10-20 (test alignment): full-index-before.json (c8e5fbe) stays the baseline. After the nine removals (338 -> 329,
// 19 -> 10 SW Stories) the index may differ only by the approved changes of storybook-inventory.mjs: the added SW Stories
// (integrated improvement, home menu, Footer, Viewer, Theme), the verification tags, the Popup move and the SW sidebar order.
const added = stories(afterIndex).filter(e => !beforeIndex.entries[e.id])
const retainedSW = Object.values(afterIndex.entries).filter(e => expected.some(entry => entry.id === e.id))
check('inventory: 19 -> 10 retained SW Stories, 338 -> 329 total plus only the approved added Stories', beforeSW.length === 19 && expected.length === 10 && retainedSW.length === 10 && stories(beforeIndex).length === 338 && stories(afterIndex).length === 329 + approvedAddedStoryIds.length && JSON.stringify(added.map(e => e.id).sort()) === JSON.stringify([...approvedAddedStoryIds].sort()), { ...report.data.inventory, added: added.map(e => e.id), unapproved: added.map(e => e.id).filter(id => !approvedAddedStoryIds.includes(id)), missing: approvedAddedStoryIds.filter(id => !afterIndex.entries[id]) })
check('inventory: the nine requested IDs existed before and are absent now', retiredUnusedDemoIds.length === 9 && retiredUnusedDemoIds.every(id => beforeIndex.entries[id] && !afterIndex.entries[id]))
check('inventory: retained SW IDs, names, titles, tags and order unchanged except the approved tags and Popup move', sig(withApprovedOrder(expected.map(withApprovedEntryChanges))) === sig(retainedSW), retainedSW.map(e => [e.id, e.title, e.tags]))
// Entries outside SW keep their values and order; retained SW entries keep their values (order checked above), and the SW
// block sits right after Center Graphic (2b0e1a2 storySort: SW > Header, Footer, Popup, Viewer, Theme).
const others = index => Object.values(index.entries).filter(e => !retiredUnusedDemoIds.includes(e.id) && !approvedAddedStoryIds.includes(e.id) && !/^sw-(tool|popup)--docs$/.test(e.id))
const outsideSW = list => JSON.stringify(list.filter(e => !e.id.startsWith('sw-')))
const keys = Object.keys(afterIndex.entries), firstSW = keys.findIndex(id => id.startsWith('sw-'))
const swOrder = { previous: keys[firstSW - 1], afterCenterGraphic: afterIndex.entries[keys[firstSW - 1]]?.title.startsWith('Center Graphic/') && !keys.slice(firstSW + 1).some(id => afterIndex.entries[id].title.startsWith('Center Graphic/')), contiguous: keys.filter(id => id.startsWith('sw-')).every((id, i) => keys[firstSW + i] === id) }
check('inventory: every non-removed index entry identical (docs and other packages included) except the approved SW changes', outsideSW(others(beforeIndex)) === outsideSW(others(afterIndex)) && JSON.stringify(others(beforeIndex).filter(e => e.id.startsWith('sw-')).map(withApprovedEntryChanges)) === JSON.stringify(others(beforeIndex).filter(e => e.id.startsWith('sw-')).map(e => afterIndex.entries[e.id])) && swOrder.afterCenterGraphic && swOrder.contiguous, swOrder)
check('inventory: Dropdown keeps sw-header--composed-controls / Dropdown / SW/Header', afterIndex.entries['sw-header--composed-controls']?.name === 'Dropdown' && afterIndex.entries['sw-header--composed-controls']?.title === 'SW/Header')
check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === 'Property Panel · 4개 비교')
// ---- Source: retained Story declarations byte-identical; product, AI template and other packages untouched.
const sourcePath = 'src/sw/sw-header.stories.tsx'
const exports = text => Object.fromEntries(parse(text, { sourceType: 'module', plugins: ['typescript', 'jsx'] }).program.body
.filter(n => n.type === 'ExportNamedDeclaration' && n.declaration).map(n => [n.declaration.declarations?.[0].id.name ?? n.declaration.id.name, text.slice(n.start, n.end)]))
const oldExports = exports(execFileSync('git', ['show', `${baseCommit}:${sourcePath}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n'))
const newExports = exports((await readFile(sourcePath, 'utf8')).replaceAll('\r\n', '\n'))
const removedExports = ['PropertyFields', 'PropertyShapeSlot', 'Composition', 'Colorize', 'DynamicControls', 'PropertyBar', 'AdditionalHover', 'PropertyPanel', 'LayerDetailInput']
check('source: the nine Story exports are removed', removedExports.every(name => oldExports[name] && !newExports[name]))
// 2026-10-20 (test alignment): an export changed or added after c8e5fbe must equal its version at the approving commit;
// the moved Popup Story is compared in its new file. All other retained exports stay byte-identical to c8e5fbe.
const exportsAt = (commit, file) => exports(execFileSync('git', ['show', `${commit}:${file}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n'))
const movedNow = Object.fromEntries(await Promise.all(Object.entries(movedHeaderDeclarations).map(async ([name, [file]]) => [name, exports((await readFile(file, 'utf8')).replaceAll('\r\n', '\n'))[name]])))
const approvedExport = name => approvedHeaderDeclarations[name] && newExports[name] === exportsAt(approvedHeaderDeclarations[name], sourcePath)[name]
const movedExport = name => movedHeaderDeclarations[name] && !newExports[name] && movedNow[name] === exportsAt(movedHeaderDeclarations[name][1], movedHeaderDeclarations[name][0])[name]
const changed = Object.keys(oldExports).filter(name => !removedExports.includes(name) && oldExports[name] !== newExports[name] && !approvedExport(name) && !movedExport(name))
const unapprovedNew = Object.keys(newExports).filter(name => !oldExports[name] && !approvedExport(name))
check('source: retained exports (Stories and ComposedDemo) byte-identical to c8e5fbe or their approving commit; only approved new or moved exports', changed.length === 0 && unapprovedNew.length === 0, { changed, unapprovedNew })
// Other packages, Overview, Dropdown CSF, assets and Storybook config, and the SW package outside the integrated-improvement
// files (verified by their own suites), may differ from the approved main merge in nothing. sinceBaseline lists the approved
// changes after c8e5fbe; update approvedMainCommit (storybook-inventory.mjs) on the next approved merge.
const untouched = ['packages/sw/src', 'packages/sw/ai', 'packages/sw/package.json', 'packages/sw/README.md', 'packages/ui', 'packages/org-chart', 'packages/org-chart-react', 'packages/center-graphic', 'src/sw/sw-header-overview.tsx', 'src/sw/sw-header-dropdown.stories.tsx', 'src/sw/sw-composed-controls.css', 'src/sw/assets', '.storybook']
// Untracked new files count as changes (source-changes.mjs).
const unexpected = unapprovedChanges(approvedMainCommit, untouched, integratedChangedFiles)
check('source: other packages, Overview, Dropdown CSF, assets and Storybook config unchanged since the approved main merge; SW package only within the integrated-improvement files', unexpected.length === 0, { unexpected, sinceBaseline: sourceChanges(baseCommit, untouched).length })
// ---- Browser
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.gif': 'image/gif', '.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(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); 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()
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
report.browser = browser.version()
page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const goto = async id => {
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
}
const noDocOverflow = () => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)
try {
// Sidebar: the manager lists the ten retained Stories in order, none of the nine menus.
await page.goto(`${base}/?path=/story/sw-header--overview`)
await page.frameLocator('#storybook-preview-iframe').locator('[data-preview-stage]').waitFor()
const sidebar = await page.locator('[data-item-id^="sw-header--"]').evaluateAll(list => [...new Map(list.map(e => [e.dataset.itemId, e.textContent.replace('Skip to content', '').trim()])).entries()])
report.data.sidebar = sidebar
await writeFile(path.join(out, 'manager-story-order.json'), JSON.stringify(sidebar, null, 2) + '\n')
// .storybook/main.ts excludes `verification` Stories (Detail Buttons) from the default sidebar; direct URLs stay.
const visible = afterSW.filter(e => !e.tags.includes('verification'))
check('sidebar: retained non-verification Stories in index order with their names', JSON.stringify(sidebar) === JSON.stringify(visible.map(e => [e.id, e.name])), sidebar)
const removedNames = beforeSW.filter(e => retiredUnusedDemoIds.includes(e.id)).map(e => e.name)
check('sidebar: none of the nine removed menu IDs or names remain', sidebar.every(([id, text]) => !retiredUnusedDemoIds.includes(id) && !removedNames.includes(text)), removedNames)
await page.screenshot({ path: path.join(out, 'manager-sidebar.png') })
// A removed URL no longer resolves to a Story.
await page.goto(`${base}/iframe.html?id=sw-header--colorize&viewMode=story`)
await page.waitForTimeout(1500)
check('removed URL renders no SW Story', await page.locator('.hm-sw-header, .hm-sw-theme').count() === 0)
report.errors.length = 0 // the missing-story error above is expected; start the regression clean
// 2026-10-20: Property Panel · 4개 비교 moved to SW/Popup (same sw-header-- ID), so it is added to the render pass.
const moved = movedToPopupIds.map(id => afterIndex.entries[id]).filter(Boolean)
check('inventory: moved Popup Story keeps its sw-header-- ID under SW/Popup', moved.length === movedToPopupIds.length && moved.every(e => e.title === 'SW/Popup'), moved.map(e => [e.id, e.title]))
for (const entry of [...afterSW, ...moved]) {
await goto(entry.id.replace('sw-header--', ''))
check(`render ${entry.id}`, !(await page.locator('.sb-errordisplay').isVisible()))
}
// Overview: Header 49px, Stage, Dropdown open/close, Bar edit, panel open/Escape, callbacks.
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 }); await goto('overview')
const header = page.locator('header.hm-sw-header')
const height = await header.evaluate(h => h.getBoundingClientRect().height)
check(`Overview ${width}: one Header 49px, one Stage, no page overflow`, height === 49 && await header.count() === 1 && await page.locator('[data-preview-stage]').count() === 1 && await noDocOverflow(), { height })
await page.screenshot({ path: path.join(out, `overview-${width}.png`) })
}
await page.setViewportSize({ width: 1440, height: 900 }); await goto('overview')
{
const header = page.locator('header.hm-sw-header'), callback = () => page.locator('[data-preview-callback]').textContent()
const layer = header.getByRole('button', { name: '레이어', exact: true })
await layer.click()
check('Overview: layer Dropdown opens', await header.locator('.hm-sw-header__layer-panel').count() === 1 && await layer.getAttribute('aria-expanded') === 'true')
await page.keyboard.press('Escape')
check('Overview: Escape closes Dropdown and restores focus', await header.locator('.hm-sw-header__layer-panel').count() === 0 && await layer.evaluate(e => e === document.activeElement))
await layer.click(); await header.getByRole('button', { name: '레이어 상세', exact: true }).click()
check('Overview: layer Detail callback (former Layer Detail page) and close', (await callback()).includes('onLayerDetail') && await header.locator('.hm-sw-header__layer-panel').count() === 0)
await header.getByRole('button', { name: '색상', exact: true }).click()
const pick = header.getByRole('button', { name: '색상 추출', exact: true })
const pickBox = await pick.boundingBox()
await pick.click()
check('Overview: btn_colorize 24 × 24 calls onColorPick and closes (former Colorize page)', pickBox.width === 24 && pickBox.height === 24 && (await callback()).includes('onColorPick') && await header.locator('.hm-sw-header__color-menu').count() === 0, pickBox)
// 2026-10-21 (user decision): the Bar values are read-only red text (<output>), never focused; the panel edits them.
const bar = page.locator('.hm-sw-property-bar [data-property-id="radius"] output')
check('Overview: property Bar value is read-only text (no input, no focus stop)', await bar.count() === 1 && await page.locator('.hm-sw-property-bar input').count() === 0 && await bar.evaluate(e => e.tabIndex < 0))
const trigger = header.locator('[data-property-panel-trigger]')
await trigger.click(); await page.locator('.hm-sw-property').waitFor()
const panelRadius = page.locator('.hm-sw-property [data-property-id="radius"] input').first()
check('Overview: panel opens with the shared Bar value', await panelRadius.inputValue() === await bar.textContent())
await panelRadius.fill('6.25'); await panelRadius.press('Enter')
await page.waitForFunction(() => document.querySelector('.hm-sw-property-bar [data-property-id="radius"] output')?.textContent === '6.25')
check('Overview: panel edit commits through callback and updates the Bar', await bar.textContent() === '6.25' && (await callback()).includes('onPropertyChange(radius, 6.25)'))
await panelRadius.fill('-1'); await panelRadius.press('Enter')
check('Overview: panel validation keeps min=0', await panelRadius.inputValue() === '6.25' && await bar.textContent() === '6.25')
await page.keyboard.press('Escape'); await page.keyboard.press('Escape')
check('Overview: Escape closes panel and returns focus to its trigger', await page.locator('.hm-sw-property').count() === 0 && await trigger.evaluate(e => e === document.activeElement))
}
// Dropdown: six controls, real open, selection, Escape and focus return.
await goto('composed-controls')
{
const host = page.locator('[data-composed-controls]'), 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')
check('Dropdown: five fields plus the 상세보기 group (6 controls)', await host.locator('.hm-sw-header__field').count() === 5 && await host.getByRole('group', { name: '상세보기', exact: true }).count() === 1)
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 })
for (const label of ['레이어', '색상', '선스타일', '문자스타일', '치수스타일']) {
await field(label).click()
const opened = await menu().count() === 1 && await field(label).getAttribute('aria-expanded') === 'true'
await page.keyboard.press('Escape')
check(`Dropdown ${width}: ${label} opens, Escape closes, focus returns`, opened && await menu().count() === 0 && await field(label).evaluate(e => e === document.activeElement))
}
check(`Dropdown ${width}: no page overflow`, await noDocOverflow())
await page.screenshot({ path: path.join(out, `dropdown-${width}.png`), fullPage: true })
}
await page.setViewportSize({ width: 1440, height: 900 })
await field('문자스타일').click(); await page.locator('.hm-sw-header__list-menu--text-style').getByRole('button', { name: /Dotum/ }).click()
check('Dropdown: text style selection reflected in field and status', (await field('문자스타일').textContent()).includes('Dotum') && (await page.locator('[data-style-state]').textContent()).includes('Dotum'))
await field('선스타일').click()
const center = page.getByRole('button', { name: 'CENTER', exact: true })
await center.click()
check('Dropdown: line style selection reflected', (await page.locator('[data-style-state]').textContent()).includes('CENTER'))
if (await menu().count()) await page.keyboard.press('Escape')
}
// Comparison: 2 × 2, original sizes, Inner scroll reaches the 754px bottom, input and dropdown work.
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 }); await goto('property-panel-compare')
const g = await page.evaluate(() => {
const r = e => e.getBoundingClientRect(), s = document.querySelector('[data-compare-scroll]')
const cells = [...document.querySelectorAll('[data-compare]')].map(c => ({ id: c.dataset.compare, x: r(c).x, y: r(c).y, right: r(c).right, bottom: r(c).bottom, w: r(c.querySelector('.hm-sw-property')).width, h: r(c.querySelector('.hm-sw-property')).height }))
s.scrollTop = s.scrollHeight; s.scrollLeft = s.scrollWidth
const last = r(document.querySelectorAll('[data-compare] .hm-sw-property')[3]), sr = r(s)
return { cells, reach: last.bottom <= sr.bottom + 0.5 && last.right <= sr.right + 0.5, docFits: document.documentElement.scrollHeight <= innerHeight && document.documentElement.scrollWidth <= innerWidth }
})
const [a, b, c, d] = g.cells
report.data[`compare${width}`] = g
check(`Compare ${width}: four states in order at 314 × 256 / 361 / 658 / 754`, JSON.stringify(g.cells.map(e => [e.id, e.w, e.h])) === JSON.stringify([['none', 314, 256], ['all', 314, 361], ['circle', 314, 658], ['expanded', 314, 754]]), g.cells)
check(`Compare ${width}: 2 columns × 2 rows`, Math.abs(a.y - b.y) < 0.5 && Math.abs(c.y - d.y) < 0.5 && Math.abs(a.x - c.x) < 0.5 && Math.abs(b.x - d.x) < 0.5 && b.x >= a.right && c.y >= Math.max(a.bottom, b.bottom))
check(`Compare ${width}: Inner scroll reaches the 754px panel bottom; page does not scroll`, g.reach && g.docFits, g)
await page.screenshot({ path: path.join(out, `compare-${width}-end.png`) })
}
await page.setViewportSize({ width: 1440, height: 900 }); await goto('property-panel-compare')
{
const cell = id => page.locator(`[data-compare="${id}"]`), log = () => page.locator('[data-property-log]').textContent()
const radius = cell('circle').locator('[data-property-id="radius"] input.hm-sw-header__input').first()
await radius.scrollIntoViewIfNeeded(); await radius.click()
check('Compare: editable Input caret #E60403 (former Layer Detail · Input page)', await radius.evaluate(e => getComputedStyle(e).caretColor === 'rgb(230, 4, 3)'))
await radius.fill('5.5'); await radius.press('Enter')
check('Compare: Input Enter commits only in its own panel', (await radius.inputValue()).startsWith('5.5') && (await log()).includes('circle: property(radius, 5.5'))
await radius.click(); await radius.fill('9'); await radius.press('Escape')
check('Compare: Input Escape cancels', (await radius.inputValue()).startsWith('5.5'))
const trigger = cell('all').locator('[data-property-id="layer"] button[aria-haspopup], [data-property-id="layer"] [aria-expanded]').first()
await trigger.scrollIntoViewIfNeeded(); await trigger.click()
const drop = page.locator('.hm-sw-property__drop').first(); await drop.waitFor({ timeout: 2000 })
await drop.getByRole('button', { name: 'CC-D GNL', exact: true }).first().click()
check('Compare: layer Dropdown selection commits via callback', (await log()).includes('all: property(layer, CC-D GNL)'))
if (await page.locator('.hm-sw-property__drop').count()) await page.keyboard.press('Escape')
await trigger.click(); await page.locator('.hm-sw-property__drop').first().waitFor()
await page.keyboard.press('Escape')
check('Compare: Escape closes the Dropdown and returns focus', await page.locator('.hm-sw-property__drop').count() === 0 && await trigger.evaluate(e => document.activeElement === e || e.contains(document.activeElement)))
}
// Window Move Motion: the single 3x step section with nine paused stages, live Hover 1400ms.
await goto('window-move-motion')
{
const stages = await page.locator('[data-motion-time]').evaluateAll(list => list.map(e => ({ time: e.dataset.motionTime, animations: e.querySelector('.hm-sw-header__motion')?.getAnimations({ subtree: true }).length ?? -1, zoom: getComputedStyle(e.querySelector('.sw-story-zoom')).zoom })))
check('Motion: one section 「단계별 진행 (3배 확대)」 with nine stages at zoom 3', await page.getByRole('heading', { level: 2 }).allTextContents().then(t => JSON.stringify(t) === JSON.stringify(['단계별 진행 (3배 확대)'])) && stages.length === 9 && stages.every(s => Number(s.zoom) === 3), stages)
check('Motion: rest stage static, timed stages hold three paused animations', stages[0].time === 'rest' && stages[0].animations === 0 && stages.slice(1).every(s => s.animations === 3), stages)
}
check('no browser console or page errors', report.errors.length === 0, report.errors)
} catch (e) { check('suite completed', false, e.stack) }
finally {
report.status = report.checks.every(c => c.status === 'PASS') ? 'PASS' : 'FAIL'
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2) + '\n')
await browser.close(); server.close()
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length, report.status)
process.exitCode = report.status === 'PASS' ? 0 : 1
}