Add gap analysis script comparing Angular vs React DOM structure per route
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user