codeceptjs 4.0.0-rc.11 → 4.0.0-rc.16

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 -1
  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 +178 -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
package/lib/ai.js CHANGED
@@ -7,6 +7,7 @@ import { generateText } from 'ai'
7
7
  import { fileURLToPath } from 'url'
8
8
  import path from 'path'
9
9
  import { fileExists } from './utils.js'
10
+ import store from './store.js'
10
11
 
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
13
 
@@ -24,8 +25,8 @@ async function loadPrompts() {
24
25
  for (const name of promptNames) {
25
26
  let promptPath
26
27
 
27
- if (global.codecept_dir) {
28
- promptPath = path.join(global.codecept_dir, `prompts/${name}.js`)
28
+ if (store.codeceptDir) {
29
+ promptPath = path.join(store.codeceptDir, `prompts/${name}.js`)
29
30
  }
30
31
 
31
32
  if (!promptPath || !fileExists(promptPath)) {
@@ -0,0 +1,18 @@
1
+ import Assertion from './assert.js'
2
+ import { equals, urlEquals, fileEquals } from './assert/equal.js'
3
+ import { includes, fileIncludes } from './assert/include.js'
4
+ import { empty } from './assert/empty.js'
5
+ import { truth } from './assert/truth.js'
6
+
7
+ export { Assertion, equals, urlEquals, fileEquals, includes, fileIncludes, empty, truth }
8
+
9
+ export default {
10
+ Assertion,
11
+ equals,
12
+ urlEquals,
13
+ fileEquals,
14
+ includes,
15
+ fileIncludes,
16
+ empty,
17
+ truth,
18
+ }
package/lib/codecept.js CHANGED
@@ -21,6 +21,7 @@ import { emptyFolder } from './utils.js'
21
21
  import { initCodeceptGlobals } from './globals.js'
22
22
  import { validateTypeScriptSetup, getTSNodeESMWarning } from './utils/loaderCheck.js'
23
23
  import recorder from './recorder.js'
24
+ import store from './store.js'
24
25
 
25
26
  import storeListener from './listener/store.js'
26
27
  import stepsListener from './listener/steps.js'
@@ -71,7 +72,7 @@ class Codecept {
71
72
  } else {
72
73
  // For npm packages, resolve from the user's directory
73
74
  // This ensures packages like tsx are found in user's node_modules
74
- const userDir = global.codecept_dir || process.cwd()
75
+ const userDir = store.codeceptDir || process.cwd()
75
76
 
76
77
  try {
77
78
  // Use createRequire to resolve from user's directory
@@ -102,8 +103,6 @@ class Codecept {
102
103
  await this.requireModules(this.requiringModules)
103
104
  // initializing listeners
104
105
  await container.create(this.config, this.opts)
105
- // Store container globally for easy access
106
- global.container = container
107
106
  await this.runHooks()
108
107
  }
109
108
 
@@ -129,6 +128,7 @@ class Codecept {
129
128
  './listener/config.js',
130
129
  './listener/result.js',
131
130
  './listener/helpers.js',
131
+ './listener/pageobjects.js',
132
132
  './listener/globalTimeout.js',
133
133
  './listener/globalRetry.js',
134
134
  './listener/retryEnhancer.js',
@@ -171,7 +171,7 @@ class Codecept {
171
171
  */
172
172
  loadTests(pattern) {
173
173
  const options = {
174
- cwd: global.codecept_dir,
174
+ cwd: store.codeceptDir,
175
175
  }
176
176
 
177
177
  let patterns = [pattern]
@@ -203,7 +203,7 @@ class Codecept {
203
203
  globSync(pattern, options).forEach(file => {
204
204
  if (file.includes('node_modules')) return
205
205
  if (!fsPath.isAbsolute(file)) {
206
- file = fsPath.join(global.codecept_dir, file)
206
+ file = fsPath.join(store.codeceptDir, file)
207
207
  }
208
208
  if (!this.testFiles.includes(fsPath.resolve(file))) {
209
209
  this.testFiles.push(fsPath.resolve(file))
@@ -293,7 +293,7 @@ class Codecept {
293
293
 
294
294
  if (test) {
295
295
  if (!fsPath.isAbsolute(test)) {
296
- test = fsPath.join(global.codecept_dir, test)
296
+ test = fsPath.join(store.codeceptDir, test)
297
297
  }
298
298
  const testBasename = fsPath.basename(test, '.js')
299
299
  const testFeatureBasename = fsPath.basename(test, '.feature')
@@ -135,6 +135,7 @@ export default async function (options) {
135
135
  printCheck('plugins', checks['plugins'], Object.keys(Container.plugins()).join(', '))
136
136
 
137
137
  if (Object.keys(helpers).length) {
138
+ store.dryRun = false
138
139
  const suite = Container.mocha().suite
139
140
  const test = createTest('test', () => {})
140
141
  checks.setup = true
@@ -154,7 +155,6 @@ export default async function (options) {
154
155
  checks.teardown = true
155
156
  for (const helper of Object.values(helpers).reverse()) {
156
157
  try {
157
- if (helper._passed) await helper._passed(test)
158
158
  if (helper._after) await helper._after(test)
159
159
  if (helper._finishTest) await helper._finishTest(suite)
160
160
  if (helper._afterSuite) await helper._afterSuite(suite)
@@ -166,6 +166,7 @@ export default async function (options) {
166
166
  }
167
167
 
168
168
  printCheck('Helpers After', checks['teardown'], standardActingHelpers.some(h => Object.keys(helpers).includes(h)) ? 'Closing browser' : '')
169
+ store.dryRun = true
169
170
  }
170
171
 
171
172
  try {
@@ -20,8 +20,7 @@ export default async function (test, options) {
20
20
  if (config.plugins) {
21
21
  // disable all plugins by default, they can be enabled with -p option
22
22
  for (const plugin in config.plugins) {
23
- // if `-p all` is passed, then enabling all plugins, otherwise plugins could be enabled by `-p customLocator,commentStep,tryTo`
24
- config.plugins[plugin].enabled = options.plugins === 'all'
23
+ config.plugins[plugin].enabled = false
25
24
  }
26
25
  }
27
26
 
@@ -50,7 +49,7 @@ async function printTests(files) {
50
49
  const { default: figures } = await import('figures')
51
50
  const { default: colors } = await import('chalk')
52
51
 
53
- output.print(output.styles.debug(`Tests from ${global.codecept_dir}:`))
52
+ output.print(output.styles.debug(`Tests from ${store.codeceptDir}:`))
54
53
  output.print()
55
54
 
56
55
  const mocha = Container.mocha()
@@ -5,6 +5,7 @@ import { mkdirp } from 'mkdirp'
5
5
  import path from 'path'
6
6
  import { fileExists, ucfirst, lcfirst, beautify } from '../utils.js'
7
7
  import output from '../output.js'
8
+ import store from '../store.js'
8
9
  import generateDefinitions from './definitions.js'
9
10
  import { getConfig, getTestRoot, safeFileWrite, readConfig } from './utils.js'
10
11
 
@@ -20,6 +21,7 @@ Scenario('test something', async ({ {{actor}} }) => {
20
21
  // generates empty test
21
22
  export async function test(genPath) {
22
23
  const testsPath = getTestRoot(genPath)
24
+ store.codeceptDir = testsPath
23
25
  global.codecept_dir = testsPath
24
26
  const config = await getConfig(testsPath)
25
27
  if (!config) return
@@ -8,6 +8,7 @@ import fsPath from 'path'
8
8
  import { getConfig, getTestRoot } from '../utils.js'
9
9
  import Codecept from '../../codecept.js'
10
10
  import output from '../../output.js'
11
+ import store from '../../store.js'
11
12
  import { matchStep } from '../../mocha/bdd.js'
12
13
 
13
14
  const uuidFn = IdGenerator.uuid()
@@ -43,9 +44,9 @@ export default async function (genPath, options) {
43
44
  }
44
45
 
45
46
  const files = []
46
- globSync(options.feature || config.gherkin.features, { cwd: options.feature ? '.' : global.codecept_dir }).forEach(file => {
47
+ globSync(options.feature || config.gherkin.features, { cwd: options.feature ? '.' : store.codeceptDir }).forEach(file => {
47
48
  if (!fsPath.isAbsolute(file)) {
48
- file = fsPath.join(global.codecept_dir, file)
49
+ file = fsPath.join(store.codeceptDir, file)
49
50
  }
50
51
  files.push(fsPath.resolve(file))
51
52
  })
@@ -92,7 +93,7 @@ export default async function (genPath, options) {
92
93
  if (child.scenario.keyword === 'Scenario Outline') continue // skip scenario outline
93
94
  parseSteps(child.scenario.steps)
94
95
  .map(step => {
95
- return Object.assign(step, { file: file.replace(global.codecept_dir, '').slice(1) })
96
+ return Object.assign(step, { file: file.replace(store.codeceptDir, '').slice(1) })
96
97
  })
97
98
  .map(step => newSteps.set(`${step.type}(${step})`, step))
98
99
  }
@@ -107,7 +108,7 @@ export default async function (genPath, options) {
107
108
  }
108
109
 
109
110
  if (!fsPath.isAbsolute(stepFile)) {
110
- stepFile = fsPath.join(global.codecept_dir, stepFile)
111
+ stepFile = fsPath.join(store.codeceptDir, stepFile)
111
112
  }
112
113
 
113
114
  const snippets = [...newSteps.values()]
@@ -19,6 +19,7 @@ const defaultConfig = {
19
19
  output: '',
20
20
  helpers: {},
21
21
  include: {},
22
+ noGlobals: true,
22
23
  plugins: {
23
24
  },
24
25
  }
@@ -8,6 +8,7 @@ import event from '../event.js'
8
8
  import { createRuns } from './run-multiple/collection.js'
9
9
  import { clearString, replaceValueDeep } from '../utils.js'
10
10
  import { getConfig, getTestRoot, fail } from './utils.js'
11
+ import store from '../store.js'
11
12
 
12
13
  const __filename = fileURLToPath(import.meta.url)
13
14
  const __dirname = path.dirname(__filename)
@@ -35,6 +36,7 @@ export default async function (selectedRuns, options) {
35
36
  const configFile = options.config
36
37
 
37
38
  const testRoot = getTestRoot(configFile)
39
+ store.codeceptDir = testRoot
38
40
  global.codecept_dir = testRoot
39
41
 
40
42
  // copy opts to run
@@ -41,6 +41,7 @@ export default async function (workerCount, selectedRuns, options) {
41
41
  output.print(`CodeceptJS v${Codecept.version()} ${output.standWithUkraine()}`)
42
42
  output.print(`Running tests in ${output.styles.bold(numberOfWorkers)} workers...`)
43
43
  store.hasWorkers = true
44
+ store.workerMode = true
44
45
  process.env.RUNS_WITH_WORKERS = 'true'
45
46
 
46
47
  const workers = new Workers(numberOfWorkers, config)
@@ -32,7 +32,7 @@ export default async function (test, options) {
32
32
  codecept.loadTests(test)
33
33
 
34
34
  if (options.verbose) {
35
- global.debugMode = true
35
+ store.debugMode = true
36
36
  const { getMachineInfo } = await import('./info.js')
37
37
  await getMachineInfo()
38
38
  }
@@ -21,8 +21,8 @@ const { options, tests, testRoot, workerIndex, poolMode } = workerData
21
21
 
22
22
  // Global error handlers to catch critical errors but not test failures
23
23
  process.on('uncaughtException', (err) => {
24
- if (global.container?.tsFileMapping && fixErrorStack) {
25
- const fileMapping = global.container.tsFileMapping()
24
+ if (container?.tsFileMapping && fixErrorStack) {
25
+ const fileMapping = container.tsFileMapping()
26
26
  if (fileMapping) {
27
27
  fixErrorStack(err, fileMapping)
28
28
  }
@@ -40,8 +40,8 @@ process.on('uncaughtException', (err) => {
40
40
  })
41
41
 
42
42
  process.on('unhandledRejection', (reason, promise) => {
43
- if (reason && typeof reason === 'object' && reason.stack && global.container?.tsFileMapping && fixErrorStack) {
44
- const fileMapping = global.container.tsFileMapping()
43
+ if (reason && typeof reason === 'object' && reason.stack && container?.tsFileMapping && fixErrorStack) {
44
+ const fileMapping = container.tsFileMapping()
45
45
  if (fileMapping) {
46
46
  fixErrorStack(reason, fileMapping)
47
47
  }
@@ -163,8 +163,8 @@ initPromise = (async function () {
163
163
  // IMPORTANT: await is required here since getConfig is async
164
164
  baseConfig = await getConfig(options.config || testRoot)
165
165
  } catch (configErr) {
166
- if (global.container?.tsFileMapping && fixErrorStack) {
167
- const fileMapping = global.container.tsFileMapping()
166
+ if (container?.tsFileMapping && fixErrorStack) {
167
+ const fileMapping = container.tsFileMapping()
168
168
  if (fileMapping) {
169
169
  fixErrorStack(configErr, fileMapping)
170
170
  }
@@ -185,8 +185,8 @@ initPromise = (async function () {
185
185
  try {
186
186
  await codecept.init(testRoot)
187
187
  } catch (initErr) {
188
- if (global.container?.tsFileMapping && fixErrorStack) {
189
- const fileMapping = global.container.tsFileMapping()
188
+ if (container?.tsFileMapping && fixErrorStack) {
189
+ const fileMapping = container.tsFileMapping()
190
190
  if (fileMapping) {
191
191
  fixErrorStack(initErr, fileMapping)
192
192
  }
@@ -218,8 +218,8 @@ initPromise = (async function () {
218
218
  parentPort?.close()
219
219
  }
220
220
  } catch (err) {
221
- if (global.container?.tsFileMapping && fixErrorStack) {
222
- const fileMapping = global.container.tsFileMapping()
221
+ if (container?.tsFileMapping && fixErrorStack) {
222
+ const fileMapping = container.tsFileMapping()
223
223
  if (fileMapping) {
224
224
  fixErrorStack(err, fileMapping)
225
225
  }
package/lib/container.js CHANGED
@@ -18,6 +18,11 @@ import actorFactory from './actor.js'
18
18
 
19
19
  let asyncHelperPromise
20
20
 
21
+ let beforeCalledSet = new Set()
22
+
23
+ export function getBeforeCalledSet() { return beforeCalledSet }
24
+ export function resetBeforeCalledSet() { beforeCalledSet = new Set() }
25
+
21
26
  let container = {
22
27
  helpers: {},
23
28
  support: {},
@@ -150,10 +155,23 @@ class Container {
150
155
  if (!name) {
151
156
  return container.proxySupport
152
157
  }
153
- // Always return the proxy to ensure MetaStep creation works
158
+ if (typeof container.support[name] === 'function') {
159
+ return container.support[name]
160
+ }
154
161
  return container.proxySupport[name]
155
162
  }
156
163
 
164
+ /**
165
+ * Get raw (non-proxied) support objects for direct access.
166
+ * Used by listeners to call lifecycle hooks without MetaStep wrapping.
167
+ *
168
+ * @api
169
+ * @returns {object}
170
+ */
171
+ static supportObjects() {
172
+ return container.support
173
+ }
174
+
157
175
  /**
158
176
  * Get all helpers or get a helper by name
159
177
  *
@@ -183,7 +201,7 @@ class Container {
183
201
  * @api
184
202
  */
185
203
  static tsFileMapping() {
186
- return container.tsFileMapping
204
+ return store.tsFileMapping
187
205
  }
188
206
 
189
207
  /**
@@ -426,11 +444,11 @@ async function requireHelperFromModule(helperName, config, HelperClass) {
426
444
  tempJsFile = allTempFiles
427
445
  fileMapping = mapping
428
446
  // Store file mapping in container for runtime error fixing (merge with existing)
429
- if (!container.tsFileMapping) {
430
- container.tsFileMapping = new Map()
447
+ if (!store.tsFileMapping) {
448
+ store.tsFileMapping = new Map()
431
449
  }
432
450
  for (const [key, value] of mapping.entries()) {
433
- container.tsFileMapping.set(key, value)
451
+ store.tsFileMapping.set(key, value)
434
452
  }
435
453
  } catch (tsError) {
436
454
  throw new Error(`Failed to load TypeScript helper ${importPath}: ${tsError.message}. Make sure 'typescript' package is installed.`)
@@ -542,6 +560,19 @@ function createSupportObjects(config) {
542
560
  let currentValue = currentObject[prop]
543
561
 
544
562
  if (isFunction(currentValue) || isAsyncFunction(currentValue)) {
563
+ if (prop.toString().charAt(0) !== '_' && currentObject._before && !beforeCalledSet.has(name)) {
564
+ beforeCalledSet.add(name)
565
+ const originalValue = currentValue
566
+ const wrappedValue = async function (...args) {
567
+ await currentObject._before()
568
+ return originalValue.apply(currentObject, args)
569
+ }
570
+ const ms = new MetaStep(name, prop)
571
+ ms.setContext(currentObject)
572
+ debug(`metastep is created for ${name}.${prop.toString()}() (with _before)`)
573
+ return ms.run.bind(ms, asyncWrapper(wrappedValue))
574
+ }
575
+
545
576
  const ms = new MetaStep(name, prop)
546
577
  ms.setContext(currentObject)
547
578
  if (isAsyncFunction(currentValue)) currentValue = asyncWrapper(currentValue)
@@ -600,6 +631,8 @@ function createSupportObjects(config) {
600
631
  let value
601
632
  if (container.sharedKeys.has(prop) && prop in container.support) {
602
633
  value = container.support[prop]
634
+ } else if (prop in container.support && typeof container.support[prop] === 'function') {
635
+ value = container.support[prop]
603
636
  } else {
604
637
  value = lazyLoad(prop)
605
638
  }
@@ -614,6 +647,9 @@ function createSupportObjects(config) {
614
647
  if (container.sharedKeys.has(key) && key in container.support) {
615
648
  return container.support[key]
616
649
  }
650
+ if (key in container.support && typeof container.support[key] === 'function') {
651
+ return container.support[key]
652
+ }
617
653
  return lazyLoad(key)
618
654
  },
619
655
  },
@@ -654,14 +690,28 @@ async function loadPluginFallback(modulePath, config) {
654
690
  async function createPlugins(config, options = {}) {
655
691
  const plugins = {}
656
692
 
657
- const enabledPluginsByOptions = (options.plugins || '').split(',')
693
+ const pluginOptionMap = new Map()
694
+ for (const token of (options.plugins || '').split(',').filter(Boolean)) {
695
+ const parts = token.split(':')
696
+ pluginOptionMap.set(parts[0], parts.slice(1))
697
+ }
698
+
699
+ for (const [name] of pluginOptionMap) {
700
+ if (!config[name]) config[name] = {}
701
+ }
702
+
658
703
  for (const pluginName in config) {
659
704
  if (!config[pluginName]) config[pluginName] = {}
660
705
  const pluginConfig = config[pluginName]
661
- if (!pluginConfig.enabled && enabledPluginsByOptions.indexOf(pluginName) < 0) {
706
+ const enabledByCli = pluginOptionMap.has(pluginName)
707
+ if (!pluginConfig.enabled && !enabledByCli) {
662
708
  continue // plugin is disabled
663
709
  }
664
710
 
711
+ if (enabledByCli && pluginOptionMap.get(pluginName).length > 0) {
712
+ pluginConfig._args = pluginOptionMap.get(pluginName)
713
+ }
714
+
665
715
  // Generic workers gate:
666
716
  // - runInWorker / runInWorkers controls plugin execution inside worker threads.
667
717
  // - runInParent / runInMain can disable plugin in workers parent process.
@@ -672,7 +722,7 @@ async function createPlugins(config, options = {}) {
672
722
  continue
673
723
  }
674
724
 
675
- if (!options.child && process.env.RUNS_WITH_WORKERS === 'true' && !runInParent) {
725
+ if (!options.child && store.workerMode && !runInParent) {
676
726
  continue
677
727
  }
678
728
  let module
@@ -681,7 +731,7 @@ async function createPlugins(config, options = {}) {
681
731
  module = pluginConfig.require
682
732
  if (module.startsWith('.')) {
683
733
  // local
684
- module = path.resolve(global.codecept_dir, module) // custom plugin
734
+ module = path.resolve(store.codeceptDir, module) // custom plugin
685
735
  }
686
736
  } else {
687
737
  module = `./plugin/${pluginName}.js`
@@ -716,7 +766,7 @@ async function loadGherkinStepsAsync(paths) {
716
766
  bddModule.clearCurrentStepFile()
717
767
  }
718
768
  } else {
719
- const folderPath = paths.startsWith('.') ? normalizeAndJoin(global.codecept_dir, paths) : ''
769
+ const folderPath = paths.startsWith('.') ? normalizeAndJoin(store.codeceptDir, paths) : ''
720
770
  if (folderPath !== '') {
721
771
  const files = globSync(folderPath)
722
772
  for (const file of files) {
@@ -764,7 +814,7 @@ async function loadSupportObject(modulePath, supportObjectName) {
764
814
  }
765
815
  }
766
816
  if (typeof modulePath === 'string' && modulePath.charAt(0) === '.') {
767
- modulePath = path.join(global.codecept_dir, modulePath)
817
+ modulePath = path.join(store.codeceptDir, modulePath)
768
818
  }
769
819
  try {
770
820
  // Use dynamic import for both ESM and CJS modules
@@ -888,7 +938,7 @@ async function loadTranslation(locale, vocabularies) {
888
938
  const langs = await Translation.getLangs()
889
939
  if (langs[locale]) {
890
940
  translation = new Translation(langs[locale])
891
- } else if (fileExists(path.join(global.codecept_dir, locale))) {
941
+ } else if (fileExists(path.join(store.codeceptDir, locale))) {
892
942
  // get from a provided file instead
893
943
  translation = Translation.createDefault()
894
944
  translation.loadVocabulary(locale)
@@ -905,7 +955,7 @@ function getHelperModuleName(helperName, config) {
905
955
  // classical require
906
956
  if (config[helperName].require) {
907
957
  if (config[helperName].require.startsWith('.')) {
908
- let helperPath = path.resolve(global.codecept_dir, config[helperName].require)
958
+ let helperPath = path.resolve(store.codeceptDir, config[helperName].require)
909
959
  // Add .js extension if not present for ESM compatibility
910
960
  if (!path.extname(helperPath)) {
911
961
  helperPath += '.js'
package/lib/effects.js CHANGED
@@ -4,6 +4,7 @@ import store from './store.js'
4
4
  import event from './event.js'
5
5
  import container from './container.js'
6
6
  import MetaStep from './step/meta.js'
7
+ import { empty } from './assert/empty.js'
7
8
  import { isAsyncFunction } from './utils.js'
8
9
 
9
10
  /**
@@ -111,6 +112,11 @@ class WithinStep extends MetaStep {
111
112
  *
112
113
  * @throws Will handle errors that occur during the callback execution. Errors are logged and attached as notes to the test.
113
114
  */
115
+ let hopeThatFailures = []
116
+ event.dispatcher.on(event.test.before, () => {
117
+ hopeThatFailures = []
118
+ })
119
+
114
120
  async function hopeThat(callback) {
115
121
  if (store.dryRun) return
116
122
  const sessionName = 'hopeThat'
@@ -131,6 +137,7 @@ async function hopeThat(callback) {
131
137
  result = false
132
138
  const msg = err.inspect ? err.inspect() : err.toString()
133
139
  output.debug(`Unsuccessful assertion > ${msg}`)
140
+ hopeThatFailures.push(msg)
134
141
  event.dispatcher.once(event.test.finished, test => {
135
142
  if (!test.notes) test.notes = []
136
143
  test.notes.push({ type: 'conditionalError', text: msg })
@@ -153,6 +160,16 @@ async function hopeThat(callback) {
153
160
  )
154
161
  }
155
162
 
163
+ /**
164
+ * Asserts that no `hopeThat` soft assertion has failed in the current test.
165
+ * Call once at the end of a scenario to fail it when any soft assertion failed.
166
+ */
167
+ hopeThat.noErrors = function () {
168
+ const failures = hopeThatFailures
169
+ hopeThatFailures = []
170
+ empty('soft assertions').assert(failures)
171
+ }
172
+
156
173
  /**
157
174
  * A CodeceptJS utility function to retry a step or callback multiple times with a specified polling interval.
158
175
  *
@@ -256,6 +256,134 @@ class WebElement {
256
256
  }
257
257
  }
258
258
 
259
+ /**
260
+ * Run a function in the browser with this element as the first argument.
261
+ * @param {Function} fn Browser-side function. Receives the element, then extra args.
262
+ * @param {...any} args Additional arguments passed to the function
263
+ * @returns {Promise<any>} Value returned by fn
264
+ */
265
+ async evaluate(fn, ...args) {
266
+ switch (this.helperType) {
267
+ case 'playwright':
268
+ case 'puppeteer':
269
+ return this.element.evaluate(fn, ...args)
270
+ case 'webdriver':
271
+ return this.helper.executeScript(fn, this.element, ...args)
272
+ default:
273
+ throw new Error(`Unsupported helper type: ${this.helperType}`)
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Focus the element.
279
+ * @returns {Promise<void>}
280
+ */
281
+ async focus() {
282
+ switch (this.helperType) {
283
+ case 'playwright':
284
+ return this.element.focus()
285
+ case 'puppeteer':
286
+ if (this.element.focus) return this.element.focus()
287
+ return this.element.evaluate(el => el.focus())
288
+ case 'webdriver':
289
+ return this.helper.executeScript(el => el.focus(), this.element)
290
+ default:
291
+ throw new Error(`Unsupported helper type: ${this.helperType}`)
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Type characters via the page/browser keyboard into the focused element.
297
+ * Unlike `type()`, this does not call `.fill()`/`.setValue()`, so it works
298
+ * with contenteditable nodes, iframe bodies, and editor-owned hidden textareas.
299
+ * @param {string} text Text to send
300
+ * @param {Object} [options] Options (e.g. `{ delay }`)
301
+ * @returns {Promise<void>}
302
+ */
303
+ async typeText(text, options = {}) {
304
+ const s = String(text)
305
+ switch (this.helperType) {
306
+ case 'playwright':
307
+ case 'puppeteer':
308
+ return this.helper.page.keyboard.type(s, options)
309
+ case 'webdriver': {
310
+ const ENTER = '\uE007'
311
+ const parts = s.split('\n')
312
+ for (let i = 0; i < parts.length; i++) {
313
+ if (parts[i]) await this.helper.browser.keys(parts[i])
314
+ if (i < parts.length - 1) await this.helper.browser.keys(ENTER)
315
+ }
316
+ return
317
+ }
318
+ default:
319
+ throw new Error(`Unsupported helper type: ${this.helperType}`)
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Select all content in the focused field and delete it via keyboard input.
325
+ * Sends Ctrl+A and Meta+A (so it works across platforms) followed by Backspace.
326
+ * @returns {Promise<void>}
327
+ */
328
+ async selectAllAndDelete() {
329
+ switch (this.helperType) {
330
+ case 'playwright':
331
+ await this.helper.page.keyboard.press('Control+a').catch(() => {})
332
+ await this.helper.page.keyboard.press('Meta+a').catch(() => {})
333
+ await this.helper.page.keyboard.press('Backspace')
334
+ return
335
+ case 'puppeteer':
336
+ for (const mod of ['Control', 'Meta']) {
337
+ try {
338
+ await this.helper.page.keyboard.down(mod)
339
+ await this.helper.page.keyboard.press('KeyA')
340
+ await this.helper.page.keyboard.up(mod)
341
+ } catch (e) {}
342
+ }
343
+ await this.helper.page.keyboard.press('Backspace')
344
+ return
345
+ case 'webdriver': {
346
+ const b = this.helper.browser
347
+ await b.keys(['Control', 'a']).catch(() => {})
348
+ await b.keys(['Meta', 'a']).catch(() => {})
349
+ await b.keys(['Backspace'])
350
+ return
351
+ }
352
+ default:
353
+ throw new Error(`Unsupported helper type: ${this.helperType}`)
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Treat this element as an iframe; invoke `fn` with a WebElement wrapping
359
+ * the iframe body. For WebDriver this switches the browser into the frame
360
+ * for the duration of the callback and switches back on exit.
361
+ * @param {(body: WebElement) => Promise<any>} fn
362
+ * @returns {Promise<any>} Return value of fn
363
+ */
364
+ async inIframe(fn) {
365
+ switch (this.helperType) {
366
+ case 'playwright':
367
+ case 'puppeteer': {
368
+ const frame = await this.element.contentFrame()
369
+ const body = await frame.$('body')
370
+ return fn(new WebElement(body, this.helper))
371
+ }
372
+ case 'webdriver': {
373
+ const browser = this.helper.browser
374
+ await browser.switchFrame(this.element)
375
+ try {
376
+ const body = await browser.$('body')
377
+ return await fn(new WebElement(body, this.helper))
378
+ } finally {
379
+ await browser.switchFrame(null)
380
+ }
381
+ }
382
+ default:
383
+ throw new Error(`Unsupported helper type: ${this.helperType}`)
384
+ }
385
+ }
386
+
259
387
  /**
260
388
  * Find first child element matching the locator
261
389
  * @param {string|Object} locator Element locator