diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 013a8e66..6f46aef1 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -36,6 +36,8 @@ jobs: - uses: actions/cache@v6 with: path: .artifacts/visual-regression/cache + # File list must match VISUAL_CACHE_HASH_FILES in + # packages/@luke-ui/react/scripts/visual-regression-contract.ts. key: visual-main-${{ runner.os }}-${{ runner.arch }}-${{ github.event.pull_request.base.sha || github.event.before }}-${{ hashFiles('pnpm-lock.yaml', diff --git a/docs/VISUAL_TESTING.md b/docs/VISUAL_TESTING.md index 3c6501cf..24a32a72 100644 --- a/docs/VISUAL_TESTING.md +++ b/docs/VISUAL_TESTING.md @@ -14,7 +14,12 @@ The command captures the local `origin/main` ref in a disposable Git worktree, t current working tree, including uncommitted changes. Fetch before running when you need the latest remote commit. Set `VISUAL_BASE_REF` to use another local ref, such as `upstream/main` in a fork. The command compares matching capture IDs and writes a self-contained report to -`.artifacts/visual-regression/report/index.html`. +`.artifacts/visual-regression/report/index.html`. Capture identity, harness files, and that artifact +directory are defined in `packages/@luke-ui/react/src/test-utils/visual-capture-id.ts` and +`packages/@luke-ui/react/scripts/visual-regression-contract.ts`. GitHub Actions cannot import those +modules. A unit test fails if the workflow lists a different harness file or artifact path. +`vitest.config.ts` is copied into the base worktree, so it also cannot import those modules. A unit +test fails if it drifts from the capture-dir literals. The first run installs and builds the comparison worktree. Later runs reuse its ignored cache while the base SHA, platform, architecture, browser, lockfile, and visual configuration remain unchanged. diff --git a/packages/@luke-ui/react/scripts/open-visual-report.ts b/packages/@luke-ui/react/scripts/open-visual-report.ts index 1bade8a5..89ce128c 100644 --- a/packages/@luke-ui/react/scripts/open-visual-report.ts +++ b/packages/@luke-ui/react/scripts/open-visual-report.ts @@ -1,11 +1,13 @@ import { execFileSync } from 'node:child_process'; import { platform } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { access } from 'node:fs/promises'; +import { + visualPackageRoot, + visualRepoRootFromPackage, + visualReportIndex, +} from './visual-regression-contract.js'; -const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const report = path.resolve(packageRoot, '../../../.artifacts/visual-regression/report/index.html'); +const report = visualReportIndex(visualRepoRootFromPackage(visualPackageRoot(import.meta.url))); await access(report); if (platform() === 'darwin') execFileSync('open', [report], { stdio: 'inherit' }); diff --git a/packages/@luke-ui/react/scripts/visual-regression-contract.test.ts b/packages/@luke-ui/react/scripts/visual-regression-contract.test.ts new file mode 100644 index 00000000..88b67580 --- /dev/null +++ b/packages/@luke-ui/react/scripts/visual-regression-contract.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vite-plus/test'; +import { + VISUAL_ARTIFACTS_DIR, + VISUAL_CACHE_DIR, + VISUAL_CACHE_HASH_FILES, + VISUAL_CAPTURE_DIR_ENV, + VISUAL_CAPTURE_FALLBACK_DIR, + VISUAL_REPORT_DIR, + VISUAL_REPORT_INDEX, + VISUAL_SUMMARY_FILE, + visualPackageRoot, + visualRepoRootFromPackage, +} from './visual-regression-contract.js'; + +const packageRoot = visualPackageRoot(import.meta.url); +const repoRoot = visualRepoRootFromPackage(packageRoot); +const workflowPath = path.join(repoRoot, '.github/workflows/visual-regression.yml'); +const docsPath = path.join(repoRoot, 'docs/VISUAL_TESTING.md'); +const vitestConfigPath = path.join(packageRoot, 'vitest.config.ts'); + +test('workflow hashFiles matches the visual harness file list', () => { + const workflow = readFileSync(workflowPath, 'utf8'); + const hashFiles = workflow.match(/hashFiles\(([\s\S]*?)\)/); + if (hashFiles?.[1] == null) { + throw new Error(`Expected hashFiles(...) in ${workflowPath}`); + } + + const listed = [...hashFiles[1].matchAll(/'([^']+)'/g)].map((match) => match[1]); + expect(listed).toEqual([...VISUAL_CACHE_HASH_FILES]); +}); + +test('workflow and docs use VISUAL_ARTIFACTS_DIR', () => { + const workflow = readFileSync(workflowPath, 'utf8'); + const docs = readFileSync(docsPath, 'utf8'); + + expect(workflow).toContain(`path: ${VISUAL_CACHE_DIR}`); + expect(workflow).toContain(VISUAL_SUMMARY_FILE); + expect(workflow).toContain(`path: ${VISUAL_REPORT_DIR}`); + + expect(docs).toContain(VISUAL_REPORT_INDEX); + expect(docs).toContain(VISUAL_ARTIFACTS_DIR); +}); + +test('Vitest config inlines capture-dir literals and does not import the contract', () => { + const config = readFileSync(vitestConfigPath, 'utf8'); + + expect(config).toContain(VISUAL_CAPTURE_DIR_ENV); + expect(config).toContain(VISUAL_CAPTURE_FALLBACK_DIR); + expect(config).not.toMatch(/from ['"]\.\/scripts\/visual-regression-contract/); +}); diff --git a/packages/@luke-ui/react/scripts/visual-regression-contract.ts b/packages/@luke-ui/react/scripts/visual-regression-contract.ts new file mode 100644 index 00000000..731b084f --- /dev/null +++ b/packages/@luke-ui/react/scripts/visual-regression-contract.ts @@ -0,0 +1,39 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const VISUAL_LOCKFILE = 'pnpm-lock.yaml'; + +/** Files that shape how a capture renders. Hashed into the cache key and copied into the base worktree. */ +export const VISUAL_HARNESS_FILES = [ + 'packages/@luke-ui/react/vitest.config.ts', + 'packages/@luke-ui/react/src/test-utils/render-setup.ts', + 'packages/@luke-ui/react/src/test-utils/render.tsx', + 'packages/@luke-ui/react/src/test-utils/visual-setup.ts', +] as const; + +export const VISUAL_CACHE_HASH_FILES = [VISUAL_LOCKFILE, ...VISUAL_HARNESS_FILES]; + +export const VISUAL_ARTIFACTS_DIR = '.artifacts/visual-regression'; +export const VISUAL_CACHE_DIR = `${VISUAL_ARTIFACTS_DIR}/cache`; +export const VISUAL_REPORT_DIR = `${VISUAL_ARTIFACTS_DIR}/report`; +export const VISUAL_REPORT_INDEX = `${VISUAL_REPORT_DIR}/index.html`; +export const VISUAL_SUMMARY_FILE = `${VISUAL_REPORT_DIR}/summary.json`; + +export const VISUAL_CAPTURE_DIR_ENV = 'VISUAL_CAPTURE_DIR'; +export const VISUAL_CAPTURE_FALLBACK_DIR = '.visual-captures'; + +export function visualPackageRoot(fromScriptUrl: string): string { + return path.resolve(path.dirname(fileURLToPath(fromScriptUrl)), '..'); +} + +export function visualRepoRootFromPackage(packageRoot: string): string { + return path.resolve(packageRoot, '../../..'); +} + +export function visualArtifactsRoot(repoRoot: string): string { + return path.join(repoRoot, VISUAL_ARTIFACTS_DIR); +} + +export function visualReportIndex(repoRoot: string): string { + return path.join(repoRoot, ...VISUAL_REPORT_INDEX.split('/')); +} diff --git a/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts b/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts index 8d87afd8..49977e3b 100644 --- a/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts +++ b/packages/@luke-ui/react/scripts/visual-regression-lib.test.ts @@ -3,6 +3,10 @@ import path from 'node:path'; import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { PNG } from 'pngjs'; import { expect, test } from 'vite-plus/test'; +import { + formatVisualCaptureName, + formatVisualViewport, +} from '../src/test-utils/visual-capture-id.js'; import { assertCapturesPainted, compareCaptures, renderReport } from './visual-regression-lib.js'; const png = (red: number) => { @@ -55,6 +59,31 @@ const pngWithBand = ( return PNG.sync.write(image); }; +test('records the full viewport token produced by the capture-name helper', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'visual-viewport-')); + const base = path.join(root, 'base'); + const current = path.join(root, 'current'); + await Promise.all([base, current].map((directory) => mkdir(directory))); + const captureName = formatVisualCaptureName('button/sink', formatVisualViewport(1024, 800)); + const relativeFile = `${captureName}.png`; + await Promise.all( + [base, current].map((directory) => + mkdir(path.join(directory, path.dirname(relativeFile)), { recursive: true }), + ), + ); + await Promise.all([ + writeFile(path.join(base, relativeFile), png(0)), + writeFile(path.join(current, relativeFile), png(0)), + ]); + const [result] = await compareCaptures(base, current, path.join(root, 'diff')); + expect(result).toMatchObject({ + baseViewport: '1024x800', + currentViewport: '1024x800', + id: 'button/sink', + status: 'unchanged', + }); +}); + test('classifies matched, changed, added, and removed captures', async () => { const root = await mkdtemp(path.join(tmpdir(), 'visual-regression-')); const base = path.join(root, 'base'); @@ -99,8 +128,9 @@ test('counts anti-aliased pixels and flags a removed thin stroke as a change', a test('rejects a tall capture whose bottom decile never painted', async () => { const root = await mkdtemp(path.join(tmpdir(), 'visual-painted-')); + const captureName = formatVisualCaptureName('tall', formatVisualViewport(1024, 800)); await writeFile( - path.join(root, 'tall__viewport-1024x800.png'), + path.join(root, `${captureName}.png`), pngWithBand(1024, 900, [0, 0, 0, 255], [10, 10, 10, 255]), ); @@ -123,7 +153,10 @@ test('accepts a tall capture whose bottom decile painted', async () => { image.data.set(color, index); } } - await writeFile(path.join(root, 'tall__viewport-1024x800.png'), PNG.sync.write(image)); + await writeFile( + path.join(root, `${formatVisualCaptureName('tall', formatVisualViewport(1024, 800))}.png`), + PNG.sync.write(image), + ); await expect(assertCapturesPainted(root)).resolves.toBeUndefined(); }); @@ -131,7 +164,7 @@ test('accepts a tall capture whose bottom decile painted', async () => { test('ignores a capture that fits its viewport even with a uniform bottom decile', async () => { const root = await mkdtemp(path.join(tmpdir(), 'visual-painted-')); await writeFile( - path.join(root, 'fits__viewport-1024x800.png'), + path.join(root, `${formatVisualCaptureName('fits', formatVisualViewport(1024, 800))}.png`), pngWithBand(1024, 720, [0, 0, 0, 255], [0, 0, 0, 255]), ); diff --git a/packages/@luke-ui/react/scripts/visual-regression-lib.ts b/packages/@luke-ui/react/scripts/visual-regression-lib.ts index 91e183f5..06ed56dd 100644 --- a/packages/@luke-ui/react/scripts/visual-regression-lib.ts +++ b/packages/@luke-ui/react/scripts/visual-regression-lib.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import pixelmatch from 'pixelmatch'; import { PNG } from 'pngjs'; +import { parseVisualCaptureIdentity } from '../src/test-utils/visual-capture-id.js'; export type VisualResult = { id: string; @@ -22,10 +23,10 @@ type CaptureFile = { file: string; viewport?: string }; async function listPngs(root: string) { const result = new Map(); await walkPngs(root, (file, captureName) => { - const metadata = captureName.match(/^(.*)__viewport-(\d+x\d+)$/); - const id = metadata?.[1] ?? captureName; + const identity = parseVisualCaptureIdentity(captureName); + const id = identity?.id ?? captureName; if (result.has(id)) throw new Error(`Duplicate visual capture ID: ${id}`); - result.set(id, { file, viewport: metadata?.[2] }); + result.set(id, { file, viewport: identity?.viewport }); }); return result; } @@ -60,11 +61,13 @@ async function walkPngs(root: string, visitor: (file: string, captureName: strin export async function assertCapturesPainted(directory: string) { const viewportCaptures: Array<{ file: string; id: string; viewportHeight: number }> = []; await walkPngs(directory, (file, captureName) => { - const metadata = captureName.match(/^(.*)__viewport-\d+x(\d+)$/); - const id = metadata?.[1]; - const viewportHeight = metadata?.[2]; - if (id === undefined || viewportHeight === undefined) return; - viewportCaptures.push({ file, id, viewportHeight: Number(viewportHeight) }); + const identity = parseVisualCaptureIdentity(captureName); + if (identity === undefined) return; + viewportCaptures.push({ + file, + id: identity.id, + viewportHeight: identity.viewportHeight, + }); }); const offenders: Array = []; diff --git a/packages/@luke-ui/react/scripts/visual-regression-runner.test.ts b/packages/@luke-ui/react/scripts/visual-regression-runner.test.ts new file mode 100644 index 00000000..f877827a --- /dev/null +++ b/packages/@luke-ui/react/scripts/visual-regression-runner.test.ts @@ -0,0 +1,164 @@ +import { writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { PNG } from 'pngjs'; +import { expect, test } from 'vite-plus/test'; +import { + VISUAL_CACHE_HASH_FILES, + VISUAL_CAPTURE_DIR_ENV, + visualArtifactsRoot, +} from './visual-regression-contract.js'; +import type { VisualRegressionIo } from './visual-regression-runner.js'; +import { + runVisualRegression, + visualCacheDirectory, + visualCacheSignature, +} from './visual-regression-runner.js'; + +type CommandCall = { + args: Array; + command: string; + cwd?: string; +}; + +async function fakeRepo() { + const repoRoot = await mkdtemp(path.join(tmpdir(), 'visual-lifecycle-')); + await Promise.all( + VISUAL_CACHE_HASH_FILES.map(async (file) => { + const destination = path.join(repoRoot, file); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, `${file}\n`); + }), + ); + return repoRoot; +} + +function createIo( + repoRoot: string, + run: VisualRegressionIo['run'], + overrides?: Partial, +): VisualRegressionIo { + return { + arch: 'x64', + copyFile, + env: { VISUAL_BASE_SHA: 'abc123' }, + gitOutput: () => 'abc123', + log: () => {}, + mkdir, + platform: 'linux', + readFile, + repoRoot, + rm, + run, + writeFile, + ...overrides, + }; +} + +function worktreeCalls(calls: Array) { + return calls.filter((call) => call.command === 'git' && call.args[0] === 'worktree'); +} + +function png() { + const image = new PNG({ height: 1, width: 1 }); + image.data[3] = 255; + return PNG.sync.write(image); +} + +test('cache hit skips the base capture and does not create a worktree', async () => { + const repoRoot = await fakeRepo(); + const artifacts = visualArtifactsRoot(repoRoot); + const signature = await visualCacheSignature(repoRoot, readFile); + const cache = visualCacheDirectory(artifacts, 'abc123', 'linux', 'x64', signature); + const baseCaptures = path.join(cache, 'captures'); + const image = png(); + await mkdir(baseCaptures, { recursive: true }); + await writeFile(path.join(cache, 'complete'), ''); + await writeFile(path.join(baseCaptures, 'scene.png'), image); + + const calls: Array = []; + await runVisualRegression( + createIo(repoRoot, (command, args, cwd, env) => { + calls.push({ args, command, cwd }); + const captureDir = env?.[VISUAL_CAPTURE_DIR_ENV]; + if (captureDir === undefined || captureDir === '') return; + if (!args.includes('--project=visual')) return; + writeFileSync(path.join(captureDir, 'scene.png'), image); + }), + ); + + expect(worktreeCalls(calls)).toEqual([]); + expect(calls.some((call) => call.cwd === path.join(artifacts, 'worktree'))).toBe(false); + expect( + calls.some( + (call) => + call.command === 'corepack' && + call.args.includes('build:packages') && + call.cwd === repoRoot, + ), + ).toBe(true); + + const summary: { counts: { added: number; removed: number; unchanged: number } } = JSON.parse( + await readFile(path.join(artifacts, 'report', 'summary.json'), 'utf8'), + ); + expect(summary.counts.added).toBe(0); + expect(summary.counts.removed).toBe(0); + expect(summary.counts.unchanged).toBe(1); +}); + +test('cache miss creates and removes the base worktree', async () => { + const repoRoot = await fakeRepo(); + const worktree = path.join(visualArtifactsRoot(repoRoot), 'worktree'); + const calls: Array = []; + + await runVisualRegression( + createIo(repoRoot, (command, args, cwd) => { + calls.push({ args, command, cwd }); + }), + ); + + expect(worktreeCalls(calls)).toEqual([ + { args: ['worktree', 'add', '--detach', worktree, 'abc123'], command: 'git', cwd: undefined }, + { args: ['worktree', 'remove', '--force', worktree], command: 'git', cwd: undefined }, + ]); + expect(calls.some((call) => call.command === 'corepack' && call.cwd === worktree)).toBe(true); + expect( + calls.some( + (call) => + call.command === 'corepack' && + call.args.includes('build:packages') && + call.cwd === repoRoot, + ), + ).toBe(true); +}); + +test('removes the base worktree when capture throws', async () => { + const repoRoot = await fakeRepo(); + const worktree = path.join(visualArtifactsRoot(repoRoot), 'worktree'); + const calls: Array = []; + + await expect( + runVisualRegression( + createIo(repoRoot, (command, args, cwd) => { + calls.push({ args, command, cwd }); + if (command === 'corepack' && cwd === worktree) { + throw new Error('capture failed'); + } + }), + ), + ).rejects.toThrow('capture failed'); + + expect(worktreeCalls(calls)).toEqual([ + { args: ['worktree', 'add', '--detach', worktree, 'abc123'], command: 'git', cwd: undefined }, + { args: ['worktree', 'remove', '--force', worktree], command: 'git', cwd: undefined }, + ]); + expect( + calls.some( + (call) => + call.command === 'corepack' && + call.args.includes('build:packages') && + call.cwd === repoRoot, + ), + ).toBe(false); +}); diff --git a/packages/@luke-ui/react/scripts/visual-regression-runner.ts b/packages/@luke-ui/react/scripts/visual-regression-runner.ts new file mode 100644 index 00000000..0f8ce8d6 --- /dev/null +++ b/packages/@luke-ui/react/scripts/visual-regression-runner.ts @@ -0,0 +1,169 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { arch, platform } from 'node:os'; +import path from 'node:path'; +import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { + VISUAL_CACHE_HASH_FILES, + VISUAL_CAPTURE_DIR_ENV, + VISUAL_HARNESS_FILES, + visualArtifactsRoot, + visualPackageRoot, + visualRepoRootFromPackage, +} from './visual-regression-contract.js'; +import { assertCapturesPainted, compareCaptures, renderReport } from './visual-regression-lib.js'; + +export type VisualRegressionIo = { + arch: string; + copyFile: (source: string, destination: string) => Promise; + env: NodeJS.ProcessEnv; + gitOutput: (args: Array, cwd?: string) => string; + log: (message: string) => void; + mkdir: (directory: string, options?: { recursive?: boolean }) => Promise; + platform: string; + readFile: (file: string) => Promise; + repoRoot: string; + rm: (target: string, options?: { force?: boolean; recursive?: boolean }) => Promise; + run: (command: string, args: Array, cwd?: string, env?: NodeJS.ProcessEnv) => void; + writeFile: (file: string, data: string) => Promise; +}; + +function createVisualRegressionIo(): VisualRegressionIo { + const repoRoot = visualRepoRootFromPackage(visualPackageRoot(import.meta.url)); + return { + arch: arch(), + copyFile, + env: process.env, + gitOutput: (args, cwd = repoRoot) => { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + }, + log: (message) => { + // CLI output is the entry point to the generated report. + // oxlint-disable-next-line no-console + console.log(message); + }, + mkdir, + platform: platform(), + readFile, + repoRoot, + rm, + run: (command, args, cwd = repoRoot, env = process.env) => { + execFileSync(command, args, { cwd, env, stdio: 'inherit' }); + }, + writeFile, + }; +} + +export async function visualCacheSignature( + repoRoot: string, + read: VisualRegressionIo['readFile'], +): Promise { + const hashedFiles = await Promise.all( + VISUAL_CACHE_HASH_FILES.map((file) => read(path.join(repoRoot, file))), + ); + const hash = createHash('sha256'); + for (const contents of hashedFiles) hash.update(contents); + return hash.digest('hex').slice(0, 12); +} + +export function visualCacheDirectory( + artifacts: string, + baseSha: string, + platformName: string, + archName: string, + signature: string, +): string { + return path.join( + artifacts, + 'cache', + `${baseSha}-${platformName}-${archName}-chromium-${signature}`, + ); +} + +export async function runVisualRegression(io: VisualRegressionIo = createVisualRegressionIo()) { + const artifacts = visualArtifactsRoot(io.repoRoot); + const configuredBaseSha = io.env.VISUAL_BASE_SHA?.trim(); + const baseRef = io.env.VISUAL_BASE_REF?.trim() || 'origin/main'; + const baseSha = configuredBaseSha || io.gitOutput(['rev-parse', baseRef]); + const current = io.env.GITHUB_SHA ?? 'working tree'; + const signature = await visualCacheSignature(io.repoRoot, io.readFile); + const cache = visualCacheDirectory(artifacts, baseSha, io.platform, io.arch, signature); + const baseCaptures = path.join(cache, 'captures'); + const currentCaptures = path.join(artifacts, 'current'); + const worktree = path.join(artifacts, 'worktree'); + + try { + await io.readFile(path.join(cache, 'complete')); + } catch { + await io.rm(worktree, { force: true, recursive: true }); + io.run('git', ['worktree', 'add', '--detach', worktree, baseSha]); + try { + await capture(io, worktree, baseCaptures); + await io.writeFile(path.join(cache, 'complete'), ''); + } finally { + io.run('git', ['worktree', 'remove', '--force', worktree]); + } + } + + await capture(io, io.repoRoot, currentCaptures); + // Guard against unpainted tall captures only on the current tree. The base is + // whatever origin/main produced and cannot be fixed retroactively; a stale base + // must not block the current tree's comparison. + await assertCapturesPainted(currentCaptures); + const reportDir = path.join(artifacts, 'report'); + const results = await compareCaptures( + baseCaptures, + currentCaptures, + path.join(reportDir, 'diffs'), + ); + const counts = await renderReport( + results, + { base: baseSha, current, platform: `${io.platform} ${io.arch} Chromium` }, + path.join(reportDir, 'index.html'), + ); + await io.writeFile( + path.join(reportDir, 'summary.json'), + JSON.stringify({ counts, results }, null, 2), + ); + io.log(`Visual report: ${path.join(reportDir, 'index.html')}`); + io.log(`${counts.changed} changed, ${counts.added} added, ${counts.removed} removed`); +} + +async function capture(io: VisualRegressionIo, worktree: string, target: string) { + if (worktree !== io.repoRoot) { + await Promise.all( + VISUAL_HARNESS_FILES.map(async (file) => { + const destination = path.join(worktree, file); + await io.mkdir(path.dirname(destination), { recursive: true }); + await io.copyFile(path.join(io.repoRoot, file), destination); + }), + ); + } + await io.rm(target, { force: true, recursive: true }); + await io.mkdir(target, { recursive: true }); + if (worktree !== io.repoRoot) { + io.run('corepack', ['pnpm', 'install', '--frozen-lockfile'], worktree); + io.run( + 'corepack', + ['pnpm', '--filter', '@luke-ui/react', 'exec', 'playwright', 'install', 'chromium'], + worktree, + ); + } + io.run('corepack', ['pnpm', 'build:packages'], worktree); + io.run( + 'corepack', + [ + 'pnpm', + '--filter', + '@luke-ui/react', + 'exec', + 'vp', + 'test', + 'run', + '--project=visual', + '--update', + ], + worktree, + { ...io.env, [VISUAL_CAPTURE_DIR_ENV]: target }, + ); +} diff --git a/packages/@luke-ui/react/scripts/visual-regression.ts b/packages/@luke-ui/react/scripts/visual-regression.ts index 1ca300e1..6882f881 100644 --- a/packages/@luke-ui/react/scripts/visual-regression.ts +++ b/packages/@luke-ui/react/scripts/visual-regression.ts @@ -1,127 +1,3 @@ -import { execFileSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { arch, platform } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { assertCapturesPainted, compareCaptures, renderReport } from './visual-regression-lib.js'; +import { runVisualRegression } from './visual-regression-runner.js'; -const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const repoRoot = path.resolve(packageRoot, '../../..'); -const artifacts = path.join(repoRoot, '.artifacts/visual-regression'); - -// Files that shape how a capture renders. Copied into the base worktree so both -// revisions render under the same harness, and hashed into the cache key so a -// change to any of them invalidates cached baselines. Keep this list in sync with -// the `hashFiles` list in .github/workflows/visual-regression.yml. -const HARNESS_FILES = [ - 'packages/@luke-ui/react/vitest.config.ts', - 'packages/@luke-ui/react/src/test-utils/render-setup.ts', - 'packages/@luke-ui/react/src/test-utils/render.tsx', - 'packages/@luke-ui/react/src/test-utils/visual-setup.ts', -]; - -async function main() { - const configuredBaseSha = process.env.VISUAL_BASE_SHA?.trim(); - const baseRef = process.env.VISUAL_BASE_REF?.trim() || 'origin/main'; - const baseSha = configuredBaseSha || output(['rev-parse', baseRef]); - const current = process.env.GITHUB_SHA ?? 'working tree'; - const hashedFiles = await Promise.all( - ['pnpm-lock.yaml', ...HARNESS_FILES].map((file) => readFile(path.join(repoRoot, file))), - ); - const hash = createHash('sha256'); - for (const contents of hashedFiles) hash.update(contents); - const signature = hash.digest('hex').slice(0, 12); - const cache = path.join( - artifacts, - 'cache', - `${baseSha}-${platform()}-${arch()}-chromium-${signature}`, - ); - const baseCaptures = path.join(cache, 'captures'); - const currentCaptures = path.join(artifacts, 'current'); - const worktree = path.join(artifacts, 'worktree'); - - try { - await readFile(path.join(cache, 'complete')); - } catch { - await rm(worktree, { force: true, recursive: true }); - run('git', ['worktree', 'add', '--detach', worktree, baseSha]); - try { - await capture(worktree, baseCaptures); - await writeFile(path.join(cache, 'complete'), ''); - } finally { - run('git', ['worktree', 'remove', '--force', worktree]); - } - } - - await capture(repoRoot, currentCaptures); - // Guard against unpainted tall captures only on the current tree. The base is - // whatever origin/main produced and cannot be fixed retroactively; a stale base - // must not block the current tree's comparison. - await assertCapturesPainted(currentCaptures); - const reportDir = path.join(artifacts, 'report'); - const results = await compareCaptures( - baseCaptures, - currentCaptures, - path.join(reportDir, 'diffs'), - ); - const counts = await renderReport( - results, - { base: baseSha, current, platform: `${platform()} ${arch()} Chromium` }, - path.join(reportDir, 'index.html'), - ); - await writeFile( - path.join(reportDir, 'summary.json'), - JSON.stringify({ counts, results }, null, 2), - ); - // CLI output is the entry point to the generated report. - // oxlint-disable-next-line no-console - console.log(`Visual report: ${path.join(reportDir, 'index.html')}`); - // oxlint-disable-next-line no-console - console.log(`${counts.changed} changed, ${counts.added} added, ${counts.removed} removed`); -} - -async function capture(worktree: string, target: string) { - if (worktree !== repoRoot) { - await Promise.all( - HARNESS_FILES.map((file) => copyFile(path.join(repoRoot, file), path.join(worktree, file))), - ); - } - await rm(target, { force: true, recursive: true }); - await mkdir(target, { recursive: true }); - if (worktree !== repoRoot) { - run('corepack', ['pnpm', 'install', '--frozen-lockfile'], worktree); - run( - 'corepack', - ['pnpm', '--filter', '@luke-ui/react', 'exec', 'playwright', 'install', 'chromium'], - worktree, - ); - } - run('corepack', ['pnpm', 'build:packages'], worktree); - run( - 'corepack', - [ - 'pnpm', - '--filter', - '@luke-ui/react', - 'exec', - 'vp', - 'test', - 'run', - '--project=visual', - '--update', - ], - worktree, - { ...process.env, VISUAL_CAPTURE_DIR: target }, - ); -} - -function run(command: string, args: Array, cwd = repoRoot, env = process.env) { - return execFileSync(command, args, { cwd, env, stdio: 'inherit' }); -} - -function output(args: Array, cwd = repoRoot) { - return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); -} - -await main(); +await runVisualRegression(); diff --git a/packages/@luke-ui/react/src/test-utils/visual-capture-id.test.ts b/packages/@luke-ui/react/src/test-utils/visual-capture-id.test.ts new file mode 100644 index 00000000..9f746596 --- /dev/null +++ b/packages/@luke-ui/react/src/test-utils/visual-capture-id.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'vite-plus/test'; +import { + formatVisualCaptureName, + formatVisualViewport, + parseVisualCaptureIdentity, +} from './visual-capture-id.js'; + +test('parses the capture name the browser helper writes, including full viewport and height', () => { + const viewport = formatVisualViewport(1024, 800); + const captureName = formatVisualCaptureName('button/kitchen-sink', viewport); + const identity = parseVisualCaptureIdentity(captureName); + + expect(captureName).toBe('button/kitchen-sink__viewport-1024x800'); + expect(identity).toEqual({ + id: 'button/kitchen-sink', + viewport: '1024x800', + viewportHeight: 800, + viewportWidth: 1024, + }); + expect(identity?.viewport).toBe(viewport); + expect(identity?.viewportHeight).toBe(800); +}); + +test('leaves a capture with no viewport token unparsed', () => { + expect(parseVisualCaptureIdentity('legacy')).toBeUndefined(); + expect(parseVisualCaptureIdentity('button/sink__viewport-tall')).toBeUndefined(); +}); diff --git a/packages/@luke-ui/react/src/test-utils/visual-capture-id.ts b/packages/@luke-ui/react/src/test-utils/visual-capture-id.ts new file mode 100644 index 00000000..450a8bca --- /dev/null +++ b/packages/@luke-ui/react/src/test-utils/visual-capture-id.ts @@ -0,0 +1,42 @@ +const VIEWPORT_MARKER = '__viewport-'; +const VIEWPORT_SIZE = /^(\d+)x(\d+)$/; + +export type VisualCaptureIdentity = { + id: string; + viewport: string; + viewportHeight: number; + viewportWidth: number; +}; + +/** Viewport token written into a capture name, for example `1024x800`. */ +export function formatVisualViewport(width: number, height: number): string { + return `${width}x${height}`; +} + +/** Capture name without extension: `{id}__viewport-{width}x{height}`. */ +export function formatVisualCaptureName(id: string, viewport: string): string { + return `${id}${VIEWPORT_MARKER}${viewport}`; +} + +/** + * Reads the capture name written by `formatVisualCaptureName`. Returns `undefined` when + * the name has no trailing `{width}x{height}` viewport token, including legacy captures. + */ +export function parseVisualCaptureIdentity(captureName: string): VisualCaptureIdentity | undefined { + const index = captureName.lastIndexOf(VIEWPORT_MARKER); + if (index === -1) return undefined; + + const id = captureName.slice(0, index); + const viewport = captureName.slice(index + VIEWPORT_MARKER.length); + const size = viewport.match(VIEWPORT_SIZE); + const width = size?.[1]; + const height = size?.[2]; + if (width === undefined || height === undefined) return undefined; + + return { + id, + viewport, + viewportHeight: Number(height), + viewportWidth: Number(width), + }; +} diff --git a/packages/@luke-ui/react/src/test-utils/visual.tsx b/packages/@luke-ui/react/src/test-utils/visual.tsx index a234b82d..fa3c0bc9 100644 --- a/packages/@luke-ui/react/src/test-utils/visual.tsx +++ b/packages/@luke-ui/react/src/test-utils/visual.tsx @@ -3,6 +3,7 @@ import { expect } from 'vite-plus/test'; import type { Locator } from 'vite-plus/test/context'; import { cdp, page, userEvent } from 'vite-plus/test/context'; import type { VisualAppearance } from './render.js'; +import { formatVisualCaptureName, formatVisualViewport } from './visual-capture-id.js'; const VISUAL_CAPTURE_ID_PATTERN = /^[a-z0-9-]+\/[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -13,7 +14,7 @@ export async function captureVisual(locator: Locator, id: string) { } const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - const viewport = `${viewportWidth}x${viewportHeight}`; + const viewport = formatVisualViewport(viewportWidth, viewportHeight); const element = locator.element(); const fullHeight = element.scrollHeight; const isTall = fullHeight > viewportHeight; @@ -33,7 +34,7 @@ export async function captureVisual(locator: Locator, id: string) { await page.viewport(viewportWidth, fullHeight); } - await expect.element(locator).toMatchScreenshot(`${id}__viewport-${viewport}`); + await expect.element(locator).toMatchScreenshot(formatVisualCaptureName(id, viewport)); if (isTall) { await page.viewport(viewportWidth, viewportHeight); diff --git a/packages/@luke-ui/react/vitest.config.ts b/packages/@luke-ui/react/vitest.config.ts index 1bee4dc6..5c8dfdbd 100644 --- a/packages/@luke-ui/react/vitest.config.ts +++ b/packages/@luke-ui/react/vitest.config.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; @@ -9,17 +8,15 @@ import { playwright } from 'vite-plus/test/browser-playwright'; const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); const configDir = path.join(dirname, '.storybook'); - -function findAncestorDir(name: string, from = dirname): string | undefined { - let current = path.resolve(from); - do { - if (fs.existsSync(path.join(current, name))) return current; - current = path.dirname(current); - } while (current !== path.dirname(current)); -} - -const artifactsDir = findAncestorDir('.artifacts'); const recipeEngineSource = fileURLToPath(new URL('./src/styles/recipe-engine.ts', import.meta.url)); +// This file is copied into a git worktree at an older revision. It cannot import +// TypeScript that does not exist there, so it has no relative imports of the +// visual-regression contract. Keep the capture-dir literals below in sync with +// `scripts/visual-regression-contract.ts`; a unit test fails if they drift. +const repoRoot = path.resolve(dirname, '../../..'); +const captureDir = process.env.VISUAL_CAPTURE_DIR; +const visualFsAllow = + captureDir === undefined || captureDir === '' ? [repoRoot] : [repoRoot, path.resolve(captureDir)]; export default defineConfig({ optimizeDeps: { @@ -35,7 +32,7 @@ export default defineConfig({ }, server: { fs: { - allow: artifactsDir ? [artifactsDir] : undefined, + allow: visualFsAllow, }, }, resolve: { @@ -111,7 +108,7 @@ export default defineConfig({ // the page and the test iframe before capturing. resolveScreenshotPath: ({ arg, ext, root }) => { return path.join( - process.env.VISUAL_CAPTURE_DIR ?? path.join(root, '.visual-captures'), + captureDir ?? path.join(root, '.visual-captures'), `${arg}${ext}`, ); },