239 lines
18 KiB
JavaScript
239 lines
18 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: 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 { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||
import path from 'node:path'
|
||
import http from 'node:http'
|
||
import { runtimeRequire } from './playwright-runtime.mjs'
|
||
const require = runtimeRequire()
|
||
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
|