codeceptjs 4.0.0-beta.7.esm-aria → 4.0.0-beta.9.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.
- package/README.md +46 -3
- package/bin/codecept.js +9 -0
- package/bin/test-server.js +64 -0
- package/docs/webapi/click.mustache +5 -1
- package/lib/ai.js +66 -102
- package/lib/codecept.js +99 -24
- package/lib/command/generate.js +33 -1
- package/lib/command/init.js +7 -3
- package/lib/command/run-workers.js +31 -2
- package/lib/command/run.js +15 -0
- package/lib/command/workers/runTests.js +331 -58
- package/lib/config.js +16 -5
- package/lib/container.js +15 -13
- package/lib/effects.js +1 -1
- package/lib/element/WebElement.js +327 -0
- package/lib/event.js +10 -1
- package/lib/helper/AI.js +11 -11
- package/lib/helper/ApiDataFactory.js +34 -6
- package/lib/helper/Appium.js +156 -42
- package/lib/helper/GraphQL.js +3 -3
- package/lib/helper/GraphQLDataFactory.js +4 -4
- package/lib/helper/JSONResponse.js +48 -40
- package/lib/helper/Mochawesome.js +24 -2
- package/lib/helper/Playwright.js +841 -153
- package/lib/helper/Puppeteer.js +263 -67
- package/lib/helper/REST.js +21 -0
- package/lib/helper/WebDriver.js +105 -16
- package/lib/helper/clientscripts/PollyWebDriverExt.js +1 -1
- package/lib/helper/extras/PlaywrightReactVueLocator.js +52 -0
- package/lib/helper/extras/PlaywrightRestartOpts.js +12 -1
- package/lib/helper/network/actions.js +8 -6
- package/lib/listener/config.js +11 -3
- package/lib/listener/enhancedGlobalRetry.js +110 -0
- package/lib/listener/globalTimeout.js +19 -4
- package/lib/listener/helpers.js +8 -2
- package/lib/listener/retryEnhancer.js +85 -0
- package/lib/listener/steps.js +12 -0
- package/lib/mocha/asyncWrapper.js +13 -3
- package/lib/mocha/cli.js +1 -1
- package/lib/mocha/factory.js +3 -0
- package/lib/mocha/gherkin.js +1 -1
- package/lib/mocha/test.js +6 -0
- package/lib/mocha/ui.js +13 -0
- package/lib/output.js +62 -18
- package/lib/plugin/coverage.js +16 -3
- package/lib/plugin/enhancedRetryFailedStep.js +99 -0
- package/lib/plugin/htmlReporter.js +3648 -0
- package/lib/plugin/retryFailedStep.js +1 -0
- package/lib/plugin/stepByStepReport.js +1 -1
- package/lib/recorder.js +28 -3
- package/lib/result.js +100 -23
- package/lib/retryCoordinator.js +207 -0
- package/lib/step/base.js +1 -1
- package/lib/step/comment.js +2 -2
- package/lib/step/meta.js +1 -1
- package/lib/template/heal.js +1 -1
- package/lib/template/prompts/generatePageObject.js +31 -0
- package/lib/template/prompts/healStep.js +13 -0
- package/lib/template/prompts/writeStep.js +9 -0
- package/lib/test-server.js +334 -0
- package/lib/utils/mask_data.js +47 -0
- package/lib/utils.js +87 -6
- package/lib/workerStorage.js +2 -1
- package/lib/workers.js +179 -23
- package/package.json +59 -47
- package/typings/index.d.ts +19 -7
- package/typings/promiseBasedTypes.d.ts +5534 -3764
- package/typings/types.d.ts +5789 -3775
package/lib/helper/WebDriver.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
660
|
-
this.browser.
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 &&
|
|
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 &&
|
|
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)
|
|
@@ -2912,14 +3009,6 @@ async function findFields(locator) {
|
|
|
2912
3009
|
let els = await this._locate(Locator.field.labelEquals(literal))
|
|
2913
3010
|
if (els.length) return els
|
|
2914
3011
|
|
|
2915
|
-
// Try ARIA selector for accessible name
|
|
2916
|
-
try {
|
|
2917
|
-
els = await this._locate(`aria/${locator.value}`)
|
|
2918
|
-
if (els.length) return els
|
|
2919
|
-
} catch (e) {
|
|
2920
|
-
// ARIA selector not supported or failed
|
|
2921
|
-
}
|
|
2922
|
-
|
|
2923
3012
|
els = await this._locate(Locator.field.labelContains(literal))
|
|
2924
3013
|
if (els.length) return els
|
|
2925
3014
|
|
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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() {
|
package/lib/listener/config.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
package/lib/listener/helpers.js
CHANGED
|
@@ -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 =>
|
|
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 =>
|
|
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
|
+
}
|
package/lib/listener/steps.js
CHANGED
|
@@ -4,10 +4,14 @@ import event from '../event.js'
|
|
|
4
4
|
import store from '../store.js'
|
|
5
5
|
import output from '../output.js'
|
|
6
6
|
import { BeforeHook, AfterHook, BeforeSuiteHook, AfterSuiteHook } from '../mocha/hooks.js'
|
|
7
|
+
import recorder from '../recorder.js'
|
|
7
8
|
|
|
8
9
|
let currentTest
|
|
9
10
|
let currentHook
|
|
10
11
|
|
|
12
|
+
// Session names that should not contribute steps to the main test trace
|
|
13
|
+
const EXCLUDED_SESSIONS = ['tryTo', 'hopeThat']
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
16
|
* Register steps inside tests
|
|
13
17
|
*/
|
|
@@ -76,6 +80,14 @@ export default function () {
|
|
|
76
80
|
return currentHook.steps.push(step)
|
|
77
81
|
}
|
|
78
82
|
if (!currentTest || !currentTest.steps) return
|
|
83
|
+
|
|
84
|
+
// Check if we're in a session that should be excluded from main test steps
|
|
85
|
+
const currentSessionId = recorder.getCurrentSessionId()
|
|
86
|
+
if (currentSessionId && EXCLUDED_SESSIONS.includes(currentSessionId)) {
|
|
87
|
+
// Skip adding this step to the main test steps
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
79
91
|
currentTest.steps.push(step)
|
|
80
92
|
})
|
|
81
93
|
|
|
@@ -117,9 +117,19 @@ export function injected(fn, suite, hookName) {
|
|
|
117
117
|
const errHandler = err => {
|
|
118
118
|
recorder.session.start('teardown')
|
|
119
119
|
recorder.cleanAsyncErr()
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
if (['before', 'beforeSuite'].includes(hookName)) {
|
|
121
|
+
suiteTestFailedHookError(suite, err, hookName)
|
|
122
|
+
}
|
|
123
|
+
if (hookName === 'after') {
|
|
124
|
+
suiteTestFailedHookError(suite, err, hookName)
|
|
125
|
+
suite.eachTest(test => {
|
|
126
|
+
event.emit(event.test.after, test)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
if (hookName === 'afterSuite') {
|
|
130
|
+
suiteTestFailedHookError(suite, err, hookName)
|
|
131
|
+
event.emit(event.suite.after, suite)
|
|
132
|
+
}
|
|
123
133
|
recorder.add(() => doneFn(err))
|
|
124
134
|
}
|
|
125
135
|
|
package/lib/mocha/cli.js
CHANGED
|
@@ -228,7 +228,7 @@ class Cli extends Base {
|
|
|
228
228
|
|
|
229
229
|
// explicitly show file with error
|
|
230
230
|
if (test.file) {
|
|
231
|
-
log += `\n${output.styles.basic(figures.circle)} ${output.styles.section('File:')}
|
|
231
|
+
log += `\n${output.styles.basic(figures.circle)} ${output.styles.section('File:')} file://${test.file}\n`
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
const steps = test.steps || (test.ctx && test.ctx.test.steps)
|