Add visual parity screenshot diff tool and mock appSettings in dev server
Add pixelmatch-based screenshot comparison script that captures Angular (:4200) and React (:8080) at every route and generates pixel diff images. Dev server: add mock /api/appSettings endpoint so Angular can bootstrap when WAF blocks the real API.
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+30
@@ -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):
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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<Buffer> {
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
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() {
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user