diff --git a/.github/workflows/visual-diff.yml b/.github/workflows/visual-diff.yml new file mode 100644 index 0000000000..3998edb635 --- /dev/null +++ b/.github/workflows/visual-diff.yml @@ -0,0 +1,145 @@ +name: Visual diff + +concurrency: + group: visual-diff-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +on: + pull_request: + types: + - opened + synchronize + reopened + labeled + unlabeled + edited + +permissions: + contents: read + pull-requests: write + +jobs: + check: + runs-on: ubuntu-latest + outputs: + requested: ${{ steps.check.outputs.requested }} + steps: + - name: Detect visual diff request + id: check + env: + PR_LABELS: ${{ toJson(github.event.pull_request.labels) }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + requested=false + if printf '%s' "$PR_LABELS" | grep -q '"name": "visual-change"'; then + requested=true + elif printf '%s' "$PR_BODY" | grep -q ''; then + requested=true + fi + echo "requested=${requested}" >> "$GITHUB_OUTPUT" + + visual-diff: + needs: check + if: needs.check.outputs.requested == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + name: Generate visual diff report + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24.x + + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup bun + run: bash scripts/setup-bun.sh + + - name: Install dependencies + run: bun install + + - name: Install Playwright browser + run: bunx playwright install --with-deps chromium + + - name: Install Supabase CLI + uses: supabase/setup-cli@v2 + with: + version: 2.84.2 + + - name: Link Supabase templates + run: ln -sfn supabase/templates templates + + - name: Run visual diff pipeline + run: | + bun scripts/visual-diff.ts run \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha }}" + + - name: Upload visual diff report + if: always() + uses: actions/upload-artifact@v6 + with: + name: visual-diff-report-${{ github.event.pull_request.head.sha }} + path: .context/visual-diff/report/ + if-no-files-found: ignore + retention-days: 14 + + - name: Comment visual diff summary on PR + if: always() + uses: actions/github-script@v8 + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + JOB_STATUS: ${{ job.status }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ARTIFACT_NAME: visual-diff-report-${{ github.event.pull_request.head.sha }} + with: + script: | + const fs = require('node:fs') + const path = require('node:path') + const marker = '' + const issue_number = context.payload.pull_request.number + const summaryPath = path.join(process.env.GITHUB_WORKSPACE, '.context/visual-diff/report/summary.md') + const ok = process.env.JOB_STATUS === 'success' + const summary = fs.existsSync(summaryPath) + ? fs.readFileSync(summaryPath, 'utf8').trim() + : 'Visual diff did not produce a report. Check the workflow logs and artifacts.' + + const body = [ + marker, + `## Visual diff ${ok ? 'passed' : 'failed'}`, + '', + summary, + '', + `Commit: \`${process.env.HEAD_SHA}\``, + `[Download the HTML report from workflow artifacts](${process.env.RUN_URL}) (artifact: \`${process.env.ARTIFACT_NAME}\`).`, + '', + 'Open `index.html` from the artifact for side-by-side before/after/diff screenshots.', + ].join('\n') + + const { owner, repo } = context.repo + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }) + const existing = comments.find(comment => comment.body?.includes(marker)) + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }) + } + else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }) + } diff --git a/.gitignore b/.gitignore index 45e6fbcb36..4f87dd7cf1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ fastlane/metadata/review_information/phone_number.txt test-results playwright-report +.context/visual-diff playwright/.cache playwright/.auth CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 8c25f387ff..6b5c4473a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -753,6 +753,46 @@ Key points: `playwright/e2e` and run them locally with `bun run test:front` before shipping UI changes. +### Visual diff for UI changes + +When a PR changes customer-facing UI (layout, spacing, colors, components, or +copy placement), reviewers need a before/after screenshot diff. + +**Request the automated PR report** by either: + +- adding the `visual-change` label to the PR, or +- including `` anywhere in the PR description. + +On each push, the `Visual diff` GitHub Action captures screenshots from the PR +base commit and head commit, generates a diff report, uploads it as a workflow +artifact, and updates a sticky PR comment (``). + +**Local workflow before opening or updating the PR:** + +1. Capture the current UI baseline: `bun run visual:capture:before` +2. Apply your UI edits and rebuild or refresh the local app as needed. +3. Capture the updated UI: `bun run visual:capture:after` +4. Generate the report: `bun run visual:diff` +5. Paste `.context/visual-diff/report/summary.md` into the PR description under + `## Visual changes`. +6. Add the `visual-change` label (or the HTML marker above) so CI refreshes the + report on every push. + +**Full local pipeline against `main`:** + +```bash +bun run visual:run -- --base origin/main +``` + +**Route configuration:** edit `playwright/visual-diff.config.ts` when a PR +introduces a new screen that should be part of the visual diff set. + +**Outputs:** + +- `.context/visual-diff/before/` and `after/` — raw PNG captures +- `.context/visual-diff/report/index.html` — side-by-side before/after/diff view +- `.context/visual-diff/report/summary.md` — markdown table for the PR body + ## Mobile Development ### Capacitor Configuration diff --git a/bun.lock b/bun.lock index 986235da52..d2b66a13ed 100644 --- a/bun.lock +++ b/bun.lock @@ -184,6 +184,8 @@ "jose": "^6.2.3", "knip": "^6.17.1", "oxlint": "^1.70.0", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", "sass": "1.101.0", "simple-git-hooks": "^2.13.1", "supabase": "^2.106.0", @@ -207,7 +209,7 @@ }, "cli": { "name": "@capgo/cli", - "version": "8.17.1", + "version": "8.17.3", "bin": { "capgo": "dist/index.js", }, @@ -2444,6 +2446,8 @@ "pinia": ["pinia@3.0.4", "", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="], + "pixelmatch": ["pixelmatch@7.2.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], @@ -2460,7 +2464,7 @@ "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "pnpm-workspace-yaml": ["pnpm-workspace-yaml@1.6.1", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-yTeZntGWi8m9WNuhoVsP0DpFc4sC1U0+rr/qR6Zi9n2g3sxXY+JfccjXjjruNz96tM8I09yaJUA86doRnNLkbg=="], @@ -3180,6 +3184,8 @@ "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], + "qrcode/pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], "rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], diff --git a/package.json b/package.json index 9989ffdf3c..ffafc95995 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,10 @@ "files:prod": "wrangler dev -c cloudflare_workers/files/wrangler.jsonc --env=prod --env-file=internal/cloudflare/.env.prod", "stripe:emulator": "bun scripts/serve-stripe-emulator.ts", "init:playwright": "bunx playwright install", + "visual:capture:before": "bun scripts/visual-diff.ts capture --phase before", + "visual:capture:after": "bun scripts/visual-diff.ts capture --phase after", + "visual:diff": "bun scripts/visual-diff.ts diff", + "visual:run": "bun scripts/visual-diff.ts run", "test:front": "bun scripts/run-playwright-tests.ts", "test:local": "bun run test:all", "test:coverage:all": "bun run cli:build && bun run supabase:with-env -- bunx vitest run --coverage.enabled=true tests/*", @@ -355,6 +359,8 @@ "jose": "^6.2.3", "knip": "^6.17.1", "oxlint": "^1.70.0", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", "sass": "1.101.0", "simple-git-hooks": "^2.13.1", "supabase": "^2.106.0", diff --git a/playwright/visual-diff.config.ts b/playwright/visual-diff.config.ts new file mode 100644 index 0000000000..bc4b3154b3 --- /dev/null +++ b/playwright/visual-diff.config.ts @@ -0,0 +1,24 @@ +export interface VisualDiffRoute { + slug: string + path: string + /** When true, logs in as test@capgo.app before visiting the route. */ + auth?: boolean +} + +/** + * Console pages captured for before/after visual diffs. + * Add routes here when a PR touches a new screen reviewers should compare. + */ +export const visualDiffRoutes: VisualDiffRoute[] = [ + { slug: 'login', path: '/login/', auth: false }, + { slug: 'dashboard', path: '/dashboard', auth: true }, + { slug: 'apps', path: '/apps', auth: true }, + { slug: 'app-overview', path: '/app/com.demo.app', auth: true }, + { slug: 'channels', path: '/app/com.demo.app/channels', auth: true }, + { slug: 'devices', path: '/app/com.demo.app/devices', auth: true }, +] + +export const visualDiffViewport = { + width: 1280, + height: 720, +} as const diff --git a/scripts/serve-backend-playwright.ts b/scripts/serve-backend-playwright.ts index 15078efb54..565e8f2508 100644 --- a/scripts/serve-backend-playwright.ts +++ b/scripts/serve-backend-playwright.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path' import process, { env } from 'node:process' import { getPlaywrightStripeApiBaseUrl } from './playwright-stripe' import { getSupabaseWorktreeConfig } from './supabase-worktree-config' +import { getSupabaseStatus, type SupabaseStatus } from './supabase-worktree-status' const repoRoot = process.cwd() const sourceEnvPath = resolve(repoRoot, 'supabase/functions/.env') @@ -14,14 +15,6 @@ const stripeApiBaseUrl = getPlaywrightStripeApiBaseUrl(env) const webAppUrl = env.WEBAPP_URL || 'http://localhost:5173' const functionsReadyTimeoutMs = Number(env.PLAYWRIGHT_BACKEND_TIMEOUT_MS || '360000') -interface SupabaseStatus { - API_URL?: string - ANON_KEY?: string - PUBLISHABLE_KEY?: string - SERVICE_ROLE_KEY?: string - SECRET_KEY?: string -} - function upsertEnvValue(content: string, key: string, value: string): string { const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const matcher = new RegExp(`^${escapedKey}=.*$`, 'm') @@ -47,33 +40,6 @@ function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } -function parseSupabaseStatus(stdout: string): SupabaseStatus | null { - const jsonStart = stdout.indexOf('{') - if (jsonStart < 0) - return null - - try { - return JSON.parse(stdout.slice(jsonStart)) as SupabaseStatus - } - catch { - return null - } -} - -function getSupabaseStatus(): SupabaseStatus | null { - const statusResult = spawnSync('bun', ['scripts/supabase-worktree.ts', 'status', '-o', 'json'], { - cwd: repoRoot, - stdio: 'pipe', - encoding: 'utf8', - env: process.env, - }) - - if ((statusResult.status ?? 1) !== 0) - return null - - return parseSupabaseStatus(statusResult.stdout || '') -} - function hasHealthySupabaseApi(status: SupabaseStatus | null) { return Boolean( status?.API_URL diff --git a/scripts/supabase-worktree-status.ts b/scripts/supabase-worktree-status.ts new file mode 100644 index 0000000000..3bc9cb2030 --- /dev/null +++ b/scripts/supabase-worktree-status.ts @@ -0,0 +1,34 @@ +import { spawnSync } from 'node:child_process' +import process from 'node:process' + +export interface SupabaseStatus { + API_URL?: string + ANON_KEY?: string + PUBLISHABLE_KEY?: string + SERVICE_ROLE_KEY?: string + SECRET_KEY?: string +} + +export function parseSupabaseStatus(stdout: string): SupabaseStatus | null { + const jsonStart = stdout.indexOf('{') + if (jsonStart < 0) + return null + + try { + return JSON.parse(stdout.slice(jsonStart)) as SupabaseStatus + } + catch { + return null + } +} + +export function getSupabaseStatus(cwd = process.cwd()): SupabaseStatus | null { + const result = spawnSync('bun', ['scripts/supabase-worktree.ts', 'status', '-o', 'json'], { + cwd, + encoding: 'utf8', + env: process.env, + }) + if ((result.status ?? 1) !== 0) + return null + return parseSupabaseStatus(result.stdout || '') +} diff --git a/scripts/visual-diff.ts b/scripts/visual-diff.ts new file mode 100644 index 0000000000..7e0ae92dbb --- /dev/null +++ b/scripts/visual-diff.ts @@ -0,0 +1,717 @@ +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 { getSupabaseStatus } from './supabase-worktree-status' +import { getPlaywrightStripeApiBaseUrl, getStripeEmulatorPort } from './playwright-stripe' +import { getSupabaseWorktreeConfig } from './supabase-worktree-config' + +type Phase = 'before' | 'after' +type Command = 'capture' | '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() + +interface LoadedVisualDiffConfig { + visualDiffRoutes: VisualDiffRoute[] + visualDiffViewport: { width: number, height: number } +} + +async function loadVisualDiffConfig(): Promise { + const configPath = resolve(repoRoot, 'playwright/visual-diff.config.ts') + const module = await import(`${pathToFileURL(configPath).href}?t=${Date.now()}`) + 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' + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function parseArgs(argv: string[]): CliOptions { + const [commandRaw, ...rest] = argv + const command = (commandRaw || 'run') as Command + if (!['capture', 'diff', 'run'].includes(command)) { + throw new Error(`Unknown command "${commandRaw}". Use capture, 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 = Number(process.env.VISUAL_DIFF_THRESHOLD_PERCENT || '0.1') + let routes: string[] = [] + let skipGitCheckout = process.env.VISUAL_DIFF_SKIP_GIT === 'true' + + for (let index = 0; index < rest.length; index++) { + const arg = rest[index] + if (arg === '--phase') { + phase = rest[++index] as Phase + continue + } + if (arg === '--base') { + baseRef = rest[++index] + continue + } + if (arg === '--head') { + headRef = rest[++index] + continue + } + if (arg === '--threshold') { + thresholdPercent = Number(rest[++index]) + continue + } + if (arg === '--routes') { + 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' && phase !== 'before' && phase !== 'after') { + throw new Error('capture requires --phase before or --phase after') + } + + 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): Promise { + try { + const response = await fetch(url) + return response.status < 500 + } + catch { + return false + } +} + +function isTcpReady(port: number, host = '127.0.0.1'): Promise { + 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) + child.stderr.pipe(logStream) + + 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 = (timeoutMs: number) => { + if (hasExited()) + return Promise.resolve(true) + + return Promise.race([ + new Promise((resolveExit) => { + processToStop.child.once('exit', () => resolveExit(true)) + }), + sleep(timeoutMs).then(() => 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) +} + +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 ensureBackendStack() { + 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 (!(await isHttpReady(backendOkUrl))) { + 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') + await page.click('[data-test="continue"]') + await page.waitForSelector('[data-test="password"]', { timeout: 30_000 }) + await page.fill('[data-test="password"]', 'testtest') + + const submit = page.locator('[data-test="submit"]') + 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 captureScreenshots(phase: Phase, routeFilter: string[], options: { forceFrontend?: boolean } = {}) { + const { visualDiffRoutes, visualDiffViewport } = await loadVisualDiffConfig() + const routes = selectedRoutes(visualDiffRoutes, routeFilter) + const targetDir = phaseDir(phase) + mkdirSync(targetDir, { recursive: true }) + + const { supabaseUrl, supabaseAnon, apiDomain } = await ensureBackendStack() + await prepareScreenshotData(supabaseUrl, supabaseAnon) + await ensureFrontendStack(supabaseUrl, supabaseAnon, apiDomain, { force: options.forceFrontend }) + + const browser = await chromium.launch({ headless: true }) + try { + const context = await browser.newContext({ + baseURL: frontendBaseUrl, + viewport: visualDiffViewport, + deviceScaleFactor: 1, + }) + const page = await context.newPage() + + for (const route of routes) { + if (route.auth) + await login(page) + + await page.goto(route.path, { waitUntil: 'domcontentloaded' }) + await settlePage(page) + const outputPath = resolve(targetDir, `${route.slug}.png`) + await page.screenshot({ path: outputPath, fullPage: false }) + console.log(`[visual-diff] captured ${phase} ${route.slug} -> ${outputPath}`) + } + + await context.close() + } + finally { + await browser.close() + } +} + +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 + 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[]): Promise { + const beforeRoot = phaseDir('before') + const afterRoot = phaseDir('after') + const { visualDiffRoutes } = 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, + }) + } + + 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 ` +
+

