diff --git a/docs/superpowers/plans/2026-04-16-angular-react-comparison.md b/docs/superpowers/plans/2026-04-16-angular-react-comparison.md new file mode 100644 index 00000000..9f405e2b --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-angular-react-comparison.md @@ -0,0 +1,1136 @@ +# Angular → React Comparison Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a repeatable comparison pipeline that visually diffs and behaviorally verifies the React rewrite against the Angular original, producing an HTML report and a gap analysis. + +**Architecture:** Extends the existing `tests/parity/visual/screenshot-diff.ts` with multi-viewport support and an HTML report. Adds new behavioral E2E specs to `tests/e2e-angular/cross-app/` using the existing dual-app fixture harness. Adds a gap analysis script that compares DOM structure between both apps. + +**Tech Stack:** Playwright (already installed), pixelmatch + pngjs (already used), existing cross-app fixtures (`tests/e2e-angular/support/`), Node.js scripts for report generation. + +--- + +## File Structure + +### New files +- `tests/parity/visual/screenshot-diff-multi.ts` — Multi-viewport screenshot runner (extends existing single-viewport script) +- `tests/parity/visual/generate-report.ts` — HTML report generator from screenshot-diff JSON output +- `tests/parity/visual/report-template.html` — HTML template for the visual diff report +- `tests/parity/gap/gap-analysis.ts` — DOM structure comparison script +- `tests/e2e-angular/cross-app/19-popular-requests-behavior.spec.ts` — Popular requests click + filter pre-fill tests +- `tests/e2e-angular/cross-app/20-visual-parity-smoke.spec.ts` — Lightweight Playwright visual comparison tests using `toHaveScreenshot` + +### Modified files +- `package.json` — Add script entries for the new commands +- `tests/e2e-angular/support/selectors.ts` — Add any missing selectors for popular requests + +--- + +### Task 1: Multi-Viewport Screenshot Runner + +**Files:** +- Create: `tests/parity/visual/screenshot-diff-multi.ts` + +This extends the existing `tests/parity/visual/screenshot-diff.ts` pattern to capture at 3 viewports (desktop 1440px, tablet 768px, mobile 375px) and output structured JSON for report generation. + +- [ ] **Step 1: Create the multi-viewport screenshot script** + +```typescript +// tests/parity/visual/screenshot-diff-multi.ts +/** + * Multi-viewport visual parity: Angular vs React. + * + * Captures both apps at 3 viewports, diffs with pixelmatch, + * writes JSON report consumed by generate-report.ts. + * + * 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"; + +const ANGULAR_BASE = "http://localhost:4200"; +const REACT_BASE = "http://localhost:8080"; +const OUTPUT_DIR = resolve(import.meta.dirname, "../../../comparison-report/visual"); +const SETTLE_MS = 1500; + +const VIEWPORTS = [ + { name: "desktop", width: 1440, height: 900 }, + { name: "tablet", width: 768, height: 1024 }, + { name: "mobile", width: 375, height: 812 }, +] as const; + +interface RouteEntry { + name: string; + angular: string; + react: string; + waitMs?: number; + fullPage?: boolean; + /** Selectors to hide before screenshot (dynamic content). */ + maskSelectors?: string[]; +} + +const ROUTES: RouteEntry[] = [ + { + name: "onlineboard-start", + angular: "/", + react: "/ru/onlineboard", + }, + { + name: "onlineboard-departure", + angular: "/onlineboard/departure/AAQ/16042026-0000-2400", + react: "/ru/onlineboard/departure/AAQ/16042026-0000-2400", + waitMs: 3000, + maskSelectors: ['[data-testid="board-flight-status"]'], + }, + { + name: "onlineboard-arrival", + angular: "/onlineboard/arrival/AAQ/16042026-0000-2400", + react: "/ru/onlineboard/arrival/AAQ/16042026-0000-2400", + waitMs: 3000, + maskSelectors: ['[data-testid="board-flight-status"]'], + }, + { + name: "onlineboard-route", + angular: "/onlineboard/route/MOW-AER/16042026-0000-2400", + react: "/ru/onlineboard/route/MOW-AER/16042026-0000-2400", + waitMs: 3000, + maskSelectors: ['[data-testid="board-flight-status"]'], + }, + { + name: "onlineboard-flight", + angular: "/onlineboard/flight/SU0022/16042026", + react: "/ru/onlineboard/flight/SU0022/16042026", + waitMs: 3000, + }, + { + name: "onlineboard-details", + angular: "/onlineboard/SU0022-16042026", + react: "/ru/onlineboard/SU0022-16042026", + waitMs: 3000, + }, + { + name: "schedule-start", + angular: "/schedule", + react: "/ru/schedule", + }, + { + name: "schedule-route", + angular: "/schedule/route/MOW-KUF-20260416-20260423", + react: "/ru/schedule/route/MOW-KUF-20260416-20260423", + waitMs: 3000, + }, + { + name: "flights-map", + angular: "/flights-map", + react: "/ru/flights-map", + waitMs: 2000, + }, + { + name: "error-404", + angular: "/error/404", + react: "/error/404", + }, +]; + +async function maskDynamicContent(page: Page, selectors: string[]): Promise { + for (const sel of selectors) { + await page.evaluate((s) => { + document.querySelectorAll(s).forEach((el) => { + (el as HTMLElement).style.visibility = "hidden"; + }); + }, sel); + } +} + +async function captureScreenshot( + page: Page, + url: string, + waitMs: number, + fullPage: boolean, + maskSelectors: string[], +): Promise { + await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + await page.waitForTimeout(waitMs); + if (maskSelectors.length > 0) { + await maskDynamicContent(page, maskSelectors); + } + return page.screenshot({ fullPage, type: "png" }) as Promise; +} + +function alignAndDiff( + angularBuf: Buffer, + reactBuf: Buffer, +): { diffBuf: Buffer; mismatchCount: number; totalPixels: number; width: number; height: number } { + const angularPng = PNG.sync.read(angularBuf); + const reactPng = PNG.sync.read(reactBuf); + const width = Math.max(angularPng.width, reactPng.width); + const height = Math.max(angularPng.height, reactPng.height); + + const pad = (src: PNG): Buffer => { + const out = new PNG({ width, height }); + 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; + } + for (let y = 0; y < src.height; y++) { + for (let x = 0; x < src.width; x++) { + const si = (y * src.width + x) * 4; + const di = (y * width + x) * 4; + out.data[di] = src.data[si]!; + out.data[di + 1] = src.data[si + 1]!; + out.data[di + 2] = src.data[si + 2]!; + out.data[di + 3] = src.data[si + 3]!; + } + } + return PNG.sync.write(out); + }; + + const aPadded = PNG.sync.read(pad(angularPng)); + const rPadded = PNG.sync.read(pad(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 { diffBuf: PNG.sync.write(diffPng), mismatchCount, totalPixels: width * height, width, height }; +} + +export interface ScreenshotResult { + route: string; + viewport: string; + mismatchPct: string; + mismatchCount: number; + totalPixels: number; + heightDiff: number; + angularHeight: number; + reactHeight: number; + error?: string; +} + +async function main() { + mkdirSync(join(OUTPUT_DIR, "screenshots/angular"), { recursive: true }); + mkdirSync(join(OUTPUT_DIR, "screenshots/react"), { recursive: true }); + mkdirSync(join(OUTPUT_DIR, "diffs"), { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const allResults: ScreenshotResult[] = []; + + for (const vp of VIEWPORTS) { + console.log(`\n${"=".repeat(60)}`); + console.log(`VIEWPORT: ${vp.name} (${vp.width}x${vp.height})`); + console.log("=".repeat(60)); + + const angularCtx = await browser.newContext({ viewport: { width: vp.width, height: vp.height } }); + const reactCtx = await browser.newContext({ viewport: { width: vp.width, height: vp.height } }); + const angularPage = await angularCtx.newPage(); + const reactPage = await reactCtx.newPage(); + + await mockAngularAPIs(angularPage); + await mockAngularAPIs(reactPage); + // React popular requests use 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 waitMs = route.waitMs ?? SETTLE_MS; + const fullPage = route.fullPage !== false; + const masks = route.maskSelectors ?? []; + const fileBase = `${route.name}-${vp.name}`; + + console.log(`\n📸 ${route.name} @ ${vp.name}`); + + try { + const [angBuf, reactBuf] = await Promise.all([ + captureScreenshot(angularPage, `${ANGULAR_BASE}${route.angular}`, waitMs, fullPage, masks), + captureScreenshot(reactPage, `${REACT_BASE}${route.react}`, waitMs, fullPage, masks), + ]); + + writeFileSync(join(OUTPUT_DIR, "screenshots/angular", `${fileBase}.png`), angBuf); + writeFileSync(join(OUTPUT_DIR, "screenshots/react", `${fileBase}.png`), reactBuf); + + const { diffBuf, mismatchCount, totalPixels, width, height } = alignAndDiff(angBuf, reactBuf); + writeFileSync(join(OUTPUT_DIR, "diffs", `${fileBase}.png`), diffBuf); + + const angPng = PNG.sync.read(angBuf); + const reactPng = PNG.sync.read(reactBuf); + const heightDiff = reactPng.height - angPng.height; + const mismatchPct = ((mismatchCount / totalPixels) * 100).toFixed(2); + + allResults.push({ + route: route.name, viewport: vp.name, + mismatchPct, mismatchCount, totalPixels, + heightDiff, angularHeight: angPng.height, reactHeight: reactPng.height, + }); + console.log(` ✅ ${mismatchPct}% diff, height Δ=${heightDiff}px`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + allResults.push({ + route: route.name, viewport: vp.name, + mismatchPct: "ERR", mismatchCount: -1, totalPixels: -1, + heightDiff: 0, angularHeight: 0, reactHeight: 0, error: msg, + }); + console.log(` ❌ ${msg}`); + } + } + + await angularCtx.close(); + await reactCtx.close(); + } + + // Summary table + console.log("\n" + "=".repeat(80)); + console.log("MULTI-VIEWPORT VISUAL PARITY SUMMARY"); + console.log("=".repeat(80)); + console.log( + "Route".padEnd(28) + "Viewport".padEnd(10) + + "Diff %".padEnd(10) + "Mismatch px".padEnd(14) + + "Height Δ".padEnd(10) + "Status", + ); + console.log("-".repeat(80)); + for (const r of allResults) { + const status = r.error ? `❌ ${r.error.slice(0, 25)}` : Number(r.mismatchPct) < 0.5 ? "✅ PASS" : "⚠️ DIFF"; + console.log( + r.route.padEnd(28) + r.viewport.padEnd(10) + + r.mismatchPct.padEnd(10) + + (r.mismatchCount >= 0 ? r.mismatchCount.toLocaleString() : "N/A").padEnd(14) + + `${r.heightDiff}px`.padEnd(10) + status, + ); + } + + writeFileSync( + join(OUTPUT_DIR, "report.json"), + JSON.stringify({ + timestamp: new Date().toISOString(), + viewports: VIEWPORTS, + routes: ROUTES.map((r) => r.name), + results: allResults, + }, null, 2), + ); + console.log(`\nJSON report: ${join(OUTPUT_DIR, "report.json")}`); + + await browser.close(); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +``` + +- [ ] **Step 2: Run it to verify it works with both apps running** + +Run: `npx tsx tests/parity/visual/screenshot-diff-multi.ts` + +Expected: Screenshots captured at 3 viewports for each route, JSON report written to `comparison-report/visual/report.json`. Console shows summary table with diff percentages. + +- [ ] **Step 3: Commit** + +```bash +git add tests/parity/visual/screenshot-diff-multi.ts +git commit -m "Add multi-viewport screenshot diff runner for Angular/React comparison" +``` + +--- + +### Task 2: HTML Report Generator + +**Files:** +- Create: `tests/parity/visual/report-template.html` +- Create: `tests/parity/visual/generate-report.ts` + +Generates an interactive HTML report from the JSON output of Task 1. + +- [ ] **Step 1: Create the HTML template** + +```html + + + + + + Visual Parity Report — Angular vs React + + + +

