// 2026-10-20 Firefox counterpart of hover-stability.browser.mjs: the installed Firefox over WebDriver BiDi (no // Playwright build), real pointer moves. Home Menu tiles and the full-footer commands: icon / text / box are the same // at rest, on hover and while pressed; SWHeader field values are 400 and not cut; POLYGON / WIPEOUT ellipsis. // Usage: node packages/sw/tests/hover-stability-firefox.browser.mjs [storybook-url] import { mkdir, writeFile, rm } from 'node:fs/promises' import { spawn } from 'node:child_process' import os from 'node:os' import path from 'node:path' const out = path.resolve(process.argv[2] ?? 'docs/sw/hover-stability-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 VIEW = { width: 1920, height: 1100 } const sleep = ms => new Promise(r => setTimeout(r, ms)) await mkdir(out, { recursive: true }) const profile = path.join(os.tmpdir(), `sw-ff-hover-${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 context = (await send('browsingContext.getTree', {})).contexts[0].context // The headless window may ignore MOZ_HEADLESS_WIDTH; ask for the viewport and fall back to what the window gives. try { await send('browsingContext.setViewport', { context, viewport: VIEW }) } catch (e) { console.log('INFO setViewport:', e.message) } const ev = async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text + ' :: ' + expr.slice(0, 120)); return r.result.value } const goto = async story => { await send('browsingContext.navigate', { context, url: `${base}/iframe.html?id=${story}&viewMode=story`, wait: 'complete' }) for (let i = 0; i < 40; i++) { if (await ev(`!!document.querySelector('#storybook-root > *')`)) break; await sleep(250) } await ev('document.fonts.ready.then(() => true)'); await sleep(400) } const pointer = actions => send('input.performActions', { context, actions: [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions }] }) const move = (x, y) => pointer([{ type: 'pointerMove', x: Math.round(x), y: Math.round(y), duration: 0 }]) const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d).slice(0, 500)) } const SNAP = `(sel, i, q) => { const el = [...document.querySelectorAll(sel)].filter(e => e.getClientRects().length)[i] const vis = e => { for (let n = e; n && n !== el.parentElement; n = n.parentElement) { const c = getComputedStyle(n); if (c.display === 'none' || c.visibility === 'hidden') return false } return true } const r = e => { const b = e.getBoundingClientRect(); return [b.x, b.y, b.width, b.height].map(v => Math.round(v * 1000) / 1000) } const art = [...el.querySelectorAll(q.icon)].filter(vis).map(i => i.querySelector('img, svg') ?? i) const label = el.querySelector(q.label), range = document.createRange(); range.selectNodeContents(label) const t = range.getBoundingClientRect() return JSON.stringify({ name: label.textContent.trim(), box: r(el), icon: art.length === 1 ? r(art[0]) : null, n: art.length, text: [t.x, t.y, t.width, t.height].map(v => Math.round(v * 1000) / 1000), color: getComputedStyle(label).color, active: el.dataset.active === 'true' }) }` const snap = async (sel, i, q) => JSON.parse(await ev(`(${SNAP})(${JSON.stringify(sel)}, ${i}, ${JSON.stringify(q)})`)) // Inside the item (the page may scroll between reads: a scroll moves the whole item, not its content). const rd = v => Math.round(v * 1000) / 1000 const geo = s => JSON.stringify([s.box[2], s.box[3], s.icon && [rd(s.icon[0] - s.box[0]), rd(s.icon[1] - s.box[1]), s.icon[2], s.icon[3]], s.n, [rd(s.text[0] - s.box[0]), rd(s.text[1] - s.box[1]), s.text[2], s.text[3]]]) async function cycleAll(sel, q, tag) { const n = await ev(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.getClientRects().length).length`) const results = [] // 2026-10-20: state-based waits replace the fixed 60–100ms sleeps (Firefox sometimes read a tile before :hover or its // transition applied, so a tile looked unhovered or a read hit the wrong index). Each read waits until the item's // :hover / :active state is the expected one and its transitions ended; a wait that times out is recorded and fails. // The preview sets html { scroll-behavior: smooth } (src/hm-theme.css): scroll instantly, or the item is measured mid-scroll // and the pointer lands on another tile (the former "저장" hover / index misses). // After a release outside the item Firefox keeps :active on it until the next press, so that last wait checks :hover only. const settle = (i, hover, active = false) => ev(`(async () => { const el = [...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.getClientRects().length)[${i}] const frame = () => new Promise(r => requestAnimationFrame(() => r())) for (let t = 0; t < 120; t++) { await frame(); if (el.matches(':hover') === ${hover} && (${active === null} || el.matches(':active') === ${active}) && el.getAnimations({ subtree: true }).every(a => a.playState !== 'running')) { await frame(); return true } } return false })()`) for (let i = 0; i < n; i++) { await ev(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.getClientRects().length)[${i}].scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }), true`) await move(1, 1); const waits = [await settle(i, false)] const d0 = await snap(sel, i, q) const [x, y] = [d0.box[0] + d0.box[2] / 2, d0.box[1] + d0.box[3] / 2] await move(x, y); waits.push(await settle(i, true)); const h1 = await snap(sel, i, q) await move(1, 1); waits.push(await settle(i, false)); const d1 = await snap(sel, i, q) await move(x, y); waits.push(await settle(i, true)); const h2 = await snap(sel, i, q) await pointer([{ type: 'pointerDown', button: 0 }]); waits.push(await settle(i, true, true)); const a = await snap(sel, i, q) await pointer([{ type: 'pointerMove', x: 1, y: 1, duration: 0 }, { type: 'pointerUp', button: 0 }]); waits.push(await settle(i, false, null)) const same = [h1, d1, h2, a].every(s => geo(s) === geo(d0)) && waits.every(Boolean) results.push({ name: d0.name, same, hovered: d0.active || h1.color !== d0.color, d0, h1, a, waits }) check(`Firefox ${tag} "${d0.name}": icon · text · box unchanged (Default/Hover/Default/Hover/Active)`, same, { d0: geo(d0), h1: geo(h1), a: geo(a) }) } return results } const report = { browser: session.capabilities.browserVersion } await goto('sw-header--interactive-states') report.viewport = JSON.parse(await ev('JSON.stringify([innerWidth, innerHeight])')) console.log('INFO Firefox', report.browser, 'viewport', report.viewport.join(' x ')) report.headerFields = JSON.parse(await ev(`JSON.stringify([...document.querySelectorAll('.hm-sw-header')].filter(h => h.getBoundingClientRect().width >= 1800).flatMap(h => [...h.querySelectorAll('.hm-sw-header__field-value')].filter(e => e.getClientRects().length).map(e => { const c = getComputedStyle(e), g = document.createRange(); g.selectNodeContents(e); const tw = g.getBoundingClientRect().width, bw = e.getBoundingClientRect().width; return { text: e.textContent, field: e.closest('[data-menu]')?.dataset.menu, weight: c.fontWeight, textW: Math.round(tw * 100) / 100, boxW: Math.round(bw * 100) / 100, cut: tw > bw + 0.05 } })))`)) // Fixed 38px 가중치 / 스케일 slots hold short numbers; a longer word there is a data-length case (see the Chromium test). check('Firefox Header: field values are 400 and not cut (Standard, 0_Layer_Cludmark, Continuous, 1.00, 255,229,0 …)', report.headerFields.length >= 7 && report.headerFields.every(f => f.weight === '400' && (!f.cut || (f.field === 'line-style' && f.boxW <= 38))), report.headerFields.filter(f => f.weight !== '400' || f.cut)) await goto('sw-header--home-menu') report.home = await cycleAll('.hm-sw-home-menu > .hm-sw-home-menu__sections .hm-sw-home-menu__item', { icon: '.hm-sw-home-menu__icon', label: '.hm-sw-home-menu__label' }, 'Home Menu') check(`Firefox Home Menu: ${report.home.length} tiles, hover reached every non-active tile`, report.home.length >= 15 && report.home.every(r => r.hovered), report.home.filter(r => !r.hovered).map(r => r.name)) await send('browsingContext.captureScreenshot', { context }).then(r => writeFile(path.join(out, 'home-menu-firefox.png'), Buffer.from(r.data, 'base64'))) await goto('sw-footer--full-footer') const FOOT = '.sw-footer-story-workspace .hm-sw-footer-tool' report.footer = [] const tabs = await ev(`document.querySelectorAll('.sw-footer-story-workspace .hm-sw-footer-menu__tab').length`) for (let k = 0; k < tabs; k++) { await ev(`document.querySelectorAll('.sw-footer-story-workspace .hm-sw-footer-menu__tab')[${k}].click(), true`); await sleep(120) report.footer.push(...await cycleAll(FOOT, { icon: '.hm-sw-footer-tool__icon', label: '.hm-sw-footer-tool__label' }, `Footer tab ${k + 1}`)) } check(`Firefox Footer: ${report.footer.length} commands, all 68 × 47, hover reached every command`, report.footer.length >= 11 && report.footer.every(r => r.d0.box[2] === 68 && r.d0.box[3] === 47 && r.hovered), report.footer.filter(r => !r.hovered).map(r => r.name)) await ev(`document.querySelectorAll('.sw-footer-story-workspace .hm-sw-footer-menu__tab')[0].click(), true`); await sleep(120) report.ellipsis = {} for (const name of ['POLYGON', 'WIPEOUT']) { const box = JSON.parse(await ev(`(() => { const b = [...document.querySelectorAll('${FOOT}')].find(e => e.textContent === '${name}'); b.scrollIntoView({ block: 'center', inline: 'center' }); const r = b.getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`)) await move(1, 1); await sleep(60); await move(box[0], box[1]); await sleep(150) report.ellipsis[name] = JSON.parse(await ev(`(() => { const b = [...document.querySelectorAll('${FOOT}')].find(e => e.textContent === '${name}'), l = b.querySelector('.hm-sw-footer-tool__label'); return JSON.stringify({ overflow: getComputedStyle(l).textOverflow, cut: l.scrollWidth > l.clientWidth, title: b.getAttribute('title'), w: b.getBoundingClientRect().width, h: b.getBoundingClientRect().height }) })()`)) const e = report.ellipsis[name] check(`Firefox Footer: ${name} ends in … (cut) and its hover title is the full name, 68 × 47`, e.overflow === 'ellipsis' && e.cut && e.title === name && e.w === 68 && e.h === 47, e) } await move(1, 1) await send('browsingContext.captureScreenshot', { context }).then(r => writeFile(path.join(out, 'footer-firefox.png'), Buffer.from(r.data, 'base64'))) // The Storybook UI font (sb-common-assets / Nunito Sans) is not part of the SW page; it fails only on this static server. const swErrors = errors.filter(e => !e.includes('sb-common-assets')) report.environmentErrors = errors.length - swErrors.length check('Firefox: no console errors (Storybook UI font requests excluded)', swErrors.length === 0, swErrors) await writeFile(path.join(out, 'hover-stability-firefox.json'), JSON.stringify({ checks, report }, null, 2)) const failed = checks.filter(c => !c.ok).length console.log(`${checks.length - failed}/${checks.length} PASS (firefox ${report.browser})`) try { await send('session.end', {}) } catch {} ws.close(); proc.kill(); await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {}) process.exitCode = failed ? 1 : 0