diff --git a/package.json b/package.json index f8c6aeea..418cc2aa 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@testing-library/react-hooks": "^8.0.1", "@types/leaflet": "^1.9.21", "@types/node": "^24.0.0", + "@types/pngjs": "^6.0.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -74,6 +75,8 @@ "http-proxy-middleware": "^3.0.5", "https-proxy-agent": "^9.0.0", "jsdom": "^29.0.2", + "pixelmatch": "^7.1.0", + "pngjs": "^7.0.0", "react-test-renderer": "^19.2.5", "sass": "^1.99.0", "typescript": "^5.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 642a30af..48ab9a0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: '@types/node': specifier: ^24.0.0 version: 24.12.2 + '@types/pngjs': + specifier: ^6.0.5 + version: 6.0.5 '@types/react': specifier: ^18.2.0 version: 18.3.28 @@ -144,6 +147,12 @@ importers: jsdom: specifier: ^29.0.2 version: 29.0.2 + pixelmatch: + specifier: ^7.1.0 + version: 7.1.0 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 react-test-renderer: specifier: ^19.2.5 version: 19.2.5(react@18.3.1) @@ -2763,6 +2772,9 @@ packages: '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -5085,6 +5097,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + pkg-dir@3.0.0: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} @@ -5110,6 +5126,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -9999,6 +10019,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 24.12.2 + '@types/prop-types@15.7.15': {} '@types/pug@2.0.10': {} @@ -12631,6 +12655,10 @@ snapshots: pirates@4.0.7: {} + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + pkg-dir@3.0.0: dependencies: find-up: 3.0.0 @@ -12657,6 +12685,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-calc@10.1.1(postcss@8.5.9): diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs index 3ed88666..2989d3dc 100644 --- a/scripts/dev-server.mjs +++ b/scripts/dev-server.mjs @@ -40,6 +40,24 @@ await new Promise((r) => setTimeout(r, 18000)); const app = express(); +// --- Mock fallback for /api/appSettings when WAF blocks requests --- +app.get("/api/appSettings", (req, res) => { + res.json({ + showDebugVersion: "False", + uiOptions: { + isTestVersion: "", + filter: { + schedule: { searchFrom: "30d", searchTo: "30d", timeStep: "" }, + onlineboard: { searchFrom: "2d", searchTo: "14d", timeStep: "" }, + }, + buttons: { + buyTicket: { period: { min: "2h", max: "72h" } }, + flightStatus: { availableFrom: "24h", visible: "" }, + }, + }, + }); +}); + // --- API proxy via curl (bypasses WAF TLS fingerprinting) --- app.use(["/api", "/flights"], (req, res) => { const targetUrl = `${API_TARGET}${req.originalUrl}`; diff --git a/tests/parity/visual/screenshot-diff.ts b/tests/parity/visual/screenshot-diff.ts new file mode 100644 index 00000000..f4a0a2c3 --- /dev/null +++ b/tests/parity/visual/screenshot-diff.ts @@ -0,0 +1,280 @@ +/** + * 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); +});