// Regression for the SW viewer work (2026-10-19): the existing SW/Header and SW/Popup Stories must render their // headers and popups pixel-identical before (base Storybook) and after (this branch) at several widths. // Element mode compares every .hm-sw-header / .hm-sw-property element; the full page is reported for information // (the 2026-10-19 follow-up changed the Story work-area background to the shared grid canvas on purpose). // Usage: node packages/sw/tests/viewer-regression.browser.mjs import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { runtimeRequire } from './playwright-runtime.mjs' const require = runtimeRequire() const { chromium } = require('playwright') const { PNG } = require('pngjs') const pixelmatch = require('pixelmatch').default ?? require('pixelmatch') const [base, next, outArg, mode] = process.argv.slice(2) // --neutral-canvas: on the new side only, put back the pre-grid Story backgrounds (white work area, page color under // dropdowns), so semi-transparent header / dropdown edge pixels compare against the same backdrop. const neutral = mode === "--neutral-canvas" const out = path.resolve(outArg ?? 'docs/sw/viewer-qa/regression') 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] // 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) await page.waitForSelector('#storybook-root > *') if (restore) await page.addStyleTag({ content: '.sw-overview-stage { background: var(--hm-sem-bg-surface) !important; } .sw-story-scroller--menu { background: none !important; }' }) await page.evaluate(() => document.fonts.ready) await page.waitForTimeout(400) const full = PNG.sync.read(await page.screenshot({ fullPage: true, animations: 'disabled' })) 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() return { full, elements } } const compare = (a, b, out) => { if (a.width !== b.width || a.height !== b.height) return -1 const image = new PNG({ width: a.width, height: a.height }) const diff = pixelmatch(a.data, b.data, image.data, a.width, a.height, { threshold: 0 }) if (diff && out) return { diff, image } return diff } const results = [] for (const id of ids) for (const width of widths) { const url = root => `${root}/iframe.html?id=${id}&viewMode=story` const [a, b] = [await shot(url(base), width), await shot(url(next), width, neutral)] const elementDiffs = a.elements.length === b.elements.length ? a.elements.map((item, index) => compare(item, b.elements[index])) : [-1] const fullDiff = compare(a.full, b.full) const pass = elementDiffs.every(diff => diff === 0) results.push({ id, width, elements: a.elements.length + ' / ' + b.elements.length, elementDiffPixels: elementDiffs, fullPageDiffPixels: fullDiff, result: pass ? 'PASS' : 'FAIL' }) console.log(`${pass ? 'PASS' : 'FAIL'} ${id} @${width} elements ${a.elements.length}/${b.elements.length} diff [${elementDiffs.join(',')}] full-page diff ${fullDiff}`) } await browser.close() await writeFile(path.join(out, neutral ? 'regression-neutral-canvas.json' : 'regression.json'), JSON.stringify({ base, next, results }, null, 2) + '\n') const failed = results.filter(item => item.result !== 'PASS').length console.log(`${results.length - failed}/${results.length} PASS`) process.exit(failed ? 1 : 0)