${result.slug} ${result.diffPercent.toFixed(3)}%

+
+
Before
${result.slug} before
+
After
${result.slug} after
+
Diff
${result.slug} diff
+
+
+ ` + }).join('\n') + + const html = ` + + + + Capgo visual diff report + + + +

Capgo visual diff

+

Changed routes: ${changed.length} · Unchanged routes: ${unchanged.length} · Threshold: ${thresholdPercent}%

+ ${htmlRows} + + +` + writeFileSync(resolve(outDir, 'index.html'), html) + console.log(`[visual-diff] report written to ${outDir}`) + return summary +} + +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) + + try { + if (!options.skipGitCheckout) { + await stopFrontendStack() + await checkoutRef(baseSha) + await captureScreenshots('before', options.routes, { forceFrontend: true }) + await stopFrontendStack() + await checkoutRef(headSha) + } + + await captureScreenshots('after', options.routes, { forceFrontend: !options.skipGitCheckout }) + const results = await compareScreenshots(options.thresholdPercent, options.routes) + const summary = generateReport(results, options.thresholdPercent) + + if (summary.changedCount > 0) { + console.log(`[visual-diff] ${summary.changedCount} route(s) changed`) + process.exitCode = 0 + } + 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]) + } + catch (error) { + console.error(`[visual-diff] failed to restore git ref ${originalRef}:`, error) + } + } + } +} + +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 === '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) +})