test(ui-tools): add visual regression testing infrastructure

Solution-ID: SOL-1735410002
Issue-ID: ISS-1766921318981-22
Task-ID: T1
This commit is contained in:
catlog22
2025-12-29 15:46:02 +08:00
parent 8578d2d426
commit 70063f4045
10 changed files with 363 additions and 2 deletions

View File

@@ -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);
});
});

View File

@@ -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<string> {
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;
}

4
ccw/tests/visual/snapshots/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
current/*
diff/*
!current/.gitkeep
!diff/.gitkeep

View File

@@ -0,0 +1 @@
# Intentionally tracked so baseline snapshots can be committed.

View File

@@ -0,0 +1 @@
# Intentionally left blank (ignored folder placeholder).

View File

@@ -0,0 +1 @@
# Intentionally left blank (ignored folder placeholder).

View File

@@ -0,0 +1 @@
# Placeholder for visual specs.

94
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}
}

1
visual-tester.test.ts Normal file
View File

@@ -0,0 +1 @@
import './ccw/tests/visual/helpers/visual-tester.test.ts';