feat: add AI-powered visual regression testing infrastructure

- Add docker-compose.web-testing.yml with vlmkit service
- Add 5 test scripts: capture, compare, pipeline, console-monitor, link-checker
- Add vrt.config.json with 3 viewports + maskSelectors
- Add package.json with @mizchi/vlmkit and Node >=24
- Add tests/README.md with VRT documentation
- Update .gitignore for test artifacts

Based on APAW issue #144 / milestone #106
This commit is contained in:
NW
2026-06-30 21:09:30 +01:00
parent f2d4d6d3b1
commit 8d85776135
10 changed files with 1049 additions and 44 deletions

View File

@@ -0,0 +1,107 @@
const pixelmatch = require('pixelmatch');
const { PNG } = require('pngjs');
const fs = require('fs');
const path = require('path');
const THRESHOLD = parseFloat(process.env.PIXELMATCH_THRESHOLD) || 0.05;
const BASELINE_DIR = process.env.BASELINE_DIR || './screenshots/baseline';
const CURRENT_DIR = process.env.CURRENT_DIR || './screenshots/current';
const DIFF_DIR = process.env.DIFF_DIR || './screenshots/diffs';
const REPORTS_DIR = process.env.REPORTS_DIR || './reports';
function readPNG(filepath) {
const data = fs.readFileSync(filepath);
return PNG.sync.read(data);
}
function compareImages(baselinePath, currentPath, diffPath) {
const img1 = readPNG(baselinePath);
const img2 = readPNG(currentPath);
const { width, height } = img1;
const diff = new PNG({ width, height });
const mismatchedPixels = pixelmatch(img1.data, img2.data, diff.data, width, height, {
threshold: THRESHOLD,
});
const totalPixels = width * height;
const mismatchPercent = (mismatchedPixels / totalPixels) * 100;
if (mismatchedPixels > 0) {
fs.mkdirSync(path.dirname(diffPath), { recursive: true });
fs.writeFileSync(diffPath, PNG.sync.write(diff));
}
return { mismatchedPixels, totalPixels, mismatchPercent, diffCreated: mismatchedPixels > 0 };
}
function findPNGFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter(f => f.endsWith('.png'));
}
async function main() {
const baselineFiles = findPNGFiles(BASELINE_DIR);
const currentFiles = findPNGFiles(CURRENT_DIR);
if (baselineFiles.length === 0) {
console.error(`No baseline screenshots found in ${BASELINE_DIR}`);
process.exit(1);
}
if (currentFiles.length === 0) {
console.error(`No current screenshots found in ${CURRENT_DIR}`);
process.exit(1);
}
const allFiles = [...new Set([...baselineFiles, ...currentFiles])];
const comparisons = [];
for (const file of allFiles) {
const baselinePath = path.join(BASELINE_DIR, file);
const currentPath = path.join(CURRENT_DIR, file);
if (!fs.existsSync(baselinePath)) {
comparisons.push({ file, status: 'missing_baseline' });
continue;
}
if (!fs.existsSync(currentPath)) {
comparisons.push({ file, status: 'missing_current' });
continue;
}
try {
const diffPath = path.join(DIFF_DIR, file);
const result = compareImages(baselinePath, currentPath, diffPath);
comparisons.push({ file, status: result.mismatchedPixels === 0 ? 'pass' : 'fail', ...result });
} catch (err) {
comparisons.push({ file, status: 'error', error: err.message });
}
}
const passed = comparisons.filter(c => c.status === 'pass').length;
const failed = comparisons.filter(c => c.status === 'fail').length;
const errors = comparisons.filter(c => c.status === 'error').length;
const missing = comparisons.filter(c => c.status === 'missing_baseline' || c.status === 'missing_current').length;
const report = {
timestamp: new Date().toISOString(),
threshold: THRESHOLD,
summary: { total: comparisons.length, passed, failed, errors, missing },
comparisons,
};
fs.mkdirSync(REPORTS_DIR, { recursive: true });
const reportPath = path.join(REPORTS_DIR, 'visual-test-report.json');
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`Report saved to ${reportPath}`);
console.log(`Passed: ${passed}, Failed: ${failed}, Errors: ${errors}, Missing: ${missing}`);
if (failed > 0 || errors > 0) process.exit(1);
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});