super-pancake-automation
Version:
A lightweight DOM-based UI automation framework using Chrome DevTools Protocol
764 lines (694 loc) • 32.1 kB
JavaScript
// Enhanced HTML Reporter with Modern UI
import fs from 'fs';
import path from 'path';
console.log('🎨 Modern HTML Reporter (Integrated) loaded');
export function initializeReportDirectory() {
const reportDir = 'test-report';
const resultsDir = path.join(reportDir, 'results');
const screenshotsDir = path.join(reportDir, 'screenshots');
console.log(`Initializing report directory at: ${path.resolve(reportDir)}`);
try {
// Clear global results
global.allTestResults = new Map();
// Remove and recreate directory
if (fs.existsSync(reportDir)) {
fs.rmSync(reportDir, { recursive: true, force: true });
console.log('Cleared existing test-report directory');
}
// Always recreate these directories even if root was just cleared
fs.mkdirSync(path.join(reportDir, 'results'), { recursive: true });
fs.mkdirSync(path.join(reportDir, 'screenshots'), { recursive: true });
console.log('Created test-report, results, and screenshots directories');
} catch (err) {
console.error('Failed to initialize report directory:', err);
}
}
export function addTestResult(result) {
const dir = 'test-report/results';
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Enhance result with additional details
const enhancedResult = {
...result,
id: result.id || generateTestId(),
timestamp: result.timestamp || new Date().toISOString(),
duration: result.duration || 0,
browser: result.browser || 'Chrome',
environment: result.environment || 'Local',
tags: result.tags || [],
screenshots: result.screenshots || [],
logs: result.logs || [],
metadata: {
framework: 'Super Pancake Automation',
version: '2.8.0',
nodeVersion: process.version,
platform: process.platform,
...result.metadata
}
};
// Store in global results map
if (!global.allTestResults) {
global.allTestResults = new Map();
}
global.allTestResults.set(enhancedResult.id, enhancedResult);
// Save individual result file
const filename = `${enhancedResult.testName?.replace(/[^a-zA-Z0-9]/g, '_') || 'test'}_${enhancedResult.id}.json`;
const filepath = path.join(dir, filename);
try {
fs.writeFileSync(filepath, JSON.stringify(enhancedResult, null, 2));
console.log(`📊 Saved test result: ${filename}`);
} catch (err) {
console.error('❌ Failed to save test result:', err);
}
return enhancedResult.id;
}
export function writeReport() {
const reportPath = 'automationTestReport.html';
try {
// Collect all results
const resultFiles = collectResultFiles();
const testSummary = generateTestSummary(resultFiles);
const htmlContent = generateModernHTML(testSummary, resultFiles);
// Write the report
fs.writeFileSync(reportPath, htmlContent);
console.log(`🎉 Modern test report generated: ${reportPath}`);
return reportPath;
} catch (err) {
console.error('❌ Failed to generate modern report:', err);
throw err;
}
}
function generateTestId() {
return `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
function collectResultFiles() {
const resultsDir = 'test-report/results';
if (!fs.existsSync(resultsDir)) {
return [];
}
const files = fs.readdirSync(resultsDir, { withFileTypes: true });
const results = [];
files.forEach(file => {
if (file.isFile() && file.name.endsWith('.json')) {
try {
const content = fs.readFileSync(path.join(resultsDir, file.name), 'utf8');
const result = JSON.parse(content);
results.push(result);
} catch (err) {
console.warn(`⚠️ Skipped invalid result file: ${file.name}`);
}
}
});
return results.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
}
function generateTestSummary(results) {
const summary = {
total: results.length,
passed: 0,
failed: 0,
skipped: 0,
totalDuration: 0,
startTime: null,
endTime: null,
browsers: new Set(),
environments: new Set(),
tags: new Set()
};
results.forEach(result => {
// Count status
switch (getTestStatus(result).toLowerCase()) {
case 'passed':
summary.passed++;
break;
case 'failed':
summary.failed++;
break;
case 'skipped':
summary.skipped++;
break;
}
// Collect metadata
summary.totalDuration += result.duration || 0;
summary.browsers.add(result.browser || 'Chrome');
summary.environments.add(result.environment || 'Local');
if (result.tags) {
result.tags.forEach(tag => summary.tags.add(tag));
}
// Track time range
const timestamp = new Date(result.timestamp || Date.now());
if (!summary.startTime || timestamp < summary.startTime) {
summary.startTime = timestamp;
}
if (!summary.endTime || timestamp > summary.endTime) {
summary.endTime = timestamp;
}
});
return {
...summary,
browsers: Array.from(summary.browsers),
environments: Array.from(summary.environments),
tags: Array.from(summary.tags),
successRate: summary.total > 0 ? ((summary.passed / summary.total) * 100).toFixed(1) : 0
};
}
function getTestStatus(result) {
if (!result.status) return 'unknown';
const status = result.status.toLowerCase();
if (['passed', 'pass', 'success'].includes(status)) return 'passed';
if (['failed', 'fail', 'error'].includes(status)) return 'failed';
if (['skipped', 'skip'].includes(status)) return 'skipped';
return 'unknown';
}
function generateModernHTML(summary, results) {
return `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🥞 Super Pancake Test Report</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #6366f1; --success: #10b981; --danger: #ef4444; --warning: #f59e0b;
--light: #f8fafc; --dark: #1e293b; --border: #e2e8f0;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); --radius: 12px;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; color: var(--dark);
}
.container { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
.header {
background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 3rem 0; color: white; text-align: center;
}
.logo { display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 1rem; }
.logo i { font-size: 3rem; color: #fbbf24; }
.header h1 { font-size: 3rem; font-weight: 800; text-shadow: 0 2px 4px rgba(0,0,0,0.3); }
.subtitle { font-size: 1.25rem; opacity: 0.9; margin-bottom: 2rem; }
.header-meta { display: flex; justify-content: center; gap: 2rem; font-size: 0.9rem; opacity: 0.8; }
.header-meta span { display: flex; align-items: center; gap: 0.5rem; }
.dashboard {
background: white; margin: -2rem 0 2rem; border-radius: var(--radius);
box-shadow: var(--shadow); position: relative; z-index: 2;
}
.summary-grid {
display: flex; justify-content: space-between;
gap: 1rem; padding: 2rem; flex-wrap: nowrap;
}
.summary-card {
flex: 1; min-width: 160px;
display: flex; align-items: center; gap: 1rem; padding: 1.5rem;
border-radius: var(--radius); background: var(--light);
border: 2px solid transparent; transition: all 0.3s ease;
}
.summary-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.summary-card.total { border-color: var(--primary); }
.summary-card.passed { border-color: var(--success); }
.summary-card.failed { border-color: var(--danger); }
.summary-card.skipped { border-color: var(--warning); }
.summary-card.success-rate { border-color: var(--primary); }
.summary-card.duration { border-color: #ec4899; }
.card-icon {
font-size: 2rem; width: 60px; height: 60px; display: flex;
align-items: center; justify-content: center; border-radius: 50%;
background: white; box-shadow: var(--shadow);
}
.total .card-icon { color: var(--primary); }
.passed .card-icon { color: var(--success); }
.failed .card-icon { color: var(--danger); }
.skipped .card-icon { color: var(--warning); }
.success-rate .card-icon { color: var(--primary); }
.duration .card-icon { color: #ec4899; }
.card-content h3 { font-size: 2rem; font-weight: 700; margin-bottom: 0.25rem; }
.card-content p { color: #64748b; font-size: 0.875rem; font-weight: 500; }
.progress-container { padding: 0 2rem 2rem; }
.progress-bar {
height: 12px; background: #f1f5f9; border-radius: 6px;
overflow: hidden; display: flex; margin-bottom: 1rem;
}
.progress-segment { height: 100%; transition: all 0.3s ease; }
.progress-segment.passed { background: var(--success); }
.progress-segment.failed { background: var(--danger); }
.progress-segment.skipped { background: var(--warning); }
.progress-labels {
display: flex; justify-content: center; gap: 2rem; font-size: 0.875rem;
}
.label { display: flex; align-items: center; gap: 0.5rem; font-weight: 500; }
.label::before { content: ''; width: 12px; height: 12px; border-radius: 50%; }
.label.passed::before { background: var(--success); }
.label.failed::before { background: var(--danger); }
.label.skipped::before { background: var(--warning); }
.results { margin-bottom: 3rem; }
.results-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 1.5rem;
}
.test-card {
background: white; border-radius: var(--radius); box-shadow: var(--shadow);
overflow: hidden; transition: all 0.3s ease; cursor: pointer;
border-left: 4px solid transparent;
}
.test-card:hover { transform: translateY(-2px); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); }
.test-card.passed { border-left-color: var(--success); }
.test-card.failed { border-left-color: var(--danger); }
.test-card.skipped { border-left-color: var(--warning); }
.test-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1.5rem; border-bottom: 1px solid var(--border);
}
.test-info h3 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.25rem; }
.test-meta {
font-size: 0.875rem; color: #64748b; display: flex;
align-items: center; gap: 1rem;
}
.test-status {
display: flex; align-items: center; gap: 0.5rem; font-weight: 600;
font-size: 0.875rem; padding: 0.25rem 0.75rem; border-radius: 20px;
}
.test-status.passed { background: rgba(16, 185, 129, 0.1); color: var(--success); }
.test-status.failed { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
.test-status.skipped { background: rgba(245, 158, 11, 0.1); color: var(--warning); }
.test-details { padding: 1.5rem; }
.test-description {
color: #64748b; font-size: 0.875rem; line-height: 1.6; margin-bottom: 1rem;
}
.test-tags { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
.tag {
padding: 0.25rem 0.5rem; background: var(--light); border-radius: 4px;
font-size: 0.75rem; color: #64748b; font-weight: 500;
}
.test-metrics {
display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem; font-size: 0.875rem;
}
.metric { text-align: center; }
.metric-value { font-weight: 600; color: var(--dark); }
.metric-label {
color: #64748b; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px;
}
.footer {
background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: white; padding: 2rem 0;
}
.footer-content {
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 1rem;
}
.footer-info p { margin-bottom: 0.25rem; opacity: 0.8; }
.footer-links { display: flex; gap: 1rem; }
.footer-links a {
color: white; text-decoration: none; display: flex;
align-items: center; gap: 0.5rem; opacity: 0.8; transition: all 0.3s ease;
}
.footer-links a:hover { opacity: 1; transform: translateY(-2px); }
.loading {
display: flex; align-items: center; justify-content: center;
padding: 3rem; color: #64748b;
}
.spinner {
width: 40px; height: 40px; border: 4px solid var(--border);
border-top: 4px solid var(--primary); border-radius: 50%;
animation: spin 1s linear infinite; margin-right: 1rem;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
@keyframes slideIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
.test-card { animation: slideIn 0.3s ease-out; }
.filter-btn {
padding: 0.5rem 1rem; border: 2px solid var(--border); background: white;
border-radius: var(--radius); font-size: 0.875rem; font-weight: 500;
cursor: pointer; transition: all 0.3s ease;
}
.filter-btn:hover { border-color: var(--primary); color: var(--primary); }
.filter-btn.active { background: var(--primary); border-color: var(--primary); color: white; }
@media (max-width: 768px) {
.container { padding: 0 1rem; }
.header h1 { font-size: 2rem; }
.summary-grid { flex-wrap: wrap; }
.summary-card { min-width: 120px; }
.results-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="app">
<header class="header">
<div class="container">
<div class="header-content">
<div class="logo">
<i class="fas fa-layer-group"></i>
<h1>Super Pancake Test Report</h1>
</div>
<p class="subtitle">Comprehensive Test Automation Results</p>
<div class="header-meta">
<span class="timestamp">
<i class="far fa-clock"></i>
Generated: ${new Date().toLocaleString()}
</span>
<span class="version">
<i class="fas fa-code-branch"></i>
v2.8.0
</span>
</div>
</div>
</div>
</header>
<section class="dashboard">
<div class="container">
<div class="summary-grid">
<div class="summary-card total">
<div class="card-icon"><i class="fas fa-vial"></i></div>
<div class="card-content">
<h3>${summary.total}</h3>
<p>Total Tests</p>
</div>
</div>
<div class="summary-card passed">
<div class="card-icon"><i class="fas fa-check-circle"></i></div>
<div class="card-content">
<h3>${summary.passed}</h3>
<p>Passed</p>
</div>
</div>
<div class="summary-card failed">
<div class="card-icon"><i class="fas fa-times-circle"></i></div>
<div class="card-content">
<h3>${summary.failed}</h3>
<p>Failed</p>
</div>
</div>
<div class="summary-card skipped">
<div class="card-icon"><i class="fas fa-minus-circle"></i></div>
<div class="card-content">
<h3>${summary.skipped}</h3>
<p>Skipped</p>
</div>
</div>
<div class="summary-card success-rate">
<div class="card-icon"><i class="fas fa-percentage"></i></div>
<div class="card-content">
<h3>${summary.successRate}%</h3>
<p>Success Rate</p>
</div>
</div>
<div class="summary-card duration">
<div class="card-icon"><i class="fas fa-stopwatch"></i></div>
<div class="card-content">
<h3>${formatDuration(summary.totalDuration)}</h3>
<p>Total Duration</p>
</div>
</div>
</div>
<div class="progress-container">
<div class="progress-bar">
<div class="progress-segment passed" style="width: ${(summary.passed / summary.total * 100) || 0}%"></div>
<div class="progress-segment failed" style="width: ${(summary.failed / summary.total * 100) || 0}%"></div>
<div class="progress-segment skipped" style="width: ${(summary.skipped / summary.total * 100) || 0}%"></div>
</div>
<div class="progress-labels">
<span class="label passed">${summary.passed} Passed</span>
<span class="label failed">${summary.failed} Failed</span>
<span class="label skipped">${summary.skipped} Skipped</span>
</div>
</div>
</div>
</section>
<!-- Filters and Controls -->
<section class="controls" style="background: white; border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 2rem;">
<div class="container">
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.5rem 2rem; gap: 1rem; flex-wrap: wrap;">
<div style="position: relative; flex: 1; max-width: 300px;">
<i class="fas fa-search" style="position: absolute; left: 1rem; top: 50%; transform: translateY(-50%); color: #94a3b8;"></i>
<input type="text" id="searchTests" placeholder="Search tests..."
style="width: 100%; padding: 0.75rem 1rem 0.75rem 2.5rem; border: 2px solid var(--border); border-radius: var(--radius); font-size: 0.875rem; transition: all 0.3s ease;"
onkeyup="filterTests()">
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">All Tests</button>
<button class="filter-btn" data-filter="passed" onclick="setFilter('passed')">Passed</button>
<button class="filter-btn" data-filter="failed" onclick="setFilter('failed')">Failed</button>
<button class="filter-btn" data-filter="skipped" onclick="setFilter('skipped')">Skipped</button>
</div>
</div>
</div>
</section>
<section class="results">
<div class="container">
<div class="results-grid" id="resultsContainer">
${generateTestCards(results)}
</div>
</div>
</section>
<footer class="footer">
<div class="container">
<div class="footer-content">
<div class="footer-info">
<p>Generated by <strong>Super Pancake Automation Framework</strong></p>
<p>Node.js ${process.version} • ${process.platform}</p>
</div>
<div class="footer-links">
<a href="https://github.com/pradapjackie/super-pancake" target="_blank">
<i class="fab fa-github"></i> GitHub
</a>
<a href="https://www.npmjs.com/package/super-pancake-automation" target="_blank">
<i class="fab fa-npm"></i> NPM
</a>
</div>
</div>
</div>
</footer>
</div>
<script>
let allTests = ${JSON.stringify(results)};
let currentFilter = 'all';
function setFilter(filter) {
currentFilter = filter;
// Update active button
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.filter === filter) {
btn.classList.add('active');
}
});
filterTests();
}
function filterTests() {
const searchTerm = document.getElementById('searchTests').value.toLowerCase();
let filteredTests = allTests;
// Filter by status
if (currentFilter !== 'all') {
filteredTests = filteredTests.filter(test => {
const status = getTestStatus(test);
return status === currentFilter;
});
}
// Filter by search term
if (searchTerm) {
filteredTests = filteredTests.filter(test => {
return (test.testName || '').toLowerCase().includes(searchTerm) ||
(test.description || '').toLowerCase().includes(searchTerm) ||
(test.tags || []).some(tag => tag.toLowerCase().includes(searchTerm));
});
}
renderTests(filteredTests);
}
function renderTests(tests) {
const container = document.getElementById('resultsContainer');
if (tests.length === 0) {
container.innerHTML = '<div class="loading">No tests match your criteria</div>';
return;
}
container.innerHTML = tests.map(test => generateTestCard(test)).join('');
}
function generateTestCard(test) {
const status = getTestStatus(test);
const duration = formatDuration(test.duration || 0);
const timestamp = new Date(test.timestamp || Date.now()).toLocaleString();
return \`
<div class="test-card \${status}">
<div class="test-header">
<div class="test-info">
<h3>\${escapeHtml(test.testName || 'Unnamed Test')}</h3>
<div class="test-meta">
<span><i class="far fa-clock"></i> \${duration}</span>
<span><i class="fas fa-calendar"></i> \${timestamp}</span>
<span><i class="fas fa-desktop"></i> \${test.browser || 'Chrome'}</span>
</div>
</div>
<div class="test-status \${status}">
<i class="fas fa-\${getStatusIcon(status)}"></i>
\${status.charAt(0).toUpperCase() + status.slice(1)}
</div>
</div>
<div class="test-details">
<div class="test-description">
\${escapeHtml(test.description || 'No description available')}
</div>
\${test.tags && test.tags.length > 0 ? \`
<div class="test-tags">
\${test.tags.map(tag => \`<span class="tag">\${escapeHtml(tag)}</span>\`).join('')}
</div>
\` : ''}
<div class="test-metrics">
<div class="metric">
<div class="metric-value">\${duration}</div>
<div class="metric-label">Duration</div>
</div>
<div class="metric">
<div class="metric-value">\${test.environment || 'Local'}</div>
<div class="metric-label">Environment</div>
</div>
<div class="metric">
<div class="metric-value">\${(test.screenshots || []).length}</div>
<div class="metric-label">Screenshots</div>
</div>
</div>
</div>
</div>
\`;
}
function getTestStatus(test) {
if (!test.status) return 'unknown';
const status = test.status.toLowerCase();
if (['passed', 'pass', 'success'].includes(status)) return 'passed';
if (['failed', 'fail', 'error'].includes(status)) return 'failed';
if (['skipped', 'skip'].includes(status)) return 'skipped';
return 'unknown';
}
function getStatusIcon(status) {
switch (status) {
case 'passed': return 'check-circle';
case 'failed': return 'times-circle';
case 'skipped': return 'minus-circle';
default: return 'question-circle';
}
}
function formatDuration(ms) {
if (ms < 1000) return \`\${ms}ms\`;
if (ms < 60000) return \`\${(ms / 1000).toFixed(1)}s\`;
return \`\${Math.floor(ms / 60000)}m \${Math.floor((ms % 60000) / 1000)}s\`;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>`;
}
function generateTestCards(results) {
if (results.length === 0) {
return '<div class="loading"><div class="spinner"></div>No test results found</div>';
}
return results.map(result => {
const status = getTestStatus(result);
const duration = formatDuration(result.duration || 0);
const timestamp = new Date(result.timestamp || Date.now()).toLocaleString();
// Fix screenshot paths - use relative paths from the report location
const screenshots = (result.screenshots || []).map(screenshot => {
if (screenshot.startsWith('./') || screenshot.startsWith('../')) {
return screenshot;
}
if (screenshot.startsWith('/')) {
return '.' + screenshot;
}
// Check if it's in test-report/screenshots folder
if (fs.existsSync(`test-report/screenshots/${screenshot}`)) {
return `test-report/screenshots/${screenshot}`;
}
return screenshot;
});
return `
<div class="test-card ${status}">
<div class="test-header">
<div class="test-info">
<h3>${escapeHtml(result.testName || 'Unnamed Test')}</h3>
<div class="test-meta">
<span><i class="far fa-clock"></i> ${duration}</span>
<span><i class="fas fa-calendar"></i> ${timestamp}</span>
<span><i class="fas fa-desktop"></i> ${result.browser || 'Chrome'}</span>
</div>
</div>
<div class="test-status ${status}">
<i class="fas fa-${getStatusIcon(status)}"></i>
${status.charAt(0).toUpperCase() + status.slice(1)}
</div>
</div>
<div class="test-details">
<div class="test-description">
${escapeHtml(result.description || 'No description available')}
</div>
${result.tags && result.tags.length > 0 ? `
<div class="test-tags">
${result.tags.map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join('')}
</div>
` : ''}
${result.error ? `
<div style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 1rem; margin: 1rem 0;">
<strong style="color: #dc2626;">Error:</strong>
<pre style="margin: 0.5rem 0 0; white-space: pre-wrap; font-size: 0.875rem; color: #dc2626;">${escapeHtml(result.error)}</pre>
</div>
` : ''}
${screenshots.length > 0 ? `
<div style="margin: 1rem 0;">
<strong style="color: #374151; margin-bottom: 0.5rem; display: block;">Screenshots:</strong>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.5rem;">
${screenshots.map(screenshot => `
<div style="border: 1px solid #e5e7eb; border-radius: 6px; overflow: hidden;">
<img src="${screenshot}" alt="Test Screenshot"
style="width: 100%; height: auto; display: block; cursor: pointer;"
onclick="window.open('${screenshot}', '_blank')"
onerror="this.parentElement.innerHTML='<div style=\\"padding: 1rem; text-align: center; color: #6b7280; font-size: 0.75rem;\\">Screenshot not found</div>'">
</div>
`).join('')}
</div>
</div>
` : ''}
<div class="test-metrics">
<div class="metric">
<div class="metric-value">${duration}</div>
<div class="metric-label">Duration</div>
</div>
<div class="metric">
<div class="metric-value">${result.environment || 'Local'}</div>
<div class="metric-label">Environment</div>
</div>
<div class="metric">
<div class="metric-value">${screenshots.length}</div>
<div class="metric-label">Screenshots</div>
</div>
</div>
</div>
</div>
`;
}).join('');
}
function formatDuration(ms) {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}
function getStatusIcon(status) {
switch (status) {
case 'passed': return 'check-circle';
case 'failed': return 'times-circle';
case 'skipped': return 'minus-circle';
default: return 'question-circle';
}
}
function escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}