- hover-stability-firefox: instant scroll and state waits instead of a fixed 60ms delay (smooth scroll in preview). - integrated-visual, viewer-regression, theme-inheritance: deterministic Chromium raster flags, animations held at time 0, layout settle wait, integer instant scroll of inner panels; tolerance stays 0. - text-weight: wait for the Viewer More panel instead of a fixed 200ms and fix the failure report.
134 lines
13 KiB
JavaScript
134 lines
13 KiB
JavaScript
// 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 <evidence-dir> [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
|