548 lines
19 KiB
TypeScript
548 lines
19 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|