cypress-xray-junit-reporter
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
760 lines (677 loc) • 23.9 kB
JavaScript
// 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-xray-junit-reporter")
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 { EXIT_CODES } = require("./src/exitCodes")
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) {
debug("Checking for options in", options)
if (!options) {
debug("No options provided")
return {}
}
if (options.reporterOptions) {
debug("Command-line options for mocha@6+")
return options.reporterOptions
}
// this is require to handle .mocharc.js files
debug("Looking for .mocharc.js options")
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)
debug("options", config)
config.mochaFile = getSetting(config.mochaFile, "test-results.xml")
config.jenkinsMode = getSetting(config.jenkinsMode, false)
config.xrayMode = getSetting(config.xrayMode, true)
config.requiredJiraKey = getSetting(config.requiredJiraKey, false)
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
}
debug("jenkins mode - testcaseSwitchClassnameAndName", options.testcaseSwitchClassnameAndName)
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) {
debug("Building jenkins classname for", test)
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
const requiredJiraKey = this._options.requiredJiraKey
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) => {
wsNum++
tests.forEach((test) => {
const err = test.err
const testCase = this.getTestcaseData(test, err)
if (test.state !== "skipped" && test.state !== "pending") {
if (test.parent.uuid) {
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 if (!test.parent.uuid && test.state == "failed") {
// if test.parent.uuid is missing the suite hasn't been initialized correctly
// if its a failure, it is caused by before/beforeEach fail
logMessages.analyzedTestcase(shortenLogMode, wsNum, test.title)
const suite = test.parent
const uuidSuite = uuidv4()
suite.uuid = uuidSuite
testOrderedByUUID.push(uuidSuite)
testsuites.push(this.getTestsuiteData(suite))
findSuite(test.parent.title, testOrderedByUUID.indexOf(uuidSuite)).push(testCase)
} else {
throw new Error("ERROR", EXIT_CODES.XRAY_CONFIG_ERROR, `XRAY_CONFIG_ERROR <${test.title}>`)
}
} else {
logMessages.skippedTestcase(shortenLogMode, wsNum, test.title)
skippedTestcase.push(test.title)
}
})
wsNum--
if (missingJiraKey.length > 0) {
logMessages.missingError(shortenLogMode, wsNum)
if (requiredJiraKey) {
console.error(`\n JIRA keys are required but missing in ${missingJiraKey.length} test case(s).`)
console.error(` Set requiredJiraKey: false in reporter options to disable this check. Exiting with code 104...`)
process.exit(EXIT_CODES.MISSING_JIRA_KEYS)
}
missingJiraKey = []
}
if (skippedTestcase.length > 0) {
logMessages.skippedError(shortenLogMode, skippedTestcase)
skippedTestcase = []
}
}
let testsuiteNum = 0
const processSuites = (suites) => {
logMessages.foundingSuite(shortenLogMode, wsNum, suites.length)
wsNum++
suites.forEach((suite) => {
logMessages.analyzingSuite(shortenLogMode, wsNum, suite.title)
if (isInvalidSuite(suite)) {
// Skip invalid/empty suites (e.g., describe blocks with no tests or nested suites)
logMessages.endSuite(wsNum, suite.title)
return
}
if (suite.suites.length && !suite.tests.length) {
processSuites(suite.suites)
testsuiteNum++
} else if (suite.tests.length && !suite.suites.length) {
processTests(suite.tests)
} else if (suite.suites.length && suite.tests.length) {
processTests(suite.tests)
processSuites(suite.suites)
} else {
throw new Error("ERROR", EXIT_CODES.XRAY_CONFIG_ERROR, `XRAY_CONFIG_ERROR <${test.title}>`)
}
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)) {
debug("removing report file", 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)
processSuites(rootSuite.suites)
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(),
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 parseJiraKeyFromConfig(test) {
const testConfig = test._testConfig
if (!testConfig) {
return []
}
const jiraKey = testConfig["jiraKey"] ? testConfig.jiraKey : testConfig.unverifiedTestConfig?.jiraKey
if (!jiraKey) {
return []
}
return jiraKey
}
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 addPropertyMultipleScreenshots(screenshots, properties) {
if (!screenshots || screenshots.length === 0) {
return
}
const items = screenshots.map((screenshot) => ({
item: [
{
_attr: { name: screenshot.name },
},
screenshot.base64,
],
}))
properties.push({
property: [
{
_attr: { name: "testrun_evidence" },
},
...items,
],
})
}
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 createFailureObject(filePath) {
const base64 = getBase64(filePath)
const name = filePath.split(path.sep).pop()
return { name, base64 }
}
function getFailureObject(screenshot, screenshotName, state) {
if (screenshot?.relativeFoldersArray && screenshot?.screenshotsFolder && screenshot?.screenshotName && state !== "passed") {
const { relativeFoldersArray, screenshotsFolder } = screenshot
for (let i = 0; i < relativeFoldersArray.length; i++) {
const pathParts = [screenshotsFolder, ...relativeFoldersArray.slice(i), screenshotName]
const screenshotPath = path.join(...pathParts)
try {
return createFailureObject(screenshotPath)
} catch (error) {
if (error.message.includes("no such file or directory")) {
continue
} else {
throw error
}
}
}
// No file was found
logMessages.warning(false, 0, `Missing screenshot: ${screenshot.screenshotName}`)
}
}
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, 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)
const testcase = {
testcase: [
{
_attr: {
name: flipClassAndName ? classname : name,
time: typeof test.duration === "undefined" ? 0 : test.duration / 1000,
classname: flipClassAndName ? name : 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 properties = []
if (xrayMode) {
const jiraKey = parseJiraKeyFromConfig(test)
if (jiraKey.length) {
addPropertyJiraKey(jiraKey, properties)
} else {
missingJiraKey.push(test.title)
}
}
if (attachScreenshot && test.screenshot?.screenshotArray) {
const failureObjects = []
// Move the last screenshot to the front of the array so the thumbnail displays the last screen capture.
for (let i = test.screenshot.screenshotArray.length - 1; i >= 0; i--) {
const testName = test.screenshot.screenshotArray[i]
const failureObj = getFailureObject(test.screenshot, testName, test.state)
if (failureObj) failureObjects.push(failureObj)
}
if (failureObjects.length > 0) {
addPropertyMultipleScreenshots(failureObjects, properties)
}
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) {
try {
this._xml = this.getXml(testsuites)
} catch (exc) {
console.error("XML generation error: Failed to generate XML content")
console.error(`Details: ${exc.message}`)
process.exit(EXIT_CODES.XML_GENERATION_ERROR)
}
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]",
testsuites[0]?.testsuite[0]?._attr?.file ? sanitize(testsuites[0]?.testsuite[0]?._attr?.file) : "suiteFilename"
)
}
if (reportFilename.indexOf("[suiteFilenameNoExt]") !== -1) {
reportFilename = reportFilename.replace(
"[suiteFilenameNoExt]",
testsuites[0]?.testsuite[0]?._attr?.file ? sanitize(testsuites[0]?.testsuite[0]?._attr?.file).split(".")[0] : "suiteFilename"
)
}
if (reportFilename.indexOf("[suiteName]") !== -1) {
reportFilename = reportFilename.replace(
"[suiteName]",
testsuites[1]?.testsuite[0]?._attr?.name ? 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
_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,
},
}
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) {
debug("writing file to", filePath)
mkdirp.sync(path.dirname(filePath))
try {
fs.writeFileSync(filePath, xml, "utf-8")
} catch (exc) {
console.error(`XML generation error: Failed to write report to ${filePath}`)
console.error(`Details: ${exc.message}`)
process.exit(EXIT_CODES.XML_GENERATION_ERROR)
}
debug("results written successfully")
}
}
module.exports = CypressXrayJunitReporter