Files
flights_web/docs/superpowers/plans/2026-04-16-angular-react-comparison.md
T

42 KiB

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
// 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<void> {
  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<Buffer> {
  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<Buffer>;
}

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
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
<!-- tests/parity/visual/report-template.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Visual Parity Report — Angular vs React</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 2rem; }
    h1 { margin-bottom: 0.5rem; }
    .meta { color: #888; margin-bottom: 2rem; font-size: 0.9rem; }
    .summary { display: flex; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap; }
    .stat { background: #16213e; padding: 1rem 1.5rem; border-radius: 8px; min-width: 140px; }
    .stat .label { font-size: 0.8rem; color: #888; text-transform: uppercase; }
    .stat .value { font-size: 1.8rem; font-weight: bold; margin-top: 0.25rem; }
    .stat .value.pass { color: #4caf50; }
    .stat .value.warn { color: #ff9800; }
    .stat .value.fail { color: #f44336; }
    .filters { margin-bottom: 1.5rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
    .filters button { background: #16213e; border: 1px solid #333; color: #e0e0e0; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; }
    .filters button.active { background: #0f3460; border-color: #4caf50; }
    .route-section { margin-bottom: 3rem; }
    .route-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
    .route-header h2 { font-size: 1.2rem; }
    .badge { padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.75rem; font-weight: bold; }
    .badge.pass { background: #1b5e20; color: #4caf50; }
    .badge.warn { background: #4e3a00; color: #ff9800; }
    .badge.fail { background: #4a0000; color: #f44336; }
    .badge.error { background: #333; color: #999; }
    .viewport-row { display: flex; gap: 1rem; margin-bottom: 1rem; align-items: flex-start; flex-wrap: wrap; }
    .viewport-label { writing-mode: vertical-rl; text-orientation: mixed; background: #16213e; padding: 0.5rem; border-radius: 4px; font-size: 0.8rem; min-width: 30px; text-align: center; }
    .images { display: flex; gap: 0.5rem; flex: 1; flex-wrap: wrap; }
    .img-col { flex: 1; min-width: 200px; }
    .img-col .label { font-size: 0.75rem; color: #888; margin-bottom: 0.25rem; text-transform: uppercase; }
    .img-col img { width: 100%; border: 1px solid #333; border-radius: 4px; cursor: pointer; }
    .img-col img:hover { border-color: #4caf50; }
    .stats-row { font-size: 0.8rem; color: #888; margin-top: 0.5rem; }
    .overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 1000; cursor: zoom-out; justify-content: center; align-items: center; }
    .overlay.active { display: flex; }
    .overlay img { max-width: 95%; max-height: 95%; object-fit: contain; }
  </style>
</head>
<body>
  <h1>Visual Parity Report</h1>
  <div class="meta">Generated: <span id="timestamp"></span> | Routes: <span id="routeCount"></span> | Viewports: desktop, tablet, mobile</div>
  <div class="summary" id="summary"></div>
  <div class="filters" id="filters"></div>
  <div id="routes"></div>
  <div class="overlay" id="overlay" onclick="this.classList.remove('active')">
    <img id="overlay-img" src="">
  </div>
  <script>
    // %%DATA%% is replaced by generate-report.ts
    const DATA = %%DATA%%;

    function statusOf(r) {
      if (r.error) return 'error';
      const pct = parseFloat(r.mismatchPct);
      if (pct < 0.5) return 'pass';
      if (pct < 2.0) return 'warn';
      return 'fail';
    }

    function render() {
      document.getElementById('timestamp').textContent = DATA.timestamp;
      document.getElementById('routeCount').textContent = DATA.routes.length;

      const counts = { pass: 0, warn: 0, fail: 0, error: 0 };
      DATA.results.forEach(r => counts[statusOf(r)]++);

      const summaryEl = document.getElementById('summary');
      summaryEl.innerHTML = `
        <div class="stat"><div class="label">Pass (&lt;0.5%)</div><div class="value pass">${counts.pass}</div></div>
        <div class="stat"><div class="label">Warning (0.5-2%)</div><div class="value warn">${counts.warn}</div></div>
        <div class="stat"><div class="label">Fail (&gt;2%)</div><div class="value fail">${counts.fail}</div></div>
        <div class="stat"><div class="label">Error</div><div class="value">${counts.error}</div></div>
        <div class="stat"><div class="label">Total</div><div class="value">${DATA.results.length}</div></div>
      `;

      const filtersEl = document.getElementById('filters');
      ['all', 'pass', 'warn', 'fail', 'error'].forEach(f => {
        const btn = document.createElement('button');
        btn.textContent = f === 'all' ? 'All' : f.charAt(0).toUpperCase() + f.slice(1);
        btn.className = f === 'all' ? 'active' : '';
        btn.onclick = () => {
          filtersEl.querySelectorAll('button').forEach(b => b.classList.remove('active'));
          btn.classList.add('active');
          document.querySelectorAll('.route-section').forEach(el => {
            el.style.display = f === 'all' || el.dataset.worstStatus === f ? '' : 'none';
          });
        };
        filtersEl.appendChild(btn);
      });

      const routesEl = document.getElementById('routes');
      for (const routeName of DATA.routes) {
        const routeResults = DATA.results.filter(r => r.route === routeName);
        const worstStatus = routeResults.reduce((w, r) => {
          const s = statusOf(r);
          const order = { error: 3, fail: 2, warn: 1, pass: 0 };
          return order[s] > order[w] ? s : w;
        }, 'pass');

        const section = document.createElement('div');
        section.className = 'route-section';
        section.dataset.worstStatus = worstStatus;

        let html = `<div class="route-header"><h2>${routeName}</h2><span class="badge ${worstStatus}">${worstStatus.toUpperCase()}</span></div>`;

        for (const r of routeResults) {
          const s = statusOf(r);
          const base = `screenshots`;
          html += `
            <div class="viewport-row">
              <div class="viewport-label">${r.viewport}</div>
              <div class="images">
                <div class="img-col"><div class="label">Angular</div><img src="${base}/angular/${r.route}-${r.viewport}.png" onclick="showOverlay(this.src)" loading="lazy"></div>
                <div class="img-col"><div class="label">React</div><img src="${base}/react/${r.route}-${r.viewport}.png" onclick="showOverlay(this.src)" loading="lazy"></div>
                <div class="img-col"><div class="label">Diff</div><img src="diffs/${r.route}-${r.viewport}.png" onclick="showOverlay(this.src)" loading="lazy"></div>
              </div>
            </div>
            <div class="stats-row">
              <span class="badge ${s}">${r.mismatchPct}%</span>
              ${r.mismatchCount >= 0 ? r.mismatchCount.toLocaleString() + ' px' : 'N/A'}
              | Height: Angular ${r.angularHeight}px, React ${r.reactHeight}px (Δ${r.heightDiff}px)
              ${r.error ? ' | Error: ' + r.error : ''}
            </div>
          `;
        }

        section.innerHTML = html;
        routesEl.appendChild(section);
      }
    }

    function showOverlay(src) {
      document.getElementById('overlay-img').src = src;
      document.getElementById('overlay').classList.add('active');
    }

    render();
  </script>
</body>
</html>
  • Step 2: Create the report generator script
// 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
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"

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:

  // Popular Requests
  POPULAR_REQUESTS_PANEL: 'popular-requests-panel',
  POPULAR_REQUEST_ITEM: 'popular-request-item',

And add Angular overrides if needed:

  [S.POPULAR_REQUESTS_PANEL]: 'popular-requests',
  [S.POPULAR_REQUEST_ITEM]: 'popular-request',
  • Step 2: Create the popular requests behavior spec
// 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
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
// 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<Record<string, number>> {
  await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
  await page.waitForTimeout(waitMs);

  const counts: Record<string, number> = {};
  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
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:

"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
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
// 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
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"