Files
HMDesign/packages/sw/tests/overview.browser.mjs
T
shkim4andClaude Opus 5.5 55179b4577 feat(sw): integrated improvement 0.2.0 (popup content, header open, tool buttons)
SW package 0.2.0 integrated improvement (Task A, steps 1-8). All new props are
optional; existing SW Stories are pixel-identical to the previous build (28/28).

- Build reproducibility: dist text normalized to LF, dist/build-info.json
  (commit, dirty, tool versions, no timestamp), packages/sw/.gitattributes
  (eol=lf). LF checkouts give identical dist and tarball.
- SWHeader: documentOpenPlacement ("menu" default | "header": Open right of
  the logo in the existing document-button look, reusing onDocumentOpen and
  documentOpenLabel, no duplicate Open in the dropdown), leading slot,
  hiddenControls sub-items documentNew / documentOpen / layerFreeze /
  layerLock. Open moves into More on measured overflow (with hysteresis).
- showNew / showOpen (SWHeaderDocumentTitle), showFreeze / showLock (layer
  menu, field, property window list).
- SWToolButton (text / pill / document) and SWToolGroup (radio selection),
  reusing the existing toggle, pill and file-name looks.
- SWPopup / SWPopupSection / SWPopupRow on the shared property window frame;
  SWHeaderPropertyPanel gains children, title, autoFocus and optional
  selection / properties / groups. Escape stack for several popups.
- Field dropdowns inside a popup use the shared fixed overlay
  (useSWFixedDropdownPosition), so the scrolling body no longer clips them.
- AI guide, examples E/F, README, Storybook (SW/Header/Options, SW/Tool,
  SW/Popup) and verification record docs/sw/sw-integrated-improvement-2026-10-19.md.
- Regression scripts rescoped for the intended changes (unused-removal 81/81,
  overview 52/54 with the two pre-existing historical assertions).

Known limitation (unresolved): tools / leading content (incl. SWToolGroup)
does not move into More. With a wide tool bar the configured fields shrink
instead; in Example F the document name and layer field reach 0px at 390px.

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

