/** * 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; results: ScreenshotResult[]; } // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- // Override either base via env vars to point at staging/live envs. // `ANGULAR_PATH_PREFIX` is prepended to each route's `angular` path, used // when the live env requires a locale segment (`/ru-ru`) that the local // Angular dev server doesn't. // `MOCK_ANGULAR=0` disables the API mock route handlers — required when // pointing at a real backend (mocks blackhole real API requests). const ANGULAR_BASE = process.env.ANGULAR_BASE ?? "http://localhost:4200"; const ANGULAR_PATH_PREFIX = process.env.ANGULAR_PATH_PREFIX ?? ""; const REACT_BASE = process.env.REACT_BASE ?? "http://localhost:8080"; const MOCK_ANGULAR = process.env.MOCK_ANGULAR !== "0"; const MOCK_REACT = process.env.MOCK_REACT !== "0"; 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[]; } /** * URL conventions * * Angular and React use *different* path separators in their search URLs: * Angular: `/onlineboard/departure/AAQ/16042026-0000-2400` (slash between * station and date, hyphen between dateParts and time range) * React: `/onlineboard/departure/AAQ-16042026-00002400` (one * single-segment param: STATION-yyyyMMdd-HHHHHHHH) * * Schedule URLs share the same `MOW-KUF-yyyyMMdd-yyyyMMdd` shape on both. * * Dates default to today (yyyyMMdd) and the current Monday→Sunday week, * so the diff stays valid across runs and the API actually returns data. * Override via env: TODAY=20260420 / SCHEDULE_FROM=20260420 / SCHEDULE_TO=20260426. */ function ymd(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}${m}${day}`; } const TODAY_YMD = process.env.TODAY ?? ymd(new Date()); const _today = new Date( Number(TODAY_YMD.slice(0, 4)), Number(TODAY_YMD.slice(4, 6)) - 1, Number(TODAY_YMD.slice(6, 8)), ); const _monday = new Date(_today); _monday.setDate(_today.getDate() - ((_today.getDay() + 6) % 7)); const _sunday = new Date(_monday); _sunday.setDate(_monday.getDate() + 6); const SCHEDULE_FROM = process.env.SCHEDULE_FROM ?? ymd(_monday); const SCHEDULE_TO = process.env.SCHEDULE_TO ?? ymd(_sunday); const ROUTES: RouteEntry[] = [ { name: "onlineboard-start", angular: "/", react: "/ru/onlineboard", }, { name: "onlineboard-departure", angular: `/onlineboard/departure/AAQ/${TODAY_YMD}-0000-2400`, react: `/ru/onlineboard/departure/AAQ-${TODAY_YMD}-00002400`, waitMs: 3000, }, { name: "onlineboard-arrival", angular: `/onlineboard/arrival/AAQ/${TODAY_YMD}-0000-2400`, react: `/ru/onlineboard/arrival/AAQ-${TODAY_YMD}-00002400`, waitMs: 3000, }, { name: "onlineboard-route", angular: `/onlineboard/route/MOW-AER/${TODAY_YMD}-0000-2400`, react: `/ru/onlineboard/route/MOW-AER-${TODAY_YMD}-00002400`, waitMs: 3000, }, { name: "onlineboard-flight", angular: `/onlineboard/flight/SU0022/${TODAY_YMD}`, react: `/ru/onlineboard/flight/SU0022-${TODAY_YMD}`, waitMs: 3000, }, { name: "onlineboard-details", angular: `/onlineboard/SU0022-${TODAY_YMD}`, react: `/ru/onlineboard/SU0022-${TODAY_YMD}`, waitMs: 3000, }, { name: "schedule-start", angular: "/schedule", react: "/ru/schedule", }, { name: "schedule-route", angular: `/schedule/route/MOW-KUF-${SCHEDULE_FROM}-${SCHEDULE_TO}`, react: `/ru/schedule/route/MOW-KUF-${SCHEDULE_FROM}-${SCHEDULE_TO}`, 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 { 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 { 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; } 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). // When pointing at a real env (live test/staging), opt out via // MOCK_ANGULAR=0 / MOCK_REACT=0 so the page hits the real backend. if (MOCK_ANGULAR) await mockAngularAPIs(angularPage); if (MOCK_REACT) 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 ?? []; const angularUrl = `${ANGULAR_BASE}${ANGULAR_PATH_PREFIX}${route.angular}`; const reactUrl = `${REACT_BASE}${route.react}`; console.log(`\n ${route.name}`); console.log(` Angular: ${angularUrl}`); console.log(` React: ${reactUrl}`); try { const [angularBuf, reactBuf] = await Promise.all([ captureScreenshot(angularPage, angularUrl, waitMs, fullPage, maskSelectors), captureScreenshot(reactPage, reactUrl, 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); });