# 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" ```