mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
94
ccw/tests/visual/helpers/visual-tester.test.ts
Normal file
94
ccw/tests/visual/helpers/visual-tester.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
164
ccw/tests/visual/helpers/visual-tester.ts
Normal file
164
ccw/tests/visual/helpers/visual-tester.ts
Normal 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
4
ccw/tests/visual/snapshots/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
current/*
|
||||
diff/*
|
||||
!current/.gitkeep
|
||||
!diff/.gitkeep
|
||||
1
ccw/tests/visual/snapshots/baseline/.gitkeep
Normal file
1
ccw/tests/visual/snapshots/baseline/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Intentionally tracked so baseline snapshots can be committed.
|
||||
1
ccw/tests/visual/snapshots/current/.gitkeep
Normal file
1
ccw/tests/visual/snapshots/current/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Intentionally left blank (ignored folder placeholder).
|
||||
1
ccw/tests/visual/snapshots/diff/.gitkeep
Normal file
1
ccw/tests/visual/snapshots/diff/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Intentionally left blank (ignored folder placeholder).
|
||||
1
ccw/tests/visual/specs/.gitkeep
Normal file
1
ccw/tests/visual/specs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder for visual specs.
|
||||
94
package-lock.json
generated
94
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
1
visual-tester.test.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './ccw/tests/visual/helpers/visual-tester.test.ts';
|
||||
Reference in New Issue
Block a user