From 70063f4045b3f07570f425320128da8759111fdc Mon Sep 17 00:00:00 2001 From: catlog22 Date: Mon, 29 Dec 2025 15:46:02 +0800 Subject: [PATCH] test(ui-tools): add visual regression testing infrastructure Solution-ID: SOL-1735410002 Issue-ID: ISS-1766921318981-22 Task-ID: T1 --- .../visual/helpers/visual-tester.test.ts | 94 ++++++++++ ccw/tests/visual/helpers/visual-tester.ts | 164 ++++++++++++++++++ ccw/tests/visual/snapshots/.gitignore | 4 + ccw/tests/visual/snapshots/baseline/.gitkeep | 1 + ccw/tests/visual/snapshots/current/.gitkeep | 1 + ccw/tests/visual/snapshots/diff/.gitkeep | 1 + ccw/tests/visual/specs/.gitkeep | 1 + package-lock.json | 94 +++++++++- package.json | 4 + visual-tester.test.ts | 1 + 10 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 ccw/tests/visual/helpers/visual-tester.test.ts create mode 100644 ccw/tests/visual/helpers/visual-tester.ts create mode 100644 ccw/tests/visual/snapshots/.gitignore create mode 100644 ccw/tests/visual/snapshots/baseline/.gitkeep create mode 100644 ccw/tests/visual/snapshots/current/.gitkeep create mode 100644 ccw/tests/visual/snapshots/diff/.gitkeep create mode 100644 ccw/tests/visual/specs/.gitkeep create mode 100644 visual-tester.test.ts diff --git a/ccw/tests/visual/helpers/visual-tester.test.ts b/ccw/tests/visual/helpers/visual-tester.test.ts new file mode 100644 index 00000000..be4f0644 --- /dev/null +++ b/ccw/tests/visual/helpers/visual-tester.test.ts @@ -0,0 +1,94 @@ +import { after, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createRequire } from 'node:module'; + +import { compareSnapshots, updateBaseline } from './visual-tester.ts'; + +const require = createRequire(import.meta.url); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { PNG } = require('pngjs') as typeof import('pngjs'); + +const ORIGINAL_ENV = { ...process.env }; + +function writeSolidPng(filePath: string, rgba: [number, number, number, number], pixelCount = 100): void { + const size = Math.max(1, Math.floor(Math.sqrt(pixelCount))); + const png = new PNG({ width: size, height: size }); + + for (let i = 0; i < png.data.length; i += 4) { + png.data[i] = rgba[0]; + png.data[i + 1] = rgba[1]; + png.data[i + 2] = rgba[2]; + png.data[i + 3] = rgba[3]; + } + + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, PNG.sync.write(png)); +} + +describe('visual-tester helpers', () => { + const snapshotRoot = mkdtempSync(join(tmpdir(), 'ccw-visual-snapshots-')); + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV, CCW_VISUAL_SNAPSHOT_ROOT: snapshotRoot }; + }); + + after(() => { + process.env = ORIGINAL_ENV; + rmSync(snapshotRoot, { recursive: true, force: true }); + }); + + it('updates baseline from current snapshots', () => { + const currentPath = join(snapshotRoot, 'current', 'baseline-copy.png'); + writeSolidPng(currentPath, [10, 20, 30, 255]); + + const baselinePath = updateBaseline('baseline-copy'); + assert.deepEqual(readFileSync(baselinePath), readFileSync(currentPath)); + }); + + it('compares identical PNGs as pass', () => { + const baselinePath = join(snapshotRoot, 'baseline', 'same.png'); + const currentPath = join(snapshotRoot, 'current', 'same.png'); + writeSolidPng(currentPath, [255, 0, 0, 255]); + writeSolidPng(baselinePath, [255, 0, 0, 255]); + + const result = compareSnapshots(baselinePath, currentPath); + assert.equal(result.pass, true); + assert.equal(result.diffPixels, 0); + }); + + it('fails and generates a diff PNG when over tolerance', () => { + const baselinePath = join(snapshotRoot, 'baseline', 'different.png'); + const currentPath = join(snapshotRoot, 'current', 'different.png'); + writeSolidPng(baselinePath, [255, 255, 255, 255]); + writeSolidPng(currentPath, [255, 255, 255, 255]); + + const png = PNG.sync.read(readFileSync(currentPath)); + png.data[0] = 0; + writeFileSync(currentPath, PNG.sync.write(png)); + + const result = compareSnapshots(baselinePath, currentPath, 0.1); + assert.equal(result.pass, false); + assert.ok(result.diffPath); + assert.ok(readFileSync(result.diffPath!).length > 0); + }); + + it('respects configured tolerance percent', () => { + const baselinePath = join(snapshotRoot, 'baseline', 'tolerance.png'); + const currentPath = join(snapshotRoot, 'current', 'tolerance.png'); + writeSolidPng(baselinePath, [0, 0, 0, 255]); + writeSolidPng(currentPath, [0, 0, 0, 255]); + + const png = PNG.sync.read(readFileSync(currentPath)); + png.data[0] = 255; + writeFileSync(currentPath, PNG.sync.write(png)); + + const failResult = compareSnapshots(baselinePath, currentPath, 0.1); + assert.equal(failResult.pass, false); + + const passResult = compareSnapshots(baselinePath, currentPath, 100); + assert.equal(passResult.pass, true); + }); +}); diff --git a/ccw/tests/visual/helpers/visual-tester.ts b/ccw/tests/visual/helpers/visual-tester.ts new file mode 100644 index 00000000..1fe464fd --- /dev/null +++ b/ccw/tests/visual/helpers/visual-tester.ts @@ -0,0 +1,164 @@ +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +const require = createRequire(import.meta.url); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { PNG } = require('pngjs') as typeof import('pngjs'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pixelmatchModule = require('pixelmatch'); +const pixelmatch = + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + typeof pixelmatchModule === 'function' ? pixelmatchModule : pixelmatchModule.default; + +const DEFAULT_TOLERANCE_PERCENT = 0.1; + +function getSnapshotsRoot(): string { + const envRoot = process.env.CCW_VISUAL_SNAPSHOT_ROOT; + if (envRoot) { + return resolve(process.cwd(), envRoot); + } + + return fileURLToPath(new URL('../snapshots', import.meta.url)); +} + +function ensureSnapshotDirsExist(): { baselineDir: string; currentDir: string; diffDir: string } { + const root = getSnapshotsRoot(); + const baselineDir = join(root, 'baseline'); + const currentDir = join(root, 'current'); + const diffDir = join(root, 'diff'); + + mkdirSync(baselineDir, { recursive: true }); + mkdirSync(currentDir, { recursive: true }); + mkdirSync(diffDir, { recursive: true }); + + return { baselineDir, currentDir, diffDir }; +} + +function toPngName(name: string): string { + const sanitized = name.replace(/[\\/]/g, '_').trim(); + return sanitized.toLowerCase().endsWith('.png') ? sanitized : `${sanitized}.png`; +} + +function getSnapshotPaths(name: string): { baseline: string; current: string; diff: string } { + const fileName = toPngName(name); + const { baselineDir, currentDir, diffDir } = ensureSnapshotDirsExist(); + return { + baseline: join(baselineDir, fileName), + current: join(currentDir, fileName), + diff: join(diffDir, fileName), + }; +} + +type CaptureOptions = { + viewport?: { width: number; height: number }; + fullPage?: boolean; + timeoutMs?: number; + waitForMs?: number; +}; + +export async function captureSnapshot( + url: string, + selector: string | undefined, + name: string, + options?: CaptureOptions +): Promise { + const { current } = getSnapshotPaths(name); + const browser = await chromium.launch(); + + try { + const page = await browser.newPage({ viewport: options?.viewport }); + await page.goto(url, { waitUntil: 'load', timeout: options?.timeoutMs ?? 30_000 }); + + if (options?.waitForMs) { + await page.waitForTimeout(options.waitForMs); + } + + const screenshot = selector + ? await page.locator(selector).screenshot() + : await page.screenshot({ fullPage: options?.fullPage ?? true }); + + writeFileSync(current, screenshot); + return current; + } finally { + await browser.close(); + } +} + +type CompareResult = { + pass: boolean; + baselinePath: string; + currentPath: string; + diffPath?: string; + diffPixels: number; + diffRatio: number; + tolerancePercent: number; +}; + +type CompareOptions = { + pixelmatchThreshold?: number; + diffPath?: string; +}; + +export function compareSnapshots( + baselinePath: string, + currentPath: string, + tolerancePercent: number = DEFAULT_TOLERANCE_PERCENT, + options?: CompareOptions +): CompareResult { + const baselinePng = PNG.sync.read(readFileSync(baselinePath)); + const currentPng = PNG.sync.read(readFileSync(currentPath)); + + if (baselinePng.width !== currentPng.width || baselinePng.height !== currentPng.height) { + throw new Error( + `Snapshot size mismatch: baseline=${baselinePng.width}x${baselinePng.height} current=${currentPng.width}x${currentPng.height}` + ); + } + + const diffPng = new PNG({ width: baselinePng.width, height: baselinePng.height }); + const diffPixels = pixelmatch( + baselinePng.data, + currentPng.data, + diffPng.data, + baselinePng.width, + baselinePng.height, + { threshold: options?.pixelmatchThreshold ?? 0.1 } + ); + + const totalPixels = baselinePng.width * baselinePng.height; + const diffRatio = totalPixels > 0 ? diffPixels / totalPixels : 0; + const pass = diffRatio <= tolerancePercent / 100; + + if (pass) { + return { + pass: true, + baselinePath, + currentPath, + diffPixels, + diffRatio, + tolerancePercent, + }; + } + + const resolvedDiffPath = + options?.diffPath ?? join(ensureSnapshotDirsExist().diffDir, basename(currentPath)); + writeFileSync(resolvedDiffPath, PNG.sync.write(diffPng)); + + return { + pass: false, + baselinePath, + currentPath, + diffPath: resolvedDiffPath, + diffPixels, + diffRatio, + tolerancePercent, + }; +} + +export function updateBaseline(name: string): string { + const { baseline, current } = getSnapshotPaths(name); + copyFileSync(current, baseline); + return baseline; +} diff --git a/ccw/tests/visual/snapshots/.gitignore b/ccw/tests/visual/snapshots/.gitignore new file mode 100644 index 00000000..c7f3c3c1 --- /dev/null +++ b/ccw/tests/visual/snapshots/.gitignore @@ -0,0 +1,4 @@ +current/* +diff/* +!current/.gitkeep +!diff/.gitkeep diff --git a/ccw/tests/visual/snapshots/baseline/.gitkeep b/ccw/tests/visual/snapshots/baseline/.gitkeep new file mode 100644 index 00000000..e968598d --- /dev/null +++ b/ccw/tests/visual/snapshots/baseline/.gitkeep @@ -0,0 +1 @@ +# Intentionally tracked so baseline snapshots can be committed. diff --git a/ccw/tests/visual/snapshots/current/.gitkeep b/ccw/tests/visual/snapshots/current/.gitkeep new file mode 100644 index 00000000..99610900 --- /dev/null +++ b/ccw/tests/visual/snapshots/current/.gitkeep @@ -0,0 +1 @@ +# Intentionally left blank (ignored folder placeholder). diff --git a/ccw/tests/visual/snapshots/diff/.gitkeep b/ccw/tests/visual/snapshots/diff/.gitkeep new file mode 100644 index 00000000..99610900 --- /dev/null +++ b/ccw/tests/visual/snapshots/diff/.gitkeep @@ -0,0 +1 @@ +# Intentionally left blank (ignored folder placeholder). diff --git a/ccw/tests/visual/specs/.gitkeep b/ccw/tests/visual/specs/.gitkeep new file mode 100644 index 00000000..69f0c10c --- /dev/null +++ b/ccw/tests/visual/specs/.gitkeep @@ -0,0 +1 @@ +# Placeholder for visual specs. diff --git a/package-lock.json b/package-lock.json index 817b6a42..1c6466d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-workflow", - "version": "6.3.8", + "version": "6.3.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-workflow", - "version": "6.3.8", + "version": "6.3.13", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", @@ -27,10 +27,14 @@ "ccw-mcp": "ccw/bin/ccw-mcp.js" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@types/better-sqlite3": "^7.6.12", "@types/gradient-string": "^1.1.6", "@types/inquirer": "^9.0.9", "@types/node": "^25.0.1", + "pixelmatch": "^7.1.0", + "playwright": "^1.57.0", + "pngjs": "^7.0.0", "typescript": "^5.9.3" }, "engines": { @@ -172,6 +176,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -1191,6 +1211,21 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2264,6 +2299,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -2273,6 +2321,48 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", diff --git a/package.json b/package.json index cbaf04dc..0e7c1c59 100644 --- a/package.json +++ b/package.json @@ -74,10 +74,14 @@ }, "homepage": "https://github.com/catlog22/Claude-Code-Workflow#readme", "devDependencies": { + "@playwright/test": "^1.57.0", "@types/better-sqlite3": "^7.6.12", "@types/gradient-string": "^1.1.6", "@types/inquirer": "^9.0.9", "@types/node": "^25.0.1", + "pixelmatch": "^7.1.0", + "playwright": "^1.57.0", + "pngjs": "^7.0.0", "typescript": "^5.9.3" } } diff --git a/visual-tester.test.ts b/visual-tester.test.ts new file mode 100644 index 00000000..64eba735 --- /dev/null +++ b/visual-tester.test.ts @@ -0,0 +1 @@ +import './ccw/tests/visual/helpers/visual-tester.test.ts';