UNPKG

@testomatio/reporter

Version:
1,020 lines (1,019 loc) 43.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const debug_1 = __importDefault(require("debug")); const lodash_merge_1 = __importDefault(require("lodash.merge")); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const picocolors_1 = __importDefault(require("picocolors")); const handlebars_1 = __importDefault(require("handlebars")); const file_url_1 = __importDefault(require("file-url")); const utils_js_1 = require("../utils/utils.js"); const constants_js_1 = require("../constants.js"); const node_url_1 = require("node:url"); const debug = (0, debug_1.default)('@testomatio/reporter:pipe:html'); // @ts-ignore – this line will be removed in compiled code (already defined in the global scope of commonjs) class HtmlPipe { constructor(params, store = {}) { this.store = store || {}; this.title = params.title || process.env.TESTOMATIO_TITLE; this.apiKey = params.apiKey || process.env.TESTOMATIO; this.isHtml = process.env.TESTOMATIO_HTML_REPORT_SAVE; debug('HTML Pipe: ', this.apiKey ? 'API KEY' : '*no api key provided*'); this.isEnabled = false; this.htmlOutputPath = ''; this.filenameMsg = ''; this.tests = []; if (this.isHtml) { this.isEnabled = true; this.htmlReportDir = process.env.TESTOMATIO_HTML_REPORT_FOLDER || constants_js_1.HTML_REPORT.FOLDER; if (process.env.TESTOMATIO_HTML_FILENAME && process.env.TESTOMATIO_HTML_FILENAME.endsWith('.html')) { this.htmlReportName = process.env.TESTOMATIO_HTML_FILENAME; } if (process.env.TESTOMATIO_HTML_FILENAME && !process.env.TESTOMATIO_HTML_FILENAME.endsWith('.html')) { this.htmlReportName = constants_js_1.HTML_REPORT.REPORT_DEFAULT_NAME; this.filenameMsg = 'HTML filename must include the extension ".html".' + ` The default report name "${this.htmlReportDir}/${constants_js_1.HTML_REPORT.REPORT_DEFAULT_NAME}" is used!`; } if (!process.env.TESTOMATIO_HTML_FILENAME) { this.htmlReportName = constants_js_1.HTML_REPORT.REPORT_DEFAULT_NAME; } this.templateFolderPath = path_1.default.resolve(__dirname, '..', 'template'); this.templateHtmlPath = path_1.default.resolve(this.templateFolderPath, constants_js_1.HTML_REPORT.TEMPLATE_NAME); this.htmlOutputPath = path_1.default.join(this.htmlReportDir, this.htmlReportName); // create a new folder for the HTML reports utils_js_1.fileSystem.createDir(this.htmlReportDir); debug(picocolors_1.default.yellow('HTML Pipe:'), `Save HTML report: ${this.isEnabled}`, `HTML report folder: ${this.htmlReportDir}, report name: ${this.htmlReportName}`); } } async createRun() { // empty } async prepareRun() { } updateRun() { // empty } /** * Add test data to the result array for saving. As a result of this function, we get a result object to save. * @param {import('../../types/types.js').HtmlTestData} test - object which includes each test entry. */ addTest(test) { if (!this.isEnabled) return; if (test?.stack && typeof test.stack === 'string') { test.stack = test.stack.replace((0, utils_js_1.ansiRegExp)(), ''); } const hasPayload = Boolean(test?.status) || (Array.isArray(test?.files) && test.files.length) || (Array.isArray(test?.artifacts) && test.artifacts.length) || (Array.isArray(test?.steps) && test.steps.length) || Boolean(test?.message) || Boolean(test?.logs) || (test?.meta && ((Array.isArray(test.meta.attachments) && test.meta.attachments.length) || test.meta.traces !== undefined)); if (!hasPayload) return; const index = this.tests.findIndex(t => (0, utils_js_1.isSameTest)(t, test)); if (index >= 0) { this.tests[index] = (0, lodash_merge_1.default)(this.tests[index], test); return; } this.tests.push(test); } async finishRun(runParams) { if (!this.isEnabled) return; if (this.isHtml) { // GENERATE HTML reports based on the results data this.buildReport({ runParams, // TODO: this.tests=[] in case of Mocha, need retest by Vitalii tests: this.tests, outputPath: this.htmlOutputPath, templatePath: this.templateHtmlPath, warningMsg: this.filenameMsg, }); } } /** * Generates an HTML report based on provided test data and a template. * @param {object} opts - Test options used to generate the HTML report: * runParams, tests, outputPath, templatePath * @returns {void} - This function does not return anything. */ buildReport(opts) { const { runParams, tests, outputPath, templatePath, warningMsg: msg } = opts; debug('HTML tests data:', tests); if (!outputPath) { console.log(picocolors_1.default.yellow(`🚨 HTML export path is not set, ignoring...`)); return; } console.log(picocolors_1.default.yellow(`⏳ The test results will be added to the HTML report. It will take some time...`)); if (msg) { console.log(picocolors_1.default.blue(msg)); } const aggregatedTests = aggregateTestRetries(tests); aggregatedTests.forEach(test => { const logsRaw = test.logs || test.meta?.logs || test.meta?.console || test.meta?.stdout || test.meta?.stderr || ''; const stackRaw = test.stack || ''; const messageRaw = test.message || ''; const { steps: stepsFromMsg, restText: messageClean } = extractStepLines(messageRaw); const { steps: stepsFromLogs, restText: logsClean } = extractStepLines(logsRaw); const { steps: stepsFromStack, restText: stackClean } = extractStepLines(stackRaw); let stepsTree = null; if (Array.isArray(test.steps) && test.steps.length) { const userSteps = filterUserStepsTree(test.steps); stepsTree = userSteps.length ? userSteps : null; } if (!stepsTree && stepsFromLogs.length > 0) { stepsTree = buildStepsTreeFromLogs(stepsFromLogs); } const allStepLines = [...stepsFromMsg, ...stepsFromLogs, ...stepsFromStack]; const fallbackStepsText = allStepLines.length ? allStepLines.map((s, i) => `${i + 1}. ${s}`).join('\n') : ''; test.message = messageClean; test.stack = stackClean; parseRetryInfo(test); if (test.meta?.traces !== undefined) { test.traces = typeof test.meta.traces === 'string' ? test.meta.traces : JSON.stringify(test.meta.traces, null, 2); delete test.meta.traces; } loadTracesFromFiles(test); const status = String(test.status || '').toLowerCase(); if ((status === 'skipped' || status === 'pending') && test.meta?.todo) { test.status = 'todo'; } prepareTestStepsForReport(test, stepsTree, allStepLines, fallbackStepsText); delete test._stepsFromMessage; test.steps = toHtmlSafe(test.steps || ''); const rawFields = stripFailureBlock(toPlainText(logsClean)); const rawStack = extractLogsFromStack(test.stack); const logsFromFields = normalizeLogs(rawFields); const logsFromStack = normalizeLogs(stripStepMarkedLinesRaw(rawStack)); const logsMerged = (logsFromFields || logsFromStack).trim(); const messageProcessed = decodeHtmlEntities(toPlainText(test.message)).trim(); const messageNoInlineLogs = stripInlineLogsBlock(messageProcessed); const messageFinal = cleanNoiseBlock(messageNoInlineLogs).trim(); const logsFinal = cleanNoiseBlock(logsMerged).trim(); const hasStack = hasMeaningfulText(test.stack); const finalText = buildMessageForReport({ status, messageRaw: messageFinal, logsText: logsFinal, hasStack, }); test.message = toHtmlSafe(finalText); if (!test.suite_title?.trim()) { test.suite_title = 'Unknown suite'; } if (!test.title?.trim()) { test.title = 'Unknown test title'; } test.artifacts = normalizeArtifacts(test); const allPossibleArtifacts = [ ...(test.artifacts || []), ...(test.manuallyAttachedArtifacts || []), ...(test.files || []), ...(test.meta?.attachments || []), ]; test.artifactsUploaded = allPossibleArtifacts.some(artifact => { const link = artifact?.link || artifact?.path; return link && (link.startsWith('http://') || link.startsWith('https://')) && !link.startsWith('file://'); }); normalizeRetries(test); if (test.traces) { test.traces = typeof test.traces === 'string' ? test.traces : JSON.stringify(test.traces, null, 2); } }); const data = { title: this.title || 'Test Results', runId: this.store.runId || '', status: runParams.status || 'No status info', parallel: runParams.isParallel || 'No parallel info', runUrl: this.store.runUrl || '', executionTime: testExecutionSumTime(aggregatedTests), executionDate: getCurrentDateTimeFormatted(), tests: aggregatedTests, envVars: collectEnvironmentVariables(), }; // generate output HTML based on the template const html = this.#generateHTMLReport(data, templatePath); if (!html) return; fs_1.default.writeFileSync(outputPath, html, 'utf-8'); // Check if the file exists if (fs_1.default.existsSync(outputPath)) { // Get the absolute path of the file const absolutePath = path_1.default.resolve(outputPath); // Convert the file path to a file URL const fileUrlPath = (0, file_url_1.default)(absolutePath, { resolve: true }); debug('HTML tests data:', fileUrlPath); console.log(picocolors_1.default.green(`📊 The HTML report was successfully generated. Full filepath: ${fileUrlPath}`)); } else { console.log(picocolors_1.default.red(`🚨 Failed to generate the HTML report.`)); } } /** * Generates an HTML report based on provided test data and a template path. * @param {any} data - Test data used to generate the HTML report. * @param {string} [templatePath=""] - The path to the HTML template used for generating the report. * @returns {string | void} - The generated HTML report as a string or void if templatePath is not provided. */ #generateHTMLReport(data, templatePath = '') { if (!templatePath) { console.log(picocolors_1.default.red(`🚨 HTML template not found. Report generation is impossible!`)); return; } const templateSource = fs_1.default.readFileSync(templatePath, 'utf8'); this.#loadReportHelpers(); try { const template = handlebars_1.default.compile(templateSource); return template(data); } catch (e) { console.log(picocolors_1.default.red('❌ Oops! An unknown error occurred when generating an HTML report')); console.log(picocolors_1.default.red(e)); } } #loadReportHelpers() { handlebars_1.default.registerHelper('getTestsByStatus', (tests, status) => tests.filter(test => test.status.toLowerCase() === status.toLowerCase()).length); handlebars_1.default.registerHelper('formatDuration', milliseconds => { if (!milliseconds || milliseconds === 0) return '0ms'; const totalSeconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; const ms = milliseconds % 1000; if (minutes > 0) { return `${minutes}m ${seconds}s ${ms}ms`; } else if (seconds > 0) { return `${seconds}s ${ms}ms`; } else { return `${ms}ms`; } }); handlebars_1.default.registerHelper('selectComponent', () => new handlebars_1.default.SafeString(`<select style="width: 70px;height: 38px;" class="form-select" aria-label="Tests counter on page"> <option value="0">10</option> <option value="1">25</option> <option value="2">50</option> </select>`)); handlebars_1.default.registerHelper('emptyDataComponent', () => { const svgFilePath = path_1.default.join(__dirname, '..', 'template', 'emptyData.svg'); const svgContent = fs_1.default.readFileSync(svgFilePath, 'utf8'); return new handlebars_1.default.SafeString(` <div class="noData"> <div class="noDataSvg"> ${svgContent} </div> <div class="noDataText"> NO MATCHING TESTS </div> <div>`); }); handlebars_1.default.registerHelper('pageDispleyElements', tests => { // We wrapp the lines to the HTML format we need const totalTests = JSON.parse(JSON.stringify(tests) .replace(/<script>/g, '&lt;script&gt;') .replace(/<\/script>/g, '&lt;/script&gt;')); const paginationOptions = { 0: 10, 1: 25, 2: 50, }; const statuses = ['all', 'passed', 'failed', 'skipped', 'todo']; const pageItemGroups = { all: {}, passed: {}, failed: {}, skipped: {}, todo: {}, totalTests, }; function paginateItems(items, pageSize) { const paginatedItems = []; for (let i = 0; i < items.length; i += pageSize) { paginatedItems.push(items.slice(i, i + pageSize)); } return paginatedItems; } statuses.forEach(status => { for (const option in paginationOptions) { if (paginationOptions.hasOwnProperty(option)) { const pageSize = paginationOptions[option]; let filteredItems = totalTests; if (status !== 'all') { filteredItems = totalTests.filter(item => String(item.status).toLowerCase() === status); } pageItemGroups[status][option] = paginateItems(filteredItems, pageSize); } } }); pageItemGroups.totalTests = totalTests; return JSON.stringify(pageItemGroups); }); handlebars_1.default.registerHelper('ObjectLength', obj => { return Object.keys(obj).length; }); } async sync() { // HtmlPipe doesn't buffer tests, so sync is a no-op // Reserved for future use if needed } toString() { return 'HTML Reporter'; } } /** * Calculates the total execution time for an array of tests. * @param {Object[]} tests - An array of test objects. * @param {number} tests[].run_time - The execution time of each test in milliseconds. * @returns {string} - The total execution time in a formatted duration string. */ function testExecutionSumTime(tests) { const totalMilliseconds = tests.reduce((sum, test) => { if (typeof test.run_time === 'number' && !Number.isNaN(test.run_time)) { return sum + test.run_time; } return sum; }, 0); return formatDuration(totalMilliseconds); } function parseRetryInfo(test) { test.retries = test.retries || { retryCount: 0, attempts: [] }; if (test.meta && test.meta.retryCount !== undefined) { const n = Number(test.meta.retryCount); if (!Number.isNaN(n)) test.retries.retryCount = n; } } function escapeHtml(str = '') { return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); } /** * Converts value to plain text. Handles arrays by joining with newlines. * @param {string|string[]} value - String or array of strings * @returns {string} Plain text representation */ function toPlainText(value) { if (!value) return ''; if (Array.isArray(value)) return value.map(v => String(v)).join('\n'); return String(value); } function stripFailureBlock(text = '') { const t = String(text); const idx = t.indexOf('################[ Failure ]'); if (idx !== -1) return t.slice(0, idx).trim(); const idx2 = t.indexOf('[ Failure ]'); if (idx2 !== -1) return t.slice(0, idx2).trim(); return t.trim(); } /** * Normalizes log text by removing ANSI codes and trimming whitespace. * Removes empty lines and trims trailing spaces from each line. * @param {string} text - Raw log text * @returns {string} Cleaned log text */ function normalizeLogs(text = '') { return String(text) .replace((0, utils_js_1.ansiRegExp)(), '') .split('\n') .map(l => l.trimEnd()) .filter(l => l.trim()) .join('\n') .trim(); } function toHtmlSafe(value) { const noAnsi = toPlainText(value).replace((0, utils_js_1.ansiRegExp)(), ''); return escapeHtml(noAnsi).replace(/\n/g, '<br>'); } function hasMeaningfulText(value) { return typeof value === 'string' && value.trim().length > 0; } function buildMessageForReport({ status, messageRaw, logsText, hasStack }) { const hasMsg = hasMeaningfulText(messageRaw); const hasLogs = hasMeaningfulText(logsText); if (status === 'failed') { if (hasStack) { return ''; } const parts = []; if (hasMsg) parts.push(messageRaw); if (hasLogs) parts.push(`--- Logs ---\n${logsText}`); return parts.length ? parts.join('\n\n') : 'No message'; } if (hasMsg) return messageRaw; if (hasLogs) return logsText; return 'No logs'; } function stripInlineLogsBlock(text = '') { const t = String(text || ''); const markers = ['--- Logs ---', '[ Logs ]', 'Logs:']; let cut = -1; for (const m of markers) { const i = t.indexOf(m); if (i !== -1) cut = cut === -1 ? i : Math.min(cut, i); } return (cut === -1 ? t : t.slice(0, cut)).trim(); } function extractLogsFromStack(stack) { if (!stack) return ''; const clean = String(stack).replace((0, utils_js_1.ansiRegExp)(), ''); const lines = clean.split('\n'); const startIdx = lines.findIndex(l => l.includes('[ Logs ]')); if (startIdx === -1) return ''; let endIdx = lines.findIndex((l, i) => i > startIdx && (l.includes('[ Failure ]') || l.includes('################[ Failure ]'))); if (endIdx === -1) endIdx = lines.length; const slice = lines.slice(startIdx + 1, endIdx); return slice .map(l => l.trimEnd()) .filter(l => l.trim()) .filter(l => !l.includes('[ Logs ]') && !l.includes('Logs:')) .join('\n') .trim(); } function decodeHtmlEntities(value = '') { return String(value || '') .replace(/<br\s*\/?>/gi, '\n') .replace(/&gt;/gi, '>') .replace(/&lt;/gi, '<') .replace(/&amp;/gi, '&') .replace(/&quot;/gi, '"') .replace(/&#39;/gi, "'") .replace(/\r\n/g, '\n'); } function stripStepMarkedLinesRaw(text = '') { const t = decodeHtmlEntities(text); return t .split('\n') .filter(line => { const ln = line.replace((0, utils_js_1.ansiRegExp)(), ''); return !/^\s*(?:>|&gt;|[⏩►])\s/i.test(ln); }) .join('\n') .trim(); } /** * Converts 'pending' status to 'todo' for Testomat.io display * @param {string} value - Status value * @returns {string} Status with 'pending' converted to 'todo' */ function normalizeStatus(value) { const s = String(value || '').toLowerCase(); if (s === 'pending') return 'todo'; return s || 'unknown'; } function pickAttemptStatus(a) { if (!a) return 'unknown'; if (a.passed === true) return 'passed'; if (a.passed === false) return 'failed'; return normalizeStatus(a.status ?? a.state ?? a.outcome ?? a.result ?? a.verdict ?? a.ok ?? 'unknown'); } function pickAttemptDuration(a) { const n = a?.duration ?? a?.durationMs ?? a?.run_time ?? a?.time ?? a?.elapsed ?? null; return typeof n === 'number' && !Number.isNaN(n) ? n : null; } function buildAttemptsFromCount(retryCount, finalStatus) { const total = Math.max(1, Number(retryCount || 0) + 1); const arr = []; for (let i = 0; i < total; i++) { const status = i === total - 1 ? normalizeStatus(finalStatus) : 'unknown'; arr.push({ status, duration: null }); } return arr; } function normalizeRetries(test) { test.meta = test.meta || {}; parseRetryInfo(test); const finalStatus = normalizeStatus(test.status); const attemptsRaw = (Array.isArray(test.attempts) && test.attempts) || (Array.isArray(test.retries?.attempts) && test.retries.attempts) || (Array.isArray(test.meta?.attempts) && test.meta.attempts) || (Array.isArray(test.meta?.retries) && test.meta.retries) || []; const retryCountFromMeta = typeof test.meta.retryCount === 'number' ? test.meta.retryCount : undefined; const retryCountFromRetries = typeof test.retries?.retryCount === 'number' ? test.retries.retryCount : undefined; let attemptsNormalized = []; if (attemptsRaw.length > 0) { attemptsNormalized = attemptsRaw.map(a => ({ status: pickAttemptStatus(a), duration: pickAttemptDuration(a), })); const lastIdx = attemptsNormalized.length - 1; if (lastIdx >= 0) attemptsNormalized[lastIdx].status = finalStatus; } else { const retryCount = retryCountFromMeta ?? retryCountFromRetries ?? 0; attemptsNormalized = buildAttemptsFromCount(retryCount, finalStatus); } const retryCountFinal = Math.max(0, attemptsNormalized.length - 1); const hadFailures = attemptsNormalized.slice(0, -1).some(a => a.status === 'failed'); const passedAfterRetries = finalStatus === 'passed' && hadFailures; test.retries = { retryCount: retryCountFinal, attempts: attemptsNormalized, hadFailures, passedAfterRetries, finalStatus, }; const metaFlaky = test.meta?.flaky === true || test.meta?.isFlaky === true; test.flaky = Boolean(metaFlaky || passedAfterRetries); } /** * Formats duration in milliseconds into a human-readable string representation. * @param {number} duration - The duration in milliseconds. * @returns {string} - The formatted duration string (e.g., "2h 30m 15s 500ms"). */ function formatDuration(duration) { const milliseconds = duration % 1000; duration = (duration - milliseconds) / 1000; const seconds = duration % 60; duration = (duration - seconds) / 60; const minutes = duration % 60; const hours = (duration - minutes) / 60; return `${hours}h ${minutes}m ${seconds}s ${milliseconds}ms`; } /** * Retrieves the current date and time in a formatted string. * @returns {string} - The formatted date and time string (e.g., "(01/01/2023 12:00:00)"). */ function getCurrentDateTimeFormatted() { const currentDate = new Date(); const day = currentDate.getDate().toString().padStart(2, '0'); const month = (currentDate.getMonth() + 1).toString().padStart(2, '0'); const year = currentDate.getFullYear(); const hours = currentDate.getHours().toString().padStart(2, '0'); const minutes = currentDate.getMinutes().toString().padStart(2, '0'); const seconds = currentDate.getSeconds().toString().padStart(2, '0'); return `(${day}/${month}/${year} ${hours}:${minutes}:${seconds})`; } /** * Aggregates duplicate test records (from retries) into a single entry * @param {Array} tests - Array of all tests * @returns {Array} - Aggregated array of tests */ function aggregateTestRetries(tests) { if (!Array.isArray(tests) || tests.length === 0) return tests; const grouped = new Map(); for (const t of tests) { const rid = t?.rid || t?.meta?.rid || t?.meta?.RID || t?.meta?.runRid || t?.meta?.testRid; const key = rid ? `rid:${rid}` : `ft:${t?.file || ''}|${t?.title || ''}`; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(t); } const aggregated = []; grouped.forEach(group => { if (group.length === 1) { aggregated.push(group[0]); return; } const attemptsOnly = group.filter(x => x && x.status); const base = attemptsOnly.length ? attemptsOnly[attemptsOnly.length - 1] : group[group.length - 1]; const allFiles = []; const allArtifacts = []; const allMetaAttachments = []; const allManual = []; for (const x of group) { if (Array.isArray(x?.files)) allFiles.push(...x.files); if (Array.isArray(x?.artifacts)) allArtifacts.push(...x.artifacts); if (Array.isArray(x?.meta?.attachments)) allMetaAttachments.push(...x.meta.attachments); if (Array.isArray(x?.manuallyAttachedArtifacts)) allManual.push(...x.manuallyAttachedArtifacts); if (Array.isArray(x?.meta?.manuallyAttachedArtifacts)) allManual.push(...x.meta.manuallyAttachedArtifacts); } const attempts = attemptsOnly.map(a => ({ status: normalizeStatus(a.status), duration: a.run_time || a.time || 0, })); const retryCount = Math.max(0, attempts.length - 1); const hadFailures = attempts.slice(0, -1).some(a => a.status === 'failed'); const finalStatus = normalizeStatus(base.status); const passedAfterRetries = finalStatus === 'passed' && hadFailures; const merged = (0, lodash_merge_1.default)({}, base); if (allFiles.length) merged.files = allFiles; if (allArtifacts.length) merged.artifacts = allArtifacts; merged.meta = merged.meta || {}; if (allMetaAttachments.length) { merged.meta.attachments = [...(merged.meta.attachments || []), ...allMetaAttachments]; } if (allManual.length) { merged.manuallyAttachedArtifacts = [...(merged.manuallyAttachedArtifacts || []), ...allManual]; } merged.retries = { retryCount, attempts, hadFailures, passedAfterRetries, finalStatus, }; merged.flaky = Boolean(passedAfterRetries || merged.meta?.flaky || merged.meta?.isFlaky); aggregated.push(merged); }); return aggregated; } function extractStepLines(raw = '') { const text = decodeHtmlEntities(toPlainText(raw || '')); if (!text.trim()) return { steps: [], restText: '' }; const lines = text.split('\n'); const steps = []; const rest = []; for (const line of lines) { const cleanedLine = line.replace((0, utils_js_1.ansiRegExp)(), ''); const stepMatch = cleanedLine.match(/^\s*(?:>|&gt;|[⏩►])\s*(.+?)\s*$/i); if (stepMatch) { steps.push(stepMatch[1].trim()); continue; } const stepWithLabel = cleanedLine.match(/^\s*(?:>|&gt;|[⏩►]\s*)?\s*Step:\s*(.+)\s*$/i); if (stepWithLabel) { steps.push(stepWithLabel[1].trim()); continue; } if (/^\s*Step\s*\d+\s*$/i.test(cleanedLine)) continue; rest.push(line); } return { steps, restText: rest.join('\n').trim() }; } function filterUserStepsTree(steps) { if (!Array.isArray(steps)) return []; const isUserStep = s => String(s?.category || '').toLowerCase() === 'user'; const walk = arr => { const out = []; for (const s of arr) { if (!s) continue; const children = walk(s.steps || []); if (isUserStep(s)) { const copy = { ...s }; if (children.length) copy.steps = children; else delete copy.steps; out.push(copy); } else if (children.length) { out.push(...children); } } return out; }; return walk(steps); } /** * Builds a tree structure from a flat array of step names * This is used when steps are stored as logs (like in Playwright) * @param {string[]} stepLines - Array of step names * @returns {Array} - Tree structure of steps */ function buildStepsTreeFromLogs(stepLines) { if (!Array.isArray(stepLines) || stepLines.length === 0) return []; const result = []; const stack = []; const indentStack = []; stepLines.forEach(line => { if (!line || !line.trim()) return; const text = line.trim(); const indent = line.search(/\S/); while (indentStack.length > 0 && indentStack[indentStack.length - 1] >= indent) { stack.pop(); indentStack.pop(); } const stepObj = { category: 'user', title: text, duration: 0, }; if (stack.length === 0) { result.push(stepObj); } else { const parent = stack[stack.length - 1]; if (!parent.steps) parent.steps = []; parent.steps.push(stepObj); } stack.push(stepObj); indentStack.push(indent); }); return result; } function cleanNoiseBlock(text = '') { const t = decodeHtmlEntities(String(text || '')).replace((0, utils_js_1.ansiRegExp)(), ''); let lines = t .split('\n') .map(l => l.trim()) .filter(Boolean); lines = dropISayEcho(lines); return lines.join('\n').trim(); } function dropISayEcho(lines) { const out = []; for (let i = 0; i < lines.length; i++) { const cur = lines[i]; const prev = out[out.length - 1]; const m = prev && prev.match(/^I say\s+"([\s\S]*)"$/); if (m) { const said = m[1]; if (cur === said) continue; } out.push(cur); } return out; } /** * Collects all Testomatio and S3 environment variables * Uses hardcoded list to avoid file system dependencies for end users * @returns {Object} Object with TESTOMATIO_ and S3_ variables grouped */ function collectEnvironmentVariables() { return getHardcodedEnvVars(); } /** * Process environment variables configuration and collect their values * @param {Object} varConfigs - Object with variable configurations { [key]: { description } } * @param {Set} sensitiveVars - Set of sensitive variable names * @returns {Object} Processed environment variables with metadata */ function processEnvironmentVariables(varConfigs, sensitiveVars) { const result = {}; for (const [key, config] of Object.entries(varConfigs)) { const value = process.env[key]; const isSensitive = sensitiveVars.has(key); if (isSensitive) { if (value !== undefined) { result[key] = { value: '***', description: config.description, isSet: true, isSensitive: true }; } else { result[key] = { value: '', description: config.description, isSet: false, isSensitive: true }; } } else { if (value !== undefined) { result[key] = { value, description: config.description, isSet: true }; } else { result[key] = { value: '', description: config.description, isSet: false }; } } } return result; } /** * Hardcoded environment variables stored in code * This is the main source of truth for env vars to avoid file system dependencies * @returns {Object} Object with TESTOMATIO_ and S3_ variables */ function getHardcodedEnvVars() { const allVars = { testomatio: { TESTOMATIO: { description: 'API Key for Testomat.io' }, TESTOMATIO_API_KEY: { description: 'API Key (alias for TESTOMATIO)' }, TESTOMATIO_CREATE: { description: 'Create new tests in Testomat.io' }, TESTOMATIO_DEBUG: { description: 'Enable debug mode' }, TESTOMATIO_DISABLE_BATCH_UPLOAD: { description: 'Disable batch upload' }, TESTOMATIO_ENV: { description: 'Environment label (e.g., "Windows, Chrome")' }, TESTOMATIO_EXCLUDE_FILES_FROM_REPORT_GLOB_PATTERN: { description: 'Glob pattern to exclude files' }, TESTOMATIO_EXCLUDE_SKIPPED: { description: 'Exclude skipped tests from report' }, TESTOMATIO_FILENAME: { description: 'HTML report filename' }, TESTOMATIO_HTML_FILENAME: { description: 'HTML report filename' }, TESTOMATIO_HTML_REPORT_FOLDER: { description: 'Folder for HTML report' }, TESTOMATIO_HTML_REPORT_SAVE: { description: 'Save HTML report' }, TESTOMATIO_INTERCEPT_CONSOLE_LOGS: { description: 'Intercept console logs' }, TESTOMATIO_MARK_DETACHED: { description: 'Mark tests as detached' }, TESTOMATIO_MAX_REQUEST_FAILURES: { description: 'Max request failures' }, TESTOMATIO_MAX_REQUEST_FAILURES_COUNT: { description: 'Max request failures count' }, TESTOMATIO_MAX_REQUEST_RETRIES_WITHIN_TIME_SECONDS: { description: 'Max retries within time period' }, TESTOMATIO_NO_STEPS: { description: 'Disable steps reporting' }, TESTOMATIO_NO_TIMESTAMP: { description: 'Remove timestamps from logs' }, TESTOMATIO_PROCEED: { description: 'Proceed even if tests fail' }, TESTOMATIO_PUBLISH: { description: 'Publish results to Testomat.io' }, TESTOMATIO_REQUEST_TIMEOUT: { description: 'Request timeout in milliseconds' }, TESTOMATIO_RUN: { description: 'Run ID to report tests to' }, TESTOMATIO_RUNGROUP_TITLE: { description: 'Title for run group' }, TESTOMATIO_SHARED_RUN: { description: 'Share run for parallel execution' }, TESTOMATIO_SHARED_RUN_TIMEOUT: { description: 'Timeout for shared run (in seconds)' }, TESTOMATIO_STACK_ARTIFACTS: { description: 'Stack artifacts in report' }, TESTOMATIO_STACK_FILTER: { description: 'Filter stack traces' }, TESTOMATIO_STACK_PASSED: { description: 'Report stack for passed tests' }, TESTOMATIO_STEPS_PASSED: { description: 'Report steps for passed tests' }, TESTOMATIO_SUITE: { description: 'Suite ID for new tests' }, TESTOMATIO_TOKEN: { description: 'API Token (alias for TESTOMATIO)' }, TESTOMATIO_TITLE: { description: 'Title for the test run' }, TESTOMATIO_URL: { description: 'Testomat.io URL (custom instance)' }, TESTOMATIO_WORKDIR: { description: 'Working directory for relative paths' }, }, s3: { S3_ACCESS_KEY_ID: { description: 'S3 access key ID' }, S3_BUCKET: { description: 'S3 bucket name' }, S3_ENDPOINT: { description: 'S3 endpoint URL' }, S3_FORCE_PATH_STYLE: { description: 'S3 force path style' }, S3_KEY: { description: 'S3 access key' }, S3_PREFIX: { description: 'S3 key prefix' }, S3_REGION: { description: 'S3 region' }, S3_SECRET: { description: 'S3 secret key' }, S3_SECRET_ACCESS_KEY: { description: 'S3 secret access key' }, S3_SESSION_TOKEN: { description: 'S3 session token' }, }, }; const sensitiveVars = new Set([ 'TESTOMATIO', 'TESTOMATIO_TOKEN', 'TESTOMATIO_API_KEY', 'S3_KEY', 'S3_SECRET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'S3_SESSION_TOKEN', ]); const envVars = { testomatio: processEnvironmentVariables(allVars.testomatio, sensitiveVars), s3: processEnvironmentVariables(allVars.s3, sensitiveVars), }; return envVars; } /** * Prepares test steps for HTML report display * @param {object} test - Test object * @param {Array} stepsTree - Steps tree from logs * @param {Array} allStepLines - All step lines from message/logs/stack * @param {string} fallbackStepsText - Fallback steps text */ function prepareTestStepsForReport(test, stepsTree, allStepLines, fallbackStepsText) { if (Array.isArray(test.steps) && test.steps.length) { const userSteps = filterUserStepsTree(test.steps); test.stepsArray = userSteps; if (userSteps.length) { test.steps = userSteps .map(s => (0, utils_js_1.formatStep)(s)) .flat() .join('\n'); } else if (stepsTree) { test.stepsArray = stepsTree; test.steps = stepsTree.map(s => (0, utils_js_1.formatStep)(s)).flat().join('\n'); } else if (fallbackStepsText) { test.stepsArray = allStepLines.map(t => ({ category: 'user', title: t, duration: 0 })); test.steps = fallbackStepsText; } else { test.steps = ''; test.stepsArray = []; } } else if (stepsTree) { test.stepsArray = stepsTree; test.steps = stepsTree.map(s => (0, utils_js_1.formatStep)(s)).flat().join('\n'); } else if (fallbackStepsText) { test.stepsArray = allStepLines.map(t => ({ category: 'user', title: t, duration: 0 })); test.steps = fallbackStepsText; } else if (typeof test.steps === 'string' && test.steps.trim()) { test.stepsArray = []; test.steps = String(test.steps).replace((0, utils_js_1.ansiRegExp)(), '').trim(); } else { test.steps = ''; test.stepsArray = []; } } /** * Normalizes artifacts from different sources into a unified format * @param {object} test - Test object with artifacts * @returns {Array} - Normalized artifacts array with trace files filtered out */ function normalizeArtifacts(test) { test.artifacts = test.artifacts || []; test.meta = test.meta || {}; const allArtifacts = [ ...(test.artifacts || []), ...(test.meta?.attachments || []), ...(test.manuallyAttachedArtifacts || []), ...(test.files || []), ...(test.meta?.manuallyAttachedArtifacts || []), ]; return allArtifacts .map(artifact => { if (typeof artifact === 'string') { const abs = path_1.default.isAbsolute(artifact) ? artifact : path_1.default.resolve(process.cwd(), artifact); const href = artifact.startsWith('file://') ? artifact : (0, file_url_1.default)(abs, { resolve: true }); const base = path_1.default.basename(abs); return { name: base, title: base, path: href, fsPath: abs, relativePath: artifact, }; } if (artifact?.path) { const raw = String(artifact.path); const isFileUrl = raw.startsWith('file://'); const abs = isFileUrl ? null : path_1.default.isAbsolute(raw) ? raw : path_1.default.resolve(process.cwd(), raw); const href = isFileUrl ? raw : (0, file_url_1.default)(abs, { resolve: true }); const base = abs ? path_1.default.basename(abs) : artifact.name || artifact.title || 'attachment'; return { ...artifact, name: artifact.name || artifact.title || base, title: artifact.title || artifact.name || base, path: href, fsPath: abs || artifact.fsPath || null, relativePath: artifact.relativePath || raw, }; } return artifact; }) .filter(Boolean) .filter(artifact => { const isTrace = (artifact.title === 'trace' || artifact.name === 'trace') && (artifact.type === 'application/zip' || artifact.path?.endsWith('.zip') || artifact.relativePath?.endsWith('.zip')); return !isTrace; }); } /** * Loads trace files from test.files and converts them to base64 data URLs * @param {object} test - Test object with files array */ function loadTracesFromFiles(test) { if (!test.traces && test.files && Array.isArray(test.files) && test.files.length > 0) { const traceFiles = test.files.filter(f => f.path && f.path.trim().length > 0 && (f.title === 'trace' || f.name === 'trace') && (f.type === 'application/zip' || f.path.endsWith('.zip'))); if (traceFiles.length > 0) { const traceDataList = []; traceFiles.forEach(f => { if (!fs_1.default.existsSync(f.path)) { console.warn(`Trace file not found: ${f.path}`); return; } try { const fileBuffer = fs_1.default.readFileSync(f.path, null); if (!fileBuffer || fileBuffer.length === 0) { console.warn(`Empty trace file: ${f.path}`); return; } const base64 = fileBuffer.toString('base64'); let filename = 'trace.zip'; try { filename = path_1.default.basename(f.path); } catch (e) { console.warn(`Could not extract filename from ${f.path}, using default`); } const dataUrl = `data:application/zip;base64,${base64}`; traceDataList.push({ dataUrl, name: filename }); } catch (e) { console.error(`Failed to convert trace to base64: ${f.path}`, e.message); } }); if (traceDataList.length > 0) { test.traces = traceDataList; } } } } module.exports = HtmlPipe;