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) {
- ${test.err ? `
${escapeHtml(test.err.message || '').replace(/\x1b\[[0-9;]*m/g, '')}
` : ''} + ${test.err ? `
${escapeHtml(getErrorMessage(test))}
` : ''} ${featureDetails} ${tags} ${metadata} @@ -606,25 +1133,147 @@ module.exports = function (config) { ` } - function generateArtifactsHtml(artifacts) { - if (!artifacts || artifacts.length === 0) return '' + function generateArtifactsHtml(artifacts, isFailedTest = false) { + if (!artifacts || artifacts.length === 0) { + output.print(`HTML Reporter: No artifacts found for test`) + return '' + } + + output.print(`HTML Reporter: Processing ${artifacts.length} artifacts, isFailedTest: ${isFailedTest}`) + output.print(`HTML Reporter: Artifacts: ${JSON.stringify(artifacts)}`) + + // Separate screenshots from other artifacts + const screenshots = [] + const otherArtifacts = [] + + artifacts.forEach(artifact => { + output.print(`HTML Reporter: Processing artifact: ${artifact} (type: ${typeof artifact})`) + + // Handle different artifact formats + let artifactPath = artifact + if (typeof artifact === 'object' && artifact.path) { + artifactPath = artifact.path + } else if (typeof artifact === 'object' && artifact.file) { + artifactPath = artifact.file + } else if (typeof artifact === 'object' && artifact.src) { + artifactPath = artifact.src + } + + // Check if it's a screenshot file + if (typeof artifactPath === 'string' && artifactPath.match(/\.(png|jpg|jpeg|gif|webp|bmp|svg)$/i)) { + screenshots.push(artifactPath) + output.print(`HTML Reporter: Found screenshot: ${artifactPath}`) + } else { + otherArtifacts.push(artifact) + output.print(`HTML Reporter: Found other artifact: ${artifact}`) + } + }) + + output.print(`HTML Reporter: Found ${screenshots.length} screenshots and ${otherArtifacts.length} other artifacts`) + + let artifactsHtml = '' + + // For failed tests, prominently display screenshots + if (isFailedTest && screenshots.length > 0) { + const screenshotsHtml = screenshots + .map(screenshot => { + let relativePath = path.relative(reportDir, screenshot) + const filename = path.basename(screenshot) + + // If relative path goes up directories, try to find the file in common locations + if (relativePath.startsWith('..')) { + // Try to find screenshot relative to output directory + const outputRelativePath = path.relative(reportDir, path.resolve(screenshot)) + if (!outputRelativePath.startsWith('..')) { + relativePath = outputRelativePath + } else { + // Use just the filename if file is in same directory as report + const sameDir = path.join(reportDir, filename) + if (fs.existsSync(sameDir)) { + relativePath = filename + } else { + // Keep original path as fallback + relativePath = screenshot + } + } + } + + output.print(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`) - const artifactsHtml = artifacts - .map(artifact => { - if (typeof artifact === 'string' && artifact.match(/\.(png|jpg|jpeg|gif)$/i)) { - const relativePath = path.relative(reportDir, artifact) + return ` +
+
+ 📸 ${escapeHtml(filename)} +
+ Test failure screenshot +
+ ` + }) + .join('') + + artifactsHtml += ` +
+

Screenshots:

+
${screenshotsHtml}
+
+ ` + } else if (screenshots.length > 0) { + // For non-failed tests, display screenshots normally + const screenshotsHtml = screenshots + .map(screenshot => { + let relativePath = path.relative(reportDir, screenshot) + const filename = path.basename(screenshot) + + // If relative path goes up directories, try to find the file in common locations + if (relativePath.startsWith('..')) { + // Try to find screenshot relative to output directory + const outputRelativePath = path.relative(reportDir, path.resolve(screenshot)) + if (!outputRelativePath.startsWith('..')) { + relativePath = outputRelativePath + } else { + // Use just the filename if file is in same directory as report + const sameDir = path.join(reportDir, filename) + if (fs.existsSync(sameDir)) { + relativePath = filename + } else { + // Keep original path as fallback + relativePath = screenshot + } + } + } + + output.print(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`) return `Screenshot` - } - return `
${escapeHtml(artifact.toString())}
` - }) - .join('') + }) + .join('') - return ` + artifactsHtml += ` +
+

Screenshots:

+
${screenshotsHtml}
+
+ ` + } + + // Display other artifacts if any + if (otherArtifacts.length > 0) { + const otherArtifactsHtml = otherArtifacts.map(artifact => `
${escapeHtml(artifact.toString())}
`).join('') + + artifactsHtml += ` +
+

Other Artifacts:

+
${otherArtifactsHtml}
+
+ ` + } + + return artifactsHtml + ? `
-

Artifacts:

-
${artifactsHtml}
+ ${artifactsHtml}
` + : '' } function generateFailuresHtml(failures) { @@ -634,13 +1283,63 @@ module.exports = function (config) { return failures .map((failure, index) => { - const failureText = failure.toString().replace(/\x1b\[[0-9;]*m/g, '') // Remove ANSI escape codes - return ` + // Helper function to safely extract string values + const safeString = value => { + if (!value) return '' + if (typeof value === 'string') return value + if (typeof value === 'object' && value.toString) { + const str = value.toString() + return str === '[object Object]' ? '' : str + } + return String(value) + } + + if (typeof failure === 'object' && failure !== null) { + // Enhanced failure object with test details + console.log('this is failure', failure) + const testName = safeString(failure.testName) || 'Unknown Test' + const featureName = safeString(failure.featureName) || 'Unknown Feature' + let message = safeString(failure.message) || 'Test failed' + const stack = safeString(failure.stack) || '' + const filePath = safeString(failure.filePath) || '' + + // If message is still "[object Object]", try to extract from the failure object itself + if (message === '[object Object]' || message === '') { + if (failure.err && failure.err.message) { + message = safeString(failure.err.message) + } else if (failure.error && failure.error.message) { + message = safeString(failure.error.message) + } else if (failure.toString && typeof failure.toString === 'function') { + const str = failure.toString() + message = str === '[object Object]' ? 'Test failed' : str + } else { + message = 'Test failed' + } + } + + return ` +
+

Failure ${index + 1}: ${escapeHtml(testName)}

+
+ Feature: ${escapeHtml(featureName)} + ${filePath ? `File: ${escapeHtml(filePath)}` : ''} +
+
+ Error: ${escapeHtml(message)} +
+ ${stack ? `
${escapeHtml(stack.replace(/\x1b\[[0-9;]*m/g, ''))}
` : ''} +
+ ` + } else { + // Fallback for simple string failures + const failureText = safeString(failure).replace(/\x1b\[[0-9;]*m/g, '') || 'Test failed' + return `

Failure ${index + 1}

${escapeHtml(failureText)}
` + } }) .join('') } @@ -672,8 +1371,117 @@ module.exports = function (config) { return `${(duration / 1000).toFixed(2)}s` } - function escapeHtml(unsafe) { - return unsafe.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + function escapeHtml(text) { + if (!text) return '' + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + } + + function getErrorMessage(test) { + if (!test) return 'Test failed' + + // Helper function to safely extract string from potentially circular objects + const safeExtract = (obj, prop) => { + try { + if (!obj || typeof obj !== 'object') return '' + const value = obj[prop] + if (typeof value === 'string') return value + if (value && typeof value.toString === 'function') { + const str = value.toString() + return str === '[object Object]' ? '' : str + } + return '' + } catch (e) { + return '' + } + } + + // Helper function to safely stringify objects avoiding circular references + const safeStringify = obj => { + try { + if (!obj) return '' + if (typeof obj === 'string') return obj + + // Try to get message property first + if (obj.message && typeof obj.message === 'string') { + return obj.message + } + + // For error objects, extract key properties manually + if (obj instanceof Error || (obj.name && obj.message)) { + return obj.message || obj.toString() || 'Error occurred' + } + + // For other objects, try toString first + if (obj.toString && typeof obj.toString === 'function') { + const str = obj.toString() + if (str !== '[object Object]' && !str.includes('[Circular Reference]')) { + return str + } + } + + // Last resort: extract message-like properties + if (obj.message) return obj.message + if (obj.description) return obj.description + if (obj.text) return obj.text + + return 'Error occurred' + } catch (e) { + return 'Error occurred' + } + } + + let errorMessage = '' + let errorStack = '' + + // Primary error source + if (test.err) { + errorMessage = safeExtract(test.err, 'message') || safeStringify(test.err) + errorStack = safeExtract(test.err, 'stack') + } + + // Alternative error sources for different test frameworks + if (!errorMessage && test.error) { + errorMessage = safeExtract(test.error, 'message') || safeStringify(test.error) + errorStack = safeExtract(test.error, 'stack') + } + + // Check for nested error in parent + if (!errorMessage && test.parent && test.parent.err) { + errorMessage = safeExtract(test.parent.err, 'message') || safeStringify(test.parent.err) + errorStack = safeExtract(test.parent.err, 'stack') + } + + // Check for error details array (some frameworks use this) + if (!errorMessage && test.err && test.err.details && Array.isArray(test.err.details)) { + errorMessage = test.err.details + .map(item => safeExtract(item, 'message') || safeStringify(item)) + .filter(msg => msg && msg !== '[Circular]') + .join(' ') + } + + // Fallback to test title if no error message found + if (!errorMessage || errorMessage === '[Circular]') { + errorMessage = `Test failed: ${test.title || 'Unknown test'}` + } + + // Clean ANSI escape codes and remove circular reference markers + const cleanMessage = (errorMessage || '') + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/\[Circular\]/g, '') + .replace(/\s+/g, ' ') + .trim() + + const cleanStack = (errorStack || '') + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/\[Circular\]/g, '') + .trim() + + // Return combined error information + if (cleanStack && cleanStack !== cleanMessage && !cleanMessage.includes(cleanStack)) { + return `${cleanMessage}\n\nStack trace:\n${cleanStack}` + } + + return cleanMessage } function generateSystemInfoHtml(systemInfo) { @@ -806,12 +1614,6 @@ module.exports = function (config) {
-
-

Failures

-
- {{failuresHtml}} -
-
@@ -873,7 +1675,7 @@ body { padding: 0 1rem; } -.stats-section, .tests-section, .failures-section, .retries-section, .filters-section, .history-section, .system-info-section { +.stats-section, .tests-section, .retries-section, .filters-section, .history-section, .system-info-section { background: white; margin-bottom: 2rem; border-radius: 8px; @@ -881,7 +1683,7 @@ body { overflow: hidden; } -.stats-section h2, .tests-section h2, .failures-section h2, .retries-section h2, .filters-section h2, .history-section h2 { +.stats-section h2, .tests-section h2, .retries-section h2, .filters-section h2, .history-section h2 { background: #34495e; color: white; padding: 1rem; @@ -1093,9 +1895,12 @@ body { .step-item { display: flex; - align-items: center; + align-items: flex-start; padding: 0.5rem 0; border-bottom: 1px solid #ecf0f1; + word-wrap: break-word; + overflow-wrap: break-word; + min-height: 2rem; } .step-item:last-child { @@ -1104,6 +1909,8 @@ body { .step-status { margin-right: 0.5rem; + flex-shrink: 0; + margin-top: 0.2rem; } .step-status.success { color: #27ae60; } @@ -1113,11 +1920,18 @@ body { flex: 1; font-family: 'Courier New', monospace; font-size: 0.9rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.4; + margin-right: 0.5rem; + min-width: 0; } .step-duration { font-size: 0.8rem; color: #7f8c8d; + flex-shrink: 0; + margin-top: 0.2rem; } .artifacts-list { @@ -1168,25 +1982,61 @@ body { border-radius: 4px; } -.failure-item { - padding: 1rem; - margin-bottom: 1rem; - border: 1px solid #fcc; - border-radius: 4px; - background: #fee; +/* Enhanced screenshot styles for failed tests */ +.screenshots-section { + margin-top: 1rem; } -.failure-item h4 { - color: #c0392b; - margin-bottom: 0.5rem; +.screenshots-section h4 { + color: #e74c3c; + margin-bottom: 0.75rem; + font-size: 1rem; + font-weight: 600; } -.failure-details { - color: #333; - font-family: 'Courier New', monospace; +.screenshots-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.screenshot-container { + border: 2px solid #e74c3c; + border-radius: 8px; + overflow: hidden; + background: white; + box-shadow: 0 4px 8px rgba(231, 76, 60, 0.1); +} + +.screenshot-header { + background: #e74c3c; + color: white; + padding: 0.5rem 1rem; font-size: 0.9rem; - white-space: pre-wrap; - word-wrap: break-word; + font-weight: 500; +} + +.screenshot-label { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.failure-screenshot { + width: 100%; + max-width: 100%; + height: auto; + display: block; + cursor: pointer; + transition: opacity 0.2s; +} + +.failure-screenshot:hover { + opacity: 0.9; +} + +.other-artifacts-section { + margin-top: 1rem; } /* Filter Controls */ @@ -1546,6 +2396,9 @@ body { padding: 0.5rem 0; border-bottom: 1px solid #ecf0f1; font-family: 'Segoe UI', sans-serif; + word-wrap: break-word; + overflow-wrap: break-word; + min-height: 2rem; } .bdd-step-item:last-child { @@ -1558,12 +2411,17 @@ body { margin-right: 0.5rem; min-width: 60px; text-align: left; + flex-shrink: 0; } .bdd-step-text { flex: 1; color: #2c3e50; margin-right: 0.5rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.4; + min-width: 0; } .step-comment { @@ -1571,12 +2429,12 @@ body { margin-top: 0.5rem; padding: 0.5rem; background: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 4px; + border-left: 3px solid #8e44ad; + font-style: italic; color: #6c757d; - font-family: 'Courier New', monospace; - font-size: 0.8rem; - white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.4; } @media (max-width: 768px) { @@ -1758,6 +2616,11 @@ function drawPieChart() { let currentAngle = -Math.PI / 2; // Start from top + // Calculate percentages + const passedPercent = Math.round((data.passed / total) * 100); + const failedPercent = Math.round((data.failed / total) * 100); + const pendingPercent = Math.round((data.pending / total) * 100); + // Draw passed segment if (data.passed > 0) { const angle = (data.passed / total) * 2 * Math.PI; @@ -1767,6 +2630,21 @@ function drawPieChart() { ctx.closePath(); ctx.fillStyle = '#27ae60'; ctx.fill(); + + // Add percentage text on segment if significant enough + if (passedPercent >= 10) { + const textAngle = currentAngle + angle / 2; + const textRadius = radius * 0.7; + const textX = centerX + Math.cos(textAngle) * textRadius; + const textY = centerY + Math.sin(textAngle) * textRadius; + + ctx.fillStyle = '#fff'; + ctx.font = 'bold 14px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(passedPercent + '%', textX, textY); + } + currentAngle += angle; } @@ -1779,6 +2657,21 @@ function drawPieChart() { ctx.closePath(); ctx.fillStyle = '#e74c3c'; ctx.fill(); + + // Add percentage text on segment if significant enough + if (failedPercent >= 10) { + const textAngle = currentAngle + angle / 2; + const textRadius = radius * 0.7; + const textX = centerX + Math.cos(textAngle) * textRadius; + const textY = centerY + Math.sin(textAngle) * textRadius; + + ctx.fillStyle = '#fff'; + ctx.font = 'bold 14px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(failedPercent + '%', textX, textY); + } + currentAngle += angle; } @@ -1791,145 +2684,260 @@ function drawPieChart() { ctx.closePath(); ctx.fillStyle = '#f39c12'; ctx.fill(); + + // Add percentage text on segment if significant enough + if (pendingPercent >= 10) { + const textAngle = currentAngle + angle / 2; + const textRadius = radius * 0.7; + const textX = centerX + Math.cos(textAngle) * textRadius; + const textY = centerY + Math.sin(textAngle) * textRadius; + + ctx.fillStyle = '#fff'; + ctx.font = 'bold 14px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(pendingPercent + '%', textX, textY); + } } - // Add legend + // Add legend with percentages const legendY = centerY + radius + 40; ctx.font = '14px Arial'; ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; - let legendX = centerX - 120; + let legendX = centerX - 150; // Passed legend ctx.fillStyle = '#27ae60'; ctx.fillRect(legendX, legendY, 15, 15); ctx.fillStyle = '#333'; - ctx.fillText('Passed (' + data.passed + ')', legendX + 20, legendY + 12); + ctx.fillText('Passed (' + data.passed + ' - ' + passedPercent + '%)', legendX + 20, legendY + 12); // Failed legend - legendX += 100; + legendX += 130; ctx.fillStyle = '#e74c3c'; ctx.fillRect(legendX, legendY, 15, 15); ctx.fillStyle = '#333'; - ctx.fillText('Failed (' + data.failed + ')', legendX + 20, legendY + 12); + ctx.fillText('Failed (' + data.failed + ' - ' + failedPercent + '%)', legendX + 20, legendY + 12); // Pending legend if (data.pending > 0) { - legendX += 90; + legendX += 120; ctx.fillStyle = '#f39c12'; ctx.fillRect(legendX, legendY, 15, 15); ctx.fillStyle = '#333'; - ctx.fillText('Pending (' + data.pending + ')', legendX + 20, legendY + 12); + ctx.fillText('Pending (' + data.pending + ' - ' + pendingPercent + '%)', legendX + 20, legendY + 12); } } // Draw history chart function drawHistoryChart() { const canvas = document.getElementById('historyChart'); - if (!canvas || !window.testData.history || window.testData.history.length === 0) return; + + if (!canvas || !window.testData || !window.testData.history || window.testData.history.length === 0) { + return; + } const ctx = canvas.getContext('2d'); const history = window.testData.history.slice().reverse(); // Most recent last + console.log('History chart - Total data points:', window.testData.history.length); + console.log('History chart - Processing points:', history.length); + console.log('History chart - Raw history data:', window.testData.history); + console.log('History chart - Reversed history:', history); - const padding = 50; + const padding = 60; + const bottomPadding = 80; // Extra space for timestamps const chartWidth = canvas.width - 2 * padding; - const chartHeight = canvas.height - 2 * padding; + const chartHeight = canvas.height - padding - bottomPadding; // Clear canvas ctx.clearRect(0, 0, canvas.width, canvas.height); - // Find max values - const maxTests = Math.max(...history.map(h => h.stats.tests || 0)); - const maxDuration = Math.max(...history.map(h => h.duration || 0)); + // Calculate success rates and max values + const dataPoints = history.map((run, index) => { + const total = run.stats.tests || 0; + const passed = run.stats.passes || 0; + const failed = run.stats.failures || 0; + const successRate = total > 0 ? (passed / total) * 100 : 0; + const timestamp = new Date(run.timestamp); + + return { + index, + timestamp, + total, + passed, + failed, + successRate, + duration: run.duration || 0, + retries: run.retries || 0 + }; + }); + + console.log('History chart - Data points created:', dataPoints.length); + console.log('History chart - Data points:', dataPoints); + + const maxTests = Math.max(...dataPoints.map(d => d.total)); + const maxSuccessRate = 100; if (maxTests === 0) return; + // Draw background + ctx.fillStyle = '#fafafa'; + ctx.fillRect(padding, padding, chartWidth, chartHeight); + // Draw axes ctx.strokeStyle = '#333'; - ctx.lineWidth = 1; + ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(padding, padding); - ctx.lineTo(padding, canvas.height - padding); - ctx.lineTo(canvas.width - padding, canvas.height - padding); + ctx.lineTo(padding, padding + chartHeight); + ctx.lineTo(padding + chartWidth, padding + chartHeight); ctx.stroke(); // Draw grid lines - ctx.strokeStyle = '#eee'; + ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1; - for (let i = 1; i <= 5; i++) { - const y = padding + (chartHeight * i / 5); + for (let i = 1; i <= 4; i++) { + const y = padding + (chartHeight * i / 4); ctx.beginPath(); ctx.moveTo(padding, y); - ctx.lineTo(canvas.width - padding, y); + ctx.lineTo(padding + chartWidth, y); ctx.stroke(); } - // Draw pass/fail rates - const stepX = chartWidth / (history.length - 1); + // Calculate positions + const stepX = dataPoints.length > 1 ? chartWidth / (dataPoints.length - 1) : chartWidth / 2; - // Draw passed tests line + // Draw success rate area chart + ctx.fillStyle = 'rgba(39, 174, 96, 0.1)'; ctx.strokeStyle = '#27ae60'; ctx.lineWidth = 3; ctx.beginPath(); - history.forEach((run, index) => { - const x = padding + (index * stepX); - const y = canvas.height - padding - ((run.stats.passes || 0) / maxTests) * chartHeight; + + dataPoints.forEach((point, index) => { + const x = dataPoints.length === 1 ? padding + chartWidth / 2 : padding + (index * stepX); + const y = padding + chartHeight - (point.successRate / maxSuccessRate) * chartHeight; + if (index === 0) { - ctx.moveTo(x, y); + ctx.moveTo(x, padding + chartHeight); + ctx.lineTo(x, y); } else { ctx.lineTo(x, y); } + + point.x = x; + point.y = y; }); - ctx.stroke(); - // Draw failed tests line - ctx.strokeStyle = '#e74c3c'; + // Close the area + if (dataPoints.length > 0) { + const lastPoint = dataPoints[dataPoints.length - 1]; + ctx.lineTo(lastPoint.x, padding + chartHeight); + ctx.closePath(); + ctx.fill(); + } + + // Draw success rate line + ctx.strokeStyle = '#27ae60'; ctx.lineWidth = 3; ctx.beginPath(); - history.forEach((run, index) => { - const x = padding + (index * stepX); - const y = canvas.height - padding - ((run.stats.failures || 0) / maxTests) * chartHeight; + dataPoints.forEach((point, index) => { if (index === 0) { - ctx.moveTo(x, y); + ctx.moveTo(point.x, point.y); } else { - ctx.lineTo(x, y); + ctx.lineTo(point.x, point.y); } }); ctx.stroke(); - // Add labels - ctx.fillStyle = '#333'; - ctx.font = '12px Arial'; - ctx.textAlign = 'center'; + // Draw data points with enhanced styling + dataPoints.forEach(point => { + // Outer ring based on status + const ringColor = point.failed > 0 ? '#e74c3c' : '#27ae60'; + ctx.strokeStyle = ringColor; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(point.x, point.y, 8, 0, 2 * Math.PI); + ctx.stroke(); + + // Inner circle + ctx.fillStyle = point.failed > 0 ? '#e74c3c' : '#27ae60'; + ctx.beginPath(); + ctx.arc(point.x, point.y, 5, 0, 2 * Math.PI); + ctx.fill(); + + // White center dot + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(point.x, point.y, 2, 0, 2 * Math.PI); + ctx.fill(); + }); - // Y-axis labels + // Y-axis labels (Success Rate %) + ctx.fillStyle = '#666'; + ctx.font = '11px Arial'; ctx.textAlign = 'right'; - for (let i = 0; i <= 5; i++) { - const value = Math.round((maxTests * i) / 5); - const y = canvas.height - padding - (chartHeight * i / 5); - ctx.fillText(value.toString(), padding - 10, y + 4); + for (let i = 0; i <= 4; i++) { + const value = Math.round((maxSuccessRate * i) / 4); + const y = padding + chartHeight - (chartHeight * i / 4); + ctx.fillText(value + '%', padding - 10, y + 4); } - // Legend + // X-axis labels (Timestamps) + ctx.textAlign = 'center'; + ctx.font = '10px Arial'; + dataPoints.forEach((point, index) => { + const timeStr = point.timestamp.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + const dateStr = point.timestamp.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); + + console.log('Drawing label ' + index + ': ' + timeStr + ' at x=' + point.x); + ctx.fillText(timeStr, point.x, padding + chartHeight + 15); + ctx.fillText(dateStr, point.x, padding + chartHeight + 30); + }); + + // Enhanced legend with statistics + const legendY = 25; + ctx.font = '12px Arial'; ctx.textAlign = 'left'; + + // Success rate legend ctx.fillStyle = '#27ae60'; - ctx.fillRect(padding, 20, 15, 15); + ctx.fillRect(padding + 20, legendY, 15, 15); ctx.fillStyle = '#333'; - ctx.fillText('Passed Tests', padding + 20, 32); + ctx.fillText('Success Rate', padding + 40, legendY + 12); - ctx.fillStyle = '#e74c3c'; - ctx.fillRect(padding + 120, 20, 15, 15); + // Current stats + if (dataPoints.length > 0) { + const latest = dataPoints[dataPoints.length - 1]; + const trend = dataPoints.length > 1 ? + (latest.successRate - dataPoints[dataPoints.length - 2].successRate) : 0; + const trendIcon = trend > 0 ? '↗' : trend < 0 ? '↘' : '→'; + const trendColor = trend > 0 ? '#27ae60' : trend < 0 ? '#e74c3c' : '#666'; + + ctx.fillStyle = '#666'; + ctx.fillText('Latest: ' + latest.successRate.toFixed(1) + '%', padding + 150, legendY + 12); + + ctx.fillStyle = trendColor; + ctx.fillText(trendIcon + ' ' + Math.abs(trend).toFixed(1) + '%', padding + 240, legendY + 12); + } + + // Chart title ctx.fillStyle = '#333'; - ctx.fillText('Failed Tests', padding + 140, 32); + ctx.font = 'bold 14px Arial'; + ctx.textAlign = 'center'; + ctx.fillText('Test Success Rate History', canvas.width / 2, 20); } -// Initialize - hide failures section if no failures and draw charts +// Initialize charts and filters document.addEventListener('DOMContentLoaded', function() { - const failuresSection = document.querySelector('.failures-section'); - const failureItems = document.querySelectorAll('.failure-item'); - if (failureItems.length === 0) { - failuresSection.style.display = 'none'; - } // Draw charts drawPieChart();