UNPKG

@testomatio/reporter

Version:
694 lines (693 loc) 29.6 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 path_1 = __importDefault(require("path")); const picocolors_1 = __importDefault(require("picocolors")); const fs_1 = __importDefault(require("fs")); const fast_xml_parser_1 = require("fast-xml-parser"); const constants_js_1 = require("./constants.js"); const crypto_1 = require("crypto"); const url_1 = require("url"); const nunit_parser_js_1 = require("./junit-adapter/nunit-parser.js"); const utils_js_1 = require("./utils/utils.js"); const index_js_1 = require("./pipe/index.js"); const index_js_2 = __importDefault(require("./junit-adapter/index.js")); const config_js_1 = require("./config.js"); const uploader_js_1 = require("./uploader.js"); // @ts-ignore this line will be removed in compiled code, because __dirname is defined in commonjs const debug = (0, debug_1.default)('@testomatio/reporter:xml'); const ridRunId = (0, crypto_1.randomUUID)(); const TESTOMATIO_URL = process.env.TESTOMATIO_URL || 'https://app.testomat.io'; const { TESTOMATIO_RUNGROUP_TITLE, TESTOMATIO_SUITE, TESTOMATIO_MAX_STACK_TRACE, TESTOMATIO_TITLE, TESTOMATIO_ENV, TESTOMATIO_RUN, TESTOMATIO_MARK_DETACHED, TESTOMATIO_LEGACY_NUNIT, } = process.env; const options = { ignoreDeclaration: true, ignoreAttributes: false, alwaysCreateTextNode: false, attributeNamePrefix: '', parseTagValue: true, }; const MAX_OUTPUT_LENGTH = parseInt(TESTOMATIO_MAX_STACK_TRACE, 10) || 10000; const reduceOptions = {}; class XmlReader { constructor(opts = {}) { this.requestParams = { apiKey: opts.apiKey || config_js_1.config.TESTOMATIO, url: opts.url || TESTOMATIO_URL, title: TESTOMATIO_TITLE, env: TESTOMATIO_ENV, group_title: TESTOMATIO_RUNGROUP_TITLE, detach: TESTOMATIO_MARK_DETACHED, // batch uploading is implemented for xml already isBatchEnabled: false, }; this.runId = opts.runId || TESTOMATIO_RUN; this.adapter = (0, index_js_2.default)(opts.lang?.toLowerCase(), opts); if (!this.adapter) throw new Error('XML adapter for this format not found'); this.opts = opts || {}; this.store = {}; this.pipesPromise = (0, index_js_1.pipesFactory)(opts, this.store); this.parser = new fast_xml_parser_1.XMLParser(options); this.tests = []; this.stats = {}; this.stats.language = opts.lang?.toLowerCase(); this.uploader = new uploader_js_1.S3Uploader(); // Enhanced NUnit parsing - enabled by default for NUnit XML // Can be disabled via opts.enhancedNunit = false or TESTOMATIO_LEGACY_NUNIT=1 this.enhancedNunit = !(0, utils_js_1.transformEnvVarToBoolean)(TESTOMATIO_LEGACY_NUNIT); this.groupParameterized = opts.groupParameterized !== false; // Default true, can be disabled // @ts-ignore const packageJsonPath = path_1.default.resolve(__dirname, '..', 'package.json'); this.version = JSON.parse(fs_1.default.readFileSync(packageJsonPath).toString()).version; console.log(constants_js_1.APP_PREFIX, `Testomatio Reporter v${this.version}`); } connectAdapter() { if (this.opts.javaTests) { this.adapter = (0, index_js_2.default)('java', this.opts); return this.adapter; } this.adapter = (0, index_js_2.default)(this.stats.language, this.opts); return this.adapter; } parse(fileName) { let xmlData = fs_1.default.readFileSync(path_1.default.resolve(fileName)).toString(); // we remove too long stack traces const cutRegexes = [ /(<output><!\[CDATA\[)([\s\S]*?)(\]\]><\/output>)/g, /(<system-err><!\[CDATA\[)([\s\S]*?)(\]\]><\/system-err>)/g, /(<system-out><!\[CDATA\[)([\s\S]*?)(\]\]><\/system-out>)/g, ]; for (const regex of cutRegexes) { xmlData = xmlData.replace(regex, (_, p1, p2, p3) => `${p1}${p2.substring(0, MAX_OUTPUT_LENGTH)}${p3}`); } const jsonResult = this.parser.parse(xmlData); let jsonSuite; if (jsonResult.testsuites) { jsonSuite = jsonResult.testsuites; } else if (jsonResult.testsuite) { jsonSuite = jsonResult; } else if (jsonResult.TestRun) { return this.processTRX(jsonResult); } else if (jsonResult['test-run']) { return this.processNUnit(jsonResult['test-run']); } else if (jsonResult.assemblies) { return this.processXUnit(jsonResult.assemblies); } else { console.log(jsonResult); throw new Error("Format can't be parsed"); } return this.processJUnit(jsonSuite); } processJUnit(jsonSuite) { const { testsuite, name, failures, errors } = jsonSuite; const tests = testsuite?.tests || jsonSuite.tests; reduceOptions.preferClassname = this.stats.language === 'python'; const resultTests = processTestSuite(testsuite); const hasFailures = resultTests.filter(t => t.status === 'failed').length > 0; const status = failures > 0 || errors > 0 || hasFailures ? 'failed' : 'passed'; const time = testsuite.time || 0; // debug('time', jsonSuite, time) if (time) { if (!this.stats.duration) this.stats.duration = 0; this.stats.duration += parseFloat(time); } this.tests = this.tests.concat(resultTests); return { create_tests: true, duration: parseFloat(time), failed_count: parseInt(failures, 10), name, passed_count: parseInt(tests, 10) - parseInt(failures, 10), skipped_count: 0, status, tests: resultTests, tests_count: parseInt(tests, 10), }; } processNUnit(jsonSuite) { // Use enhanced NUnit parser if enabled and this is actually NUnit XML if (this.enhancedNunit && this.isNUnitXml(jsonSuite)) { debug('Using enhanced NUnit parser'); return this.processNUnitEnhanced(jsonSuite); } // Fallback to legacy parser for backward compatibility debug('Using legacy NUnit parser'); const { result, total, passed, failed, inconclusive, skipped } = jsonSuite; reduceOptions.preferClassname = this.stats.language === 'python'; const resultTests = processTestSuite(jsonSuite['test-suite']); this.tests = this.tests.concat(resultTests); return { status: result?.toLowerCase(), create_tests: true, tests_count: parseInt(total, 10), passed_count: parseInt(passed, 10), failed_count: parseInt(failed, 10), skipped_count: parseInt(inconclusive + skipped, 10), tests: resultTests, }; } /** * Check if the XML is actually NUnit format (has test-suite hierarchy) * @param {Object} jsonSuite - Parsed XML suite object * @returns {boolean} - True if this is NUnit XML format */ isNUnitXml(jsonSuite) { // NUnit XML has test-suite elements with type attributes if (jsonSuite['test-suite']) { const testSuite = Array.isArray(jsonSuite['test-suite']) ? jsonSuite['test-suite'][0] : jsonSuite['test-suite']; // Check for NUnit-specific test-suite types return (testSuite && testSuite.type && ['Assembly', 'TestSuite', 'TestFixture', 'ParameterizedMethod'].includes(testSuite.type)); } return false; } processNUnitEnhanced(jsonSuite) { debug('Processing NUnit XML with enhanced parser'); try { const nunitParser = new nunit_parser_js_1.NUnitXmlParser({ groupParameterized: this.groupParameterized, ...this.opts, }); const result = nunitParser.parseTestRun(jsonSuite); // Add parsed tests to our collection this.tests = this.tests.concat(result.tests); debug(`Enhanced NUnit parser processed ${result.tests.length} tests`); return result; } catch (error) { debug('Enhanced NUnit parser failed, falling back to legacy parser:', error.message); console.warn(`${constants_js_1.APP_PREFIX} Enhanced NUnit parsing failed, using legacy parser: ${error.message}`); // Fallback to legacy parser this.enhancedNunit = false; return this.processNUnit(jsonSuite); } } processTRX(jsonSuite) { let defs = jsonSuite?.TestRun?.TestDefinitions?.UnitTest; if (!Array.isArray(defs)) defs = [defs].filter(d => !!d); // Parse test definitions const tests = defs.map(td => this._parseTRXTestDefinition(td)); // Parse test results let result = jsonSuite?.TestRun?.Results?.UnitTestResult; if (!Array.isArray(result)) result = [result].filter(d => !!d); const results = result.map(td => this._parseTRXTestResult(td, tests)); debug(results); const counters = jsonSuite?.TestRun?.ResultSummary?.Counters || {}; const failed_count = parseInt(counters.failed, 10) + parseInt(counters.error, 10); const status = failed_count > 0 ? constants_js_1.STATUS.FAILED : constants_js_1.STATUS.PASSED.toString(); this.tests = results.filter(t => !!t.title); return { status, create_tests: !process.env.IGNORE_NEW_TESTS, tests_count: parseInt(counters.total, 10), passed_count: parseInt(counters.passed, 10), skipped_count: parseInt(counters.notExecuted, 10), failed_count, tests: results, }; } _parseTRXTestDefinition(td) { const title = td.name.replace(/\(.*?\)/, '').trim(); const exampleMatch = td.name.match(/\((.*?)\)/); const example = exampleMatch ? { ...exampleMatch[1] .split(',') .map(p => p.trim()) .filter(p => p !== ''), } : null; const suite = td.TestMethod.className.split(', ')[0].split('.'); const suite_title = suite.pop(); // Convert namespace to file path for C# const file = `${suite.join('/')}.cs`; return { title, // Base name without parameters for test import example, // Parameters object for parameterized tests file, // File path with .cs extension description: td.Description, suite_title, id: td.Execution.id, }; } _parseTRXTestResult(td, tests) { const test = tests.find(t => t.id === td.executionId) || {}; const result = { suite_title: test.suite_title, title: test.title?.trim(), file: test.file, description: test.description, code: test.code, run_time: parseFloat(td.duration) * 1000, stack: td.Output?.StdOut || '', files: td?.ResultFiles?.ResultFile?.map(rf => rf.path), create: true, overwrite: true, }; // Add example for parameterized tests if (test.example) { result.example = test.example; } // Map TRX status to Testomat.io status result.status = this._mapTRXStatus(td.outcome); return result; } _mapTRXStatus(outcome) { switch (outcome) { case 'Passed': return constants_js_1.STATUS.PASSED; case 'Failed': return constants_js_1.STATUS.FAILED; case 'Skipped': return constants_js_1.STATUS.SKIPPED; default: return constants_js_1.STATUS.PASSED; } } processXUnit(assemblies) { const tests = []; assemblies = Array.isArray(assemblies.assembly) ? assemblies.assembly : [assemblies.assembly]; assemblies.forEach(assembly => { const { collection } = assembly; const suites = Array.isArray(collection) ? collection : [collection]; suites.forEach(suite => { const { test } = suite; if (!test) return; const cases = Array.isArray(test) ? test : [test]; cases.forEach(testCase => { const { type, time, result } = testCase; let message = ''; let stack = ''; if (testCase.failure) { message = testCase.failure.message; stack = testCase.failure['stack-trace']; } if (testCase.reason) { message = testCase.reason.message; } let status = constants_js_1.STATUS.PASSED; if (result === 'Pass') status = constants_js_1.STATUS.PASSED; if (result === 'Fail') status = constants_js_1.STATUS.FAILED; if (result === 'Skip') status = constants_js_1.STATUS.SKIPPED; const pathParts = type.split('.'); const suite_title = pathParts[pathParts.length - 1]; const file = pathParts.slice(0, -1).join('/'); const title = testCase.method || testCase.name.split('.').pop(); const run_time = parseFloat(time) * 1000; tests.push({ create: true, stack, message, file, status, title, suite_title, run_time, retry: false, }); }); }); }); const hasFailures = tests.filter(t => t.status === constants_js_1.STATUS.FAILED).length > 0; const status = hasFailures ? constants_js_1.STATUS.FAILED : constants_js_1.STATUS.PASSED; this.tests = tests; debug(tests); return { status, create_tests: true, name: 'xUnit', tests_count: tests.length, passed_count: tests.filter(t => t.status === constants_js_1.STATUS.PASSED).length, failed_count: tests.filter(t => t.status === constants_js_1.STATUS.FAILED).length, skipped_count: tests.filter(t => t.status === constants_js_1.STATUS.SKIPPED).length, tests, }; } calculateStats() { this.stats = { ...this.stats, detach: this.requestParams.detach, status: 'passed', create_tests: true, tests_count: 0, passed_count: 0, failed_count: 0, skipped_count: 0, }; this.tests.forEach(t => { this.stats.tests_count++; if (t.status === 'passed') this.stats.passed_count++; if (t.status === 'failed') this.stats.failed_count++; }); if (this.stats.failed_count) this.stats.status = 'failed'; return this.stats; } fetchSourceCode() { this.tests.forEach(t => { try { const file = this.adapter.getFilePath(t); if (!file) return; if (!this.stats.language) { if (file.endsWith('.php')) this.stats.language = 'php'; if (file.endsWith('.py')) this.stats.language = 'python'; if (file.endsWith('.java')) this.stats.language = 'java'; if (file.endsWith('.rb')) this.stats.language = 'ruby'; if (file.endsWith('.js')) this.stats.language = 'js'; if (file.endsWith('.ts')) this.stats.language = 'ts'; if (file.endsWith('.cs')) this.stats.language = 'csharp'; } if (!fs_1.default.existsSync(file)) { debug('Failed to open file with the source code', file); return; } const contents = fs_1.default.readFileSync(file).toString(); t.code = (0, utils_js_1.fetchSourceCode)(contents, { ...t, lang: this.stats.language }); if (t.code) debug('Fetched code for test %s', t.title); t.test_id = (0, utils_js_1.fetchIdFromCode)(t.code, { lang: this.stats.language }); if (t.test_id) debug('Fetched test id %s for test %s', t.test_id, t.title); } catch (err) { debug(err); } }); } formatTests() { this.tests.forEach(t => { if (t.file) { t.file = t.file.replace(process.cwd() + path_1.default.sep, ''); } this.adapter.formatTest(t); t.title = (0, utils_js_1.humanize)(t.title); }); } formatErrors() { this.tests .filter(t => !!t.stack) .forEach(t => { t.stack = this.formatStack(t); t.message = this.adapter.formatMessage(t); }); } formatStack(t) { const stack = this.adapter.formatStack(t); const sourcePart = (0, utils_js_1.fetchSourceCodeFromStackTrace)(stack); if (!sourcePart) return stack; const separator = picocolors_1.default.bold(picocolors_1.default.red('################[ Failure ]################')); return `${stack}\n\n${separator}\n${(0, utils_js_1.fetchSourceCodeFromStackTrace)(stack)}`; } async uploadArtifacts() { for (const test of this.tests.filter(t => !!t.stack)) { let files = []; if (!test.files?.length) continue; files = test.files.map(f => (path_1.default.isAbsolute(f) ? f : path_1.default.join(process.cwd(), f))); if (!files.length) continue; const runId = this.runId || this.store.runId || Date.now().toString(); test.artifacts = await Promise.all(files.map(f => this.uploader.uploadFileByPath(f, [runId, path_1.default.basename(f)]))); console.log(constants_js_1.APP_PREFIX, `🗄️ Uploaded ${picocolors_1.default.bold(`${files.length} artifacts`)} for test ${test.title}`); } } async createRun() { const runParams = { api_key: this.requestParams.apiKey, title: this.requestParams.title, env: this.requestParams.env, group_title: this.requestParams.group_title, isBatchEnabled: this.requestParams.isBatchEnabled, }; debug('Run', runParams); this.pipes = this.pipes || (await this.pipesPromise); const run = await Promise.all(this.pipes.map(p => p.createRun(runParams))); this.uploader.checkEnabled(); return run; } /** * Calculate the approximate size of data in bytes (JSON stringified length) * @param {Object} data - Data to measure * @returns {number} Size in bytes */ #getObjectSize(data) { const body = JSON.stringify(data); return new TextEncoder().encode(body).length; } /** * Split tests array into chunks based on data size * @param {Array} tests - Array of tests to split * @returns {Array<Array>} Array of test chunks */ #splitTestsIntoChunks(tests) { const maxSizeBytes = 1 * 1024 * 1024; const chunks = []; let currentChunk = []; let currentChunkSize = 0; for (const test of tests) { const testSize = this.#getObjectSize(test); const wouldExceedSize = currentChunkSize + testSize > maxSizeBytes; if (wouldExceedSize) { if (currentChunk.length > 0) { chunks.push(currentChunk); } currentChunk = []; currentChunkSize = 0; } currentChunk.push(test); currentChunkSize += testSize; } if (currentChunk.length > 0) { chunks.push(currentChunk); } return chunks; } async uploadData() { await this.uploadArtifacts(); this.calculateStats(); this.connectAdapter(); this.fetchSourceCode(); this.formatErrors(); this.formatTests(); this.pipes = this.pipes || (await this.pipesPromise); // Create run before uploading tests to ensure runId is set await this.createRun(); if (!this.tests || !Array.isArray(this.tests) || this.tests.length === 0) { debug('No tests to upload, finishing run'); const finishData = { api_key: this.requestParams.apiKey, status: 'finished', duration: this.stats.duration, detach: this.requestParams.detach, }; return Promise.all(this.pipes.map(p => p.finishRun(finishData))); } const testChunks = this.#splitTestsIntoChunks(this.tests); const totalChunks = testChunks.length; const totalTests = this.tests.length; debug(`Split ${totalTests} tests into ${totalChunks} chunks (max 1MB per chunk)`); let uploadedTests = 0; for (let i = 0; i < testChunks.length; i++) { const chunk = testChunks[i]; const chunkNum = i + 1; if (totalChunks > 1) { debug(`Uploading chunk ${chunkNum}/${totalChunks} (${chunk.length} tests)`); } for (const test of chunk) { await Promise.all(this.pipes.map(p => p.addTest(test))); } await Promise.all(this.pipes.map(p => p.sync())); uploadedTests += chunk.length; debug(`Uploaded ${uploadedTests}/${totalTests} tests`); } if (totalChunks > 1) { console.log(constants_js_1.APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests in ${totalChunks} chunks`); } else { console.log(constants_js_1.APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests`); } const finishData = { api_key: this.requestParams.apiKey, status: 'finished', duration: this.stats.duration, detach: this.requestParams.detach, }; debug('Finishing run with status:', finishData.status); return Promise.all(this.pipes.map(p => p.finishRun(finishData))); } async _finishRun() { this.pipes = this.pipes || (await this.pipesPromise); return Promise.all(this.pipes.map(p => p.finishRun({ status: 'finished' }))); } } module.exports = XmlReader; function reduceTestCases(prev, item) { let testCases = item.testcase; if (!testCases) testCases = item['test-case']; if (!Array.isArray(testCases)) { testCases = [testCases]; } // suite inside test case const testCase = item['test-suite']?.['test-case']; if (testCase) { const nestedCases = Array.isArray(testCase) ? testCase : [testCase]; testCases.push(...nestedCases); } const suiteOutput = item['system-out'] || item.output || item.log || ''; const suiteErr = item['system-err'] || item.output || item.log || ''; testCases .filter(t => !!t) .forEach(testCaseItem => { const file = testCaseItem.file || item.filepath || item.fullname || item.package || ''; let stack = ''; let message = ''; if (testCaseItem.error) stack = testCaseItem.error; if (testCaseItem.failure) stack = testCaseItem.failure; if (testCaseItem?.failure?.['stack-trace']) stack = testCaseItem.failure['stack-trace']; if (testCaseItem?.failure?.message) message = testCaseItem.failure.message; if (testCaseItem?.error?.message) message = testCaseItem.error.message; if (testCaseItem.failure && testCaseItem.failure['#text']) stack = testCaseItem.failure['#text']; if (testCaseItem.error && testCaseItem.error['#text']) stack = testCaseItem.error['#text']; if (!message) message = stack.trim().split('\n')[0]; const isParametrized = item.type === 'ParameterizedMethod'; const preferClassname = reduceOptions.preferClassname || isParametrized; // SpecFlow config let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem); let example = null; const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname; title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname; tags ||= []; const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/); if (exampleMatches) { example = { ...exampleMatches[1] .split(',') .map(v => v.trim().replace(/[^\w\s-]/g, '')) .filter(v => v !== ''), }; title = title.replace(/\(.*?\)/, '').trim(); } stack = `${testCaseItem['system-out'] || testCaseItem.output || testCaseItem.log || ''}\n\n${stack}\n\n${suiteOutput}\n\n${suiteErr}`.trim(); if (!testId) testId = (0, utils_js_1.fetchIdFromOutput)(stack); if (tags?.length && !testId) { testId = tags .filter(t => t.startsWith('T')) .map(t => `@${t}`) .find(t => t.match(utils_js_1.TEST_ID_REGEX)) ?.slice(2); } let status = constants_js_1.STATUS.PASSED.toString(); if ('failure' in testCaseItem || 'error' in testCaseItem) status = constants_js_1.STATUS.FAILED; if ('skipped' in testCaseItem) status = constants_js_1.STATUS.SKIPPED; if (testCaseItem.result && Object.values(constants_js_1.STATUS).includes(testCaseItem.result.toLowerCase())) { status = testCaseItem.result.toLowerCase(); } let rid = null; if (testCaseItem.id) rid = `${ridRunId}-${testCaseItem.id}`; // Extract attachments let files = []; if (testCaseItem.attachments) { const attachments = Array.isArray(testCaseItem.attachments.attachment) ? testCaseItem.attachments.attachment : [testCaseItem.attachments.attachment]; files = attachments.filter(a => a && a.filePath).map(a => a.filePath); } // Extract files from stack trace using existing utility const stackFiles = (0, utils_js_1.fetchFilesFromStackTrace)(stack); files = [...new Set([...files, ...stackFiles])]; // Remove duplicates prev.push({ rid, file, stack, example, tags, create: true, test_id: testId, message, line: testCaseItem.lineno, // seconds are used in junit reports, but ms are used by testomatio run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000, status, title, root_suite_id: TESTOMATIO_SUITE, suite_title: suiteTitle, files, retry: false, }); }); return prev; } function processTestSuite(testsuite) { if (!testsuite) return []; if (testsuite.testsuite) return processTestSuite(testsuite.testsuite); if (testsuite['test-suite'] && !testsuite['test-case']) return processTestSuite(testsuite['test-suite']); let suites = testsuite; if (!Array.isArray(testsuite)) { suites = [testsuite]; } const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']); return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat(); } function fetchProperties(item) { const tags = []; let title = ''; if (!item.properties) return {}; // Handle both single property and array of properties const properties = Array.isArray(item.properties.property) ? item.properties.property : [item.properties.property].filter(Boolean); const prop = properties.find(p => p.name === 'Description'); if (prop) title = prop.value; let testId = properties.find(p => p.name === 'ID')?.value; if (testId?.startsWith('@')) testId = testId.slice(1); if (testId?.startsWith('T')) testId = testId.slice(1); properties.filter(p => p.name === 'Category').forEach(p => tags.push(p.value)); return { title, tags, testId }; }