Files
HMDesign/packages/sw/tests/integrated-improvement.browser.mjs
T
shkim4 2fc1b3e108 fix(sw): 2026-10-21 SW design review and responsive header without More from 760px
User-approved design review (2026-10-21):
- SW Header: from 760px up nothing moves into More; the five menus stay with their
  titles (titles and values ellipsize), file name shrinks to 96px. Open next to the
  logo no longer blocks field shrinking above 760px (760/768 overflow fixed, also for
  configured `controls` headers). Dropdown yellow ring inside the button, black seam.
- Property Bar values as read-only red text; property popup "=" labels, read-only
  black values, per-entry hover with pointer.
- Home Menu recent files (3 rows, path + name caption, empty preview), Footer + slot
  guide off by default, Viewer file list without icons, Storybook widths 1920-760.
- Tests: old More expectations (integrated-improvement, keyboard-dismiss,
  footer-popup-viewer-refinement, storybook-cleanup-stage1) moved to the new rule;
  functional checks kept.
2026-10-21 17:29:25 +09:00

211 lines
20 KiB
JavaScript

// Functional checks of the 2026-10-19 SW integrated improvement on the built Storybook (storybook-static).
// Usage: node packages/sw/tests/integrated-improvement.browser.mjs <evidence-dir> [section ...]
// Sections: options, tool, popup (default: all). Screenshots and result JSON go to <evidence-dir>.
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
import { runtimeRequire } from './playwright-runtime.mjs'
const require = runtimeRequire()
const out = path.resolve(process.argv[2] ?? 'docs/sw/integrated-improvement-qa/verify')
// 'tool' opens sw-tool--tool-buttons, retired 2026-10-19 (storybook-inventory retiredToolIds): historical, run it only
// by name against an old build. The default runs the sections whose Stories exist (2026-10-20).
const sections = process.argv.slice(3).length ? process.argv.slice(3) : ['options', 'popup']
await mkdir(out, { recursive: true })
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', '.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 } = require('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: 1920, height: 1000 } })
const report = { browser: browser.version(), checks: [], errors: [], data: {} }
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 }); console.log(ok ? 'PASS' : 'FAIL', name, ok || data === undefined ? '' : JSON.stringify(data)) }
const goto = async id => {
await page.goto(`${base}/iframe.html?id=${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)
await page.waitForTimeout(100)
}
const log = () => page.locator('[data-callback-log]').first().textContent()
const rect = locator => locator.evaluate(e => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, right: r.right } })
if (sections.includes('options')) {
await goto('sw-header--document-open')
const frames = page.locator('.sw-story-frame')
// Default placement unchanged: no header Open, dropdown footer keeps + New / Open.
const plain = frames.nth(0).locator('header.hm-sw-header')
check('menu (default): no Open next to the logo', await plain.locator('[data-document-open]').count() === 0)
await plain.locator('.hm-sw-header__document-button').click()
check('menu (default): dropdown footer keeps + New and Open', JSON.stringify(await plain.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New","Open"]')
await page.keyboard.press('Escape')
const plainHome = await rect(plain.locator('.hm-sw-header__home')), plainFile = await rect(plain.locator('.hm-sw-header__document-button'))
const junction = plainFile.x - plainHome.right // the file name's place after the logo in the unchanged header
report.data.junction = junction
const header = page.locator('[data-open-placement="header"] header.hm-sw-header')
const open = header.locator('[data-document-open]')
const home = await rect(header.locator('.hm-sw-header__home')), openBox = await rect(open), docBox = await rect(header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button'))
check('header: Open takes the file name place right of the logo (same junction offset, 48px)', Math.abs(openBox.x - home.right - junction) < 0.6 && openBox.h === 48, { home, openBox, junction })
check('header: file name follows Open without overlap', docBox.x >= openBox.right - 0.5, { openBox, docBox })
const styles = await open.evaluate(e => { const s = getComputedStyle(e); return { cls: e.className, bg: s.backgroundImage.slice(0, 40), color: s.color, weight: s.fontWeight, padL: s.paddingLeft, borderL: s.borderLeftWidth, borderR: s.borderRightWidth } })
const fileStyles = await header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button').evaluate(e => { const s = getComputedStyle(e); return { bg: s.backgroundImage.slice(0, 40), color: s.color, weight: s.fontWeight, padL: s.paddingLeft } })
check('header: Open uses hm-sw-header__document-button (same surface, color, weight as the file name)', styles.cls.includes('hm-sw-header__document-button') && styles.bg === fileStyles.bg && styles.color === fileStyles.color && styles.weight === fileStyles.weight && styles.padL === '5px', { styles, fileStyles })
check('D-1: file name after Open starts with 12px padding', fileStyles.padL === '12px', fileStyles)
await open.hover()
check('header: Open hover turns only the text #FFC600', await open.locator('.hm-sw-header__document-name').evaluate(e => getComputedStyle(e).color) === 'rgb(255, 198, 0)')
await page.mouse.move(1, 1)
await page.locator('[data-open-placement="header"]').screenshot({ path: path.join(out, 'open-placement-header.png') })
await header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button').click()
check('header: dropdown footer has + New only (no duplicate Open)', JSON.stringify(await header.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New"]')
await page.keyboard.press('Escape')
await open.click()
check('header: Open calls onDocumentOpen', (await log()).includes('onDocumentOpen'))
check('documentOpenLabel is the Open text', await page.locator('[data-open-label] [data-document-open]').textContent() === '파일 열기')
// Responsive: measured room. No overflow anywhere; Open is either shown or reachable in More.
const widths = []
for (const item of await page.locator('[data-responsive-open] > [data-width]').all()) {
const width = Number(await item.getAttribute('data-width'))
const h = item.locator('header.hm-sw-header')
const state = await h.evaluate(header => {
const bar = header.querySelector(':scope > .hm-sw-header__bar'), area = header.querySelector('.hm-sw-header__object-area')
const open = header.querySelector('[data-document-open]'), doc = header.querySelector('.hm-sw-header__bar > .hm-sw-header__document')
const layer = header.querySelector('.hm-sw-header__field--layer')
const parts = [...bar.children].filter(e => getComputedStyle(e).display !== 'none').map(e => e.getBoundingClientRect())
const overlap = parts.some((r, i) => i > 1 && r.left < parts[i - 1].right - 0.5)
return {
height: header.getBoundingClientRect().height, barOverflow: bar.scrollWidth - bar.clientWidth, areaOverflow: area.scrollWidth - area.clientWidth,
headerRight: header.getBoundingClientRect().right, barRight: Math.max(...parts.map(r => r.right)), overlap,
open: open ? open.getBoundingClientRect().width : 0, doc: doc ? doc.getBoundingClientRect().width : 0, layer: layer ? layer.getBoundingClientRect().width : 0,
more: !!header.querySelector('.hm-sw-header__more') && getComputedStyle(header.querySelector('.hm-sw-header__more')).display !== 'none',
menus: [...header.querySelectorAll('.hm-sw-header__fields .hm-sw-header__field[data-menu]')].filter(e => e.getClientRects().length && getComputedStyle(e).display !== 'none').map(e => e.dataset.menu),
titles: [...header.querySelectorAll('.hm-sw-header__fields .hm-sw-header__field[data-menu]')].filter(e => e.getClientRects().length && getComputedStyle(e).display !== 'none' && [...e.querySelectorAll('.hm-sw-header__field-label')].some(l => l.getClientRects().length && getComputedStyle(l).display !== 'none')).length,
}
})
let inMore = null
if (!state.open) {
await h.locator('.hm-sw-header__more-trigger').click()
const itemOpen = h.locator('.hm-sw-header__more-item', { hasText: /^Open$/ })
inMore = await itemOpen.isVisible()
if (inMore) { await itemOpen.click(); inMore = (await log()).endsWith('onDocumentOpen') } else await page.keyboard.press('Escape')
}
widths.push({ width, ...state, inMore })
check(`responsive ${width}: 49px, no overflow or overlap, nothing past the edge`, state.height === 49 && state.barOverflow <= 1 && state.areaOverflow <= 1 && !state.overlap && state.barRight <= state.headerRight + 0.5, state)
check(`responsive ${width}: Open ${state.open ? 'shown next to the logo' : 'moved into More and works there'}`, state.open > 0 || inMore === true, { open: state.open, inMore })
// 2026-10-21 (user decision): from 760px up nothing moves into More; the fields shrink beside Open (fluid widths,
// titles and values ellipsize). The Figma layer width 234 holds where there is room (1920); narrower it is fluid.
if (width >= 760) check(`responsive ${width}: Open shown, no More, the five menus stay in the header with their titles`, state.open > 0 && !state.more && JSON.stringify(state.menus) === '["layer","color","line-style","text-style","dimension-style"]' && state.titles === 5, state)
if (width >= 1920) check(`responsive ${width}: layer field keeps its Figma width (234)`, Math.abs(state.layer - 234) < 0.6, state.layer)
else if (width >= 760) check(`responsive ${width}: layer field shrinks fluidly (0 < width <= 234)`, state.layer > 0 && state.layer <= 234.5, state.layer)
}
report.data.responsive = widths
await page.locator('[data-responsive-open]').screenshot({ path: path.join(out, 'open-placement-responsive.png') })
// Hysteresis: shrink a header below the collapse width, then grow it back; Open returns, never flickers in between.
const probe = page.locator('[data-responsive-open] > [data-width="520"] .sw-story-frame')
const shown = async () => probe.locator('[data-document-open]').count()
const trace = []
for (const w of [520, 560, 600, 640, 680, 720, 760, 820, 900, 1080]) { await probe.evaluate((e, w) => { e.style.width = `${w}px` }, w); await page.waitForTimeout(80); trace.push([w, await shown()]) }
report.data.hysteresis = trace
const flips = trace.slice(1).filter((step, index) => step[1] !== trace[index][1]).length
check('resize: collapsed Open returns once when the header grows (measured, with hysteresis)', trace[0][1] === 0 && trace.at(-1)[1] === 1 && flips === 1, trace)
await goto('sw-header--hidden-sub-controls')
const onlyOpen = page.locator('[data-hidden="new"] .hm-sw-header__document-footer')
const footer = await rect(onlyOpen), action = await rect(onlyOpen.locator('.hm-sw-header__document-action'))
check('hidden documentNew: footer keeps Open on its right side', JSON.stringify(await onlyOpen.locator('.hm-sw-header__document-action').allTextContents()) === '["Open"]' && Math.abs(footer.right - 8 - action.right) < 1, { footer, action })
check('hidden documentNew + documentOpen: no footer', await page.locator('[data-hidden="new-open"] .hm-sw-header__document-footer').count() === 0 && await page.locator('[data-hidden="new-open"] .hm-sw-header__document-row').count() === 2)
const layer = page.locator('[data-hidden="layer-states"]')
const icons = await layer.locator('.hm-sw-header__field--layer .hm-sw-header__layer-icons > *').evaluateAll(list => list.map(e => ({ empty: e.hasAttribute('data-empty'), w: e.getBoundingClientRect().width })))
check('hidden layerFreeze / layerLock: field draws the bulb only, two empty 20px places', icons.length === 3 && !icons[0].empty && icons[1].empty && icons[2].empty && icons.every(i => i.w === 20), icons)
const rows = await layer.locator('.hm-sw-header__layer-row').evaluateAll(list => list.map(row => ({ buttons: [...row.querySelectorAll('button')].map(b => b.className.split(' ').at(-1)), empty: row.querySelectorAll('[data-empty]').length, name: row.querySelector('.hm-sw-header__layer-name').getBoundingClientRect().x })))
check('hidden layerFreeze / layerLock: rows have no snowflake / lock button, places kept', rows.every(r => r.empty === 2 && !r.buttons.some(b => /freeze|lock/.test(b))), rows)
await layer.locator('.hm-sw-header__layer-toggle--visibility').first().click()
check('hidden layer states: the bulb still reports its callback', (await log()).includes('onLayerVisibilityChange(0, false)'))
await page.locator('[data-hidden="layer-states"]').screenshot({ path: path.join(out, 'hidden-layer-states.png') })
await page.locator('[data-hidden="new"]').screenshot({ path: path.join(out, 'hidden-document-new.png') })
await goto('sw-header--document-open')
const bar = page.locator('[data-composed-open]')
const cHome = await rect(bar.locator('.hm-sw-header__home')), cOpen = await rect(bar.locator('.hm-sw-header__document[data-tool] button')), cDoc = bar.locator('.hm-sw-header__document:not([data-tool]) .hm-sw-header__document-button')
check('composed: Open right of the logo at the same junction offset as SWHeader', Math.abs(cOpen.x - cHome.right - report.data.junction) < 0.6 && cOpen.h === 48, { cHome, cOpen })
check('composed: file name after Open has 12px padding', await cDoc.evaluate(e => getComputedStyle(e).paddingLeft) === '12px')
await bar.locator('.hm-sw-header__document[data-tool] button').click()
check('composed: Open callback', (await log()).includes('onDocumentOpen'))
await cDoc.click()
check('composed: showOpen={false} removes Open from the dropdown', JSON.stringify(await bar.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New"]')
await page.keyboard.press('Escape')
await bar.screenshot({ path: path.join(out, 'composed-open.png') })
}
if (sections.includes('options') || sections.includes('composed')) {
await goto('sw-header--document-open')
const rows = []
for (const item of await page.locator('[data-composed-responsive] > [data-width]').all()) {
const width = Number(await item.getAttribute('data-width'))
const h = item.locator('header.hm-sw-header')
const state = await h.evaluate(header => {
const bar = header.querySelector(':scope > .hm-sw-header__bar'), area = header.querySelector('.hm-sw-header__object-area')
const visible = selector => [...header.querySelectorAll(selector)].filter(e => e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden').length
const clipped = [...header.querySelectorAll('.hm-sw-header__control:not([data-hidden]) .hm-sw-header__field')].some(f => f.getBoundingClientRect().width < 40)
return { height: header.getBoundingClientRect().height, barOverflow: bar.scrollWidth - bar.clientWidth, areaOverflow: area.scrollWidth - area.clientWidth, open: visible('[data-document-open]'), more: visible('.hm-sw-header__more-trigger'), clipped, right: Math.max(...[...bar.children].map(e => e.getBoundingClientRect().right)) - header.getBoundingClientRect().right }
})
rows.push({ width, ...state })
check(`composed responsive ${width}: 49px, no overflow, nothing past the edge`, state.height === 49 && state.barOverflow <= 1 && state.areaOverflow <= 1 && state.right <= 0.5, state)
if (width >= 760) check(`composed responsive ${width}: 파일 열기 shown, no More (from 760px)`, state.open > 0 && !state.more, state)
if (!state.open) {
await h.locator('.hm-sw-header__more-trigger').click()
const entry = h.locator('.hm-sw-header__more-item', { hasText: '파일 열기' })
const ok = await entry.isVisible()
if (ok) await entry.click(); else await page.keyboard.press('Escape')
check(`composed responsive ${width}: 파일 열기 reachable in More`, ok && (await log()).endsWith('onDocumentOpen'))
}
}
report.data.composedResponsive = rows
await page.locator('[data-composed-responsive]').screenshot({ path: path.join(out, 'composed-responsive.png') })
}
if (sections.includes('tool')) {
await goto('sw-tool--tool-buttons')
const group = page.locator('[data-view-group]').first()
check('tool: Model / Layout is a radio group', await group.getAttribute('role') === 'radiogroup' && await group.locator('[role="radio"]').count() === 2)
await group.locator('[role="radio"]').first().focus()
await page.keyboard.press('ArrowRight')
check('tool: arrow key moves focus and selects Layout', await group.locator('[role="radio"]').nth(1).getAttribute('aria-checked') === 'true' && (await log()).includes('view(layout)'))
const depth = page.locator('[data-display-group] button').first()
await depth.click()
check('tool: 2.5D toggle reports and shows pressed (#FFC600)', await depth.getAttribute('aria-pressed') === 'true' && await depth.evaluate(e => getComputedStyle(e).color) === 'rgb(255, 198, 0)')
const text = await page.locator('[data-tool-states] .hm-sw-header__toggle').first().evaluate(e => { const s = getComputedStyle(e); return { weight: s.fontWeight, h: e.getBoundingClientRect().height } })
const ref = await page.locator('[data-display-group]').first().evaluate(e => getComputedStyle(e.querySelector('.hm-sw-header__properties-caption')).fontSize)
check('tool: text buttons are the 속성Bar toggle look (700, 19px row, 12px caption)', text.weight === '700' && text.h === 19 && ref === '12px', { text, ref })
const pill = page.locator('[data-fit-group] button').first()
const pillBox = await rect(pill)
await pill.hover()
const hover = await pill.evaluate(e => ({ ring: getComputedStyle(e, '::after').opacity, icon: getComputedStyle(e.querySelector('.hm-sw-header__icon')).visibility, hoverIcon: getComputedStyle(e.querySelector('.hm-sw-header__icon-hover')).visibility }))
check('tool: pill button is 40 x 38 with the yellow ring and hover icon', pillBox.w === 40 && pillBox.h === 38 && hover.ring === '1' && hover.icon === 'hidden' && hover.hoverIcon === 'visible', { pillBox, hover })
await page.mouse.move(1, 1)
await pill.click()
check('tool: pill click callback', (await log()).includes('fit'))
check('tool: pill accessible name from its text', await pill.evaluate(e => e.textContent.trim()) === '화면 맞춤')
const toolBar = page.locator('[data-tool-bar]')
const tHome = await rect(toolBar.locator('.hm-sw-header__home')), tOpen = await rect(toolBar.locator('.hm-sw-header__document[data-tool] button'))
check('tool: document look keeps the logo junction in a composed bar', Math.abs(tOpen.x - tHome.right - (report.data.junction ?? -15)) < 0.6, { tHome, tOpen })
for (const [name, locator] of [['tool-in-header', page.locator('.sb-page-section').nth(0)], ['tool-composed', page.locator('.sb-page-section').nth(1)], ['tool-states', page.locator('.sb-page-section').nth(2)]]) await locator.screenshot({ path: path.join(out, `${name}.png`) })
}
if (sections.includes('popup')) {
const { popupChecks } = await import('./integrated-popup.checks.mjs')
await popupChecks({ page, goto, check, log, rect, out, report })
}
check('no browser console or page errors', report.errors.length === 0, report.errors)
const failed = report.checks.filter(c => c.status === 'FAIL').length
report.status = failed ? 'FAIL' : 'PASS'
await writeFile(path.join(out, 'integrated-improvement-result.json'), JSON.stringify(report, null, 2) + '\n')
console.log(`TOTAL ${report.checks.length} FAIL ${failed}`)
await browser.close(); server.close()
if (failed) process.exitCode = 1