From af7f98d66b4f48a03069f2b06155b4656c09f18e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=88=98=ED=98=84?= Date: Wed, 21 Oct 2026 06:08:31 +0900 Subject: [PATCH] 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. --- .../tests/hover-stability-firefox.browser.mjs | 28 +++++++++++++------ .../sw/tests/integrated-visual.browser.mjs | 11 +++++++- packages/sw/tests/text-weight.browser.mjs | 7 +++-- .../sw/tests/theme-inheritance.browser.mjs | 4 ++- .../sw/tests/viewer-regression.browser.mjs | 13 ++++++++- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/sw/tests/hover-stability-firefox.browser.mjs b/packages/sw/tests/hover-stability-firefox.browser.mjs index 4171bf6..56b820d 100644 --- a/packages/sw/tests/hover-stability-firefox.browser.mjs +++ b/packages/sw/tests/hover-stability-firefox.browser.mjs @@ -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 diff --git a/packages/sw/tests/integrated-visual.browser.mjs b/packages/sw/tests/integrated-visual.browser.mjs index f7a0c05..09c940a 100644 --- a/packages/sw/tests/integrated-visual.browser.mjs +++ b/packages/sw/tests/integrated-visual.browser.mjs @@ -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 }) diff --git a/packages/sw/tests/text-weight.browser.mjs b/packages/sw/tests/text-weight.browser.mjs index db2fc1f..7f44f12 100644 --- a/packages/sw/tests/text-weight.browser.mjs +++ b/packages/sw/tests/text-weight.browser.mjs @@ -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() diff --git a/packages/sw/tests/theme-inheritance.browser.mjs b/packages/sw/tests/theme-inheritance.browser.mjs index 2f14936..81b0faa 100644 --- a/packages/sw/tests/theme-inheritance.browser.mjs +++ b/packages/sw/tests/theme-inheritance.browser.mjs @@ -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}`)) diff --git a/packages/sw/tests/viewer-regression.browser.mjs b/packages/sw/tests/viewer-regression.browser.mjs index 1f19639..1528d1a 100644 --- a/packages/sw/tests/viewer-regression.browser.mjs +++ b/packages/sw/tests/viewer-regression.browser.mjs @@ -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()