Files
HMDesign/packages/sw/tests/range-cross-browser.browser.mjs
T
shkim4 23d522c136 feat(sw): viewer header logo menu and file list; simplify SW Storybook
SW Viewer Header (approved optional props, public export names unchanged at 170):
- SWViewerHeader: logoMenu, documents, activeDocumentId, onDocumentSelect,
  onDocumentOpen, documentOpenLabel; measured responsive toggles (full ->
  icons -> More) with the SWHeader More / file-list styling.
- SW AI guides, ExampleH, viewer/text-weight/theme-inheritance tests and the
  logo-document-menu tests with their QA record.

SW Storybook cleanup (Story IDs and test fixtures kept):
- Sidebar: Overview -> "SW Header" (first), 전체 Footer -> "SW Footer",
  Footer/Viewer responsive pages -> "Responsive More", Viewer content-variants
  -> "Settings Popup" (merges the settings popup page).
- Hidden by the verification tag (direct URL kept): Header Dropdown,
  Document Open, Interactive States, Document Dropdown, Hidden Sub Controls,
  Home Menu standalone; Footer tool groups / custom content / parts;
  SW/Theme; Viewer menu groups / item states / variable size / settings
  panel / Header + Dropdown.
- Removed demo UI: Overview size and callback output (demo data and
  selection only with &verification), Home Menu Figma node table and width
  demo, Header Responsive More reduced to 1440 / 768 / 390, Popup reopen
  button, property-panel comparison captions, Viewer context-menu demo
  (&verification only).
- Tests: storybook-inventory records the hidden and renamed Stories;
  overview / range-cross-browser / viewer open the demo controls with
  &verification; integrated-popup reloads the Story instead of the reopen
  button; dropdown-consolidation asserts Dropdown is hidden from the sidebar
  and still opens by its direct URL.
2026-10-21 14:52:46 +09:00

