// 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 // node packages/sw/tests/integrated-visual.browser.mjs compare 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')