test(sw): stabilize Firefox hover, pixel and timing checks
- hover-stability-firefox: instant scroll and state waits instead of a fixed 60ms delay (smooth scroll in preview). - integrated-visual, viewer-regression, theme-inheritance: deterministic Chromium raster flags, animations held at time 0, layout settle wait, integer instant scroll of inner panels; tolerance stays 0. - text-weight: wait for the Viewer More panel instead of a fixed 200ms and fix the failure report.
This commit is contained in:
1 parent
1ffff67991
commit
af7f98d66b
5 files changed
+49
-14
No files matched your search
@@ -60,18 +60,28 @@ const geo = s => JSON.stringify([s.box[2], s.box[3], s.icon && [rd(s.icon[0] - s
|
||||
async function cycleAll(sel, q, tag) {
|
||||
const n = await ev(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.getClientRects().length).length`)
|
||||
const results = []
|
||||
// 2026-10-20: state-based waits replace the fixed 60–100ms sleeps (Firefox sometimes read a tile before :hover or its
|
||||
// transition applied, so a tile looked unhovered or a read hit the wrong index). Each read waits until the item's
|
||||
// :hover / :active state is the expected one and its transitions ended; a wait that times out is recorded and fails.
|
||||
// The preview sets html { scroll-behavior: smooth } (src/hm-theme.css): scroll instantly, or the item is measured mid-scroll
|
||||
// and the pointer lands on another tile (the former "저장" hover / index misses).
|
||||
// After a release outside the item Firefox keeps :active on it until the next press, so that last wait checks :hover only.
|
||||
const settle = (i, hover, active = false) => ev(`(async () => { const el = [...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.getClientRects().length)[${i}]
|
||||
const frame = () => new Promise(r => requestAnimationFrame(() => r()))
|
||||
for (let t = 0; t < 120; t++) { await frame(); if (el.matches(':hover') === ${hover} && (${active === null} || el.matches(':active') === ${active}) && el.getAnimations({ subtree: true }).every(a => a.playState !== 'running')) { await frame(); return true } }
|
||||
return false })()`)
|
||||
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)
|
||||
await ev(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.getClientRects().length)[${i}].scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }), true`)
|
||||
await move(1, 1); const waits = [await settle(i, false)]
|
||||
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 })
|
||||
await move(x, y); waits.push(await settle(i, true)); const h1 = await snap(sel, i, q)
|
||||
await move(1, 1); waits.push(await settle(i, false)); const d1 = await snap(sel, i, q)
|
||||
await move(x, y); waits.push(await settle(i, true)); const h2 = await snap(sel, i, q)
|
||||
await pointer([{ type: 'pointerDown', button: 0 }]); waits.push(await settle(i, true, true)); const a = await snap(sel, i, q)
|
||||
await pointer([{ type: 'pointerMove', x: 1, y: 1, duration: 0 }, { type: 'pointerUp', button: 0 }]); waits.push(await settle(i, false, null))
|
||||
const same = [h1, d1, h2, a].every(s => geo(s) === geo(d0)) && waits.every(Boolean)
|
||||
results.push({ name: d0.name, same, hovered: d0.active || h1.color !== d0.color, d0, h1, a, waits })
|
||||
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
|
||||
|
||||
@@ -24,7 +24,10 @@ if (mode === 'capture') {
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
const { chromium } = require('playwright')
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
// 2026-10-20: software raster without font hinting / subpixel positioning. Without these flags two captures of the same
|
||||
// build differed in text anti-aliasing (Δ1 paragraphs, 2 × 2 glyph spots); with them four captures were identical. The
|
||||
// zero threshold of `compare` is unchanged, and a 1px layout change still shows (25 of 28 shots).
|
||||
const browser = await chromium.launch({ headless: true, args: ['--disable-gpu', '--font-render-hinting=none', '--disable-font-subpixel-positioning', '--disable-lcd-text', '--force-color-profile=srgb'], executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 }, reducedMotion: 'reduce' })
|
||||
const errors = []
|
||||
page.on('pageerror', e => errors.push(e.message))
|
||||
@@ -36,7 +39,13 @@ if (mode === 'capture') {
|
||||
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.mouse.move(1, 1)
|
||||
await page.evaluate(() => { window.__swRunning = document.getAnimations().filter(a => a.playState === 'running') })
|
||||
await page.addStyleTag({ content: '*,*::before,*::after{animation-play-state:paused!important;animation-delay:0s!important;transition:none!important;caret-color:transparent!important}' })
|
||||
// 2026-10-20: the paused animations above stop at whatever time they had reached, so two captures of the same build
|
||||
// differed (Window Move Motion). Animations that were running before the pause are set to time 0; specimens the Story paused itself keep
|
||||
// their own time. Then wait until the layout holds for three frames (measured Stories such as Responsive More).
|
||||
await page.evaluate(() => { for (const a of window.__swRunning) { a.pause(); a.currentTime = 0 } })
|
||||
await page.evaluate(() => new Promise(resolve => { let last = '', stable = 0; const tick = () => { const now = [document.documentElement.scrollHeight, ...[...document.querySelectorAll('.hm-sw-header')].map(h => h.getBoundingClientRect().width)].join(); stable = now === last ? stable + 1 : 0; last = now; stable >= 3 ? resolve() : requestAnimationFrame(tick) }; requestAnimationFrame(tick) }))
|
||||
await page.waitForTimeout(150)
|
||||
const file = `${id}-${width}.png`
|
||||
await page.screenshot({ path: path.join(out, file), fullPage: true })
|
||||
|
||||
@@ -14,7 +14,7 @@ const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.
|
||||
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 = await 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, 500)) }
|
||||
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : String(JSON.stringify(d)).slice(0, 500)) }
|
||||
const report = {}
|
||||
let errors = []
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1100 } })
|
||||
@@ -125,8 +125,11 @@ for (const t of ['파일 열기', '레이어']) {
|
||||
const tab = vh.locator('.hm-sw-viewer-header__tab', { hasText: 'Layout' }).first()
|
||||
await stableOnHover('Viewer Layout tab', tab, tab)
|
||||
// More stays: narrow header shows the More button, it opens and lists the moved items.
|
||||
await vh.evaluate(e => { e.style.width = '700px' }); await page.waitForTimeout(200)
|
||||
await vh.evaluate(e => { e.style.width = '700px' })
|
||||
const more = vh.getByRole('button', { name: /추가 도구/ })
|
||||
// 2026-10-20: the header re-measures through a ResizeObserver; wait for More instead of a fixed 200ms (the check below
|
||||
// still fails when it never appears). The fixed wait failed intermittently on the same build.
|
||||
await more.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {})
|
||||
check('Viewer: More button shown at a narrow width', await more.count() === 1 && await more.isVisible())
|
||||
await more.click(); await page.waitForTimeout(200)
|
||||
const moreItems = await page.locator('.hm-sw-viewer-menu [role="menuitem"], .hm-sw-viewer-menu [role="menuitemradio"], .hm-sw-viewer-menu [role="menuitemcheckbox"]').count()
|
||||
|
||||
@@ -27,7 +27,9 @@ const DEFAULT = '"Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", sans-se
|
||||
const results = []
|
||||
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
|
||||
const errors = []
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
// 2026-10-20: software raster without font hinting / subpixel positioning, as integrated-visual / viewer-regression: the
|
||||
// regression pass failed on varying Stories (Home Menu, Footer, Responsive More) on the same build, before and after the change.
|
||||
const browser = await chromium.launch({ headless: true, args: ['--disable-gpu', '--font-render-hinting=none', '--disable-font-subpixel-positioning', '--disable-lcd-text', '--force-color-profile=srgb'], executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const newPage = async (width = 1440, height = 1000) => {
|
||||
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
|
||||
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
|
||||
|
||||
@@ -20,7 +20,9 @@ await mkdir(out, { recursive: true })
|
||||
const ids = ['overview', 'interactive-states', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls', 'document-open', 'hidden-sub-controls', 'detail-buttons'].map(id => `sw-header--${id}`)
|
||||
.concat(['sw-popup--custom-content', 'sw-popup--multiple-popups'])
|
||||
const widths = [1920, 1180, 768]
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
// 2026-10-20: software raster without font hinting / subpixel positioning, as integrated-visual.browser.mjs: two
|
||||
// captures of the same build otherwise differed in text anti-aliasing (Δ1 areas, 2 × 2 glyph spots). Threshold stays 0.
|
||||
const browser = await chromium.launch({ headless: true, args: ['--disable-gpu', '--font-render-hinting=none', '--disable-font-subpixel-positioning', '--disable-lcd-text', '--force-color-profile=srgb'], executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const shot = async (url, width, restore = false) => {
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' })
|
||||
await page.goto(url)
|
||||
@@ -32,6 +34,15 @@ const shot = async (url, width, restore = false) => {
|
||||
const elements = []
|
||||
for (const element of await page.locator('.hm-sw-header, .hm-sw-property').all()) {
|
||||
if (!(await element.isVisible())) continue
|
||||
// 2026-10-20: panels inside an inner scroller (Property Panel · 4개 비교) were scrolled into view at varying offsets, so
|
||||
// two captures of the same build differed by 3–7px. Scroll instantly, keep every scroll offset whole, wait for stable frames.
|
||||
await element.evaluate(e => new Promise(resolve => {
|
||||
e.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' })
|
||||
for (let n = e.parentElement; n; n = n.parentElement) { n.scrollTop = Math.round(n.scrollTop); n.scrollLeft = Math.round(n.scrollLeft) }
|
||||
let last = '', stable = 0
|
||||
const tick = () => { const r = e.getBoundingClientRect(), now = [r.x, r.y, r.width, r.height].join(); stable = now === last ? stable + 1 : 0; last = now; stable >= 3 ? resolve() : requestAnimationFrame(tick) }
|
||||
requestAnimationFrame(tick)
|
||||
}))
|
||||
elements.push(PNG.sync.read(await element.screenshot({ animations: 'disabled' })))
|
||||
}
|
||||
await page.close()
|
||||
|
||||
Reference in new issue
Block a user