refactor(storybook): consolidate dropdown stories into composed controls
This commit is contained in:
1 parent
c165753bbe
commit
9482d45f34
74 files changed
+38833
-283
No files matched your search
@@ -2,6 +2,7 @@ import { createRequire } from 'node:module'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
import { swStoryEntries, retainedInventory } from './storybook-inventory.mjs'
|
||||
|
||||
// Usage: node packages/sw/tests/detail-buttons.browser.mjs baseline|verify <evidence-dir> [base-url]
|
||||
const mode = process.argv[2] ?? 'verify'
|
||||
@@ -67,7 +68,7 @@ try {
|
||||
check('preserve editable Input',await editing.isEditable()&&await editing.evaluate(e=>getComputedStyle(e).caretColor==='rgb(230, 4, 3)'))
|
||||
} else {
|
||||
const index = await (await fetch(`${base}/index.json`)).json()
|
||||
const ids = Object.values(index.entries).filter(e => e.title === 'SW/Header' && e.type === 'story').map(e => e.id)
|
||||
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.
|
||||
@@ -82,7 +83,8 @@ try {
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width:1920,height:1000 })
|
||||
await goto('text-dimension-style-dropdown')
|
||||
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) }
|
||||
@@ -97,9 +99,9 @@ try {
|
||||
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'].map(id=>`sw-header--${id}`)
|
||||
const retainedIds=oldIds.filter(id=>!retiredIds.includes(id))
|
||||
check('retained stories remain; only the five approved demos are absent',ids.length===28&&retainedIds.every(id=>ids.includes(id))&&retiredIds.every(id=>!ids.includes(id)),{before:oldIds.length,after:ids.length})
|
||||
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}`)
|
||||
const retainedIds=retainedInventory(oldIds.map(id=>({id}))).map(e=>e.id)
|
||||
check('retained stories remain; only approved retirements are absent',ids.length===24&&retainedIds.every(id=>ids.includes(id))&&retiredIds.every(id=>!ids.includes(id)),{before:oldIds.length,after:ids.length})
|
||||
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 (로고만)','파일 목록 제거','레이어 제거','색상 제거','스타일 그룹 제거','여러 메뉴 제거','순서 변경','숨김 + 순서 변경']
|
||||
@@ -117,8 +119,8 @@ try {
|
||||
}
|
||||
await page.setViewportSize({width:1920,height:1000})
|
||||
// Real dropdown integration, separate from the shared-button specimen.
|
||||
await goto('color-dropdown')
|
||||
const host=page.locator('section').filter({has:page.getByRole('heading',{name:'선택 · 입력 · Callback 기록',exact:true})})
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// Build Storybook first. Historical QA is preserved; use a new evidence directory for every run.
|
||||
// Usage: node packages/sw/tests/dropdown-consolidation.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 { retiredDropdownIds, swStoryEntries, inventorySignature } from './storybook-inventory.mjs'
|
||||
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-dropdown-consolidation-qa')
|
||||
await mkdir(out, { recursive: true })
|
||||
const report = { baseCommit: 'c165753bbe24de6581c66704bde4ba3246049ec2', checks: [], errors: [], callbacks: [], geometry: [] }
|
||||
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ` ${JSON.stringify(data)}`}`) }
|
||||
const before = JSON.parse(await readFile('docs/sw/storybook-dropdown-consolidation-qa/before-full-index.json', 'utf8'))
|
||||
const after = JSON.parse(await readFile('storybook-static/index.json', 'utf8'))
|
||||
const beforeSW = swStoryEntries(before), afterSW = swStoryEntries(after)
|
||||
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(beforeSW.filter(e => !retiredDropdownIds.includes(e.id)))) === JSON.stringify(inventorySignature(afterSW)))
|
||||
check('Dropdown group has only Composed Controls with original ID', afterSW.filter(e => e.title === 'SW/Header/Dropdown').length === 1 && after.entries['sw-header--composed-controls']?.title === 'SW/Header/Dropdown')
|
||||
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)))
|
||||
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')
|
||||
const declarations = text => Object.fromEntries(parse(text, { sourceType: 'module', plugins: ['typescript', 'jsx'] }).program.body.map(n => {
|
||||
const d = n.type === 'ExportNamedDeclaration' ? n.declaration : n
|
||||
return [d.type === 'VariableDeclaration' ? d.declarations[0].id.name : d.id?.name ?? d.type, text.slice(n.start, n.end)]
|
||||
}))
|
||||
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 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' }))
|
||||
|
||||
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 pathname = new URL(req.url, 'http://localhost').pathname
|
||||
const file = path.resolve(root, '.' + decodeURIComponent(pathname === '/' ? '/index.html' : pathname))
|
||||
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
|
||||
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()
|
||||
const 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()); if (m.type() === 'info' && m.text().startsWith('[SWHeader Composed] ')) report.callbacks.push(m.text().slice(20)) })
|
||||
const go = async (id = 'composed-controls') => {
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
|
||||
await page.locator('.hm-sw-theme, .hm-sw-header').first().waitFor()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.mouse.move(1, 1)
|
||||
}
|
||||
for (const entry of process.argv[3] === 'followup' ? [] : afterSW) {
|
||||
await go(entry.id.replace('sw-header--', ''))
|
||||
check(`render ${entry.id}`, !await page.locator('.sb-errordisplay').isVisible())
|
||||
}
|
||||
await go()
|
||||
const host = page.locator('[data-composed-controls]')
|
||||
const 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')
|
||||
const events = name => report.callbacks.filter(line => line.startsWith(name)).length
|
||||
const once = async (name, action) => { const previous = events(name); await action(); check(`${name} callback exactly once`, events(name) === previous + 1, report.callbacks.slice(-5)) }
|
||||
const labels = ['레이어', '색상', '선스타일', '문자스타일', '치수스타일']
|
||||
if (process.argv[3] !== 'followup') {
|
||||
check('one copy of five Dropdown fields and the details group', await host.locator('.hm-sw-header__field').count() === 5 && await host.getByRole('group', { name: '상세보기', exact: true }).count() === 1 && await field('속성Bar').count() === 1 && await field('속성창').count() === 1)
|
||||
const hitTest = locator => locator.evaluate(e => {
|
||||
const r = e.getBoundingClientRect()
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height, right: r.right, bottom: r.bottom, hit: e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) }
|
||||
})
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
const geometry = await host.evaluate(e => ({ overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
fields: [...e.querySelectorAll('.hm-sw-header__field')].map(f => ({ width: f.getBoundingClientRect().width, height: f.getBoundingClientRect().height })) }))
|
||||
report.geometry.push({ width, ...geometry })
|
||||
check(`composition wraps without page overflow or scaling at ${width}`, geometry.overflow === 0 && geometry.fields.every(f => f.height === 38) && JSON.stringify(geometry.fields.map(f => f.width)) === JSON.stringify([234, 192, 284, 164, 164]), geometry)
|
||||
for (const label of labels) {
|
||||
const trigger = field(label)
|
||||
await trigger.hover()
|
||||
check(`${label} Hover does not open at ${width}`, await menu().count() === 0)
|
||||
await trigger.click()
|
||||
const box = await hitTest(menu())
|
||||
const footer = page.getByRole('button', { name: `${label} 상세`, exact: true })
|
||||
check(`${label} overlay in viewport, not clipped or covered at ${width}`, box.x >= 0 && box.right <= width && box.hit && (await hitTest(footer)).hit, box)
|
||||
check(`${label} selected and expanded at ${width}`, await trigger.getAttribute('aria-expanded') === 'true' && await trigger.getAttribute('data-selected') === 'true')
|
||||
if (width === 390 || width === 1440) await page.screenshot({ path: path.join(out, `overlay-${labels.indexOf(label)}-${width}.png`), fullPage: true })
|
||||
await page.keyboard.press('Escape')
|
||||
check(`${label} Escape closes and restores focus at ${width}`, await menu().count() === 0 && await trigger.evaluate(e => e === document.activeElement))
|
||||
await trigger.click(); await trigger.click()
|
||||
check(`${label} trigger toggles closed at ${width}`, await menu().count() === 0)
|
||||
await trigger.click(); await page.getByRole('heading', { name: 'Composed Controls', exact: true }).click()
|
||||
check(`${label} outside click closes at ${width}`, await menu().count() === 0)
|
||||
}
|
||||
await page.screenshot({ path: path.join(out, `composed-${width}.png`), fullPage: true })
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
await field('레이어').focus(); await page.keyboard.press('ArrowDown')
|
||||
check('keyboard opens layer menu and focuses current row', await page.locator('.hm-sw-header__layer-menu').isVisible() && await page.locator('.hm-sw-header__layer-item').first().evaluate(e => e === document.activeElement))
|
||||
await page.keyboard.press('ArrowDown')
|
||||
check('keyboard moves to next layer row', await page.locator('.hm-sw-header__layer-item').nth(1).evaluate(e => e === document.activeElement))
|
||||
await once('onLayerSelect', () => page.keyboard.press('Enter'))
|
||||
check('layer selection appears, closes and restores focus', (await field('레이어').textContent()).includes('Sample-Center') && await menu().count() === 0 && await field('레이어').evaluate(e => e === document.activeElement))
|
||||
await field('레이어').click()
|
||||
const active = page.locator('.hm-sw-header__layer-row[data-active]')
|
||||
await active.locator('.hm-sw-header__layer-item').hover()
|
||||
check('selected layer retains original row Hover background', await active.evaluate(e => getComputedStyle(e).backgroundColor === 'rgb(98, 83, 61)'))
|
||||
for (const [i, name] of ['onLayerVisibilityChange', 'onLayerFreezeChange', 'onLayerLockChange'].entries()) {
|
||||
const toggle = active.locator('.hm-sw-header__layer-toggle').nth(i)
|
||||
const pressed = await toggle.getAttribute('aria-pressed')
|
||||
await once(name, () => toggle.click())
|
||||
check(`${name} toggles only its own state, menu remains open`, await toggle.getAttribute('aria-pressed') !== pressed && await menu().count() === 1)
|
||||
}
|
||||
const disabledLayer = page.locator('.hm-sw-header__layer-row[data-disabled]')
|
||||
check('disabled layer selection and toggles blocked', await disabledLayer.locator('button:disabled').count() === 4)
|
||||
await page.keyboard.press('Escape')
|
||||
const beforeStyles = await page.locator('[data-style-state]').textContent()
|
||||
await field('색상').click()
|
||||
check('full color menu: seven palette chips, two user colors, modes, RGB and pick', await page.locator('.hm-sw-header__color-option').count() === 9 && await page.getByRole('button', { name: 'ByLayer', exact: true }).count() === 1 && await page.getByRole('textbox', { name: '색상값 (R,G,B)', exact: true }).count() === 1 && await page.getByRole('button', { name: '색상 추출', exact: true }).count() === 1)
|
||||
await once('onColorSelect', () => page.locator('.hm-sw-header__color-option').nth(1).click())
|
||||
check('color palette updates displayed RGB and leaves menu open', (await field('색상').textContent()).includes('241,4,4') && await menu().count() === 1)
|
||||
await once('onColorModeChange', () => page.getByRole('button', { name: 'ByBlock', exact: true }).click())
|
||||
check('ByBlock On and displayed mode', (await field('색상').textContent()).includes('ByBlock') && await page.getByRole('button', { name: 'ByBlock', exact: true }).getAttribute('aria-pressed') === 'true')
|
||||
const rgb = page.locator('.hm-sw-header__color-input input')
|
||||
await rgb.fill('255,0,0'); await once('onColorInput', () => rgb.press('Enter'))
|
||||
check('RGB commit clears mode and updates chip', (await field('색상').textContent()).includes('255,0,0') && await field('색상').locator('.hm-sw-header__chip span').evaluate(e => getComputedStyle(e).backgroundColor === 'rgb(255, 0, 0)'))
|
||||
const count = events('onColorInput'); await rgb.fill('256,0,0'); await rgb.press('Enter')
|
||||
check('invalid RGB restores without callback', await rgb.inputValue() === '255,0,0' && events('onColorInput') === count)
|
||||
await once('onColorPick', () => page.getByRole('button', { name: '색상 추출', exact: true }).click())
|
||||
check('color pick closes and restores focus', await menu().count() === 0 && await field('색상').evaluate(e => e === document.activeElement))
|
||||
check('color changes leave layer, line, text and dimension values intact', (await page.locator('[data-style-state]').textContent()).replace(/색상: .*? · 선:/, '색상: _ · 선:') === beforeStyles.replace(/색상: .*? · 선:/, '색상: _ · 선:'))
|
||||
await field('선스타일').click()
|
||||
check('all original line rows and weights retained', await page.locator('.hm-sw-header__line-column--type .hm-sw-header__menu-row').count() === 5 && await page.locator('.hm-sw-header__line-column').nth(1).locator('.hm-sw-header__menu-row').count() === 4)
|
||||
await once('onLineTypeSelect', () => page.getByRole('button', { name: 'CENTER', exact: true }).click())
|
||||
await page.getByRole('button', { name: 'CENTER', exact: true }).hover()
|
||||
check('selected line retains original row Hover and preview', await page.locator('.hm-sw-header__line-column--type .hm-sw-header__menu-row[data-active]').evaluate(e => getComputedStyle(e).backgroundColor === 'rgb(98, 83, 61)' && !!e.querySelector('.hm-sw-header__line-preview')))
|
||||
await once('onLineWeightSelect', () => page.getByRole('button', { name: /User/ }).click())
|
||||
const userWeight = page.locator('.hm-sw-header__line-column').nth(1).getByRole('textbox')
|
||||
await userWeight.fill('0.5'); await once('onLineWeightValueChange', () => userWeight.press('Enter'))
|
||||
const scale = page.getByRole('textbox', { name: '스케일', exact: true })
|
||||
await scale.fill('2'); await once('onLineScaleChange', () => scale.press('Enter'))
|
||||
check('line type, editable weight and scale update independently; menu stays open', (await field('선스타일').textContent()).includes('CENTER') && (await field('선스타일').textContent()).includes('0.5') && await scale.inputValue() === '2' && await menu().count() === 1)
|
||||
const scaleCount = events('onLineScaleChange'); await scale.fill('0'); await scale.press('Enter')
|
||||
check('invalid scale restores, no callback', await scale.inputValue() === '2' && events('onLineScaleChange') === scaleCount)
|
||||
await page.keyboard.press('Escape')
|
||||
for (const [label, item, callback] of [['문자스타일', 'Dotum', 'onTextStyleSelect'], ['치수스타일', 'ISO - 25', 'onDimensionStyleSelect']]) {
|
||||
await field(label).click()
|
||||
check(`${label} full two-item list retained`, await menu().locator('.hm-sw-header__menu-row').count() === 2)
|
||||
await once(callback, () => menu().getByRole('button', { name: new RegExp(item) }).click())
|
||||
check(`${label} selection displayed, closes, focus returns`, (await field(label).textContent()).includes(item) && await menu().count() === 0 && await field(label).evaluate(e => e === document.activeElement))
|
||||
}
|
||||
for (const [label, callback] of labels.map((label, i) => [label, ['Layer', 'Color', 'LineStyle', 'TextStyle', 'DimensionStyle'][i]])) {
|
||||
await field(label).click()
|
||||
const button = page.getByRole('button', { name: `${label} 상세`, exact: true })
|
||||
await button.hover()
|
||||
check(`${label} detail Hover uses existing text opacity`, await button.locator('[data-detail-label]').evaluate(e => getComputedStyle(e).opacity === '0.35'))
|
||||
await once(`on${callback}Detail`, () => button.click())
|
||||
check(`${label} detail closes, focus returns, no fabricated popup`, await menu().count() === 0 && await field(label).evaluate(e => e === document.activeElement) && await page.getByRole('dialog').count() === 0)
|
||||
}
|
||||
await field('레이어').click(); await field('색상').click()
|
||||
check('opening another control leaves exactly one menu and preserves selection', await menu().count() === 1 && await field('레이어').getAttribute('aria-expanded') === 'false' && (await field('레이어').textContent()).includes('Sample-Center'))
|
||||
await page.keyboard.press('Escape')
|
||||
await field('색상').focus(); await page.keyboard.press('Enter'); await page.keyboard.press('Tab')
|
||||
check('keyboard Tab enters the open color controls', await menu().evaluate(e => e.contains(document.activeElement)))
|
||||
await page.keyboard.press('Escape')
|
||||
} else {
|
||||
// Recheck only line-weight behavior affected by the shared property-state correction.
|
||||
await field('선스타일').click()
|
||||
await page.getByRole('button', { name: /User/ }).click()
|
||||
const userWeight = page.locator('.hm-sw-header__line-column').nth(1).getByRole('textbox')
|
||||
await userWeight.fill('0.5'); await userWeight.press('Enter')
|
||||
await page.waitForFunction(() => document.querySelector('[data-composed-controls] .hm-sw-header__field--line-style')?.textContent.includes('0.5'))
|
||||
check('User line weight still updates displayed numeric value', (await field('선스타일').textContent()).includes('0.5'))
|
||||
await page.locator('.hm-sw-header__line-column').nth(1).getByRole('button', { name: 'ByLayer', exact: true }).click()
|
||||
check('weight mode label preserves the shared numeric value', (await field('선스타일').textContent()).includes('ByLayer') && (await page.locator('[data-style-state]').textContent()).includes('/ 0.5 /'))
|
||||
await page.locator('.hm-sw-header__line-column').nth(1).getByRole('button', { name: /ByDefault/ }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await field('속성Bar').click()
|
||||
check('details opens existing 28px property Bar', await page.getByRole('region', { name: '속성Bar', exact: true }).evaluate(e => e.getBoundingClientRect().height === 28))
|
||||
await field('속성창').click()
|
||||
await page.waitForFunction(() => document.activeElement?.getAttribute('role') === 'dialog')
|
||||
const panelGeometry = await page.getByRole('dialog').evaluate(e => ({ width: e.getBoundingClientRect().width, focused: e === document.activeElement, active: document.activeElement?.outerHTML }))
|
||||
check('details opens existing 314px property panel with focus', panelGeometry.width === 314 && panelGeometry.focused, panelGeometry)
|
||||
await page.screenshot({ path: path.join(out, 'composed-property-panel.png'), fullPage: true })
|
||||
const radius = page.getByRole('dialog').getByRole('textbox', { name: '반지름', exact: true })
|
||||
await radius.fill('12.5'); await radius.press('Enter')
|
||||
await page.waitForFunction(() => document.querySelector('.hm-sw-property-bar [data-property-id="radius"] input')?.value === '12.5')
|
||||
const barRadius = await page.getByRole('region', { name: '속성Bar', exact: true }).getByRole('textbox', { name: '반지름', exact: true }).inputValue()
|
||||
check('property Bar and panel share edited state', Number(barRadius) === 12.5, barRadius)
|
||||
const panelWeight = page.getByRole('dialog').getByRole('textbox', { name: '선가중치', exact: true })
|
||||
await panelWeight.fill('0.75'); await panelWeight.press('Enter')
|
||||
await page.waitForFunction(() => document.querySelector('[data-composed-controls] .hm-sw-header__field--line-style')?.textContent.includes('0.75'))
|
||||
check('property panel weight edit appears in composed line field', (await field('선스타일').textContent()).includes('0.75'))
|
||||
await page.getByRole('button', { name: '속성창 닫기', exact: true }).click()
|
||||
check('property panel close returns focus', await page.getByRole('dialog').count() === 0 && await field('속성창').evaluate(e => e === document.activeElement))
|
||||
await field('속성창').click(); await page.keyboard.press('Escape')
|
||||
check('property panel Escape returns focus', await page.getByRole('dialog').count() === 0 && await field('속성창').evaluate(e => e === document.activeElement))
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await field('속성창').click()
|
||||
await page.waitForFunction(() => document.activeElement?.getAttribute('role') === 'dialog')
|
||||
const narrowPanel = await page.getByRole('dialog').evaluate(e => { const r = e.getBoundingClientRect(); return { width: r.width, left: r.left, right: r.right, bottom: r.bottom, overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth } })
|
||||
check('narrow details panel keeps natural width and fits viewport', narrowPanel.width === 314 && narrowPanel.left >= 0 && narrowPanel.right <= 390 && narrowPanel.bottom <= 844 && narrowPanel.overflow === 0, narrowPanel)
|
||||
await page.screenshot({ path: path.join(out, 'composed-property-panel-390.png'), fullPage: true })
|
||||
await page.keyboard.press('Escape'); await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
await field('속성Bar').click()
|
||||
await page.getByText('시연 설정', { exact: true }).click()
|
||||
await page.getByLabel('컨트롤 Disabled', { exact: true }).check()
|
||||
check('all seven actionable controls Disabled', await host.locator('button:disabled').count() === 7 && await menu().count() === 0)
|
||||
const disabledCount = report.callbacks.length
|
||||
for (const button of await host.locator('button:disabled').all()) { const box = await button.boundingBox(); await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) }
|
||||
check('Disabled controls block callbacks and overlays', report.callbacks.length === disabledCount && await menu().count() === 0 && await page.getByRole('dialog').count() === 0)
|
||||
await page.getByLabel('컨트롤 Disabled', { exact: true }).uncheck()
|
||||
await page.getByLabel('레이어 상태 표시 전용', { exact: true }).check(); await field('레이어').click()
|
||||
check('callback-free layer icons become readonly status images', await menu().locator('button.hm-sw-header__layer-toggle').count() === 0 && await menu().getByRole('img').count() > 0)
|
||||
await page.keyboard.press('Escape'); await page.getByLabel('레이어 상태 표시 전용', { exact: true }).uncheck()
|
||||
await page.getByLabel('메뉴 상세 Callback 연결', { exact: true }).uncheck(); await field('색상').click()
|
||||
const noDetailCount = events('onColorDetail')
|
||||
await page.getByRole('button', { name: '색상 상세', exact: true }).click()
|
||||
check('missing detail callback keeps safe no-op and menu open', events('onColorDetail') === noDetailCount && await menu().count() === 1)
|
||||
await page.keyboard.press('Escape'); await page.getByLabel('메뉴 상세 Callback 연결', { exact: true }).check()
|
||||
for (const scenario of ['long', 'many', 'empty']) {
|
||||
await page.getByRole('combobox', { name: /목록 데이터/ }).selectOption(scenario); await field('레이어').click()
|
||||
const rows = page.locator('.hm-sw-header__layer-row')
|
||||
check(`retained ${scenario} layer scenario`, await rows.count() === ({ long: 3, many: 20, empty: 0 })[scenario])
|
||||
if (scenario === 'many') check('long layer list scrolls internally', await page.locator('.hm-sw-header__layer-list').evaluate(e => e.scrollHeight > e.clientHeight))
|
||||
if (scenario === 'long') {
|
||||
await rows.first().getByRole('button').last().click()
|
||||
check('long layer name preserved with full title', (await field('레이어').getAttribute('title'))?.includes('0123456789') || (await field('레이어').locator('[title]').first().getAttribute('title'))?.includes('0123456789'))
|
||||
} else await page.keyboard.press('Escape')
|
||||
if (scenario === 'many') {
|
||||
await field('색상').click()
|
||||
check('all 40 user colors accessible with palette scroll', await page.locator('.hm-sw-header__color-option').count() === 47 && await page.locator('.hm-sw-header__color-palette').evaluate(e => e.scrollHeight > e.clientHeight))
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1000 }); await go('overview')
|
||||
check('Overview keeps one Header at 49px and 16:9 Stage', await page.locator('header.hm-sw-header').count() === 1 && await page.locator('[data-preview-stage]').evaluate(e => Math.abs(e.getBoundingClientRect().width / e.getBoundingClientRect().height - 16 / 9) < 0.001) && await page.locator('header.hm-sw-header').evaluate(e => e.getBoundingClientRect().height === 49))
|
||||
await go('window-move-motion')
|
||||
check('Window Move Motion retains only nine 3x steps', JSON.stringify(await page.locator('section > header h2').allTextContents()) === JSON.stringify(['단계별 진행 (3배 확대)']) && await page.locator('[data-motion-time]').count() === 9 && await page.locator('.sw-story-zoom').first().evaluate(e => getComputedStyle(e).zoom === '3'))
|
||||
await page.goto(`${base}/?path=/story/sw-header--composed-controls`)
|
||||
await page.locator('#storybook-preview-iframe').waitFor()
|
||||
await page.getByRole('link', { name: 'Composed Controls', exact: true }).waitFor()
|
||||
check('manager shows Dropdown group with the single Composed Controls page', await page.getByText('Dropdown', { exact: true }).isVisible() && await page.getByRole('link', { name: 'Composed Controls', exact: true }).count() === 1)
|
||||
check('retired Dropdown links absent in manager', await page.locator(retiredDropdownIds.map(id => `a[href*="${id}"]`).join(',')).count() === 0)
|
||||
await page.screenshot({ path: path.join(out, 'composed-manager.png'), fullPage: true })
|
||||
check('no browser console or page errors', report.errors.length === 0, report.errors)
|
||||
} catch (error) { check('suite completes', false, error.stack) }
|
||||
finally {
|
||||
if (browser) 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, 'result.json'), JSON.stringify(report, null, 2) + '\n')
|
||||
console.log(JSON.stringify({ status: report.status, checks: report.checks.length, errors: report.errors }))
|
||||
process.exitCode = report.status === 'PASS' ? 0 : 1
|
||||
}
|
||||
@@ -6,21 +6,24 @@ 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 } 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 = Object.values(JSON.parse(await readFile('storybook-static/index.json', 'utf8')).entries).filter(e => e.type === 'story' && e.title === 'SW/Header')
|
||||
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('all 28 Story IDs, names, order and tags retained', after.length === 28 && JSON.stringify(before.map(e => [e.id,e.name,e.tags])) === JSON.stringify(after.map(e => [e.id,e.name,e.tags])))
|
||||
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').map(n => [n.declaration.declarations[0].id.name, text.slice(n.start,n.end).replaceAll('\r\n','\n')]))
|
||||
.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)
|
||||
check('other 27 Story declarations byte-identical', Object.keys(oldStories).filter(k => k !== 'Overview').every(k => oldStories[k] === newStories[k]))
|
||||
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)))
|
||||
check('package production source untouched', !execFileSync('git', ['diff', report.baseCommit, '--', 'packages/sw/src'], { encoding:'utf8' }))
|
||||
|
||||
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' }
|
||||
|
||||
@@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
import { parse } from '@babel/parser'
|
||||
import { swStoryEntries, retiredDropdownIds, inventorySignature } from './storybook-inventory.mjs'
|
||||
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-cleanup-stage1-qa')
|
||||
await mkdir(out, { recursive: true })
|
||||
@@ -14,16 +15,15 @@ const check = (name, ok, data) => {
|
||||
report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data })
|
||||
console.log(ok ? 'PASS' : 'FAIL', name)
|
||||
}
|
||||
const retired = ['responsive', 'text-stroke', 'dynamic-data', 'default', 'controls'].map(id => `sw-header--${id}`)
|
||||
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 = Object.values(JSON.parse(await readFile('storybook-static/index.json', 'utf8')).entries)
|
||||
.filter(e => e.type === 'story' && e.title === 'SW/Header')
|
||||
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')
|
||||
report.deleted = before.filter(e => !after.some(a => a.id === e.id)).map(e => ({ name: e.name, id: e.id }))
|
||||
check('33 -> 28; precisely the five requested IDs removed', before.length === 33 && after.length === 28 &&
|
||||
report.deleted.length === 5 && retired.every(id => report.deleted.some(e => e.id === id)), report.deleted)
|
||||
check('all retained names, IDs, ordering and tags unchanged', JSON.stringify(before.filter(e => !retired.includes(e.id)).map(e => [e.id, e.name, e.tags])) ===
|
||||
JSON.stringify(after.map(e => [e.id, e.name, e.tags])))
|
||||
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('all retained names, IDs and tags unchanged', JSON.stringify(inventorySignature(before.filter(e => !retired.includes(e.id)))) ===
|
||||
JSON.stringify(inventorySignature(after)))
|
||||
|
||||
// Compare declaration text with the starting commit, including every retained fixture/helper.
|
||||
const file = 'src/sw/sw-header.stories.tsx'
|
||||
@@ -40,8 +40,8 @@ const declarations = text => {
|
||||
})
|
||||
}
|
||||
const oldDeclarations = new Map(declarations(original).map(d => [d.name, d.text]))
|
||||
const changed = declarations(current).filter(d => d.name !== 'WindowMoveMotion' && oldDeclarations.get(d.name) !== d.text)
|
||||
check('every retained declaration unchanged except WindowMoveMotion', changed.length === 0, changed.map(d => d.name))
|
||||
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))
|
||||
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' })
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Current SW inventory after the approved stage-1 and Dropdown consolidations.
|
||||
export const retiredStage1Ids = ['responsive', 'text-stroke', 'dynamic-data', 'default', 'controls'].map(id => `sw-header--${id}`)
|
||||
export const retiredDropdownIds = ['layer-dropdown', 'color-dropdown', 'line-style-dropdown', 'text-dimension-style-dropdown'].map(id => `sw-header--${id}`)
|
||||
export const swStoryEntries = index => Object.values(index.entries).filter(entry => entry.type === 'story' && (entry.title === 'SW/Header' || entry.title === 'SW/Header/Dropdown'))
|
||||
export const retainedInventory = entries => entries.filter(entry => ![...retiredStage1Ids, ...retiredDropdownIds].includes(entry.id))
|
||||
export const inventorySignature = entries => entries.map(entry => [entry.id, entry.name, entry.tags]).sort((a, b) => a[0].localeCompare(b[0]))
|
||||
Reference in new issue
Block a user