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.
240 lines
19 KiB
JavaScript
240 lines
19 KiB
JavaScript
// 2026-10-20 SW hover position stability + header field text + footer command ellipsis / tooltip.
|
||
// Hover (and Active / Selected) may change only color, background and the hover ring: the icon, the text, their gap
|
||
// and the item box stay where they are (Header Home Menu tiles, Footer command slots in every story and category,
|
||
// including the ▲ panel). Also: Header field values are Dotum 400 and not cut; long footer commands end in an
|
||
// ellipsis inside the unchanged 68 × 47 slot and show their full name on hover.
|
||
// Usage: build Storybook first, then node packages/sw/tests/hover-stability.browser.mjs [evidence-dir] [chromium|firefox]
|
||
import { createRequire } from 'node:module'
|
||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||
import path from 'node:path'
|
||
import http from 'node:http'
|
||
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
|
||
const require = createRequire(path.join(runtime, 'index.js'))
|
||
const playwright = require('playwright')
|
||
const out = path.resolve(process.argv[2] ?? 'docs/sw/hover-stability-qa'); await mkdir(out, { recursive: true })
|
||
const engine = process.argv[3] ?? 'chromium'
|
||
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
|
||
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
|
||
await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`
|
||
const browser = engine === 'firefox'
|
||
? await playwright.firefox.launch({ headless: true })
|
||
: await playwright.chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d).slice(0, 600)) }
|
||
const report = { engine }
|
||
let errors = []
|
||
const page = await browser.newPage({ viewport: { width: 1920, height: 1100 } })
|
||
page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
|
||
const goto = async id => { errors = []; await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`); await page.locator('#storybook-root > *').first().waitFor(); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(300) }
|
||
// Sub-pixel rounding (e.g. a centered odd-width box) is not a layout move: positions are compared to 0.01px.
|
||
const EPS = 0.01
|
||
|
||
// Geometry of one item: the box, the visible icon art (the <img> / <svg> inside the shown icon slot) and the text
|
||
// (glyph run through a Range, so a padding or position change of the text box is caught too).
|
||
const SNAP = `(el, q) => {
|
||
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 icons = [...el.querySelectorAll(q.icon)].filter(vis)
|
||
const art = icons.map(i => i.querySelector('img, svg') ?? i)
|
||
const label = el.querySelector(q.label)
|
||
const range = document.createRange(); range.selectNodeContents(label)
|
||
const t = range.getBoundingClientRect(), lc = getComputedStyle(label)
|
||
const text = [t.x, t.y, t.width, t.height].map(v => Math.round(v * 1000) / 1000)
|
||
const icon = art.length === 1 ? r(art[0]) : null
|
||
return {
|
||
box: r(el), icon, iconCount: art.length, iconSrc: art[0]?.getAttribute?.('src')?.slice(-40) ?? art[0]?.tagName,
|
||
text, labelBox: r(label),
|
||
// vertical gap between the icon art and the text run (both slots stack vertically)
|
||
gap: icon ? Math.round((icon[1] > text[1] ? icon[1] - (text[1] + text[3]) : text[1] - (icon[1] + icon[3])) * 1000) / 1000 : null,
|
||
font: [lc.fontFamily.split(',')[0], lc.fontSize, lc.fontWeight, lc.lineHeight, lc.letterSpacing].join('|'),
|
||
color: lc.color, iconTransform: icons[0] ? getComputedStyle(icons[0]).transform : null,
|
||
}
|
||
}`
|
||
const snap = (loc, q) => loc.evaluate(new Function('return ' + SNAP)(), q)
|
||
const geom = s => JSON.stringify({ box: s.box, icon: s.icon, n: s.iconCount, text: s.text, labelBox: s.labelBox, gap: s.gap, font: s.font })
|
||
const delta = (a, b) => {
|
||
const d = {}
|
||
for (const k of ['box', 'icon', 'text', 'labelBox']) if (a[k] && b[k]) { const v = a[k].map((x, i) => Math.round((b[k][i] - x) * 1000) / 1000); if (v.some(x => Math.abs(x) > EPS)) d[k] = v }
|
||
if (a.gap !== null && b.gap !== null && Math.abs(a.gap - b.gap) > EPS) d.gap = [a.gap, b.gap]
|
||
if (a.font !== b.font) d.font = [a.font, b.font]
|
||
if (a.iconCount !== b.iconCount) d.iconCount = [a.iconCount, b.iconCount]
|
||
return d
|
||
}
|
||
const away = async () => { await page.mouse.move(2, 2); await page.waitForTimeout(90) }
|
||
const center = async loc => { const b = await loc.boundingBox(); return [b.x + b.width / 2, b.y + b.height / 2] }
|
||
// Default → Hover → Default → Hover → Active (held, released outside so nothing is clicked).
|
||
async function cycle(loc, q) {
|
||
await loc.scrollIntoViewIfNeeded(); await away(); const d0 = await snap(loc, q)
|
||
const [x, y] = await center(loc)
|
||
await page.mouse.move(x, y); await page.waitForTimeout(90); const h1 = await snap(loc, q)
|
||
await away(); const d1 = await snap(loc, q)
|
||
await page.mouse.move(x, y); await page.waitForTimeout(90); const h2 = await snap(loc, q)
|
||
await page.mouse.down(); await page.waitForTimeout(60); const a = await snap(loc, q)
|
||
await page.mouse.move(2, 2); await page.mouse.up(); await page.waitForTimeout(90)
|
||
const d2 = await snap(loc, q)
|
||
return { d0, h1, d1, h2, a, d2 }
|
||
}
|
||
function judge(name, c) {
|
||
const moves = { hover: delta(c.d0, c.h1), back: delta(c.d0, c.d1), hover2: delta(c.d0, c.h2), active: delta(c.d0, c.a), end: delta(c.d0, c.d2) }
|
||
const moved = Object.fromEntries(Object.entries(moves).filter(([, v]) => Object.keys(v).length))
|
||
return { name, ok: Object.keys(moved).length === 0, moved, colorChanged: c.d0.color !== c.h1.color, iconSwap: c.d0.iconSrc !== c.h1.iconSrc, default: c.d0, hover: c.h1, active: c.a }
|
||
}
|
||
|
||
// ---------- 1. Header field values: Dotum 400, nothing cut ----------
|
||
// Full-width headers (1800 / 1920px) show every field; narrower ones move the aux fields into More.
|
||
const fields = []
|
||
for (const id of ['sw-header--interactive-states', 'sw-header--document-open', 'sw-header--responsive-more']) {
|
||
await goto(id)
|
||
const wide = page.locator('.hm-sw-header').filter({ visible: true })
|
||
for (let i = 0, n = await wide.count(); i < n; i++) {
|
||
const h = wide.nth(i), w = await h.evaluate(e => e.getBoundingClientRect().width)
|
||
if (w < 1800) continue
|
||
for (const f of await h.locator('.hm-sw-header__field-value').evaluateAll(list => list.filter(e => e.getClientRects().length).map(e => { const c = getComputedStyle(e), range = document.createRange(); range.selectNodeContents(e); return { text: e.textContent, field: e.closest('[data-menu]')?.dataset.menu, weight: c.fontWeight, size: c.fontSize, spacing: c.letterSpacing, family: c.fontFamily.split(',')[0], textW: Math.round(range.getBoundingClientRect().width * 100) / 100, boxW: Math.round(e.getBoundingClientRect().width * 100) / 100, client: e.clientWidth, scroll: e.scrollWidth, cut: range.getBoundingClientRect().width > e.getBoundingClientRect().width + 0.05 } }))) fields.push({ story: id, header: i, width: w, ...f })
|
||
}
|
||
}
|
||
report.headerFields = fields
|
||
check('Header: every field value is Dotum 400 / 14px / 0.56px', fields.length >= 6 && fields.every(f => f.weight === '400' && f.size === '14px' && f.spacing === '0.56px' && /dotum/i.test(f.family)), fields)
|
||
for (const want of ['Standard', '0_Layer_Cludmark', '1.00', '255,229,0']) {
|
||
const f = fields.filter(x => x.text === want)
|
||
check(`Header: "${want}" is shown whole (text width ≤ box)`, f.length > 0 && f.every(x => !x.cut), f)
|
||
}
|
||
// The 가중치 / 스케일 slots of the line-style field are a fixed 38px (Figma, numbers such as 0.25): a word such as
|
||
// ByLayer there is a data-length case (ellipsis + title), reported apart, not a weight / cut regression.
|
||
const fixedSlot = f => f.field === 'line-style' && f.client <= 38
|
||
check('Header: no field value is cut in a full-width (≥ 1800px) header (except fixed 38px 가중치 / 스케일 slots)', fields.filter(f => !fixedSlot(f)).every(f => !f.cut), fields.filter(f => f.cut && !fixedSlot(f)))
|
||
report.fixedSlotCut = [...new Set(fields.filter(f => f.cut && fixedSlot(f)).map(f => `${f.text} (${f.textW} / ${f.client}px)`))]
|
||
console.log('INFO fixed 38px slot values longer than the slot:', report.fixedSlotCut.join(', ') || 'none')
|
||
await goto('sw-header--interactive-states')
|
||
const others = await page.locator('.hm-sw-header :is(.hm-sw-header__menu-text, .hm-sw-header__font-name, .hm-sw-header__document-name)').evaluateAll(l => [...new Set(l.filter(e => e.getClientRects().length).map(e => getComputedStyle(e).fontWeight))])
|
||
check('Header: buttons / menu text / font sample keep their own weight (700)', others.every(w => w === '700'), others)
|
||
|
||
// ---------- 2. Header Home Menu tiles ----------
|
||
const HQ = { icon: '.hm-sw-home-menu__icon', label: '.hm-sw-home-menu__label' }
|
||
await goto('sw-header--home-menu')
|
||
const tiles = page.locator('.hm-sw-home-menu > .hm-sw-home-menu__sections .hm-sw-home-menu__item').filter({ visible: true })
|
||
const tileCount = await tiles.count()
|
||
const homeResults = []
|
||
for (let i = 0; i < tileCount; i++) {
|
||
const t = tiles.nth(i)
|
||
if (await t.isDisabled()) continue
|
||
const name = (await t.locator(HQ.label).textContent()).trim()
|
||
// A panel tile (최근파일) stays Active after a click; the cycle releases outside, so it never opens.
|
||
const activeAtRest = await t.getAttribute('data-active') === 'true'
|
||
homeResults.push({ ...judge(name, await cycle(t, HQ)), activeAtRest })
|
||
}
|
||
report.homeMenu = homeResults
|
||
check(`Home Menu: tiles measured (${homeResults.length})`, homeResults.length >= 15, homeResults.map(r => r.name))
|
||
for (const r of homeResults) check(`Home Menu "${r.name}": icon · text · gap · box unchanged (Default/Hover/Default/Hover/Active)`, r.ok, r.moved)
|
||
check('Home Menu: hover still shows the accent (#FF5C00 text, hover icon, orange ring)', homeResults.filter(r => !r.activeAtRest).every(r => r.hover.color === 'rgb(255, 92, 0)' && r.default.color !== r.hover.color), homeResults.map(r => [r.name, r.default.color, r.hover.color]))
|
||
// Active tiles (shown Active by a Story) have the same icon / text place inside the tile as Default tiles.
|
||
const rel = s => [s.icon[0] - s.box[0], s.icon[1] - s.box[1], s.text[0] - s.box[0], s.text[1] - s.box[1]].map(v => Math.round(v * 1000) / 1000).join(',')
|
||
const activeRel = homeResults.filter(r => r.activeAtRest).map(r => { const d = homeResults.find(x => !x.activeAtRest && x.name === r.name); return { name: r.name, active: rel(r.default), def: d && rel(d.default) } })
|
||
report.homeActiveRelative = activeRel
|
||
check(`Home Menu: Active tiles (${activeRel.length}) = Default tiles: same icon / text offset inside the tile`, activeRel.length > 0 && activeRel.every(a => a.def && a.active === a.def), activeRel)
|
||
const ring = await tiles.first().evaluate(async e => { const before = getComputedStyle(e, '::after').opacity; return before })
|
||
check('Home Menu: ring hidden at rest', ring === '0', ring)
|
||
// Keyboard focus / Escape still work on the menu
|
||
await tiles.first().focus(); await page.keyboard.press('ArrowRight'); await page.waitForTimeout(60)
|
||
const focused = await page.evaluate(() => document.activeElement?.textContent?.trim())
|
||
check('Home Menu: ArrowRight moves focus to the next tile', focused === homeResults[1]?.name, focused)
|
||
await page.screenshot({ path: path.join(out, `home-menu-${engine}.png`), clip: { x: 0, y: 0, width: 1920, height: 220 } })
|
||
|
||
// ---------- 3. Footer command slots: every story, every category with commands, ▲ panel ----------
|
||
const FQ = { icon: '.hm-sw-footer-tool__icon', label: '.hm-sw-footer-tool__label' }
|
||
const footerResults = []
|
||
async function footerTools(story, scope, tag) {
|
||
const tools = scope.locator('.hm-sw-footer-tool:visible')
|
||
const n = await tools.count()
|
||
for (let i = 0; i < n; i++) {
|
||
const t = tools.nth(i)
|
||
if (await t.isDisabled()) continue
|
||
const name = (await t.locator(FQ.label).textContent()).trim()
|
||
const r = judge(`${story} · ${tag} · ${name}`, await cycle(t, FQ))
|
||
// Selected (pressed) — the same geometry as Default
|
||
r.label = name
|
||
footerResults.push(r)
|
||
}
|
||
return n
|
||
}
|
||
const STORIES = ['sw-footer--full-footer', 'sw-footer--tool-groups', 'sw-footer--custom-content']
|
||
for (const story of STORIES) {
|
||
await goto(story)
|
||
const footers = page.locator('.hm-sw-footer:visible')
|
||
const fc = await footers.count()
|
||
for (let f = 0; f < fc; f++) {
|
||
const footer = footers.nth(f)
|
||
const tabs = footer.locator('.hm-sw-footer-menu__tab')
|
||
const tc = await tabs.count()
|
||
if (tc === 0) { await footerTools(story, footer, `footer${f}`); continue }
|
||
for (let k = 0; k < tc; k++) {
|
||
const tab = tabs.nth(k)
|
||
if (!(await tab.isVisible()) || await tab.isDisabled()) continue
|
||
await tab.click(); await away()
|
||
await footerTools(story, footer, `footer${f} · ${(await tab.textContent()).trim()}`)
|
||
}
|
||
}
|
||
}
|
||
// ▲ panel (narrow widths): commands that do not fit open above the slots.
|
||
await goto('sw-footer--responsive')
|
||
const narrow = page.locator('.sw-footer-story-width .hm-sw-footer').nth(4)
|
||
const toggle = narrow.locator('.hm-sw-footer-more')
|
||
if (await toggle.count()) {
|
||
await toggle.click(); await page.waitForTimeout(150)
|
||
const panel = page.locator('.hm-sw-footer__more-panel, [class*="more-panel"]').filter({ has: page.locator('.hm-sw-footer-tool') }).first()
|
||
if (await panel.count()) await footerTools('sw-footer--responsive', panel, '▲ panel')
|
||
}
|
||
report.footer = footerResults
|
||
check(`Footer: command slots measured (${footerResults.length})`, footerResults.length >= 20, footerResults.length)
|
||
const footerLabels = [...new Set(footerResults.map(r => r.label))]
|
||
report.footerLabels = footerLabels
|
||
for (const r of footerResults) check(`Footer "${r.name}": icon · text · gap · 68 × 47 unchanged (Default/Hover/Default/Hover/Active)`, r.ok && r.default.box[2] === 68 && r.default.box[3] === 47, { moved: r.moved, box: r.default.box })
|
||
check('Footer: hover still colors the command (#FF5C00) and swaps to the orange icon where one is given', footerResults.every(r => r.hover.color === 'rgb(255, 92, 0)'), footerResults.filter(r => r.hover.color !== 'rgb(255, 92, 0)').map(r => r.name))
|
||
|
||
// Selected (pressed) geometry = Default geometry
|
||
await goto('sw-footer--full-footer')
|
||
const work = page.locator('.sw-footer-story-workspace .hm-sw-footer')
|
||
const poly = work.locator('.hm-sw-footer-tool', { hasText: 'POLYGON' })
|
||
await poly.scrollIntoViewIfNeeded(); await away(); const polyDefault = await snap(poly, FQ)
|
||
await poly.click(); await away(); const polyPressed = await snap(poly, FQ)
|
||
check('Footer: Selected (aria-pressed) POLYGON keeps the Default geometry, accent color', await poly.getAttribute('aria-pressed') === 'true' && Object.keys(delta(polyDefault, polyPressed)).length === 0 && polyPressed.color === 'rgb(255, 92, 0)', delta(polyDefault, polyPressed))
|
||
await poly.click(); await away()
|
||
|
||
// ---------- 4. Footer ellipsis + tooltip ----------
|
||
const labelInfo = loc => loc.evaluate(b => { const l = b.querySelector('.hm-sw-footer-tool__label'), c = getComputedStyle(l), r = b.getBoundingClientRect(); return { text: l.textContent, overflow: c.textOverflow, cut: l.scrollWidth > l.clientWidth, client: l.clientWidth, scroll: l.scrollWidth, size: c.fontSize, title: b.getAttribute('title'), w: r.width, h: r.height } })
|
||
const ell = {}
|
||
for (const name of ['POLYGON', 'WIPEOUT', 'ELLIPSE', 'LINE']) {
|
||
const b = work.locator('.hm-sw-footer-tool', { hasText: name }).first()
|
||
const rest = await labelInfo(b)
|
||
await b.scrollIntoViewIfNeeded(); await b.hover(); await page.waitForTimeout(120)
|
||
const hover = await labelInfo(b)
|
||
await away()
|
||
ell[name] = { rest, hover }
|
||
}
|
||
report.ellipsis = ell
|
||
check('Footer: label ends in an ellipsis (text-overflow: ellipsis), font stays 14px, slot 68 × 47', Object.values(ell).every(e => e.rest.overflow === 'ellipsis' && e.rest.size === '14px' && e.rest.w === 68 && e.rest.h === 47), ell)
|
||
for (const name of ['POLYGON', 'WIPEOUT']) {
|
||
const e = ell[name]
|
||
check(`Footer: ${name} — ${e.rest.cut ? 'cut → ellipsis, hover title = full name' : 'fits → shown whole'}`, e.rest.cut ? e.hover.title === name : e.hover.title === null || e.hover.title === name, e)
|
||
}
|
||
check('Footer: a command that fits (LINE) gets no automatic tooltip', !ell.LINE.rest.cut && ell.LINE.hover.title === null, ell.LINE)
|
||
check('Footer: POLYGON / WIPEOUT accessible names are the full command names', await page.getByRole('button', { name: 'POLYGON', exact: true }).count() >= 1 && await page.getByRole('button', { name: 'WIPEOUT', exact: true }).count() >= 1)
|
||
check('Footer: ariaLabel still wins (RECTA... = Rectangle)', await work.getByRole('button', { name: 'Rectangle', exact: true }).count() === 1)
|
||
// Keyboard focus on a cut command also exposes the full name
|
||
const wipe = work.locator('.hm-sw-footer-tool', { hasText: 'WIPEOUT' }).first()
|
||
await wipe.focus(); await page.waitForTimeout(60)
|
||
check('Footer: keyboard focus on WIPEOUT exposes the same title', ell.WIPEOUT.rest.cut ? await wipe.getAttribute('title') === 'WIPEOUT' : true, await wipe.getAttribute('title'))
|
||
// An explicit SWFooterTool.title is kept as is (no duplicate tooltip)
|
||
const explicit = await page.evaluate(() => [...document.querySelectorAll('.hm-sw-footer-tool[title]')].map(b => [b.textContent, b.title]))
|
||
report.explicitTitles = explicit
|
||
// click still selects
|
||
await page.keyboard.press('Enter'); await page.waitForTimeout(80)
|
||
check('Footer: Enter on WIPEOUT selects it (aria-pressed)', await wipe.getAttribute('aria-pressed') === 'true')
|
||
await page.keyboard.press('Enter'); await page.waitForTimeout(80)
|
||
await page.screenshot({ path: path.join(out, `footer-${engine}.png`), clip: { x: 0, y: 0, width: 1920, height: 1100 } })
|
||
check('No page errors', errors.length === 0, errors)
|
||
|
||
await writeFile(path.join(out, `hover-stability-${engine}.json`), JSON.stringify({ checks, report }, null, 2))
|
||
const failed = checks.filter(c => !c.ok).length
|
||
console.log(`${checks.length - failed}/${checks.length} PASS (${engine})`)
|
||
await browser.close(); server.close()
|
||
process.exitCode = failed ? 1 : 0
|