Add interactive HTML report generator for visual parity diffs

Self-contained dark-themed HTML report with summary stats, filter
buttons (pass/warn/fail/error), side-by-side image comparison per
route and viewport, lazy-loaded images, and full-size overlay on click.
The generator script reads report.json, converts absolute paths to
relative, and injects data into the template.
This commit is contained in:
2026-04-16 17:41:47 +03:00
parent d634f93700
commit e82289b979
2 changed files with 372 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
/**
* Generate an interactive HTML report from the visual parity JSON output.
*
* Reads comparison-report/visual/report.json and the HTML template,
* injects the data, fixes image paths to be relative, and writes
* comparison-report/visual/report.html.
*
* Usage:
* npx tsx tests/parity/visual/generate-report.ts
*/
import { readFileSync, writeFileSync } from "node:fs";
import { resolve, relative } from "node:path";
import type { MultiViewportReport } from "./screenshot-diff-multi.js";
const ROOT = resolve(import.meta.dirname, "../../..");
const REPORT_DIR = resolve(ROOT, "comparison-report/visual");
const REPORT_JSON = resolve(REPORT_DIR, "report.json");
const TEMPLATE_PATH = resolve(import.meta.dirname, "report-template.html");
const OUTPUT_PATH = resolve(REPORT_DIR, "report.html");
// Read inputs
const report: MultiViewportReport = JSON.parse(readFileSync(REPORT_JSON, "utf-8"));
const template = readFileSync(TEMPLATE_PATH, "utf-8");
// Fix image paths: convert absolute paths to relative from the report HTML location
for (const result of report.results) {
result.angularPath = relative(REPORT_DIR, result.angularPath);
result.reactPath = relative(REPORT_DIR, result.reactPath);
result.diffPath = relative(REPORT_DIR, result.diffPath);
}
// Inject data and write
const html = template.replace("%%DATA%%", JSON.stringify(report, null, 2));
writeFileSync(OUTPUT_PATH, html, "utf-8");
console.log(`Report written to ${OUTPUT_PATH}`);
console.log(` Routes: ${new Set(report.results.map((r) => r.route)).size}`);
console.log(` Viewports: ${Object.keys(report.viewports).length}`);
console.log(` Total comparisons: ${report.results.length}`);
+332
View File
@@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visual Parity Report</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #1a1a2e;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
padding: 24px;
line-height: 1.5;
}
h1 { color: #fff; margin-bottom: 8px; font-size: 1.6rem; }
.timestamp { color: #888; font-size: 0.85rem; margin-bottom: 24px; }
/* Summary bar */
.summary {
display: flex; gap: 16px; flex-wrap: wrap;
margin-bottom: 24px;
}
.summary-card {
padding: 12px 20px;
border-radius: 8px;
font-size: 1.1rem;
font-weight: 600;
min-width: 120px;
text-align: center;
}
.summary-card .count { font-size: 2rem; display: block; }
.summary-card.pass { background: #0d3320; color: #4ade80; }
.summary-card.warn { background: #3b2f0a; color: #fbbf24; }
.summary-card.fail { background: #3b0d0d; color: #f87171; }
.summary-card.error { background: #2d1b4e; color: #c084fc; }
/* Filters */
.filters {
display: flex; gap: 8px; flex-wrap: wrap;
margin-bottom: 24px;
}
.filters button {
padding: 8px 16px;
border: 1px solid #444;
border-radius: 6px;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.15s;
}
.filters button:hover { background: #1a3a5c; }
.filters button.active { background: #2563eb; border-color: #2563eb; color: #fff; }
/* Route group */
.route-group {
margin-bottom: 32px;
border: 1px solid #2a2a4a;
border-radius: 10px;
overflow: hidden;
}
.route-header {
background: #16213e;
padding: 12px 20px;
display: flex; align-items: center; gap: 12px;
cursor: pointer;
user-select: none;
}
.route-header:hover { background: #1a2a4e; }
.route-name { font-size: 1.15rem; font-weight: 600; color: #fff; }
.badge {
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.badge.pass { background: #065f46; color: #6ee7b7; }
.badge.warn { background: #78350f; color: #fde68a; }
.badge.fail { background: #7f1d1d; color: #fca5a5; }
.badge.error { background: #4c1d95; color: #ddd6fe; }
.route-body { padding: 0; }
.route-body.collapsed { display: none; }
/* Viewport row */
.viewport-row {
padding: 16px 20px;
border-top: 1px solid #2a2a4a;
}
.viewport-row-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 12px;
}
.viewport-label {
font-weight: 600; color: #93c5fd; font-size: 0.95rem;
}
.stats {
display: flex; gap: 16px; font-size: 0.82rem; color: #999;
}
.stats span { white-space: nowrap; }
.stats .pct { font-weight: 700; }
.stats .pct.pass { color: #4ade80; }
.stats .pct.warn { color: #fbbf24; }
.stats .pct.fail { color: #f87171; }
.images {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.img-cell {
text-align: center;
}
.img-cell .label {
font-size: 0.75rem; color: #888;
margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px;
}
.img-cell img {
width: 100%;
height: auto;
border-radius: 6px;
border: 1px solid #333;
cursor: zoom-in;
background: #111;
}
.error-msg {
color: #f87171;
font-size: 0.9rem;
padding: 8px 0;
}
/* Overlay */
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.92);
z-index: 1000;
cursor: zoom-out;
overflow: auto;
padding: 20px;
}
.overlay.visible { display: flex; align-items: center; justify-content: center; }
.overlay img {
max-width: 95vw;
max-height: 95vh;
border-radius: 8px;
}
</style>
</head>
<body>
<h1>Visual Parity Report: Angular vs React</h1>
<div class="timestamp" id="timestamp"></div>
<div class="summary" id="summary"></div>
<div class="filters" id="filters"></div>
<div id="content"></div>
<div class="overlay" id="overlay" onclick="this.classList.remove('visible')">
<img id="overlay-img" src="" alt="Full size">
</div>
<script>
const DATA = %%DATA%%;
// Classify a result
function classify(r) {
if (r.error) return 'error';
if (r.mismatchPct > 2) return 'fail';
if (r.mismatchPct >= 0.5) return 'warn';
return 'pass';
}
// Group results by route
function groupByRoute(results) {
const map = new Map();
for (const r of results) {
if (!map.has(r.route)) map.set(r.route, []);
map.get(r.route).push(r);
}
return map;
}
// Worst status for a group
function worstStatus(items) {
const order = ['pass', 'warn', 'fail', 'error'];
let worst = 0;
for (const item of items) {
const idx = order.indexOf(classify(item));
if (idx > worst) worst = idx;
}
return order[worst];
}
// Summary counts
const counts = { pass: 0, warn: 0, fail: 0, error: 0 };
for (const r of DATA.results) counts[classify(r)]++;
document.getElementById('timestamp').textContent = 'Generated: ' + DATA.timestamp;
// Render summary
const summaryEl = document.getElementById('summary');
for (const [key, label] of [['pass','Pass (<0.5%)'],['warn','Warn (0.5-2%)'],['fail','Fail (>2%)'],['error','Error']]) {
summaryEl.innerHTML += `<div class="summary-card ${key}"><span class="count">${counts[key]}</span>${label}</div>`;
}
// Render filters
const filtersEl = document.getElementById('filters');
let activeFilter = 'all';
const filterButtons = [
['all', 'All (' + DATA.results.length + ')'],
['pass', 'Pass (' + counts.pass + ')'],
['warn', 'Warn (' + counts.warn + ')'],
['fail', 'Fail (' + counts.fail + ')'],
['error', 'Error (' + counts.error + ')'],
];
for (const [key, label] of filterButtons) {
const btn = document.createElement('button');
btn.textContent = label;
btn.dataset.filter = key;
if (key === 'all') btn.classList.add('active');
btn.addEventListener('click', () => {
activeFilter = key;
document.querySelectorAll('.filters button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyFilter();
});
filtersEl.appendChild(btn);
}
function applyFilter() {
document.querySelectorAll('.route-group').forEach(g => {
if (activeFilter === 'all') {
g.style.display = '';
g.querySelectorAll('.viewport-row').forEach(r => r.style.display = '');
} else {
let hasVisible = false;
g.querySelectorAll('.viewport-row').forEach(r => {
const show = r.dataset.status === activeFilter;
r.style.display = show ? '' : 'none';
if (show) hasVisible = true;
});
g.style.display = hasVisible ? '' : 'none';
}
});
}
// Render content
const contentEl = document.getElementById('content');
const grouped = groupByRoute(DATA.results);
for (const [route, items] of grouped) {
const worst = worstStatus(items);
const group = document.createElement('div');
group.className = 'route-group';
const header = document.createElement('div');
header.className = 'route-header';
header.innerHTML = `<span class="route-name">${route}</span><span class="badge ${worst}">${worst}</span>`;
const body = document.createElement('div');
body.className = 'route-body';
header.addEventListener('click', () => body.classList.toggle('collapsed'));
for (const r of items) {
const status = classify(r);
const row = document.createElement('div');
row.className = 'viewport-row';
row.dataset.status = status;
if (r.error) {
row.innerHTML = `
<div class="viewport-row-header">
<span class="viewport-label">${r.viewport}</span>
<span class="badge error">error</span>
</div>
<div class="error-msg">${r.error}</div>`;
} else {
const pctClass = status;
row.innerHTML = `
<div class="viewport-row-header">
<span class="viewport-label">${r.viewport}</span>
<span class="badge ${status}">${status}</span>
<div class="stats">
<span>Diff: <span class="pct ${pctClass}">${r.mismatchPct.toFixed(2)}%</span></span>
<span>Pixels: ${r.mismatchCount.toLocaleString()} / ${r.totalPixels.toLocaleString()}</span>
<span>Height delta: ${r.heightDiff}px</span>
<span>Angular: ${r.angularDimensions.width}x${r.angularDimensions.height}</span>
<span>React: ${r.reactDimensions.width}x${r.reactDimensions.height}</span>
</div>
</div>
<div class="images">
<div class="img-cell">
<div class="label">Angular</div>
<img loading="lazy" src="${r.angularPath}" alt="Angular ${r.route} ${r.viewport}" onclick="showOverlay(this.src)">
</div>
<div class="img-cell">
<div class="label">React</div>
<img loading="lazy" src="${r.reactPath}" alt="React ${r.route} ${r.viewport}" onclick="showOverlay(this.src)">
</div>
<div class="img-cell">
<div class="label">Diff</div>
<img loading="lazy" src="${r.diffPath}" alt="Diff ${r.route} ${r.viewport}" onclick="showOverlay(this.src)">
</div>
</div>`;
}
body.appendChild(row);
}
group.appendChild(header);
group.appendChild(body);
contentEl.appendChild(group);
}
// Overlay
function showOverlay(src) {
const overlay = document.getElementById('overlay');
document.getElementById('overlay-img').src = src;
overlay.classList.add('visible');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') document.getElementById('overlay').classList.remove('visible');
});
</script>
</body>
</html>