Files
HMDesign/packages/sw/tests/integrated-visual.browser.mjs
shkim4 af7f98d66b 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.
2026-10-21 06:08:31 +09:00

80 lines
6.5 KiB
JavaScript
Raw Permalink 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.
// Visual preservation for the SW integrated improvement (2026-10-19).
// capture: screenshots every existing SW Story (1920) and the Header Stories at 10 widths from storybook-static.
// compare: pixel-diffs two capture directories and writes compare-result.json.
// Usage: node packages/sw/tests/integrated-visual.browser.mjs capture <dir>
// node packages/sw/tests/integrated-visual.browser.mjs compare <before-dir> <after-dir> <out-dir>
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
import { runtimeRequire } from './playwright-runtime.mjs'
const require = runtimeRequire()
const [mode, a, b, c] = process.argv.slice(2)
// Stories that existed before the improvement; IDs are fixed so new Stories never enter the comparison.
export const baselineStories = ['overview', 'detail-buttons', 'interactive-states', 'window-move-motion', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls']
const widthStories = ['overview', 'responsive-more']
const widths = [1920, 1800, 1440, 1180, 1080, 900, 768, 640, 520, 390]
if (mode === 'capture') {
const out = path.resolve(a)
await mkdir(out, { recursive: true })
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)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
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')
// 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))
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
const shots = []
const shoot = async (id, width) => {
await page.setViewportSize({ width, height: 1000 })
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
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 })
shots.push(file)
}
for (const id of baselineStories) await shoot(id, 1920)
for (const id of widthStories) for (const width of widths.slice(1)) await shoot(id, width)
await writeFile(path.join(out, 'capture.json'), JSON.stringify({ browser: browser.version(), shots, errors }, null, 2))
await browser.close(); server.close()
console.log(`captured ${shots.length}, errors ${errors.length}`)
} else if (mode === 'compare') {
const { PNG } = require('pngjs')
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
const before = path.resolve(a), after = path.resolve(b), out = path.resolve(c)
await mkdir(out, { recursive: true })
const results = []
for (const file of (await readdir(before)).filter(name => name.endsWith('.png'))) {
let next
try { next = PNG.sync.read(await readFile(path.join(after, file))) } catch { results.push({ file, status: 'MISSING' }); continue }
const prev = PNG.sync.read(await readFile(path.join(before, file)))
if (prev.width !== next.width || prev.height !== next.height) { results.push({ file, status: 'SIZE', before: [prev.width, prev.height], after: [next.width, next.height] }); continue }
const diff = new PNG({ width: prev.width, height: prev.height })
const changed = pixelmatch(prev.data, next.data, diff.data, prev.width, prev.height, { threshold: 0 })
if (changed) await writeFile(path.join(out, `diff-${file}`), PNG.sync.write(diff))
results.push({ file, status: changed ? 'DIFF' : 'SAME', changed })
}
const summary = { total: results.length, same: results.filter(r => r.status === 'SAME').length, results }
await writeFile(path.join(out, 'compare-result.json'), JSON.stringify(summary, null, 2))
console.log(`${summary.same}/${summary.total} identical`)
for (const r of results.filter(r => r.status !== 'SAME')) console.log(r.status, r.file, r.changed ?? '')
} else throw new Error('mode: capture | compare')