codeceptjs 4.0.0-rc.11 → 4.0.0-rc.15

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.
Files changed (54) hide show
  1. package/lib/ai.js +3 -2
  2. package/lib/assertions.js +18 -0
  3. package/lib/codecept.js +6 -6
  4. package/lib/command/check.js +2 -0
  5. package/lib/command/dryRun.js +2 -3
  6. package/lib/command/generate.js +2 -0
  7. package/lib/command/gherkin/snippets.js +5 -4
  8. package/lib/command/init.js +1 -0
  9. package/lib/command/run-multiple.js +2 -0
  10. package/lib/command/run-workers.js +1 -0
  11. package/lib/command/run.js +1 -1
  12. package/lib/command/workers/runTests.js +10 -10
  13. package/lib/container.js +63 -13
  14. package/lib/effects.js +17 -0
  15. package/lib/element/WebElement.js +128 -0
  16. package/lib/globals.js +22 -10
  17. package/lib/heal.js +4 -3
  18. package/lib/helper/ApiDataFactory.js +2 -1
  19. package/lib/helper/FileSystem.js +3 -2
  20. package/lib/helper/GraphQLDataFactory.js +2 -1
  21. package/lib/helper/Playwright.js +17 -16
  22. package/lib/helper/Puppeteer.js +16 -6
  23. package/lib/helper/WebDriver.js +8 -2
  24. package/lib/helper/extras/Download.js +45 -0
  25. package/lib/helper/extras/richTextEditor.js +115 -0
  26. package/lib/history.js +3 -2
  27. package/lib/listener/config.js +6 -4
  28. package/lib/listener/emptyRun.js +2 -1
  29. package/lib/listener/helpers.js +4 -1
  30. package/lib/listener/mocha.js +2 -1
  31. package/lib/listener/pageobjects.js +43 -0
  32. package/lib/listener/result.js +3 -2
  33. package/lib/locator.js +112 -0
  34. package/lib/mocha/cli.js +4 -2
  35. package/lib/mocha/factory.js +2 -1
  36. package/lib/mocha/scenarioConfig.js +2 -1
  37. package/lib/mocha/ui.js +5 -6
  38. package/lib/plugin/aiTrace.js +4 -3
  39. package/lib/plugin/analyze.js +1 -1
  40. package/lib/plugin/auth.js +3 -3
  41. package/lib/plugin/pageInfo.js +2 -1
  42. package/lib/plugin/pauseOn.js +167 -0
  43. package/lib/plugin/screenshotOnFail.js +3 -4
  44. package/lib/plugin/stepByStepReport.js +5 -4
  45. package/lib/rerun.js +2 -1
  46. package/lib/result.js +2 -1
  47. package/lib/step/base.js +3 -2
  48. package/lib/step/record.js +1 -1
  49. package/lib/store.js +72 -3
  50. package/lib/translation.js +2 -1
  51. package/lib/utils/mask_data.js +2 -1
  52. package/lib/utils.js +4 -3
  53. package/lib/workers.js +2 -0
  54. package/package.json +5 -3
@@ -1,10 +1,11 @@
1
1
  import event from '../event.js'
2
+ import container from '../container.js'
2
3
 
