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.
211 lines
20 KiB
JavaScript
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
|