// 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, 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 [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' let server if (base === 'static') { 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'} 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));base=`http://127.0.0.1:${server.address().port}` } const { chromium } = loadPlaywright() await mkdir(output, { recursive: true }) 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: 1920, height: 1000 } }) const report = { mode, browser: browser.version(), checks: [], errors: [], styles: {} } page.on('pageerror', e => report.errors.push(e.message)) page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) }) const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); if (!ok) console.error('FAIL', name, data) } 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 style = e => { const s = getComputedStyle(e), t = e.querySelector('[data-detail-label],span:last-child'), i = e.querySelector('img') return { background:s.backgroundColor,color:s.color,opacity:s.opacity,border:s.border, radius:s.borderRadius,shadow:s.boxShadow, textColor:getComputedStyle(t).color,textOpacity:getComputedStyle(t).opacity,textStroke:getComputedStyle(t).webkitTextStrokeWidth, 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') { await goto('detail-buttons') await page.screenshot({path:path.join(output,'comparison-default.png'),fullPage:true}) 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') { // 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})) check(`required responsive width ${width}`,layout.height===49&&layout.overflow===0&&layout.documentOverflow===0,layout) } await page.setViewportSize({width:1920,height:1000}) await goto('property-panel-compare') for(const [id,height] of [['none',256],['all',361],['circle',658],['expanded',754]]){ 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('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('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!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. // 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} })) } } await page.setViewportSize({ width:1920,height:1000 }) await goto('composed-controls') await page.locator('[data-composed-controls]').getByRole('button',{name:'문자스타일',exact:true}).click() const original = page.getByRole('button',{name:'문자스타일 상세',exact:true}).first() snapshots.textDefault = await original.evaluate(style) if (!(await original.isDisabled())) { await original.hover(); snapshots.textHover = await original.evaluate(style) } else { // Existing callback-enabled example supplies the effective text-style Hover reference. const enabled = page.locator('button[data-detail="문자스타일 상세"]:not(:disabled)').first() await enabled.hover(); snapshots.textHover = await enabled.evaluate(style) } if (mode === 'baseline') { await writeFile(path.join(output,'baseline.json'),JSON.stringify(snapshots,null,2)) } else { const before=JSON.parse(await readFile(path.join(output,'baseline.json'),'utf8')) for(const key of Object.keys(snapshots).filter(k=>!k.startsWith('text'))) check(`unchanged header ${key} (capture a current baseline for replacement fixtures)`,JSON.stringify(before[key])===JSON.stringify(snapshots[key])) const oldIds=JSON.parse(await readFile(path.join(output,'baseline-story-ids.json'),'utf8')) 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) // 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()))} // 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 controls allowlist @${width}`,items.length>0,items);await page.keyboard.press('Escape')} } // Real dropdown integration, separate from the shared-button specimen. await goto('composed-controls') const host=page.locator('.sw-composed-controls') for(const [name,callback] of [['색상','onColorDetail'],['선스타일','onLineStyleDetail'],['문자스타일','onTextStyleDetail'],['치수스타일','onDimensionStyleDetail']]){ const trigger=host.getByRole('button',{name,exact:true});await trigger.click() const detail=host.getByRole('button',{name:`${name} 상세`,exact:true});await detail.hover() check(`actual ${name} shared hover`,(await detail.evaluate(style)).textOpacity==='0.35') if(name!=='색상'){ const row=host.locator('.hm-sw-header__menu-row').first();await row.locator('button').first().hover() check(`unchanged ${name} list hover`,await row.evaluate(e=>getComputedStyle(e).backgroundColor)==='rgb(98, 83, 61)') } 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)) } // 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 (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] for(let j=0;j<5;j++){ const row=rows.nth(j),button=row.locator('[data-detail-case="default"] button'),off=row.locator('[data-detail-case="disabled"] button'),empty=row.locator('[data-detail-case="no-callback"] button') await page.mouse.move(1,1);const rest=await button.evaluate(style) await button.hover();const hover=await button.evaluate(style) await page.mouse.move(1,1);const leave=await button.evaluate(style) await off.hover({force:true});const disabled=await off.evaluate(style) report.styles[names[j]]={default:rest,hover,disabled} for(const [state,s,ref] of [['default',rest,before.textDefault],['hover',hover,before.textHover]]){ check(`${names[j]} ${state} equals original text design`,['background','color','opacity','border','radius','shadow','textColor','textOpacity','textStroke','iconOpacity','iconContent'].every(k=>s[k]===ref[k]),{actual:s,reference:ref}) check(`${names[j]} ${state} dimensions`,s.width===widths[j]&&s.height===24,s) } check(`${names[j]} leave restores`,JSON.stringify(rest)===JSON.stringify(leave)) check(`${names[j]} disabled stays default`,disabled.disabled&&['background','textOpacity','iconContent','radius'].every(k=>disabled[k]===rest[k])) const count=await row.locator('output').textContent();const box=await off.boundingBox();await page.mouse.click(box.x+box.width/2,box.y+box.height/2) check(`${names[j]} disabled blocks callback`,await row.locator('output').textContent()===count) await button.click();check(`${names[j]} callback once`,await row.locator('output').textContent()==='1') await empty.hover();check(`${names[j]} callback-free hover`,!(await empty.isDisabled())&&(await empty.evaluate(style)).background===hover.background);await empty.click() await page.mouse.move(1,1);await button.focus();await page.keyboard.press('Tab');await page.keyboard.press('Shift+Tab') check(`${names[j]} keyboard focus visible`,await button.evaluate(e=>e.matches(':focus-visible')&&getComputedStyle(e).outlineStyle!=='none')) await page.keyboard.press('Enter');check(`${names[j]} keyboard callback`,await row.locator('output').textContent()==='2') } await page.mouse.move(1,1);await page.screenshot({path:path.join(output,'comparison-default.png'),fullPage:true}) await rows.nth(3).locator('[data-detail-case="hover"] button').hover();await page.screenshot({path:path.join(output,'comparison-pointer-hover.png'),fullPage:true}) } } check('no browser 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(output,`${mode}-result.json`),JSON.stringify(report,null,2));await browser.close();if(server)await new Promise(resolve=>server.close(resolve));console.log(JSON.stringify({status:report.status,checks:report.checks.length,stories:report.storyIds?.length,errors:report.errors}));process.exitCode=report.status==='PASS'?0:1 }