1137 lines
42 KiB
Markdown
1137 lines
42 KiB
Markdown
# 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<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**
|
|
|
|
```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
|
|
<!-- 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**
|
|
|
|
```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<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**
|
|
|
|
```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"
|
|
```
|