Files
HMDesign/packages/sw/tests/hover-stability.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

240 lines
19 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 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: SWHeader field values are Noto Sans KR 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: Noto Sans KR 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 Noto Sans KR 400 / 14px / 0.56px', fields.length >= 6 && fields.every(f => f.weight === '400' && f.size === '14px' && f.spacing === '0.56px' && /noto sans kr/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