UNPKG

cypress-junit-reporter-shp

Version:

Cypress Reporter | Enhances your Cypress test suite with the cypress-xray-junit-reporter a specialized custom reporter designed to seamlessly generating comprehensive XRay-compatible JUnit-style XML reports, complete with embedded screenshots on test fail

735 lines (629 loc) 22.6 kB
// Using strict mode is a suggested practice because it helps to write more robust code 'use strict'; const xml = require('xml'); const { reporters } = require('mocha'); const { Base } = reporters; const fs = require('fs'); const path = require('path'); // const debug = require('debug')('cypress-junit-reporter-shp'); const mkdirp = require('mkdirp'); const md5 = require('md5'); const stripAnsi = require('strip-ansi'); const createStatsCollector = require("mocha/lib/stats-collector"); const logMessages = require('./src/logMessages'); const { v4: uuidv4 } = require('uuid'); // Save timer references so that times are correct even if Date is stubbed. // See https://github.com/mochajs/mocha/issues/237 const startTime = global.Date; // A subset of invalid characters as defined in http://www.w3.org/TR/xml/#charsets that can occur in e.g. stacktraces // regex lifted from https://github.com/MylesBorins/xml-sanitizer/ (licensed MIT) const INVALID_CHARACTERS_REGEX = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007f-\u0084\u0086-\u009f\uD800-\uDFFF\uFDD0-\uFDFF\uFFFF\uC008]/g; //eslint-disable-line no-control-regex const testTotals = { registered: 0, skipped: 0, }; // number of whitespace indentation characters let wsNum = 0 let testOrderedByUUID = []; let skippedTestcase = [] function findReporterOptions(options) { if (!options) { return {}; } if (options.reporterOptions) { return options.reporterOptions; } // this is require to handle .mocharc.js files return Object.keys(options).filter(function (key) { return key.startsWith('reporterOptions.'); }) .reduce(function (reporterOptions, key) { reporterOptions[key.substring('reporterOptions.'.length)] = options[key]; return reporterOptions; }, {}); } function configureDefaults(options) { const config = findReporterOptions(options); config.mochaFile = getSetting(config.mochaFile, 'test-results.xml'); config.jenkinsMode = getSetting(config.jenkinsMode, false); config.xrayMode = getSetting(config.xrayMode, true); config.shortenLogMode = getSetting(config.shortenLogMode, false); config.attachScreenshot = getSetting(config.attachScreenshot, false); config.toConsole = !!config.toConsole; config.rootSuiteTitle = config.rootSuiteTitle || 'Root Suite'; config.testsuitesTitle = config.testsuitesTitle || 'Mocha Tests'; config.suiteTitleSeparatedBy = config.suiteTitleSeparatedBy || ' '; if (config.jenkinsMode) { updateOptionsForJenkinsMode(config); } return config; } function updateOptionsForJenkinsMode(options) { if (options.useFullSuiteTitle === undefined) { options.useFullSuiteTitle = true; } if (options.testcaseSwitchClassnameAndName === undefined) { options.testcaseSwitchClassnameAndName = true; } if (options.suiteTitleSeparatedBy === undefined) { options.suiteTitleSeparatedBy = '.'; } } function getSetting(value, defaultVal) { if (value !== undefined) { return value; } return defaultVal; } function defaultSuiteTitle(suite) { if (suite.root && suite.title === '') { return stripAnsi(this._options.rootSuiteTitle); } return stripAnsi(suite.title); } function fullSuiteTitle(suite) { let parent = suite.parent; const title = [suite.title]; while (parent) { if (parent.root && parent.title === '') { title.unshift(this._options.rootSuiteTitle); } else { title.unshift(parent.title); } parent = parent.parent; } return stripAnsi(title.join(this._options.suiteTitleSeparatedBy)); } function isInvalidSuite(suite) { return (!suite.root && suite.title === '') || (suite.tests.length === 0 && suite.suites.length === 0); } function generateSuiteProperties(options) { const props = options.properties; if (!props) { return []; } return Object.keys(props).reduce(function (properties, name) { const value = props[name]; properties.push({ property: { _attr: { name: name, value: value } } }); return properties; }, []); } function getJenkinsClassname(test, options) { let parent = test.parent; const titles = []; while (parent) { parent.title && titles.unshift(parent.title); parent = parent.parent; } if (options.jenkinsClassnamePrefix) { titles.unshift(options.jenkinsClassnamePrefix); } return titles.join(options.suiteTitleSeparatedBy); } /** * JUnit reporter for mocha.js. * @module cypress-xray-junit-reporter * @param {EventEmitter} runner - the test runner * @param {Object} options - mocha options */ function CypressXrayJunitReporter(runner, options) { createStatsCollector(runner); // Reset total tests counters testTotals.registered = 0; testTotals.skipped = 0; this._options = configureDefaults(options); const shortenLogMode = this._options.shortenLogMode const xrayMode = this._options.xrayMode this._runner = runner; this._generateSuiteTitle = this._options.useFullSuiteTitle ? fullSuiteTitle : defaultSuiteTitle; this._antId = 0; this._Date = (options?.Date) || startTime; const testsuites = []; this._testsuites = testsuites; function findSuite(suiteTitle, suiteNumber) { if (typeof suiteTitle === 'string' && typeof suiteNumber === 'number') { // control on the name of the passed suite if (testsuites[suiteNumber]?.testsuite[0]._attr.name === suiteTitle) { // if it corresponds to the passed suite title, return it return testsuites[suiteNumber]?.testsuite; } else if (suiteTitle !== '' && testsuites[suiteNumber]?.testsuite[0]._attr.name !== 'Root Suite') { throw new Error('Suite counting error: suite title not corresponding.\nExpect: ' + suiteTitle + ', got: ' + testsuites[suiteNumber]?.testsuite[0]._attr.name); } } else if (suiteNumber === undefined) { return testsuites[testsuites.length - 1].testsuite; } else { throw new Error('Invalid argument passed to findSuite\nPassed: ' + suiteTitle + ' & ' + suiteNumber); } } const processTests = (tests, config_owner) => { wsNum++; tests.forEach((test) => { const err = test.err; const testCase = this.getTestcaseData(test, config_owner, err); logMessages.analyzedTestcase(shortenLogMode, wsNum, test.title); findSuite(test.parent.title, testOrderedByUUID.indexOf(test.parent.uuid)).push(testCase); // if (test.state !== 'skipped' && test.state !== 'pending') { // // if (missingJiraKey.includes(test.title)) { // // logMessages.warning(shortenLogMode, wsNum, `Missing jira key in testcase: ${test.title}`); // // logMessages.skippedTestcase(shortenLogMode, wsNum, test.title); // // skippedTestcase.push(test.title); // // } else { // // logMessages.analyzedTestcase(shortenLogMode, wsNum, test.title); // // findSuite(test.parent.title, testOrderedByUUID.indexOf(test.parent.uuid)).push(testCase); // // } // } else { // logMessages.warning(shortenLogMode, wsNum, `Missing jira key in testcase carlos : ${test.state} ${test.title} `); // logMessages.skippedTestcase(shortenLogMode, wsNum, test.title); // skippedTestcase.push(test.title); // } }); wsNum--; if (missingJiraKey.length > 0) { logMessages.missingError(shortenLogMode, wsNum) missingJiraKey = [] } if (skippedTestcase.length > 0) { logMessages.skippedError(shortenLogMode, skippedTestcase) skippedTestcase = [] } }; let testsuiteNum = 0 const processSuites = (suites, config_owner) => { logMessages.foundingSuite(shortenLogMode, wsNum, suites.length) wsNum++ suites.forEach((suite) => { logMessages.analyzingSuite(shortenLogMode, wsNum, suite.title) config_owner = config_owner ? config_owner : parseOwnerFromConfig(suite); if (suite.suites.length && !suite.tests.length) { processSuites(suite.suites, config_owner); testsuiteNum++ } else if (suite.tests.length && !suite.suites.length) { processTests(suite.tests, config_owner); } else if (suite.suites.length && suite.tests.length) { processTests(suite.tests, config_owner) processSuites(suite.suites, config_owner); } else { throw new Error('Config Error'); } logMessages.endSuite(wsNum, suite.title) }); wsNum-- }; function mapSuites(suite, testTotals) { const suites = suite.suites.reduce((acc, testsuite) => { const mappedSuites = mapSuites(testsuite, testTotals); if (mappedSuites) { acc.push(mappedSuites); } return acc; }, []); const mappedSuite = { ...suite, suites }; return mappedSuite; } // get functionality from the Base reporter Base.call(this, runner); // remove old results this._runner.on('start', function () { wsNum = 0 testOrderedByUUID = []; skippedTestcase = [] if (fs.existsSync(this._options.mochaFile)) { fs.unlinkSync(this._options.mochaFile); } }.bind(this)); this._onSuiteBegin = function (suite) { if (!isInvalidSuite(suite)) { const uuidSuite = uuidv4();; suite.uuid = uuidSuite; testOrderedByUUID.push(uuidSuite); testsuites.push(this.getTestsuiteData(suite)); } }; this._runner.on('suite', function (suite) { // allow tests to mock _onSuiteBegin return this._onSuiteBegin(suite); }.bind(this)); this._onSuiteEnd = function (suite) { if (!isInvalidSuite(suite) && suite.tests.length !== 0) { const testsuite = findSuite(suite.title, testOrderedByUUID.indexOf(suite.uuid)); if (testsuite) { const start = testsuite[0]._attr.timestamp; testsuite[0]._attr.time = this._Date.now() - start; } } }; this._runner.on('suite end', function (suite) { // allow tests to mock _onSuiteEnd return this._onSuiteEnd(suite); }.bind(this)); this._runner.on('end', function () { const rootSuite = mapSuites(this.runner.suite, testTotals); logMessages.startingPlugin(xrayMode) const config_owner = parseOwnerFromConfig(rootSuite); processSuites(rootSuite.suites, config_owner) logMessages.endPlugin() this.flush(testsuites); }.bind(this)); } /** * Produces an xml node for a test suite * @param {Object} suite - a test suite * @return {Object} - an object representing the xml node */ CypressXrayJunitReporter.prototype.getTestsuiteData = function (suite) { const _attr = { name: this._generateSuiteTitle(suite), timestamp: this._Date.now(), owner: parseOwnerFromConfig(suite), tests: suite.tests.length }; const testsuite = { testsuite: [{ _attr: _attr }] }; if (suite.file) { if (suite.file.includes('\\') || suite.file.includes('/')) { const separator = suite.file.includes('\\') ? '\\' : '/'; testsuite.testsuite[0]._attr.file = suite.file.split(separator).pop() } else { testsuite.testsuite[0]._attr.file = suite.file } } const properties = generateSuiteProperties(this._options); if (properties.length) { testsuite.testsuite.push({ properties: properties }); } return testsuite; }; let missingJiraKey = [] function addPropertyJiraKey(jiraKey, properties) { properties.push({ property: { _attr: { name: 'test_key', value: jiraKey } } }) } function addPropertyOwner(owner, properties) { properties.push({ property: { _attr: { name: 'owner', value: owner } } }) } function parseJiraKeyFromConfig(test) { const testConfig = test._testConfig if (!testConfig) { return [] } const jiraKey = testConfig['jiraKey'] ? testConfig.jiraKey : testConfig.unverifiedTestConfig?.jiraKey if (!jiraKey) { return [] } return jiraKey } function getSuiteConfig(suite, level) { level = level == undefined ? 0 : level console.log("buscando config do suite pai") console.log(suite.order) console.log(suite._testConfig) let config = suite._testConfig; if (!config) { console.log("buscando config do parent") console.log(suite.parent?._testConfig?.owner); if (suite.parent) { config = suite.parent?._testConfig } if (!config) { //tenta encontrar nos suites console.log("buscando config do suite filho") if (suite.suites && suite.suites.length > 0) { return getSuiteConfig(suite.suites[0]) } else return null } else return null; } return config; } function parseOwnerFromConfig(test) { let testConfig = getSuiteConfig(test) if (!testConfig) { return "" } const owner = testConfig['owner'] ? testConfig.owner : testConfig.unverifiedTestConfig?.owner if (!owner) { return "" } return owner } function addPropertyScreenshot(screenshot, properties) { const fileName = screenshot.name const base64 = screenshot.base64 properties.push({ property: [{ _attr: { name: 'testrun_evidence' } }, { item: [{ _attr: { name: fileName } }, base64] }] }) } function getErrorMsg(testcase) { const failures = testcase.testcase.filter(function (item) { if (item.failure) { return item.failure; } }); if (failures.length) { const message = failures[0].failure._cdata; return message } } function addPropertyMessage(message, properties) { if (message) { properties.push({ property: { _attr: { name: 'testrun_comment' }, _cdata: '{color:#ff0000}' + message + '{color}' } }) } } function getScreen(test) { const path = test.screenshot if (path && test.state !== 'passed') { const base64 = getBase64(path) const name = path.split('/').pop() const failureObj = { name: name, base64: base64 } return failureObj } } function getBase64(path) { return fs.readFileSync(path, { encoding: 'base64' }) } /** * Produces an xml config for a given test case. * @param {object} test - test case * @param {object} err - if test failed, the failure object * @returns {object} */ CypressXrayJunitReporter.prototype.getTestcaseData = function (test, config_owner, err) { const xrayMode = this._options.xrayMode const attachScreenshot = this._options.attachScreenshot const jenkinsMode = this._options.jenkinsMode; const flipClassAndName = this._options.testcaseSwitchClassnameAndName; const name = stripAnsi(jenkinsMode ? getJenkinsClassname(test, this._options) : test.fullTitle()); const classname = stripAnsi(test.title); config_owner = config_owner ? config_owner : parseOwnerFromConfig(test) const testcase = { testcase: [{ _attr: { name: name, time: (typeof test.duration === 'undefined') ? 0 : test.duration / 1000, owner: config_owner, classname: classname } }] }; // We need to merge console.logs into one <system-out> - // see JUnit schema (only accepts 1 <system-out> per test). let systemOutLines = []; if (this._options.outputs && (test.consoleOutputs && test.consoleOutputs.length > 0)) { systemOutLines = systemOutLines.concat(test.consoleOutputs); } if (systemOutLines.length > 0) { testcase.testcase.push({ 'system-out': this.removeInvalidCharacters(stripAnsi(systemOutLines.join('\n'))) }); } if (this._options.outputs && (test.consoleErrors && test.consoleErrors.length > 0)) { testcase.testcase.push({ 'system-err': this.removeInvalidCharacters(stripAnsi(test.consoleErrors.join('\n'))) }); } if (err) { let message; if (err.message && typeof err.message.toString === 'function') { message = err.message + ''; } else if (typeof err.inspect === 'function') { message = err.inspect() + ''; } else { message = ''; } let failureMessage = err.stack || message; if (!Base.hideDiff && err.expected !== undefined) { const oldUseColors = Base.useColors; Base.useColors = false; failureMessage += "\n" + Base.generateDiff(err.actual, err.expected); Base.useColors = oldUseColors; } const failureElement = { _attr: { message: this.removeInvalidCharacters(message) || '', type: err.name || '' }, _cdata: this.removeInvalidCharacters(failureMessage) }; testcase.testcase.push({ failure: failureElement }); } let attributes let properties = [] if (xrayMode) { const jiraKey = parseJiraKeyFromConfig(test) if (jiraKey.length) { addPropertyJiraKey(jiraKey, properties) } else { missingJiraKey.push(test.title) } } const owner = parseOwnerFromConfig(test) if (owner.length) { addPropertyOwner(owner, properties) } // if (attachScreenshot) { // const path = test.screenshot // const screenshot = getScreen(test) // if (path && test.state !== 'passed') { // const fileName = path.split('/').slice(-2).join("/") //"nome e a ultima pasta" // if (fileName.length) { // // addPropertyScreenshot(screenshot, properties) // // addTestCaseScreenshot(screenshot, testcase) // let attachment = `[[ATTACHMENT|data:image/png;base64,${screenshot.base64}]]` // testcase.testcase.push({ 'system-out': { // _attr: { name: 'testrun_attachment' }, // _cdata: attachment // }}); // // testcase.testcase.push([ // // { // // "system-out": [{ // // _attr: { name: 'testrun_evidence' }, // // fileName // // }] // // } // // ]) // } // } // const errMessage = getErrorMsg(testcase) // addPropertyMessage(errMessage, properties) // } if (properties.length) { testcase.testcase.push({ properties, }) } return testcase; }; function countTest(item, array) { // Use the reduce() method to count the occurrences of the item property return array.reduce((count, element) => { // Increment the count if the object has the item property return count + (item in element ? 1 : 0); }, 0); } /** * @param {string} input * @returns {string} without invalid characters */ CypressXrayJunitReporter.prototype.removeInvalidCharacters = function (input) { if (!input) { return input; } return input.replace(INVALID_CHARACTERS_REGEX, ''); }; /** * Writes xml to disk and ouputs content if "toConsole" is set to true. * @param {Array.<Object>} testsuites - a list of xml configs */ CypressXrayJunitReporter.prototype.flush = function (testsuites) { this._xml = this.getXml(testsuites); const reportFilename = this.formatReportFilename(this._xml, testsuites); this.writeXmlToDisk(this._xml, reportFilename); if (this._options.toConsole === true) { console.log(this._xml); // eslint-disable-line no-console } }; /** * Formats the report filename by replacing placeholders * @param {string} xml - xml string * @param {Array.<Object>} testsuites - a list of xml configs */ CypressXrayJunitReporter.prototype.formatReportFilename = function (xml, testsuites) { const sanitize = require('sanitize-filename'); let reportFilename = this._options.mochaFile; if (reportFilename.indexOf('[hash]') !== -1) { reportFilename = reportFilename.replace('[hash]', md5(xml)); } if (reportFilename.indexOf('[testsuitesTitle]') !== -1) { reportFilename = reportFilename.replace('[testsuitesTitle]', sanitize(this._options.testsuitesTitle)); } if (reportFilename.indexOf('[rootSuiteTitle]') !== -1) { reportFilename = reportFilename.replace('[rootSuiteTitle]', sanitize(this._options.rootSuiteTitle)); } if (reportFilename.indexOf('[suiteFilename]') !== -1) { reportFilename = reportFilename.replace('[suiteFilename]', sanitize(testsuites[0]?.testsuite[0]?._attr?.file) ?? 'suiteFilename'); } if (reportFilename.indexOf('[suiteFilenameNoExt]') !== -1) { reportFilename = reportFilename.replace('[suiteFilenameNoExt]', sanitize(testsuites[0]?.testsuite[0]?._attr?.file).split('.')[0] ?? 'suiteFilename'); } if (reportFilename.indexOf('[suiteName]') !== -1) { reportFilename = reportFilename.replace('[suiteName]', sanitize(testsuites[1]?.testsuite[0]?._attr?.name ?? 'suiteName')); } return reportFilename; }; /** * Produces an XML string from the given test data. * @param {Array.<Object>} testsuites - a list of xml configs * @returns {string} */ CypressXrayJunitReporter.prototype.getXml = function (testsuites) { let totalTests = 0; const stats = this._runner.stats; const hasProperties = (!!this._options.properties) const startTime = this._Date; testsuites.forEach(function (suite) { const _suiteAttr = suite.testsuite[0]._attr; // testsuite is an array: [attrs, properties?, testcase, testcase, …] // we want to make sure that we are grabbing test cases at the correct index const _casesIndex = hasProperties ? 2 : 1; const _cases = suite.testsuite.slice(_casesIndex); // suiteTime has unrounded time as a Number of milliseconds const suiteTime = _suiteAttr.time; _suiteAttr.time = (suiteTime / 1000 || 0).toFixed(3); _suiteAttr.timestamp = new startTime(_suiteAttr.timestamp).toISOString().slice(0, -5); _suiteAttr.failures = 0; _suiteAttr.skipped = 0; console.log("teste asaasdasd") console.log(_suiteAttr) _cases.forEach(function (testcase) { _suiteAttr.skipped += countTest('skipped', testcase.testcase) _suiteAttr.failures += countTest('failure', testcase.testcase) if (typeof testcase.testcase[0]._attr.time === 'number') { testcase.testcase[0]._attr.time = testcase.testcase[0]._attr.time.toFixed(3); } }); if (!_suiteAttr.skipped) { delete _suiteAttr.skipped; } totalTests += _suiteAttr.tests; }); const rootSuite = { _attr: { name: this._options.testsuitesTitle, time: (stats.duration / 1000 || 0).toFixed(3), tests: totalTests, failures: stats.failures, owner: "testecarlos" } }; if (stats.pending) { rootSuite._attr.skipped = stats.pending; } testsuites = [rootSuite].concat(testsuites); return xml({ testsuites: testsuites }, { declaration: true, indent: ' ' }); }; /** * Writes a JUnit test report XML document. * @param {string} xml - xml string * @param {string} filePath - path to output file */ CypressXrayJunitReporter.prototype.writeXmlToDisk = function (xml, filePath) { if (filePath) { mkdirp.sync(path.dirname(filePath)); try { fs.writeFileSync(filePath, xml, 'utf-8'); } catch (exc) { } console.log("Sucesso ao gerar report report") } }; module.exports = CypressXrayJunitReporter;