// Chrome vs Firefox property-panel gauge (range) check on the running Storybook. // Usage: node packages/sw/tests/range-cross-browser.browser.mjs [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