bel: string;
width: number;
height: number;
}
interface RouteTarget {
path: string;
viewports: ViewportConfig[];
}
interface DiffResult {
exceeded: boolean;
diffPercentage: number;
diffPixelCount: number;
diffBuffer: Buffer;
}
interface CaptureClientConfig {
apiKey: string;
baseUrl: string;
timeoutMs: number;
}
class VisualRegressionEngine {
private config: CaptureClientConfig;
private baselineDir: string;
private diffDir: string;
constructor(config: CaptureClientConfig, baselineDir: string, diffDir: string) {
this.config = config;
this.baselineDir = baselineDir;
this.diffDir = diffDir;
}
async initializeDirectories(): Promise<void> {
await mkdir(this.baselineDir, { recursive: true });
await mkdir(this.diffDir, { recursive: true });
}
async captureBaseline(route: RouteTarget): Promise<void> {
for (const vp of route.viewports) {
const filename = ${route.path.replace(/\//g, '_')}_${vp.label}.png;
const targetUrl = new URL(${this.config.baseUrl}/v1/render);
targetUrl.searchParams.set('url', `https://app.example.com${route.path}`);
targetUrl.searchParams.set('viewport_w', String(vp.width));
targetUrl.searchParams.set('viewport_h', String(vp.height));
targetUrl.searchParams.set('format', 'png');
targetUrl.searchParams.set('wait_until', 'networkidle');
const response = await fetch(targetUrl.toString(), {
headers: { Authorization: `Bearer ${this.config.apiKey}` },
signal: AbortSignal.timeout(this.config.timeoutMs),
});
if (!response.ok) throw new Error(`Capture failed: ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile(join(this.baselineDir, filename), buffer);
console.log(`[Baseline] Stored: ${filename}`);
}
}
async compareAgainstBaseline(route: RouteTarget, thresholdPercent: number): Promise<DiffResult[]> {
const results: DiffResult[] = [];
for (const vp of route.viewports) {
const filename = `${route.path.replace(/\//g, '_')}_${vp.label}.png`;
const baselinePath = join(this.baselineDir, filename);
const currentPath = join(this.diffDir, `current_${filename}`);
// Capture current state
const targetUrl = new URL(`${this.config.baseUrl}/v1/render`);
targetUrl.searchParams.set('url', `https://app.example.com${route.path}`);
targetUrl.searchParams.set('viewport_w', String(vp.width));
targetUrl.searchParams.set('viewport_h', String(vp.height));
targetUrl.searchParams.set('format', 'png');
targetUrl.searchParams.set('wait_until', 'networkidle');
const response = await fetch(targetUrl.toString(), {
headers: { Authorization: `Bearer ${this.config.apiKey}` },
});
const currentBuffer = Buffer.from(await response.arrayBuffer());
await writeFile(currentPath, currentBuffer);
// Run diff
const diff = await this.computePixelDiff(baselinePath, currentPath, thresholdPercent);
results.push(diff);
}
return results;
}
private async computePixelDiff(
baselinePath: string,
currentPath: string,
threshold: number
): Promise<DiffResult> {
const [baselineImg, currentImg] = await Promise.all([
loadImage(baselinePath),
loadImage(currentPath),
]);
if (baselineImg.width !== currentImg.width || baselineImg.height !== currentImg.height) {
return {
exceeded: true,
diffPercentage: 100,
diffPixelCount: 0,
diffBuffer: Buffer.alloc(0),
};
}
const canvas = createCanvas(baselineImg.width, baselineImg.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(baselineImg, 0, 0);
const baselineData = ctx.getImageData(0, 0, canvas.width, canvas.height);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(currentImg, 0, 0);
const currentData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const diffCanvas = createCanvas(canvas.width, canvas.height);
const diffCtx = diffCanvas.getContext('2d');
const outputData = diffCtx.createImageData(canvas.width, canvas.height);
let mismatchCount = 0;
const noiseFloor = 12; // Ignores sub-pixel anti-aliasing variance
for (let i = 0; i < baselineData.data.length; i += 4) {
const rDelta = Math.abs(baselineData.data[i] - currentData.data[i]);
const gDelta = Math.abs(baselineData.data[i + 1] - currentData.data[i + 1]);
const bDelta = Math.abs(baselineData.data[i + 2] - currentData.data[i + 2]);
const avgDelta = (rDelta + gDelta + bDelta) / 3;
if (avgDelta > noiseFloor) {
mismatchCount++;
outputData.data[i] = 255;
outputData.data[i + 1] = 0;
outputData.data[i + 2] = 0;
outputData.data[i + 3] = 255;
} else {
const dimFactor = 0.25;
outputData.data[i] = baselineData.data[i] * dimFactor;
outputData.data[i + 1] = baselineData.data[i + 1] * dimFactor;
outputData.data[i + 2] = baselineData.data[i + 2] * dimFactor;
outputData.data[i + 3] = 255;
}
}
diffCtx.putImageData(outputData, 0, 0);
const diffBuffer = diffCanvas.toBuffer('image/png');
const totalPixels = canvas.width * canvas.height;
const diffPercent = (mismatchCount / totalPixels) * 100;
return {
exceeded: diffPercent > threshold,
diffPercentage: parseFloat(diffPercent.toFixed(3)),
diffPixelCount: mismatchCount,
diffBuffer,
};
}
}
export { VisualRegressionEngine };
### Why This Structure Works
- **Typed Configuration Matrix:** Routes and viewports are defined as structured objects rather than hardcoded arrays. This enables programmatic generation from a CMS or design system manifest.
- **Noise Floor Filtering:** The `noiseFloor` constant (set to 12) filters out anti-aliasing artifacts and sub-pixel rounding differences that plague exact-match implementations.
- **Dimmed Baseline Output:** Unchanged pixels are dimmed to 25% opacity in the diff image. This forces the reviewer's eye directly to the regression area, reducing triage time.
- **AbortSignal Timeout:** Network requests include explicit timeout handling, preventing CI runners from hanging indefinitely on unresponsive rendering endpoints.
## Pitfall Guide
### 1. Dynamic Content Leakage
**Explanation:** Timestamps, user avatars, ad slots, or session-specific banners change on every render, triggering false positives.
**Fix:** Inject a stylesheet before capture that targets dynamic nodes: `.dynamic-banner { visibility: hidden !important; } .user-avatar { background: #e5e7eb !important; }`. Most screenshot APIs support a `custom_css` parameter for this exact purpose.
### 2. Anti-Aliasing Noise Overload
**Explanation:** Sub-pixel rendering differences between CI environments and local machines cause isolated pixel mismatches that accumulate into threshold violations.
**Fix:** Never use a 0% threshold. Start at 0.1%, analyze diff images weekly, and adjust per component. Implement a cluster filter that ignores mismatches smaller than 3x3 pixels.
### 3. Viewport Drift Across Environments
**Explanation:** Local macOS rendering uses Core Text and sub-pixel smoothing, while Linux CI runners use FreeType. Font metrics shift, causing layout differences even with identical CSS.
**Fix:** Rely entirely on cloud rendering APIs for baseline and comparison captures. If local fallback is required, standardize the CI base image with identical font packages and disable hardware acceleration flags.
### 4. Baseline Rot
**Explanation:** Intentional UI changes ship without updating reference images, causing the pipeline to fail indefinitely. Developers eventually disable the check to unblock deployments.
**Fix:** Mandate baseline commits in every PR that modifies layout. Provide a `--update-baseline` CLI flag that overwrites reference images and requires explicit approval in the PR description.
### 5. Network Race Conditions
**Explanation:** Screenshots capture before async data loads, infinite scroll triggers, or lazy images resolve. The diff compares a loaded state against a partially rendered state.
**Fix:** Enforce `wait_until: networkidle` or explicit selector waits (e.g., `wait_for_selector: .content-loaded`). Verify that all critical assets have resolved before triggering the capture request.
### 6. Threshold Misconfiguration
**Explanation:** Setting thresholds uniformly across all routes ignores component criticality. A 0.5% shift on a marketing hero banner is acceptable; the same shift on a checkout form is catastrophic.
**Fix:** Implement route-specific thresholds in your configuration matrix. Critical paths use 0.1%, marketing pages use 0.5%, and data-heavy dashboards use 1.0% with cluster filtering.
### 7. Ignoring Mobile Viewports
**Explanation:** Desktop-only monitoring misses responsive breakpoints where >60% of traffic originates. CSS media queries often break layout on specific device widths.
**Fix:** Include a responsive matrix by default: `390x844` (iPhone), `414x896` (iPhone Max), `768x1024` (iPad), `1440x900` (Desktop). Capture all viewports in parallel to minimize pipeline latency.
## Production Bundle
### Action Checklist
- [ ] Define viewport matrix: Map critical breakpoints to route targets before writing capture logic.
- [ ] Configure noise filtering: Set `noiseFloor` to 10β15 and threshold to 0.1% for production routes.
- [ ] Inject dynamic content masks: Use API `custom_css` or `hide_selectors` parameters to neutralize timestamps and ads.
- [ ] Implement baseline versioning: Store reference images in a dedicated `visual-baselines/` directory with Git LFS for large assets.
- [ ] Add CI artifact retention: Configure pipeline to upload diff images only on failure, retaining them for 14 days.
- [ ] Enforce network idle waits: Always pass `wait_until: networkidle` or explicit DOM readiness selectors to the capture API.
- [ ] Schedule weekly threshold audits: Review diff images monthly to adjust thresholds and remove obsolete routes.
### Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|----------|---------------------|-----|-------------|
| Static Marketing Site | Cloud API + 0.5% threshold | Low dynamic content, high visual polish requirements | Low (few routes, fast renders) |
| Dynamic SaaS Dashboard | Cloud API + 1.0% threshold + cluster filter | Heavy async data, acceptable layout variance | Medium (more routes, longer waits) |
| E-commerce Checkout | Cloud API + 0.1% threshold + strict masks | Zero tolerance for layout shifts, high revenue impact | High (requires precise masking, frequent baseline updates) |
| Internal Admin Panel | Local headless browser + 0.3% threshold | Lower traffic, acceptable environment variance | Low (no API costs, but higher maintenance) |
### Configuration Template
```typescript
// visual-regression.config.ts
import { VisualRegressionEngine } from './engine';
export const ENGINE_CONFIG = {
apiKey: process.env.RENDER_API_KEY!,
baseUrl: 'https://render.provider.io',
timeoutMs: 15000,
};
export const BASELINE_DIR = './.visual/baseline';
export const DIFF_DIR = './.visual/diffs';
export const ROUTE_MATRIX = [
{
path: '/',
viewports: [
{ label: 'mobile', width: 390, height: 844 },
{ label: 'desktop', width: 1440, height: 900 },
],
},
{
path: '/auth/login',
viewports: [
{ label: 'mobile', width: 390, height: 844 },
{ label: 'desktop', width: 1440, height: 900 },
],
},
{
path: '/checkout/summary',
viewports: [
{ label: 'mobile', width: 390, height: 844 },
{ label: 'tablet', width: 768, height: 1024 },
{ label: 'desktop', width: 1440, height: 900 },
],
},
];
export const THRESHOLDS: Record<string, number> = {
'/': 0.5,
'/auth/login': 0.1,
'/checkout/summary': 0.1,
};
export const MASK_CSS = `
.ad-container { visibility: hidden !important; }
.timestamp { visibility: hidden !important; }
.user-avatar { background: #e5e7eb !important; }
.dynamic-banner { display: none !important; }
`;
Quick Start Guide
- Install dependencies:
npm install canvas @types/node
- Create configuration: Copy the template above into
visual-regression.config.ts and update ROUTE_MATRIX to match your application routes.
- Generate baselines: Run
node scripts/capture-baseline.mjs to fetch reference images and store them in .visual/baseline/.
- Integrate into CI: Add a pipeline step that runs
node scripts/run-diff.mjs after deployment, failing the job if any route exceeds its configured threshold.
- Verify locally: Open
.visual/diffs/ after a test run. Red highlights indicate regressions; dimmed areas confirm unchanged layout. Adjust THRESHOLDS or MASK_CSS as needed before merging.