Add multi-viewport screenshot diff script for visual parity pipeline

Extends the single-viewport screenshot-diff.ts pattern to capture at
3 viewports (desktop 1440, tablet 768, mobile 375), supports masking
dynamic content via CSS selectors, and outputs structured JSON report
to comparison-report/visual/ for downstream report generation.
This commit is contained in:
2026-04-16 17:37:17 +03:00
parent 97c4def0cc
commit 1a03d4ae13
@@ -0,0 +1,388 @@
/**
* Multi-viewport visual parity comparison: Angular vs React.
*
* Captures full-page screenshots at 3 viewports (desktop, tablet, mobile)
* for each route, generates pixel-diff images, and outputs a structured
* JSON report for the comparison report pipeline.
*
* Usage:
* npx tsx tests/parity/visual/screenshot-diff-multi.ts
*
* Prerequisites:
* - Angular running on http://localhost:4200
* - React running on http://localhost:8080
* - Playwright browsers installed (npx playwright install chromium)
*/
import { chromium, type Page } from "@playwright/test";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve, join } from "node:path";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
import { mockAngularAPIs } from "../../e2e-angular/support/angular-api-mock.js";
// ---------------------------------------------------------------------------
// Exported types (consumed by report generators)
// ---------------------------------------------------------------------------
export interface ScreenshotResult {
route: string;
viewport: string;
angularPath: string;
reactPath: string;
diffPath: string;
mismatchPct: number;
mismatchCount: number;
totalPixels: number;
heightDiff: number;
angularDimensions: { width: number; height: number };
reactDimensions: { width: number; height: number };
error?: string;
}
export interface MultiViewportReport {
timestamp: string;
viewports: Record<string, { width: number; height: number }>;
results: ScreenshotResult[];
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const ANGULAR_BASE = "http://localhost:4200";
const REACT_BASE = "http://localhost:8080";
const REPORT_DIR = resolve(import.meta.dirname, "../../../comparison-report/visual");
const SCREENSHOTS_DIR = join(REPORT_DIR, "screenshots");
const DIFFS_DIR = join(REPORT_DIR, "diffs");
const VIEWPORTS = {
desktop: { width: 1440, height: 900 },
tablet: { width: 768, height: 1024 },
mobile: { width: 375, height: 812 },
} as const;
/** Wait for network idle + extra settle time for animations. */
const SETTLE_MS = 1500;
interface RouteEntry {
name: string;
angular: string;
react: string;
/** Extra wait time in ms for pages that load data. */
waitMs?: number;
/** Scroll to capture full page? Default true. */
fullPage?: boolean;
/** CSS selectors of elements to mask (hide) before capture — use for dynamic content. */
maskSelectors?: string[];
}
const ROUTES: RouteEntry[] = [
{
name: "onlineboard-start",
angular: "/",
react: "/ru/onlineboard",
},
{
name: "onlineboard-departure",
angular: "/onlineboard/departure/AAQ/16042026-0000-2400",
react: "/ru/onlineboard/departure/AAQ/16042026-0000-2400",
waitMs: 3000,
},
{
name: "onlineboard-arrival",
angular: "/onlineboard/arrival/AAQ/16042026-0000-2400",
react: "/ru/onlineboard/arrival/AAQ/16042026-0000-2400",
waitMs: 3000,
},
{
name: "onlineboard-route",
angular: "/onlineboard/route/MOW-AER/16042026-0000-2400",
react: "/ru/onlineboard/route/MOW-AER/16042026-0000-2400",
waitMs: 3000,
},
{
name: "onlineboard-flight",
angular: "/onlineboard/flight/SU0022/16042026",
react: "/ru/onlineboard/flight/SU0022/16042026",
waitMs: 3000,
},
{
name: "onlineboard-details",
angular: "/onlineboard/SU0022-16042026",
react: "/ru/onlineboard/SU0022-16042026",
waitMs: 3000,
},
{
name: "schedule-start",
angular: "/schedule",
react: "/ru/schedule",
},
{
name: "schedule-route",
angular: "/schedule/route/MOW-KUF-20260416-20260423",
react: "/ru/schedule/route/MOW-KUF-20260416-20260423",
waitMs: 3000,
},
{
name: "flights-map",
angular: "/flights-map",
react: "/ru/flights-map",
waitMs: 2000,
},
{
name: "error-404",
angular: "/error/404",
react: "/error/404",
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function applyMasks(page: Page, selectors: string[]): Promise<void> {
if (selectors.length === 0) return;
await page.evaluate((sels) => {
for (const sel of sels) {
document.querySelectorAll(sel).forEach((el) => {
(el as HTMLElement).style.visibility = "hidden";
});
}
}, selectors);
}
async function captureScreenshot(
page: Page,
url: string,
waitMs: number,
fullPage: boolean,
maskSelectors: string[],
): Promise<Buffer> {
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
await page.waitForTimeout(waitMs);
await applyMasks(page, maskSelectors);
return page.screenshot({ fullPage, type: "png" }) as Promise<Buffer>;
}
function alignAndDiff(
angularBuf: Buffer,
reactBuf: Buffer,
): { diffPng: PNG; mismatchCount: number; totalPixels: number; width: number; height: number } {
const angularPng = PNG.sync.read(angularBuf);
const reactPng = PNG.sync.read(reactBuf);
// Use the larger dimensions to avoid clipping
const width = Math.max(angularPng.width, reactPng.width);
const height = Math.max(angularPng.height, reactPng.height);
// Create canvases of uniform size, fill with white
const padded = (src: PNG): Buffer => {
const out = new PNG({ width, height });
// Fill white
for (let i = 0; i < out.data.length; i += 4) {
out.data[i] = 255;
out.data[i + 1] = 255;
out.data[i + 2] = 255;
out.data[i + 3] = 255;
}
// Copy source pixels
for (let y = 0; y < src.height; y++) {
for (let x = 0; x < src.width; x++) {
const srcIdx = (y * src.width + x) * 4;
const dstIdx = (y * width + x) * 4;
out.data[dstIdx] = src.data[srcIdx]!;
out.data[dstIdx + 1] = src.data[srcIdx + 1]!;
out.data[dstIdx + 2] = src.data[srcIdx + 2]!;
out.data[dstIdx + 3] = src.data[srcIdx + 3]!;
}
}
return PNG.sync.write(out);
};
const aPadded = PNG.sync.read(padded(angularPng));
const rPadded = PNG.sync.read(padded(reactPng));
const diffPng = new PNG({ width, height });
const mismatchCount = pixelmatch(
aPadded.data,
rPadded.data,
diffPng.data,
width,
height,
{ threshold: 0.1, alpha: 0.3 },
);
return { diffPng, mismatchCount, totalPixels: width * height, width, height };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
// Create output directories
for (const app of ["angular", "react"] as const) {
mkdirSync(join(SCREENSHOTS_DIR, app), { recursive: true });
}
mkdirSync(DIFFS_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const results: ScreenshotResult[] = [];
for (const [vpName, vpSize] of Object.entries(VIEWPORTS)) {
console.log(`\n${"=".repeat(60)}`);
console.log(`VIEWPORT: ${vpName} (${vpSize.width}x${vpSize.height})`);
console.log("=".repeat(60));
const angularCtx = await browser.newContext({ viewport: vpSize });
const reactCtx = await browser.newContext({ viewport: vpSize });
const angularPage = await angularCtx.newPage();
const reactPage = await reactCtx.newPage();
// Mock API endpoints for both apps (WAF blocks real API in dev)
await mockAngularAPIs(angularPage);
await mockAngularAPIs(reactPage);
// Override popular requests mock for React (React uses different field names)
await reactPage.route("**/Requests/*/getpopular", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ mode: "FlightNumber", carrier: "SU", flightNumber: "0654", type: "Onlineboard" },
{ mode: "Route", departure: "LED", arrival: "KRR", type: "Onlineboard" },
{ mode: "Route", departure: "VKO", arrival: "KUF", type: "Onlineboard" },
{ mode: "Arrival", arrival: "VKO", type: "Onlineboard" },
]),
});
});
for (const route of ROUTES) {
const fileName = `${route.name}-${vpName}.png`;
const angularPath = join(SCREENSHOTS_DIR, "angular", fileName);
const reactPath = join(SCREENSHOTS_DIR, "react", fileName);
const diffPath = join(DIFFS_DIR, fileName);
const waitMs = route.waitMs ?? SETTLE_MS;
const fullPage = route.fullPage !== false;
const maskSelectors = route.maskSelectors ?? [];
console.log(`\n ${route.name}`);
console.log(` Angular: ${ANGULAR_BASE}${route.angular}`);
console.log(` React: ${REACT_BASE}${route.react}`);
try {
const [angularBuf, reactBuf] = await Promise.all([
captureScreenshot(angularPage, `${ANGULAR_BASE}${route.angular}`, waitMs, fullPage, maskSelectors),
captureScreenshot(reactPage, `${REACT_BASE}${route.react}`, waitMs, fullPage, maskSelectors),
]);
writeFileSync(angularPath, angularBuf);
writeFileSync(reactPath, reactBuf);
const { diffPng, mismatchCount, totalPixels } = alignAndDiff(angularBuf, reactBuf);
const diffBuf = PNG.sync.write(diffPng);
writeFileSync(diffPath, diffBuf);
const angularPng = PNG.sync.read(angularBuf);
const reactPng = PNG.sync.read(reactBuf);
const heightDiff = reactPng.height - angularPng.height;
const mismatchPct = (mismatchCount / totalPixels) * 100;
results.push({
route: route.name,
viewport: vpName,
angularPath,
reactPath,
diffPath,
mismatchPct,
mismatchCount,
totalPixels,
heightDiff,
angularDimensions: { width: angularPng.width, height: angularPng.height },
reactDimensions: { width: reactPng.width, height: reactPng.height },
});
console.log(` ${mismatchPct.toFixed(2)}% diff (${mismatchCount.toLocaleString()} px), height delta=${heightDiff}px`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
results.push({
route: route.name,
viewport: vpName,
angularPath,
reactPath,
diffPath,
mismatchPct: -1,
mismatchCount: -1,
totalPixels: -1,
heightDiff: 0,
angularDimensions: { width: 0, height: 0 },
reactDimensions: { width: 0, height: 0 },
error: msg,
});
console.log(` ERROR: ${msg}`);
}
}
await angularCtx.close();
await reactCtx.close();
}
// ---------------------------------------------------------------------------
// Summary table
// ---------------------------------------------------------------------------
console.log("\n" + "=".repeat(85));
console.log("MULTI-VIEWPORT VISUAL PARITY SUMMARY");
console.log("=".repeat(85));
console.log(
"Route".padEnd(28) +
"Viewport".padEnd(10) +
"Diff %".padEnd(10) +
"Mismatch px".padEnd(15) +
"Height delta".padEnd(14) +
"Status",
);
console.log("-".repeat(85));
for (const r of results) {
const pct = r.error ? "ERR" : r.mismatchPct.toFixed(2);
const status = r.error
? `FAIL ${r.error.slice(0, 25)}`
: r.mismatchPct < 0.5
? "PASS"
: "DIFF";
console.log(
r.route.padEnd(28) +
r.viewport.padEnd(10) +
pct.padEnd(10) +
(r.mismatchCount >= 0 ? r.mismatchCount.toLocaleString() : "N/A").padEnd(15) +
`${r.heightDiff}px`.padEnd(14) +
status,
);
}
console.log("=".repeat(85));
// ---------------------------------------------------------------------------
// JSON report
// ---------------------------------------------------------------------------
const report: MultiViewportReport = {
timestamp: new Date().toISOString(),
viewports: { ...VIEWPORTS },
results,
};
const reportPath = join(REPORT_DIR, "report.json");
writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`\nReport: ${reportPath}`);
console.log(`Screenshots: ${SCREENSHOTS_DIR}/`);
console.log(`Diffs: ${DIFFS_DIR}/`);
await browser.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});