Add gap analysis script comparing Angular vs React DOM structure per route

This commit is contained in:
2026-04-16 17:46:35 +03:00
parent 712d32ac72
commit e1882f49bc
+547
View File
@@ -0,0 +1,547 @@
/**
* Gap analysis: Angular vs React DOM structure comparison.
*
* Crawls both apps per route, counts DOM elements matching CSS selectors,
* compares counts, and generates a Markdown + JSON gap report.
*
* Usage:
* npx tsx tests/parity/gap/gap-analysis.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 { 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,
"../../../comparison-report/gaps",
);
const VIEWPORT = { width: 1440, height: 900 };
const DEFAULT_WAIT_MS = 1500;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface SectionDef {
name: string;
/** CSS selector — broad enough to match both Angular and React DOM */
selector: string;
/** If true, this section is a known gap (already tracked, not a surprise) */
knownGap?: boolean;
}
interface RouteConfig {
name: string;
angular: string;
react: string;
waitMs?: number;
sections: SectionDef[];
}
type GapStatus =
| "match"
| "missing-in-react"
| "extra-in-react"
| "count-diff"
| "both-zero";
interface SectionResult {
section: string;
selector: string;
angularCount: number;
reactCount: number;
status: GapStatus;
knownGap: boolean;
}
interface ApiCallSet {
angular: string[];
react: string[];
angularOnly: string[];
reactOnly: string[];
}
interface RouteResult {
route: string;
sections: SectionResult[];
apiCalls: ApiCallSet;
}
// ---------------------------------------------------------------------------
// Route definitions
// ---------------------------------------------------------------------------
const ROUTES: RouteConfig[] = [
{
name: "onlineboard-start",
angular: "/",
react: "/ru/onlineboard",
sections: [
{ name: "page-tabs", selector: '[data-testid*="tab"], [data-testid*="Tab"], .p-tabview-nav li, [role="tablist"] [role="tab"]' },
{ name: "filter-accordion", selector: '[data-testid*="filter"], [data-testid*="Filter"], .p-accordion, [data-testid*="accordion"]' },
{ name: "popular-requests", selector: '[data-testid*="popular"], [data-testid*="Popular"], [class*="popular"]' },
{ name: "search-history", selector: '[data-testid*="history"], [data-testid*="History"], [class*="history"]' },
{ name: "breadcrumbs", selector: '[data-testid*="breadcrumb"], [data-testid*="Breadcrumb"], nav[aria-label*="breadcrumb"], .breadcrumb' },
{ name: "feedback-button", selector: '[data-testid*="feedback"], [data-testid*="Feedback"], [class*="feedback"]' },
],
},
{
name: "onlineboard-details",
angular: "/onlineboard/SU0022-16042026",
react: "/ru/onlineboard/SU0022-16042026",
waitMs: 3000,
sections: [
{ name: "flight-number-header", selector: '[data-testid*="flight-number"], [data-testid*="flightNumber"], [class*="flight-number"], h1, h2' },
{ name: "status-badge", selector: '[data-testid*="status"], [data-testid*="Status"], [class*="status-badge"], [class*="statusBadge"]' },
{ name: "departure-station", selector: '[data-testid*="departure"], [data-testid*="Departure"], [class*="departure"]' },
{ name: "arrival-station", selector: '[data-testid*="arrival"], [data-testid*="Arrival"], [class*="arrival"]' },
{ name: "aircraft-info", selector: '[data-testid*="aircraft"], [data-testid*="Aircraft"], [class*="aircraft"]' },
{ name: "accordion-panels", selector: '[data-testid*="accordion"], .p-accordion-tab, [class*="accordion"]', knownGap: true },
{ name: "mini-flight-list", selector: '[data-testid*="mini-flight"], [data-testid*="miniFlight"], [class*="mini-flight"]', knownGap: true },
{ name: "day-tabs", selector: '[data-testid*="day-tab"], [data-testid*="dayTab"], [class*="day-tab"]', knownGap: true },
{ name: "back-button", selector: '[data-testid*="back"], [data-testid*="Back"], [class*="back-btn"], a[href*="back"]', knownGap: true },
{ name: "flight-actions", selector: '[data-testid*="action"], [data-testid*="Action"], [class*="flight-action"]', knownGap: true },
{ name: "boarding-info", selector: '[data-testid*="boarding"], [data-testid*="Boarding"], [class*="boarding"]', knownGap: true },
{ name: "meal-services", selector: '[data-testid*="meal"], [data-testid*="Meal"], [class*="meal"]', knownGap: true },
{ name: "transfer-section", selector: '[data-testid*="transfer"], [data-testid*="Transfer"], [class*="transfer"]', knownGap: true },
{ name: "last-update", selector: '[data-testid*="last-update"], [data-testid*="lastUpdate"], [class*="last-update"]', knownGap: true },
{ name: "operator-logo", selector: '[data-testid*="operator"], [data-testid*="Operator"], [class*="operator-logo"]', knownGap: true },
],
},
{
name: "schedule-start",
angular: "/schedule",
react: "/ru/schedule",
sections: [
{ name: "departure-input", selector: '[data-testid*="departure"], [data-testid*="Departure"], input[placeholder*="departure" i], input[placeholder*="departure" i], [class*="departure"] input' },
{ name: "arrival-input", selector: '[data-testid*="arrival-input"], [data-testid*="arrivalInput"], input[placeholder*="arrival" i], [class*="arrival"] input' },
{ name: "calendar", selector: '[data-testid*="calendar"], [data-testid*="Calendar"], .p-calendar, [class*="calendar"], [data-testid*="date"]' },
{ name: "direct-only-checkbox", selector: '[data-testid*="direct"], [data-testid*="Direct"], [class*="direct"] input[type="checkbox"], [class*="direct"]' },
{ name: "return-checkbox", selector: '[data-testid*="return"], [data-testid*="Return"], [class*="return"] input[type="checkbox"], [class*="return-date"]' },
{ name: "search-button", selector: '[data-testid*="search"], [data-testid*="Search"], button[type="submit"], [class*="search"] button' },
{ name: "popular-requests", selector: '[data-testid*="popular"], [data-testid*="Popular"], [class*="popular"]' },
],
},
{
name: "flights-map",
angular: "/flights-map",
react: "/ru/flights-map",
waitMs: 3000,
sections: [
{ name: "map-container", selector: ".leaflet-container" },
{ name: "filter-departure", selector: '[data-testid*="departure"], [data-testid*="Departure"], [class*="departure"] input, [class*="filter"] [class*="departure"]' },
{ name: "filter-arrival", selector: '[data-testid*="arrival-filter"], [data-testid*="arrivalFilter"], [class*="arrival"] input, [class*="filter"] [class*="arrival"]' },
{ name: "domestic-toggle", selector: '[data-testid*="domestic"], [data-testid*="Domestic"], [class*="domestic"]' },
{ name: "international-toggle", selector: '[data-testid*="international"], [data-testid*="International"], [class*="international"]' },
{ name: "connecting-toggle", selector: '[data-testid*="connecting"], [data-testid*="Connecting"], [class*="connecting"]' },
{ name: "map-markers", selector: ".leaflet-marker-icon", knownGap: true },
{ name: "map-polylines", selector: ".leaflet-interactive", knownGap: true },
],
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function classifyGap(
angularCount: number,
reactCount: number,
): GapStatus {
if (angularCount === 0 && reactCount === 0) return "both-zero";
if (angularCount === reactCount) return "match";
if (angularCount > 0 && reactCount === 0) return "missing-in-react";
if (angularCount === 0 && reactCount > 0) return "extra-in-react";
return "count-diff";
}
async function countElements(page: Page, selector: string): Promise<number> {
return page.locator(selector).count();
}
/** Normalize API URL to a comparable path (strip host, query params, locale prefix). */
function normalizeApiPath(url: string): string {
try {
const u = new URL(url);
return u.pathname.replace(/\/(ru|en)\//g, "/*/");
} catch {
return url;
}
}
function collectApiCalls(urls: string[]): string[] {
return [...new Set(urls.map(normalizeApiPath))].sort();
}
// ---------------------------------------------------------------------------
// Report generation
// ---------------------------------------------------------------------------
function generateMarkdown(
results: RouteResult[],
timestamp: string,
): string {
// Aggregate stats
let totalSections = 0;
let matching = 0;
let missingNew = 0;
let missingKnown = 0;
let countDiffs = 0;
let extraInReact = 0;
let bothZero = 0;
for (const r of results) {
for (const s of r.sections) {
totalSections++;
switch (s.status) {
case "match":
matching++;
break;
case "missing-in-react":
if (s.knownGap) missingKnown++;
else missingNew++;
break;
case "extra-in-react":
extraInReact++;
break;
case "count-diff":
countDiffs++;
break;
case "both-zero":
bothZero++;
break;
}
}
}
const lines: string[] = [];
const ln = (s = "") => lines.push(s);
ln("# Gap Analysis Report");
ln(`Generated: ${timestamp}`);
ln();
ln("## Summary");
ln(`- Total sections checked: ${totalSections}`);
ln(`- Matching: ${matching}`);
ln(`- Both zero (no elements in either): ${bothZero}`);
ln(`- Missing in React (NEW): ${missingNew}`);
ln(`- Missing in React (known): ${missingKnown}`);
ln(`- Count differences: ${countDiffs}`);
ln(`- Extra in React: ${extraInReact}`);
ln();
// NEW gaps
const newGaps = results.flatMap((r) =>
r.sections
.filter((s) => s.status === "missing-in-react" && !s.knownGap)
.map((s) => ({ route: r.route, ...s })),
);
ln("## NEW Gaps (unexpected)");
if (newGaps.length === 0) {
ln("None -- all missing sections are accounted for in known gaps.");
} else {
ln("| Route | Section | Angular | React |");
ln("|-------|---------|---------|-------|");
for (const g of newGaps) {
ln(`| ${g.route} | ${g.section} | ${g.angularCount} | ${g.reactCount} |`);
}
}
ln();
// Known gaps
const knownGaps = results.flatMap((r) =>
r.sections
.filter((s) => s.knownGap && s.status === "missing-in-react")
.map((s) => ({ route: r.route, ...s })),
);
ln("## Known Gaps");
if (knownGaps.length === 0) {
ln("None -- all known gaps have been resolved.");
} else {
ln("| Route | Section | Angular | React |");
ln("|-------|---------|---------|-------|");
for (const g of knownGaps) {
ln(`| ${g.route} | ${g.section} | ${g.angularCount} | ${g.reactCount} |`);
}
}
ln();
// Count differences
const diffs = results.flatMap((r) =>
r.sections
.filter((s) => s.status === "count-diff")
.map((s) => ({ route: r.route, ...s })),
);
ln("## Count Differences");
if (diffs.length === 0) {
ln("None -- element counts match where both apps render content.");
} else {
ln("| Route | Section | Angular | React |");
ln("|-------|---------|---------|-------|");
for (const d of diffs) {
ln(`| ${d.route} | ${d.section} | ${d.angularCount} | ${d.reactCount} |`);
}
}
ln();
// Extra in React
const extras = results.flatMap((r) =>
r.sections
.filter((s) => s.status === "extra-in-react")
.map((s) => ({ route: r.route, ...s })),
);
if (extras.length > 0) {
ln("## Extra in React");
ln("| Route | Section | Angular | React |");
ln("|-------|---------|---------|-------|");
for (const e of extras) {
ln(`| ${e.route} | ${e.section} | ${e.angularCount} | ${e.reactCount} |`);
}
ln();
}
// API call comparison
ln("## API Call Comparison");
for (const r of results) {
ln(`### ${r.route}`);
if (r.apiCalls.angularOnly.length > 0) {
ln(`Angular-only API calls:`);
for (const c of r.apiCalls.angularOnly) ln(`- \`${c}\``);
} else {
ln(`Angular-only API calls: none`);
}
if (r.apiCalls.reactOnly.length > 0) {
ln(`React-only API calls:`);
for (const c of r.apiCalls.reactOnly) ln(`- \`${c}\``);
} else {
ln(`React-only API calls: none`);
}
ln();
}
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// 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
await mockAngularAPIs(angularPage);
await mockAngularAPIs(reactPage);
// Override popular requests mock for React (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",
},
]),
});
});
const allResults: RouteResult[] = [];
for (const route of ROUTES) {
const waitMs = route.waitMs ?? DEFAULT_WAIT_MS;
console.log(`\n--- ${route.name} ---`);
console.log(` Angular: ${ANGULAR_BASE}${route.angular}`);
console.log(` React: ${REACT_BASE}${route.react}`);
// Track API calls per app
const angularApiCalls: string[] = [];
const reactApiCalls: string[] = [];
const angularListener = (request: { url: () => string }) => {
const url = request.url();
if (url.includes("/api/") || url.includes("/Api/")) {
angularApiCalls.push(url);
}
};
const reactListener = (request: { url: () => string }) => {
const url = request.url();
if (url.includes("/api/") || url.includes("/Api/")) {
reactApiCalls.push(url);
}
};
angularPage.on("request", angularListener);
reactPage.on("request", reactListener);
try {
// Navigate both pages
await Promise.all([
angularPage.goto(`${ANGULAR_BASE}${route.angular}`, {
waitUntil: "networkidle",
timeout: 30_000,
}),
reactPage.goto(`${REACT_BASE}${route.react}`, {
waitUntil: "networkidle",
timeout: 30_000,
}),
]);
// Extra settle time
await Promise.all([
angularPage.waitForTimeout(waitMs),
reactPage.waitForTimeout(waitMs),
]);
// Count elements for each section
const sectionResults: SectionResult[] = [];
for (const section of route.sections) {
const [angularCount, reactCount] = await Promise.all([
countElements(angularPage, section.selector),
countElements(reactPage, section.selector),
]);
const status = classifyGap(angularCount, reactCount);
const knownGap = section.knownGap ?? false;
sectionResults.push({
section: section.name,
selector: section.selector,
angularCount,
reactCount,
status,
knownGap,
});
const icon =
status === "match"
? "[OK]"
: status === "both-zero"
? "[--]"
: knownGap
? "[KG]"
: "[!!]";
console.log(
` ${icon} ${section.name}: angular=${angularCount} react=${reactCount} (${status})`,
);
}
// Compute API call diff
const angularNorm = collectApiCalls(angularApiCalls);
const reactNorm = collectApiCalls(reactApiCalls);
const angularOnly = angularNorm.filter((c) => !reactNorm.includes(c));
const reactOnly = reactNorm.filter((c) => !angularNorm.includes(c));
allResults.push({
route: route.name,
sections: sectionResults,
apiCalls: {
angular: angularNorm,
react: reactNorm,
angularOnly,
reactOnly,
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.log(` ERROR: ${msg}`);
allResults.push({
route: route.name,
sections: route.sections.map((s) => ({
section: s.name,
selector: s.selector,
angularCount: -1,
reactCount: -1,
status: "both-zero" as GapStatus,
knownGap: s.knownGap ?? false,
})),
apiCalls: {
angular: [],
react: [],
angularOnly: [],
reactOnly: [],
},
});
} finally {
angularPage.removeListener("request", angularListener);
reactPage.removeListener("request", reactListener);
}
}
// Generate reports
const timestamp = new Date().toISOString();
const markdown = generateMarkdown(allResults, timestamp);
writeFileSync(join(OUTPUT_DIR, "gap-report.md"), markdown);
console.log(`\nMarkdown report: ${join(OUTPUT_DIR, "gap-report.md")}`);
const jsonData = { timestamp, viewport: VIEWPORT, results: allResults };
writeFileSync(
join(OUTPUT_DIR, "gap-data.json"),
JSON.stringify(jsonData, null, 2),
);
console.log(`JSON data: ${join(OUTPUT_DIR, "gap-data.json")}`);
// Print summary to console
console.log("\n" + "=".repeat(60));
console.log("GAP ANALYSIS SUMMARY");
console.log("=".repeat(60));
let total = 0,
matches = 0,
newGaps = 0,
knownGaps = 0,
diffs = 0;
for (const r of allResults) {
for (const s of r.sections) {
total++;
if (s.status === "match") matches++;
else if (s.status === "missing-in-react" && !s.knownGap) newGaps++;
else if (s.status === "missing-in-react" && s.knownGap) knownGaps++;
else if (s.status === "count-diff") diffs++;
}
}
console.log(`Total sections: ${total}`);
console.log(`Matching: ${matches}`);
console.log(`NEW gaps: ${newGaps}`);
console.log(`Known gaps: ${knownGaps}`);
console.log(`Count diffs: ${diffs}`);
console.log("=".repeat(60));
await browser.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});