test(sw): align legacy storybook suites and add header composition fixture

- dropdown-consolidation, storybook-cleanup-stage1, detail-buttons, property-panel-comparison: assert their recorded
  state and accept today only the approved later Story changes (storybook-inventory.mjs helpers).
- header-composition.browser.mjs + fixtures/header-composition.tsx: the removed Composition Story presets, hidden
  controls, invalid menuOrder / hiddenControls and live changes, on a test-only page using the public API only.
- storybook-unused-removal: header comment lists which older scripts run on current Stories again.
This commit is contained in:
shkim4 committed 2026-10-21 06:08:23 +09:00
1 parent 1e05eaf1de
commit b50e95c325
8 files changed
+375 -66

No files matched your search

+59 -28
View File
@@ -1,12 +1,17 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (composition, colorize, layer-detail-input; preservation/verify modes). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
// 2026-10-20 (test alignment): `capture`, `preservation`, `baseline` and `verify` are current regressions again. The checks of
// the Stories retired by the unused demo removal (5faa1f1) run on current public Stories: K Guide (the same fluid Header
// as Composition), Overview (btn_colorize, layer detail), Property Panel · 4개 비교 (editable Input) and Document Open
// (`controls` allowlist composition). No current Story sets hiddenControls / menuOrder presets, so the Composition preset
// loop is the historical `composition-history` mode, for a Storybook built at or before c8e5fbe (pass its base URL).
// The same presets run today on a test-only fixture in header-composition.browser.mjs.
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
import { swStoryEntries, retainedInventory, retiredPropertyPanelIds } from './storybook-inventory.mjs'
import { swStoryEntries, retainedInventory, retiredPropertyPanelIds, retiredUnusedDemoIds, laterRetiredHeaderIds, approvedAddedHeaderIds, movedToPopupIds } from './storybook-inventory.mjs'
import { loadPlaywright } from './playwright-runtime.mjs'
// Usage: node packages/sw/tests/detail-buttons.browser.mjs baseline|verify <evidence-dir> [base-url]
// Run `baseline` into a new evidence directory at the reference state, then `verify` into the same directory.
const mode = process.argv[2] ?? 'verify'
const output = path.resolve(process.argv[3] ?? 'docs/sw/detail-button-qa')
let base = process.argv[4] ?? 'static'
@@ -37,6 +42,8 @@ const style = e => {
iconOpacity:i && getComputedStyle(i).opacity,iconContent:i && getComputedStyle(i).content,
height:e.getBoundingClientRect().height,width:e.getBoundingClientRect().width,outline:s.outline,disabled:e.disabled }
}
// Header height, overflow and the menus inside its fields in DOM order (configured controls or the default menus).
const composedMenus = h => ({height:h.getBoundingClientRect().height,overflow:h.scrollWidth-h.clientWidth,menus:[...h.querySelectorAll('.hm-sw-header__fields > *')].filter(e=>e.querySelector('.hm-sw-header__field')).map(e=>e.dataset.control??({layer:'layer',color:'color','line-style':'lineStyle','text-style':'textStyle','dimension-style':'dimensionStyle'}[e.dataset.menu]??(e.classList.contains('hm-sw-header__layer')?'layer':null)))})
const snapshots = {}
try {
if (mode === 'capture') {
@@ -45,7 +52,9 @@ try {
await page.locator('[data-detail-row="문자스타일 상세"] [data-detail-case="hover"] button').hover()
await page.screenshot({path:path.join(output,'comparison-pointer-hover.png'),fullPage:true})
} else if (mode === 'preservation') {
await goto('composition')
// 2026-10-20: Composition, Colorize and Layer Detail · Input were removed (5faa1f1). K Guide renders the same fluid
// figmaArgs Header (window motion, K guide), Overview's Header the btn_colorize menu, the comparison the editable Input.
await goto('k-guide')
for(const width of [390,768,1280,1440]){
await page.setViewportSize({width,height:1000})
const layout=await page.locator('header.hm-sw-header').first().evaluate(h=>({height:h.getBoundingClientRect().height,overflow:h.scrollWidth-h.clientWidth,documentOverflow:document.documentElement.scrollWidth-document.documentElement.clientWidth}))
@@ -57,27 +66,48 @@ try {
const box=await page.locator(`[data-compare="${id}"] .hm-sw-property`).boundingBox()
check(`preserve property panel ${id}`,box.width===314&&box.height===height,box)
}
await goto('composition')
await goto('k-guide')
const motion=page.locator('[data-sw-window-move]').first();await motion.hover()
check('preserve window motion 1400ms',await motion.evaluate(e=>e.getAnimations({subtree:true}).length===3&&e.getAnimations({subtree:true}).every(a=>a.effect.getTiming().duration===1400)))
await page.mouse.move(1,900);check('window motion leave clears animations',await motion.evaluate(e=>e.getAnimations({subtree:true}).length===0))
const guide=page.locator('.hm-sw-k-guide').first();await guide.locator('button').hover()
check('preserve K guide animation',await guide.locator('.hm-sw-k-guide__panel').isVisible()&&await guide.locator('.hm-sw-k-guide__panel').evaluate(e=>getComputedStyle(e).animationDuration==='0.511s'))
await page.mouse.move(1,900);check('K guide leave closes',await guide.locator('.hm-sw-k-guide__panel').count()===0)
await goto('colorize');const colorHeader=page.locator('header.hm-sw-header').first();await colorHeader.getByRole('button',{name:'색상',exact:true}).click()
check('preserve Colorize size',(await colorHeader.getByRole('button',{name:'색상 추출',exact:true}).boundingBox()).width===24)
await goto('layer-detail-input');const editing=page.getByRole('textbox',{name:'Editing',exact:true})
check('preserve editable Input',await editing.isEditable()&&await editing.evaluate(e=>getComputedStyle(e).caretColor==='rgb(230, 4, 3)'))
await goto('overview');const colorHeader=page.locator('header.hm-sw-header').first();await colorHeader.getByRole('button',{name:'색상',exact:true}).click()
check('preserve Colorize size (Overview Header)',(await colorHeader.getByRole('button',{name:'색상 추출',exact:true}).boundingBox()).width===24)
await goto('property-panel-compare');const editing=page.locator('[data-compare="circle"] [data-property-id="radius"] input.hm-sw-header__input').first()
check('preserve editable Input (Property Panel · 4개 비교)',await editing.isEditable()&&await editing.evaluate(e=>getComputedStyle(e).caretColor==='rgb(230, 4, 3)'))
} else if (mode === 'composition-history') {
// Historical: the verify-mode Composition preset loop of 2026-10-19 on sw-header--composition (removed in 5faa1f1).
// Run it only against a Storybook built at or before c8e5fbe (base-url); the current Storybook has no such Story.
await goto('composition')
const presets=['기본 전체','구성 A','구성 B (레이어·선스타일)','구성 C (로고만)','파일 목록 제거','레이어 제거','색상 제거','스타일 그룹 제거','여러 메뉴 제거','순서 변경','숨김 + 순서 변경']
const all=['layer','color','lineStyle','textStyle','dimensionStyle']
const removed=[[],['textStyle','dimensionStyle'],['color','textStyle','dimensionStyle'],all,[],['layer'],['color'],['lineStyle','textStyle','dimensionStyle'],['color','textStyle'],[],['layer','textStyle']]
const orders=[...Array(9).fill(all),['color','layer','dimensionStyle','lineStyle','textStyle'],['dimensionStyle','textStyle','color','layer','lineStyle']]
for(let p=0;p<presets.length;p++)for(const width of [1920,1440,1080,900,768,640,390]){
await page.setViewportSize({width,height:1000});await page.getByRole('button',{name:presets[p],exact:true}).click()
const h=page.locator('[data-composition-demo] header.hm-sw-header')
const actual=await h.evaluate(composedMenus)
const expected=orders[p].filter(id=>!removed[p].includes(id))
check(`composition ${presets[p]} @${width}`,actual.height===49&&actual.overflow===0&&JSON.stringify(actual.menus)===JSON.stringify(expected),{actual,expected})
const more=h.locator('.hm-sw-header__more-trigger')
if(await more.isVisible()) {await more.click();const items=await h.locator('.hm-sw-header__more-item').allTextContents();check(`nonempty More ${presets[p]} @${width}`,items.length>0);await page.keyboard.press('Escape')}
}
} else {
const index = await (await fetch(`${base}/index.json`)).json()
const ids = swStoryEntries(index).map(e => e.id)
report.storyIds = ids
if (mode === 'baseline') await writeFile(path.join(output, 'baseline-story-ids.json'), JSON.stringify(ids))
// These retained examples cover fixed-width and fluid headers after the two Args demos were retired.
for (const story of ['overview', 'composition']) {
// 2026-10-20: Composition was removed (5faa1f1); K Guide renders the same fluid figmaArgs Header.
for (const story of ['overview', 'k-guide']) {
await goto(story)
for (const width of [1920,1800,1440,1180,1080,900,768,640,520,390]) {
await page.setViewportSize({ width, height:1000 })
// 2026-10-20: Overview sizes its 16:9 Stage through a ResizeObserver; wait until the Header box holds for three frames
// (without it a snapshot could catch the previous width, so baseline and verify differed on the same build).
await page.locator('header.hm-sw-header').first().evaluate(h => new Promise(resolve => { let last = '', stable = 0; const tick = () => { const r = h.getBoundingClientRect(), now = [r.x, r.y, r.width].join(); stable = now === last ? stable + 1 : 0; last = now; stable >= 3 ? resolve() : requestAnimationFrame(tick) }; requestAnimationFrame(tick) }))
snapshots[`${story}-${width}`] = await page.locator('header.hm-sw-header').first().evaluate(h => [h,...h.querySelectorAll('*')].map(e => {
const b=e.getBoundingClientRect(),s=getComputedStyle(e)
return {tag:e.tagName,class:e.getAttribute('class'),x:b.x,y:b.y,width:b.width,height:b.height,display:s.display,visibility:s.visibility,color:s.color,background:s.backgroundColor}
@@ -104,24 +134,24 @@ try {
const retiredIds=['default','controls','responsive','text-stroke','dynamic-data','layer-dropdown','color-dropdown','line-style-dropdown','text-dimension-style-dropdown'].map(id=>`sw-header--${id}`)
// 2026-10-19: the five duplicate property panel demos are retired as well (24 -> 19).
retiredIds.push(...retiredPropertyPanelIds)
const retainedIds=retainedInventory(oldIds.map(id=>({id}))).map(e=>e.id).filter(id=>!retiredPropertyPanelIds.includes(id))
check('retained stories remain; only approved retirements are absent',ids.length===19&&retainedIds.every(id=>ids.includes(id))&&retiredIds.every(id=>!ids.includes(id)),{before:oldIds.length,after:ids.length})
// 2026-10-20: then the nine unused demos (5faa1f1) and the Popup move; the approved Header additions are listed in
// storybook-inventory.mjs. Expected from this run's baseline and from the list recorded on 2026-10-16 (which predates
// this suite's own sw-header--detail-buttons), so a fresh baseline cannot accept an unapproved Story.
retiredIds.push(...retiredUnusedDemoIds)
const expectedFrom=list=>{const retained=retainedInventory(list.map(id=>({id}))).map(e=>e.id).filter(id=>!laterRetiredHeaderIds.includes(id)&&!movedToPopupIds.includes(id));return {retained,ids:[...new Set([...retained,...approvedAddedHeaderIds,'sw-header--detail-buttons'])].sort()}}
const fromBaseline=expectedFrom(oldIds),fromRecorded=expectedFrom(JSON.parse(await readFile('docs/sw/detail-button-qa/baseline-story-ids.json','utf8')))
check('retained stories remain; only approved retirements are absent; only approved additions',JSON.stringify([...ids].sort())===JSON.stringify(fromBaseline.ids)&&JSON.stringify(fromBaseline.ids)===JSON.stringify(fromRecorded.ids)&&fromBaseline.retained.every(id=>ids.includes(id))&&retiredIds.every(id=>!ids.includes(id))&&movedToPopupIds.every(id=>index.entries[id]?.title==='SW/Popup'),{before:oldIds.length,after:ids.length,unexpected:ids.filter(id=>!fromRecorded.ids.includes(id)),missing:fromRecorded.ids.filter(id=>!ids.includes(id))})
for(const id of ids){await goto(id.replace('sw-header--',''));check(`render ${id}`,!(await page.locator('.sb-errordisplay').isVisible()))}
await goto('composition')
const presets=['기본 전체','구성 A','구성 B (레이어·선스타일)','구성 C (로고만)','파일 목록 제거','레이어 제거','색상 제거','스타일 그룹 제거','여러 메뉴 제거','순서 변경','숨김 + 순서 변경']
const all=['layer','color','lineStyle','textStyle','dimensionStyle']
const removed=[[],['textStyle','dimensionStyle'],['color','textStyle','dimensionStyle'],all,[],['layer'],['color'],['lineStyle','textStyle','dimensionStyle'],['color','textStyle'],[],['layer','textStyle']]
const orders=[...Array(9).fill(all),['color','layer','dimensionStyle','lineStyle','textStyle'],['dimensionStyle','textStyle','color','layer','lineStyle']]
for(let p=0;p<presets.length;p++)for(const width of [1920,1440,1080,900,768,640,390]){
await page.setViewportSize({width,height:1000});await page.getByRole('button',{name:presets[p],exact:true}).click()
const h=page.locator('[data-composition-demo] header.hm-sw-header')
const actual=await h.evaluate(h=>({height:h.getBoundingClientRect().height,overflow:h.scrollWidth-h.clientWidth,menus:[...h.querySelectorAll('.hm-sw-header__fields > *')].filter(e=>e.querySelector('.hm-sw-header__field')).map(e=>e.dataset.control??({layer:'layer',color:'color','line-style':'lineStyle','text-style':'textStyle','dimension-style':'dimensionStyle'}[e.dataset.menu]??(e.classList.contains('hm-sw-header__layer')?'layer':null)))}))
const expected=orders[p].filter(id=>!removed[p].includes(id))
check(`composition ${presets[p]} @${width}`,actual.height===49&&actual.overflow===0&&JSON.stringify(actual.menus)===JSON.stringify(expected),{actual,expected})
// 2026-10-20: the Composition presets (hiddenControls / menuOrder) are the `composition-history` mode. The configured
// composition that stays public is Document Open's `controls` allowlist, rendered at ten frame widths.
await goto('document-open')
for(const width of [1920,1800,1440,1180,1080,900,768,640,520,390]){
const h=page.locator(`[data-composed-responsive] [data-width="${width}"] header.hm-sw-header`)
const actual=await h.evaluate(composedMenus)
check(`controls allowlist composition @${width}`,actual.height===49&&actual.overflow===0&&JSON.stringify(actual.menus)===JSON.stringify(['layer','color']),actual)
const more=h.locator('.hm-sw-header__more-trigger')
if(await more.isVisible()) {await more.click();const items=await h.locator('.hm-sw-header__more-item').allTextContents();check(`nonempty More ${presets[p]} @${width}`,items.length>0);await page.keyboard.press('Escape')}
if(await more.isVisible()) {await more.click();const items=await h.locator('.hm-sw-header__more-item').allTextContents();check(`nonempty More controls allowlist @${width}`,items.length>0,items);await page.keyboard.press('Escape')}
}
await page.setViewportSize({width:1920,height:1000})
// Real dropdown integration, separate from the shared-button specimen.
await goto('composed-controls')
const host=page.locator('.sw-composed-controls')
@@ -135,11 +165,12 @@ try {
}
await detail.click();check(`actual ${name} callback / close / focus`,(await host.locator('[data-callback-log]').textContent()).includes(callback)&&await trigger.getAttribute('aria-expanded')==='false'&&await trigger.evaluate(e=>e===document.activeElement))
}
await goto('layer-detail-input')
const layerHost=page.locator('section').filter({has:page.getByRole('heading',{name:'레이어 상세 Click Callback',exact:true})})
// Layer Detail · Input was removed (5faa1f1); Overview's Header connects onLayerDetail to its callback output.
await goto('overview')
const layerHost=page.locator('header.hm-sw-header').first()
const layerTrigger=layerHost.getByRole('button',{name:'레이어',exact:true});await layerTrigger.click()
await layerHost.getByRole('button',{name:'레이어 상세',exact:true}).click()
check('actual layer callback / close / focus',(await layerHost.locator('[data-callback-log]').textContent()).includes('onLayerDetail')&&await layerTrigger.getAttribute('aria-expanded')==='false'&&await layerTrigger.evaluate(e=>e===document.activeElement))
check('actual layer callback / close / focus (Overview)',(await page.locator('[data-preview-callback]').textContent()).includes('onLayerDetail')&&await layerTrigger.getAttribute('aria-expanded')==='false'&&await layerTrigger.evaluate(e=>e===document.activeElement))
await goto('detail-buttons')
const rows=page.locator('[data-detail-comparison] [data-detail-row]')
const names=['레이어 상세','색상 상세','선스타일 상세','문자스타일 상세','치수스타일 상세'], widths=[113,99,128,142,142]
@@ -5,8 +5,9 @@ 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'
import { retiredDropdownIds, swStoryEntries, retainedInventory, inventorySignature, laterRetiredHeaderIds, headerInventoryIssues, withApprovedEntryChanges, approvedAddedStoryIds, movedToPopupIds, headerSourceIssues, approvedMainCommit, integratedChangedFiles } from './storybook-inventory.mjs'
import { loadPlaywright } from './playwright-runtime.mjs'
import { sourceChanges, unapprovedChanges } from './source-changes.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-dropdown-page-qa')
await mkdir(out, { recursive: true })
@@ -20,12 +21,29 @@ 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)))
// 2026-10-20 (test alignment): each inventory and source check asserts the consolidation on its recorded state (the
// index of the c705a1f smoke run and the c705a1f source, read from Git) and, for today, only the approved later
// changes listed in storybook-inventory.mjs. The browser checks below run unchanged on the current Storybook.
report.consolidationCommit = 'c705a1fc59fe8ecbce663bf4601638fde43553a1'
const git = (...args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 26 })
const recorded = JSON.parse(git('show', `${report.consolidationCommit}:docs/sw/storybook-dropdown-page-qa/smoke/after-full-index.json`))
const recordedSW = swStoryEntries(recorded)
const signature = list => JSON.stringify(inventorySignature(list))
const now = headerInventoryIssues(beforeSW, after)
check('28 -> 24 SW Stories, exactly four approved Dropdown retirements (recorded at c705a1f); now only approved later changes', beforeSW.length === 28 && recordedSW.length === 24 &&
signature(retainedInventory(beforeSW)) === signature(recordedSW) && now.ok, { recorded: recordedSW.length, ...now })
check('Dropdown is one Story directly under Header with original ID, no Dropdown folder', afterSW.filter(e => e.name === 'Dropdown').length === 1 && after.entries['sw-header--composed-controls']?.title === 'SW/Header' && !afterSW.some(e => e.title.startsWith('SW/Header/')))
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 otherRetained = list => list.filter(e => e.id !== 'sw-header--composed-controls')
const keptNow = otherRetained(previousSW).filter(e => !laterRetiredHeaderIds.includes(e.id))
check('menu correction keeps all 24 IDs; only composed Story name/title change (recorded at c705a1f); the other entries now differ only by approved changes',
previousSW.length === 24 && recordedSW.length === 24 && signature(retainedInventory(previousSW)) === signature(recordedSW) && JSON.stringify(otherRetained(previousSW)) === JSON.stringify(otherRetained(recordedSW)) &&
JSON.stringify(keptNow.map(withApprovedEntryChanges)) === JSON.stringify(keptNow.map(e => after.entries[e.id])), keptNow.filter(e => JSON.stringify(withApprovedEntryChanges(e)) !== JSON.stringify(after.entries[e.id])).map(e => e.id))
const nonSW = index => Object.values(index.entries).filter(e => !e.title.startsWith('SW/Header'))
check('all non-SW index entries identical', JSON.stringify(nonSW(before)) === JSON.stringify(nonSW(after)))
// Today: entries outside SW keep their values and order; SW entries outside SW/Header are exactly the approved additions and the Popup move.
const outsideSW = index => JSON.stringify(Object.values(index.entries).filter(e => !e.id.startsWith('sw-')))
const otherSW = { actual: nonSW(after).filter(e => e.id.startsWith('sw-')).map(e => e.id).sort(), expected: [...approvedAddedStoryIds.filter(id => !id.startsWith('sw-header--')), ...movedToPopupIds].sort() }
check('all non-SW index entries identical (recorded at c705a1f and now); other SW titles hold only approved additions and the Popup move', JSON.stringify(nonSW(before)) === JSON.stringify(nonSW(recorded)) &&
outsideSW(before) === outsideSW(after) && JSON.stringify(otherSW.actual) === JSON.stringify(otherSW.expected), otherSW)
const sourcePath = 'src/sw/sw-header.stories.tsx'
const original = execFileSync('git', ['show', `${report.baseCommit}:${sourcePath}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n')
const source = (await readFile(sourcePath, 'utf8')).replaceAll('\r\n', '\n')
@@ -35,9 +53,19 @@ const declarations = text => Object.fromEntries(parse(text, { sourceType: 'modul
}))
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 ignored = [...removed, 'ImportDeclaration', 'meta', 'ComposedDemo']
const declarationsAt = commit => declarations(git('show', `${commit}:${sourcePath}`).replaceAll('\r\n', '\n'))
const consolidated = declarationsAt(report.consolidationCommit)
const sinceConsolidation = headerSourceIssues([...new Set([...Object.keys(consolidated), ...Object.keys(currentDeclarations)])].filter(key => !ignored.includes(key)), consolidated, currentDeclarations, declarationsAt)
check('all retained declarations identical outside imports, meta and ComposedDemo (at c705a1f); later changes only by approved commits at their approved content',
Object.entries(oldDeclarations).filter(([key]) => !ignored.includes(key)).every(([key, value]) => consolidated[key] === value) && sinceConsolidation.length === 0, sinceConsolidation)
const untouched = ['packages/sw/src', 'packages/sw/ai', 'packages/sw/package.json', 'src/sw/sw-header-overview.tsx', 'src/sw/sw-stories.css', 'src/sw/assets', '.storybook']
check('runtime, API, AI template, Overview, existing CSS/assets and Storybook configuration untouched', !execFileSync('git', ['diff', report.baseCommit, '--', ...untouched], { encoding: 'utf8' }))
// Today the same paths may differ from the approved main merge only in the integrated-improvement files (as in overview.browser.mjs).
// Untracked new files count as changes (source-changes.mjs).
const sourceDiff = { consolidation: git('diff', '--name-only', report.baseCommit, report.consolidationCommit, '--', ...untouched).trim(),
unapproved: unapprovedChanges(approvedMainCommit, untouched, integratedChangedFiles),
sinceBaseline: sourceChanges(report.baseCommit, untouched).length }
check('runtime, API, AI template, Overview, existing CSS/assets and Storybook configuration untouched by the consolidation; unchanged since the approved main merge', sourceDiff.consolidation === '' && sourceDiff.unapproved.length === 0, sourceDiff)
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.png': 'image/png' }
const server = http.createServer(async (req, res) => {
+45
View File
@@ -0,0 +1,45 @@
// Test-only host for header-composition.browser.mjs (2026-10-20). Not a Storybook Story and not shipped (package
// "files" is dist + ai). It replaces the Composition demo removed in 5faa1f1 for the hiddenControls / menuOrder checks.
// Uses only the public @hm-design/sw API, like a consuming project. window.__setComposition({ hidden, order }) changes
// the props at runtime; the layer selection is host state, so it must survive reordering, hiding and showing again.
import { useEffect, useState } from "react"
import { createRoot } from "react-dom/client"
import { SWHeader, type SWHeaderHideableControlId, type SWHeaderLayer, type SWHeaderMenuId } from "@hm-design/sw"
import "@hm-design/sw/styles.css"
type Composition = { hidden?: SWHeaderHideableControlId[]; order?: SWHeaderMenuId[] }
declare global { interface Window { __setComposition?: (next: Composition) => void; __callbacks: string[] } }
window.__callbacks = []
const LAYERS: SWHeaderLayer[] = [
{ id: "0", name: "0", color: "#ffffff" },
{ id: "wall", name: "WALL", color: "#ff4242" },
{ id: "dim", name: "DIM", color: "#47af91" },
]
const record = (line: string) => { window.__callbacks.push(line) }
function Host() {
const [composition, setComposition] = useState<Composition>({})
const [layerId, setLayerId] = useState("0")
useEffect(() => { window.__setComposition = next => setComposition(next) }, [])
const layer = LAYERS.find(item => item.id === layerId)!
return (
<div data-composition-fixture="" data-hidden={(composition.hidden ?? []).join(",")} data-order={composition.order?.join(",") ?? "default"}>
<SWHeader
documentTitle="C0020101-006-평면및종단면도(06)" drawingModeLabel="그리기모드"
documents={[{ id: "doc-current", name: "C0020101-006-평면및종단면도(06)" }]} activeDocumentId="doc-current"
layers={LAYERS} activeLayerId={layerId} layer={{ name: layer.name }}
color={{ value: "255,229,0", swatch: "#ffe500" }}
lineStyle={{ name: "Continuous", weight: "0.25", scale: "1.00" }}
textStyle={{ font: "Arial", name: "Standard" }} dimensionStyle={{ name: "Standard" }}
hiddenControls={composition.hidden} menuOrder={composition.order}
onHomeClick={() => record("onHomeClick")} onLayerSelect={item => { setLayerId(item.id); record(`onLayerSelect(${item.id})`) }}
onLayerOpen={() => record("onLayerOpen")} onColorOpen={() => record("onColorOpen")} onLineStyleOpen={() => record("onLineStyleOpen")}
onTextStyleOpen={() => record("onTextStyleOpen")} onDimensionStyleOpen={() => record("onDimensionStyleOpen")}
onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")} onMinimize={() => record("onMinimize")} onClose={() => record("onClose")}
/>
</div>
)
}
createRoot(document.getElementById("root")!).render(<Host />)
@@ -0,0 +1,122 @@
// SWHeader hiddenControls / menuOrder regression on a test-only fixture (2026-10-20). The Composition Story that showed
// these props was removed in 5faa1f1 (approved); this suite keeps its eleven presets × seven widths (detail-buttons
// composition-history) and adds the documented edge cases: unknown / duplicate ids, every hideable part, runtime changes
// that keep host values and call no callback. Build the package first: npm run build -w @hm-design/sw
// Usage: node packages/sw/tests/header-composition.browser.mjs <new-evidence-dir>
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import http from 'node:http'
import { fileURLToPath } from 'node:url'
import { loadPlaywright } from './playwright-runtime.mjs'
const out = path.resolve(process.argv[2] ?? '')
if (!process.argv[2]) throw new Error('Pass a new evidence directory')
const site = path.join(out, 'fixture')
await mkdir(site, { recursive: true })
const require = createRequire(import.meta.url)
await require('esbuild').build({
entryPoints: [fileURLToPath(new URL('./fixtures/header-composition.tsx', import.meta.url))], bundle: true, outdir: site, format: 'esm', jsx: 'automatic', minify: true,
loader: { '.ttf': 'file', '.woff': 'file', '.woff2': 'file', '.svg': 'file', '.png': 'file' }, define: { 'process.env.NODE_ENV': '"production"' }, logLevel: 'warning',
})
await writeFile(path.join(site, 'index.html'), '<!doctype html><html lang="ko"><head><meta charset="utf-8"><link rel="icon" href="data:,"><link rel="stylesheet" href="header-composition.css"></head><body style="margin:0"><div id="root"></div><script type="module" src="header-composition.js"></script></body></html>')
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.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(site, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(site, 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 report = { checks: [], errors: [] }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); if (!ok) console.error('FAIL', name, JSON.stringify(data)) }
const { chromium } = loadPlaywright()
const 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: 1920, height: 900 } })
page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const header = page.locator('[data-composition-fixture] header.hm-sw-header')
const set = async composition => {
await page.evaluate(next => window.__setComposition(next), composition)
await page.waitForFunction(next => document.querySelector('[data-composition-fixture]')?.dataset.order === (next.order?.join(',') ?? 'default') && document.querySelector('[data-composition-fixture]')?.dataset.hidden === (next.hidden ?? []).join(','), composition)
// The fields measure themselves (ResizeObserver); wait until the field boxes hold for three frames.
await header.evaluate(h => new Promise(resolve => { let last = '', stable = 0; const tick = () => { const now = [...h.querySelectorAll('.hm-sw-header__field, .hm-sw-header__more-trigger')].map(e => { const r = e.getBoundingClientRect(); return [r.x, r.width].join() }).join('|'); stable = now === last ? stable + 1 : 0; last = now; stable >= 3 ? resolve() : requestAnimationFrame(tick) }; requestAnimationFrame(tick) }))
}
// Header height, overflow and the menus inside its fields in DOM order (detail-buttons' reading, plus bare field buttons:
// without dropdown data a menu renders as the field itself, not inside a wrapper).
const composedMenus = h => ({ height: h.getBoundingClientRect().height, overflow: h.scrollWidth - h.clientWidth, menus: [...h.querySelectorAll('.hm-sw-header__fields > *')].filter(e => e.matches('.hm-sw-header__field') || e.querySelector('.hm-sw-header__field')).map(e => e.dataset.control ?? ({ layer: 'layer', color: 'color', 'line-style': 'lineStyle', 'text-style': 'textStyle', 'dimension-style': 'dimensionStyle' }[e.dataset.menu] ?? (e.classList.contains('hm-sw-header__layer') ? 'layer' : null))) })
const ALL = ['layer', 'color', 'lineStyle', 'textStyle', 'dimensionStyle']
// Every hideable part except the menus (checked through composedMenus) and the sub-items (Hidden Sub Controls Story).
const PARTS = {
home: h => h.locator('button.hm-sw-header__home').count(), document: h => h.locator('.hm-sw-header__document').count(), drawingMode: h => h.locator('.hm-sw-header__mode').count(),
details: h => h.getByRole('group', { name: '상세보기', exact: true }).count(), propertyBar: h => h.getByRole('button', { name: '속성Bar', exact: true }).count(), propertyPanel: h => h.getByRole('button', { name: '속성창', exact: true }).count(),
print: h => h.locator('.hm-sw-header__action--print').count(), popupGuide: h => h.locator('.hm-sw-header__action--guide').count(), windowMove: h => h.locator('[data-sw-window-move]').count(),
minimize: h => h.locator('.hm-sw-header__window-button--minimize').count(), close: h => h.locator('.hm-sw-header__window-button--close').count(),
}
const present = async () => Object.fromEntries(await Promise.all(Object.entries(PARTS).map(async ([id, count]) => [id, await count(header) > 0])))
try {
await page.goto(`http://127.0.0.1:${server.address().port}/`)
await header.waitFor(); await page.evaluate(() => document.fonts.ready)
await set({})
const defaults = await present()
check('default: every part and the five menus in the approved order', Object.values(defaults).every(Boolean) && JSON.stringify((await header.evaluate(composedMenus)).menus) === JSON.stringify(ALL), { defaults, ...(await header.evaluate(composedMenus)) })
// 1. The eleven Composition presets of 2026-10-19 at seven widths (expected menus unchanged from that Story).
const hideable = [...Object.keys(PARTS).slice(0, 3), ...ALL, ...Object.keys(PARTS).slice(3)]
const presets = [
['기본 전체', []], ['구성 A', ['drawingMode', 'textStyle', 'dimensionStyle', 'details', 'propertyBar', 'propertyPanel', 'print', 'popupGuide', 'windowMove', 'minimize', 'close']],
['구성 B (레이어·선스타일)', hideable.filter(id => id !== 'layer' && id !== 'lineStyle')], ['구성 C (로고만)', hideable],
['파일 목록 제거', ['document']], ['레이어 제거', ['layer']], ['색상 제거', ['color']], ['스타일 그룹 제거', ['lineStyle', 'textStyle', 'dimensionStyle']],
['여러 메뉴 제거', ['document', 'color', 'textStyle', 'print', 'minimize']],
['순서 변경', [], ['color', 'layer', 'dimensionStyle', 'lineStyle', 'textStyle']], ['숨김 + 순서 변경', ['layer', 'textStyle'], ['dimensionStyle', 'textStyle', 'color', 'layer']],
]
const orders = [...Array(9).fill(ALL), ['color', 'layer', 'dimensionStyle', 'lineStyle', 'textStyle'], ['dimensionStyle', 'textStyle', 'color', 'layer', 'lineStyle']]
for (const [p, [name, hidden, order]] of presets.entries()) for (const width of [1920, 1440, 1080, 900, 768, 640, 390]) {
await page.setViewportSize({ width, height: 900 }); await set({ hidden, order })
const actual = await header.evaluate(composedMenus), expected = orders[p].filter(id => !hidden.includes(id))
check(`preset ${name} @${width}: menus, 49px, no overflow`, actual.height === 49 && actual.overflow === 0 && JSON.stringify(actual.menus) === JSON.stringify(expected), { actual, expected })
const more = header.locator('.hm-sw-header__more-trigger')
if (await more.isVisible()) { await more.click(); const items = await header.locator('.hm-sw-header__more-item').allTextContents(); check(`preset ${name} @${width}: nonempty More`, items.length > 0, items); await page.keyboard.press('Escape') }
if (width === 1920) {
const parts = await present(), wrong = Object.entries(parts).filter(([id, shown]) => shown === hidden.includes(id)).map(([id]) => id)
check(`preset ${name} @1920: exactly the listed parts removed`, wrong.length === 0, { wrong, parts })
if (hidden.includes('home')) check(`preset ${name}: hidden home keeps the logo as a static image`, await header.locator('.hm-sw-header__home').count() === 1 && await header.locator('button.hm-sw-header__home').count() === 0)
}
}
await page.setViewportSize({ width: 1920, height: 900 })
// 2. Invalid settings: unknown / duplicate menuOrder ids are ignored, missing menus follow in the default order.
await set({ order: ['color', 'color', 'unknown', 'layer'] })
check('menuOrder: duplicate and unknown ids ignored, missing menus follow in default order', JSON.stringify((await header.evaluate(composedMenus)).menus) === JSON.stringify(['color', 'layer', 'lineStyle', 'textStyle', 'dimensionStyle']), await header.evaluate(composedMenus))
await set({ order: ['dimensionStyle', 'drawingMode', 'print'] })
check('menuOrder: non-menu ids ignored; other parts never move', JSON.stringify((await header.evaluate(composedMenus)).menus) === JSON.stringify(['dimensionStyle', 'layer', 'color', 'lineStyle', 'textStyle']) && Object.values(await present()).every(Boolean))
await set({ order: [] })
check('menuOrder: empty list keeps the default order', JSON.stringify((await header.evaluate(composedMenus)).menus) === JSON.stringify(ALL))
await set({ hidden: ['bogus', 'color', 'color'] })
check('hiddenControls: unknown and duplicate ids ignored', JSON.stringify((await header.evaluate(composedMenus)).menus) === JSON.stringify(['layer', 'lineStyle', 'textStyle', 'dimensionStyle']) && Object.values(await present()).every(Boolean))
// 3. Runtime changes keep host values and call no callback; a reordered menu still opens its own dropdown.
await set({})
await header.getByRole('button', { name: '레이어', exact: true }).click()
await page.locator('.hm-sw-header__layer-item').filter({ hasText: 'WALL' }).first().click()
const selected = await page.evaluate(() => [...window.__callbacks])
check('layer selection reaches the host once', selected.filter(line => line === 'onLayerSelect(wall)').length === 1, selected)
for (const composition of [{ order: ['dimensionStyle', 'color', 'layer'] }, { hidden: ['layer'] }, { hidden: ['layer'], order: ['color'] }, {}]) {
await set(composition)
const field = header.getByRole('button', { name: '레이어', exact: true })
check(`change to ${JSON.stringify(composition)}: no callback; layer value kept while shown`, (await page.evaluate(() => window.__callbacks.length)) === selected.length && (composition.hidden?.includes('layer') ? await field.count() === 0 : (await field.textContent()).includes('WALL')))
}
await set({ order: ['dimensionStyle', 'color', 'layer'] })
await header.getByRole('button', { name: '레이어', exact: true }).click()
check('reordered layer field opens its own dropdown and calls only onLayerOpen', await page.locator('.hm-sw-header__layer-panel, .hm-sw-header__layer-menu').count() > 0 && (await page.evaluate(() => window.__callbacks.slice(-1)[0])) === 'onLayerOpen')
await page.keyboard.press('Escape')
check('Escape closes it and returns focus to the moved field', await page.locator('.hm-sw-header__layer-panel, .hm-sw-header__layer-menu').count() === 0 && await header.getByRole('button', { name: '레이어', exact: true }).evaluate(e => e === document.activeElement))
await page.screenshot({ path: path.join(out, 'reordered-1920.png') })
check('no browser console or page errors', report.errors.length === 0, report.errors)
} catch (error) { check('suite completed', false, error.stack) }
finally {
await browser.close(); server.close()
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('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)
process.exitCode = report.status === 'PASS' ? 0 : 1
}
@@ -1,9 +1,12 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (property-panel and four others in verify). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
// 2026-10-20 (test alignment): `verify` is a current regression again. The preservation checks of the Stories retired by
// the unused demo removal (5faa1f1) run on current public Stories (Overview for the Header-connected panel; every other
// current SW/Header Story renders), and the inventory accepts only the approved changes in storybook-inventory.mjs.
// `before` stays the recorded capture of the five retired property panel Stories (historical; needs a build before c8e5fbe).
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 { swStoryEntries, retiredPropertyPanelIds } from './storybook-inventory.mjs'
import { swStoryEntries, retiredPropertyPanelIds, retiredUnusedDemoIds, approvedAddedStoryIds, headerInventoryIssues } from './storybook-inventory.mjs'
import { loadPlaywright } from './playwright-runtime.mjs'
// Property panel comparison consolidation (2026-10-19).
@@ -62,8 +65,16 @@ try {
const before = JSON.parse(await readFile(path.resolve(process.env.SW_BEFORE_INVENTORY ?? 'docs/sw/property-panel-comparison-qa/before/story-inventory-before.json'), 'utf8'))
const expected = before.sw.filter(e => !retiredPropertyPanelIds.includes(e.id))
const signature = list => JSON.stringify(list.map(e => [e.id, e.name, e.title, e.tags]).sort((a, b) => a[0].localeCompare(b[0])))
check('inventory: 24 -> 19 SW Stories, 343 -> 338 total, only 5 approved retirements', before.sw.length === 24 && sw.length === 19 && before.total === 343 && stories.length === 338 && retiredPropertyPanelIds.every(id => !index.entries[id]), { before: [before.total, before.sw.length], after: [stories.length, sw.length] })
check('inventory: retained SW Story IDs, names, titles, tags unchanged', signature(expected) === signature(sw), { retained: sw.length })
// 2026-10-20 (test alignment): 24 -> 19 / 343 -> 338 is asserted on the run recorded at c8e5fbe (read from Git); today
// the index may differ from it only by the approved changes of storybook-inventory.mjs (nine unused-demo removals, the
// approved additions, the verification tags and the Popup move).
const recorded = JSON.parse(execFileSync('git', ['show', 'c8e5fbeebc26fba92998fc5ddcd28c6c110e452d:docs/sw/property-panel-comparison-qa/verify/result.json'], { encoding: 'utf8', maxBuffer: 1 << 26 })).data.inventory
const now = headerInventoryIssues(before.sw, index)
report.data.inventory.recorded = { total: recorded.total, sw: recorded.sw }
check('inventory: 24 -> 19 SW Stories, 343 -> 338 total, only 5 approved retirements (recorded at c8e5fbe); now only approved later changes', before.sw.length === 24 && recorded.sw === 19 && before.total === 343 && recorded.total === 338 &&
JSON.stringify(recorded.ids.map(([id]) => id).sort()) === JSON.stringify(expected.map(e => e.id).sort()) && retiredPropertyPanelIds.every(id => !index.entries[id]) &&
stories.length === 338 - retiredUnusedDemoIds.length + approvedAddedStoryIds.length && now.unexpected.length === 0 && now.missing.length === 0 && now.retiredPresent.length === 0, { before: [before.total, before.sw.length], recorded: [recorded.total, recorded.sw], after: [stories.length, sw.length], ...now })
check('inventory: retained SW Story IDs, names, titles, tags unchanged (recorded at c8e5fbe); now only the approved tags and Popup move', JSON.stringify(recorded.ids) === JSON.stringify(expected.map(e => [e.id, e.name])) && now.changed.length === 0, { retained: sw.length, changed: now.changed })
check('inventory: compare Story keeps its ID and name', index.entries['sw-header--property-panel-compare']?.name === 'Property Panel · 4개 비교')
check('inventory: Dropdown Story keeps ID / name / title', index.entries['sw-header--composed-controls']?.name === 'Dropdown' && index.entries['sw-header--composed-controls']?.title === 'SW/Header')
@@ -181,12 +192,14 @@ try {
check('Dropdown Story renders composed controls', await page.locator('.sw-composed-controls').count() > 0)
await goto('window-move-motion')
check('Window Move Motion Story renders', await page.locator('[data-sw-window-move]').count() > 0)
await goto('property-panel')
// 2026-10-20: the Property Panel page was removed (5faa1f1); the Header-connected panel is the one of Overview.
await goto('overview')
const trigger = page.locator('.hm-sw-header [data-property-panel-trigger]')
await trigger.click(); const opened = await page.locator('.hm-sw-property').count()
await page.keyboard.press('Escape'); await page.keyboard.press('Escape')
check('Property Panel (Header-connected) still opens; Escape closes', opened === 1 && await page.locator('.hm-sw-property').count() === 0, { opened })
for (const id of ['property-shape-slot', 'property-fields', 'property-bar', 'layer-detail-input', 'k-guide']) {
check('Property Panel (Header-connected, Overview) still opens; Escape closes', opened === 1 && await page.locator('.hm-sw-property').count() === 0, { opened })
// Every other current SW/Header Story renders (the original list named the then-retained Stories; four were removed in 5faa1f1).
for (const id of sw.map(e => e.id.replace('sw-header--', '')).filter(id => !['overview', 'composed-controls', 'window-move-motion'].includes(id))) {
await goto(id); check(`render ${id}`, !(await page.locator('.sb-errordisplay').isVisible()))
}
check('no browser console or page errors', report.errors.length === 0, report.errors)
@@ -1,14 +1,17 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (dynamic-controls, composition). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
// Scoped verification for the five approved Story removals; serves the production build locally.
// Usage: node packages/sw/tests/storybook-cleanup-stage1.browser.mjs [evidence-dir]
// 2026-10-20 (test alignment): current regression again. The checks of the retired Dynamic Controls and Composition pages
// (removed 5faa1f1) run on current public Stories: Document Open (controls allowlist), Overview (host document list) and
// K Guide (the same fluid Header). Inventory and source checks accept only the approved changes in storybook-inventory.mjs.
// Usage: node packages/sw/tests/storybook-cleanup-stage1.browser.mjs <evidence-dir> (copy before-index.json from
// docs/sw/storybook-cleanup-stage1-qa/ into a new evidence directory; the recorded QA folder is not overwritten)
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 { swStoryEntries, retiredDropdownIds, retainedInventory, inventorySignature } from './storybook-inventory.mjs'
import { swStoryEntries, retiredDropdownIds, retainedInventory, headerInventoryIssues, headerSourceIssues, approvedMainCommit, integratedChangedFiles } from './storybook-inventory.mjs'
import { loadPlaywright } from './playwright-runtime.mjs'
import { sourceChanges, unapprovedChanges } from './source-changes.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-cleanup-stage1-qa')
await mkdir(out, { recursive: true })
@@ -19,13 +22,15 @@ const check = (name, ok, data) => {
}
const retired = [...['responsive', 'text-stroke', 'dynamic-data', 'default', 'controls'].map(id => `sw-header--${id}`), ...retiredDropdownIds]
const before = JSON.parse(await readFile(path.join(out, 'before-index.json'), 'utf8'))
const after = swStoryEntries(JSON.parse(await readFile('storybook-static/index.json', 'utf8')))
const afterIndex = JSON.parse(await readFile('storybook-static/index.json', 'utf8'))
const after = swStoryEntries(afterIndex)
await writeFile(path.join(out, 'after-index.json'), JSON.stringify(after, null, 2) + '\n')
report.deleted = before.filter(e => !after.some(a => a.id === e.id)).map(e => ({ name: e.name, id: e.id }))
check('33 -> 24; precisely the approved stage-1 and Dropdown IDs removed', before.length === 33 && after.length === 24 &&
report.deleted.length === 9 && retired.every(id => report.deleted.some(e => e.id === id)), report.deleted)
check('retained names, IDs and tags preserved except approved Dropdown rename', JSON.stringify(inventorySignature(retainedInventory(before))) ===
JSON.stringify(inventorySignature(after)))
// 2026-10-20: 33 -> 24 stays the assertion on the before list; today the 24 may differ only by storybook-inventory.mjs changes.
const retainedAtDropdown = retainedInventory(before), now = headerInventoryIssues(before, afterIndex)
check('33 -> 24; precisely the approved stage-1 and Dropdown IDs removed; now only approved later retirements and additions', before.length === 33 && retainedAtDropdown.length === 24 &&
retired.every(id => before.some(e => e.id === id) && !retainedAtDropdown.some(e => e.id === id)) && now.unexpected.length === 0 && now.missing.length === 0 && now.retiredPresent.length === 0, { deleted: report.deleted, ...now })
check('retained names, IDs and tags preserved except approved Dropdown rename and the approved later tag / Popup changes', now.changed.length === 0, now.changed)
// Compare declaration text with the starting commit, including every retained fixture/helper.
const file = 'src/sw/sw-header.stories.tsx'
@@ -43,11 +48,20 @@ const declarations = text => {
}
const oldDeclarations = new Map(declarations(original).map(d => [d.name, d.text]))
const changed = declarations(current).filter(d => !['WindowMoveMotion', 'ComposedDemo', 'meta', 'import:react', 'import:@hm-design/sw'].includes(d.name) && oldDeclarations.get(d.name) !== d.text)
check('retained declarations unchanged outside motion and composition integration', changed.length === 0, changed.map(d => d.name))
// 2026-10-20: a declaration changed after 92830fe must be an approved change at its approved content (storybook-inventory.mjs).
const map = text => Object.fromEntries(declarations(text).map(d => [d.name, d.text]))
const declarationsAt = commit => map(execFileSync('git', ['show', `${commit}:${file}`], { encoding: 'utf8', maxBuffer: 1 << 26 }).replaceAll('\r\n', '\n'))
const unapproved = headerSourceIssues(changed.map(d => d.name), Object.fromEntries(oldDeclarations), map(current), declarationsAt)
check('retained declarations unchanged outside motion and composition integration, except approved later changes at their approved content', unapproved.length === 0, { unapproved, approved: changed.map(d => d.name).filter(name => !unapproved.includes(name)) })
const section = text => text.match(/<StorySection title="단계별 진행 \(3배 확대\)"[\s\S]*?<\/StorySection>/)?.[0]
check('retained step section is byte-identical after newline normalization', !!section(original) && section(original) === section(current))
const productionDiff = execFileSync('git', ['diff', report.baseCommit, '--', 'packages/sw/src', 'src/sw/sw-stories.css', 'src/sw/assets', '.storybook'], { encoding: 'utf8' })
check('production implementation, CSS, SVG/assets and Storybook configuration unchanged', productionDiff === '')
// The stage-1 commit 50bf5bd left these paths untouched; today they may differ from the approved main merge only in the
// integrated-improvement files (as in overview.browser.mjs).
const productionPaths = ['packages/sw/src', 'src/sw/sw-stories.css', 'src/sw/assets', '.storybook']
const gitFiles = (...refs) => execFileSync('git', ['diff', '--name-only', ...refs, '--', ...productionPaths], { encoding: 'utf8' }).trim().split(/\r?\n/).filter(Boolean)
// Untracked new files count as changes (source-changes.mjs).
const productionDiff = { stage1: gitFiles(report.baseCommit, '50bf5bda8e956a4973b923d64131f2f2285bd38c'), unapproved: unapprovedChanges(approvedMainCommit, productionPaths, integratedChangedFiles), sinceBaseline: sourceChanges(report.baseCommit, productionPaths).length }
check('production implementation, CSS, SVG/assets and Storybook configuration unchanged by stage 1 and since the approved main merge', productionDiff.stage1.length === 0 && productionDiff.unapproved.length === 0, productionDiff)
const root = path.resolve('storybook-static')
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.png': 'image/png' }
@@ -119,23 +133,26 @@ try {
check('Responsive More still switches the representative field and opens its dropdown', await header.locator('.hm-sw-header__line-menu').count() > 0 && await header.locator('.hm-sw-header__line-menu').isVisible())
await page.keyboard.press('Escape')
await goto('dynamic-controls')
await page.getByRole('checkbox', { name: '구성 color', exact: true }).uncheck()
check('Dynamic Controls removes a configured field', await page.locator('header.hm-sw-header [data-control="color"], header.hm-sw-header [data-menu="color"]').count() === 0)
await page.getByRole('checkbox', { name: '구성 color', exact: true }).check()
check('Dynamic Controls restores a configured field', await page.locator('header.hm-sw-header [data-menu="color"]').count() > 0)
await page.getByRole('button', { name: '구성 초기화', exact: true }).click()
// 2026-10-20: Dynamic Controls (removed 5faa1f1) toggled a `controls` entry at runtime. No current Story toggles it;
// Document Open renders the same allowlist statically next to the default Header, and Overview owns a growing document list.
await goto('document-open')
const configured = page.locator('[data-composed-responsive] [data-width="1920"] header.hm-sw-header')
check('controls allowlist leaves out a field it does not configure (Document Open; formerly Dynamic Controls)', await configured.locator('[data-control="lineStyle"], [data-menu="line-style"]').count() === 0 && await configured.locator('[data-control="color"]').count() === 1)
check('Header without an allowlist keeps that field (Document Open default; formerly Dynamic Controls)', await page.locator('header.hm-sw-header').first().locator('[data-menu="line-style"]').count() > 0)
await goto('overview')
const documents = page.locator('header.hm-sw-header .hm-sw-header__document-button')
await documents.click()
await page.getByRole('button', { name: '+ New', exact: true }).click()
await documents.click()
check('host-owned document list can still grow dynamically', await page.getByText('새 문서 1', { exact: true }).count() > 0)
check('host-owned document list can still grow dynamically (Overview host; formerly Dynamic Controls)', await page.getByText('새 문서 1.dwg', { exact: true }).count() > 0)
await page.keyboard.press('Escape')
await goto('composition')
// Composition (removed 5faa1f1) showed the default Header at the page width; K Guide renders the same figmaArgs Header.
await goto('k-guide')
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 1000 })
const geometry = await page.locator('header.hm-sw-header').evaluate(e => ({ height: e.getBoundingClientRect().height, overflow: e.scrollWidth - e.clientWidth }))
check(`retained fluid header layout at ${width}px`, geometry.height === 49 && geometry.overflow === 0, geometry)
check(`retained fluid header layout at ${width}px (K Guide; formerly Composition)`, geometry.height === 49 && geometry.overflow === 0, geometry)
}
check('no browser errors', report.errors.length === 0, report.errors)
} catch (error) {
+52
View File
@@ -78,3 +78,55 @@ export const approvedHeaderDeclarations = {
PropertyPanelComparePage: '2b0e1a2262002aedb01c29c5d18ffae1219bf3ef', // exported for the moved Popup Story
}
export const movedHeaderDeclarations = { PropertyPanelCompare: ['src/sw/sw-property-panel.stories.tsx', '2b0e1a2262002aedb01c29c5d18ffae1219bf3ef'] }
// 2026-10-20 (legacy suite alignment): dropdown-consolidation, storybook-cleanup-stage1, property-panel-comparison and
// detail-buttons compare an earlier SW/Header inventory or source with today's through the approved changes below.
// Retired after the Dropdown consolidation and absent today (the Popup move keeps its ID: withApprovedEntryChanges).
export const laterRetiredHeaderIds = [...retiredPropertyPanelIds, ...retiredUnusedDemoIds]
// The approved additions that sit under SW/Header.
export const approvedAddedHeaderIds = approvedAddedStoryIds.filter(id => id.startsWith('sw-header--'))
// An earlier SW/Header entry list against today's index: `changed` (ID, name, title or tags differ from the earlier entry
// with the approved changes applied), `unexpected` (SW/Header Stories neither retained nor approved), `missing` (approved
// additions not under SW/Header) and `retiredPresent` (an approved retirement back in the index).
export const headerInventoryIssues = (entries, index) => {
const expected = retainedInventory(entries).filter(entry => !laterRetiredHeaderIds.includes(entry.id)).map(withApprovedEntryChanges)
const signature = entry => entry && JSON.stringify([entry.id, entry.name, entry.title, entry.tags])
const current = swStoryEntries(index)
const issues = {
actual: current.length, expected: expected.filter(entry => entry.title === 'SW/Header').length + approvedAddedHeaderIds.length,
changed: expected.filter(entry => signature(entry) !== signature(index.entries[entry.id])).map(entry => entry.id),
unexpected: current.filter(entry => !expected.some(item => item.id === entry.id) && !approvedAddedHeaderIds.includes(entry.id)).map(entry => entry.id),
missing: approvedAddedHeaderIds.filter(id => index.entries[id]?.title !== 'SW/Header'),
retiredPresent: [...retiredStage1Ids, ...retiredDropdownIds, ...laterRetiredHeaderIds].filter(id => index.entries[id]),
}
return { ...issues, ok: issues.actual === issues.expected && ['changed', 'unexpected', 'missing', 'retiredPresent'].every(key => issues[key].length === 0) }
}
// src/sw/sw-header.stories.tsx top-level declarations (Story exports, helpers, fixtures, types; imports as
// `import:<source>`) that approved commits changed, added or removed after the stage-1 baseline 92830fe, under the last
// commit that touched each. Each suite still excludes the declarations of its own change (Dropdown, motion, ComposedDemo, meta).
export const approvedHeaderSourceChanges = {
// chore(sw): improve interactive header story preview (Overview itself changed again in 170a5c2)
'bf16578226fe89e5d9a6ba904b7979bebb602661': ['import:./sw-header-overview'],
// refactor(storybook): consolidate property panel comparison layout (retiredPropertyPanelIds)
'c8e5fbeebc26fba92998fc5ddcd28c6c110e452d': ['PropertyCompare', 'PropertyPanelVariants', 'PropertyStateStory', 'PropertyPanelNone', 'PropertyPanelAll', 'PropertyPanelCircle', 'PropertyPanelExpanded'],
// refactor(storybook): remove unused SW header demo stories (retiredUnusedDemoIds and the demo code only they used)
'5faa1f1ffe89eb7aeb4aaec81ac5bb2d0f4fced9': ['StyleHostProps', 'StyleHeader', 'FIGMA_LAYERS', 'noop', 'LayerHostProps', 'describeLayers', 'LayerHeader', 'LayerDemo',
'InputDemo', 'LayerDetailInput', 'PropertyPanelDemo', 'PropertyPanel', 'RequiredColorDemo', 'AdditionalHover', 'PropertyBarDemo', 'PropertyBar', 'CONTROL_FIXTURE',
'DynamicControlsDemo', 'DynamicControls', 'Colorize', 'HIDEABLE_PARTS', 'MENU_IDS', 'COMPOSITION_PRESETS', 'CompositionDemo', 'Composition', 'SampleShapeViewer',
'SHAPE_MODES', 'SHAPE_MODE_LABELS', 'ShapeSlotDemo', 'PropertyShapeSlot', 'FIELD_NODES', 'FIELD_FIXTURE', 'FIELD_HOVER', 'FieldPanel', 'PropertyFieldsDemo', 'PropertyFields',
'import:./assets/figma-reference/btn-detail-layer-26-10767.png', 'import:./assets/figma-reference/input-26-11656.png'],
// feat(sw): integrate responsive home menu into header
'170a5c23aed10f8fc65e5b20e9b94bd3c1ff1227': ['import:@hm-design/sw', 'import:./sw-home-menu-fixture', 'OVERVIEW_HOME_MENU', 'Overview', 'MeasuredMoreDemo', 'ResponsiveMore'],
// refactor(sw): simplify storybook navigation and examples (verification tags, Popup move)
'2b0e1a2262002aedb01c29c5d18ffae1219bf3ef': ['meta', 'WindowMoveMotion', 'KGuide', 'PropertyPanelCompare', 'PropertyPanelComparePage'],
}
const approvedHeaderSourceCommit = Object.fromEntries(Object.entries(approvedHeaderSourceChanges).flatMap(([commit, names]) => names.map(name => [name, commit])))
// Declarations among `names` that differ between `base` and `current` without an approved change: none listed, or the
// current text differs from the approving commit's, or that commit did not change it. `at(ref)` returns the declaration
// map of the file at a Git ref, keyed like `base` and `current`.
export const headerSourceIssues = (names, base, current, at) => {
const cache = new Map(), version = ref => { if (!cache.has(ref)) cache.set(ref, at(ref)); return cache.get(ref) }
return names.filter(name => base[name] !== current[name]).filter(name => {
const commit = approvedHeaderSourceCommit[name]
return !commit || current[name] !== version(commit)[name] || version(`${commit}^`)[name] === version(commit)[name]
})
}
@@ -1,7 +1,8 @@
// 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.
// 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'