/** * Visual parity comparison: Angular vs React. * * Takes full-page screenshots of both apps for each route, * then generates pixel-diff images using pixelmatch. * * Usage: * npx tsx tests/parity/visual/screenshot-diff.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"; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- const ANGULAR_BASE = "http://localhost:4200"; const REACT_BASE = "http://localhost:8080"; const OUTPUT_DIR = resolve(import.meta.dirname, "../../../screenshot-diffs"); const VIEWPORT = { width: 1440, height: 900 }; // 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; } 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: "schedule-start", angular: "/schedule", react: "/ru/schedule", }, { name: "schedule-route", angular: "/schedule/route/MOW-KUF-20220425-20220501", react: "/ru/schedule/route/MOW-KUF-20220425-20220501", 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 captureScreenshot( page: Page, url: string, waitMs: number, fullPage: boolean, ): Promise { await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); await page.waitForTimeout(waitMs); return page.screenshot({ fullPage, type: "png" }) as Promise; } 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() { mkdirSync(OUTPUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true }); const angularCtx = await browser.newContext({ viewport: VIEWPORT }); const reactCtx = await browser.newContext({ viewport: VIEWPORT }); 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" }, ]), }); }); const results: Array<{ name: string; mismatchPct: string; mismatchCount: number; totalPixels: number; heightDiff: number; error?: string; }> = []; for (const route of ROUTES) { const routeDir = join(OUTPUT_DIR, route.name); mkdirSync(routeDir, { recursive: true }); const waitMs = route.waitMs ?? SETTLE_MS; const fullPage = route.fullPage !== false; 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), captureScreenshot(reactPage, `${REACT_BASE}${route.react}`, waitMs, fullPage), ]); writeFileSync(join(routeDir, "angular.png"), angularBuf); writeFileSync(join(routeDir, "react.png"), reactBuf); const { diffPng, mismatchCount, totalPixels, width, height } = alignAndDiff(angularBuf, reactBuf); const diffBuf = PNG.sync.write(diffPng); writeFileSync(join(routeDir, "diff.png"), diffBuf); const angularPng = PNG.sync.read(angularBuf); const reactPng = PNG.sync.read(reactBuf); const heightDiff = reactPng.height - angularPng.height; const mismatchPct = ((mismatchCount / totalPixels) * 100).toFixed(2); results.push({ name: route.name, mismatchPct, mismatchCount, totalPixels, heightDiff }); console.log(` āœ… ${mismatchPct}% diff (${mismatchCount.toLocaleString()} px), height Ī”=${heightDiff}px`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); results.push({ name: route.name, mismatchPct: "ERR", mismatchCount: -1, totalPixels: -1, heightDiff: 0, error: msg }); console.log(` āŒ Error: ${msg}`); } } // Summary console.log("\n" + "=".repeat(70)); console.log("VISUAL PARITY SUMMARY"); console.log("=".repeat(70)); console.log( "Page".padEnd(30) + "Diff %".padEnd(10) + "Mismatch px".padEnd(15) + "Height Ī”".padEnd(12) + "Status", ); console.log("-".repeat(70)); for (const r of results) { const status = r.error ? `āŒ ${r.error.slice(0, 30)}` : Number(r.mismatchPct) < 0.5 ? "āœ… PASS" : "āš ļø DIFF"; console.log( r.name.padEnd(30) + r.mismatchPct.padEnd(10) + (r.mismatchCount >= 0 ? r.mismatchCount.toLocaleString() : "N/A").padEnd(15) + `${r.heightDiff}px`.padEnd(12) + status, ); } console.log("=".repeat(70)); // Write JSON report writeFileSync( join(OUTPUT_DIR, "report.json"), JSON.stringify({ timestamp: new Date().toISOString(), viewport: VIEWPORT, results }, null, 2), ); console.log(`\nReport: ${join(OUTPUT_DIR, "report.json")}`); console.log(`Screenshots: ${OUTPUT_DIR}/`); await browser.close(); } main().catch((err) => { console.error(err); process.exit(1); });