diff --git a/docs/plugins.md b/docs/plugins.md index 0489bf097..19b335ada 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -817,7 +817,7 @@ Enable it manually on each run via `-p` option: ## reportData -TypeScript: Explicitly type reportData arrays as any[] to avoid 'never' errors +TypeScript: Explicitly type reportData arrays as any\[] to avoid 'never' errors ## retryFailedStep diff --git a/lib/plugin/htmlReporter.js b/lib/plugin/htmlReporter.js index 77f7d9133..70daf848a 100644 --- a/lib/plugin/htmlReporter.js +++ b/lib/plugin/htmlReporter.js @@ -1538,6 +1538,30 @@ module.exports = function (config) { function escapeHtml(text) { if (!text) return '' + // Convert non-string values to strings before escaping + if (typeof text !== 'string') { + // Handle arrays by recursively flattening and joining with commas + if (Array.isArray(text)) { + // Recursive helper to flatten deeply nested arrays with depth limit to prevent stack overflow + const flattenArray = (arr, depth = 0, maxDepth = 100) => { + if (depth >= maxDepth) { + // Safety limit reached, return string representation + return String(arr) + } + return arr + .map(item => { + if (Array.isArray(item)) { + return flattenArray(item, depth + 1, maxDepth) + } + return String(item) + }) + .join(', ') + } + text = flattenArray(text) + } else { + text = String(text) + } + } return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') } @@ -1653,8 +1677,12 @@ module.exports = function (config) { if (!systemInfo) return '' const formatInfo = (key, value) => { + // Handle array values (e.g., ['Node', '22.14.0', 'path']) if (Array.isArray(value) && value.length > 1) { - return `
${key}: ${escapeHtml(value[1])}
` + // value[1] might be an array itself (e.g., edgeInfo: ['Edge', ['Chromium (140.0.3485.54)'], 'N/A']) + // escapeHtml now handles this, but we can also flatten it here for clarity + const displayValue = value[1] + return `
${key}: ${escapeHtml(displayValue)}
` } else if (typeof value === 'string' && value !== 'N/A' && value !== 'undefined') { return `
${key}: ${escapeHtml(value)}
` } diff --git a/test/unit/plugin/htmlReporter_test.js b/test/unit/plugin/htmlReporter_test.js new file mode 100644 index 000000000..742d8f13d --- /dev/null +++ b/test/unit/plugin/htmlReporter_test.js @@ -0,0 +1,190 @@ +const { expect } = require('chai') + +// Helper function to simulate the escapeHtml behavior from htmlReporter.js +function escapeHtml(text) { + if (!text) return '' + // Convert non-string values to strings before escaping + if (typeof text !== 'string') { + // Handle arrays by recursively flattening and joining with commas + if (Array.isArray(text)) { + // Recursive helper to flatten deeply nested arrays with depth limit to prevent stack overflow + const flattenArray = (arr, depth = 0, maxDepth = 100) => { + if (depth >= maxDepth) { + // Safety limit reached, return string representation + return String(arr) + } + return arr + .map(item => { + if (Array.isArray(item)) { + return flattenArray(item, depth + 1, maxDepth) + } + return String(item) + }) + .join(', ') + } + text = flattenArray(text) + } else { + text = String(text) + } + } + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +} + +describe('htmlReporter plugin', () => { + describe('escapeHtml function', () => { + it('should escape HTML special characters in strings', () => { + const result = escapeHtml('') + expect(result).to.include('<script>') + expect(result).to.include('"') + }) + + it('should handle string inputs correctly', () => { + const result = escapeHtml('Hello ') + expect(result).to.include('Hello <World>') + }) + + it('should handle array inputs by converting to string', () => { + const result = escapeHtml(['Item1', 'Item2', 'Item3']) + expect(result).to.include('Item1, Item2, Item3') + }) + + it('should handle nested arrays by flattening them', () => { + // This is the key test case from the issue + const result = escapeHtml(['Edge', ['Chromium (140.0.3485.54)'], 'N/A']) + expect(result).to.include('Edge') + expect(result).to.include('Chromium (140.0.3485.54)') + expect(result).to.include('N/A') + // Should not crash with "text.replace is not a function" + }) + + it('should handle deeply nested arrays', () => { + const result = escapeHtml(['Level1', ['Level2', ['Level3']], 'End']) + expect(result).to.include('Level1') + expect(result).to.include('Level2') + expect(result).to.include('Level3') + expect(result).to.include('End') + }) + + it('should handle null and undefined inputs', () => { + const resultNull = escapeHtml(null) + expect(resultNull).to.equal('') + + const resultUndefined = escapeHtml(undefined) + expect(resultUndefined).to.equal('') + }) + + it('should handle empty strings', () => { + const result = escapeHtml('') + expect(result).to.equal('') + }) + + it('should handle numbers by converting to strings', () => { + const result = escapeHtml(42) + expect(result).to.include('42') + }) + + it('should handle objects by converting to strings', () => { + const result = escapeHtml({ key: 'value' }) + expect(result).to.include('[object Object]') + }) + + it('should escape all HTML entities in arrays', () => { + const result = escapeHtml(['
', '"quoted"', "it's", 'A&B']) + expect(result).to.include('<div>') + expect(result).to.include('"quoted"') + expect(result).to.include('it's') + expect(result).to.include('A&B') + }) + }) + + describe('generateSystemInfoHtml function', () => { + it('should handle system info with nested arrays', () => { + // This tests the real-world scenario from the issue + const systemInfo = { + nodeInfo: ['Node', '22.14.0', '~\\AppData\\Local\\fnm_multishells\\19200_1763624547202\\node.EXE'], + osInfo: ['OS', 'Windows 10 10.0.19045'], + cpuInfo: ['CPU', '(12) x64 12th Gen Intel(R) Core(TM) i5-12500'], + chromeInfo: ['Chrome', '142.0.7444.163', 'N/A'], + edgeInfo: ['Edge', ['Chromium (140.0.3485.54)'], 'N/A'], // This is the problematic case + firefoxInfo: undefined, + safariInfo: ['Safari', 'N/A'], + playwrightBrowsers: 'chromium: 136.0.7103.25, firefox: 137.0, webkit: 18.4', + } + + // Test that processing this system info doesn't crash + // We simulate the formatInfo function behavior + const formatValue = value => { + if (Array.isArray(value) && value.length > 1) { + const displayValue = value[1] + return escapeHtml(displayValue) + } else if (typeof value === 'string') { + return value + } + return '' + } + + // Test each system info value + expect(formatValue(systemInfo.nodeInfo)).to.include('22.14.0') + expect(formatValue(systemInfo.osInfo)).to.include('Windows 10') + expect(formatValue(systemInfo.cpuInfo)).to.include('12th Gen') + expect(formatValue(systemInfo.chromeInfo)).to.include('142.0.7444.163') + + // The critical test: edgeInfo with nested array should not crash + const edgeResult = formatValue(systemInfo.edgeInfo) + expect(edgeResult).to.include('Chromium') + expect(edgeResult).to.include('140.0.3485.54') + + expect(formatValue(systemInfo.safariInfo)).to.equal('N/A') + }) + + it('should handle undefined values gracefully', () => { + const systemInfo = { + firefoxInfo: undefined, + } + + const formatValue = value => { + if (Array.isArray(value) && value.length > 1) { + return 'has value' + } + return '' + } + + expect(formatValue(systemInfo.firefoxInfo)).to.equal('') + }) + + it('should handle string values directly', () => { + const systemInfo = { + playwrightBrowsers: 'chromium: 136.0.7103.25, firefox: 137.0, webkit: 18.4', + } + + const formatValue = value => { + if (typeof value === 'string') { + return value + } + return '' + } + + expect(formatValue(systemInfo.playwrightBrowsers)).to.include('chromium') + expect(formatValue(systemInfo.playwrightBrowsers)).to.include('firefox') + expect(formatValue(systemInfo.playwrightBrowsers)).to.include('webkit') + }) + }) + + describe('edge cases', () => { + it('should handle arrays with HTML content', () => { + const result = escapeHtml(['']) + expect(result).to.include('<script>') + expect(result).to.include('alert("xss")') + expect(result).to.include('</script>') + }) + + it('should handle mixed array types', () => { + const result = escapeHtml(['String', 42, true, null, ['nested']]) + expect(result).to.include('String') + expect(result).to.include('42') + expect(result).to.include('true') + expect(result).to.include('null') + expect(result).to.include('nested') + }) + }) +})