codeceptjs 4.0.0-beta.6.esm-aria → 4.0.0-beta.8.esm-aria

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 (69) hide show
  1. package/README.md +46 -3
  2. package/bin/codecept.js +9 -0
  3. package/bin/test-server.js +64 -0
  4. package/docs/webapi/click.mustache +5 -1
  5. package/lib/ai.js +66 -102
  6. package/lib/codecept.js +99 -24
  7. package/lib/command/generate.js +33 -1
  8. package/lib/command/init.js +7 -3
  9. package/lib/command/run-workers.js +31 -2
  10. package/lib/command/run.js +15 -0
  11. package/lib/command/workers/runTests.js +331 -58
  12. package/lib/config.js +16 -5
  13. package/lib/container.js +15 -13
  14. package/lib/effects.js +1 -1
  15. package/lib/element/WebElement.js +327 -0
  16. package/lib/event.js +10 -1
  17. package/lib/helper/AI.js +11 -11
  18. package/lib/helper/ApiDataFactory.js +34 -6
  19. package/lib/helper/Appium.js +156 -42
  20. package/lib/helper/GraphQL.js +3 -3
  21. package/lib/helper/GraphQLDataFactory.js +4 -4
  22. package/lib/helper/JSONResponse.js +48 -40
  23. package/lib/helper/Mochawesome.js +24 -2
  24. package/lib/helper/Playwright.js +841 -153
  25. package/lib/helper/Puppeteer.js +263 -67
  26. package/lib/helper/REST.js +21 -0
  27. package/lib/helper/WebDriver.js +116 -26
  28. package/lib/helper/clientscripts/PollyWebDriverExt.js +1 -1
  29. package/lib/helper/extras/PlaywrightReactVueLocator.js +52 -0
  30. package/lib/helper/extras/PlaywrightRestartOpts.js +12 -1
  31. package/lib/helper/network/actions.js +8 -6
  32. package/lib/listener/config.js +11 -3
  33. package/lib/listener/enhancedGlobalRetry.js +110 -0
  34. package/lib/listener/globalTimeout.js +19 -4
  35. package/lib/listener/helpers.js +8 -2
  36. package/lib/listener/retryEnhancer.js +85 -0
  37. package/lib/listener/steps.js +12 -0
  38. package/lib/mocha/asyncWrapper.js +13 -3
  39. package/lib/mocha/cli.js +1 -1
  40. package/lib/mocha/factory.js +3 -0
  41. package/lib/mocha/gherkin.js +1 -1
  42. package/lib/mocha/test.js +6 -0
  43. package/lib/mocha/ui.js +13 -0
  44. package/lib/output.js +62 -18
  45. package/lib/plugin/coverage.js +16 -3
  46. package/lib/plugin/enhancedRetryFailedStep.js +99 -0
  47. package/lib/plugin/htmlReporter.js +3648 -0
  48. package/lib/plugin/retryFailedStep.js +1 -0
  49. package/lib/plugin/stepByStepReport.js +1 -1
  50. package/lib/recorder.js +28 -3
  51. package/lib/result.js +100 -23
  52. package/lib/retryCoordinator.js +207 -0
  53. package/lib/step/base.js +1 -1
  54. package/lib/step/comment.js +2 -2
  55. package/lib/step/meta.js +1 -1
  56. package/lib/template/heal.js +1 -1
  57. package/lib/template/prompts/generatePageObject.js +31 -0
  58. package/lib/template/prompts/healStep.js +13 -0
  59. package/lib/template/prompts/writeStep.js +9 -0
  60. package/lib/test-server.js +334 -0
  61. package/lib/utils/mask_data.js +47 -0
  62. package/lib/utils.js +87 -6
  63. package/lib/workerStorage.js +2 -1
  64. package/lib/workers.js +179 -23
  65. package/package.json +60 -52
  66. package/translations/utils.js +2 -10
  67. package/typings/index.d.ts +19 -7
  68. package/typings/promiseBasedTypes.d.ts +5525 -3759
  69. package/typings/types.d.ts +5791 -3781
@@ -3,6 +3,7 @@ let webdriverio
3
3
  import assert from 'assert'
4
4
  import path from 'path'
5
5
  import crypto from 'crypto'
6
+
6
7
  import Helper from '@codeceptjs/helper'
