UNPKG

@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
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 `<!DOCTYPE html> <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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); } 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()">&times;</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