scripts/visual-diff.ts
Rama main · réplica de lectura del repositorio original.
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import net from 'node:net'
import { dirname, resolve } from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import type { Page } from '@playwright/test'
import { chromium } from '@playwright/test'
import { createClient } from '@supabase/supabase-js'
import pixelmatch from 'pixelmatch'
import { PNG } from 'pngjs'
import type { VisualDiffRoute } from '../playwright/visual-diff.config'
import { dismissSupportPrompt } from '../playwright/support/dismissSupportPrompt'
import { getSupabaseStatus } from './supabase-worktree-status'
import { getPlaywrightStripeApiBaseUrl, getStripeEmulatorPort } from './playwright-stripe'
import { getSupabaseWorktreeConfig } from './supabase-worktree-config'
type Phase = 'before' | 'after'
type Command = 'capture' | 'capture-route' | 'diff' | 'run'
interface ManagedProcess {
child: ChildProcessWithoutNullStreams
name: string
}
interface DiffResult {
slug: string
changed: boolean
diffPixels: number
totalPixels: number
diffPercent: number
beforePath: string
afterPath: string
diffPath: string
}
interface CliOptions {
command: Command
phase?: Phase
baseRef?: string
headRef?: string
thresholdPercent: number
routes: string[]
skipGitCheckout: boolean
}
const repoRoot = process.cwd()
const defaultVisualDiffConfigPath = process.env.VISUAL_DIFF_CONFIG_PATH || resolve(repoRoot, 'playwright/visual-diff.config.ts')
interface LoadedVisualDiffConfig {
visualDiffRoutes: VisualDiffRoute[]
visualDiffViewport: { width: number, height: number }
}
async function loadVisualDiffConfig(configPath = defaultVisualDiffConfigPath): Promise<LoadedVisualDiffConfig> {
const module = await import(pathToFileURL(configPath).href)
return {
visualDiffRoutes: module.visualDiffRoutes as VisualDiffRoute[],
visualDiffViewport: module.visualDiffViewport as LoadedVisualDiffConfig['visualDiffViewport'],
}
}
const outputRoot = resolve(repoRoot, '.context/visual-diff')
const logRoot = resolve(outputRoot, 'logs')
const frontendBaseUrl = process.env.VISUAL_DIFF_BASE_URL || 'http://127.0.0.1:5173'
const supabaseConfig = getSupabaseWorktreeConfig(repoRoot)
const localSupabaseUrl = `http://127.0.0.1:${supabaseConfig.ports.api}`
const localApiDomain = `127.0.0.1:${supabaseConfig.ports.api}/functions/v1`
const stripePort = getStripeEmulatorPort(process.env)
const stripeApiBaseUrl = getPlaywrightStripeApiBaseUrl(process.env)
const managedProcesses: ManagedProcess[] = []
const isWindows = process.platform === 'win32'
const httpReadyTimeoutMs = 5_000
const visualDiffActionTimeoutMs = 60_000
const visualDiffRouteTimeoutMs = 180_000
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function parseThreshold(raw: string, source: string): number {
const value = Number(raw)
if (!Number.isFinite(value) || value < 0 || value > 100)
throw new Error(`${source} must be a number between 0 and 100, received "${raw}"`)
return value
}
function parseArgs(argv: string[]): CliOptions {
const [commandRaw, ...rest] = argv
const command = (commandRaw || 'run') as Command
if (!['capture', 'capture-route', 'diff', 'run'].includes(command)) {
throw new Error(`Unknown command "${commandRaw}". Use capture, capture-route, diff, or run.`)
}
let phase: Phase | undefined
let baseRef = process.env.VISUAL_DIFF_BASE_REF
let headRef = process.env.VISUAL_DIFF_HEAD_REF
let thresholdPercent = parseThreshold(process.env.VISUAL_DIFF_THRESHOLD_PERCENT || '0.1', 'VISUAL_DIFF_THRESHOLD_PERCENT')
let routes: string[] = []
let skipGitCheckout = process.env.VISUAL_DIFF_SKIP_GIT === 'true'
function requireArg(flag: string, value: string | undefined) {
if (!value || value.startsWith('--'))
throw new Error(`${flag} requires a value`)
return value
}
for (let index = 0; index < rest.length; index++) {
const arg = rest[index]
if (arg === '--phase') {
phase = requireArg('--phase', rest[++index]) as Phase
continue
}
if (arg === '--base') {
baseRef = requireArg('--base', rest[++index])
continue
}
if (arg === '--head') {
headRef = requireArg('--head', rest[++index])
continue
}
if (arg === '--threshold') {
const rawThreshold = requireArg('--threshold', rest[++index])
thresholdPercent = parseThreshold(rawThreshold, '--threshold')
continue
}
if (arg === '--routes') {
routes = requireArg('--routes', rest[++index]).split(',').map(route => route.trim()).filter(Boolean)
continue
}
if (arg === '--skip-git') {
skipGitCheckout = true
continue
}
throw new Error(`Unknown argument "${arg}"`)
}
if ((command === 'capture' || command === 'capture-route') && phase !== 'before' && phase !== 'after') {
throw new Error(`${command} requires --phase before or --phase after`)
}
if (command === 'capture-route' && routes.length !== 1)
throw new Error('capture-route requires exactly one --routes slug')
return {
command,
phase,
baseRef,
headRef,
thresholdPercent,
routes,
skipGitCheckout,
}
}
function selectedRoutes(routes: VisualDiffRoute[], routeFilter: string[]) {
if (routeFilter.length === 0)
return routes
const known = new Set(routes.map(route => route.slug))
for (const slug of routeFilter) {
if (!known.has(slug))
throw new Error(`Unknown visual diff route "${slug}"`)
}
return routes.filter(route => routeFilter.includes(route.slug))
}
function phaseDir(phase: Phase) {
return resolve(outputRoot, phase)
}
function reportDir() {
return resolve(outputRoot, 'report')
}
function diffDir() {
return resolve(outputRoot, 'diff')
}
async function isHttpReady(url: string, options: { requireSuccess?: boolean } = {}): Promise<boolean> {
const controller = new AbortController()
let timeout: ReturnType<typeof setTimeout> | undefined
try {
const response = await Promise.race([
fetch(url, { signal: controller.signal }),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
controller.abort()
reject(new Error(`Timed out waiting for ${url}`))
}, httpReadyTimeoutMs)
}),
])
return options.requireSuccess ? response.ok : response.status < 500
}
catch {
return false
}
finally {
if (timeout)
clearTimeout(timeout)
}
}
function isTcpReady(port: number, host = '127.0.0.1'): Promise<boolean> {
return new Promise((resolveReady) => {
const socket = net.connect({ port, host })
socket.setTimeout(750)
socket.once('connect', () => {
socket.destroy()
resolveReady(true)
})
socket.once('timeout', () => {
socket.destroy()
resolveReady(false)
})
socket.once('error', () => {
socket.destroy()
resolveReady(false)
})
})
}
async function waitForHttp(url: string, timeoutMs: number, label: string) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isHttpReady(url))
return
await sleep(1000)
}
throw new Error(`Timed out waiting for ${label} at ${url}`)
}
async function waitForTcp(port: number, timeoutMs: number, label: string) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isTcpReady(port))
return
await sleep(500)
}
throw new Error(`Timed out waiting for ${label} on port ${port}`)
}
async function waitForFile(path: string, timeoutMs: number, label: string) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (existsSync(path))
return
await sleep(1000)
}
throw new Error(`Timed out waiting for ${label} at ${path}`)
}
function startProcess(name: string, args: string[], env: NodeJS.ProcessEnv = {}): ManagedProcess {
mkdirSync(logRoot, { recursive: true })
const logPath = resolve(logRoot, `${name}.log`)
const logStream = createWriteStream(logPath, { flags: 'a' })
const child = spawn(args[0], args.slice(1), {
cwd: repoRoot,
env: { ...process.env, ...env },
detached: !isWindows,
})
child.stdout.pipe(logStream, { end: false })
child.stderr.pipe(logStream, { end: false })
child.on('close', () => {
logStream.end()
})
const managed = { child, name }
managedProcesses.push(managed)
return managed
}
async function stopProcess(processToStop: ManagedProcess) {
const hasExited = () => processToStop.child.exitCode !== null || processToStop.child.signalCode !== null
if (hasExited())
return
const waitForExit = async (timeoutMs: number) => {
if (hasExited())
return true
return Promise.race([
new Promise<boolean>((resolveExit) => {
processToStop.child.once('exit', () => resolveExit(true))
}),
(async () => {
await sleep(timeoutMs)
return hasExited()
})(),
])
}
if (processToStop.child.pid) {
if (isWindows) {
spawnSync('taskkill', ['/PID', String(processToStop.child.pid), '/T'], { stdio: 'ignore' })
}
else {
try {
process.kill(-processToStop.child.pid, 'SIGTERM')
}
catch {
// ignore
}
spawnSync('pkill', ['-TERM', '-P', String(processToStop.child.pid)], { stdio: 'ignore' })
}
}
processToStop.child.kill('SIGTERM')
if (await waitForExit(5000))
return
if (processToStop.child.pid && !isWindows) {
try {
process.kill(-processToStop.child.pid, 'SIGKILL')
}
catch {
// ignore
}
spawnSync('pkill', ['-KILL', '-P', String(processToStop.child.pid)], { stdio: 'ignore' })
}
processToStop.child.kill('SIGKILL')
await waitForExit(2000)
}
function waitForManagedProcess(managed: ManagedProcess, timeoutMs: number, label: string) {
const { child } = managed
if (child.exitCode !== null || child.signalCode !== null) {
if (child.exitCode === 0)
return Promise.resolve()
return Promise.reject(new Error(`${label} exited before it completed`))
}
return new Promise<void>((resolveProcess, rejectProcess) => {
let settled = false
let timeout: ReturnType<typeof setTimeout> | undefined
const finish = (callback: () => void) => {
if (settled)
return
settled = true
if (timeout)
clearTimeout(timeout)
callback()
}
const onError = (error: Error) => finish(() => rejectProcess(error))
const onClose = (code: number | null, signal: NodeJS.Signals | null) => finish(() => {
if (code === 0) {
resolveProcess()
return
}
rejectProcess(new Error(`${label} exited with code ${code ?? 'none'}${signal ? ` (${signal})` : ''}`))
})
timeout = setTimeout(() => {
finish(() => rejectProcess(new Error(`Timed out ${label} after ${timeoutMs}ms`)))
}, timeoutMs)
child.once('error', onError)
child.once('close', onClose)
})
}
async function captureRouteInChild(phase: Phase, route: VisualDiffRoute, configPath: string, runnerPath: string) {
const captureProcess = startProcess(
`capture-${phase}-${route.slug}`,
[process.execPath, runnerPath, 'capture-route', '--phase', phase, '--routes', route.slug],
// Lets a route's prepare() require head-only UI on the after capture.
{ VISUAL_DIFF_CONFIG_PATH: configPath, VISUAL_DIFF_PHASE: phase },
)
try {
await waitForManagedProcess(captureProcess, visualDiffRouteTimeoutMs, `capturing ${phase} ${route.slug}`)
}
catch (error) {
await stopProcess(captureProcess)
throw error
}
finally {
const index = managedProcesses.indexOf(captureProcess)
if (index >= 0)
managedProcesses.splice(index, 1)
}
}
async function stopAllProcesses() {
for (const managed of [...managedProcesses].reverse())
await stopProcess(managed)
managedProcesses.length = 0
}
function git(args: string[]) {
const result = spawnSync('git', args, {
cwd: repoRoot,
encoding: 'utf8',
env: process.env,
})
if ((result.status ?? 1) !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`)
}
return (result.stdout || '').trim()
}
function resolveGitRef(ref: string | undefined, fallback: string) {
if (!ref)
return fallback
return git(['rev-parse', ref])
}
async function stopBackendStack() {
for (let index = managedProcesses.length - 1; index >= 0; index--) {
if (managedProcesses[index].name !== 'backend-playwright')
continue
await stopProcess(managedProcesses[index])
managedProcesses.splice(index, 1)
}
rmSync(resolve(outputRoot, 'backend.ready'), { force: true })
if (!isWindows)
spawnSync('pkill', ['-f', 'supabase-functions.playwright.env'], { stdio: 'ignore' })
}
async function ensureBackendStack(options: { force?: boolean } = {}) {
if (!(await isTcpReady(stripePort))) {
startProcess('stripe-emulator', ['bun', 'run', 'stripe:emulator'], {
STRIPE_EMULATOR_PORT: String(stripePort),
})
await waitForTcp(stripePort, 60_000, 'Stripe emulator')
}
const backendOkUrl = `${localSupabaseUrl}/functions/v1/ok`
if (options.force || !(await isHttpReady(backendOkUrl, { requireSuccess: true }))) {
await stopBackendStack()
const readyFile = resolve(outputRoot, 'backend.ready')
rmSync(readyFile, { force: true })
startProcess('backend-playwright', ['bun', 'run', 'backend:playwright'], {
ENV: 'local',
STRIPE_SECRET_KEY: 'sk_test_emulator',
STRIPE_API_BASE_URL: stripeApiBaseUrl,
STRIPE_WEBHOOK_SECRET: 'testsecret',
WEBAPP_URL: frontendBaseUrl,
PLAYWRIGHT_READY_FILE: readyFile,
})
await waitForFile(readyFile, 360_000, 'Supabase functions')
}
const status = getSupabaseStatus()
const supabaseUrl = status?.API_URL || localSupabaseUrl
const supabaseAnon = process.env.SUPABASE_ANON_KEY || status?.ANON_KEY || status?.PUBLISHABLE_KEY
if (!supabaseAnon)
throw new Error('Unable to resolve the local Supabase anon key')
return {
supabaseUrl,
supabaseAnon,
apiDomain: supabaseUrl.replace(/^https?:\/\//, '').replace(/\/$/, '') + '/functions/v1',
}
}
async function stopFrontendStack() {
for (let index = managedProcesses.length - 1; index >= 0; index--) {
if (managedProcesses[index].name !== 'frontend-preview')
continue
await stopProcess(managedProcesses[index])
managedProcesses.splice(index, 1)
}
if (!isWindows)
spawnSync('pkill', ['-f', 'vite preview --host 127.0.0.1 --port 5173'], { stdio: 'ignore' })
}
async function ensureFrontendStack(
supabaseUrl: string,
supabaseAnon: string,
apiDomain: string,
options: { force?: boolean } = {},
) {
if (!options.force && await isHttpReady(`${frontendBaseUrl}/login/`))
return
await stopFrontendStack()
const build = spawnSync('bun', ['run', 'build'], {
cwd: repoRoot,
stdio: 'inherit',
env: {
...process.env,
ENV: 'local',
SUPA_URL: supabaseUrl,
SUPA_ANON: supabaseAnon,
API_DOMAIN: apiDomain || localApiDomain,
CAPTCHA_KEY: '',
},
})
if ((build.status ?? 1) !== 0)
throw new Error('Frontend build failed')
startProcess('frontend-preview', ['bunx', 'vite', 'preview', '--host', '127.0.0.1', '--port', '5173'], {
ENV: 'local',
SUPA_URL: supabaseUrl,
SUPA_ANON: supabaseAnon,
API_DOMAIN: apiDomain || localApiDomain,
CAPTCHA_KEY: '',
})
await waitForHttp(`${frontendBaseUrl}/login/`, 180_000, 'frontend preview')
}
async function prepareScreenshotData(supabaseUrl: string, supabaseAnon: string) {
const supabase = createClient(supabaseUrl, supabaseAnon, {
auth: {
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false,
},
})
const { error: signInError } = await supabase.auth.signInWithPassword({
email: 'test@capgo.app',
password: 'testtest',
})
if (signInError)
throw new Error(`Unable to sign in for visual diff setup: ${signInError.message}`)
}
async function login(page: Page) {
await page.goto('/login/', { waitUntil: 'domcontentloaded' })
await page.fill('[data-test="email"]', 'test@capgo.app')
const continueButton = page.locator('[data-test="continue"]')
const submit = page.locator('[data-test="submit"]')
if (await continueButton.count())
await continueButton.click()
await submit.waitFor({ state: 'visible', timeout: 30_000 })
await page.fill('[data-test="password"]', 'testtest')
if (await submit.isEnabled())
await submit.click()
else
await page.keyboard.press('Enter')
await page.waitForURL(/\/(apps|dashboard)(\/|$)/, { timeout: 60_000 })
}
async function settlePage(page: Page) {
await page.waitForLoadState('domcontentloaded')
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {})
await page.waitForTimeout(1200)
await page.evaluate(() => {
document.documentElement.style.caretColor = 'transparent'
})
}
async function captureRouteScreenshot(
phase: Phase,
route: VisualDiffRoute,
visualDiffViewport: LoadedVisualDiffConfig['visualDiffViewport'],
) {
const targetDir = phaseDir(phase)
mkdirSync(targetDir, { recursive: true })
console.log(`[visual-diff] launching browser for ${phase} ${route.slug}`)
const browser = await chromium.launch({ headless: true, timeout: visualDiffActionTimeoutMs })
try {
console.log(`[visual-diff] creating browser context for ${phase} ${route.slug}`)
const context = await browser.newContext({
baseURL: frontendBaseUrl,
viewport: visualDiffViewport,
deviceScaleFactor: 1,
})
const page = await context.newPage()
page.setDefaultTimeout(visualDiffActionTimeoutMs)
page.setDefaultNavigationTimeout(visualDiffActionTimeoutMs)
if (route.auth) {
console.log(`[visual-diff] logging in for ${phase} ${route.slug}`)
await login(page)
console.log(`[visual-diff] logged in for ${phase} ${route.slug}`)
}
console.log(`[visual-diff] navigating ${phase} ${route.slug}`)
await page.goto(route.path, { waitUntil: 'domcontentloaded', timeout: visualDiffActionTimeoutMs })
console.log(`[visual-diff] settling ${phase} ${route.slug}`)
await settlePage(page)
if (route.auth)
await dismissSupportPrompt(page)
if (route.prepare) {
console.log(`[visual-diff] preparing ${phase} ${route.slug}`)
await route.prepare(page)
await settlePage(page)
}
const outputPath = resolve(targetDir, `${route.slug}.png`)
console.log(`[visual-diff] screenshotting ${phase} ${route.slug}`)
await page.screenshot({ path: outputPath, fullPage: false, timeout: visualDiffActionTimeoutMs })
console.log(`[visual-diff] captured ${phase} ${route.slug} -> ${outputPath}`)
await context.close()
}
finally {
await browser.close()
}
}
async function captureScreenshots(
phase: Phase,
routeFilter: string[],
options: { forceFrontend?: boolean, forceBackend?: boolean } = {},
config?: LoadedVisualDiffConfig,
configPath = defaultVisualDiffConfigPath,
runnerPath = resolve(repoRoot, 'scripts/visual-diff.ts'),
) {
const { visualDiffRoutes } = config ?? await loadVisualDiffConfig(configPath)
const routes = selectedRoutes(visualDiffRoutes, routeFilter)
mkdirSync(phaseDir(phase), { recursive: true })
console.log(`[visual-diff] preparing ${phase} capture stack`)
const { supabaseUrl, supabaseAnon, apiDomain } = await ensureBackendStack({ force: options.forceBackend })
await prepareScreenshotData(supabaseUrl, supabaseAnon)
await ensureFrontendStack(supabaseUrl, supabaseAnon, apiDomain, { force: options.forceFrontend })
console.log(`[visual-diff] ${phase} frontend ready`)
for (const route of routes) {
console.log(`[visual-diff] capturing ${phase} ${route.slug} in an isolated browser`)
await captureRouteInChild(phase, route, configPath, runnerPath)
console.log(`[visual-diff] captured ${phase} ${route.slug}`)
}
}
function readPng(path: string) {
return PNG.sync.read(readFileSync(path))
}
function writeDiffImage(beforePath: string, afterPath: string, diffPath: string) {
const before = readPng(beforePath)
const after = readPng(afterPath)
if (before.width !== after.width || before.height !== after.height) {
const width = Math.max(before.width, after.width)
const height = Math.max(before.height, after.height)
const diff = new PNG({ width, height })
const mismatchPixels = width * height
mkdirSync(dirname(diffPath), { recursive: true })
writeFileSync(diffPath, PNG.sync.write(diff))
return {
diffPixels: mismatchPixels,
totalPixels: mismatchPixels,
diffPercent: 100,
sizeMismatch: true,
}
}
const { width, height } = before
const diff = new PNG({ width, height })
const diffPixels = pixelmatch(before.data, after.data, diff.data, width, height, {
threshold: 0.1,
includeAA: true,
})
mkdirSync(dirname(diffPath), { recursive: true })
writeFileSync(diffPath, PNG.sync.write(diff))
const totalPixels = width * height
return {
diffPixels,
totalPixels,
diffPercent: totalPixels === 0 ? 0 : (diffPixels / totalPixels) * 100,
sizeMismatch: false,
}
}
async function compareScreenshots(
thresholdPercent: number,
routeFilter: string[],
config?: LoadedVisualDiffConfig,
): Promise<DiffResult[]> {
const beforeRoot = phaseDir('before')
const afterRoot = phaseDir('after')
const { visualDiffRoutes } = config ?? await loadVisualDiffConfig()
const routes = selectedRoutes(visualDiffRoutes, routeFilter)
const results: DiffResult[] = []
for (const route of routes) {
const beforePath = resolve(beforeRoot, `${route.slug}.png`)
const afterPath = resolve(afterRoot, `${route.slug}.png`)
const diffPath = resolve(diffDir(), `${route.slug}-diff.png`)
if (!existsSync(beforePath) || !existsSync(afterPath)) {
if (routeFilter.length > 0) {
throw new Error(`Missing screenshot pair for ${route.slug}. Capture before and after first.`)
}
console.warn(`[visual-diff] skipping ${route.slug}: missing before/after screenshot pair`)
continue
}
const comparison = writeDiffImage(beforePath, afterPath, diffPath)
results.push({
slug: route.slug,
changed: comparison.diffPercent > thresholdPercent || comparison.sizeMismatch,
diffPixels: comparison.diffPixels,
totalPixels: comparison.totalPixels,
diffPercent: comparison.diffPercent,
beforePath,
afterPath,
diffPath,
})
}
if (results.length === 0 && routes.length > 0) {
throw new Error(`No screenshot pairs available to compare for ${routes.length} configured route(s). Capture before and after first.`)
}
return results
}
function generateReport(results: DiffResult[], thresholdPercent: number) {
const outDir = reportDir()
rmSync(outDir, { recursive: true, force: true })
mkdirSync(outDir, { recursive: true })
for (const result of results) {
const beforeDest = resolve(outDir, 'before', `${result.slug}.png`)
const afterDest = resolve(outDir, 'after', `${result.slug}.png`)
const diffDest = resolve(outDir, 'diff', `${result.slug}-diff.png`)
mkdirSync(dirname(beforeDest), { recursive: true })
mkdirSync(dirname(afterDest), { recursive: true })
mkdirSync(dirname(diffDest), { recursive: true })
writeFileSync(beforeDest, readFileSync(result.beforePath))
writeFileSync(afterDest, readFileSync(result.afterPath))
writeFileSync(diffDest, readFileSync(result.diffPath))
}
const changed = results.filter(result => result.changed)
const unchanged = results.filter(result => !result.changed)
const summary = {
generatedAt: new Date().toISOString(),
thresholdPercent,
changedCount: changed.length,
unchangedCount: unchanged.length,
routes: results,
}
writeFileSync(resolve(outDir, 'summary.json'), JSON.stringify(summary, null, 2))
const summaryMarkdown = [
'## Visual changes',
'',
`Generated at ${summary.generatedAt}. Threshold: ${thresholdPercent}% pixel difference.`,
]
if (changed.length === 0) {
summaryMarkdown.push('', 'No visual differences detected for the configured routes.')
}
else {
summaryMarkdown.push('', '| Route | Diff % | Status |', '| --- | ---: | --- |')
for (const result of results) {
summaryMarkdown.push(
`| ${result.slug} | ${result.diffPercent.toFixed(3)} | ${result.changed ? 'changed' : 'unchanged'} |`,
)
}
}
writeFileSync(resolve(outDir, 'summary.md'), `${summaryMarkdown.join('\n')}\n`)
const htmlRows = results.map((result) => {
const before = `before/${result.slug}.png`
const after = `after/${result.slug}.png`
const diff = `diff/${result.slug}-diff.png`
return `
<section class="route ${result.changed ? 'changed' : 'unchanged'}">
<h2>${result.slug} <span>${result.diffPercent.toFixed(3)}%</span></h2>
<div class="grid">
<figure><figcaption>Before</figcaption><img src="${before}" alt="${result.slug} before"></figure>
<figure><figcaption>After</figcaption><img src="${after}" alt="${result.slug} after"></figure>
<figure><figcaption>Diff</figcaption><img src="${diff}" alt="${result.slug} diff"></figure>
</div>
</section>
`
}).join('\n')
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Capgo visual diff report</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; background: #0f172a; color: #e2e8f0; }
h1 { margin-bottom: 8px; }
.meta { color: #94a3b8; margin-bottom: 24px; }
.route { margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid #334155; }
.route.changed h2 span { color: #f87171; }
.route.unchanged h2 span { color: #4ade80; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
figure { margin: 0; }
figcaption { margin-bottom: 8px; color: #94a3b8; }
img { width: 100%; border: 1px solid #334155; border-radius: 8px; background: #111827; }
</style>
</head>
<body>
<h1>Capgo visual diff</h1>
<p class="meta">Changed routes: ${changed.length} · Unchanged routes: ${unchanged.length} · Threshold: ${thresholdPercent}%</p>
${htmlRows}
</body>
</html>
`
writeFileSync(resolve(outDir, 'index.html'), html)
console.log(`[visual-diff] report written to ${outDir}`)
return summary
}
function bundleVisualDiffModule(sourcePath: string, snapshotPath: string) {
const build = spawnSync('bun', [
'build',
sourcePath,
'--bundle',
'--target=bun',
'--packages=external',
`--outfile=${snapshotPath}`,
], {
cwd: repoRoot,
stdio: 'inherit',
env: process.env,
})
if ((build.status ?? 1) !== 0)
throw new Error(`Failed to bundle visual diff module ${sourcePath}`)
}
function snapshotVisualDiffConfig(snapshotPath: string) {
bundleVisualDiffModule(defaultVisualDiffConfigPath, snapshotPath)
}
function snapshotVisualDiffRunner(snapshotPath: string) {
bundleVisualDiffModule(resolve(repoRoot, 'scripts/visual-diff.ts'), snapshotPath)
}
async function checkoutRef(ref: string) {
git(['checkout', '--force', ref])
const install = spawnSync('bun', ['install'], { cwd: repoRoot, stdio: 'inherit', env: process.env })
if ((install.status ?? 1) !== 0)
throw new Error(`bun install failed after checkout ${ref}`)
}
async function runPipeline(options: CliOptions) {
const originalRef = git(['rev-parse', 'HEAD'])
const baseSha = resolveGitRef(options.baseRef, git(['merge-base', 'HEAD', 'origin/main']))
const headSha = resolveGitRef(options.headRef, originalRef)
const visualDiffConfigPath = resolve(outputRoot, `visual-diff-config-${process.pid}.mjs`)
const visualDiffRunnerPath = resolve(outputRoot, `visual-diff-runner-${process.pid}.mjs`)
try {
if (!options.skipGitCheckout && originalRef !== headSha)
await checkoutRef(headSha)
snapshotVisualDiffConfig(visualDiffConfigPath)
const visualDiffConfig = await loadVisualDiffConfig(visualDiffConfigPath)
snapshotVisualDiffRunner(visualDiffRunnerPath)
if (!options.skipGitCheckout) {
await stopFrontendStack()
await stopBackendStack()
await checkoutRef(baseSha)
await captureScreenshots(
'before',
options.routes,
{ forceFrontend: true, forceBackend: true },
visualDiffConfig,
visualDiffConfigPath,
visualDiffRunnerPath,
)
await stopFrontendStack()
await stopBackendStack()
await checkoutRef(headSha)
}
await captureScreenshots(
'after',
options.routes,
{
forceFrontend: !options.skipGitCheckout,
forceBackend: !options.skipGitCheckout,
},
visualDiffConfig,
visualDiffConfigPath,
visualDiffRunnerPath,
)
const results = await compareScreenshots(options.thresholdPercent, options.routes, visualDiffConfig)
const summary = generateReport(results, options.thresholdPercent)
if (summary.changedCount > 0)
console.log(`[visual-diff] ${summary.changedCount} route(s) changed`)
else
console.log('[visual-diff] no visual differences detected')
}
finally {
if (!options.skipGitCheckout) {
try {
const currentRef = git(['rev-parse', 'HEAD'])
if (currentRef !== originalRef) {
git(['checkout', '--force', originalRef])
const install = spawnSync('bun', ['install'], { cwd: repoRoot, stdio: 'inherit', env: process.env })
if ((install.status ?? 1) !== 0)
throw new Error(`bun install failed after restoring git ref ${originalRef}`)
}
}
catch (error) {
console.error(`[visual-diff] failed to restore git ref ${originalRef}:`, error)
}
}
rmSync(visualDiffConfigPath, { force: true })
rmSync(visualDiffRunnerPath, { force: true })
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
mkdirSync(outputRoot, { recursive: true })
try {
if (options.command === 'capture') {
await captureScreenshots(options.phase!, options.routes)
return
}
if (options.command === 'capture-route') {
const { visualDiffRoutes, visualDiffViewport } = await loadVisualDiffConfig()
const [route] = selectedRoutes(visualDiffRoutes, options.routes)
if (!route)
throw new Error('capture-route could not resolve its configured route')
await captureRouteScreenshot(options.phase!, route, visualDiffViewport)
return
}
if (options.command === 'diff') {
const results = await compareScreenshots(options.thresholdPercent, options.routes)
generateReport(results, options.thresholdPercent)
return
}
await runPipeline(options)
}
finally {
await stopAllProcesses()
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})