- 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.
80 lines
6.5 KiB
JavaScript
80 lines
6.5 KiB
JavaScript
// 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')
|