7
8
  import promiseRetry from 'promise-retry'
8
9
  import { includes as stringIncludes } from '../assert/include.js'
@@ -22,6 +23,7 @@ import { focusElement } from './scripts/focusElement.js'
22
23
  import { blurElement } from './scripts/blurElement.js'
23
24
  import { dontSeeElementError, seeElementError, seeElementInDOMError, dontSeeElementInDOMError } from './errors/ElementAssertion.js'
24
25
  import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
26
+ import WebElement from '../element/WebElement.js'
25
27
 
26
28
  const SHADOW = 'shadow'
27
29
  const webRoot = 'body'
@@ -504,7 +506,7 @@ class WebDriver extends Helper {
504
506
  }
505
507
  config.capabilities.browserName = config.browser || config.capabilities.browserName
506
508
 
507
- // WebDriver Bidi Protocol. Default: false
509
+ // WebDriver Bidi Protocol. Default: true
508
510
  config.capabilities.webSocketUrl = config.bidiProtocol ?? config.capabilities.webSocketUrl ?? true
509
511
 
510
512
  config.capabilities.browserVersion = config.browserVersion || config.capabilities.browserVersion
@@ -656,8 +658,11 @@ class WebDriver extends Helper {
656
658
 
657
659
  this.browser.on('dialog', () => {})
658
660
 
659
- await this.browser.sessionSubscribe({ events: ['log.entryAdded'] })
660
- this.browser.on('log.entryAdded', logEvents)
661
+ // Check for Bidi, because "sessionSubscribe" is an exclusive Bidi protocol feature. Otherwise, error will be thrown.
662
+ if (this.browser.capabilities && this.browser.capabilities.webSocketUrl) {
663
+ await this.browser.sessionSubscribe({ events: ['log.entryAdded'] })
664
+ this.browser.on('log.entryAdded', logEvents)
665
+ }
661
666
 
662
667
  return this.browser
663
668
  }
