Files
HMDesign/packages/sw/tests/hover-stability-firefox.browser.mjs
T
shkim4 ef69f5143b test(sw): align browser tests and theme story with Noto Sans KR and Bold
Font expectations move back to Noto Sans KR (Dotum becomes the theme
inheritance verification value), viewer labels expect 700, and the new
text-weight.browser.mjs checks the requested Bold scope and outlines.
2026-10-20 18:17:50 +09:00

124 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = []
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