@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
455 lines (408 loc) • 15.9 kB
JavaScript
/**
* HTML Report Generator for TDD visual comparison results
* Creates an interactive report with overlay, toggle, and onion skin modes
*/
import { writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join, relative, dirname } from 'path';
import { fileURLToPath } from 'url';
import * as output from '../utils/output.js';
export class HtmlReportGenerator {
constructor(workingDir, config) {
this.workingDir = workingDir;
this.config = config;
this.reportDir = join(workingDir, '.vizzly', 'report');
this.reportPath = join(this.reportDir, 'index.html');
// Get path to the CSS file that ships with the package
let __filename = fileURLToPath(import.meta.url);
let __dirname = dirname(__filename);
this.cssPath = join(__dirname, 'report-generator', 'report.css');
}
/**
* Sanitize HTML content to prevent XSS attacks
* @param {string} text - Text to sanitize
* @returns {string} Sanitized text
*/
sanitizeHtml(text) {
if (typeof text !== 'string') return '';
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
/**
* Sanitize build info object
* @param {Object} buildInfo - Build information to sanitize
* @returns {Object} Sanitized build info
*/
sanitizeBuildInfo(buildInfo = {}) {
let sanitized = {};
if (buildInfo.baseline && typeof buildInfo.baseline === 'object') {
sanitized.baseline = {
buildId: this.sanitizeHtml(buildInfo.baseline.buildId || ''),
buildName: this.sanitizeHtml(buildInfo.baseline.buildName || ''),
environment: this.sanitizeHtml(buildInfo.baseline.environment || ''),
branch: this.sanitizeHtml(buildInfo.baseline.branch || '')
};
}
if (typeof buildInfo.threshold === 'number') {
sanitized.threshold = Math.max(0, Math.min(1, buildInfo.threshold));
}
return sanitized;
}
/**
* Generate HTML report from TDD results
* @param {Object} results - TDD comparison results
* @param {Object} buildInfo - Build information
* @returns {string} Path to generated report
*/
async generateReport(results, buildInfo = {}) {
// Validate inputs
if (!results || typeof results !== 'object') {
throw new Error('Invalid results object provided');
}
const {
comparisons = [],
passed = 0,
failed = 0,
total = 0
} = results;
// Filter only failed comparisons for the report
const failedComparisons = comparisons.filter(comp => comp && comp.status === 'failed');
const reportData = {
buildInfo: {
timestamp: new Date().toISOString(),
...this.sanitizeBuildInfo(buildInfo)
},
summary: {
total,
passed,
failed,
passRate: total > 0 ? (passed / total * 100).toFixed(1) : '0.0'
},
comparisons: failedComparisons.map(comp => this.processComparison(comp)).filter(Boolean)
};
const htmlContent = this.generateHtmlTemplate(reportData);
try {
// Ensure report directory exists
await mkdir(this.reportDir, {
recursive: true
});
await writeFile(this.reportPath, htmlContent, 'utf8');
output.debug('report', 'generated html report');
return this.reportPath;
} catch (error) {
output.debug('report', 'html generation failed', {
error: error.message
});
throw new Error(`Report generation failed: ${error.message}`);
}
}
/**
* Process comparison data for HTML report
* @param {Object} comparison - Comparison object
* @returns {Object} Processed comparison data
*/
processComparison(comparison) {
if (!comparison || typeof comparison !== 'object') {
output.warn('Invalid comparison object provided');
return null;
}
return {
name: comparison.name || 'unnamed',
status: comparison.status,
baseline: this.getRelativePath(comparison.baseline, this.reportDir),
current: this.getRelativePath(comparison.current, this.reportDir),
diff: this.getRelativePath(comparison.diff, this.reportDir),
threshold: comparison.threshold || 0,
diffPercentage: comparison.diffPercentage || 0
};
}
/**
* Get relative path from report directory to image file
* @param {string} imagePath - Absolute path to image
* @param {string} reportDir - Report directory path
* @returns {string|null} Relative path or null if invalid
*/
getRelativePath(imagePath, reportDir) {
if (!imagePath || !existsSync(imagePath)) {
return null;
}
return relative(reportDir, imagePath);
}
/**
* Generate the complete HTML template
* @param {Object} data - Report data
* @returns {string} HTML content
*/
generateHtmlTemplate(data) {
return `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vizzly TDD Report</title>
<link rel="stylesheet" href="file://${this.cssPath}">
</head>
<body>
<div class="container">
<header class="header">
<h1>🐻 Vizzly Visual Testing Report</h1>
<div class="summary">
<div class="stat">
<span class="stat-number">${data.summary.total}</span>
<span class="stat-label">Total</span>
</div>
<div class="stat passed">
<span class="stat-number">${data.summary.passed}</span>
<span class="stat-label">Passed</span>
</div>
<div class="stat failed">
<span class="stat-number">${data.summary.failed}</span>
<span class="stat-label">Failed</span>
</div>
<div class="stat">
<span class="stat-number">${data.summary.passRate}%</span>
<span class="stat-label">Pass Rate</span>
</div>
</div>
<div class="build-info">
<span>Generated: ${new Date(data.buildInfo.timestamp).toLocaleString()}</span>
</div>
</header>
${data.comparisons.length === 0 ? '<div class="no-failures">🎉 All tests passed! No visual differences detected.</div>' : `<main class="comparisons">
${data.comparisons.map(comp => this.generateComparisonHtml(comp)).join('')}
</main>`}
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Handle view mode switching
document.querySelectorAll('.view-mode-btn').forEach(btn => {
btn.addEventListener('click', function () {
let comparison = this.closest('.comparison');
let mode = this.dataset.mode;
// Update active button
comparison
.querySelectorAll('.view-mode-btn')
.forEach(b => b.classList.remove('active'));
this.classList.add('active');
// Update viewer mode
let viewer = comparison.querySelector('.comparison-viewer');
viewer.dataset.mode = mode;
// Hide all mode containers
viewer.querySelectorAll('.mode-container').forEach(container => {
container.style.display = 'none';
});
// Show appropriate mode container
let activeContainer = viewer.querySelector('.' + mode + '-mode');
if (activeContainer) {
activeContainer.style.display = 'block';
}
});
});
// Handle onion skin drag-to-reveal
document.querySelectorAll('.onion-container').forEach(container => {
let isDragging = false;
function updateOnionSkin(x) {
let rect = container.getBoundingClientRect();
let percentage = Math.max(
0,
Math.min(100, ((x - rect.left) / rect.width) * 100)
);
let currentImg = container.querySelector('.onion-current');
let divider = container.querySelector('.onion-divider');
if (currentImg && divider) {
currentImg.style.clipPath = 'inset(0 ' + (100 - percentage) + '% 0 0)';
divider.style.left = percentage + '%';
}
}
container.addEventListener('mousedown', function (e) {
isDragging = true;
updateOnionSkin(e.clientX);
e.preventDefault();
});
container.addEventListener('mousemove', function (e) {
if (isDragging) {
updateOnionSkin(e.clientX);
}
});
document.addEventListener('mouseup', function () {
isDragging = false;
});
// Touch events for mobile
container.addEventListener('touchstart', function (e) {
isDragging = true;
updateOnionSkin(e.touches[0].clientX);
e.preventDefault();
});
container.addEventListener('touchmove', function (e) {
if (isDragging) {
updateOnionSkin(e.touches[0].clientX);
e.preventDefault();
}
});
document.addEventListener('touchend', function () {
isDragging = false;
});
});
// Handle overlay mode clicking
document.querySelectorAll('.overlay-container').forEach(container => {
container.addEventListener('click', function () {
let diffImage = this.querySelector('.diff-image');
if (diffImage) {
// Toggle diff visibility
let isVisible = diffImage.style.opacity === '1';
diffImage.style.opacity = isVisible ? '0' : '1';
}
});
});
// Handle toggle mode clicking
document.querySelectorAll('.toggle-container img').forEach(img => {
let isBaseline = true;
let comparison = img.closest('.comparison');
let baselineSrc = comparison.querySelector('.baseline-image').src;
let currentSrc = comparison.querySelector('.current-image').src;
img.addEventListener('click', function () {
isBaseline = !isBaseline;
this.src = isBaseline ? baselineSrc : currentSrc;
// Update cursor style to indicate interactivity
this.style.cursor = 'pointer';
});
});
console.log('Vizzly TDD Report loaded successfully');
});
// Accept/Reject baseline functions
async function acceptBaseline(screenshotName) {
const button = document.querySelector(\`button[onclick*="\${screenshotName}"]\`);
if (button) {
button.disabled = true;
button.innerHTML = '⏳ Accepting...';
}
try {
const response = await fetch('/accept-baseline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: screenshotName })
});
if (response.ok) {
// Mark as accepted and hide the comparison
const comparison = document.querySelector(\`[data-comparison="\${screenshotName}"]\`);
if (comparison) {
comparison.style.background = '#e8f5e8';
comparison.style.border = '2px solid #4caf50';
const status = comparison.querySelector('.diff-status');
if (status) {
status.innerHTML = '✅ Accepted as new baseline';
status.style.color = '#4caf50';
}
const actions = comparison.querySelector('.comparison-actions');
if (actions) {
actions.innerHTML = '<div style="color: #4caf50; padding: 0.5rem;">✅ Screenshot accepted as new baseline</div>';
}
}
// Auto-refresh after short delay to show updated report
setTimeout(() => window.location.reload(), 2000);
} else {
throw new Error('Failed to accept baseline');
}
} catch (error) {
console.error('Error accepting baseline:', error);
if (button) {
button.disabled = false;
button.innerHTML = '✅ Accept as Baseline';
}
alert('Failed to accept baseline. Please try again.');
}
}
function rejectChanges(screenshotName) {
const comparison = document.querySelector(\`[data-comparison="\${screenshotName}"]\`);
if (comparison) {
comparison.style.background = '#fff3cd';
comparison.style.border = '2px solid #ffc107';
const status = comparison.querySelector('.diff-status');
if (status) {
status.innerHTML = '⚠️ Changes rejected - baseline unchanged';
status.style.color = '#856404';
}
const actions = comparison.querySelector('.comparison-actions');
if (actions) {
actions.innerHTML = '<div style="color: #856404; padding: 0.5rem;">⚠️ Changes rejected - baseline kept as-is</div>';
}
}
}
</script>
</body>
</html>`;
}
/**
* Generate HTML for a single comparison
* @param {Object} comparison - Comparison data
* @returns {string} HTML content
*/
generateComparisonHtml(comparison) {
if (!comparison || !comparison.baseline || !comparison.current || !comparison.diff) {
return `<div class="comparison error">
<h3>${this.sanitizeHtml(comparison?.name || 'Unknown')}</h3>
<p>Missing comparison images</p>
</div>`;
}
let safeName = this.sanitizeHtml(comparison.name);
return `
<div class="comparison" data-comparison="${safeName}">
<div class="comparison-header">
<h3>${safeName}</h3>
<div class="comparison-meta">
<span class="diff-status">Visual differences detected</span>
</div>
</div>
<div class="comparison-controls">
<button class="view-mode-btn active" data-mode="overlay">Overlay</button>
<button class="view-mode-btn" data-mode="toggle">Toggle</button>
<button class="view-mode-btn" data-mode="onion">Onion Skin</button>
<button class="view-mode-btn" data-mode="side-by-side">Side by Side</button>
</div>
<div class="comparison-actions">
<button class="accept-btn" onclick="acceptBaseline('${safeName}')">
✅ Accept as Baseline
</button>
<button class="reject-btn" onclick="rejectChanges('${safeName}')">
❌ Keep Current Baseline
</button>
</div>
<div class="comparison-viewer">
<!-- Overlay Mode -->
<div class="mode-container overlay-mode" data-mode="overlay">
<div class="overlay-container">
<img class="current-image" src="${comparison.current}" alt="Current" />
<img class="baseline-image" src="${comparison.baseline}" alt="Baseline" />
<img class="diff-image" src="${comparison.diff}" alt="Diff" />
</div>
</div>
<!-- Toggle Mode -->
<div class="mode-container toggle-mode" data-mode="toggle" style="display: none;">
<div class="toggle-container">
<img class="toggle-image" src="${comparison.baseline}" alt="Baseline" />
</div>
</div>
<!-- Onion Skin Mode -->
<div class="mode-container onion-mode" data-mode="onion" style="display: none;">
<div class="onion-container">
<img class="onion-baseline" src="${comparison.baseline}" alt="Baseline" />
<img class="onion-current" src="${comparison.current}" alt="Current" />
<div class="onion-divider"></div>
</div>
</div>
<!-- Side by Side Mode -->
<div class="mode-container side-by-side-mode" data-mode="side-by-side" style="display: none;">
<div class="side-by-side-container">
<div class="side-by-side-image">
<img src="${comparison.baseline}" alt="Baseline" />
<label>Baseline</label>
</div>
<div class="side-by-side-image">
<img src="${comparison.current}" alt="Current" />
<label>Current</label>
</div>
</div>
</div>
</div>
</div>`;
}
}