Visual Parity Report

+
Generated: | Routes: | Viewports: desktop, tablet, mobile
+
+
+
+
+ +
+ + + + ``` + +- [ ] **Step 2: Create the report generator script** + +```typescript +// tests/parity/visual/generate-report.ts +/** + * Generates an HTML report from screenshot-diff-multi.ts JSON output. + * + * Usage: + * npx tsx tests/parity/visual/generate-report.ts + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, join } from "node:path"; + +const REPORT_DIR = resolve(import.meta.dirname, "../../../comparison-report/visual"); +const TEMPLATE_PATH = resolve(import.meta.dirname, "report-template.html"); + +function main() { + const jsonPath = join(REPORT_DIR, "report.json"); + const data = readFileSync(jsonPath, "utf-8"); + const template = readFileSync(TEMPLATE_PATH, "utf-8"); + + const html = template.replace("%%DATA%%", data); + const outPath = join(REPORT_DIR, "report.html"); + writeFileSync(outPath, html); + console.log(`Report generated: ${outPath}`); +} + +main(); +``` + +- [ ] **Step 3: Run screenshot-diff-multi then generate report** + +Run: `npx tsx tests/parity/visual/screenshot-diff-multi.ts && npx tsx tests/parity/visual/generate-report.ts` + +Expected: `comparison-report/visual/report.html` generated. Open in browser to verify the side-by-side view works. + +- [ ] **Step 4: Commit** + +```bash +git add tests/parity/visual/report-template.html tests/parity/visual/generate-report.ts +git commit -m "Add HTML report generator for visual parity comparison" +``` + +--- + +### Task 3: Popular Requests Behavioral Tests + +**Files:** +- Create: `tests/e2e-angular/cross-app/19-popular-requests-behavior.spec.ts` +- Modify: `tests/e2e-angular/support/selectors.ts` — Add popular request item selectors + +This is the key behavioral test that catches the filter pre-fill gap between Angular and React. + +- [ ] **Step 1: Add popular request selectors** + +In `tests/e2e-angular/support/selectors.ts`, add to the `S` object: + +```typescript + // Popular Requests + POPULAR_REQUESTS_PANEL: 'popular-requests-panel', + POPULAR_REQUEST_ITEM: 'popular-request-item', +``` + +And add Angular overrides if needed: + +```typescript + [S.POPULAR_REQUESTS_PANEL]: 'popular-requests', + [S.POPULAR_REQUEST_ITEM]: 'popular-request', +``` + +- [ ] **Step 2: Create the popular requests behavior spec** + +```typescript +// tests/e2e-angular/cross-app/19-popular-requests-behavior.spec.ts +import { test, expect } from '../support/cross-app-fixtures'; +import { S, tid } from '../support/selectors'; + +function formatToday(): string { + const d = new Date(); + return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`; +} + +test.describe('Popular Requests Behavior (Cross-App)', () => { + test.beforeEach(async ({ page, localePath }) => { + await page.goto(localePath('onlineboard')); + await page.waitForLoadState('networkidle'); + }); + + test('1: Popular requests panel is visible on onlineboard start', async ({ page, app }) => { + const panel = page.locator(tid(S.POPULAR_REQUESTS_PANEL, app)); + // Fallback: look for any popular request items + const items = page.locator(tid(S.POPULAR_REQUEST_ITEM, app)); + const fallback = page.locator('[data-testid*="popular"]'); + const target = (await panel.count()) > 0 ? panel + : (await items.count()) > 0 ? items.first() + : fallback.first(); + await expect(target).toBeVisible({ timeout: 10_000 }); + }); + + test('2: Popular requests panel shows up to 4 items', async ({ page, app }) => { + const items = page.locator(tid(S.POPULAR_REQUEST_ITEM, app)); + const fallback = page.locator('[data-testid*="popular-request"]'); + const target = (await items.count()) > 0 ? items : fallback; + const count = await target.count(); + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThanOrEqual(4); + }); + + test('3: Clicking flight number request navigates to flight search', async ({ + page, app, locale, + }) => { + // Click the first popular request (mocked as FlightNumber type) + const items = page.locator(tid(S.POPULAR_REQUEST_ITEM, app)); + const fallback = page.locator('[data-testid*="popular-request"]'); + const target = (await items.count()) > 0 ? items : fallback; + + if ((await target.count()) === 0) { + test.skip(true, 'No popular request items found'); + return; + } + + await target.first().click(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + + // Should have navigated away from the start page + const url = page.url(); + expect(url).toMatch(/onlineboard\/(flight|departure|arrival|route|SU)/); + }); + + test('4: Clicking route request navigates to route search', async ({ + page, app, locale, + }) => { + const items = page.locator(tid(S.POPULAR_REQUEST_ITEM, app)); + const fallback = page.locator('[data-testid*="popular-request"]'); + const target = (await items.count()) > 0 ? items : fallback; + + if ((await target.count()) < 2) { + test.skip(true, 'Not enough popular request items'); + return; + } + + // Second item is mocked as Route type + await target.nth(1).click(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + + const url = page.url(); + expect(url).toMatch(/onlineboard\/route/); + }); + + test('5: Popular requests visible on schedule start page', async ({ + page, app, localePath, + }) => { + await page.goto(localePath('schedule')); + await page.waitForLoadState('networkidle'); + + const panel = page.locator(tid(S.POPULAR_REQUESTS_PANEL, app)); + const fallback = page.locator('[data-testid*="popular"]'); + const target = (await panel.count()) > 0 ? panel : fallback.first(); + + if ((await target.count()) === 0) { + test.skip(true, 'Popular requests not shown on schedule page'); + return; + } + await expect(target).toBeVisible({ timeout: 10_000 }); + }); + + test('6: Popular request items are keyboard accessible', async ({ page, app }) => { + const items = page.locator(tid(S.POPULAR_REQUEST_ITEM, app)); + const fallback = page.locator('[data-testid*="popular-request"]'); + const target = (await items.count()) > 0 ? items : fallback; + + if ((await target.count()) === 0) { + test.skip(true, 'No popular request items found'); + return; + } + + // Tab to the first item and press Enter + const first = target.first(); + await first.focus(); + const isFocused = await first.evaluate( + (el) => document.activeElement === el || el.contains(document.activeElement), + ); + + if (!isFocused) { + test.skip(true, 'Item not focusable — keyboard accessibility may not be implemented'); + return; + } + + await page.keyboard.press('Enter'); + await page.waitForTimeout(1000); + // Should have navigated + const url = page.url(); + expect(url).not.toMatch(/\/onlineboard$/); + }); +}); +``` + +- [ ] **Step 3: Run the spec against both apps** + +Run: `npx playwright test --config=playwright-angular.config.ts tests/e2e-angular/cross-app/19-popular-requests-behavior.spec.ts` + +Expected: Tests pass for both Angular and React projects. Some tests may skip if testids aren't in place yet — that's expected and will be documented in the gap report. + +- [ ] **Step 4: Commit** + +```bash +git add tests/e2e-angular/cross-app/19-popular-requests-behavior.spec.ts tests/e2e-angular/support/selectors.ts +git commit -m "Add popular requests behavioral parity tests" +``` + +--- + +### Task 4: Gap Analysis Script + +**Files:** +- Create: `tests/parity/gap/gap-analysis.ts` + +Crawls both apps and compares DOM structure per route, counting elements by section to surface missing features. + +- [ ] **Step 1: Create the gap analysis script** + +```typescript +// tests/parity/gap/gap-analysis.ts +/** + * DOM structure gap analysis: Angular vs React. + * + * For each route, counts key DOM elements in both apps and flags + * sections that exist in Angular but are missing in React. + * + * Usage: + * npx tsx tests/parity/gap/gap-analysis.ts + * + * Prerequisites: + * - Angular running on http://localhost:4200 + * - React running on http://localhost:8080 + */ + +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"; + +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 }; + +interface SectionProbe { + name: string; + /** CSS selector to count */ + selector: string; + /** If true, absence in React is a known gap (not a surprise) */ + knownGap?: boolean; +} + +interface RouteProbe { + name: string; + angular: string; + react: string; + waitMs?: number; + sections: SectionProbe[]; +} + +const ROUTE_PROBES: RouteProbe[] = [ + { + name: "onlineboard-start", + angular: "/", + react: "/ru/onlineboard", + sections: [ + { name: "page-tabs", selector: '[data-testid*="nav-"][data-testid*="-tab"], [data-testid*="-tab"]' }, + { name: "filter-accordion", selector: '[data-testid*="filter"], .filter-accordion, .p-accordion' }, + { name: "popular-requests", selector: '[data-testid*="popular"]' }, + { name: "search-history", selector: '[data-testid*="search-history"], [data-testid*="history"]' }, + { name: "breadcrumbs", selector: '[data-testid*="breadcrumb"], p-breadcrumb, nav[aria-label*="bread"]' }, + { name: "feedback-button", selector: '[data-testid*="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"], h1, h2' }, + { name: "status-badge", selector: '[data-testid*="status"]' }, + { name: "departure-station", selector: '[data-testid*="departure"]' }, + { name: "arrival-station", selector: '[data-testid*="arrival"]' }, + { name: "accordion-panels", selector: '.p-accordion-tab, [data-testid*="accordion"], details' }, + { name: "mini-flight-list", selector: '[data-testid*="mini-list"], [data-testid*="flight-mini"]', knownGap: true }, + { name: "day-tabs", selector: '[data-testid*="day-tab"]', knownGap: true }, + { name: "back-button", selector: '[data-testid*="back"], [data-testid*="cancel"]', knownGap: true }, + { name: "flight-actions", selector: '[data-testid*="buy-ticket"], [data-testid*="registration"], [data-testid*="print"], [data-testid*="share"]', knownGap: true }, + { name: "aircraft-info", selector: '[data-testid*="aircraft"], [data-testid*="equipment"]' }, + { name: "boarding-info", selector: '[data-testid*="boarding"]', knownGap: true }, + { name: "meal-services", selector: '[data-testid*="meal"], [data-testid*="catering"]', knownGap: true }, + { name: "transfer-section", selector: '[data-testid*="transfer"]', knownGap: true }, + { name: "last-update", selector: '[data-testid*="last-update"], [data-testid*="updated"]', knownGap: true }, + { name: "operator-logo", selector: '[data-testid*="operator-logo"], img[alt*="airline" i]', knownGap: true }, + ], + }, + { + name: "schedule-start", + angular: "/schedule", + react: "/ru/schedule", + sections: [ + { name: "departure-input", selector: '[data-testid*="departure"]' }, + { name: "arrival-input", selector: '[data-testid*="arrival"]' }, + { name: "calendar", selector: '[data-testid*="calendar"], .p-calendar' }, + { name: "direct-only-checkbox", selector: '[data-testid*="direct"]' }, + { name: "return-checkbox", selector: '[data-testid*="return"]' }, + { name: "search-button", selector: '[data-testid*="search-button"]' }, + { name: "popular-requests", selector: '[data-testid*="popular"]' }, + ], + }, + { + name: "flights-map", + angular: "/flights-map", + react: "/ru/flights-map", + waitMs: 3000, + sections: [ + { name: "map-container", selector: '[data-testid*="map-container"], .leaflet-container' }, + { name: "map-markers", selector: '.leaflet-marker-icon', knownGap: true }, + { name: "map-polylines", selector: '.leaflet-interactive:not(.leaflet-marker-icon)', knownGap: true }, + { name: "filter-departure", selector: '[data-testid*="departure"]' }, + { name: "filter-arrival", selector: '[data-testid*="arrival"]' }, + { name: "domestic-toggle", selector: '[data-testid*="domestic"]' }, + { name: "international-toggle", selector: '[data-testid*="international"]' }, + { name: "connecting-toggle", selector: '[data-testid*="connecting"]' }, + ], + }, +]; + +async function countElements(page: Page, url: string, sections: SectionProbe[], waitMs: number): Promise> { + await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + await page.waitForTimeout(waitMs); + + const counts: Record = {}; + for (const section of sections) { + counts[section.name] = await page.locator(section.selector).count(); + } + return counts; +} + +interface GapEntry { + route: string; + section: string; + angularCount: number; + reactCount: number; + status: "match" | "missing-in-react" | "extra-in-react" | "both-zero" | "count-diff"; + knownGap: boolean; +} + +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(); + + await mockAngularAPIs(angularPage); + await mockAngularAPIs(reactPage); + 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 allGaps: GapEntry[] = []; + const apiComparison: Array<{ route: string; angularAPIs: string[]; reactAPIs: string[] }> = []; + + for (const probe of ROUTE_PROBES) { + const waitMs = probe.waitMs ?? 1500; + console.log(`\n🔍 ${probe.name}`); + + // Capture API calls + const angularAPICalls: string[] = []; + const reactAPICalls: string[] = []; + angularPage.on("request", (req) => { + if (req.url().includes("/api/")) angularAPICalls.push(req.url().replace(ANGULAR_BASE, "")); + }); + reactPage.on("request", (req) => { + if (req.url().includes("/api/")) reactAPICalls.push(req.url().replace(REACT_BASE, "")); + }); + + const [angularCounts, reactCounts] = await Promise.all([ + countElements(angularPage, `${ANGULAR_BASE}${probe.angular}`, probe.sections, waitMs), + countElements(reactPage, `${REACT_BASE}${probe.react}`, probe.sections, waitMs), + ]); + + apiComparison.push({ + route: probe.name, + angularAPIs: [...new Set(angularAPICalls)], + reactAPIs: [...new Set(reactAPICalls)], + }); + + for (const section of probe.sections) { + const ac = angularCounts[section.name] ?? 0; + const rc = reactCounts[section.name] ?? 0; + let status: GapEntry["status"]; + + if (ac === 0 && rc === 0) status = "both-zero"; + else if (ac > 0 && rc === 0) status = "missing-in-react"; + else if (ac === 0 && rc > 0) status = "extra-in-react"; + else if (ac === rc) status = "match"; + else status = "count-diff"; + + allGaps.push({ + route: probe.name, + section: section.name, + angularCount: ac, + reactCount: rc, + status, + knownGap: section.knownGap ?? false, + }); + + const icon = status === "match" ? "✅" : status === "missing-in-react" ? "❌" : status === "count-diff" ? "⚠️" : status === "extra-in-react" ? "🆕" : "⬜"; + const known = section.knownGap ? " (known gap)" : ""; + console.log(` ${icon} ${section.name}: Angular=${ac}, React=${rc}${known}`); + } + + angularPage.removeAllListeners("request"); + reactPage.removeAllListeners("request"); + } + + // Generate Markdown report + const missing = allGaps.filter((g) => g.status === "missing-in-react"); + const newGaps = missing.filter((g) => !g.knownGap); + const knownGaps = missing.filter((g) => g.knownGap); + const countDiffs = allGaps.filter((g) => g.status === "count-diff"); + + let md = `# Gap Analysis Report\n\n`; + md += `Generated: ${new Date().toISOString()}\n\n`; + md += `## Summary\n\n`; + md += `- **Total sections checked:** ${allGaps.length}\n`; + md += `- **Matching:** ${allGaps.filter((g) => g.status === "match").length}\n`; + md += `- **Missing in React (NEW):** ${newGaps.length}\n`; + md += `- **Missing in React (known):** ${knownGaps.length}\n`; + md += `- **Count differences:** ${countDiffs.length}\n`; + md += `- **Extra in React:** ${allGaps.filter((g) => g.status === "extra-in-react").length}\n\n`; + + if (newGaps.length > 0) { + md += `## NEW Gaps (unexpected)\n\n`; + md += `| Route | Section | Angular | React |\n|-------|---------|---------|-------|\n`; + for (const g of newGaps) { + md += `| ${g.route} | ${g.section} | ${g.angularCount} | ${g.reactCount} |\n`; + } + md += `\n`; + } + + if (knownGaps.length > 0) { + md += `## Known Gaps\n\n`; + md += `| Route | Section | Angular | React |\n|-------|---------|---------|-------|\n`; + for (const g of knownGaps) { + md += `| ${g.route} | ${g.section} | ${g.angularCount} | ${g.reactCount} |\n`; + } + md += `\n`; + } + + if (countDiffs.length > 0) { + md += `## Count Differences\n\n`; + md += `| Route | Section | Angular | React |\n|-------|---------|---------|-------|\n`; + for (const g of countDiffs) { + md += `| ${g.route} | ${g.section} | ${g.angularCount} | ${g.reactCount} |\n`; + } + md += `\n`; + } + + md += `## API Call Comparison\n\n`; + for (const api of apiComparison) { + md += `### ${api.route}\n\n`; + const onlyAngular = api.angularAPIs.filter((a) => !api.reactAPIs.some((r) => r.includes(a.split("?")[0]!))); + const onlyReact = api.reactAPIs.filter((r) => !api.angularAPIs.some((a) => a.includes(r.split("?")[0]!))); + if (onlyAngular.length > 0) { + md += `**Angular-only API calls:**\n`; + for (const a of onlyAngular) md += `- \`${a}\`\n`; + } + if (onlyReact.length > 0) { + md += `**React-only API calls:**\n`; + for (const r of onlyReact) md += `- \`${r}\`\n`; + } + if (onlyAngular.length === 0 && onlyReact.length === 0) { + md += `API calls match.\n`; + } + md += `\n`; + } + + writeFileSync(join(OUTPUT_DIR, "gap-report.md"), md); + writeFileSync(join(OUTPUT_DIR, "gap-data.json"), JSON.stringify({ gaps: allGaps, apiComparison }, null, 2)); + + console.log(`\nGap report: ${join(OUTPUT_DIR, "gap-report.md")}`); + + await browser.close(); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +``` + +- [ ] **Step 2: Run it with both apps running** + +Run: `npx tsx tests/parity/gap/gap-analysis.ts` + +Expected: `comparison-report/gaps/gap-report.md` generated with tables showing missing sections, count differences, and API call comparison. + +- [ ] **Step 3: Commit** + +```bash +git add tests/parity/gap/gap-analysis.ts +git commit -m "Add DOM structure gap analysis script for Angular/React comparison" +``` + +--- + +### Task 5: Package.json Script Entries + +**Files:** +- Modify: `package.json` + +Add convenience scripts to run the comparison pipeline. + +- [ ] **Step 1: Add scripts to package.json** + +Add these to the `"scripts"` section: + +```json +"compare:visual": "tsx tests/parity/visual/screenshot-diff-multi.ts && tsx tests/parity/visual/generate-report.ts", +"compare:gap": "tsx tests/parity/gap/gap-analysis.ts", +"compare:behavior": "playwright test --config=playwright-angular.config.ts tests/e2e-angular/cross-app/", +"compare:all": "pnpm compare:visual && pnpm compare:gap && pnpm compare:behavior" +``` + +- [ ] **Step 2: Add comparison-report/ to .gitignore** + +Append to `.gitignore`: + +``` +# Comparison report output (generated) +comparison-report/ +``` + +- [ ] **Step 3: Verify scripts run** + +Run: `pnpm compare:visual` + +Expected: Screenshots captured, HTML report generated at `comparison-report/visual/report.html`. + +- [ ] **Step 4: Commit** + +```bash +git add package.json .gitignore +git commit -m "Add comparison pipeline scripts and ignore generated reports" +``` + +--- + +### Task 6: End-to-End Smoke Test + +**Files:** +- Create: `tests/e2e-angular/cross-app/20-visual-parity-smoke.spec.ts` + +Lightweight Playwright test using built-in `toHaveScreenshot` for CI-friendly visual regression. This runs as part of the normal test suite and catches regressions going forward. + +- [ ] **Step 1: Create the visual parity smoke spec** + +```typescript +// tests/e2e-angular/cross-app/20-visual-parity-smoke.spec.ts +import { test, expect } from '../support/cross-app-fixtures'; + +/** + * Visual Parity Smoke Tests + * + * Uses Playwright's built-in toHaveScreenshot for CI-friendly + * visual regression. On first run, creates baseline screenshots. + * Subsequent runs compare against baselines. + * + * These are lightweight checks — the full multi-viewport comparison + * pipeline (pnpm compare:visual) is more thorough. + */ + +const ROUTES = [ + { name: 'onlineboard-start', path: 'onlineboard' }, + { name: 'schedule-start', path: 'schedule' }, + { name: 'error-404', path: '../error/404' }, +] as const; + +for (const route of ROUTES) { + test(`visual-parity: ${route.name}`, async ({ page, app, localePath }) => { + await page.goto(localePath(route.path)); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + + await expect(page).toHaveScreenshot(`${route.name}-${app}.png`, { + fullPage: true, + maxDiffPixelRatio: 0.02, + }); + }); +} +``` + +- [ ] **Step 2: Run to create baselines** + +Run: `npx playwright test --config=playwright-angular.config.ts tests/e2e-angular/cross-app/20-visual-parity-smoke.spec.ts --update-snapshots` + +Expected: Baseline screenshots created in the test-results directory. Subsequent runs will compare against these. + +- [ ] **Step 3: Run without --update-snapshots to verify comparison works** + +Run: `npx playwright test --config=playwright-angular.config.ts tests/e2e-angular/cross-app/20-visual-parity-smoke.spec.ts` + +Expected: Tests pass (screenshots match baselines since nothing changed). + +- [ ] **Step 4: Commit** + +```bash +git add tests/e2e-angular/cross-app/20-visual-parity-smoke.spec.ts +git commit -m "Add visual parity smoke tests for CI regression detection" +```