Files
HMDesign/packages/sw/tests/integrated-visual.browser.mjs
T

71 lines
5.0 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')
const browser = await chromium.launch({ headless: true, 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.addStyleTag({ content: '*,*::before,*::after{animation-play-state:paused!important;animation-delay:0s!important;transition:none!important;caret-color:transparent!important}' })
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')