f1f0030b69
Schedule list day accordion stays collapsed by default but auto- opens the day matching today's date when it's in the visible week window — mirrors Angular's p-accordion default-active behaviour where today's flights are visible without a click. The user can still collapse it; we never re-open after that for the same date. Visual-diff URLs were hardcoded to a past date with the wrong React URL format (Angular path-style /AAQ/16042026 instead of React's single-segment /AAQ-20260420-00002400). Switch to dynamic yyyyMMdd of today for onlineboard pages and Mon→Sun of the current week for schedule. Schedule-route diff dropped from ~91% to ~28% on desktop after these two fixes.
436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
/**
|
|
* 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<string, { width: number; height: number }>;
|
|
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<void> {
|
|
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<Buffer> {
|
|
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<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() {
|
|
// 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);
|
|
});
|