223 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Focused Overview integration checks. Build Storybook first; historical evidence stays untouched.
// Usage: node packages/sw/tests/overview.browser.mjs [evidence-dir]
import { createRequire } from 'node:module'
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, retainedInventory, inventorySignature, retiredDropdownIds, integratedChangedFiles } from './storybook-inventory.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-overview-qa')
await mkdir(out, { recursive: true })
const report = { baseCommit: '50bf5bda8e956a4973b923d64131f2f2285bd38c', checks: [], errors: [], sizes: [] }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name) }
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')))
await writeFile(path.join(out, 'after-index.json'), JSON.stringify(after, null, 2) + '\n')
check('24 retained Story IDs, names and tags; only approved Dropdown retirements', after.length === 24 && JSON.stringify(inventorySignature(retainedInventory(before))) === JSON.stringify(inventorySignature(after)))
const source = await readFile('src/sw/sw-header.stories.tsx', 'utf8')
const baseline = execFileSync('git', ['show', `${report.baseCommit}:src/sw/sw-header.stories.tsx`], { encoding: 'utf8' })
const stories = text => Object.fromEntries(parse(text, { sourceType:'module', plugins:['typescript','jsx'] }).program.body
.filter(n => n.type === 'ExportNamedDeclaration' && n.declaration.type === 'VariableDeclaration').map(n => [n.declaration.declarations[0].id.name, text.slice(n.start,n.end).replaceAll('\r\n','\n')]))
const oldStories = stories(baseline), newStories = stories(source)
const retiredExports = ['LayerDropdown','ColorDropdown','LineStyleDropdown','TextDimensionStyleDropdown','ComposedControls']
check('retained non-Overview declarations byte-identical; relocated composition separately verified', Object.keys(oldStories).filter(k => k !== 'Overview' && !retiredExports.includes(k)).every(k => oldStories[k] === newStories[k]))
check('retired Dropdown IDs absent', retiredDropdownIds.every(id => !after.some(e => e.id === id)))
// Since 2026-10-19 the production source changes only in the integrated-improvement files (their own suites verify them).
const sourceDiff = execFileSync('git', ['diff', '--name-only', report.baseCommit, '--', 'packages/sw/src'], { encoding:'utf8' }).trim().split(/\r?\n/).filter(Boolean)
check('package production source untouched except the integrated-improvement files', sourceDiff.every(file => integratedChangedFiles.includes(file)), sourceDiff.filter(file => !integratedChangedFiles.includes(file)))
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) => {
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')
let browser, page
try {
browser = await chromium.launch({ headless:true, executablePath:process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
report.browser = browser.version()
page = await browser.newPage({ viewport:{width:1440,height:1000} }); page.setDefaultTimeout(6000)
page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m => { if(m.type() === 'error') report.errors.push(m.text()) })
const go = async(id='overview') => {
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.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))))
}
const geometry = () => page.evaluate(() => {
const box = e => { const r=e.getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height,right:r.right,bottom:r.bottom} }
const stage=document.querySelector('[data-preview-stage]'),slot=document.querySelector('[data-preview-slot]')
return {stage:box(stage),slot:box(slot),header:box(document.querySelector('header.hm-sw-header')),viewport:{w:innerWidth,h:innerHeight},overflow:document.documentElement.scrollWidth-document.documentElement.clientWidth}
})
if(process.argv[3]==='overlays'){
await go()
const stage=page.locator('[data-preview-stage]'),header=page.locator('header.hm-sw-header')
const covered=locator=>locator.evaluate(e=>{const r=e.getBoundingClientRect();return e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2))})
await header.locator('.hm-sw-header__document-button').click()
const firstDocument=header.locator('.hm-sw-header__document-item').first()
check('document first row is above the property Bar and clickable',await covered(firstDocument));await firstDocument.click()
for(const [id,label,detail]of[['layer','레이어','레이어 상세'],['color','색상','색상 상세'],['line-style','선스타일','선스타일 상세'],['text-style','문자스타일','문자스타일 상세'],['dimension-style','치수스타일','치수스타일 상세']]){
await stage.evaluate(e=>e.scrollTop=0)
const button=header.getByRole('button',{name:label,exact:true})
if(await button.isVisible())await button.click();else{await header.getByRole('button',{name:'더보기',exact:true}).click();await header.locator(`[role=menuitem][data-menu="${id}"]`).click()}
await header.getByRole('button',{name:detail,exact:true}).click()
check(`existing ${detail} reports Callback`,(await page.locator('[data-preview-callback]').textContent()).includes(`on${{'layer':'Layer','color':'Color','line-style':'LineStyle','text-style':'TextStyle','dimension-style':'DimensionStyle'}[id]}Detail`))
}
await header.getByRole('button',{name:'색상',exact:true}).click()
await header.getByRole('button',{name:'ByBlock',exact:true}).click()
check('ByBlock mode immediately appears in Header without changing numeric RGB property',(await header.locator('.hm-sw-header__field--color').textContent()).includes('ByBlock'))
await page.keyboard.press('Escape')
await header.getByRole('button',{name:'색상',exact:true}).click()
await header.locator('.hm-sw-header__color-option').first().click()
check('direct color selection clears Header mode label',!(await header.locator('.hm-sw-header__field--color').textContent()).includes('ByBlock'))
await page.setViewportSize({width:390,height:700});await go();await page.getByLabel('선택 상태',{exact:true}).selectOption('expanded')
await header.getByRole('button',{name:'더보기',exact:true}).click();await header.getByRole('menuitemcheckbox',{name:'속성창',exact:true}).click()
for(const id of ['layer','line','color']){
const field=page.locator(`.hm-sw-property [data-property-id="${id}"]`),trigger=field.locator(id==='color'?'input':'button')
await trigger.scrollIntoViewIfNeeded();await trigger.click()
const drop=page.locator('.hm-sw-property__drop');await drop.waitFor()
const r=await drop.boundingBox()
check(`small Inner ${id} dropdown stays in browser width`,r.x>=0&&r.x+r.width<=390.5,r)
if(id==='layer'){const last=drop.getByRole('button',{name:'Sample-Text',exact:true});check('small Inner lower layer row hit-test is unobstructed',await covered(last));await last.click()}
else await page.keyboard.press('Escape')
check(`small Inner ${id} selection or Escape closes only nested dropdown`,await drop.count()===0&&await page.locator('.hm-sw-property').count()===1)
}
await page.screenshot({path:path.join(out,'overview-narrow-overlay.png')})
check('no overlay browser errors',report.errors.length===0,report.errors)
}else{
for(const [width,height] of [[1920,1080],[1440,900],[1280,720],[768,600],[390,844],[1000,500]]) {
await page.setViewportSize({width,height}); await go()
const g=await geometry(); report.sizes.push(g)
check(`16:9 fitted to both Inner dimensions at ${width}x${height}`,g.stage.w>0&&Math.abs(g.stage.w/g.stage.h-16/9)<.005&&g.stage.w<=g.slot.w+.1&&g.stage.h<=g.slot.h+.1&&g.header.h===49&&g.overflow===0,g)
}
await page.setViewportSize({width:1440,height:1000}); await go()
const header=page.locator('header.hm-sw-header'),stage=page.locator('[data-preview-stage]')
const callback=()=>page.locator('[data-preview-callback]').textContent()
const open=async(id,label) => {
await stage.evaluate(e=>{e.scrollTop=0;e.scrollLeft=0})
const button=header.getByRole('button',{name:label,exact:true})
if(await button.isVisible()) {if(await button.getAttribute('aria-expanded')!=='true')await button.click()}
else {await header.getByRole('button',{name:'더보기',exact:true}).click();await header.locator(`[role="menuitem"][data-menu="${id}"]`).click()}
}
const panelButton=async()=>{
const b=header.locator('[data-property-panel-trigger]')
if(await b.isVisible())await b.click()
else{await header.getByRole('button',{name:'더보기',exact:true}).click();await header.getByRole('menuitemcheckbox',{name:'속성창',exact:true}).click()}
}
const barInput=id=>page.locator(`.hm-sw-property-bar [data-property-id="${id}"] input`)
const panelInput=id=>page.locator(`.hm-sw-property [data-property-id="${id}"] input`).first()
const waitValue=async(locator,value)=>page.waitForFunction(([element,value])=>element.value===value,[await locator.elementHandle(),value])
check('one Header and no duplicate fixed Figma specimen',await header.count()===1&&await page.getByText('Figma 기준 1920 × 49',{exact:true}).count()===0)
await header.locator('.hm-sw-header__document-button').click()
const document=header.locator('.hm-sw-header__document-list .hm-sw-header__document-item').nth(1)
const documentName=await document.textContent();await document.click()
check('document selection updates Header and Callback',!!documentName&&(await header.locator('.hm-sw-header__document-button').textContent()).includes(documentName.trim())&&(await callback()).includes('onDocumentSelect'),await callback())
await open('layer','레이어');await header.getByRole('button',{name:'CC-D GNL',exact:true}).click()
check('layer selection updates shared data', (await callback()).includes('onLayerSelect(layer, CC-D GNL)'),await callback())
await open('layer','레이어');await header.getByRole('button',{name:'레이어 상세',exact:true}).click()
check('existing layer Detail callback closes list',await header.locator('.hm-sw-header__layer-panel').count()===0&&(await callback()).includes('onLayerDetail'))
await open('color','색상')
const chip=header.locator('.hm-sw-header__color-menu .hm-sw-header__color-option').first()
await chip.click();check('color selection invokes host callback',(await callback()).includes('onColorSelect'),await callback())
await open('line-style','선스타일');await header.getByRole('button',{name:'CENTER',exact:true}).click()
check('line selection updates Header', (await callback()).includes('onLineTypeSelect(line, CENTER)'),await callback())
await open('line-style','선스타일');await header.locator('ul[aria-label="가중치"]').getByRole('button',{name:'ByLayer',exact:true}).click()
check('ByLayer weight mode does not turn numeric property into a mode string',(await header.locator('.hm-sw-header__field--line-style').textContent()).includes('ByLayer'))
await page.keyboard.press('Escape')
await open('text-style','문자스타일');await header.locator('.hm-sw-header__list-menu--text-style').getByRole('button',{name:/Dotum/}).click()
check('text style selection updates Header',(await callback()).includes('onTextStyleSelect(dotum)'),await callback())
await open('dimension-style','치수스타일');await header.locator('.hm-sw-header__list-menu--dimension-style').getByRole('button',{name:/ISO - 25/}).click()
check('dimension style selection updates Header',(await callback()).includes('onDimensionStyleSelect(iso-25)'),await callback())
await open('color','색상');await open('layer','레이어')
check('opening another dropdown closes the previous one',await header.locator('.hm-sw-header__color-menu').count()===0&&await header.locator('.hm-sw-header__layer-panel').count()===1)
await page.keyboard.press('Escape');check('Escape closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0)
await open('layer','레이어');await page.locator('.sb-page-title').click();check('outside click closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0)
await barInput('radius').fill('6.25');await barInput('radius').press('Enter')
check('Bar Enter commits state',await barInput('radius').inputValue()==='6.25'&&(await callback()).includes('onPropertyChange(radius, 6.25)'))
await barInput('radius').fill('-1');await barInput('radius').press('Enter');check('Bar validates min=0',await barInput('radius').inputValue()==='6.25')
await barInput('radius').fill('9');await barInput('radius').press('Escape');check('Bar Escape cancels edit',await barInput('radius').inputValue()==='6.25')
await panelButton();await page.locator('.hm-sw-property').waitFor()
check('Header opens panel with same Bar value',await panelInput('radius').inputValue()==='6.25')
check('numeric weight property remains numeric after ByLayer mode',await panelInput('weight').inputValue()==='0.25')
check('Header layer value shared with panel',(await page.locator('.hm-sw-property [data-property-id="layer"]').textContent()).includes('CC-D GNL'))
await panelInput('radius').fill('7.5');await panelInput('radius').press('Enter')
await waitValue(barInput('radius'),'7.5')
check('panel edit updates the Bar',await barInput('radius').inputValue()==='7.5')
await panelInput('radius').fill('-2');await panelInput('radius').press('Enter');check('panel number validation rejects negative radius',await panelInput('radius').inputValue()==='7.5')
await page.locator('.hm-sw-property [data-property-id="layer"] button').click()
const drop=page.locator('.hm-sw-property__drop')
await drop.getByRole('button',{name:'Sample-Wall',exact:true}).click()
check('panel Layer dropdown updates Header shared value',(await header.locator('.hm-sw-header__field--layer').textContent()).includes('Sample-Wall'))
await page.locator('.hm-sw-property [data-property-id="line"] button').click();await drop.getByRole('button',{name:'HIDDEN',exact:true}).click()
check('panel line dropdown updates Header shared value',(await header.locator('.hm-sw-header__field--line-style').textContent()).includes('HIDDEN'))
await panelInput('color').click()
const colorOption=drop.locator('.hm-sw-header__color-option').nth(1)
const expectedColor=await colorOption.evaluate(e=>getComputedStyle(e.querySelector('.hm-sw-header__color-chip > span')).backgroundColor.match(/\d+/g).slice(0,3).join(','))
await colorOption.click();await waitValue(panelInput('color'),expectedColor)
const selectedColor=await panelInput('color').inputValue()
check('panel color dropdown updates Header shared value',(await header.locator('.hm-sw-header__field--color').textContent()).includes(selectedColor))
await panelInput('color').fill('300,0,0');await panelInput('color').press('Enter');check('panel RGB validation rejects out-of-range value',await panelInput('color').inputValue()===selectedColor)
await page.keyboard.press('Escape')
const gauge=page.locator('.hm-sw-property input[type="range"]')
await gauge.press('End');await gauge.press('Enter');await waitValue(panelInput('opacity'),'1')
check('gauge keyboard editing commits host state',await panelInput('opacity').inputValue()==='1')
await panelInput('radius').fill('8.5');await panelInput('radius').press('Escape')
check('first Escape cancels draft and retains panel',await panelInput('radius').inputValue()==='7.5'&&await page.locator('.hm-sw-property').count()===1)
await panelInput('radius').press('Escape');check('next Escape closes panel and restores Header focus',await page.locator('.hm-sw-property').count()===0&&await header.locator('[data-property-panel-trigger]').evaluate(e=>document.activeElement===e))
await panelButton();await page.getByRole('button',{name:'속성창 닫기',exact:true}).click();check('panel Close button restores focus',await header.locator('[data-property-panel-trigger]').evaluate(e=>document.activeElement===e))
await page.getByText('시연 데이터',{exact:true}).click()
await page.getByRole('button',{name:'외부 값 12.5',exact:true}).click();await waitValue(barInput('radius'),'12.5');check('external Props update Bar',await barInput('radius').inputValue()==='12.5')
await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).check();check('Bar ReadOnly',await barInput('radius').evaluate(e=>e.readOnly))
await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).uncheck()
await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).check();check('Bar Disabled',await barInput('radius').isDisabled())
await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).uncheck()
await page.getByRole('button',{name:'필드 추가',exact:true}).click();check('dynamic Bar field added',await page.locator('.hm-sw-property-bar [data-property-id^="preview-extra-"]').count()===1)
await panelButton();check('dynamic field shared with panel',await page.locator('.hm-sw-property [data-property-id^="preview-extra-"]').count()===1)
await page.getByRole('button',{name:'추가 필드 제거',exact:true}).click();check('dynamic field removed from both',await page.locator('[data-property-id^="preview-extra-"]').count()===0)
await page.getByRole('checkbox',{name:'형상 Slot 비우기',exact:true}).check();check('shape Slot can be empty without changing panel structure',await page.locator('.hm-sw-property [data-shape-slot="empty"]').count()===1)
await page.getByRole('checkbox',{name:'형상 Slot 비우기',exact:true}).uncheck()
await page.getByText('시연 데이터',{exact:true}).click()
for(const [selection,height]of[['none',256],['all',361],['circle',658],['expanded',754]]){
await page.getByLabel('선택 상태',{exact:true}).selectOption(selection)
if(await page.locator('.hm-sw-property').count()===0)await panelButton()
const box=await page.locator('.hm-sw-property').boundingBox()
check(`original panel size ${selection}:314x${height}`,box.width===314&&box.height===height,box)
}
await page.screenshot({path:path.join(out,'overview-after-expanded.png')})
await stage.evaluate(e=>{e.scrollTop=e.scrollHeight})
await panelInput('z').scrollIntoViewIfNeeded();await panelInput('z').fill('5');await panelInput('z').press('Enter')
check('expanded popup bottom accessible through internal scroll',await panelInput('z').inputValue()==='5')
await page.screenshot({path:path.join(out,'overview-after-scrolled.png')})
await go();await page.screenshot({path:path.join(out,'overview-after.png')})
// A small Inner keeps native component dimensions; the Stage, rather than the browser document, scrolls.
await page.setViewportSize({width:390,height:700});await go();await page.getByLabel('선택 상태',{exact:true}).selectOption('expanded');await panelButton()
await panelInput('z').scrollIntoViewIfNeeded();await panelInput('z').fill('3');await panelInput('z').press('Enter')
const narrow=await stage.evaluate(e=>({top:e.scrollTop,left:e.scrollLeft,height:e.clientHeight,scrollHeight:e.scrollHeight,width:e.clientWidth,scrollWidth:e.scrollWidth}))
check('small Inner reaches full popup bottom with native Header and popup dimensions',await panelInput('z').inputValue()==='3'&&narrow.scrollHeight>narrow.height&&narrow.scrollWidth>=390,narrow)
await page.screenshot({path:path.join(out,'overview-narrow.png')})
await panelInput('z').press('Escape')
check('narrow popup Escape restores More focus',await header.getByRole('button',{name:'더보기',exact:true}).evaluate(e=>document.activeElement===e))
await page.setViewportSize({width:1440,height:1000});await page.goto(`${base}/?path=/story/sw-header--overview`)
const preview=page.frameLocator('#storybook-preview-iframe');await preview.locator('[data-preview-stage]').waitFor()
report.managerOrder=await page.locator('[data-item-id^="sw-header--"]').evaluateAll(elements=>[...new Set(elements.map(e=>e.dataset.itemId))])
await writeFile(path.join(out,'manager-story-order.json'),JSON.stringify(report.managerOrder,null,2)+'\n')
await page.screenshot({path:path.join(out,'overview-manager.png')})
const managerFit=await preview.locator('[data-preview-stage]').evaluate(e=>{const b=e.getBoundingClientRect(),slot=e.parentElement.getBoundingClientRect();return {width:b.width,height:b.height,slotWidth:slot.width,slotHeight:slot.height,viewportWidth:innerWidth,viewportHeight:innerHeight}})
check('actual Storybook manager iframe fits its own Inner',managerFit.width<=managerFit.slotWidth+.1&&managerFit.height<=managerFit.slotHeight+.1&&Math.abs(managerFit.width/managerFit.height-16/9)<.005,managerFit)
check('no browser errors',report.errors.length===0,report.errors)
}
}catch(error){check('suite completes',false,error.stack);await page?.screenshot({path:path.join(out,'failure.png')}).catch(()=>{})}
finally{
await browser?.close();await new Promise(resolve=>server.close(resolve))
report.status=report.checks.every(c=>c.status==='PASS')?'PASS':'FAIL'
await writeFile(path.join(out,process.argv[3]==='overlays'?'chrome-overlays-result.json':'chrome-result.json'),JSON.stringify(report,null,2)+'\n')
console.log(JSON.stringify({status:report.status,checks:report.checks.length,failures:report.checks.filter(c=>c.status==='FAIL')}))
process.exitCode=report.status==='PASS'?0:1
}