@playwright-mocks/reporters
Version:
Single Playwright reporter for mock interceptor with configurable logging and HTML report generation utility
696 lines (624 loc) • 26.8 kB
JavaScript
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { LoggingService } from '../logging.service.js';
import { TestReportGenerator } from './test-report.generator.js';
export class HtmlReportGenerator {
loggingService;
testReportGenerator;
cssFileName = 'report-style.css';
assetsDir;
constructor(loggingService) {
this.loggingService = loggingService;
this.testReportGenerator = new TestReportGenerator();
// ESM-safe __dirname replacement
const __filename = fileURLToPath(import.meta.url);
this.assetsDir = path.resolve(path.dirname(__filename), 'report-assets');
}
/**
* Generate HTML report from test run data
* This is NOT a Playwright reporter, but a utility that can be called
* after test execution to generate a report from collected data.
*/
generateHtmlReport(tests, options) {
// Ensure output directory exists
if (!fs.existsSync(options.outputDir)) {
fs.mkdirSync(options.outputDir, { recursive: true });
}
// Copy CSS file to output dir if not exists
const cssSource = path.join(this.assetsDir, this.cssFileName);
const cssDest = path.join(options.outputDir, this.cssFileName);
if (!fs.existsSync(cssDest)) {
fs.copyFileSync(cssSource, cssDest);
}
// Generate main report HTML with all test details
const html = this.generateHtmlContent(tests, options);
const outPath = path.join(options.outputDir, 'index.html');
fs.writeFileSync(outPath, html);
this.loggingService.logHtmlReportGenerated(options.outputDir);
}
/**
* Generate HTML report from a specific run file
*/
generateHtmlReportFromRunFile(runFilePath, options) {
if (!fs.existsSync(runFilePath)) {
throw new Error(`Run file not found: ${runFilePath}`);
}
const runData = JSON.parse(fs.readFileSync(runFilePath, 'utf-8'));
this.generateHtmlReport(runData.tests, options);
}
/**
* Generate HTML report from the latest run file in a directory
*/
generateHtmlReportFromLatestRun(runsDir, options) {
if (!fs.existsSync(runsDir)) {
throw new Error(`Runs directory not found: ${runsDir}`);
}
const files = fs.readdirSync(runsDir)
.filter(file => file.endsWith('.json'))
.map(file => ({
name: file,
path: path.join(runsDir, file),
mtime: fs.statSync(path.join(runsDir, file)).mtime.getTime()
}))
.sort((a, b) => b.mtime - a.mtime);
if (files.length === 0) {
throw new Error(`No run files found in: ${runsDir}`);
}
const latestFile = files[0];
if (!latestFile) {
throw new Error(`No run files found in: ${runsDir}`);
}
this.generateHtmlReportFromRunFile(latestFile.path, options);
}
generateHtmlContent(tests, options) {
const passedTests = tests.filter(t => t.status === 'passed');
const failedTests = tests.filter(t => t.status === 'failed');
const skippedTests = tests.filter(t => t.status === 'skipped');
const cssFile = this.cssFileName;
return `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${options.title}</title>
<link rel="stylesheet" href="${cssFile}">
<script>
// Global state
let currentFilter = 'all';
let currentGroupBy = 'file';
let searchQuery = '';
let collapsedGroups = new Set();
function toggleTestDetails(testIndex) {
var detailsRow = document.getElementById('test-details-' + testIndex);
var toggleIcon = document.getElementById('toggle-icon-' + testIndex);
if (detailsRow) {
detailsRow.classList.toggle('open');
if (toggleIcon) {
toggleIcon.textContent = detailsRow.classList.contains('open') ? '▼' : '▶';
}
}
}
function toggleBlock(id) {
var el = document.getElementById(id);
if (el) el.classList.toggle('open');
}
function toggleGroup(groupId) {
var groupHeader = document.getElementById('group-header-' + groupId);
var groupContent = document.getElementById('group-content-' + groupId);
var toggle = document.getElementById('group-toggle-' + groupId);
if (groupHeader && groupContent && toggle) {
const isCollapsed = groupHeader.classList.contains('collapsed');
if (isCollapsed) {
groupHeader.classList.remove('collapsed');
groupContent.classList.remove('collapsed');
toggle.textContent = '▼';
collapsedGroups.delete(groupId);
} else {
groupHeader.classList.add('collapsed');
groupContent.classList.add('collapsed');
toggle.textContent = '▶';
collapsedGroups.add(groupId);
}
}
}
function setFilter(filter) {
currentFilter = filter;
updateFilterButtons();
filterTests();
}
function updateFilterButtons() {
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
});
const activeBtn = document.querySelector('.filter-btn[data-filter="' + currentFilter + '"]');
if (activeBtn) activeBtn.classList.add('active');
}
function setGroupBy(groupBy) {
currentGroupBy = groupBy;
const selector = document.getElementById('group-selector');
if (selector) selector.value = groupBy;
renderTests();
}
function searchTests() {
searchQuery = document.getElementById('search-input').value.toLowerCase();
filterTests();
}
function filterTests() {
const testRows = document.querySelectorAll('.test-row');
const groupHeaders = document.querySelectorAll('.group-header');
testRows.forEach(row => {
const titleCell = row.querySelector('td:nth-child(2)');
const statusCell = row.querySelector('td:nth-child(3)');
const fileCell = row.querySelector('td:nth-child(5)');
if (!titleCell || !statusCell || !fileCell) return;
const testTitle = titleCell.textContent.toLowerCase();
const testStatus = statusCell.textContent.toLowerCase();
const testFile = fileCell.textContent.toLowerCase();
let matchesSearch = searchQuery === '' ||
testTitle.includes(searchQuery) ||
testFile.includes(searchQuery);
let matchesFilter = currentFilter === 'all' ||
(currentFilter === 'passed' && testStatus.includes('passed')) ||
(currentFilter === 'failed' && testStatus.includes('failed')) ||
(currentFilter === 'skipped' && testStatus.includes('skipped'));
if (matchesSearch && matchesFilter) {
row.classList.remove('hidden');
} else {
row.classList.add('hidden');
}
});
// Hide empty groups only if group headers exist
if (groupHeaders.length > 0) {
groupHeaders.forEach(header => {
const groupId = header.id.replace('group-header-', '');
const groupContent = document.getElementById('group-content-' + groupId);
if (groupContent) {
const visibleTests = groupContent.querySelectorAll('.test-row:not(.hidden)');
if (visibleTests.length === 0) {
header.classList.add('hidden');
} else {
header.classList.remove('hidden');
}
}
});
}
}
function showTestModal(testIndex) {
const modal = document.getElementById('test-modal');
const modalContent = document.getElementById('modal-content');
const testData = window.testData[testIndex];
if (modal && modalContent && testData) {
document.getElementById('modal-title').textContent = testData.title;
modalContent.innerHTML = generateTestModalContent(testData);
modal.classList.add('show');
}
}
function closeTestModal() {
const modal = document.getElementById('test-modal');
if (modal) {
modal.classList.remove('show');
}
}
function generateTestModalContent(test) {
return \`
<div class="test-header-details">
<span class="status-icon icon-\${test.status}">\${getStatusIcon(test.status)}</span>
<span class="\${test.status}" style="font-size:1.1rem; font-weight:600;">\${test.status}</span>
<span style="margin-left:24px; color:#b0b0b0;">\${escapeHtml(test.title)}</span>
</div>
<div class="test-header-meta">
<span class="meta-item"><span class="block-icon">⏰</span> \${test.duration}ms</span>
<span class="meta-item"><span class="block-icon">📄</span> \${escapeHtml(test.file)}</span>
</div>
<div style="margin-top: 24px;">
\${generateTestBlocks(test)}
</div>
\`;
}
function getStatusIcon(status) {
switch (status) {
case 'passed': return '✔️';
case 'failed': return '❌';
case 'skipped': return '⏭️';
case 'timedOut': return '⏰';
default: return 'ℹ️';
}
}
function generateTestBlocks(test) {
// Use the server-side generated blocks if available, otherwise fall back to simple blocks
if (test.blocks) {
return test.blocks;
}
const blocks = [];
// Error block
if (test.status === 'failed' || test.status === 'timedOut') {
blocks.push(\`
<div class="test-block open" id="modal-error">
<div class="test-block-title">
<span class="block-icon">❌</span>Error
<span class="block-toggle">▼</span>
</div>
<div class="test-block-content">
<div class="error-block">❌ Test \${test.status}</div>
</div>
</div>
\`);
}
// Attachments block
if (test.attachments && test.attachments.length > 0) {
const attachmentList = test.attachments.map(att =>
\`<li class="attachment-item"><span class='icon-attachment'>📎</span> \${escapeHtml(att.name || 'Attachment')} (\${escapeHtml(att.contentType || '')})</li>\`
).join('');
blocks.push(\`
<div class="test-block" id="modal-attachments">
<div class="test-block-title" onclick="toggleBlock('modal-attachments')">
<span class="block-icon">📎</span>Attachments
<span class="block-toggle">▶</span>
</div>
<div class="test-block-content">
<ul class="attachment-list">\${attachmentList}</ul>
</div>
</div>
\`);
}
return blocks.join('');
}
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderTests() {
const testsContainer = document.getElementById('tests-container');
if (!testsContainer) return;
const tests = window.testData || [];
const groupedTests = groupTests(tests, currentGroupBy);
testsContainer.innerHTML = generateGroupedTestsHtml(groupedTests, currentGroupBy);
// Restore collapsed state only if group headers exist
const groupHeaders = document.querySelectorAll('.group-header');
if (groupHeaders.length > 0) {
collapsedGroups.forEach(groupId => {
const groupHeader = document.getElementById('group-header-' + groupId);
const groupContent = document.getElementById('group-content-' + groupId);
const toggle = document.getElementById('group-toggle-' + groupId);
if (groupHeader && groupContent && toggle) {
groupHeader.classList.add('collapsed');
groupContent.classList.add('collapsed');
toggle.textContent = '▶';
}
});
}
filterTests();
}
function groupTests(tests, groupBy) {
const groups = {};
tests.forEach((test, index) => {
let groupKey = '';
if (groupBy === 'file') {
groupKey = test.file;
} else if (groupBy === 'status') {
groupKey = test.status;
} else if (groupBy === 'none') {
groupKey = 'All Tests';
} else {
groupKey = 'All Tests';
}
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push({...test, index});
});
return groups;
}
function generateGroupedTestsHtml(groupedTests, groupBy) {
let html = '';
Object.keys(groupedTests).forEach(groupKey => {
const tests = groupedTests[groupKey];
const groupId = groupKey.replace(/[^a-zA-Z0-9]/g, '_');
const passedCount = tests.filter(t => t.status === 'passed').length;
const failedCount = tests.filter(t => t.status === 'failed').length;
const skippedCount = tests.filter(t => t.status === 'skipped').length;
// Don't show group header for single group or when no grouping
const showGroupHeader = groupBy !== 'none' && Object.keys(groupedTests).length > 1;
if (showGroupHeader) {
html += \`
<div class="group-header" id="group-header-\${groupId}" onclick="toggleGroup('\${groupId}')">
<div>
<span>\${escapeHtml(groupKey)}</span>
<span class="group-stats">(\${tests.length} tests - \${passedCount} passed, \${failedCount} failed, \${skippedCount} skipped)</span>
</div>
<span class="group-toggle" id="group-toggle-\${groupId}">▼</span>
</div>
<div class="group-content" id="group-content-\${groupId}">
\`;
}
html += \`
<table class="tests-table">
<thead>
<tr>
<th style="width: 30px;"></th>
<th>Test Name</th>
<th>Status</th>
<th>Duration</th>
<th>File</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
\${tests.map(test => generateTestRow(test)).join('')}
</tbody>
</table>
\`;
if (showGroupHeader) {
html += \`</div>\`;
}
});
return html;
}
function generateTestRow(test) {
return \`
<tr class="test-row" onclick="toggleTestDetails(\${test.index})">
<td><span id="toggle-icon-\${test.index}" class="toggle-icon">▶</span></td>
<td>\${escapeHtml(test.title)}</td>
<td class="\${test.status}"><span class="status-icon">\${getStatusIcon(test.status)}</span>\${test.status}</td>
<td>\${test.duration}ms</td>
<td>\${escapeHtml(test.file)}</td>
<td>
<div class="test-actions" onclick="event.stopPropagation()">
<button class="action-btn" onclick="toggleTestDetails(\${test.index})">Details</button>
<button class="action-btn primary" onclick="showTestModal(\${test.index})">Full View</button>
</div>
</td>
</tr>
<tr class="test-details-row" id="test-details-\${test.index}">
<td colspan="6">
<div class="test-details-content">
\${generateTestBlocksForDetails(test)}
</div>
</td>
</tr>
\`;
}
function generateTestBlocksForDetails(test) {
const blocks = [];
// Error block
if (test.status === 'failed' || test.status === 'timedOut') {
blocks.push(\`
<div class="test-block open" id="details-error-\${test.index}">
<div class="test-block-title" onclick="toggleBlock('details-error-\${test.index}')">
<span class="block-icon">❌</span>Error
<span class="block-toggle">▼</span>
</div>
<div class="test-block-content">
<div class="error-block">❌ Test \${test.status}</div>
</div>
</div>
\`);
}
// Attachments block
if (test.attachments && test.attachments.length > 0) {
const attachmentList = test.attachments.map(att =>
\`<li class="attachment-item"><span class='icon-attachment'>📎</span> \${escapeHtml(att.name || 'Attachment')} (\${escapeHtml(att.contentType || '')})</li>\`
).join('');
blocks.push(\`
<div class="test-block" id="details-attachments-\${test.index}">
<div class="test-block-title" onclick="toggleBlock('details-attachments-\${test.index}')">
<span class="block-icon">📎</span>Attachments
<span class="block-toggle">▶</span>
</div>
<div class="test-block-content">
<ul class="attachment-list">\${attachmentList}</ul>
</div>
</div>
\`);
}
// Mocks block
const mocks = (test.attachments || []).filter(att => att.name && att.name.toLowerCase().includes('mock'));
if (mocks.length > 0) {
const mockList = mocks.map(att =>
\`<li class="attachment-item"><span class='icon-mock'>🧩</span> \${escapeHtml(att.name || 'Mock')} (\${escapeHtml(att.contentType || '')})</li>\`
).join('');
blocks.push(\`
<div class="test-block" id="details-mocks-\${test.index}">
<div class="test-block-title" onclick="toggleBlock('details-mocks-\${test.index}')">
<span class="block-icon">🧩</span>Mocks
<span class="block-toggle">▶</span>
</div>
<div class="test-block-content">
<ul class="attachment-list">\${mockList}</ul>
</div>
</div>
\`);
}
// Metadata block
blocks.push(\`
<div class="test-block" id="details-meta-\${test.index}">
<div class="test-block-title" onclick="toggleBlock('details-meta-\${test.index}')">
<span class="block-icon">ℹ️</span>Metadata
<span class="block-toggle">▶</span>
</div>
<div class="test-block-content">
<table class="meta-table">
<tr><th>Test ID</th><td>\${escapeHtml(test.id)}</td></tr>
<tr><th>File</th><td>\${escapeHtml(test.file)}</td></tr>
<tr><th>Status</th><td>\${escapeHtml(test.status)}</td></tr>
<tr><th>Duration</th><td>\${test.duration}ms</td></tr>
</table>
</div>
</div>
\`);
return blocks.join('');
}
// Close modal when clicking outside
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal-overlay')) {
closeTestModal();
}
});
// Close modal with Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeTestModal();
}
});
window.onload = function() {
// Initialize test data and add blocks for modal view
const testData = ${JSON.stringify(tests)};
// Add blocks to each test for modal view
testData.forEach((test, index) => {
test.index = index;
test.blocks = generateTestBlocksForModal(test);
});
window.testData = testData;
// Set initial state
updateFilterButtons();
renderTests();
// Open error blocks by default if they exist
var errorBlocks = document.querySelectorAll('.test-block[id*="error"]');
errorBlocks.forEach(function(block) {
if (block) block.classList.add('open');
});
}
function generateTestBlocksForModal(test) {
const blocks = [];
// Error block
if (test.status === 'failed' || test.status === 'timedOut') {
blocks.push(\`
<div class="test-block open" id="modal-error-\${test.index}">
<div class="test-block-title" onclick="toggleBlock('modal-error-\${test.index}')">
<span class="block-icon">❌</span>Error
<span class="block-toggle">▼</span>
</div>
<div class="test-block-content">
<div class="error-block">❌ Test \${test.status}</div>
</div>
</div>
\`);
}
// Attachments block
if (test.attachments && test.attachments.length > 0) {
const attachmentList = test.attachments.map(att =>
\`<li class="attachment-item"><span class='icon-attachment'>📎</span> \${escapeHtml(att.name || 'Attachment')} (\${escapeHtml(att.contentType || '')})</li>\`
).join('');
blocks.push(\`
<div class="test-block" id="modal-attachments-\${test.index}">
<div class="test-block-title" onclick="toggleBlock('modal-attachments-\${test.index}')">
<span class="block-icon">📎</span>Attachments
<span class="block-toggle">▶</span>
</div>
<div class="test-block-content">
<ul class="attachment-list">\${attachmentList}</ul>
</div>
</div>
\`);
}
// Mocks block
const mocks = (test.attachments || []).filter(att => att.name && att.name.toLowerCase().includes('mock'));
if (mocks.length > 0) {
const mockList = mocks.map(att =>
\`<li class="attachment-item"><span class='icon-mock'>🧩</span> \${escapeHtml(att.name || 'Mock')} (\${escapeHtml(att.contentType || '')})</li>\`
).join('');
blocks.push(\`
<div class="test-block" id="modal-mocks-\${test.index}">
<div class="test-block-title" onclick="toggleBlock('modal-mocks-\${test.index}')">
<span class="block-icon">🧩</span>Mocks
<span class="block-toggle">▶</span>
</div>
<div class="test-block-content">
<ul class="attachment-list">\${mockList}</ul>
</div>
</div>
\`);
}
// Metadata block
blocks.push(\`
<div class="test-block" id="modal-meta-\${test.index}">
<div class="test-block-title" onclick="toggleBlock('modal-meta-\${test.index}')">
<span class="block-icon">ℹ️</span>Metadata
<span class="block-toggle">▶</span>
</div>
<div class="test-block-content">
<table class="meta-table">
<tr><th>Test ID</th><td>\${escapeHtml(test.id)}</td></tr>
<tr><th>File</th><td>\${escapeHtml(test.file)}</td></tr>
<tr><th>Status</th><td>\${escapeHtml(test.status)}</td></tr>
<tr><th>Duration</th><td>\${test.duration}ms</td></tr>
</table>
</div>
</div>
\`);
return blocks.join('');
}
</script>
</head>
<body>
<div class="container">
<div class="header">
<h1>${options.title}</h1>
<p>Generated on ${new Date().toLocaleString()}</p>
<div class="stats">
<div class="stat-card">
<div class="stat-number">${tests.length}</div>
<div>Total Tests</div>
</div>
<div class="stat-card">
<div class="stat-number">${passedTests.length}</div>
<div>Passed</div>
</div>
<div class="stat-card">
<div class="stat-number">${failedTests.length}</div>
<div>Failed</div>
</div>
<div class="stat-card">
<div class="stat-number">${skippedTests.length}</div>
<div>Skipped</div>
</div>
</div>
</div>
<div class="content">
${tests.length === 0 ?
'<div class="no-tests">No tests were executed</div>' :
`<div class="controls">
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" id="search-input" class="search-input" placeholder="Search tests..." oninput="searchTests()">
</div>
<div class="filter-buttons">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">All (${tests.length})</button>
<button class="filter-btn" data-filter="passed" onclick="setFilter('passed')">Passed (${passedTests.length})</button>
<button class="filter-btn" data-filter="failed" onclick="setFilter('failed')">Failed (${failedTests.length})</button>
<button class="filter-btn" data-filter="skipped" onclick="setFilter('skipped')">Skipped (${skippedTests.length})</button>
</div>
<select class="group-selector" id="group-selector" onchange="setGroupBy(this.value)">
<option value="file">Group by File</option>
<option value="status">Group by Status</option>
<option value="none">No Grouping</option>
</select>
</div>
<div id="tests-container">
<!-- Tests will be rendered here by JavaScript -->
</div>`}
</div>
</div>
<!-- Test Modal -->
<div class="modal-overlay" id="test-modal">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-title">Test Details</div>
<button class="modal-close" onclick="closeTestModal()">×</button>
</div>
<div class="modal-content" id="modal-content">
<!-- Modal content will be populated by JavaScript -->
</div>
</div>
</div>
</body>
</html>`;
}
}
//# sourceMappingURL=html-report.generator.js.map