|
| 1 | +import fs from 'fs' |
| 2 | +import path from 'path' |
| 3 | +import os from 'os' |
| 4 | +import { mkdirp } from 'mkdirp' |
| 5 | +import { DOMImplementation, XMLSerializer } from '@xmldom/xmldom' |
| 6 | + |
| 7 | +import event from '../event.js' |
| 8 | +import store from '../store.js' |
| 9 | +import output from '../output.js' |
| 10 | + |
| 11 | +const defaultConfig = { |
| 12 | + outputName: 'report.xml', |
| 13 | + output: null, |
| 14 | + testGroupName: 'CodeceptJS', |
| 15 | + attachSteps: true, |
| 16 | + attachMeta: true, |
| 17 | + stepsInFailure: true, |
| 18 | +} |
| 19 | + |
| 20 | +const INVALID_XML_CHARS = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\uFFFE\\uFFFF]', 'g') |
| 21 | + |
| 22 | +/** |
| 23 | + * |
| 24 | + * Generates a JUnit-compatible XML report after a test run. |
| 25 | + * |
| 26 | + * Unlike Mocha's `mocha-junit-reporter`, this plugin understands CodeceptJS steps and substeps. |
| 27 | + * For every `<testcase>` it includes: |
| 28 | + * |
| 29 | + * * `<properties>` — the test's meta information: every `meta` key from `Scenario('...', { meta })`, plus its `tags` and `retries` |
| 30 | + * * `<system-out>` — an indented step/substep log (substeps are nested under their meta step); only failed steps are marked |
| 31 | + * * `<failure>` — for failed tests: the error message, type, stack trace and (optionally) the step trace |
| 32 | + * |
| 33 | + * The produced file is consumable by Jenkins, GitLab CI, CircleCI, GitHub Actions test reporters, etc. |
| 34 | + * |
| 35 | + * #### Configuration |
| 36 | + * |
| 37 | + * ```js |
| 38 | + * "plugins": { |
| 39 | + * "junitReporter": { |
| 40 | + * "enabled": true |
| 41 | + * } |
| 42 | + * } |
| 43 | + * ``` |
| 44 | + * |
| 45 | + * Possible config options: |
| 46 | + * |
| 47 | + * * `outputName`: file name for the report. Default: `report.xml`. |
| 48 | + * * `output`: directory where the report is stored, relative to the project root. Default: the `output` directory. |
| 49 | + * * `testGroupName`: value of the `name` attribute on the root `<testsuites>` element. Default: `CodeceptJS`. |
| 50 | + * * `attachMeta`: add the test's meta information (`meta` keys, `tags`, `retries`) as `<properties>`. Default: true. |
| 51 | + * * `attachSteps`: add the step/substep log as `<system-out>`. Default: true. |
| 52 | + * * `stepsInFailure`: append the step trace to the `<failure>` body. Default: true. |
| 53 | + * |
| 54 | + * CLI examples: |
| 55 | + * |
| 56 | + * ``` |
| 57 | + * npx codeceptjs run -p junitReporter |
| 58 | + * npx codeceptjs run -p junitReporter:outputName=junit.xml |
| 59 | + * ``` |
| 60 | + * |
| 61 | + * > ℹ When running with `run-workers`, steps are serialized between processes and substep nesting is flattened. |
| 62 | + * |
| 63 | + * @param {*} config |
| 64 | + */ |
| 65 | +export default function (config = {}) { |
| 66 | + config = Object.assign({}, defaultConfig, config) |
| 67 | + |
| 68 | + let written = false |
| 69 | + |
| 70 | + const writeReport = result => { |
| 71 | + if (written) return |
| 72 | + if (!result || !Array.isArray(result.tests)) return |
| 73 | + written = true |
| 74 | + |
| 75 | + const dir = config.output ? path.resolve(store.codeceptDir || process.cwd(), config.output) : store.outputDir || process.cwd() |
| 76 | + mkdirp.sync(dir) |
| 77 | + const file = path.join(dir, config.outputName) |
| 78 | + |
| 79 | + fs.writeFileSync(file, buildXml(result, config)) |
| 80 | + output.plugin('junitReporter', `JUnit report saved to ${file}`) |
| 81 | + } |
| 82 | + |
| 83 | + event.dispatcher.on(event.all.result, writeReport) |
| 84 | + event.dispatcher.on(event.workers.result, writeReport) |
| 85 | +} |
| 86 | + |
| 87 | +function buildXml(result, config) { |
| 88 | + const doc = new DOMImplementation().createDocument(null, null, null) |
| 89 | + const suites = groupBySuite(result.tests) |
| 90 | + |
| 91 | + const root = doc.createElement('testsuites') |
| 92 | + setAttr(root, 'name', config.testGroupName) |
| 93 | + setAttr(root, 'tests', result.tests.length) |
| 94 | + setAttr(root, 'failures', countState(result.tests, 'failed')) |
| 95 | + setAttr(root, 'skipped', countSkipped(result.tests)) |
| 96 | + setAttr(root, 'errors', 0) |
| 97 | + setAttr(root, 'time', toSeconds(sumDuration(result.tests))) |
| 98 | + setAttr(root, 'timestamp', toIso(result.stats && result.stats.start)) |
| 99 | + doc.appendChild(root) |
| 100 | + |
| 101 | + suites.forEach((tests, index) => { |
| 102 | + const suite = tests[0] && tests[0].parent |
| 103 | + const suiteName = (suite && suite.title) || 'Tests' |
| 104 | + const suiteFile = (suite && suite.file) || (tests[0] && tests[0].file) || '' |
| 105 | + |
| 106 | + const suiteEl = doc.createElement('testsuite') |
| 107 | + setAttr(suiteEl, 'name', suiteName) |
| 108 | + setAttr(suiteEl, 'id', index) |
| 109 | + setAttr(suiteEl, 'tests', tests.length) |
| 110 | + setAttr(suiteEl, 'failures', countState(tests, 'failed')) |
| 111 | + setAttr(suiteEl, 'skipped', countSkipped(tests)) |
| 112 | + setAttr(suiteEl, 'errors', 0) |
| 113 | + setAttr(suiteEl, 'time', toSeconds(sumDuration(tests))) |
| 114 | + setAttr(suiteEl, 'timestamp', toIso(suite && suite.startedAt)) |
| 115 | + setAttr(suiteEl, 'hostname', os.hostname()) |
| 116 | + if (suiteFile) setAttr(suiteEl, 'file', suiteFile) |
| 117 | + root.appendChild(suiteEl) |
| 118 | + |
| 119 | + for (const test of tests) { |
| 120 | + suiteEl.appendChild(buildTestCase(doc, test, suiteName, config)) |
| 121 | + } |
| 122 | + }) |
| 123 | + |
| 124 | + return '<?xml version="1.0" encoding="UTF-8"?>\n' + new XMLSerializer().serializeToString(doc) + '\n' |
| 125 | +} |
| 126 | + |
| 127 | +function buildTestCase(doc, test, suiteName, config) { |
| 128 | + const testEl = doc.createElement('testcase') |
| 129 | + setAttr(testEl, 'name', test.title || '(no title)') |
| 130 | + setAttr(testEl, 'classname', suiteName) |
| 131 | + setAttr(testEl, 'time', toSeconds(test.duration || 0)) |
| 132 | + const file = test.file || (test.parent && test.parent.file) |
| 133 | + if (file) setAttr(testEl, 'file', file) |
| 134 | + |
| 135 | + if (config.attachMeta) { |
| 136 | + const properties = metaProperties(test) |
| 137 | + if (properties.length) { |
| 138 | + const propertiesEl = doc.createElement('properties') |
| 139 | + for (const [name, value] of properties) { |
| 140 | + const prop = doc.createElement('property') |
| 141 | + setAttr(prop, 'name', name) |
| 142 | + setAttr(prop, 'value', value) |
| 143 | + propertiesEl.appendChild(prop) |
| 144 | + } |
| 145 | + testEl.appendChild(propertiesEl) |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + const flat = flattenSteps(Array.isArray(test.steps) ? test.steps : []) |
| 150 | + |
| 151 | + if (test.state === 'skipped' || test.state === 'pending') { |
| 152 | + const skipped = doc.createElement('skipped') |
| 153 | + const reason = skipReason(test) |
| 154 | + if (reason) setAttr(skipped, 'message', reason) |
| 155 | + testEl.appendChild(skipped) |
| 156 | + } else if (test.state === 'failed') { |
| 157 | + const err = test.err || {} |
| 158 | + const failure = doc.createElement('failure') |
| 159 | + setAttr(failure, 'message', err.message || 'Test failed') |
| 160 | + setAttr(failure, 'type', err.name || 'Error') |
| 161 | + let body = err.stack || err.message || 'Test failed' |
| 162 | + if (config.stepsInFailure && flat.length) { |
| 163 | + body += '\n\nSteps:\n' + flat.map(stepLogLine).join('\n') |
| 164 | + } |
| 165 | + failure.appendChild(doc.createTextNode(cleanText(body))) |
| 166 | + testEl.appendChild(failure) |
| 167 | + } |
| 168 | + |
| 169 | + if (config.attachSteps && flat.length) { |
| 170 | + const out = doc.createElement('system-out') |
| 171 | + out.appendChild(doc.createTextNode(cleanText(flat.map(stepLogLine).join('\n')))) |
| 172 | + testEl.appendChild(out) |
| 173 | + } |
| 174 | + |
| 175 | + return testEl |
| 176 | +} |
| 177 | + |
| 178 | +function metaProperties(test) { |
| 179 | + const props = [] |
| 180 | + const meta = test.meta || {} |
| 181 | + for (const key of Object.keys(meta)) { |
| 182 | + if (meta[key] === undefined || meta[key] === null) continue |
| 183 | + props.push([key, stringifyMeta(meta[key])]) |
| 184 | + } |
| 185 | + if (Array.isArray(test.tags) && test.tags.length) { |
| 186 | + props.push(['tags', test.tags.join(' ')]) |
| 187 | + } |
| 188 | + if (test.retries > 0 || test.retryNum > 0) { |
| 189 | + props.push(['retries', String(test.retryNum || test.retries)]) |
| 190 | + } |
| 191 | + return props |
| 192 | +} |
| 193 | + |
| 194 | +function stringifyMeta(value) { |
| 195 | + if (typeof value === 'string') return value |
| 196 | + if (typeof value === 'number' || typeof value === 'boolean') return String(value) |
| 197 | + try { |
| 198 | + return JSON.stringify(value) |
| 199 | + } catch (err) { |
| 200 | + return String(value) |
| 201 | + } |
| 202 | +} |
| 203 | + |
| 204 | +function flattenSteps(steps) { |
| 205 | + const out = [] |
| 206 | + let prevChain = [] |
| 207 | + |
| 208 | + for (const step of steps) { |
| 209 | + const chain = metaChain(step) |
| 210 | + |
| 211 | + let common = 0 |
| 212 | + while (common < chain.length && common < prevChain.length && chain[common].key === prevChain[common].key) common++ |
| 213 | + |
| 214 | + for (let d = common; d < chain.length; d++) { |
| 215 | + out.push({ depth: d, step: chain[d].step }) |
| 216 | + } |
| 217 | + out.push({ depth: chain.length, step }) |
| 218 | + prevChain = chain |
| 219 | + } |
| 220 | + |
| 221 | + return out |
| 222 | +} |
| 223 | + |
| 224 | +function metaChain(step) { |
| 225 | + const chain = [] |
| 226 | + let meta = step && step.metaStep |
| 227 | + while (meta) { |
| 228 | + chain.unshift({ step: meta, key: meta }) |
| 229 | + meta = meta.metaStep |
| 230 | + } |
| 231 | + if (!chain.length && step && step.parent && step.parent.title) { |
| 232 | + chain.push({ step: { title: step.parent.title, status: step.status }, key: `meta:${step.parent.title}` }) |
| 233 | + } |
| 234 | + return chain |
| 235 | +} |
| 236 | + |
| 237 | +function stepLogLine(entry) { |
| 238 | + const indent = ' '.repeat(entry.depth) |
| 239 | + const mark = entry.step && entry.step.status === 'failed' ? '[FAILED] ' : '' |
| 240 | + return `${indent}${mark}${stepText(entry.step)} (${stepDuration(entry.step)}ms)` |
| 241 | +} |
| 242 | + |
| 243 | +function stepText(step) { |
| 244 | + if (step && typeof step.toString === 'function' && step.toString !== Object.prototype.toString) return step.toString() |
| 245 | + return (step && (step.title || step.name)) || 'step' |
| 246 | +} |
| 247 | + |
| 248 | +function stepDuration(step) { |
| 249 | + if (!step) return 0 |
| 250 | + if (typeof step.duration === 'number' && step.duration >= 0) return step.duration |
| 251 | + if (step.startTime && step.endTime) return Math.max(0, step.endTime - step.startTime) |
| 252 | + return 0 |
| 253 | +} |
| 254 | + |
| 255 | +function groupBySuite(tests) { |
| 256 | + const groups = [] |
| 257 | + const byKey = new Map() |
| 258 | + for (const test of tests) { |
| 259 | + const key = test.parent || test |
| 260 | + if (!byKey.has(key)) { |
| 261 | + const list = [] |
| 262 | + byKey.set(key, list) |
| 263 | + groups.push(list) |
| 264 | + } |
| 265 | + byKey.get(key).push(test) |
| 266 | + } |
| 267 | + return groups |
| 268 | +} |
| 269 | + |
| 270 | +function skipReason(test) { |
| 271 | + if (test.opts && test.opts.skipInfo && test.opts.skipInfo.message) return test.opts.skipInfo.message |
| 272 | + if (test.meta && test.meta.skipReason) return test.meta.skipReason |
| 273 | + return '' |
| 274 | +} |
| 275 | + |
| 276 | +function countState(tests, state) { |
| 277 | + return tests.filter(t => t.state === state).length |
| 278 | +} |
| 279 | + |
| 280 | +function countSkipped(tests) { |
| 281 | + return tests.filter(t => t.state === 'skipped' || t.state === 'pending').length |
| 282 | +} |
| 283 | + |
| 284 | +function sumDuration(tests) { |
| 285 | + return tests.reduce((sum, t) => sum + (t.duration || 0), 0) |
| 286 | +} |
| 287 | + |
| 288 | +function toSeconds(ms) { |
| 289 | + return (Math.max(0, ms) / 1000).toFixed(3) |
| 290 | +} |
| 291 | + |
| 292 | +function toIso(value) { |
| 293 | + const date = value ? new Date(value) : new Date() |
| 294 | + return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString() |
| 295 | +} |
| 296 | + |
| 297 | +function cleanText(text) { |
| 298 | + return String(text == null ? '' : text).replace(INVALID_XML_CHARS, '') |
| 299 | +} |
| 300 | + |
| 301 | +function setAttr(el, name, value) { |
| 302 | + el.setAttribute(name, cleanText(value)) |
| 303 | +} |
0 commit comments