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:
@@ -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}`);
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user