@@ -0,0 +1,209 @@
// Current regression after removing nine unused SW Header demo pages (2026-10-19, 19 -> 10 SW Stories).
// Build Storybook first. Usage: node packages/sw/tests/storybook-unused-removal.browser.mjs [evidence-dir]
// Older scripts that open the retired IDs (detail-buttons preservation/verify, followup-four, property-close,
// property-fields, property-panel-comparison verify, storybook-cleanup-stage1) are historical evidence for their runs.
import { createRequire } from 'node:module'
import { mkdir , readFile , writeFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import http from 'node:http'
import { parse } from '@babel/parser'
import { swStoryEntries , retiredUnusedDemoIds } from './storybook-inventory.mjs'
const out = path . resolve ( process . argv [ 2 ] ? ? 'docs/sw/storybook-unused-story-removal-qa/verify' )
const qa = 'docs/sw/storybook-unused-story-removal-qa/before'
const baseCommit = 'c8e5fbeebc26fba92998fc5ddcd28c6c110e452d'
await mkdir ( out , { recursive : true } )
const report = { baseCommit , checks : [ ] , errors : [ ] , data : { } }
const check = ( name , ok , data ) => { report . checks . push ( { name , status : ok ? 'PASS' : 'FAIL' , data } ) ; if ( ! ok ) console . error ( 'FAIL' , name , JSON . stringify ( data ) ) }
// ---- Inventory: exactly the nine requested IDs leave; every other entry keeps ID, name, title, tags and order.
const beforeIndex = JSON . parse ( await readFile ( path . join ( qa , 'full-index-before.json' ) , 'utf8' ) )
const afterIndex = JSON . parse ( await readFile ( 'storybook-static/index.json' , 'utf8' ) )
const stories = index => Object . values ( index . entries ) . filter ( e => e . type === 'story' )
const sig = list => JSON . stringify ( list . map ( e => [ e . id , e . name , e . title , e . tags ] ) )
const beforeSW = swStoryEntries ( beforeIndex ) , afterSW = swStoryEntries ( afterIndex )
const expected = beforeSW . filter ( e => ! retiredUnusedDemoIds . includes ( e . id ) )
report . data . inventory = { before : [ stories ( beforeIndex ) . length , beforeSW . length ] , after : [ stories ( afterIndex ) . length , afterSW . length ] , sw : afterSW . map ( e => [ e . id , e . name ] ) }
await writeFile ( path . join ( out , 'story-inventory-after.json' ) , JSON . stringify ( { total : stories ( afterIndex ) . length , sw : afterSW . map ( e => ( { id : e . id , name : e . name , title : e . title , tags : e . tags } ) ) } , null , 2 ) + '\n' )
check ( 'inventory: 19 -> 10 SW Stories, 338 -> 329 total' , beforeSW . length === 19 && afterSW . length === 10 && stories ( beforeIndex ) . length === 338 && stories ( afterIndex ) . length === 329 , report . data . inventory )
check ( 'inventory: the nine requested IDs existed before and are absent now' , retiredUnusedDemoIds . length === 9 && retiredUnusedDemoIds . every ( id => beforeIndex . entries [ id ] && ! afterIndex . entries [ id ] ) )
check ( 'inventory: retained SW IDs, names, titles, tags and order unchanged' , sig ( expected ) === sig ( afterSW ) , afterSW . map ( e => e . id ) )
const others = index => Object . values ( index . entries ) . filter ( e => ! retiredUnusedDemoIds . includes ( e . id ) )
check ( 'inventory: every non-removed index entry identical (docs and other packages included)' , JSON . stringify ( others ( beforeIndex ) ) === JSON . stringify ( others ( afterIndex ) ) )
check ( 'inventory: Dropdown keeps sw-header--composed-controls / Dropdown / SW/Header' , afterIndex . entries [ 'sw-header--composed-controls' ] ? . name === 'Dropdown' && afterIndex . entries [ 'sw-header--composed-controls' ] ? . title === 'SW/Header' )
check ( 'inventory: comparison keeps its ID and name' , afterIndex . entries [ 'sw-header--property-panel-compare' ] ? . name === 'Property Panel · 4개 비교' )
// ---- Source: retained Story declarations byte-identical; product, AI template and other packages untouched.
const sourcePath = 'src/sw/sw-header.stories.tsx'
const exports = text => Object . fromEntries ( parse ( text , { sourceType : 'module' , plugins : [ 'typescript' , 'jsx' ] } ) . program . body
. filter ( n => n . type === 'ExportNamedDeclaration' && n . declaration ) . map ( n => [ n . declaration . declarations ? . [ 0 ] . id . name ? ? n . declaration . id . name , text . slice ( n . start , n . end ) ] ) )
const oldExports = exports ( execFileSync ( 'git' , [ 'show' , ` ${ baseCommit } : ${ sourcePath } ` ] , { encoding : 'utf8' } ) . replaceAll ( '\r\n' , '\n' ) )
const newExports = exports ( ( await readFile ( sourcePath , 'utf8' ) ) . replaceAll ( '\r\n' , '\n' ) )
const removedExports = [ 'PropertyFields' , 'PropertyShapeSlot' , 'Composition' , 'Colorize' , 'DynamicControls' , 'PropertyBar' , 'AdditionalHover' , 'PropertyPanel' , 'LayerDetailInput' ]
check ( 'source: the nine Story exports are removed' , removedExports . every ( name => oldExports [ name ] && ! newExports [ name ] ) )
const changed = Object . keys ( oldExports ) . filter ( name => ! removedExports . includes ( name ) && oldExports [ name ] !== newExports [ name ] )
check ( 'source: retained exports (Stories and ComposedDemo) byte-identical' , changed . length === 0 && Object . keys ( newExports ) . every ( name => oldExports [ name ] ) , changed )
const untouched = [ 'packages/sw/src' , 'packages/sw/ai' , 'packages/sw/package.json' , 'packages/sw/README.md' , 'packages/ui' , 'packages/org-chart' , 'packages/org-chart-react' , 'packages/center-graphic' , 'src/sw/sw-header-overview.tsx' , 'src/sw/sw-header-dropdown.stories.tsx' , 'src/sw/sw-composed-controls.css' , 'src/sw/assets' , '.storybook' ]
const diff = execFileSync ( 'git' , [ 'diff' , '--name-only' , baseCommit , '--' , ... untouched ] , { encoding : 'utf8' } ) . trim ( )
check ( 'source: runtime, public API, AI template, other packages, Overview, Dropdown CSF, assets and Storybook config untouched' , diff === '' , diff )
// ---- Browser
const root = path . resolve ( 'storybook-static' ) , mime = { '.html' : 'text/html' , '.js' : 'text/javascript' , '.css' : 'text/css' , '.json' : 'application/json' , '.svg' : 'image/svg+xml' , '.png' : 'image/png' , '.gif' : 'image/gif' , '.ttf' : 'font/ttf' , '.woff2' : 'font/woff2' }
const server = http . createServer ( async ( req , res ) => { try { const p = new URL ( req . url , 'http://localhost' ) . pathname , file = path . resolve ( root , '.' + decodeURIComponent ( p === '/' ? '/index.html' : p ) ) ; if ( path . relative ( root , file ) . startsWith ( '..' ) ) return res . writeHead ( 403 ) . end ( ) ; res . setHeader ( 'Content-Type' , mime [ path . extname ( file ) ] ? ? 'application/octet-stream' ) ; res . end ( await readFile ( file ) ) } catch { res . writeHead ( 404 ) . end ( ) } } )
await new Promise ( resolve => server . listen ( 0 , '127.0.0.1' , resolve ) )
const base = ` http://127.0.0.1: ${ server . address ( ) . port } `
const { chromium } = createRequire ( import . meta . url ) ( process . env . SW _PLAYWRIGHT _PATH ? ? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright' )
const browser = await chromium . launch ( { headless : true , executablePath : process . env . SW _CHROME _PATH ? ? 'C:/Program Files/Google/Chrome/Application/chrome.exe' } )
const page = await browser . newPage ( { viewport : { width : 1440 , height : 900 } } )
report . browser = browser . version ( )
page . on ( 'pageerror' , e => report . errors . push ( e . message ) )
page . on ( 'console' , m => { if ( m . type ( ) === 'error' ) report . errors . push ( m . text ( ) ) } )
const goto = async id => {
await page . goto ( ` ${ base } /iframe.html?id=sw-header-- ${ id } &viewMode=story ` )
await page . locator ( '.hm-sw-header, .hm-sw-theme' ) . first ( ) . waitFor ( )
await page . evaluate ( ( ) => document . fonts . ready )
await page . mouse . move ( 1 , 1 )
}
const noDocOverflow = ( ) => page . evaluate ( ( ) => document . documentElement . scrollWidth <= document . documentElement . clientWidth )
try {
// Sidebar: the manager lists the ten retained Stories in order, none of the nine menus.
await page . goto ( ` ${ base } /?path=/story/sw-header--overview ` )
await page . frameLocator ( '#storybook-preview-iframe' ) . locator ( '[data-preview-stage]' ) . waitFor ( )
const sidebar = await page . locator ( '[data-item-id^="sw-header--"]' ) . evaluateAll ( list => [ ... new Map ( list . map ( e => [ e . dataset . itemId , e . textContent . replace ( 'Skip to content' , '' ) . trim ( ) ] ) ) . entries ( ) ] )
report . data . sidebar = sidebar
await writeFile ( path . join ( out , 'manager-story-order.json' ) , JSON . stringify ( sidebar , null , 2 ) + '\n' )
// .storybook/main.ts excludes `verification` Stories (Detail Buttons) from the default sidebar; direct URLs stay.
const visible = afterSW . filter ( e => ! e . tags . includes ( 'verification' ) )
check ( 'sidebar: retained non-verification Stories in index order with their names' , JSON . stringify ( sidebar ) === JSON . stringify ( visible . map ( e => [ e . id , e . name ] ) ) , sidebar )
const removedNames = beforeSW . filter ( e => retiredUnusedDemoIds . includes ( e . id ) ) . map ( e => e . name )
check ( 'sidebar: none of the nine removed menu IDs or names remain' , sidebar . every ( ( [ id , text ] ) => ! retiredUnusedDemoIds . includes ( id ) && ! removedNames . includes ( text ) ) , removedNames )
await page . screenshot ( { path : path . join ( out , 'manager-sidebar.png' ) } )
// A removed URL no longer resolves to a Story.
await page . goto ( ` ${ base } /iframe.html?id=sw-header--colorize&viewMode=story ` )
await page . waitForTimeout ( 1500 )
check ( 'removed URL renders no SW Story' , await page . locator ( '.hm-sw-header, .hm-sw-theme' ) . count ( ) === 0 )
report . errors . length = 0 // the missing-story error above is expected; start the regression clean
for ( const entry of afterSW ) {
await goto ( entry . id . replace ( 'sw-header--' , '' ) )
check ( ` render ${ entry . id } ` , ! ( await page . locator ( '.sb-errordisplay' ) . isVisible ( ) ) )
}
// Overview: Header 49px, Stage, Dropdown open/close, Bar edit, panel open/Escape, callbacks.
for ( const width of [ 390 , 768 , 1280 , 1440 ] ) {
await page . setViewportSize ( { width , height : 900 } ) ; await goto ( 'overview' )
const header = page . locator ( 'header.hm-sw-header' )
const height = await header . evaluate ( h => h . getBoundingClientRect ( ) . height )
check ( ` Overview ${ width } : one Header 49px, one Stage, no page overflow ` , height === 49 && await header . count ( ) === 1 && await page . locator ( '[data-preview-stage]' ) . count ( ) === 1 && await noDocOverflow ( ) , { height } )
await page . screenshot ( { path : path . join ( out , ` overview- ${ width } .png ` ) } )
}
await page . setViewportSize ( { width : 1440 , height : 900 } ) ; await goto ( 'overview' )
{
const header = page . locator ( 'header.hm-sw-header' ) , callback = ( ) => page . locator ( '[data-preview-callback]' ) . textContent ( )
const layer = header . getByRole ( 'button' , { name : '레이어' , exact : true } )
await layer . click ( )
check ( 'Overview: layer Dropdown opens' , await header . locator ( '.hm-sw-header__layer-panel' ) . count ( ) === 1 && await layer . getAttribute ( 'aria-expanded' ) === 'true' )
await page . keyboard . press ( 'Escape' )
check ( 'Overview: Escape closes Dropdown and restores focus' , await header . locator ( '.hm-sw-header__layer-panel' ) . count ( ) === 0 && await layer . evaluate ( e => e === document . activeElement ) )
await layer . click ( ) ; await header . getByRole ( 'button' , { name : '레이어 상세' , exact : true } ) . click ( )
check ( 'Overview: layer Detail callback (former Layer Detail page) and close' , ( await callback ( ) ) . includes ( 'onLayerDetail' ) && await header . locator ( '.hm-sw-header__layer-panel' ) . count ( ) === 0 )
await header . getByRole ( 'button' , { name : '색상' , exact : true } ) . click ( )
const pick = header . getByRole ( 'button' , { name : '색상 추출' , exact : true } )
const pickBox = await pick . boundingBox ( )
await pick . click ( )
check ( 'Overview: btn_colorize 24 × 24 calls onColorPick and closes (former Colorize page)' , pickBox . width === 24 && pickBox . height === 24 && ( await callback ( ) ) . includes ( 'onColorPick' ) && await header . locator ( '.hm-sw-header__color-menu' ) . count ( ) === 0 , pickBox )
const bar = page . locator ( '.hm-sw-property-bar [data-property-id="radius"] input' )
await bar . fill ( '6.25' ) ; await bar . press ( 'Enter' )
check ( 'Overview: property Bar Enter commits through callback' , await bar . inputValue ( ) === '6.25' && ( await callback ( ) ) . includes ( 'onPropertyChange(radius, 6.25)' ) )
await bar . fill ( '-1' ) ; await bar . press ( 'Enter' )
check ( 'Overview: property Bar validation keeps min=0' , await bar . inputValue ( ) === '6.25' )
const trigger = header . locator ( '[data-property-panel-trigger]' )
await trigger . click ( ) ; await page . locator ( '.hm-sw-property' ) . waitFor ( )
check ( 'Overview: panel opens with the shared Bar value' , await page . locator ( '.hm-sw-property [data-property-id="radius"] input' ) . first ( ) . inputValue ( ) === '6.25' )
await page . keyboard . press ( 'Escape' ) ; await page . keyboard . press ( 'Escape' )
check ( 'Overview: Escape closes panel and returns focus to its trigger' , await page . locator ( '.hm-sw-property' ) . count ( ) === 0 && await trigger . evaluate ( e => e === document . activeElement ) )
}
// Dropdown: six controls, real open, selection, Escape and focus return.
await goto ( 'composed-controls' )
{
const host = page . locator ( '[data-composed-controls]' ) , field = name => host . getByRole ( 'button' , { name , exact : true } )
const menu = ( ) => page . locator ( '[data-composed-controls] .hm-sw-header__menu-panel, [data-composed-controls] .hm-sw-header__layer-menu' )
check ( 'Dropdown: five fields plus the 상세보기 group (6 controls)' , await host . locator ( '.hm-sw-header__field' ) . count ( ) === 5 && await host . getByRole ( 'group' , { name : '상세보기' , exact : true } ) . count ( ) === 1 )
for ( const width of [ 390 , 768 , 1280 , 1440 ] ) {
await page . setViewportSize ( { width , height : 900 } )
for ( const label of [ '레이어' , '색상' , '선스타일' , '문자스타일' , '치수스타일' ] ) {
await field ( label ) . click ( )
const opened = await menu ( ) . count ( ) === 1 && await field ( label ) . getAttribute ( 'aria-expanded' ) === 'true'
await page . keyboard . press ( 'Escape' )
check ( ` Dropdown ${ width } : ${ label } opens, Escape closes, focus returns ` , opened && await menu ( ) . count ( ) === 0 && await field ( label ) . evaluate ( e => e === document . activeElement ) )
}
check ( ` Dropdown ${ width } : no page overflow ` , await noDocOverflow ( ) )
await page . screenshot ( { path : path . join ( out , ` dropdown- ${ width } .png ` ) , fullPage : true } )
}
await page . setViewportSize ( { width : 1440 , height : 900 } )
await field ( '문자스타일' ) . click ( ) ; await page . locator ( '.hm-sw-header__list-menu--text-style' ) . getByRole ( 'button' , { name : /Dotum/ } ) . click ( )
check ( 'Dropdown: text style selection reflected in field and status' , ( await field ( '문자스타일' ) . textContent ( ) ) . includes ( 'Dotum' ) && ( await page . locator ( '[data-style-state]' ) . textContent ( ) ) . includes ( 'Dotum' ) )
await field ( '선스타일' ) . click ( )
const center = page . getByRole ( 'button' , { name : 'CENTER' , exact : true } )
await center . click ( )
check ( 'Dropdown: line style selection reflected' , ( await page . locator ( '[data-style-state]' ) . textContent ( ) ) . includes ( 'CENTER' ) )
if ( await menu ( ) . count ( ) ) await page . keyboard . press ( 'Escape' )
}
// Comparison: 2 × 2, original sizes, Inner scroll reaches the 754px bottom, input and dropdown work.
for ( const width of [ 390 , 768 , 1280 , 1440 ] ) {
await page . setViewportSize ( { width , height : 900 } ) ; await goto ( 'property-panel-compare' )
const g = await page . evaluate ( ( ) => {
const r = e => e . getBoundingClientRect ( ) , s = document . querySelector ( '[data-compare-scroll]' )
const cells = [ ... document . querySelectorAll ( '[data-compare]' ) ] . map ( c => ( { id : c . dataset . compare , x : r ( c ) . x , y : r ( c ) . y , right : r ( c ) . right , bottom : r ( c ) . bottom , w : r ( c . querySelector ( '.hm-sw-property' ) ) . width , h : r ( c . querySelector ( '.hm-sw-property' ) ) . height } ) )
s . scrollTop = s . scrollHeight ; s . scrollLeft = s . scrollWidth
const last = r ( document . querySelectorAll ( '[data-compare] .hm-sw-property' ) [ 3 ] ) , sr = r ( s )
return { cells , reach : last . bottom <= sr . bottom + 0.5 && last . right <= sr . right + 0.5 , docFits : document . documentElement . scrollHeight <= innerHeight && document . documentElement . scrollWidth <= innerWidth }
} )
const [ a , b , c , d ] = g . cells
report . data [ ` compare ${ width } ` ] = g
check ( ` Compare ${ width } : four states in order at 314 × 256 / 361 / 658 / 754 ` , JSON . stringify ( g . cells . map ( e => [ e . id , e . w , e . h ] ) ) === JSON . stringify ( [ [ 'none' , 314 , 256 ] , [ 'all' , 314 , 361 ] , [ 'circle' , 314 , 658 ] , [ 'expanded' , 314 , 754 ] ] ) , g . cells )
check ( ` Compare ${ width } : 2 columns × 2 rows ` , Math . abs ( a . y - b . y ) < 0.5 && Math . abs ( c . y - d . y ) < 0.5 && Math . abs ( a . x - c . x ) < 0.5 && Math . abs ( b . x - d . x ) < 0.5 && b . x >= a . right && c . y >= Math . max ( a . bottom , b . bottom ) )
check ( ` Compare ${ width } : Inner scroll reaches the 754px panel bottom; page does not scroll ` , g . reach && g . docFits , g )
await page . screenshot ( { path : path . join ( out , ` compare- ${ width } -end.png ` ) } )
}
await page . setViewportSize ( { width : 1440 , height : 900 } ) ; await goto ( 'property-panel-compare' )
{
const cell = id => page . locator ( ` [data-compare=" ${ id } "] ` ) , log = ( ) => page . locator ( '[data-property-log]' ) . textContent ( )
const radius = cell ( 'circle' ) . locator ( '[data-property-id="radius"] input.hm-sw-header__input' ) . first ( )
await radius . scrollIntoViewIfNeeded ( ) ; await radius . click ( )
check ( 'Compare: editable Input caret #E60403 (former Layer Detail · Input page)' , await radius . evaluate ( e => getComputedStyle ( e ) . caretColor === 'rgb(230, 4, 3)' ) )
await radius . fill ( '5.5' ) ; await radius . press ( 'Enter' )
check ( 'Compare: Input Enter commits only in its own panel' , ( await radius . inputValue ( ) ) . startsWith ( '5.5' ) && ( await log ( ) ) . includes ( 'circle: property(radius, 5.5' ) )
await radius . click ( ) ; await radius . fill ( '9' ) ; await radius . press ( 'Escape' )
check ( 'Compare: Input Escape cancels' , ( await radius . inputValue ( ) ) . startsWith ( '5.5' ) )
const trigger = cell ( 'all' ) . locator ( '[data-property-id="layer"] button[aria-haspopup], [data-property-id="layer"] [aria-expanded]' ) . first ( )
await trigger . scrollIntoViewIfNeeded ( ) ; await trigger . click ( )
const drop = page . locator ( '.hm-sw-property__drop' ) . first ( ) ; await drop . waitFor ( { timeout : 2000 } )
await drop . getByRole ( 'button' , { name : 'CC-D GNL' , exact : true } ) . first ( ) . click ( )
check ( 'Compare: layer Dropdown selection commits via callback' , ( await log ( ) ) . includes ( 'all: property(layer, CC-D GNL)' ) )
if ( await page . locator ( '.hm-sw-property__drop' ) . count ( ) ) await page . keyboard . press ( 'Escape' )
await trigger . click ( ) ; await page . locator ( '.hm-sw-property__drop' ) . first ( ) . waitFor ( )
await page . keyboard . press ( 'Escape' )
check ( 'Compare: Escape closes the Dropdown and returns focus' , await page . locator ( '.hm-sw-property__drop' ) . count ( ) === 0 && await trigger . evaluate ( e => document . activeElement === e || e . contains ( document . activeElement ) ) )
}
// Window Move Motion: the single 3x step section with nine paused stages, live Hover 1400ms.
await goto ( 'window-move-motion' )
{
const stages = await page . locator ( '[data-motion-time]' ) . evaluateAll ( list => list . map ( e => ( { time : e . dataset . motionTime , animations : e . querySelector ( '.hm-sw-header__motion' ) ? . getAnimations ( { subtree : true } ) . length ? ? - 1 , zoom : getComputedStyle ( e . querySelector ( '.sw-story-zoom' ) ) . zoom } ) ) )
check ( 'Motion: one section 「단계별 진행 (3배 확대)」 with nine stages at zoom 3' , await page . getByRole ( 'heading' , { level : 2 } ) . allTextContents ( ) . then ( t => JSON . stringify ( t ) === JSON . stringify ( [ '단계별 진행 (3배 확대)' ] ) ) && stages . length === 9 && stages . every ( s => Number ( s . zoom ) === 3 ) , stages )
check ( 'Motion: rest stage static, timed stages hold three paused animations' , stages [ 0 ] . time === 'rest' && stages [ 0 ] . animations === 0 && stages . slice ( 1 ) . every ( s => s . animations === 3 ) , stages )
}
check ( 'no browser console or page errors' , report . errors . length === 0 , report . errors )
} catch ( e ) { check ( 'suite completed' , false , e . stack ) }
finally {
report . status = report . checks . every ( c => c . status === 'PASS' ) ? 'PASS' : 'FAIL'
await writeFile ( path . join ( out , 'result.json' ) , JSON . stringify ( report , null , 2 ) + '\n' )
await browser . close ( ) ; server . close ( )
console . log ( 'TOTAL' , report . checks . length , 'FAIL' , report . checks . filter ( c => c . status === 'FAIL' ) . length , report . status )
process . exitCode = report . status === 'PASS' ? 0 : 1
}