codeceptjs 4.0.0-rc.21 → 4.0.0-rc.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,303 @@
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'
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
+ }
@@ -111,11 +111,12 @@ export default function (config) {
111
111
  }
112
112
 
113
113
  event.dispatcher.on(event.step.started, step => {
114
+ if (!step.title) return
114
115
  for (const ignored of config.ignoredSteps) {
115
- if (step.name === ignored) return
116
+ if (step.title === ignored) return
116
117
  if (ignored instanceof RegExp) {
117
- if (step.name.match(ignored)) return
118
- } else if (ignored.indexOf('*') && step.name.startsWith(ignored.slice(0, -1))) return
118
+ if (step.title.match(ignored)) return
119
+ } else if (ignored.indexOf('*') && step.title.startsWith(ignored.slice(0, -1))) return
119
120
  }
120
121
  enableRetry = true
121
122
  })
@@ -258,7 +258,7 @@ function formatTimestamp(timestampInMs) {
258
258
  }
259
259
 
260
260
  function stepTitle(step) {
261
- let title = `${step.actor}.${step.name}(${step.args ? step.args.join(',') : ''})`
261
+ let title = `${step.actor}.${step.title}(${step.args ? step.args.join(',') : ''})`
262
262
  if (title.length > 100) title = `${title.substring(0, 100)}...`
263
263
  return title
264
264
  }
