diff --git a/tests/parity/visual/screenshot-diff-multi.ts b/tests/parity/visual/screenshot-diff-multi.ts new file mode 100644 index 00000000..bd90f720 --- /dev/null +++ b/tests/parity/visual/screenshot-diff-multi.ts @@ -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; + 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 { + 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) + 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); +});