From e1882f49bc50b57dfc1136282ec9bfc3a818483e Mon Sep 17 00:00:00 2001 From: gnezim Date: Thu, 16 Apr 2026 17:46:35 +0300 Subject: [PATCH] Add gap analysis script comparing Angular vs React DOM structure per route --- tests/parity/gap/gap-analysis.ts | 547 +++++++++++++++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 tests/parity/gap/gap-analysis.ts diff --git a/tests/parity/gap/gap-analysis.ts b/tests/parity/gap/gap-analysis.ts new file mode 100644 index 00000000..b080354d --- /dev/null +++ b/tests/parity/gap/gap-analysis.ts @@ -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 { + 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); +});