diff --git a/packages/sw/tests/range-cross-browser.browser.mjs b/packages/sw/tests/range-cross-browser.browser.mjs index 7c17f79..2779ffa 100644 --- a/packages/sw/tests/range-cross-browser.browser.mjs +++ b/packages/sw/tests/range-cross-browser.browser.mjs @@ -2,38 +2,65 @@ // 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. -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