test(sw): restore range cross-browser test on the Overview story

This commit is contained in:
shkim4 committed 2026-10-20 21:49:46 +09:00
1 parent df650c02dd
commit 5032c94d9e
1 file changed
+53 -19
@@ -2,38 +2,65 @@
// 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.
import { createRequire } from 'node:module'
// 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'
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')
const STORY = `${base}/iframe.html?id=sw-header--property-panel&viewMode=story`
// 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`
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 click = t => [...document.querySelectorAll('button')].find(b => b.textContent.trim() === t).click()
click('원 1개'); await new Promise(r => setTimeout(r, 100))
document.querySelector('.hm-sw-header [data-property-panel-trigger]').click(); await new Promise(r => setTimeout(r, 200))
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 = document.querySelector('.hm-sw-property [data-property-id="opacity"] input[type="range"]'), b = r.getBoundingClientRect()
const n = document.querySelector('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input')
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-property-log]').textContent.slice(-80), focused: document.activeElement === r, focusVisible: r.matches(':focus-visible'), outline: getComputedStyle(r).outlineStyle + ' ' + getComputedStyle(r).outlineColor }) })()`
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 = `(() => { document.querySelector('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input').focus(); return 'ok' })()`
const CLICK = t => `(() => { [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(t)}).click(); return 'ok' })()`
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() {
@@ -47,6 +74,7 @@ async function chromeDriver() {
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(),
}
@@ -85,6 +113,7 @@ async function firefoxDriver() {
},
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(() => {}) },
}
@@ -97,18 +126,21 @@ const STEPS = [
{ 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') } },
{ name: 'external-0.8', run: async d => { await d.eval(CLICK('투명도 외부 값 변경')) } },
{ name: 'disabled', run: async d => { await d.eval(CLICK('투명도 Disabled')); await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('ArrowRight', 5) } },
{ name: 'readonly', run: async d => { await d.eval(CLICK('투명도 Disabled')); await d.eval(CLICK('투명도 ReadOnly')); await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('ArrowRight', 5) } },
{ name: 'restored', run: async d => { await d.eval(CLICK('투명도 ReadOnly')); await d.eval(FOCUS_PREV); await d.key('Tab'); 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)
const st = JSON.parse(await driver.eval(STATE))
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)
@@ -172,12 +204,13 @@ for (const b of ['chrome', 'firefox']) {
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], ['external-0.8', 0.8]]) {
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('property(opacity, 0.65)'), { value: st('keyboard-0.65').value, number: st('enter-commit').number, log: st('enter-commit').log })
check(`${b} 11 disabled / readOnly block keys, keep design`, st('disabled').disabled && st('disabled').value === '0.8' && st('readonly').disabled && st('readonly').value === '0.8' && 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], readonly: [st('readonly').disabled, st('readonly').value], restored: [st('restored').disabled, st('restored').value] })
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))
@@ -193,3 +226,4 @@ for (const s of report.steps) {
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