@@ -325,7 +325,7 @@ function wireSlides(options, trigger) {
325
325
  if (stepNum === -1) return
326
326
  if (savedStep === step) return
327
327
  if (scenarioFailed) return
328
- if (step.metaStep && step.metaStep.name === 'BeforeSuite') return
328
+ if (step.metaStep && step.metaStep.title === 'BeforeSuite') return
329
329
  if (!currentTest) return
330
330
  if (!stepFilter(step)) return
331
331
  if (isStepIgnored(step, options.ignoreSteps)) return
@@ -404,7 +404,7 @@ function makeStepFilter(trigger, options) {
404
404
  function isStepIgnored(step, patterns) {
405
405
  if (!patterns || !patterns.length) return false
406
406
  for (const pattern of patterns) {
407
- if (step.name && step.name.match(pattern)) return true
407
+ if (step.title && step.title.match(pattern)) return true
408
408
  }
409
409
  return false
410
410
  }
@@ -68,6 +68,7 @@ export default function(config) {
68
68
  config.customTimeoutSteps = config.customTimeoutSteps.concat(config.noTimeoutSteps).concat(config.customTimeoutSteps)
69
69
 
70
70
  event.dispatcher.on(event.step.before, step => {
71
+ if (!step.title) return
71
72
  let stepTimeout
72
73
  for (let stepRule of config.customTimeoutSteps) {
73
74
  let customTimeout = 0
@@ -75,7 +76,7 @@ export default function(config) {
75
76
  if (stepRule.length > 1) customTimeout = stepRule[1]
76
77
  stepRule = stepRule[0]
77
78
  }
78
- if (stepRule instanceof RegExp ? step.name.match(stepRule) : step.name === stepRule || (stepRule.indexOf('*') && step.name.startsWith(stepRule.slice(0, -1)))) {
79
+ if (stepRule instanceof RegExp ? step.title.match(stepRule) : step.title === stepRule || (stepRule.indexOf('*') && step.title.startsWith(stepRule.slice(0, -1)))) {
79
80
  stepTimeout = customTimeout
80
81
  break
81
82
  }
package/lib/step/base.js CHANGED
@@ -10,12 +10,12 @@ const STACK_LINE = 5
10
10
  /**
11
11
  * Each command in test executed through `I.` object is wrapped in Step.
12
12
  * Step allows logging executed commands and triggers hook before and after step execution.
13
- * @param {string} name
13
+ * @param {string} title
14
14
  */
15
15
  class Step {
16
- constructor(name) {
16
+ constructor(title) {
17
17
  /** @member {string} */
18
- this.name = name
18
+ this.title = title
19
19
  /** @member {Map<number, number>} */
20
20
  this.timeouts = new Map()
21
21
 
@@ -43,7 +43,7 @@ class Step {
43
43
  /** @member {any} */
44
44
  this.helper = null
45
45
  /** @member {string} */
46
- this.helperMethod = name
46
+ this.helperMethod = title
47
47
 
48
48
  this.startTime = 0
49
49
  this.endTime = 0
@@ -103,7 +103,7 @@ class Step {
103
103
 
104
104
  /** @return {string} */
105
105
  humanize() {
106
- return humanizeString(this.name)
106
+ return humanizeString(this.title)
107
107
  }
108
108
 
109
109
  /** @return {string} */
@@ -180,7 +180,7 @@ class Step {
180
180
 
181
181
  /** @return {string} */
182
182
  toCode() {
183
- return `${this.prefix}${this.actor}.${this.name}(${this.humanizeArgs()})${this.suffix}`
183
+ return `${this.prefix}${this.actor}.${this.title}(${this.humanizeArgs()})${this.suffix}`
184
184
  }
185
185
 
186
186
  isMetaStep() {
@@ -223,7 +223,7 @@ class Step {
223
223
 
224
224
  return {
225
225
  opts: step.opts || {},
226
- title: step.name,
226
+ title: step.title,
227
227
  args: args,
228
228
  status: step.status,
229
229
  startTime: step.startTime,
@@ -1,8 +1,8 @@
1
1
  import FuncStep from './func.js'
2
2
 
3
3
  class CommentStep extends FuncStep {
4
- constructor(name, comment) {
5
- super(name)
4
+ constructor(title, comment) {
5
+ super(title)
6
6
  this.fn = () => {}
7
7
  }
8
8
  }
@@ -2,12 +2,12 @@ import Step from './base.js'
2
2
  import store from '../store.js'
3
3
 
4
4
  class HelperStep extends Step {
5
- constructor(helper, name) {
6
- super(name)
5
+ constructor(helper, title) {
6
+ super(title)
7
7
  /** @member {CodeceptJS.Helper} helper corresponding helper */
8
8
  this.helper = helper
9
- /** @member {string} helperMethod name of method to be executed */
10
- this.helperMethod = name
9
+ /** @member {string} helperMethod title of method to be executed */
10
+ this.helperMethod = title
11
11
  }
12
12
 
13
13
  /**
package/lib/step/meta.js CHANGED
@@ -29,7 +29,7 @@ class MetaStep extends Step {
29
29
  const actorText = this.actor
30
30
 
31
31
  if (this.isBDD()) {
32
- return `${this.prefix}${actorText} ${this.name} "${this.humanizeArgs()}${this.suffix}"`
32
+ return `${this.prefix}${actorText} ${this.title} "${this.humanizeArgs()}${this.suffix}"`
33
33
  }
34
34
 
35
35
  if (actorText === 'I') {
@@ -37,14 +37,14 @@ class MetaStep extends Step {
37
37
  }
38
38
 
39
39
  if (!this.actor) {
40
- return `${this.name} ${this.humanizeArgs()}${this.suffix}`.trim()
40
+ return `${this.title} ${this.humanizeArgs()}${this.suffix}`.trim()
41
41
  }
42
42
 
43
43
  return `On ${this.prefix}${actorText}: ${this.humanize()} ${this.humanizeArgs()}${this.suffix}`.trim()
44
44
  }
45
45
 
46
46
  humanize() {
47
- return humanizeString(this.name)
47
+ return humanizeString(this.title)
48
48
  }
49
49
 
50
50
  setTrace() {}
@@ -16,12 +16,12 @@ function recordStep(step, args) {
16
16
  const { opts, timeout, retry } = stepConfig.getConfig()
17
17
 
18
18
  if (opts) {
19
- output.debug(`Step ${step.name}: options applied ${JSON.stringify(opts)}`)
19
+ output.debug(`Step ${step.title}: options applied ${JSON.stringify(opts)}`)
20
20
  store.stepOptions = opts
21
21
  step.opts = opts
22
22
  }
23
23
  if (timeout) {
24
- output.debug(`Step ${step.name} timeout ${timeout}s`)
24
+ output.debug(`Step ${step.title} timeout ${timeout}s`)
25
25
  step.setTimeout(timeout * 1000, TIMEOUT_ORDER.codeLimitTime)
26
26
  }
27
27
  if (retry) retryStep(retry)
@@ -31,7 +31,7 @@ function recordStep(step, args) {
31
31
  // run async before step hooks
32
32
  event.emit(event.step.before, step)
33
33
 
34
- const task = `${step.name}: ${step.humanizeArgs()}`
34
+ const task = `${step.title}: ${step.humanizeArgs()}`
35
35
  let val
36
36
 
37
37
  // run step inside promise
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeceptjs",
3
- "version": "4.0.0-rc.21",
3
+ "version": "4.0.0-rc.23",
4
4
  "type": "module",
5
5
  "description": "Supercharged End 2 End Testing Framework for NodeJS",
6
6
  "keywords": [