mirror of
https://github.com/LadybirdBrowser/ladybird
synced 2026-04-27 02:05:07 +02:00
Instead of rendering a reference HTML page that wraps an <img> tag pointing to a PNG, Screenshot tests now load the expected PNG directly from disk and compare it against the rendered screenshot. This eliminates the indirection of loading and rendering a second page just to display a static image. This also means --rebaseline now works for Screenshot tests, generating the expected PNG automatically instead of requiring manual screenshot capture and placement. Changes: - Add TestMode::Screenshot with its own collector and runner - Move PNGs from Screenshot/images/ to Screenshot/expected/ with normalized names matching input filenames - Remove all 92 reference HTML wrapper files and the images/ directory - Remove <link rel="match"> from all 94 Screenshot input HTML files - Update add_libweb_test.py Screenshot boilerplate accordingly - Add Screenshot mode to results viewer image comparison tabs
1003 lines
34 KiB
HTML
1003 lines
34 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Test Results</title>
|
|
<style>
|
|
:root {
|
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
|
--bg-primary: #0d1117;
|
|
--bg-secondary: #161b22;
|
|
--bg-tertiary: #21262d;
|
|
--bg-hover: #30363d;
|
|
--border-primary: #30363d;
|
|
--border-secondary: #21262d;
|
|
--text-primary: #e6edf3;
|
|
--text-secondary: #8b949e;
|
|
--text-muted: #6e7681;
|
|
--accent-blue: #58a6ff;
|
|
--accent-green: #3fb950;
|
|
--accent-red: #f85149;
|
|
--accent-orange: #d29922;
|
|
--accent-purple: #a371f7;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: var(--font-sans);
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
padding: 0;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 24px;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 24px;
|
|
padding-bottom: 16px;
|
|
border-bottom: 1px solid var(--border-primary);
|
|
}
|
|
|
|
h1 {
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
h1::before {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
background: var(--accent-red);
|
|
border-radius: 50%;
|
|
box-shadow: 0 0 8px var(--accent-red);
|
|
}
|
|
|
|
.search-box {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
.search-box input {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
font-family: inherit;
|
|
font-size: 14px;
|
|
outline: none;
|
|
width: 240px;
|
|
}
|
|
|
|
.search-box input::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.search-icon {
|
|
color: var(--text-muted);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-card.total {
|
|
border-color: var(--accent-blue);
|
|
background: linear-gradient(135deg, rgba(88, 166, 255, 0.1) 0%, transparent 100%);
|
|
}
|
|
|
|
.stat-card.fail {
|
|
border-color: var(--accent-red);
|
|
background: linear-gradient(135deg, rgba(248, 81, 73, 0.1) 0%, transparent 100%);
|
|
}
|
|
|
|
.stat-card.timeout {
|
|
border-color: var(--accent-orange);
|
|
background: linear-gradient(135deg, rgba(210, 153, 34, 0.1) 0%, transparent 100%);
|
|
}
|
|
|
|
.stat-card.crashed {
|
|
border-color: var(--accent-purple);
|
|
background: linear-gradient(135deg, rgba(163, 113, 247, 0.1) 0%, transparent 100%);
|
|
}
|
|
|
|
.stat-card.skipped {
|
|
border-color: var(--text-muted);
|
|
background: linear-gradient(135deg, rgba(110, 118, 129, 0.1) 0%, transparent 100%);
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 32px;
|
|
font-weight: 700;
|
|
font-family: var(--font-mono);
|
|
line-height: 1;
|
|
}
|
|
|
|
.stat-card.total .stat-value { color: var(--accent-blue); }
|
|
.stat-card.fail .stat-value { color: var(--accent-red); }
|
|
.stat-card.timeout .stat-value { color: var(--accent-orange); }
|
|
.stat-card.crashed .stat-value { color: var(--accent-purple); }
|
|
.stat-card.skipped .stat-value { color: var(--text-muted); }
|
|
|
|
.stat-label {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
margin-top: 4px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.test-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.test-item {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
transition: border-color 0.15s ease;
|
|
}
|
|
|
|
.test-item:hover {
|
|
border-color: var(--border-primary);
|
|
}
|
|
|
|
.test-item.expanded {
|
|
border-color: var(--accent-blue);
|
|
}
|
|
|
|
.test-header {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 12px 16px;
|
|
cursor: pointer;
|
|
gap: 12px;
|
|
transition: background 0.15s ease;
|
|
}
|
|
|
|
.test-header:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.result-badge {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.result-badge.Fail {
|
|
background: rgba(248, 81, 73, 0.2);
|
|
color: var(--accent-red);
|
|
border: 1px solid rgba(248, 81, 73, 0.4);
|
|
}
|
|
|
|
.result-badge.Timeout {
|
|
background: rgba(210, 153, 34, 0.2);
|
|
color: var(--accent-orange);
|
|
border: 1px solid rgba(210, 153, 34, 0.4);
|
|
}
|
|
|
|
.result-badge.Crashed {
|
|
background: rgba(163, 113, 247, 0.2);
|
|
color: var(--accent-purple);
|
|
border: 1px solid rgba(163, 113, 247, 0.4);
|
|
}
|
|
|
|
.result-badge.Skipped {
|
|
background: rgba(110, 118, 129, 0.2);
|
|
color: var(--text-secondary);
|
|
border: 1px solid rgba(110, 118, 129, 0.4);
|
|
}
|
|
|
|
.mode-badge {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.test-name {
|
|
flex: 1;
|
|
font-family: var(--font-mono);
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
word-break: break-all;
|
|
}
|
|
|
|
.expand-icon {
|
|
color: var(--text-muted);
|
|
transition: transform 0.2s ease;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.test-item.expanded .expand-icon {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.test-content {
|
|
display: none;
|
|
border-top: 1px solid var(--border-primary);
|
|
}
|
|
|
|
.test-item.expanded .test-content {
|
|
display: block;
|
|
}
|
|
|
|
.tab-bar {
|
|
display: flex;
|
|
background: var(--bg-tertiary);
|
|
padding: 0 16px;
|
|
gap: 4px;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.tab-btn {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
padding: 10px 16px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
transition: all 0.15s ease;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tab-btn:hover {
|
|
color: var(--text-primary);
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.tab-btn.active {
|
|
color: var(--accent-blue);
|
|
border-bottom-color: var(--accent-blue);
|
|
}
|
|
|
|
.preview-container {
|
|
min-height: 300px;
|
|
max-height: 600px;
|
|
overflow: auto;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.preview-frame {
|
|
width: 100%;
|
|
height: 500px;
|
|
border: none;
|
|
background: white;
|
|
}
|
|
|
|
.preview-image {
|
|
max-width: 100%;
|
|
display: block;
|
|
margin: 16px auto;
|
|
}
|
|
|
|
.image-diff-viewer {
|
|
padding: 0;
|
|
}
|
|
|
|
.diff-mode-bar {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 12px 16px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-primary);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 20;
|
|
}
|
|
|
|
.diff-mode-btn {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
padding: 6px 12px;
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: 4px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.diff-mode-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.diff-mode-btn.active {
|
|
background: var(--accent-blue);
|
|
border-color: var(--accent-blue);
|
|
color: white;
|
|
}
|
|
|
|
.diff-slider-container {
|
|
display: none;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.diff-slider-container.visible {
|
|
display: flex;
|
|
}
|
|
|
|
.diff-slider-container label {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.diff-slider {
|
|
width: 200px;
|
|
height: 4px;
|
|
appearance: none;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 2px;
|
|
outline: none;
|
|
}
|
|
|
|
.diff-slider::slider-thumb {
|
|
appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
background: var(--accent-blue);
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
|
|
.diff-image-wrapper {
|
|
padding: 16px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.diff-image-container {
|
|
position: relative;
|
|
display: inline-block;
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
background: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%) 50% / 16px 16px;
|
|
}
|
|
|
|
.diff-image-container img,
|
|
.diff-image-container canvas {
|
|
display: block;
|
|
}
|
|
|
|
.diff-image-actual {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
}
|
|
|
|
.diff-slide-clip {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.diff-slide-clip img {
|
|
max-width: none;
|
|
}
|
|
|
|
.diff-slide-line {
|
|
position: absolute;
|
|
top: 0;
|
|
width: 2px;
|
|
height: 100%;
|
|
background: var(--accent-blue);
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
}
|
|
|
|
.diff-slide-handle {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
width: 32px;
|
|
height: 32px;
|
|
background: var(--accent-blue);
|
|
border: 2px solid white;
|
|
border-radius: 50%;
|
|
cursor: ew-resize;
|
|
z-index: 11;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.diff-slide-handle::before,
|
|
.diff-slide-handle::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 2px;
|
|
height: 10px;
|
|
background: white;
|
|
border-radius: 1px;
|
|
}
|
|
|
|
.diff-slide-handle::before {
|
|
left: 8px;
|
|
}
|
|
|
|
.diff-slide-handle::after {
|
|
right: 8px;
|
|
}
|
|
|
|
.diff-toggle-label {
|
|
display: inline-block;
|
|
padding: 4px 8px;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 4px;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.diff-stats {
|
|
padding: 8px 16px;
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-primary);
|
|
}
|
|
|
|
.diff-stats strong {
|
|
color: var(--accent-red);
|
|
}
|
|
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 48px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 64px 24px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.empty-state h2 {
|
|
color: var(--accent-green);
|
|
margin: 0 0 8px 0;
|
|
}
|
|
|
|
.keyboard-hint {
|
|
position: fixed;
|
|
bottom: 16px;
|
|
right: 16px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
gap: 16px;
|
|
}
|
|
|
|
.keyboard-hint kbd {
|
|
background: var(--bg-tertiary);
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-family: var(--font-mono);
|
|
margin-right: 4px;
|
|
}
|
|
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>Test Results</h1>
|
|
<div class="search-box">
|
|
<span class="search-icon">🔍</span>
|
|
<input type="text" id="search" placeholder="Filter tests..." autocomplete="off">
|
|
</div>
|
|
</header>
|
|
<div id="content"><div class="loading">Loading results...</div></div>
|
|
</div>
|
|
<div class="keyboard-hint">
|
|
<span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span>
|
|
<span><kbd>Enter</kbd> Expand</span>
|
|
<span><kbd>/</kbd> Search</span>
|
|
</div>
|
|
<script src="results.js"></script>
|
|
<script>
|
|
let currentIndex = -1;
|
|
let testElements = [];
|
|
|
|
function loadResults() {
|
|
if (typeof RESULTS_DATA === 'undefined') {
|
|
document.getElementById('content').innerHTML = '<div class="empty-state"><h2>No Results</h2><p>results.js not found</p></div>';
|
|
return;
|
|
}
|
|
renderResults(RESULTS_DATA);
|
|
setupKeyboardNav();
|
|
}
|
|
|
|
function renderResults(data) {
|
|
const content = document.getElementById('content');
|
|
|
|
if (data.tests.length === 0) {
|
|
content.innerHTML = '<div class="empty-state"><h2>All Tests Passed!</h2><p>No failing tests to display.</p></div>';
|
|
return;
|
|
}
|
|
|
|
let html = `
|
|
<div class="summary">
|
|
<div class="stat-card total">
|
|
<div class="stat-value">${data.summary.total}</div>
|
|
<div class="stat-label">Total</div>
|
|
</div>
|
|
<div class="stat-card fail">
|
|
<div class="stat-value">${data.summary.fail}</div>
|
|
<div class="stat-label">Failed</div>
|
|
</div>
|
|
<div class="stat-card timeout">
|
|
<div class="stat-value">${data.summary.timeout}</div>
|
|
<div class="stat-label">Timeout</div>
|
|
</div>
|
|
<div class="stat-card crashed">
|
|
<div class="stat-value">${data.summary.crashed}</div>
|
|
<div class="stat-label">Crashed</div>
|
|
</div>
|
|
<div class="stat-card skipped">
|
|
<div class="stat-value">${data.summary.skipped}</div>
|
|
<div class="stat-label">Skipped</div>
|
|
</div>
|
|
</div>
|
|
<div class="test-list" id="testList">
|
|
`;
|
|
|
|
for (const test of data.tests) {
|
|
const testId = test.name.replace(/[^a-zA-Z0-9]/g, '-');
|
|
const tabs = buildTabs(test);
|
|
|
|
html += `
|
|
<div class="test-item" data-name="${test.name.toLowerCase()}" id="test-${testId}">
|
|
<div class="test-header" onclick="toggleTest('${testId}')">
|
|
<span class="result-badge ${test.result}">${test.result}</span>
|
|
<span class="mode-badge">${test.mode}</span>
|
|
<span class="test-name">${test.name}</span>
|
|
<span class="expand-icon">▶</span>
|
|
</div>
|
|
<div class="test-content">
|
|
<div class="tab-bar">${tabs.bar}</div>
|
|
<div class="preview-container" id="preview-${testId}">
|
|
<div class="loading">Select a tab to view content</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
html += '</div>';
|
|
content.innerHTML = html;
|
|
testElements = Array.from(document.querySelectorAll('.test-item'));
|
|
}
|
|
|
|
function buildTabs(test) {
|
|
let tabs = [];
|
|
|
|
if (test.mode === 'Layout' || test.mode === 'Text') {
|
|
tabs.push({ id: 'diff', label: 'Diff', file: `${test.name}.diff.html`, type: 'diff' });
|
|
tabs.push({ id: 'expected', label: 'Expected', file: `${test.name}.expected.txt`, type: 'text' });
|
|
tabs.push({ id: 'actual', label: 'Actual', file: `${test.name}.actual.txt`, type: 'text' });
|
|
} else if (test.mode === 'Ref' || test.mode === 'Screenshot') {
|
|
tabs.push({ id: 'compare', label: 'Compare', expected: `${test.name}.expected.png`, actual: `${test.name}.actual.png`, diff: `${test.name}.diff.png`, pixelErrors: test.pixelErrors, maxChannelDiff: test.maxChannelDiff, type: 'imageDiff' });
|
|
}
|
|
|
|
if (test.hasStdout) tabs.push({ id: 'stdout', label: 'stdout', file: `${test.name}.stdout.txt`, type: 'text' });
|
|
if (test.hasStderr) tabs.push({ id: 'stderr', label: 'stderr', file: `${test.name}.stderr.txt`, type: 'text' });
|
|
|
|
const bar = tabs.map((t, i) =>
|
|
`<button class="tab-btn${i === 0 ? ' active' : ''}" onclick="showTab('${test.name.replace(/[^a-zA-Z0-9]/g, '-')}', ${JSON.stringify(t).replace(/"/g, '"')}, this)">${t.label}</button>`
|
|
).join('');
|
|
|
|
return { bar, tabs };
|
|
}
|
|
|
|
function toggleTest(testId) {
|
|
const item = document.getElementById(`test-${testId}`);
|
|
const wasExpanded = item.classList.contains('expanded');
|
|
|
|
// Collapse all
|
|
document.querySelectorAll('.test-item.expanded').forEach(el => el.classList.remove('expanded'));
|
|
|
|
if (!wasExpanded) {
|
|
item.classList.add('expanded');
|
|
// Auto-click first tab
|
|
const firstTab = item.querySelector('.tab-btn');
|
|
if (firstTab) firstTab.click();
|
|
|
|
// Update keyboard nav index
|
|
currentIndex = testElements.indexOf(item);
|
|
}
|
|
}
|
|
|
|
function showTab(testId, tab, btn) {
|
|
// Update active tab
|
|
btn.parentElement.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
|
|
const container = document.getElementById(`preview-${testId}`);
|
|
|
|
if (tab.type === 'diff' || tab.type === 'text') {
|
|
container.innerHTML = `<iframe class="preview-frame" src="${tab.file}"></iframe>`;
|
|
} else if (tab.type === 'image') {
|
|
container.innerHTML = `<img class="preview-image" src="${tab.file}" alt="${tab.label}">`;
|
|
} else if (tab.type === 'imageDiff') {
|
|
initImageDiffViewer(container, tab.expected, tab.actual, tab.diff, tab.pixelErrors, tab.maxChannelDiff);
|
|
}
|
|
}
|
|
|
|
function initImageDiffViewer(container, expectedSrc, actualSrc, diffSrc, pixelErrors, maxChannelDiff) {
|
|
const viewerId = 'diff-' + Math.random().toString(36).substr(2, 9);
|
|
|
|
container.innerHTML = `
|
|
<div class="image-diff-viewer" id="${viewerId}">
|
|
<div class="diff-mode-bar">
|
|
<button class="diff-mode-btn active" data-mode="toggle">Toggle</button>
|
|
<button class="diff-mode-btn" data-mode="slide">Slide</button>
|
|
<button class="diff-mode-btn" data-mode="fade">Fade</button>
|
|
<button class="diff-mode-btn" data-mode="difference">Difference</button>
|
|
<div class="diff-slider-container" id="${viewerId}-slider-container">
|
|
<label>Opacity: <span id="${viewerId}-slider-value">50</span>%</label>
|
|
<input type="range" class="diff-slider" id="${viewerId}-slider" min="0" max="100" value="50">
|
|
</div>
|
|
</div>
|
|
<div class="diff-stats" id="${viewerId}-stats" style="display: none;"></div>
|
|
<div class="diff-image-wrapper">
|
|
<div class="diff-image-container" id="${viewerId}-container">
|
|
<div class="loading">Loading images...</div>
|
|
</div>
|
|
<div class="diff-toggle-label" id="${viewerId}-label" style="display: none;">Showing: Expected</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const viewer = document.getElementById(viewerId);
|
|
const imageContainer = document.getElementById(`${viewerId}-container`);
|
|
const sliderContainer = document.getElementById(`${viewerId}-slider-container`);
|
|
const slider = document.getElementById(`${viewerId}-slider`);
|
|
const sliderValue = document.getElementById(`${viewerId}-slider-value`);
|
|
const toggleLabel = document.getElementById(`${viewerId}-label`);
|
|
const statsEl = document.getElementById(`${viewerId}-stats`);
|
|
|
|
let expectedImg = new Image();
|
|
let actualImg = new Image();
|
|
let diffImg = new Image();
|
|
let currentMode = 'toggle';
|
|
let showingExpected = true;
|
|
let imagesLoaded = 0;
|
|
let width, height;
|
|
|
|
function onImageLoad() {
|
|
imagesLoaded++;
|
|
if (imagesLoaded === 2) {
|
|
setupDiffViewer();
|
|
}
|
|
}
|
|
|
|
expectedImg.onload = actualImg.onload = onImageLoad;
|
|
|
|
expectedImg.onerror = actualImg.onerror = function() {
|
|
imageContainer.innerHTML = '<div class="loading">Failed to load image</div>';
|
|
};
|
|
|
|
expectedImg.src = expectedSrc;
|
|
actualImg.src = actualSrc;
|
|
if (diffSrc)
|
|
diffImg.src = diffSrc;
|
|
|
|
function setupDiffViewer() {
|
|
width = Math.max(expectedImg.naturalWidth, actualImg.naturalWidth);
|
|
height = Math.max(expectedImg.naturalHeight, actualImg.naturalHeight);
|
|
|
|
imageContainer.innerHTML = '';
|
|
imageContainer.style.width = width + 'px';
|
|
imageContainer.style.height = height + 'px';
|
|
|
|
if (pixelErrors !== undefined) {
|
|
const totalPixels = width * height;
|
|
const pct = (pixelErrors / totalPixels * 100).toFixed(2);
|
|
statsEl.innerHTML = `Pixel errors: <strong>${pixelErrors}</strong> / ${totalPixels} (${pct}%) · Max channel difference: <strong>${maxChannelDiff}</strong>`;
|
|
statsEl.style.display = 'block';
|
|
}
|
|
|
|
renderToggleMode();
|
|
}
|
|
|
|
function renderToggleMode() {
|
|
imageContainer.innerHTML = '';
|
|
const img = showingExpected ? expectedImg.cloneNode() : actualImg.cloneNode();
|
|
img.style.display = 'block';
|
|
imageContainer.appendChild(img);
|
|
|
|
toggleLabel.style.display = 'block';
|
|
toggleLabel.textContent = 'Showing: ' + (showingExpected ? 'Expected' : 'Actual') + ' (click image to toggle)';
|
|
|
|
imageContainer.style.cursor = 'pointer';
|
|
imageContainer.onclick = function() {
|
|
showingExpected = !showingExpected;
|
|
renderToggleMode();
|
|
};
|
|
}
|
|
|
|
function renderSlideMode() {
|
|
imageContainer.innerHTML = '';
|
|
imageContainer.style.cursor = 'ew-resize';
|
|
|
|
// Base layer: actual image
|
|
const actualClone = actualImg.cloneNode();
|
|
actualClone.style.display = 'block';
|
|
imageContainer.appendChild(actualClone);
|
|
|
|
// Clip layer: expected image (clipped from left)
|
|
const clipDiv = document.createElement('div');
|
|
clipDiv.className = 'diff-slide-clip';
|
|
clipDiv.style.width = (width / 2) + 'px';
|
|
|
|
const expectedClone = expectedImg.cloneNode();
|
|
expectedClone.style.display = 'block';
|
|
expectedClone.style.width = width + 'px';
|
|
expectedClone.style.maxWidth = 'none';
|
|
clipDiv.appendChild(expectedClone);
|
|
imageContainer.appendChild(clipDiv);
|
|
|
|
// Vertical line
|
|
const line = document.createElement('div');
|
|
line.className = 'diff-slide-line';
|
|
line.style.left = (width / 2) + 'px';
|
|
imageContainer.appendChild(line);
|
|
|
|
// Draggable handle on the line
|
|
const handle = document.createElement('div');
|
|
handle.className = 'diff-slide-handle';
|
|
handle.style.left = (width / 2) + 'px';
|
|
imageContainer.appendChild(handle);
|
|
|
|
let dragging = false;
|
|
|
|
function updatePosition(clientX) {
|
|
const rect = imageContainer.getBoundingClientRect();
|
|
let x = clientX - rect.left;
|
|
x = Math.max(0, Math.min(width, x));
|
|
clipDiv.style.width = x + 'px';
|
|
line.style.left = x + 'px';
|
|
handle.style.left = x + 'px';
|
|
}
|
|
|
|
function onMouseDown(e) {
|
|
dragging = true;
|
|
e.preventDefault();
|
|
updatePosition(e.clientX);
|
|
}
|
|
|
|
function onMouseMove(e) {
|
|
if (dragging) {
|
|
updatePosition(e.clientX);
|
|
}
|
|
}
|
|
|
|
function onMouseUp() {
|
|
dragging = false;
|
|
}
|
|
|
|
handle.addEventListener('mousedown', onMouseDown);
|
|
imageContainer.addEventListener('mousedown', onMouseDown);
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
|
|
// Store cleanup function
|
|
imageContainer._cleanup = function() {
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
};
|
|
}
|
|
|
|
function renderFadeMode() {
|
|
const opacity = slider.value / 100;
|
|
|
|
imageContainer.innerHTML = '';
|
|
imageContainer.style.cursor = 'default';
|
|
imageContainer.onclick = null;
|
|
|
|
const expectedClone = expectedImg.cloneNode();
|
|
expectedClone.style.display = 'block';
|
|
expectedClone.style.position = 'relative';
|
|
imageContainer.appendChild(expectedClone);
|
|
|
|
const actualClone = actualImg.cloneNode();
|
|
actualClone.className = 'diff-image-actual';
|
|
actualClone.style.opacity = opacity;
|
|
imageContainer.appendChild(actualClone);
|
|
}
|
|
|
|
function renderDifferenceMode() {
|
|
imageContainer.innerHTML = '';
|
|
imageContainer.style.cursor = 'default';
|
|
imageContainer.onclick = null;
|
|
|
|
if (diffImg.src) {
|
|
const img = diffImg.cloneNode();
|
|
img.style.display = 'block';
|
|
imageContainer.appendChild(img);
|
|
} else {
|
|
imageContainer.innerHTML = '<div class="loading">No diff image available</div>';
|
|
}
|
|
|
|
toggleLabel.style.display = 'block';
|
|
toggleLabel.textContent = 'Difference mode: black = identical, red = different';
|
|
}
|
|
|
|
function cleanupCurrentMode() {
|
|
if (imageContainer._cleanup) {
|
|
imageContainer._cleanup();
|
|
imageContainer._cleanup = null;
|
|
}
|
|
imageContainer.onclick = null;
|
|
imageContainer.style.filter = '';
|
|
}
|
|
|
|
function setMode(mode) {
|
|
cleanupCurrentMode();
|
|
currentMode = mode;
|
|
toggleLabel.style.display = 'none';
|
|
|
|
viewer.querySelectorAll('.diff-mode-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.mode === mode);
|
|
});
|
|
|
|
if (mode === 'fade') {
|
|
sliderContainer.classList.add('visible');
|
|
slider.value = 50;
|
|
sliderValue.textContent = '50';
|
|
} else {
|
|
sliderContainer.classList.remove('visible');
|
|
}
|
|
|
|
switch (mode) {
|
|
case 'toggle': renderToggleMode(); break;
|
|
case 'slide': renderSlideMode(); break;
|
|
case 'fade': renderFadeMode(); break;
|
|
case 'difference': renderDifferenceMode(); break;
|
|
}
|
|
}
|
|
|
|
viewer.querySelectorAll('.diff-mode-btn').forEach(btn => {
|
|
btn.onclick = function() {
|
|
setMode(this.dataset.mode);
|
|
};
|
|
});
|
|
|
|
slider.oninput = function() {
|
|
sliderValue.textContent = Math.round(this.value);
|
|
if (currentMode === 'fade') {
|
|
const opacity = this.value / 100;
|
|
const actual = imageContainer.querySelector('.diff-image-actual');
|
|
if (actual) actual.style.opacity = opacity;
|
|
}
|
|
};
|
|
}
|
|
|
|
function setupKeyboardNav() {
|
|
const searchInput = document.getElementById('search');
|
|
|
|
searchInput.addEventListener('input', function() {
|
|
const query = this.value.toLowerCase();
|
|
testElements.forEach(el => {
|
|
const name = el.dataset.name;
|
|
el.classList.toggle('hidden', !name.includes(query));
|
|
});
|
|
});
|
|
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === '/' && document.activeElement !== searchInput) {
|
|
e.preventDefault();
|
|
searchInput.focus();
|
|
return;
|
|
}
|
|
|
|
if (document.activeElement === searchInput) {
|
|
if (e.key === 'Escape') {
|
|
searchInput.blur();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const visibleTests = testElements.filter(el => !el.classList.contains('hidden'));
|
|
|
|
if (e.key === 'ArrowDown' || e.key === 'j') {
|
|
e.preventDefault();
|
|
if (currentIndex < visibleTests.length - 1) {
|
|
currentIndex++;
|
|
visibleTests[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
highlightTest(visibleTests[currentIndex]);
|
|
}
|
|
} else if (e.key === 'ArrowUp' || e.key === 'k') {
|
|
e.preventDefault();
|
|
if (currentIndex > 0) {
|
|
currentIndex--;
|
|
visibleTests[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
highlightTest(visibleTests[currentIndex]);
|
|
}
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (currentIndex >= 0 && visibleTests[currentIndex]) {
|
|
const testId = visibleTests[currentIndex].id.replace('test-', '');
|
|
toggleTest(testId);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function highlightTest(el) {
|
|
testElements.forEach(t => t.style.outline = '');
|
|
el.style.outline = '2px solid var(--accent-blue)';
|
|
el.style.outlineOffset = '-2px';
|
|
}
|
|
|
|
loadResults();
|
|
</script>
|
|
</body>
|
|
</html>
|