Files
HMDesign/packages/sw/tests/storybook-unused-removal.browser.mjs
T
shkim4andClaude Opus 5.5 5faa1f1ffe refactor(storybook): remove unused SW header demo stories
Remove nine SW/Header demo pages (property-fields, property-shape-slot,
composition, colorize, dynamic-controls, property-bar, additional-hover,
property-panel, layer-detail-input) and the demo code and Storybook-only
CSS used only by them. SW Stories 19 -> 10, total 338 -> 329.

The package runtime, public API, AI template and the retained Stories
(Overview, Dropdown, Property Panel 4-state comparison, Window Move
Motion and others) are unchanged. Add a current regression script and
mark the older scripts that open the retired IDs as historical evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:10:34 +09:00

210 lines
19 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 (detail-buttons preservation/verify, followup-four, property-close,
// property-fields, property-panel-comparison verify, storybook-cleanup-stage1) are historical evidence for their runs.
import { createRequire } from 'node:module'
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 } from './storybook-inventory.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')
check('inventory: 19 -> 10 SW Stories, 338 -> 329 total', beforeSW.length === 19 && afterSW.length === 10 && stories(beforeIndex).length === 338 && stories(afterIndex).length === 329, report.data.inventory)
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', sig(expected) === sig(afterSW), afterSW.map(e => e.id))
const others = index => Object.values(index.entries).filter(e => !retiredUnusedDemoIds.includes(e.id))
check('inventory: every non-removed index entry identical (docs and other packages included)', JSON.stringify(others(beforeIndex)) === JSON.stringify(others(afterIndex)))
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]))
const changed = Object.keys(oldExports).filter(name => !removedExports.includes(name) && oldExports[name] !== newExports[name])
check('source: retained exports (Stories and ComposedDemo) byte-identical', changed.length === 0 && Object.keys(newExports).every(name => oldExports[name]), changed)
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']
const diff = execFileSync('git', ['diff', '--name-only', baseCommit, '--', ...untouched], { encoding: 'utf8' }).trim()
check('source: runtime, public API, AI template, other packages, Overview, Dropdown CSF, assets and Storybook config untouched', diff === '', diff)
// ---- 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 } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
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
for (const entry of afterSW) {
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)
const bar = page.locator('.hm-sw-property-bar [data-property-id="radius"] input')
await bar.fill('6.25'); await bar.press('Enter')
check('Overview: property Bar Enter commits through callback', await bar.inputValue() === '6.25' && (await callback()).includes('onPropertyChange(radius, 6.25)'))
await bar.fill('-1'); await bar.press('Enter')
check('Overview: property Bar validation keeps min=0', await bar.inputValue() === '6.25')
const trigger = header.locator('[data-property-panel-trigger]')
await trigger.click(); await page.locator('.hm-sw-property').waitFor()
check('Overview: panel opens with the shared Bar value', await page.locator('.hm-sw-property [data-property-id="radius"] input').first().inputValue() === '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
}