3
4
  export default function () {
4
5
  let mocha
5
6
 
6
7
  event.dispatcher.on(event.all.before, () => {
7
- mocha = global.container.mocha()
8
+ mocha = container.mocha()
8
9
  })
9
10
 
10
11
  event.dispatcher.on(event.test.passed, test => {
@@ -0,0 +1,43 @@
1
+ import event from '../event.js'
2
+ import recorder from '../recorder.js'
3
+ import store from '../store.js'
4
+ import container from '../container.js'
5
+ import { resetBeforeCalledSet, getBeforeCalledSet } from '../container.js'
6
+
7
+ export default function () {
8
+ const runAsyncSupportHook = (hook, param, force) => {
9
+ if (store.dryRun) return
10
+ const support = container.supportObjects()
11
+ Object.keys(support).forEach(key => {
12
+ if (key === 'I') return
13
+ const obj = support[key]
14
+ if (!obj || typeof obj !== 'object' || !obj[hook]) return
15
+ recorder.add(`pageobject ${key}.${hook}()`, () => obj[hook](param), force, false)
16
+ })
17
+ }
18
+
19
+ event.dispatcher.on(event.test.started, () => {
20
+ resetBeforeCalledSet()
21
+ })
22
+
23
+ event.dispatcher.on(event.test.after, () => {
24
+ if (store.dryRun) return
25
+ const support = container.supportObjects()
26
+ const called = getBeforeCalledSet()
27
+ called.forEach(name => {
28
+ const obj = support[name]
29
+ if (obj && obj._after) {
30
+ recorder.add(`pageobject ${name}._after()`, () => obj._after(), true, false)
31
+ }
32
+ })
33
+ recorder.catchWithoutStop(() => {})
34
+ })
35
+
36
+ event.dispatcher.on(event.suite.after, suite => {
37
+ runAsyncSupportHook('_afterSuite', suite, true)
38
+ })
39
+
40
+ event.dispatcher.on(event.suite.before, suite => {
41
+ runAsyncSupportHook('_beforeSuite', suite, true)
42
+ })
43
+ }
@@ -1,11 +1,12 @@
1
1
  import event from '../event.js'
2
+ import container from '../container.js'
2
3
 
3
4
  export default function () {
4
5
  event.dispatcher.on(event.hook.failed, err => {
5
- global.container.result().addStats({ failedHooks: 1 })
6
+ container.result().addStats({ failedHooks: 1 })
6
7
  })
7
8
 
8
9
  event.dispatcher.on(event.test.before, test => {
9
- global.container.result().addTest(test)
10
+ container.result().addTest(test)
10
11
  })
11
12
  }
package/lib/locator.js CHANGED
@@ -381,9 +381,121 @@ class Locator {
381
381
  return new Locator({ xpath })
382
382
  }
383
383
 
384
+ /**
385
+ * Find an element with all of the provided CSS classes (word-exact match).
386
+ * Accepts variadic class names; all must be present.
387
+ *
388
+ * Example:
389
+ * locate('button').withClass('btn-primary', 'btn-lg')
390
+ *
391
+ * @param {...string} classes
392
+ * @returns {Locator}
393
+ */
394
+ withClass(...classes) {
395
+ if (!classes.length) return this
396
+ const predicates = classes.map(c => `contains(concat(' ', normalize-space(@class), ' '), ' ${c} ')`)
397
+ const xpath = sprintf('%s[%s]', this.toXPath(), predicates.join(' and '))
398
+ return new Locator({ xpath })
399
+ }
400
+
401
+ /**
402
+ * Find an element with none of the provided CSS classes.
403
+ *
404
+ * Example:
405
+ * locate('tr').withoutClass('deleted')
406
+ *
407
+ * @param {...string} classes
408
+ * @returns {Locator}
409
+ */
410
+ withoutClass(...classes) {
411
+ if (!classes.length) return this
412
+ const predicates = classes.map(c => `not(contains(concat(' ', normalize-space(@class), ' '), ' ${c} '))`)
413
+ const xpath = sprintf('%s[%s]', this.toXPath(), predicates.join(' and '))
414
+ return new Locator({ xpath })
415
+ }
416
+
417
+ /**
418
+ * Find an element that does NOT contain the provided text.
419
+ * @param {string} text
420
+ * @returns {Locator}
421
+ */
422
+ withoutText(text) {
423
+ text = xpathLocator.literal(text)
424
+ const xpath = sprintf('%s[%s]', this.toXPath(), `not(contains(., ${text}))`)
425
+ return new Locator({ xpath })
426
+ }
427
+
428
+ /**
429
+ * Find an element that does NOT have any of the provided attribute/value pairs.
430
+ * @param {Object.<string, string>} attributes
431
+ * @returns {Locator}
432
+ */
433
+ withoutAttr(attributes) {
434
+ const operands = []
435
+ for (const attr of Object.keys(attributes)) {
436
+ operands.push(`not(@${attr} = ${xpathLocator.literal(attributes[attr])})`)
437
+ }
438
+ const xpath = sprintf('%s[%s]', this.toXPath(), operands.join(' and '))
439
+ return new Locator({ xpath })
440
+ }
441
+
442
+ /**
443
+ * Find an element that has no direct child matching the provided locator.
444
+ * @param {CodeceptJS.LocatorOrString} locator
445
+ * @returns {Locator}
446
+ */
447
+ withoutChild(locator) {
448
+ const xpath = sprintf('%s[not(./child::%s)]', this.toXPath(), convertToSubSelector(locator))
449
+ return new Locator({ xpath })
450
+ }
451
+
452
+ /**
453
+ * Find an element that has no descendant matching the provided locator.
454
+ *
455
+ * Example:
456
+ * locate('button').withoutDescendant('svg')
457
+ *
458
+ * @param {CodeceptJS.LocatorOrString} locator
459
+ * @returns {Locator}
460
+ */
461
+ withoutDescendant(locator) {
462
+ const xpath = sprintf('%s[not(./descendant::%s)]', this.toXPath(), convertToSubSelector(locator))
463
+ return new Locator({ xpath })
464
+ }
465
+
466
+ /**
467
+ * Append a raw XPath predicate. Escape hatch for expressions not covered by the DSL.
468
+ * Argument is inserted as-is inside `[ ]`; quoting/escaping is the caller's responsibility.
469
+ *
470
+ * Example:
471
+ * locate('input').and('@type="text" or @type="email"')
472
+ *
473
+ * @param {string} xpathExpression
474
+ * @returns {Locator}
475
+ */
476
+ and(xpathExpression) {
477
+ const xpath = sprintf('%s[%s]', this.toXPath(), xpathExpression)
478
+ return new Locator({ xpath })
479
+ }
480
+
481
+ /**
482
+ * Append a negated raw XPath predicate: `[not(expr)]`.
483
+ *
484
+ * Example:
485
+ * locate('button').andNot('.//svg') // button without a descendant svg
486
+ *
487
+ * @param {string} xpathExpression
488
+ * @returns {Locator}
489
+ */
490
+ andNot(xpathExpression) {
491
+ const xpath = sprintf('%s[not(%s)]', this.toXPath(), xpathExpression)
492
+ return new Locator({ xpath })
493
+ }
494
+
384
495
  /**
385
496
  * @param {String} text
386
497
  * @returns {Locator}
498
+ * @deprecated Use {@link Locator#withClass} for word-exact class matching, or {@link Locator#withAttrContains} for substring matching.
387
499
  */
388
500
  withClassAttr(text) {
389
501
  const xpath = sprintf('%s[%s]', this.toXPath(), `contains(@class, '${text}')`)
package/lib/mocha/cli.js CHANGED
@@ -8,6 +8,7 @@ import { dirname, join } from 'path'
8
8
  import event from '../event.js'
9
9
  import AssertionFailedError from '../assert/error.js'
10
10
  import output from '../output.js'
11
+ import store from '../store.js'
11
12
  import test, { cloneTest } from './test.js'
12
13
  import { fixErrorStack } from '../utils/typescript.js'
13
14
 
@@ -41,7 +42,7 @@ class Cli extends Base {
41
42
  if (opts.verbose) level = 3
42
43
  output.level(level)
43
44
  output.print(`CodeceptJS v${codeceptVersion} ${output.standWithUkraine()}`)
44
- output.print(`Using test root "${global.codecept_dir}"`)
45
+ output.print(`Using test root "${store.codeceptDir}"`)
45
46
 
46
47
  const showSteps = level >= 1
47
48
 
@@ -213,6 +214,7 @@ class Cli extends Base {
213
214
  }
214
215
 
215
216
  // append step traces
217
+ const Container = await getContainer()
216
218
  this.failures = this.failures.map(test => {
217
219
  // we will change the stack trace, so we need to clone the test
218
220
  const err = test.err
@@ -275,7 +277,7 @@ class Cli extends Base {
275
277
  }
276
278
 
277
279
  try {
278
- const fileMapping = global.container?.tsFileMapping?.()
280
+ const fileMapping = Container?.tsFileMapping?.()
279
281
  if (fileMapping) {
280
282
  fixErrorStack(err, fileMapping)
281
283
  }
@@ -8,6 +8,7 @@ import output from '../output.js'
8
8
  import scenarioUiFunction from './ui.js'
9
9
  import { initMochaGlobals } from '../globals.js'
10
10
  import { fixErrorStack } from '../utils/typescript.js'
11
+ import container from '../container.js'
11
12
 
12
13
  const __filename = fileURLToPath(import.meta.url)
13
14
  const __dirname = fsPath.dirname(__filename)
@@ -35,7 +36,7 @@ class MochaFactory {
35
36
  // Handle ECONNREFUSED without dynamic import for now
36
37
  err = new Error('Connection refused: ' + err.toString())
37
38
  }
38
- const fileMapping = global.container?.tsFileMapping?.()
39
+ const fileMapping = container?.tsFileMapping?.()
39
40
  if (fileMapping) {
40
41
  fixErrorStack(err, fileMapping)
41
42
  }
@@ -1,4 +1,5 @@
1
1
  import { isAsyncFunction } from '../utils.js'
2
+ import store from '../store.js'
2
3
 
3
4
  /** @class */
4
5
  class ScenarioConfig {
@@ -40,7 +41,7 @@ class ScenarioConfig {
40
41
  * @returns {this}
41
42
  */
42
43
  retry(retries) {
43
- if (process.env.SCENARIO_ONLY) retries = -retries
44
+ if (store.scenarioOnly) retries = -retries
44
45
  this.test.retries(retries)
45
46
  return this
46
47
  }
package/lib/mocha/ui.js CHANGED
@@ -9,13 +9,12 @@ import { HookConfig, AfterSuiteHook, AfterHook, BeforeSuiteHook, BeforeHook } fr
9
9
  import { initMochaGlobals } from '../globals.js'
10
10
  import common from 'mocha/lib/interfaces/common.js'
11
11
  import container from '../container.js'
12
+ import store from '../store.js'
12
13
 
13
14
  const setContextTranslation = context => {
14
- // Try global container first, then local container instance
15
- const containerToUse = global.container || container
16
- if (!containerToUse) return
15
+ if (!container) return
17
16
 
18
- const translation = containerToUse.translation?.() || containerToUse.translation
17
+ const translation = container.translation?.() || container.translation
19
18
  const contexts = translation?.value?.('contexts')
20
19
 
21
20
  if (contexts) {
@@ -119,7 +118,7 @@ export default function (suite) {
119
118
  context.Feature.only = function (title, opts) {
120
119
  const reString = `^${escapeRe(`${title}:`)}`
121
120
  mocha.grep(new RegExp(reString))
122
- process.env.FEATURE_ONLY = true
121
+ store.featureOnly = true
123
122
  return context.Feature(title, opts)
124
123
  }
125
124
 
@@ -171,7 +170,7 @@ export default function (suite) {
171
170
  context.Scenario.only = function (title, opts, fn) {
172
171
  const reString = `^${escapeRe(`${suites[0].title}: ${title}`.replace(/( \| {.+})?$/g, ''))}`
173
172
  mocha.grep(new RegExp(reString))
174
- process.env.SCENARIO_ONLY = true
173
+ store.scenarioOnly = true
175
174
  return addScenario(title, opts, fn)
176
175
  }
177
176
 
@@ -4,6 +4,7 @@ import { mkdirp } from 'mkdirp'
4
4
  import path from 'path'
5
5
  import { fileURLToPath } from 'url'
6
6
 
7
+ import store from '../store.js'
7
8
  import Container from '../container.js'
8
9
  import recorder from '../recorder.js'
9
10
  import event from '../event.js'
@@ -16,7 +17,7 @@ const supportedHelpers = Container.STANDARD_ACTING_HELPERS
16
17
  const defaultConfig = {
17
18
  deleteSuccessful: false,
18
19
  fullPageScreenshots: false,
19
- output: global.output_dir,
20
+ output: store.outputDir,
20
21
  captureHTML: true,
21
22
  captureARIA: true,
22
23
  captureBrowserLogs: true,
@@ -84,7 +85,7 @@ export default function (config) {
84
85
  let testFailed = false
85
86
  let firstFailedStepSaved = false
86
87
 
87
- const reportDir = config.output ? path.resolve(global.codecept_dir, config.output) : defaultConfig.output
88
+ const reportDir = config.output ? path.resolve(store.codeceptDir, config.output) : defaultConfig.output
88
89
 
89
90
  if (config.captureDebugOutput) {
90
91
  const originalDebug = output.debug
@@ -437,7 +438,7 @@ export default function (config) {
437
438
  const traceFile = path.join(dir, 'trace.md')
438
439
  fs.writeFileSync(traceFile, markdown)
439
440
 
440
- output.print(`🤖 AI Trace: ${colors.white.bold(`file://${traceFile}`)}`)
441
+ output.print(`Trace Saved: file://${traceFile}`)
441
442
 
442
443
  if (!test.artifacts) test.artifacts = {}
443
444
  test.artifacts.aiTrace = traceFile
@@ -353,7 +353,7 @@ function serializeError(error) {
353
353
  errorMessage +=
354
354
  '\n' +
355
355
  error.stack
356
- .replace(global.codecept_dir || '', '.')
356
+ .replace(store.codeceptDir || '', '.')
357
357
  .split('\n')
358
358
  .map(line => line.replace(ansiRegExp(), ''))
359
359
  .slice(0, 5)
@@ -321,7 +321,7 @@ export default function (config) {
321
321
  }
322
322
  if (config.saveToFile) {
323
323
  output.debug(`Saved user session into file for ${name}`)
324
- fs.writeFileSync(path.join(global.output_dir, `${name}_session.json`), JSON.stringify(cookies))
324
+ fs.writeFileSync(path.join(store.outputDir, `${name}_session.json`), JSON.stringify(cookies))
325
325
  }
326
326
  store[`${name}_session`] = cookies
327
327
  }
@@ -377,7 +377,7 @@ export default function (config) {
377
377
  }
378
378
 
379
379
  if (!config.saveToFile) return
380
- const cookieFile = path.join(global.output_dir, `${name}_session.json`)
380
+ const cookieFile = path.join(store.outputDir, `${name}_session.json`)
381
381
 
382
382
  if (!fileExists(cookieFile)) {
383
383
  return
@@ -412,7 +412,7 @@ export default function (config) {
412
412
 
413
413
  function loadCookiesFromFile(config) {
414
414
  for (const name in config.users) {
415
- const fileName = path.join(global.output_dir, `${name}_session.json`)
415
+ const fileName = path.join(store.outputDir, `${name}_session.json`)
416
416
  if (!fileExists(fileName)) continue
417
417
  const data = fs.readFileSync(fileName).toString()
418
418
  try {
@@ -6,6 +6,7 @@ import recorder from '../recorder.js'
6
6
  import event from '../event.js'
7
7
  import { scanForErrorMessages } from '../html.js'
8
8
  import { output } from '../index.js'
9
+ import store from '../store.js'
9
10
  import { humanizeString, ucfirst } from '../utils.js'
10
11
  import { testToFileName } from '../mocha/test.js'
11
12
  const defaultConfig = {
@@ -92,7 +93,7 @@ export default function (config = {}) {
92
93
  recorder.add('Save page info', () => {
93
94
  test.addNote('pageInfo', pageStateToMarkdown(pageState))
94
95
 
95
- const pageStateFileName = path.join(global.output_dir, `${testToFileName(test)}.pageInfo.md`)
96
+ const pageStateFileName = path.join(store.outputDir, `${testToFileName(test)}.pageInfo.md`)
96
97
  fs.writeFileSync(pageStateFileName, pageStateToMarkdown(pageState))
97
98
  test.artifacts.pageInfo = pageStateFileName
98
99
  return pageState
@@ -0,0 +1,167 @@
1
+ import event from '../event.js'
2
+ import pause from '../pause.js'
3
+ import recorder from '../recorder.js'
4
+ import Container from '../container.js'
5
+ import output from '../output.js'
6
+
7
+ const supportedHelpers = Container.STANDARD_ACTING_HELPERS
8
+
9
+ /**
10
+ * Pauses test execution in different modes. Unlike `pauseOnFail`, this plugin supports
11
+ * multiple triggers for pausing and is controlled via CLI arguments.
12
+ *
13
+ * Enable it via `-p` option with a mode:
14
+ *
15
+ * ```
16
+ * npx codeceptjs run -p pauseOn:fail
17
+ * npx codeceptjs run -p pauseOn:step
18
+ * npx codeceptjs run -p pauseOn:file:tests/login_test.js
19
+ * npx codeceptjs run -p pauseOn:file:tests/login_test.js:43
20
+ * npx codeceptjs run -p pauseOn:url:/users/*
21
+ * ```
22
+ *
23
+ * #### Modes
24
+ *
25
+ * * **fail** — pause when a step fails (same as `pauseOnFail` plugin)
26
+ * * **step** — pause before first step, use `next` to advance step-by-step
27
+ * * **file** — pause when execution reaches a specific file (and optionally line)
28
+ * * **url** — pause when the browser URL matches a pattern (supports `*` wildcards)
29
+ *
30
+ */
31
+ export default function (config = {}) {
32
+ const args = config._args || []
33
+ const mode = args[0] || 'fail'
34
+
35
+ switch (mode) {
36
+ case 'fail':
37
+ return initFailMode()
38
+ case 'step':
39
+ return initStepMode()
40
+ case 'file':
41
+ return initFileMode(args.slice(1))
42
+ case 'url':
43
+ return initUrlMode(args.slice(1))
44
+ default:
45
+ output.error(`pauseOn: unknown mode "${mode}". Available: fail, step, file, url`)
46
+ }
47
+ }
48
+
49
+ function initFailMode() {
50
+ let failed = false
51
+
52
+ event.dispatcher.on(event.test.started, () => {
53
+ failed = false
54
+ })
55
+
56
+ event.dispatcher.on(event.step.failed, () => {
57
+ failed = true
58
+ })
59
+
60
+ event.dispatcher.on(event.test.after, () => {
61
+ if (failed) pause()
62
+ })
63
+ }
64
+
65
+ function initStepMode() {
66
+ let activated = false
67
+
68
+ event.dispatcher.on(event.test.before, () => {
69
+ if (activated) return
70
+ activated = true
71
+ recorder.add('pauseOn:step', () => pause())
72
+ })
73
+ }
74
+
75
+ function initFileMode(fileArgs) {
76
+ if (fileArgs.length === 0) {
77
+ output.error('pauseOn:file requires a path. Usage: -p pauseOn:file:<path>[:<line>]')
78
+ return
79
+ }
80
+
81
+ const targetFile = fileArgs[0]
82
+ const targetLine = fileArgs[1] ? parseInt(fileArgs[1], 10) : null
83
+ let paused = false
84
+
85
+ event.dispatcher.on(event.step.before, (step) => {
86
+ if (paused) return
87
+
88
+ const stepLine = step.line()
89
+ if (!stepLine) return
90
+
91
+ const match = parseStepLine(stepLine)
92
+ if (!match) return
93
+
94
+ const fileMatches = match.file.includes(targetFile) || match.file.endsWith(targetFile)
95
+ if (!fileMatches) return
96
+
97
+ if (targetLine !== null && match.line !== targetLine) return
98
+
99
+ paused = true
100
+ recorder.add('pauseOn:file', () => pause())
101
+ })
102
+ }
103
+
104
+ function initUrlMode(urlArgs) {
105
+ if (urlArgs.length === 0) {
106
+ output.error('pauseOn:url requires a pattern. Usage: -p pauseOn:url:<pattern>')
107
+ return
108
+ }
109
+
110
+ const urlPattern = urlArgs.join(':')
111
+
112
+ const helpers = Container.helpers()
113
+ let helper = null
114
+ for (const helperName of supportedHelpers) {
115
+ if (Object.keys(helpers).indexOf(helperName) > -1) {
116
+ helper = helpers[helperName]
117
+ }
118
+ }
119
+
120
+ if (!helper) {
121
+ output.error('pauseOn:url requires a browser helper (Playwright, WebDriver, Puppeteer, Appium)')
122
+ return
123
+ }
124
+
125
+ const regex = patternToRegex(urlPattern)
126
+ let paused = false
127
+
128
+ event.dispatcher.on(event.step.after, () => {
129
+ if (paused) return
130
+
131
+ recorder.add('pauseOn:url check', async () => {
132
+ if (paused) return
133
+ try {
134
+ const currentUrl = await helper.grabCurrentUrl()
135
+ if (regex.test(currentUrl)) {
136
+ paused = true
137
+ return pause()
138
+ }
139
+ } catch (err) {
140
+ // page may not be loaded yet
141
+ }
142
+ })
143
+ })
144
+ }
145
+
146
+ function parseStepLine(stepLine) {
147
+ let line = stepLine.trim()
148
+ if (line.startsWith('at ')) line = line.substring(3).trim()
149
+
150
+ const lastColon = line.lastIndexOf(':')
151
+ if (lastColon < 0) return null
152
+ const secondLastColon = line.lastIndexOf(':', lastColon - 1)
153
+ if (secondLastColon < 0) return null
154
+
155
+ const file = line.substring(0, secondLastColon)
156
+ const lineNum = parseInt(line.substring(secondLastColon + 1, lastColon), 10)
157
+
158
+ if (isNaN(lineNum)) return null
159
+
160
+ return { file, line: lineNum }
161
+ }
162
+
163
+ function patternToRegex(pattern) {
164
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
165
+ const regexStr = escaped.replace(/\*/g, '.*')
166
+ return new RegExp(regexStr)
167
+ }
@@ -8,6 +8,7 @@ import recorder from '../recorder.js'
8
8
  import event from '../event.js'
9
9
 
10
10
  import output from '../output.js'
11
+ import store from '../store.js'
11
12
 
12
13
  import { fileExists } from '../utils.js'
13
14
  import Codeceptjs from '../index.js'
@@ -94,7 +95,7 @@ export default function (config) {
94
95
  } else {
95
96
  fileName = `${testToFileName(test, { suffix: '', unique: false })}.failed.png`
96
97
  }
97
- const quietMode = !('output_dir' in global) || !global.output_dir
98
+ const quietMode = !store.outputDir
98
99
  if (!quietMode) {
99
100
  output.plugin('screenshotOnFail', 'Test failed, try to save a screenshot')
100
101
  }
@@ -139,9 +140,7 @@ export default function (config) {
139
140
  await Promise.race([screenshotPromise, timeoutPromise])
140
141
 
141
142
  if (!test.artifacts) test.artifacts = {}
142
- // Some unit tests may not define global.output_dir; avoid throwing when it is undefined
143
- // Detect output directory safely (may not be initialized in narrow unit tests)
144
- const baseOutputDir = 'output_dir' in global && typeof global.output_dir === 'string' && global.output_dir ? global.output_dir : null
143
+ const baseOutputDir = store.outputDir || null
145
144
  if (baseOutputDir) {
146
145
  test.artifacts.screenshot = path.join(baseOutputDir, fileName)
147
146
  if (Container.mocha().options.reporterOptions['mocha-junit-reporter'] && Container.mocha().options.reporterOptions['mocha-junit-reporter'].options.attachments) {
@@ -4,8 +4,9 @@ import figures from 'figures'
4
4
  import fs from 'fs'
5
5
  import { mkdirp } from 'mkdirp'
6
6
  import path from 'path'
7
- import cheerio from 'cheerio'
7
+ import * as cheerio from 'cheerio'
8
8
 
9
+ import store from '../store.js'
9
10
  import Container from '../container.js'
10
11
  import recorder from '../recorder.js'
11
12
  import event from '../event.js'
@@ -19,7 +20,7 @@ const defaultConfig = {
19
20
  animateSlides: true,
20
21
  ignoreSteps: [],
21
22
  fullPageScreenshots: false,
22
- output: global.output_dir,
23
+ output: store.outputDir,
23
24
  screenshotsForAllureReport: false,
24
25
  disableScreenshotOnFail: true,
25
26
  }
@@ -87,7 +88,7 @@ export default function (config) {
87
88
 
88
89
  const recordedTests = {}
89
90
  const pad = '0000'
90
- const reportDir = config.output ? path.resolve(global.codecept_dir, config.output) : defaultConfig.output
91
+ const reportDir = config.output ? path.resolve(store.codeceptDir, config.output) : defaultConfig.output
91
92
 
92
93
  event.dispatcher.on(event.suite.before, suite => {
93
94
  stepNum = -1
@@ -198,7 +199,7 @@ export default function (config) {
198
199
  // Ignore steps from BeforeSuite function
199
200
  if (scenarioFailed && config.disableScreenshotOnFail) return
200
201
  if (step.metaStep && step.metaStep.name === 'BeforeSuite') return
201
- if (!step.test) return // Ignore steps from AfterSuite
202
+ if (!currentTest) return // Ignore steps from AfterSuite
202
203
 
203
204
  const fileName = `${pad.substring(0, pad.length - stepNum.toString().length) + stepNum.toString()}.png`
204
205
  if (step.status === 'failed') {
package/lib/rerun.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fsPath from 'path'
2
+ import store from './store.js'
2
3
  import container from './container.js'
3
4
  import event from './event.js'
4
5
  import BaseCodecept from './codecept.js'
@@ -29,7 +30,7 @@ class CodeceptRerunner extends BaseCodecept {
29
30
  let filesToRun = this.testFiles
30
31
  if (test) {
31
32
  if (!fsPath.isAbsolute(test)) {
32
- test = fsPath.join(global.codecept_dir, test)
33
+ test = fsPath.join(store.codeceptDir, test)
33
34
  }
34
35
  filesToRun = this.testFiles.filter(t => fsPath.basename(t, '.js') === test || t === test)
35
36
  }
package/lib/result.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs'
2
2
  import path from 'path'
3
3
  import { serializeTest } from './mocha/test.js'
4
+ import store from './store.js'
4
5
 
5
6
  /**
6
7
  * @typedef {Object} Stats Statistics for a test result.
@@ -212,7 +213,7 @@ class Result {
212
213
  */
213
214
  save(fileName) {
214
215
  if (!fileName) fileName = 'result.json'
215
- fs.writeFileSync(path.join(global.output_dir, fileName), JSON.stringify(this.simplify(), null, 2))
216
+ fs.writeFileSync(path.join(store.outputDir, fileName), JSON.stringify(this.simplify(), null, 2))
216
217
  }
217
218
 
218
219
  /**
package/lib/step/base.js CHANGED
@@ -3,6 +3,7 @@ import Secret from '../secret.js'
3
3
  import { getCurrentTimeout } from '../timeout.js'
4
4
  import { ucfirst, humanizeString, serializeError } from '../utils.js'
5
5
  import recordStep from './record.js'
6
+ import store from '../store.js'
6
7
 
7
8
  const STACK_LINE = 5
8
9
 
@@ -148,11 +149,11 @@ class Step {
148
149
  const lines = this.stack.split('\n')
149
150
  if (lines[STACK_LINE]) {
150
151
  let line = lines[STACK_LINE].trim()
151
- .replace(global.codecept_dir || '', '.')
152
+ .replace(store.codeceptDir || '', '.')
152
153
  .trim()
153
154
 
154
155
  // Map .temp.mjs back to original .ts files using container's tsFileMapping
155
- const fileMapping = global.container?.tsFileMapping?.()
156
+ const fileMapping = store.tsFileMapping
156
157
  if (line.includes('.temp.mjs') && fileMapping) {
157
158
  for (const [tsFile, mjsFile] of fileMapping.entries()) {
158
159
  if (line.includes(mjsFile)) {