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:
1 parent
1e05eaf1de
commit
b50e95c325
8 files changed
+375
-66
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user