diff --git a/lib/plugin/htmlReporter.js b/lib/plugin/htmlReporter.js index ec033050e..5dca442ca 100644 --- a/lib/plugin/htmlReporter.js +++ b/lib/plugin/htmlReporter.js @@ -2,6 +2,7 @@ const fs = require('fs') const path = require('path') const mkdirp = require('mkdirp') const crypto = require('crypto') +const { threadId } = require('worker_threads') const { template } = require('../utils') const { getMachineInfo } = require('../command/info') @@ -94,14 +95,37 @@ module.exports = function (config) { // Track current suite for BDD detection currentSuite = test.parent - // Track retry attempts - if (test.retriedTest && test.retriedTest()) { - const originalTest = test.retriedTest() - const testId = generateTestId(originalTest) - if (!testRetryAttempts.has(testId)) { - testRetryAttempts.set(testId, 0) + // Enhanced retry detection with priority-based approach + const testId = generateTestId(test) + + // Only set retry count if not already set, using priority order + if (!testRetryAttempts.has(testId)) { + // Method 1: Check retryNum property (most reliable) + if (test.retryNum && test.retryNum > 0) { + testRetryAttempts.set(testId, test.retryNum) + output.print(`HTML Reporter: Retry count detected (retryNum) for ${test.title}, attempts: ${test.retryNum}`) + } + // Method 2: Check currentRetry property + else if (test.currentRetry && test.currentRetry > 0) { + testRetryAttempts.set(testId, test.currentRetry) + output.print(`HTML Reporter: Retry count detected (currentRetry) for ${test.title}, attempts: ${test.currentRetry}`) + } + // Method 3: Check if this is a retried test + else if (test.retriedTest && test.retriedTest()) { + const originalTest = test.retriedTest() + const originalTestId = generateTestId(originalTest) + if (!testRetryAttempts.has(originalTestId)) { + testRetryAttempts.set(originalTestId, 1) // Start with 1 retry + } else { + testRetryAttempts.set(originalTestId, testRetryAttempts.get(originalTestId) + 1) + } + output.print(`HTML Reporter: Retry detected (retriedTest) for ${originalTest.title}, attempts: ${testRetryAttempts.get(originalTestId)}`) + } + // Method 4: Check if test has been seen before (indicating a retry) + else if (reportData.tests.some(t => t.id === testId)) { + testRetryAttempts.set(testId, 1) // First retry detected + output.print(`HTML Reporter: Retry detected (duplicate test) for ${test.title}, attempts: 1`) } - testRetryAttempts.set(testId, testRetryAttempts.get(testId) + 1) } }) @@ -164,32 +188,85 @@ module.exports = function (config) { // Collect test results event.dispatcher.on(event.test.finished, test => { const testId = generateTestId(test) - const retryAttempts = testRetryAttempts.get(testId) || 0 + let retryAttempts = testRetryAttempts.get(testId) || 0 + + // Additional retry detection in test.finished event + // Check if this test has retry indicators we might have missed + if (retryAttempts === 0) { + if (test.retryNum && test.retryNum > 0) { + retryAttempts = test.retryNum + testRetryAttempts.set(testId, retryAttempts) + output.print(`HTML Reporter: Late retry detection (retryNum) for ${test.title}, attempts: ${retryAttempts}`) + } else if (test.currentRetry && test.currentRetry > 0) { + retryAttempts = test.currentRetry + testRetryAttempts.set(testId, retryAttempts) + output.print(`HTML Reporter: Late retry detection (currentRetry) for ${test.title}, attempts: ${retryAttempts}`) + } else if (test._retries && test._retries > 0) { + retryAttempts = test._retries + testRetryAttempts.set(testId, retryAttempts) + output.print(`HTML Reporter: Late retry detection (_retries) for ${test.title}, attempts: ${retryAttempts}`) + } + } + + // Debug logging + output.print(`HTML Reporter: Test finished - ${test.title}, State: ${test.state}, Retries: ${retryAttempts}`) // Detect if this is a BDD/Gherkin test const isBddTest = isBddGherkinTest(test, currentSuite) const steps = isBddTest ? currentBddSteps : currentTestSteps const featureInfo = isBddTest ? getBddFeatureInfo(test, currentSuite) : null - reportData.tests.push({ + // Check if this test already exists in reportData.tests (from a previous retry) + const existingTestIndex = reportData.tests.findIndex(t => t.id === testId) + const hasFailedBefore = existingTestIndex >= 0 && reportData.tests[existingTestIndex].state === 'failed' + const currentlyFailed = test.state === 'failed' + + // Debug artifacts collection (but don't process them yet - screenshots may not be ready) + output.print(`HTML Reporter: Test ${test.title} artifacts at test.finished: ${JSON.stringify(test.artifacts)}`) + + const testData = { ...test, id: testId, duration: test.duration || 0, steps: [...steps], // Copy the steps (BDD or regular) hooks: [...currentTestHooks], // Copy the hooks - artifacts: test.artifacts || [], + artifacts: test.artifacts || [], // Keep original artifacts for now tags: test.tags || [], meta: test.meta || {}, opts: test.opts || {}, notes: test.notes || [], - retryAttempts: retryAttempts, + retryAttempts: currentlyFailed || hasFailedBefore ? retryAttempts : 0, // Only show retries for failed tests uid: test.uid, isBdd: isBddTest, feature: featureInfo, - }) + } + + if (existingTestIndex >= 0) { + // Update existing test with final result (including failed state) + reportData.tests[existingTestIndex] = testData + output.print(`HTML Reporter: Updated existing test - ${test.title}, Final state: ${test.state}`) + } else { + // Add new test + reportData.tests.push(testData) + output.print(`HTML Reporter: Added new test - ${test.title}, State: ${test.state}`) + } + + // Track retry information - only add if there were actual retries AND the test failed at some point + const existingRetryIndex = reportData.retries.findIndex(r => r.testId === testId) + + // Only track retries if: + // 1. There are retry attempts detected AND (test failed now OR failed before) + // 2. OR there's an existing retry record (meaning it failed before) + if ((retryAttempts > 0 && (currentlyFailed || hasFailedBefore)) || existingRetryIndex >= 0) { + // If no retry attempts detected but we have an existing retry record, increment it + if (retryAttempts === 0 && existingRetryIndex >= 0) { + retryAttempts = reportData.retries[existingRetryIndex].attempts + 1 + testRetryAttempts.set(testId, retryAttempts) + output.print(`HTML Reporter: Incremented retry count for duplicate test ${test.title}, attempts: ${retryAttempts}`) + } - // If this was a retry, track the retry information - if (retryAttempts > 0) { + // Remove existing retry info for this test and add updated one + reportData.retries = reportData.retries.filter(r => r.testId !== testId) reportData.retries.push({ testId: testId, testTitle: test.title, @@ -197,16 +274,150 @@ module.exports = function (config) { finalState: test.state, duration: test.duration || 0, }) + output.print(`HTML Reporter: Added retry info for ${test.title}, attempts: ${retryAttempts}, state: ${test.state}`) + } + + // Fallback: If this test already exists and either failed before or is failing now, it's a retry + else if (existingTestIndex >= 0 && (hasFailedBefore || currentlyFailed)) { + const fallbackAttempts = 1 + testRetryAttempts.set(testId, fallbackAttempts) + reportData.retries.push({ + testId: testId, + testTitle: test.title, + attempts: fallbackAttempts, + finalState: test.state, + duration: test.duration || 0, + }) + output.print(`HTML Reporter: Fallback retry detection for failed test ${test.title}, attempts: ${fallbackAttempts}`) } }) // Generate final report event.dispatcher.on(event.all.result, result => { reportData.endTime = new Date() - reportData.stats = result.stats - reportData.failures = result.failures || [] reportData.duration = reportData.endTime - reportData.startTime + // Process artifacts now that all async tasks (including screenshots) are complete + output.print(`HTML Reporter: Processing artifacts for ${reportData.tests.length} tests after all async tasks complete`) + + reportData.tests.forEach(test => { + const originalArtifacts = test.artifacts + let collectedArtifacts = [] + + output.print(`HTML Reporter: Processing test "${test.title}" (ID: ${test.id})`) + output.print(`HTML Reporter: Test ${test.title} final artifacts: ${JSON.stringify(originalArtifacts)}`) + + if (originalArtifacts) { + if (Array.isArray(originalArtifacts)) { + collectedArtifacts = originalArtifacts + output.print(`HTML Reporter: Using array artifacts: ${collectedArtifacts.length} items`) + } else if (typeof originalArtifacts === 'object') { + // Convert object properties to array (screenshotOnFail plugin format) + collectedArtifacts = Object.values(originalArtifacts).filter(artifact => artifact) + output.print(`HTML Reporter: Converted artifacts object to array: ${collectedArtifacts.length} items`) + output.print(`HTML Reporter: Converted artifacts: ${JSON.stringify(collectedArtifacts)}`) + } + } + + // Only use filesystem fallback if no artifacts found from screenshotOnFail plugin + if (collectedArtifacts.length === 0 && test.state === 'failed') { + output.print(`HTML Reporter: No artifacts from plugin, trying filesystem for test "${test.title}"`) + collectedArtifacts = collectScreenshotsFromFilesystem(test, test.id) + output.print(`HTML Reporter: Collected ${collectedArtifacts.length} screenshots from filesystem for failed test "${test.title}"`) + if (collectedArtifacts.length > 0) { + output.print(`HTML Reporter: Filesystem screenshots for "${test.title}": ${JSON.stringify(collectedArtifacts)}`) + } + } + + // Update test with processed artifacts + test.artifacts = collectedArtifacts + output.print(`HTML Reporter: Final artifacts for "${test.title}": ${JSON.stringify(test.artifacts)}`) + }) + + // Calculate stats from our collected test data instead of using result.stats + const passedTests = reportData.tests.filter(t => t.state === 'passed').length + const failedTests = reportData.tests.filter(t => t.state === 'failed').length + const pendingTests = reportData.tests.filter(t => t.state === 'pending').length + const skippedTests = reportData.tests.filter(t => t.state === 'skipped').length + + // Populate failures from our collected test data with enhanced details + reportData.failures = reportData.tests + .filter(t => t.state === 'failed') + .map(t => { + const testName = t.title || 'Unknown Test' + const featureName = t.parent?.title || 'Unknown Feature' + + if (t.err) { + const errorMessage = t.err.message || t.err.toString() || 'Test failed' + const errorStack = t.err.stack || '' + const filePath = t.file || t.parent?.file || '' + + // Create enhanced failure object with test details + return { + testName: testName, + featureName: featureName, + message: errorMessage, + stack: errorStack, + filePath: filePath, + toString: () => `${testName} (${featureName})\n${errorMessage}\n${errorStack}`.trim(), + } + } + + return { + testName: testName, + featureName: featureName, + message: `Test failed: ${testName}`, + stack: '', + filePath: t.file || t.parent?.file || '', + toString: () => `${testName} (${featureName})\nTest failed: ${testName}`, + } + }) + + reportData.stats = { + tests: reportData.tests.length, + passes: passedTests, + failures: failedTests, + pending: pendingTests, + skipped: skippedTests, + duration: reportData.duration, + failedHooks: result.stats?.failedHooks || 0, + } + + // Debug logging for final stats + output.print(`HTML Reporter: Calculated stats - Tests: ${reportData.stats.tests}, Passes: ${reportData.stats.passes}, Failures: ${reportData.stats.failures}`) + output.print(`HTML Reporter: Collected ${reportData.tests.length} tests in reportData`) + output.print(`HTML Reporter: Failures array has ${reportData.failures.length} items`) + output.print(`HTML Reporter: Retries array has ${reportData.retries.length} items`) + output.print(`HTML Reporter: testRetryAttempts Map size: ${testRetryAttempts.size}`) + + // Log retry attempts map contents + for (const [testId, attempts] of testRetryAttempts.entries()) { + output.print(`HTML Reporter: testRetryAttempts - ${testId}: ${attempts} attempts`) + } + + reportData.tests.forEach(test => { + output.print(`HTML Reporter: Test in reportData - ${test.title}, State: ${test.state}, Retries: ${test.retryAttempts}`) + }) + + // Check if running with workers + if (process.env.RUNS_WITH_WORKERS) { + // In worker mode, save results to a JSON file for later consolidation + const workerId = threadId + const jsonFileName = `worker-${workerId}-results.json` + const jsonPath = path.join(reportDir, jsonFileName) + + try { + // Always overwrite the file with the latest complete data from this worker + // This prevents double-counting when the event is triggered multiple times + fs.writeFileSync(jsonPath, safeJsonStringify(reportData)) + output.print(`HTML Reporter: Generated worker JSON results: ${jsonFileName}`) + } catch (error) { + output.print(`HTML Reporter: Failed to write worker JSON: ${error.message}`) + } + return + } + + // Single process mode - generate report normally generateHtmlReport(reportData, options) // Export stats if configured @@ -220,6 +431,53 @@ module.exports = function (config) { } }) + // Handle worker consolidation after all workers complete + event.dispatcher.on(event.workers.result, async result => { + if (process.env.RUNS_WITH_WORKERS) { + // Only run consolidation in main process + await consolidateWorkerJsonResults(options) + } + }) + + /** + * Safely serialize data to JSON, handling circular references + */ + function safeJsonStringify(data) { + const seen = new WeakSet() + return JSON.stringify( + data, + (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + // For error objects, try to extract useful information instead of "[Circular Reference]" + if (key === 'err' || key === 'error') { + return { + message: value.message || 'Error occurred', + stack: value.stack || '', + name: value.name || 'Error', + } + } + // Skip circular references for other objects + return undefined + } + seen.add(value) + + // Special handling for error objects to preserve important properties + if (value instanceof Error || (value.message && value.stack)) { + return { + message: value.message || '', + stack: value.stack || '', + name: value.name || 'Error', + toString: () => value.message || 'Error occurred', + } + } + } + return value + }, + 2, + ) + } + function generateTestId(test) { return crypto .createHash('sha256') @@ -228,6 +486,153 @@ module.exports = function (config) { .substring(0, 8) } + function collectScreenshotsFromFilesystem(test, testId) { + const screenshots = [] + + try { + // Common screenshot locations to check + const possibleDirs = [ + reportDir, // Same as report directory + global.output_dir || './output', // Global output directory + path.resolve(global.codecept_dir || '.', 'output'), // Codecept output directory + path.resolve('.', 'output'), // Current directory output + path.resolve('.', '_output'), // Alternative output directory + path.resolve('output'), // Relative output directory + path.resolve('qa', 'output'), // QA project output directory + path.resolve('..', 'qa', 'output'), // Parent QA project output directory + ] + + // Use the exact same logic as screenshotOnFail plugin's testToFileName function + const originalTestName = test.title || 'test' + const originalFeatureName = test.parent?.title || 'feature' + + // Replicate testToFileName logic from lib/mocha/test.js + function replicateTestToFileName(testTitle) { + let fileName = testTitle + + // Slice to 100 characters first + fileName = fileName.slice(0, 100) + + // Handle data-driven tests: remove everything from '{' onwards (with 3 chars before) + if (fileName.indexOf('{') !== -1) { + fileName = fileName.substr(0, fileName.indexOf('{') - 3).trim() + } + + // Apply clearString logic from utils.js + if (fileName.endsWith('.')) { + fileName = fileName.slice(0, -1) + } + fileName = fileName + .replace(/ /g, '_') + .replace(/"/g, "'") + .replace(/\//g, '_') + .replace(//g, ')') + .replace(/:/g, '_') + .replace(/\\/g, '_') + .replace(/\|/g, '_') + .replace(/\?/g, '.') + .replace(/\*/g, '^') + .replace(/'/g, '') + + // Final slice to 100 characters + return fileName.slice(0, 100) + } + + const testName = replicateTestToFileName(originalTestName) + const featureName = replicateTestToFileName(originalFeatureName) + + output.print(`HTML Reporter: Original test title: "${originalTestName}"`) + output.print(`HTML Reporter: CodeceptJS filename: "${testName}"`) + + // Generate possible screenshot names based on CodeceptJS patterns + const possibleNames = [ + `${testName}.failed.png`, // Primary CodeceptJS screenshotOnFail pattern + `${testName}.failed.jpg`, + `${featureName}_${testName}.failed.png`, + `${featureName}_${testName}.failed.jpg`, + `Test_${testName}.failed.png`, // Alternative pattern + `Test_${testName}.failed.jpg`, + `${testName}.png`, + `${testName}.jpg`, + `${featureName}_${testName}.png`, + `${featureName}_${testName}.jpg`, + `failed_${testName}.png`, + `failed_${testName}.jpg`, + `screenshot_${testId}.png`, + `screenshot_${testId}.jpg`, + 'screenshot.png', + 'screenshot.jpg', + 'failure.png', + 'failure.jpg', + ] + + output.print(`HTML Reporter: Checking ${possibleNames.length} possible screenshot names for "${testName}"`) + + // Search for screenshots in possible directories + for (const dir of possibleDirs) { + output.print(`HTML Reporter: Checking directory: ${dir}`) + if (!fs.existsSync(dir)) { + output.print(`HTML Reporter: Directory does not exist: ${dir}`) + continue + } + + try { + const files = fs.readdirSync(dir) + output.print(`HTML Reporter: Found ${files.length} files in ${dir}`) + + // Look for exact matches first + for (const name of possibleNames) { + if (files.includes(name)) { + const fullPath = path.join(dir, name) + if (!screenshots.includes(fullPath)) { + screenshots.push(fullPath) + output.print(`HTML Reporter: Found screenshot: ${fullPath}`) + } + } + } + + // Look for screenshot files that are specifically for this test + // Be more strict to avoid cross-test contamination + const screenshotFiles = files.filter(file => { + const lowerFile = file.toLowerCase() + const lowerTestName = testName.toLowerCase() + const lowerFeatureName = featureName.toLowerCase() + + return ( + file.match(/\.(png|jpg|jpeg|gif|webp|bmp)$/i) && + // Exact test name matches with .failed pattern (most specific) + (file === `${testName}.failed.png` || + file === `${testName}.failed.jpg` || + file === `${featureName}_${testName}.failed.png` || + file === `${featureName}_${testName}.failed.jpg` || + file === `Test_${testName}.failed.png` || + file === `Test_${testName}.failed.jpg` || + // Word boundary checks for .failed pattern + (lowerFile.includes('.failed.') && + (lowerFile.startsWith(lowerTestName + '.') || lowerFile.startsWith(lowerFeatureName + '_' + lowerTestName + '.') || lowerFile.startsWith('test_' + lowerTestName + '.')))) + ) + }) + + for (const file of screenshotFiles) { + const fullPath = path.join(dir, file) + if (!screenshots.includes(fullPath)) { + screenshots.push(fullPath) + output.print(`HTML Reporter: Found related screenshot: ${fullPath}`) + } + } + } catch (error) { + // Ignore directory read errors + output.print(`HTML Reporter: Could not read directory ${dir}: ${error.message}`) + } + } + } catch (error) { + output.print(`HTML Reporter: Error collecting screenshots: ${error.message}`) + } + + return screenshots + } + function isBddGherkinTest(test, suite) { // Check if the suite has BDD/Gherkin properties return !!(suite && (suite.feature || suite.file?.endsWith('.feature'))) @@ -313,6 +718,115 @@ module.exports = function (config) { } } + /** + * Consolidates JSON reports from multiple workers into a single HTML report + */ + async function consolidateWorkerJsonResults(config) { + const jsonFiles = fs.readdirSync(reportDir).filter(file => file.startsWith('worker-') && file.endsWith('-results.json')) + + if (jsonFiles.length === 0) { + output.print('HTML Reporter: No worker JSON results found to consolidate') + return + } + + output.print(`HTML Reporter: Found ${jsonFiles.length} worker JSON files to consolidate`) + + // Initialize consolidated data structure + const consolidatedData = { + stats: { + tests: 0, + passes: 0, + failures: 0, + pending: 0, + skipped: 0, + duration: 0, + failedHooks: 0, + }, + tests: [], + failures: [], + hooks: [], + startTime: new Date(), + endTime: new Date(), + retries: [], + duration: 0, + } + + try { + // Process each worker's JSON file + for (const jsonFile of jsonFiles) { + const jsonPath = path.join(reportDir, jsonFile) + try { + const workerData = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) + + // Merge stats + if (workerData.stats) { + consolidatedData.stats.passes += workerData.stats.passes || 0 + consolidatedData.stats.failures += workerData.stats.failures || 0 + consolidatedData.stats.tests += workerData.stats.tests || 0 + consolidatedData.stats.pending += workerData.stats.pending || 0 + consolidatedData.stats.skipped += workerData.stats.skipped || 0 + consolidatedData.stats.duration += workerData.stats.duration || 0 + consolidatedData.stats.failedHooks += workerData.stats.failedHooks || 0 + } + + // Merge tests and failures + if (workerData.tests) consolidatedData.tests.push(...workerData.tests) + if (workerData.failures) consolidatedData.failures.push(...workerData.failures) + if (workerData.hooks) consolidatedData.hooks.push(...workerData.hooks) + if (workerData.retries) consolidatedData.retries.push(...workerData.retries) + + // Update timestamps + if (workerData.startTime) { + const workerStart = new Date(workerData.startTime).getTime() + const currentStart = new Date(consolidatedData.startTime).getTime() + if (workerStart < currentStart) { + consolidatedData.startTime = workerData.startTime + } + } + + if (workerData.endTime) { + const workerEnd = new Date(workerData.endTime).getTime() + const currentEnd = new Date(consolidatedData.endTime).getTime() + if (workerEnd > currentEnd) { + consolidatedData.endTime = workerData.endTime + } + } + + // Update duration + if (workerData.duration) { + consolidatedData.duration = Math.max(consolidatedData.duration, workerData.duration) + } + + // Clean up the worker JSON file + try { + fs.unlinkSync(jsonPath) + } catch (error) { + output.print(`Failed to delete worker JSON file ${jsonFile}: ${error.message}`) + } + } catch (error) { + output.print(`Failed to process worker JSON file ${jsonFile}: ${error.message}`) + } + } + + // Generate the final HTML report + generateHtmlReport(consolidatedData, config) + + // Export stats if configured + if (config.exportStats) { + exportTestStats(consolidatedData, config) + } + + // Save history if configured + if (config.keepHistory) { + saveTestHistory(consolidatedData, config) + } + + output.print(`HTML Reporter: Successfully consolidated ${jsonFiles.length} worker reports`) + } catch (error) { + output.print(`HTML Reporter: Failed to consolidate worker reports: ${error.message}`) + } + } + async function generateHtmlReport(data, config) { const reportPath = path.join(reportDir, config.reportFileName) @@ -322,11 +836,26 @@ module.exports = function (config) { const historyPath = path.resolve(reportDir, config.historyPath) try { if (fs.existsSync(historyPath)) { - history = JSON.parse(fs.readFileSync(historyPath, 'utf8')).slice(0, 10) // Last 10 runs for chart + history = JSON.parse(fs.readFileSync(historyPath, 'utf8')) // Show all available history } } catch (error) { output.print(`Failed to load history for report: ${error.message}`) } + + // Add current run to history for chart display (before saving to file) + const currentRun = { + timestamp: data.endTime.toISOString(), + duration: data.duration, + stats: data.stats, + retries: data.retries.length, + testCount: data.tests.length, + } + history.unshift(currentRun) + + // Limit history entries for chart display + if (history.length > config.maxHistoryEntries) { + history = history.slice(0, config.maxHistoryEntries) + } } // Get system information @@ -340,13 +869,11 @@ module.exports = function (config) { history: JSON.stringify(history), statsHtml: generateStatsHtml(data.stats), testsHtml: generateTestsHtml(data.tests, config), - failuresHtml: generateFailuresHtml(data.failures), retriesHtml: config.showRetries ? generateRetriesHtml(data.retries) : '', cssStyles: getCssStyles(), jsScripts: getJsScripts(), showRetries: config.showRetries ? 'block' : 'none', showHistory: config.keepHistory && history.length > 0 ? 'block' : 'none', - failuresDisplay: data.failures && data.failures.length > 0 ? 'block' : 'none', codeceptVersion: Codecept.version(), systemInfoHtml: generateSystemInfoHtml(systemInfo), }) @@ -406,7 +933,7 @@ module.exports = function (config) { const steps = config.showSteps && test.steps ? (test.isBdd ? generateBddStepsHtml(test.steps) : generateStepsHtml(test.steps)) : '' const featureDetails = test.isBdd && test.feature ? generateBddFeatureHtml(test.feature) : '' const hooks = test.hooks && test.hooks.length > 0 ? generateHooksHtml(test.hooks) : '' - const artifacts = config.includeArtifacts && test.artifacts ? generateArtifactsHtml(test.artifacts) : '' + const artifacts = config.includeArtifacts && test.artifacts ? generateArtifactsHtml(test.artifacts, test.state === 'failed') : '' const metadata = config.showMetadata && (test.meta || test.opts) ? generateMetadataHtml(test.meta, test.opts) : '' const tags = config.showTags && test.tags && test.tags.length > 0 ? generateTagsHtml(test.tags) : '' const retries = config.showRetries && test.retryAttempts > 0 ? generateTestRetryHtml(test.retryAttempts) : '' @@ -428,7 +955,7 @@ module.exports = function (config) {
${escapeHtml(stack.replace(/\x1b\[[0-9;]*m/g, ''))}` : ''}
+ ${escapeHtml(failureText)}