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 outputtests/parity/visual/report-template.html— HTML template for the visual diff reporttests/parity/gap/gap-analysis.ts— DOM structure comparison scripttests/e2e-angular/cross-app/19-popular-requests-behavior.spec.ts— Popular requests click + filter pre-fill teststests/e2e-angular/cross-app/20-visual-parity-smoke.spec.ts— Lightweight Playwright visual comparison tests usingtoHaveScreenshot
Modified files
package.json— Add script entries for the new commandstests/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 (<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 (>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"
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:
// 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"