Chromium and Firefox browser tests (hover position, field text, ellipsis and tooltip), AI docs and guide, report with the open Firefox Continuous 0.6px issue and the Figma difference of the home menu hover.
124 lines
11 KiB
JavaScript
124 lines
11 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; header field values are Dotum 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 = []
|
||
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' }), true`); await sleep(60)
|
||
await move(1, 1); await sleep(80)
|
||
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); await sleep(100); const h1 = await snap(sel, i, q)
|
||
await move(1, 1); await sleep(100); const d1 = await snap(sel, i, q)
|
||
await move(x, y); await sleep(100); const h2 = await snap(sel, i, q)
|
||
await pointer([{ type: 'pointerDown', button: 0 }]); await sleep(80); const a = await snap(sel, i, q)
|
||
await pointer([{ type: 'pointerMove', x: 1, y: 1, duration: 0 }, { type: 'pointerUp', button: 0 }]); await sleep(100)
|
||
const same = [h1, d1, h2, a].every(s => geo(s) === geo(d0))
|
||
results.push({ name: d0.name, same, hovered: d0.active || h1.color !== d0.color, d0, h1, a })
|
||
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
|