@@ -1004,7 +1009,20 @@ class WebDriver extends Helper {
1004
1009
  *
1005
1010
  */
1006
1011
  async grabWebElements(locator) {
1007
- return this._locate(locator)
1012
+ const elements = await this._locate(locator)
1013
+ return elements.map(element => new WebElement(element, this))
1014
+ }
1015
+
1016
+ /**
1017
+ * {{> grabWebElement }}
1018
+ *
1019
+ */
1020
+ async grabWebElement(locator) {
1021
+ const elements = await this._locate(locator)
1022
+ if (elements.length === 0) {
1023
+ throw new ElementNotFound(locator, 'Element')
1024
+ }
1025
+ return new WebElement(elements[0], this)
1008
1026
  }
1009
1027
 
1010
1028
  /**
@@ -1049,7 +1067,7 @@ class WebDriver extends Helper {
1049
1067
  * {{ react }}
1050
1068
  */
1051
1069
  async click(locator, context = null) {
1052
- const clickMethod = this.browser.isMobile && !this.browser.isW3C ? 'touchClick' : 'elementClick'
1070
+ const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick'
1053
1071
  const locateFn = prepareLocateFn.call(this, context)
1054
1072
 
1055
1073
  const res = await findClickable.call(this, locator, locateFn)
@@ -1136,6 +1154,75 @@ class WebDriver extends Helper {
1136
1154
  await this.browser.buttonDown(2)
1137
1155
  }
1138
1156
 
1157
+ /**
1158
+ * Performs click at specific coordinates.
1159
+ * If locator is provided, the coordinates are relative to the element's top-left corner.
1160
+ * If locator is not provided, the coordinates are relative to the body element.
1161
+ *
1162
+ * ```js
1163
+ * // Click at coordinates (100, 200) relative to body
1164
+ * I.clickXY(100, 200);
1165
+ *
1166
+ * // Click at coordinates (50, 30) relative to element's top-left corner
1167
+ * I.clickXY('#someElement', 50, 30);
1168
+ * ```
1169
+ *
1170
+ * @param {CodeceptJS.LocatorOrString|number} locator Element to click on or X coordinate if no element.
1171
+ * @param {number} [x] X coordinate relative to element's top-left, or Y coordinate if locator is a number.
1172
+ * @param {number} [y] Y coordinate relative to element's top-left.
1173
+ * @returns {Promise<void>}
1174
+ */
1175
+ async clickXY(locator, x, y) {
1176
+ // If locator is a number, treat it as X coordinate and use body as base
1177
+ if (typeof locator === 'number') {
1178
+ const globalX = locator
1179
+ const globalY = x
1180
+ locator = '//body'
1181
+ x = globalX
1182
+ y = globalY
1183
+ }
1184
+
1185
+ // Locate the base element
1186
+ const res = await this._locate(withStrictLocator(locator), true)
1187
+ assertElementExists(res, locator, 'Element to click')
1188
+ const el = usingFirstElement(res)
1189
+
1190
+ // Get element position and size to calculate top-left corner
1191
+ const location = await el.getLocation()
1192
+ const size = await el.getSize()
1193
+
1194
+ // WebDriver clicks at center by default, so we need to offset from center to top-left
1195
+ // then add our desired x, y coordinates
1196
+ const offsetX = -(size.width / 2) + x
1197
+ const offsetY = -(size.height / 2) + y
1198
+
1199
+ if (this.browser.isW3C) {
1200
+ // Use performActions for W3C WebDriver
1201
+ return this.browser.performActions([
1202
+ {
1203
+ type: 'pointer',
1204
+ id: 'pointer1',
1205
+ parameters: { pointerType: 'mouse' },
1206
+ actions: [
1207
+ {
1208
+ type: 'pointerMove',
1209
+ origin: el,
1210
+ duration: 0,
1211
+ x: Math.round(offsetX),
1212
+ y: Math.round(offsetY),
1213
+ },
1214
+ { type: 'pointerDown', button: 0 },
1215
+ { type: 'pointerUp', button: 0 },
1216
+ ],
1217
+ },
1218
+ ])
1219
+ }
1220
+
1221
+ // Fallback for non-W3C browsers
1222
+ await el.moveTo({ xOffset: Math.round(offsetX), yOffset: Math.round(offsetY) })
1223
+ return el.click()
1224
+ }
1225
+
1139
1226
  /**
1140
1227
  * {{> forceRightClick }}
1141
1228
  *
@@ -1173,7 +1260,17 @@ class WebDriver extends Helper {
1173
1260
  assertElementExists(res, field, 'Field')
1174
1261
  const elem = usingFirstElement(res)
1175
1262
  highlightActiveElement.call(this, elem)
1176
- await elem.clearValue()
1263
+ try {
1264
+ await elem.clearValue()
1265
+ } catch (err) {
1266
+ if (err.message && err.message.includes('invalid element state')) {
1267
+ await this.executeScript(el => {
1268
+ el.value = ''
1269
+ }, elem)
1270
+ } else {
1271
+ throw err
1272
+ }
1273
+ }
1177
1274
  await elem.setValue(value.toString())
1178
1275
  }
1179
1276
 
@@ -1268,7 +1365,7 @@ class WebDriver extends Helper {
1268
1365
  * {{> checkOption }}
1269
1366
  */
1270
1367
  async checkOption(field, context = null) {
1271
- const clickMethod = this.browser.isMobile && !this.browser.isW3C ? 'touchClick' : 'elementClick'
1368
+ const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick'
1272
1369
  const locateFn = prepareLocateFn.call(this, context)
1273
1370
 
1274
1371
  const res = await findCheckable.call(this, field, locateFn)
@@ -1289,7 +1386,7 @@ class WebDriver extends Helper {
1289
1386
  * {{> uncheckOption }}
1290
1387
  */
1291
1388
  async uncheckOption(field, context = null) {
1292
- const clickMethod = this.browser.isMobile && !this.browser.isW3C ? 'touchClick' : 'elementClick'
1389
+ const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick'
1293
1390
  const locateFn = prepareLocateFn.call(this, context)
1294
1391
 
1295
1392
  const res = await findCheckable.call(this, field, locateFn)
@@ -2880,20 +2977,20 @@ async function findClickable(locator, locateFn) {
2880
2977
  els = await locateFn(Locator.clickable.narrow(literal))
2881
2978
  if (els.length) return els
2882
2979
 
2883
- els = await locateFn(Locator.clickable.wide(literal))
2884
- if (els.length) return els
2885
-
2886
- els = await locateFn(Locator.clickable.self(literal))
2887
- if (els.length) return els
2888
-
2889
2980
  // Try ARIA selector for accessible name
2890
2981
  try {
2891
- els = await this.browser.$$(`aria/${locator.value}`)
2982
+ els = await locateFn(`aria/${locator.value}`)
2892
2983
  if (els.length) return els
2893
2984
  } catch (e) {
2894
2985
  // ARIA selector not supported or failed
2895
2986
  }
2896
2987
 
2988
+ els = await locateFn(Locator.clickable.wide(literal))
2989
+ if (els.length) return els
2990
+
2991
+ els = await locateFn(Locator.clickable.self(literal))
2992
+ if (els.length) return els
2993
+
2897
2994
  return await locateFn(locator.value) // by css or xpath
2898
2995
  }
2899
2996
 
@@ -2918,14 +3015,6 @@ async function findFields(locator) {
2918
3015
  els = await this._locate(Locator.field.byName(literal))
2919
3016
  if (els.length) return els
2920
3017
 
2921
- // Try ARIA selector for accessible name
2922
- try {
2923
- els = await this.browser.$$(`aria/${locator.value}`)
2924
- if (els.length) return els
2925
- } catch (e) {
2926
- // ARIA selector not supported or failed
2927
- }
2928
-
2929
3018
  return await this._locate(locator.value) // by css or xpath
2930
3019
  }
2931
3020
 
@@ -3066,17 +3155,18 @@ async function findCheckable(locator, locateFn) {
3066
3155
  const literal = xpathLocator.literal(locator.value)
3067
3156
  els = await locateFn(Locator.checkable.byText(literal))
3068
3157
  if (els.length) return els
3069
- els = await locateFn(Locator.checkable.byName(literal))
3070
- if (els.length) return els
3071
3158
 
3072
3159
  // Try ARIA selector for accessible name
3073
3160
  try {
3074
- els = await this.browser.$$(`aria/${locator.value}`)
3161
+ els = await locateFn(`aria/${locator.value}`)
3075
3162
  if (els.length) return els
3076
3163
  } catch (e) {
3077
3164
  // ARIA selector not supported or failed
3078
3165
  }
3079
3166
 
3167
+ els = await locateFn(Locator.checkable.byName(literal))
3168
+ if (els.length) return els
3169
+
3080
3170
  return await locateFn(locator.value) // by css or xpath
3081
3171
  }
3082
3172
 
@@ -108,4 +108,4 @@ const pollyWebDriver = {
108
108
  },
109
109
  };
110
110
 
111
- module.exports = pollyWebDriver;
111
+ export default pollyWebDriver;
@@ -0,0 +1,52 @@
1
+ async function findReact(matcher, locator) {
2
+ // Handle both Locator objects and raw locator objects
3
+ const reactLocator = locator.locator || locator
4
+ let _locator = `_react=${reactLocator.react}`;
5
+ let props = '';
6
+
7
+ if (reactLocator.props) {
8
+ props += propBuilder(reactLocator.props);
9
+ _locator += props;
10
+ }
11
+ return matcher.locator(_locator).all();
12
+ }
13
+
14
+ async function findVue(matcher, locator) {
15
+ // Handle both Locator objects and raw locator objects
16
+ const vueLocator = locator.locator || locator
17
+ let _locator = `_vue=${vueLocator.vue}`;
18
+ let props = '';
19
+
20
+ if (vueLocator.props) {
21
+ props += propBuilder(vueLocator.props);
22
+ _locator += props;
23
+ }
24
+ return matcher.locator(_locator).all();
25
+ }
26
+
27
+ async function findByPlaywrightLocator(matcher, locator) {
28
+ // Handle both Locator objects and raw locator objects
29
+ const pwLocator = locator.locator || locator
30
+ if (pwLocator && pwLocator.toString && pwLocator.toString().includes(process.env.testIdAttribute)) {
31
+ return matcher.getByTestId(pwLocator.pw.value.split('=')[1]);
32
+ }
33
+ const pwValue = typeof pwLocator.pw === 'string' ? pwLocator.pw : pwLocator.pw
34
+ return matcher.locator(pwValue).all();
35
+ }
36
+
37
+ function propBuilder(props) {
38
+ let _props = '';
39
+
40
+ for (const [key, value] of Object.entries(props)) {
41
+ if (typeof value === 'object') {
42
+ for (const [k, v] of Object.entries(value)) {
43
+ _props += `[${key}.${k} = "${v}"]`;
44
+ }
45
+ } else {
46
+ _props += `[${key} = "${value}"]`;
47
+ }
48
+ }
49
+ return _props;
50
+ }
51
+
52
+ export { findReact, findVue, findByPlaywrightLocator };
@@ -1,6 +1,7 @@
1
1
  const RESTART_OPTS = {
2
2
  session: 'keep',
3
3
  context: false,
4
+ browser: true,
4
5
  }
5
6
 
6
7
  let restarts = null
@@ -19,9 +20,15 @@ export function setRestartStrategy(options) {
19
20
  return
20
21
  }
21
22
 
23
+ // When restart is true, map to 'browser' restart
24
+ if (restart === true) {
25
+ restarts = 'browser'
26
+ return
27
+ }
28
+
22
29
  restarts = Object.keys(RESTART_OPTS).find(key => RESTART_OPTS[key] === restart)
23
30
 
24
- if (restarts === null || restarts === undefined) throw new Error('No restart strategy set, use the following values for restart: session, context')
31
+ if (restarts === null || restarts === undefined) throw new Error('No restart strategy set, use the following values for restart: session, context, browser')
25
32
  }
26
33
 
27
34
  export function restartsSession() {
@@ -31,3 +38,7 @@ export function restartsSession() {
31
38
  export function restartsContext() {
32
39
  return restarts === 'context'
33
40
  }
41
+
42
+ export function restartsBrowser() {
43
+ return restarts === 'browser'
44
+ }
@@ -28,8 +28,8 @@ async function seeTraffic({ name, url, parameters, requestPostData, timeout = 10
28
28
  throw new Error('Missing required key "url" in object given to "I.seeTraffic".')
29
29
  }
30
30
 
31
- if (!this.recording || !this.recordedAtLeastOnce) {
32
- throw new Error('Failure in test automation. You use "I.seeTraffic", but "I.startRecordingTraffic" was never called before.')
31
+ if (!this.recordedAtLeastOnce) {
32
+ throw new Error('Failure in test automation. You use "I.seeTraffic", but "I.startRecordingTraffic" was never called before.');
33
33
  }
34
34
 
35
35
  for (let i = 0; i <= timeout * 2; i++) {
@@ -57,8 +57,8 @@ async function seeTraffic({ name, url, parameters, requestPostData, timeout = 10
57
57
  }
58
58
 
59
59
  async function grabRecordedNetworkTraffics() {
60
- if (!this.recording || !this.recordedAtLeastOnce) {
61
- throw new Error('Failure in test automation. You use "I.grabRecordedNetworkTraffics", but "I.startRecordingTraffic" was never called before.')
60
+ if (!this.recordedAtLeastOnce) {
61
+ throw new Error('Failure in test automation. You use "I.grabRecordedNetworkTraffics", but "I.startRecordingTraffic" was never called before.');
62
62
  }
63
63
 
64
64
  const promises = this.requests.map(async request => {
@@ -97,8 +97,10 @@ async function grabRecordedNetworkTraffics() {
97
97
 
98
98
  function stopRecordingTraffic() {
99
99
  // @ts-ignore
100
- this.page.removeAllListeners('request')
101
- this.recording = false
100
+ this.page.removeAllListeners('request');
101
+ // @ts-ignore
102
+ this.page.removeAllListeners('requestfinished');
103
+ this.recording = false;
102
104
  }
103
105
 
104
106
  function flushNetworkTraffics() {
@@ -2,10 +2,17 @@ import event from '../event.js'
2
2
  import recorder from '../recorder.js'
3
3
  import { deepMerge, deepClone, ucfirst } from '../utils.js'
4
4
  import output from '../output.js'
5
+
5
6
  /**
6
7
  * Enable Helpers to listen to test events
7
8
  */
8
9
  export default function () {
10
+ // Use global flag to prevent duplicate initialization across module re-imports
11
+ if (global.__codeceptConfigListenerInitialized) {
12
+ return
13
+ }
14
+ global.__codeceptConfigListenerInitialized = true
15
+
9
16
  const helpers = global.container.helpers()
10
17
 
11
18
  enableDynamicConfigFor('suite')
@@ -14,7 +21,7 @@ export default function () {
14
21
  function enableDynamicConfigFor(type) {
15
22
  event.dispatcher.on(event[type].before, (context = {}) => {
16
23
  function updateHelperConfig(helper, config) {
17
- const oldConfig = { ...helper.options }
24
+ const oldConfig = deepClone(helper.options)
18
25
  try {
19
26
  helper._setConfig(deepMerge(deepClone(oldConfig), config))
20
27
  output.debug(`[${ucfirst(type)} Config] ${helper.constructor.name} ${JSON.stringify(config)}`)
@@ -22,10 +29,11 @@ export default function () {
22
29
  recorder.throw(err)
23
30
  return
24
31
  }
25
- event.dispatcher.once(event[type].after, () => {
32
+ const restoreCallback = () => {
26
33
  helper._setConfig(oldConfig)
27
34
  output.debug(`[${ucfirst(type)} Config] Reverted for ${helper.constructor.name}`)
28
- })
35
+ }
36
+ event.dispatcher.once(event[type].after, restoreCallback)
29
37
  }
30
38
 
31
39
  // change config
@@ -0,0 +1,110 @@
1
+ import event from '../event.js'
2
+ import output from '../output.js'
3
+ import Config from '../config.js'
4
+ import { isNotSet } from '../utils.js'
5
+
6
+ const hooks = ['Before', 'After', 'BeforeSuite', 'AfterSuite']
7
+
8
+ /**
9
+ * Priority levels for retry mechanisms (higher number = higher priority)
10
+ * This ensures consistent behavior when multiple retry mechanisms are active
11
+ */
12
+ const RETRY_PRIORITIES = {
13
+ MANUAL_STEP: 100, // I.retry() or step.retry() - highest priority
14
+ STEP_PLUGIN: 50, // retryFailedStep plugin
15
+ SCENARIO_CONFIG: 30, // Global scenario retry config
16
+ FEATURE_CONFIG: 20, // Global feature retry config
17
+ HOOK_CONFIG: 10, // Hook retry config - lowest priority
18
+ }
19
+
20
+ /**
21
+ * Enhanced global retry mechanism that coordinates with other retry types
22
+ */
23
+ export default function () {
24
+ event.dispatcher.on(event.suite.before, suite => {
25
+ let retryConfig = Config.get('retry')
26
+ if (!retryConfig) return
27
+
28
+ if (Number.isInteger(+retryConfig)) {
29
+ // is number - apply as feature-level retry
30
+ const retryNum = +retryConfig
31
+ output.log(`[Global Retry] Feature retries: ${retryNum}`)
32
+
33
+ // Only set if not already set by higher priority mechanism
34
+ if (isNotSet(suite.retries())) {
35
+ suite.retries(retryNum)
36
+ suite.opts.retryPriority = RETRY_PRIORITIES.FEATURE_CONFIG
37
+ }
38
+ return
39
+ }
40
+
41
+ if (!Array.isArray(retryConfig)) {
42
+ retryConfig = [retryConfig]
43
+ }
44
+
45
+ for (const config of retryConfig) {
46
+ if (config.grep) {
47
+ if (!suite.title.includes(config.grep)) continue
48
+ }
49
+
50
+ // Handle hook retries with priority awareness
51
+ hooks
52
+ .filter(hook => !!config[hook])
53
+ .forEach(hook => {
54
+ const retryKey = `retry${hook}`
55
+ if (isNotSet(suite.opts[retryKey])) {
56
+ suite.opts[retryKey] = config[hook]
57
+ suite.opts[`${retryKey}Priority`] = RETRY_PRIORITIES.HOOK_CONFIG
58
+ }
59
+ })
60
+
61
+ // Handle feature-level retries
62
+ if (config.Feature) {
63
+ if (isNotSet(suite.retries()) || (suite.opts.retryPriority || 0) <= RETRY_PRIORITIES.FEATURE_CONFIG) {
64
+ suite.retries(config.Feature)
65
+ suite.opts.retryPriority = RETRY_PRIORITIES.FEATURE_CONFIG
66
+ output.log(`[Global Retry] Feature retries: ${config.Feature}`)
67
+ }
68
+ }
69
+ }
70
+ })
71
+
72
+ event.dispatcher.on(event.test.before, test => {
73
+ let retryConfig = Config.get('retry')
74
+ if (!retryConfig) return
75
+
76
+ if (Number.isInteger(+retryConfig)) {
77
+ // Only set if not already set by higher priority mechanism
78
+ if (test.retries() === -1) {
79
+ test.retries(retryConfig)
80
+ test.opts.retryPriority = RETRY_PRIORITIES.SCENARIO_CONFIG
81
+ output.log(`[Global Retry] Scenario retries: ${retryConfig}`)
82
+ }
83
+ return
84
+ }
85
+
86
+ if (!Array.isArray(retryConfig)) {
87
+ retryConfig = [retryConfig]
88
+ }
89
+
90
+ retryConfig = retryConfig.filter(config => !!config.Scenario)
91
+
92
+ for (const config of retryConfig) {
93
+ if (config.grep) {
94
+ if (!test.fullTitle().includes(config.grep)) continue
95
+ }
96
+
97
+ if (config.Scenario) {
98
+ // Respect priority system
99
+ if (test.retries() === -1 || (test.opts.retryPriority || 0) <= RETRY_PRIORITIES.SCENARIO_CONFIG) {
100
+ test.retries(config.Scenario)
101
+ test.opts.retryPriority = RETRY_PRIORITIES.SCENARIO_CONFIG
102
+ output.log(`[Global Retry] Scenario retries: ${config.Scenario}`)
103
+ }
104
+ }
105
+ }
106
+ })
107
+ }
108
+
109
+ // Export priority constants for use by other retry mechanisms
110
+ export { RETRY_PRIORITIES }
@@ -21,14 +21,29 @@ export default function () {
21
21
 
22
22
  // disable timeout for BeforeSuite/AfterSuite hooks
23
23
  // add separate configs to them?
24
+ // When a BeforeSuite/AfterSuite hook starts we want to disable the
25
+ // per-test timeout during that hook execution only. Previously the
26
+ // code cleared `suiteTimeout` permanently which caused the suite
27
+ // level timeout to be lost for subsequent tests. Save previous
28
+ // values and restore them when the hook finishes.
29
+ let __prevTimeout = undefined
30
+ let __prevSuiteTimeout = undefined
31
+
24
32
  event.dispatcher.on(event.hook.started, hook => {
25
- if (hook instanceof BeforeSuiteHook) {
33
+ if (hook instanceof BeforeSuiteHook || hook instanceof AfterSuiteHook) {
34
+ __prevTimeout = timeout
35
+ // copy array to preserve original values
36
+ __prevSuiteTimeout = suiteTimeout.slice()
26
37
  timeout = null
27
38
  suiteTimeout = []
28
39
  }
29
- if (hook instanceof AfterSuiteHook) {
30
- timeout = null
31
- suiteTimeout = []
40
+ })
41
+
42
+ event.dispatcher.on(event.hook.finished, hook => {
43
+ if (hook instanceof BeforeSuiteHook || hook instanceof AfterSuiteHook) {
44
+ // restore previously stored values
45
+ timeout = __prevTimeout
46
+ suiteTimeout = __prevSuiteTimeout.slice()
32
47
  }
33
48
  })
34
49
 
@@ -74,7 +74,10 @@ export default function () {
74
74
 
75
75
  event.dispatcher.on(event.all.result, () => {
76
76
  // Skip _finishTest for all helpers if any browser helper restarts to avoid double cleanup
77
- const hasBrowserRestart = Object.values(helpers).some(helper => (helper.config && helper.config.restart) || (helper.options && helper.options.restart === 'context'))
77
+ const hasBrowserRestart = Object.values(helpers).some(helper =>
78
+ (helper.config && (helper.config.restart === 'browser' || helper.config.restart === 'context' || helper.config.restart === true)) ||
79
+ (helper.options && (helper.options.restart === 'browser' || helper.options.restart === 'context' || helper.options.restart === true))
80
+ )
78
81
 
79
82
  Object.keys(helpers).forEach(key => {
80
83
  const helper = helpers[key]
@@ -86,7 +89,10 @@ export default function () {
86
89
 
87
90
  event.dispatcher.on(event.all.after, () => {
88
91
  // Skip _cleanup for all helpers if any browser helper restarts to avoid double cleanup
89
- const hasBrowserRestart = Object.values(helpers).some(helper => (helper.config && helper.config.restart) || (helper.options && helper.options.restart === 'context'))
92
+ const hasBrowserRestart = Object.values(helpers).some(helper =>
93
+ (helper.config && (helper.config.restart === 'browser' || helper.config.restart === 'context' || helper.config.restart === true)) ||
94
+ (helper.options && (helper.options.restart === 'browser' || helper.options.restart === 'context' || helper.options.restart === true))
95
+ )
90
96
 
91
97
  Object.keys(helpers).forEach(key => {
92
98
  const helper = helpers[key]
@@ -0,0 +1,85 @@
1
+ import event from '../event.js'
2
+ import { enhanceMochaTest } from '../mocha/test.js'
3
+
4
+ /**
5
+ * Enhance retried tests by copying CodeceptJS-specific properties from the original test
6
+ * This fixes the issue where Mocha's shallow clone during retries loses CodeceptJS properties
7
+ */
8
+ export default function () {
9
+ event.dispatcher.on(event.test.before, test => {
10
+ // Check if this test is a retry (has a reference to the original test)
11
+ const originalTest = test.retriedTest && test.retriedTest()
12
+
13
+ if (originalTest) {
14
+ // This is a retried test - copy CodeceptJS-specific properties from the original
15
+ copyCodeceptJSProperties(originalTest, test)
16
+
17
+ // Ensure the test is enhanced with CodeceptJS functionality
18
+ enhanceMochaTest(test)
19
+ }
20
+ })
21
+ }
22
+
23
+ /**
24
+ * Copy CodeceptJS-specific properties from the original test to the retried test
25
+ * @param {CodeceptJS.Test} originalTest - The original test object
26
+ * @param {CodeceptJS.Test} retriedTest - The retried test object
27
+ */
28
+ function copyCodeceptJSProperties(originalTest, retriedTest) {
29
+ // Copy CodeceptJS-specific properties
30
+ if (originalTest.opts !== undefined) {
31
+ retriedTest.opts = originalTest.opts ? { ...originalTest.opts } : {}
32
+ }
33
+
34
+ if (originalTest.tags !== undefined) {
35
+ retriedTest.tags = originalTest.tags ? [...originalTest.tags] : []
36
+ }
37
+
38
+ if (originalTest.notes !== undefined) {
39
+ retriedTest.notes = originalTest.notes ? [...originalTest.notes] : []
40
+ }
41
+
42
+ if (originalTest.meta !== undefined) {
43
+ retriedTest.meta = originalTest.meta ? { ...originalTest.meta } : {}
44
+ }
45
+
46
+ if (originalTest.artifacts !== undefined) {
47
+ retriedTest.artifacts = originalTest.artifacts ? [...originalTest.artifacts] : []
48
+ }
49
+
50
+ if (originalTest.steps !== undefined) {
51
+ retriedTest.steps = originalTest.steps ? [...originalTest.steps] : []
52
+ }
53
+
54
+ if (originalTest.config !== undefined) {
55
+ retriedTest.config = originalTest.config ? { ...originalTest.config } : {}
56
+ }
57
+
58
+ if (originalTest.inject !== undefined) {
59
+ retriedTest.inject = originalTest.inject ? { ...originalTest.inject } : {}
60
+ }
61
+
62
+ // Copy methods that might be missing
63
+ if (originalTest.addNote && !retriedTest.addNote) {
64
+ retriedTest.addNote = function (type, note) {
65
+ this.notes = this.notes || []
66
+ this.notes.push({ type, text: note })
67
+ }
68
+ }
69
+
70
+ if (originalTest.applyOptions && !retriedTest.applyOptions) {
71
+ retriedTest.applyOptions = originalTest.applyOptions.bind(retriedTest)
72
+ }
73
+
74
+ if (originalTest.simplify && !retriedTest.simplify) {
75
+ retriedTest.simplify = originalTest.simplify.bind(retriedTest)
76
+ }
77
+
78
+ // Preserve the uid if it exists
79
+ if (originalTest.uid !== undefined) {
80
+ retriedTest.uid = originalTest.uid
81
+ }
82
+
83
+ // Mark as enhanced
84
+ retriedTest.codeceptjs = true
85
+ }