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.
240 lines
24 KiB
JavaScript
240 lines
24 KiB
JavaScript
// 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
|
||
}
|