230 lines
20 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.
// Chrome vs Firefox property-panel gauge (range) check on the running Storybook.
// Usage: node packages/sw/tests/range-cross-browser.browser.mjs <evidence-dir> [storybook-url]
// Chrome via Playwright; Firefox = the installed browser driven over WebDriver BiDi (no Playwright build needed).
// Storybook serves packages/sw/dist: build @hm-design/sw first.
// 2026-10-20: the Story is SW/Header › Overview (sw-header--overview). The former sw-header--property-panel was
// retired on 2026-10-19; Overview is the current Story with the same Header + property window (원 선택, opacity
// gauge 0.25, step 0.01) and host ReadOnly / Disabled. Differences from the retired Story, kept visible in the
// steps: Overview has no "외부 값" button for the opacity, so `host-0.8` commits 0.8 in the value input of the same
// row (host state → gauge, through onPropertyChange); its ReadOnly / Disabled switches cover every property, not
// only the opacity; a disabled gauge cannot take focus, so the disabled / readOnly steps try to focus the gauge
// itself and record whether it got focus.
import { mkdir, writeFile, rm } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import os from 'node:os'
import path from 'node:path'
import { loadPlaywright } from './playwright-runtime.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/firefox-range-qa')
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox/firefox.exe'
// The Firefox driver talks WebDriver BiDi over the global WebSocket (Node 22+; Node 20: --experimental-websocket).
if (typeof WebSocket === 'undefined') throw new Error('No global WebSocket: run with Node 22+ or `node --experimental-websocket` (Node 20).')
const { chromium } = loadPlaywright()
const STORY = `${base}/iframe.html?id=sw-header--overview&viewMode=story&verification`
const VIEW = { width: 1400, height: 1000 }
const sleep = ms => new Promise(r => setTimeout(r, ms))
await mkdir(out, { recursive: true })
// Page-side helpers shared by both browsers (strings evaluated in the page).
const GAUGE = `document.querySelector('.hm-sw-property [data-property-id="opacity"] input[type="range"]')`
const VALUE_INPUT = `document.querySelector('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input')`
const SETUP = `(async () => {
const wait = async test => { for (let i = 0; i < 40 && !test(); i++) await new Promise(r => setTimeout(r, 50)); if (!test()) throw new Error('setup: ' + test) }
// Overview opens with 원 선택 (single circle, opacity 0.25); the property window opens from its Header button.
if (document.querySelector('[id$="-selection"]')?.value !== 'circle') throw new Error('setup: Overview selection is not 원 선택')
const trigger = document.querySelector('.hm-sw-header [data-property-panel-trigger]')
if (!trigger?.getClientRects().length) throw new Error('setup: property window button not visible (moved into More?)')
trigger.click()
await wait(() => ${GAUGE})
${GAUGE}.scrollIntoView({ block: 'center' })
await document.fonts.ready
// Screenshots compare the gauge itself: headless Firefox driven over BiDi never reports keyboard modality
// (:focus-visible stays false even for plain buttons), so the shared focus ring is hidden in both browsers.
const st = document.createElement('style'); st.textContent = '.hm-sw-property input[type=range]{outline:none!important}'; document.head.append(st)
return 'ok' })()`
const STATE = `(() => { const r = ${GAUGE}, b = r.getBoundingClientRect()
const n = ${VALUE_INPUT}
return JSON.stringify({ value: r.value, fill: r.style.getPropertyValue('--hm-sw-range-fill'), disabled: r.disabled, number: n.value, x: b.x, y: b.y, w: b.width, h: b.height,
log: document.querySelector('[data-preview-callback]').textContent.slice(-80), focused: document.activeElement === r, focusVisible: r.matches(':focus-visible'), outline: getComputedStyle(r).outlineStyle + ' ' + getComputedStyle(r).outlineColor }) })()`
// Keyboard focus (same :focus-visible rule in both engines): focus the value input, then a real Tab.
const FOCUS_PREV = `(() => { ${VALUE_INPUT}.focus(); return 'ok' })()`
// Disabled / readOnly steps: drop the current focus, then focus the gauge itself (a disabled gauge refuses it).
const FOCUS_GAUGE = `(() => { document.activeElement?.blur?.(); ${GAUGE}.focus(); return String(document.activeElement === ${GAUGE}) })()`
// Overview "시연 데이터" checkboxes (속성 ReadOnly / 속성 Disabled): the host switches for every property.
const TOGGLE = t => `(() => { [...document.querySelectorAll('.sw-overview-data label')].find(l => l.textContent.trim() === ${JSON.stringify(t)}).querySelector('input').click(); return 'ok' })()`
// Host value through the row's value input (Overview has no external opacity button): select, type, Enter.
// The input re-renders once focused (editing look), so select after that render.
const SELECT_VALUE = `(async () => { const n = ${VALUE_INPUT}; n.focus(); await new Promise(r => setTimeout(r, 80)); n.select(); return 'ok' })()`
// Overview places the panel at fractional, engine-dependent positions (16:9 stage, panel left from the Header
// button, stage scroll on focus). Shift the panel to whole pixels before each capture, the same in both engines,
// so the pixel comparison measures the gauge drawing and not a sub-pixel phase. Test only; no Story change.
const ALIGN = `(() => { const panel = ${GAUGE}.closest('.hm-sw-property'); panel.style.translate = ''
const b = ${GAUGE}.getBoundingClientRect(); panel.style.translate = (Math.round(b.x) - b.x) + 'px ' + (Math.round(b.y) - b.y) + 'px'; return 'ok' })()`
// ---------- drivers ----------
async function chromeDriver() {
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: VIEW, deviceScaleFactor: 1 })
const errors = []
page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
const keyMap = { ArrowRight: 'ArrowRight', Home: 'Home', End: 'End', Enter: 'Enter', Tab: 'Tab' }
return {
name: 'chrome', version: browser.version(), errors,
goto: async url => { await page.goto(url); await page.locator('.hm-sw-header').waitFor() },
eval: async expr => page.evaluate(expr),
key: async (k, n = 1) => { for (let i = 0; i < n; i++) await page.keyboard.press(keyMap[k]) },
type: async text => page.keyboard.type(text),
shot: async clip => (await page.screenshot({ clip })).toString('base64'),
close: () => browser.close(),
}
}
async function firefoxDriver() {
const profile = path.join(os.tmpdir(), `sw-ff-profile-${process.pid}`)
await mkdir(profile, { recursive: true })
const port = 9300 + (process.pid % 500)
const proc = spawn(FIREFOX, ['--headless', '--no-remote', '--profile', profile, `--remote-debugging-port=${port}`], { stdio: 'ignore', env: { ...process.env, MOZ_HEADLESS_WIDTH: String(VIEW.width), MOZ_HEADLESS_HEIGHT: String(VIEW.height) } })
let ws
for (let i = 0; i < 60 && !ws; i++) {
await sleep(500)
try { ws = await new Promise((res, rej) => { const s = new WebSocket(`ws://127.0.0.1:${port}/session`); s.onopen = () => res(s); s.onerror = rej }) } catch { ws = undefined }
}
if (!ws) throw new Error('Firefox BiDi not reachable')
let id = 0; const pending = new Map(), errors = []
ws.onmessage = ev => {
const m = JSON.parse(ev.data)
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.type === 'error' ? rej(new Error(`${m.error}: ${m.message}`)) : res(m.result) }
else if (m.method === 'log.entryAdded' && m.params.level === 'error') errors.push(m.params.text)
}
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej: e => rej(new Error(method + " -> " + e.message)) }); ws.send(JSON.stringify({ id: i, method, params })) })
const session = await send('session.new', { capabilities: {} })
await send('session.subscribe', { events: ['log.entryAdded'] })
const tree = await send('browsingContext.getTree', {})
const context = tree.contexts[0].context
// setViewport needs system access in Firefox; the headless window size above gives the same 1400 x 1000.
const KEYS = { ArrowRight: '\uE014', Home: '\uE011', End: '\uE010', Enter: '\uE007', Tab: '\uE004' }
return {
name: 'firefox', version: session.capabilities.browserVersion, errors,
goto: async url => {
await send('browsingContext.navigate', { context, url, wait: 'complete' })
for (let i = 0; i < 40; i++) { const r = await send('script.evaluate', { expression: `!!document.querySelector('.hm-sw-header')`, target: { context }, awaitPromise: false }); if (r.result.value) return; await sleep(250) }
throw new Error('story did not render in Firefox')
},
eval: async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text); return r.result.value },
key: async (k, n = 1) => { const a = []; for (let i = 0; i < n; i++) a.push({ type: 'keyDown', value: KEYS[k] }, { type: 'keyUp', value: KEYS[k] }); await send('input.performActions', { context, actions: [{ type: 'key', id: 'kb', actions: a }] }) },
type: async text => { const a = [...text].flatMap(c => [{ type: 'keyDown', value: c }, { type: 'keyUp', value: c }]); await send('input.performActions', { context, actions: [{ type: 'key', id: 'kb', actions: a }] }) },
shot: async clip => (await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...clip } })).data,
close: async () => { try { await send('session.end', {}) } catch {} ws.close(); proc.kill(); await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {}) },
}
}
// ---------- scenario ----------
const STEPS = [
{ name: 'initial', run: async d => {} },
{ name: 'keyboard-0.65', run: async d => { await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('ArrowRight', 40) } },
{ name: 'enter-commit', run: async d => { await d.key('Enter') } },
{ name: 'max', run: async d => { await d.key('End') } },
{ name: 'min', run: async d => { await d.key('Home') } },
// Ends with no focus, like the former external button (the value comes from the host, not from the gauge).
{ name: 'host-0.8', run: async d => { await d.eval(SELECT_VALUE); await d.type('0.8'); await d.key('Enter'); await sleep(100); await d.eval(`(() => { document.activeElement?.blur?.(); return 'ok' })()`) } },
{ name: 'disabled', run: async d => { await d.eval(TOGGLE('속성 Disabled')); await sleep(100); d.gaugeFocus = await d.eval(FOCUS_GAUGE); await d.key('ArrowRight', 5) } },
{ name: 'readonly', run: async d => { await d.eval(TOGGLE('속성 Disabled')); await d.eval(TOGGLE('속성 ReadOnly')); await sleep(100); d.gaugeFocus = await d.eval(FOCUS_GAUGE); await d.key('ArrowRight', 5) } },
{ name: 'restored', run: async d => { await d.eval(TOGGLE('속성 ReadOnly')); await sleep(100); await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('Home') } },
]
async function run(driver) {
await driver.goto(STORY)
await driver.eval(SETUP)
const states = []
for (const step of STEPS) {
driver.gaugeFocus = undefined
await step.run(driver); await sleep(250)
await driver.eval(ALIGN); await sleep(50)
const st ={ ...JSON.parse(await driver.eval(STATE)), gaugeFocusTaken: driver.gaugeFocus }
// Clip at whole pixels around the input (range is 86 x 16): 2px margin each side.
const clip = { x: Math.round(st.x) - 2, y: Math.round(st.y) - 2, width: 90, height: 20 }
const png = await driver.shot(clip)
await writeFile(path.join(out, `${driver.name}-${step.name}.png`), Buffer.from(png, 'base64'))
states.push({ step: step.name, ...st, offset: { x: st.x - clip.x, y: st.y - clip.y }, png })
}
return states
}
const report = { story: STORY, viewport: VIEW, browsers: {}, steps: [], checks: [] }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
const results = {}
for (const make of [chromeDriver, firefoxDriver]) {
const d = await make()
try { results[d.name] = await run(d); report.browsers[d.name] = { version: d.version, errors: d.errors } } finally { await d.close() }
}
// ---------- analysis (pixels decoded in a Chrome page) ----------
const ab = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const ap = await ab.newPage()
const analyse = (png, ox, oy) => ap.evaluate(async ([png, ox, oy]) => {
const i = new Image(); i.src = 'data:image/png;base64,' + png; await i.decode()
const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0)
const d = x.getImageData(0, 0, i.width, i.height).data, px = (cx, cy) => { const k = (cy * i.width + cx) * 4; return [d[k], d[k + 1], d[k + 2]] }
const bg = px(0, 0), same = (a, b, t = 6) => a.every((v, k) => Math.abs(v - b[k]) <= t)
const top = Math.round(oy), left = Math.round(ox)
// Thumb bbox: red-dominant pixels outside the 6 track rows (rows 5-10 of the 16px input).
let tx0 = 1e9, tx1 = -1, ty0 = 1e9, ty1 = -1
for (let yy = 0; yy < i.height; yy++) { const r = yy - top; if (r >= 5 && r <= 10) continue
for (let xx = 0; xx < i.width; xx++) { const p = px(xx, yy); if (p[0] > 90 && p[0] - p[1] > 50 && p[0] - p[2] > 50) { tx0 = Math.min(tx0, xx); tx1 = Math.max(tx1, xx); ty0 = Math.min(ty0, yy); ty1 = Math.max(ty1, yy) } } }
const thumb = tx1 < 0 ? null : { left: tx0 - left, right: tx1 - left, top: ty0 - top, bottom: ty1 - top, cx: (tx0 + tx1 + 1) / 2 - left, cy: (ty0 + ty1 + 1) / 2 - top }
// Track column profiles: fill column (x=3 of input) and beige column (x=82 of input).
const column = cx => { const rows = []; for (let r = 3; r <= 12; r++) rows.push(px(left + cx, top + r)); return rows }
const extent = col => { const idx = col.map((p, k) => same(p, bg, 10) ? -1 : k + 3).filter(k => k >= 0); return idx.length ? { from: idx[0], to: idx.at(-1), rows: idx.length } : null }
const hex = p => p.map(v => v.toString(16).padStart(2, '0')).join('')
const fillCol = column(3), trackCol = column(82)
return { thumb, fillCol: fillCol.map(hex), trackCol: trackCol.map(hex), fillExtent: extent(fillCol), trackExtent: extent(trackCol) }
}, [png, ox, oy])
const diff = (a, b) => ap.evaluate(async ([a, b]) => {
const load = async s => { const i = new Image(); i.src = 'data:image/png;base64,' + s; await i.decode(); const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0); return x.getImageData(0, 0, i.width, i.height).data }
const A = await load(a), B = await load(b); let max = 0, over16 = 0, over48 = 0, sum = 0
for (let k = 0; k < A.length; k += 4) { const m = Math.max(Math.abs(A[k] - B[k]), Math.abs(A[k + 1] - B[k + 1]), Math.abs(A[k + 2] - B[k + 2])); max = Math.max(max, m); sum += m; if (m > 16) over16++; if (m > 48) over48++ }
return { pixels: A.length / 4, max, mean: +(sum / (A.length / 4)).toFixed(2), over16, over48 }
}, [a, b])
for (let k = 0; k < STEPS.length; k++) {
const c = results.chrome[k], f = results.firefox[k]
const ca = await analyse(c.png, c.offset.x, c.offset.y), fa = await analyse(f.png, f.offset.x, f.offset.y)
const dd = await diff(c.png, f.png)
const strip = s => { const { png, ...rest } = s; return rest }
report.steps.push({ step: STEPS[k].name, chrome: { ...strip(c), pixels: ca }, firefox: { ...strip(f), pixels: fa }, diff: dd })
}
await ab.close()
// ---------- checks ----------
const S = Object.fromEntries(report.steps.map(s => [s.step, s]))
const isRed = h => { const v = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); return v[0] > 100 && v[0] - v[1] > 60 }
const isBeige = h => { const v = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); return v[0] > 160 && v[1] > 150 && v[2] > 120 && v[0] - v[2] > 15 && v[0] - v[2] < 45 }
for (const b of ['chrome', 'firefox']) {
const st = n => S[n][b]
check(`${b} 1-3 red fill / beige track / black-edged 6px`, isRed(st('initial').pixels.fillCol[4]) && isBeige(st('initial').pixels.trackCol[4]) && st('initial').pixels.fillCol[2] === '000000' && st('initial').pixels.fillCol[7] === '000000' && st('initial').pixels.fillExtent.from === 5 && st('initial').pixels.fillExtent.to >= 10, { fill: st('initial').pixels.fillCol, track: st('initial').pixels.trackCol, extent: st('initial').pixels.fillExtent })
const t = st('initial').pixels.thumb
check(`${b} 4-5 thumb 14px, vertically centred on the track`, t && t.right - t.left + 1 >= 11 && t.right - t.left + 1 <= 15 && Math.abs(t.cy - 8) <= 0.5, t)
for (const [n, v] of [['min', 0], ['initial', 0.25], ['keyboard-0.65', 0.65], ['max', 1], ['host-0.8', 0.8]]) {
const s = st(n), expect = 7 + 72 * v
check(`${b} 6-8/10 ${n}: value ${s.value}, thumb centre ${s.pixels.thumb?.cx} ≈ ${expect}`, Number(s.value) === v && Number(s.fill) === v && Math.abs(s.pixels.thumb.cx - expect) <= 1, { value: s.value, fill: s.fill, cx: s.pixels.thumb?.cx })
}
check(`${b} 9 keyboard + Enter commit`, st('keyboard-0.65').value === '0.65' && st('enter-commit').number === '0.65' && st('enter-commit').log.includes('onPropertyChange(opacity, 0.65)'), { value: st('keyboard-0.65').value, number: st('enter-commit').number, log: st('enter-commit').log })
check(`${b} 10b host value (value input commit) moves the gauge`, st('host-0.8').number === '0.8' && st('host-0.8').log.includes('onPropertyChange(opacity, 0.8)'), { number: st('host-0.8').number, log: st('host-0.8').log })
check(`${b} 11 disabled / readOnly block focus and keys, keep design`, st('disabled').disabled && st('disabled').value === '0.8' && st('disabled').gaugeFocusTaken === 'false' && st('readonly').disabled && st('readonly').value === '0.8' && st('readonly').gaugeFocusTaken === 'false' && isRed(st('disabled').pixels.fillCol[4]) && isBeige(st('readonly').pixels.trackCol[4]) && !st('restored').disabled && st('restored').value === '0', { disabled: [st('disabled').disabled, st('disabled').value, st('disabled').gaugeFocusTaken], readonly: [st('readonly').disabled, st('readonly').value, st('readonly').gaugeFocusTaken], restored: [st('restored').disabled, st('restored').value] })
check(`${b} 12 no console / page errors`, report.browsers[b].errors.filter(e => !e.includes('/sb-common-assets/')).length === 0, report.browsers[b].errors)
}
const near = (a, b, t) => a.every((h, k) => [0, 2, 4].every(i => Math.abs(parseInt(h.slice(i, i + 2), 16) - parseInt(b[k].slice(i, i + 2), 16)) <= t))
const noHighlight = col => col.filter((_, k) => k !== 8) // 1px highlight row under the track, reported separately
for (const s of report.steps) {
const ct = s.chrome.pixels.thumb, ft = s.firefox.pixels.thumb
const fillSame = s.step === 'min' || s.step === 'restored' || near(noHighlight(s.chrome.pixels.fillCol), noHighlight(s.firefox.pixels.fillCol), 3)
check(`compare ${s.step}: columns ±3, thumb ±1px, ≤12 px differ > 48`,
fillSame && near(noHighlight(s.chrome.pixels.trackCol), noHighlight(s.firefox.pixels.trackCol), 3)
&& Math.abs(ct.cx - ft.cx) <= 1 && Math.abs(ct.cy - ft.cy) <= 1 && s.diff.over48 <= 12,
{ diff: s.diff, focus: [s.chrome.focusVisible, s.firefox.focusVisible], chromeThumb: ct, firefoxThumb: ft, highlight: [s.chrome.pixels.fillCol[8], s.firefox.pixels.fillCol[8]] })
}
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2))
console.log('BROWSERS', JSON.stringify(Object.fromEntries(Object.entries(report.browsers).map(([k, v]) => [k, v.version]))))
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)
if (report.checks.some(c => c.status === 'FAIL')) process.exitCode = 1