codeceptjs 3.7.4 → 3.7.5-beta.2
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 +45 -0
- package/bin/codecept.js +25 -0
- package/bin/test-server.js +53 -0
- package/lib/codecept.js +41 -0
- package/lib/command/init.js +5 -0
- package/lib/command/run-failed-tests.js +216 -0
- package/lib/command/run-workers.js +16 -1
- package/lib/command/workers/runTests.js +220 -14
- package/lib/element/WebElement.js +327 -0
- package/lib/helper/JSONResponse.js +23 -4
- package/lib/helper/Mochawesome.js +24 -2
- package/lib/helper/Playwright.js +396 -57
- package/lib/helper/Puppeteer.js +107 -28
- package/lib/helper/WebDriver.js +18 -4
- package/lib/listener/retryEnhancer.js +85 -0
- package/lib/listener/steps.js +12 -0
- package/lib/mocha/cli.js +1 -1
- package/lib/mocha/test.js +6 -0
- package/lib/mocha/ui.js +13 -0
- package/lib/output.js +8 -10
- package/lib/plugin/failedTestsTracker.js +197 -0
- package/lib/plugin/htmlReporter.js +2955 -0
- package/lib/recorder.js +9 -0
- package/lib/test-server.js +323 -0
- package/lib/utils/mask_data.js +53 -0
- package/lib/utils.js +34 -2
- package/lib/workers.js +135 -9
- package/package.json +8 -6
- package/typings/index.d.ts +17 -4
- package/typings/promiseBasedTypes.d.ts +14 -0
- package/typings/types.d.ts +18 -0
|
@@ -20,7 +20,7 @@ const stderr = ''
|
|
|
20
20
|
// Requiring of Codecept need to be after tty.getWindowSize is available.
|
|
21
21
|
const Codecept = require(process.env.CODECEPT_CLASS_PATH || '../../codecept')
|
|
22
22
|
|
|
23
|
-
const { options, tests, testRoot, workerIndex } = workerData
|
|
23
|
+
const { options, tests, testRoot, workerIndex, poolMode } = workerData
|
|
24
24
|
|
|
25
25
|
// hide worker output
|
|
26
26
|
if (!options.debug && !options.verbose)
|
|
@@ -39,15 +39,26 @@ const codecept = new Codecept(config, options)
|
|
|
39
39
|
codecept.init(testRoot)
|
|
40
40
|
codecept.loadTests()
|
|
41
41
|
const mocha = container.mocha()
|
|
42
|
-
|
|
42
|
+
|
|
43
|
+
if (poolMode) {
|
|
44
|
+
// In pool mode, don't filter tests upfront - wait for assignments
|
|
45
|
+
// We'll reload test files fresh for each test request
|
|
46
|
+
} else {
|
|
47
|
+
// Legacy mode - filter tests upfront
|
|
48
|
+
filterTests()
|
|
49
|
+
}
|
|
43
50
|
|
|
44
51
|
// run tests
|
|
45
52
|
;(async function () {
|
|
46
|
-
if (
|
|
53
|
+
if (poolMode) {
|
|
54
|
+
await runPoolTests()
|
|
55
|
+
} else if (mocha.suite.total()) {
|
|
47
56
|
await runTests()
|
|
48
57
|
}
|
|
49
58
|
})()
|
|
50
59
|
|
|
60
|
+
let globalStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 }
|
|
61
|
+
|
|
51
62
|
async function runTests() {
|
|
52
63
|
try {
|
|
53
64
|
await codecept.bootstrap()
|
|
@@ -64,6 +75,192 @@ async function runTests() {
|
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
|
|
78
|
+
async function runPoolTests() {
|
|
79
|
+
try {
|
|
80
|
+
await codecept.bootstrap()
|
|
81
|
+
} catch (err) {
|
|
82
|
+
throw new Error(`Error while running bootstrap file :${err}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
initializeListeners()
|
|
86
|
+
disablePause()
|
|
87
|
+
|
|
88
|
+
// Accumulate results across all tests in pool mode
|
|
89
|
+
let consolidatedStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 }
|
|
90
|
+
let allTests = []
|
|
91
|
+
let allFailures = []
|
|
92
|
+
let previousStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 }
|
|
93
|
+
|
|
94
|
+
// Keep requesting tests until no more available
|
|
95
|
+
while (true) {
|
|
96
|
+
// Request a test assignment
|
|
97
|
+
sendToParentThread({ type: 'REQUEST_TEST', workerIndex })
|
|
98
|
+
|
|
99
|
+
const testResult = await new Promise((resolve, reject) => {
|
|
100
|
+
// Set up pool mode message handler
|
|
101
|
+
const messageHandler = async eventData => {
|
|
102
|
+
if (eventData.type === 'TEST_ASSIGNED') {
|
|
103
|
+
const testUid = eventData.test
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
// In pool mode, we need to create a fresh Mocha instance for each test
|
|
107
|
+
// because Mocha instances become disposed after running tests
|
|
108
|
+
container.createMocha() // Create fresh Mocha instance
|
|
109
|
+
filterTestById(testUid)
|
|
110
|
+
const mocha = container.mocha()
|
|
111
|
+
|
|
112
|
+
if (mocha.suite.total() > 0) {
|
|
113
|
+
// Run the test and complete
|
|
114
|
+
await codecept.run()
|
|
115
|
+
|
|
116
|
+
// Get the results from this specific test run
|
|
117
|
+
const result = container.result()
|
|
118
|
+
const currentStats = result.stats || {}
|
|
119
|
+
|
|
120
|
+
// Calculate the difference from previous accumulated stats
|
|
121
|
+
const newPasses = Math.max(0, (currentStats.passes || 0) - previousStats.passes)
|
|
122
|
+
const newFailures = Math.max(0, (currentStats.failures || 0) - previousStats.failures)
|
|
123
|
+
const newTests = Math.max(0, (currentStats.tests || 0) - previousStats.tests)
|
|
124
|
+
const newPending = Math.max(0, (currentStats.pending || 0) - previousStats.pending)
|
|
125
|
+
const newFailedHooks = Math.max(0, (currentStats.failedHooks || 0) - previousStats.failedHooks)
|
|
126
|
+
|
|
127
|
+
// Add only the new results
|
|
128
|
+
consolidatedStats.passes += newPasses
|
|
129
|
+
consolidatedStats.failures += newFailures
|
|
130
|
+
consolidatedStats.tests += newTests
|
|
131
|
+
consolidatedStats.pending += newPending
|
|
132
|
+
consolidatedStats.failedHooks += newFailedHooks
|
|
133
|
+
|
|
134
|
+
// Update previous stats for next comparison
|
|
135
|
+
previousStats = { ...currentStats }
|
|
136
|
+
|
|
137
|
+
// Add new failures to consolidated collections
|
|
138
|
+
if (result.failures && result.failures.length > allFailures.length) {
|
|
139
|
+
const newFailures = result.failures.slice(allFailures.length)
|
|
140
|
+
allFailures.push(...newFailures)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Signal test completed and request next
|
|
145
|
+
parentPort?.off('message', messageHandler)
|
|
146
|
+
resolve('TEST_COMPLETED')
|
|
147
|
+
} catch (err) {
|
|
148
|
+
parentPort?.off('message', messageHandler)
|
|
149
|
+
reject(err)
|
|
150
|
+
}
|
|
151
|
+
} else if (eventData.type === 'NO_MORE_TESTS') {
|
|
152
|
+
// No tests available, exit worker
|
|
153
|
+
parentPort?.off('message', messageHandler)
|
|
154
|
+
resolve('NO_MORE_TESTS')
|
|
155
|
+
} else {
|
|
156
|
+
// Handle other message types (support messages, etc.)
|
|
157
|
+
container.append({ support: eventData.data })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
parentPort?.on('message', messageHandler)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Exit if no more tests
|
|
165
|
+
if (testResult === 'NO_MORE_TESTS') {
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await codecept.teardown()
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// Log teardown errors but don't fail
|
|
174
|
+
console.error('Teardown error:', err)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Send final consolidated results for the entire worker
|
|
178
|
+
const finalResult = {
|
|
179
|
+
hasFailed: consolidatedStats.failures > 0,
|
|
180
|
+
stats: consolidatedStats,
|
|
181
|
+
duration: 0, // Pool mode doesn't track duration per worker
|
|
182
|
+
tests: [], // Keep tests empty to avoid serialization issues - stats are sufficient
|
|
183
|
+
failures: allFailures, // Include all failures for error reporting
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
sendToParentThread({ event: event.all.after, workerIndex, data: finalResult })
|
|
187
|
+
sendToParentThread({ event: event.all.result, workerIndex, data: finalResult })
|
|
188
|
+
|
|
189
|
+
// Add longer delay to ensure messages are delivered before closing
|
|
190
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
191
|
+
|
|
192
|
+
// Close worker thread when pool mode is complete
|
|
193
|
+
parentPort?.close()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function filterTestById(testUid) {
|
|
197
|
+
// Reload test files fresh for each test in pool mode
|
|
198
|
+
const files = codecept.testFiles
|
|
199
|
+
|
|
200
|
+
// Get the existing mocha instance
|
|
201
|
+
const mocha = container.mocha()
|
|
202
|
+
|
|
203
|
+
// Clear suites and tests but preserve other mocha settings
|
|
204
|
+
mocha.suite.suites = []
|
|
205
|
+
mocha.suite.tests = []
|
|
206
|
+
|
|
207
|
+
// Clear require cache for test files to ensure fresh loading
|
|
208
|
+
files.forEach(file => {
|
|
209
|
+
delete require.cache[require.resolve(file)]
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Set files and load them
|
|
213
|
+
mocha.files = files
|
|
214
|
+
mocha.loadFiles()
|
|
215
|
+
|
|
216
|
+
// Now filter to only the target test - use a more robust approach
|
|
217
|
+
let foundTest = false
|
|
218
|
+
for (const suite of mocha.suite.suites) {
|
|
219
|
+
const originalTests = [...suite.tests]
|
|
220
|
+
suite.tests = []
|
|
221
|
+
|
|
222
|
+
for (const test of originalTests) {
|
|
223
|
+
if (test.uid === testUid) {
|
|
224
|
+
suite.tests.push(test)
|
|
225
|
+
foundTest = true
|
|
226
|
+
break // Only add one matching test
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// If no tests found in this suite, remove it
|
|
231
|
+
if (suite.tests.length === 0) {
|
|
232
|
+
suite.parent.suites = suite.parent.suites.filter(s => s !== suite)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Filter out empty suites from the root
|
|
237
|
+
mocha.suite.suites = mocha.suite.suites.filter(suite => suite.tests.length > 0)
|
|
238
|
+
|
|
239
|
+
if (!foundTest) {
|
|
240
|
+
// If testUid doesn't match, maybe it's a simple test name - try fallback
|
|
241
|
+
mocha.suite.suites = []
|
|
242
|
+
mocha.suite.tests = []
|
|
243
|
+
mocha.loadFiles()
|
|
244
|
+
|
|
245
|
+
// Try matching by title
|
|
246
|
+
for (const suite of mocha.suite.suites) {
|
|
247
|
+
const originalTests = [...suite.tests]
|
|
248
|
+
suite.tests = []
|
|
249
|
+
|
|
250
|
+
for (const test of originalTests) {
|
|
251
|
+
if (test.title === testUid || test.fullTitle() === testUid || test.uid === testUid) {
|
|
252
|
+
suite.tests.push(test)
|
|
253
|
+
foundTest = true
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Clean up empty suites again
|
|
260
|
+
mocha.suite.suites = mocha.suite.suites.filter(suite => suite.tests.length > 0)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
67
264
|
function filterTests() {
|
|
68
265
|
const files = codecept.testFiles
|
|
69
266
|
mocha.files = files
|
|
@@ -102,14 +299,20 @@ function initializeListeners() {
|
|
|
102
299
|
event.dispatcher.on(event.hook.passed, hook => sendToParentThread({ event: event.hook.passed, workerIndex, data: hook.simplify() }))
|
|
103
300
|
event.dispatcher.on(event.hook.finished, hook => sendToParentThread({ event: event.hook.finished, workerIndex, data: hook.simplify() }))
|
|
104
301
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
302
|
+
if (!poolMode) {
|
|
303
|
+
// In regular mode, close worker after all tests are complete
|
|
304
|
+
event.dispatcher.once(event.all.after, () => {
|
|
305
|
+
sendToParentThread({ event: event.all.after, workerIndex, data: container.result().simplify() })
|
|
306
|
+
})
|
|
307
|
+
// all
|
|
308
|
+
event.dispatcher.once(event.all.result, () => {
|
|
309
|
+
sendToParentThread({ event: event.all.result, workerIndex, data: container.result().simplify() })
|
|
310
|
+
parentPort?.close()
|
|
311
|
+
})
|
|
312
|
+
} else {
|
|
313
|
+
// In pool mode, don't send result events for individual tests
|
|
314
|
+
// Results will be sent once when the worker completes all tests
|
|
315
|
+
}
|
|
113
316
|
}
|
|
114
317
|
|
|
115
318
|
function disablePause() {
|
|
@@ -121,7 +324,10 @@ function sendToParentThread(data) {
|
|
|
121
324
|
}
|
|
122
325
|
|
|
123
326
|
function listenToParentThread() {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
327
|
+
if (!poolMode) {
|
|
328
|
+
parentPort?.on('message', eventData => {
|
|
329
|
+
container.append({ support: eventData.data })
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
// In pool mode, message handling is done in runPoolTests()
|
|
127
333
|
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
const assert = require('assert')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unified WebElement class that wraps native element instances from different helpers
|
|
5
|
+
* and provides a consistent API across all supported helpers (Playwright, WebDriver, Puppeteer).
|
|
6
|
+
*/
|
|
7
|
+
class WebElement {
|
|
8
|
+
constructor(element, helper) {
|
|
9
|
+
this.element = element
|
|
10
|
+
this.helper = helper
|
|
11
|
+
this.helperType = this._detectHelperType(helper)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
_detectHelperType(helper) {
|
|
15
|
+
if (!helper) return 'unknown'
|
|
16
|
+
|
|
17
|
+
const className = helper.constructor.name
|
|
18
|
+
if (className === 'Playwright') return 'playwright'
|
|
19
|
+
if (className === 'WebDriver') return 'webdriver'
|
|
20
|
+
if (className === 'Puppeteer') return 'puppeteer'
|
|
21
|
+
|
|
22
|
+
return 'unknown'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the native element instance
|
|
27
|
+
* @returns {ElementHandle|WebElement|ElementHandle} Native element
|
|
28
|
+
*/
|
|
29
|
+
getNativeElement() {
|
|
30
|
+
return this.element
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the helper instance
|
|
35
|
+
* @returns {Helper} Helper instance
|
|
36
|
+
*/
|
|
37
|
+
getHelper() {
|
|
38
|
+
return this.helper
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get text content of the element
|
|
43
|
+
* @returns {Promise<string>} Element text content
|
|
44
|
+
*/
|
|
45
|
+
async getText() {
|
|
46
|
+
switch (this.helperType) {
|
|
47
|
+
case 'playwright':
|
|
48
|
+
return this.element.textContent()
|
|
49
|
+
case 'webdriver':
|
|
50
|
+
return this.element.getText()
|
|
51
|
+
case 'puppeteer':
|
|
52
|
+
return this.element.evaluate(el => el.textContent)
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get attribute value of the element
|
|
60
|
+
* @param {string} name Attribute name
|
|
61
|
+
* @returns {Promise<string|null>} Attribute value
|
|
62
|
+
*/
|
|
63
|
+
async getAttribute(name) {
|
|
64
|
+
switch (this.helperType) {
|
|
65
|
+
case 'playwright':
|
|
66
|
+
return this.element.getAttribute(name)
|
|
67
|
+
case 'webdriver':
|
|
68
|
+
return this.element.getAttribute(name)
|
|
69
|
+
case 'puppeteer':
|
|
70
|
+
return this.element.evaluate((el, attrName) => el.getAttribute(attrName), name)
|
|
71
|
+
default:
|
|
72
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get property value of the element
|
|
78
|
+
* @param {string} name Property name
|
|
79
|
+
* @returns {Promise<any>} Property value
|
|
80
|
+
*/
|
|
81
|
+
async getProperty(name) {
|
|
82
|
+
switch (this.helperType) {
|
|
83
|
+
case 'playwright':
|
|
84
|
+
return this.element.evaluate((el, propName) => el[propName], name)
|
|
85
|
+
case 'webdriver':
|
|
86
|
+
return this.element.getProperty(name)
|
|
87
|
+
case 'puppeteer':
|
|
88
|
+
return this.element.evaluate((el, propName) => el[propName], name)
|
|
89
|
+
default:
|
|
90
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get innerHTML of the element
|
|
96
|
+
* @returns {Promise<string>} Element innerHTML
|
|
97
|
+
*/
|
|
98
|
+
async getInnerHTML() {
|
|
99
|
+
switch (this.helperType) {
|
|
100
|
+
case 'playwright':
|
|
101
|
+
return this.element.innerHTML()
|
|
102
|
+
case 'webdriver':
|
|
103
|
+
return this.element.getProperty('innerHTML')
|
|
104
|
+
case 'puppeteer':
|
|
105
|
+
return this.element.evaluate(el => el.innerHTML)
|
|
106
|
+
default:
|
|
107
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get value of the element (for input elements)
|
|
113
|
+
* @returns {Promise<string>} Element value
|
|
114
|
+
*/
|
|
115
|
+
async getValue() {
|
|
116
|
+
switch (this.helperType) {
|
|
117
|
+
case 'playwright':
|
|
118
|
+
return this.element.inputValue()
|
|
119
|
+
case 'webdriver':
|
|
120
|
+
return this.element.getValue()
|
|
121
|
+
case 'puppeteer':
|
|
122
|
+
return this.element.evaluate(el => el.value)
|
|
123
|
+
default:
|
|
124
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if element is visible
|
|
130
|
+
* @returns {Promise<boolean>} True if element is visible
|
|
131
|
+
*/
|
|
132
|
+
async isVisible() {
|
|
133
|
+
switch (this.helperType) {
|
|
134
|
+
case 'playwright':
|
|
135
|
+
return this.element.isVisible()
|
|
136
|
+
case 'webdriver':
|
|
137
|
+
return this.element.isDisplayed()
|
|
138
|
+
case 'puppeteer':
|
|
139
|
+
return this.element.evaluate(el => {
|
|
140
|
+
const style = window.getComputedStyle(el)
|
|
141
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
|
|
142
|
+
})
|
|
143
|
+
default:
|
|
144
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if element is enabled
|
|
150
|
+
* @returns {Promise<boolean>} True if element is enabled
|
|
151
|
+
*/
|
|
152
|
+
async isEnabled() {
|
|
153
|
+
switch (this.helperType) {
|
|
154
|
+
case 'playwright':
|
|
155
|
+
return this.element.isEnabled()
|
|
156
|
+
case 'webdriver':
|
|
157
|
+
return this.element.isEnabled()
|
|
158
|
+
case 'puppeteer':
|
|
159
|
+
return this.element.evaluate(el => !el.disabled)
|
|
160
|
+
default:
|
|
161
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if element exists in DOM
|
|
167
|
+
* @returns {Promise<boolean>} True if element exists
|
|
168
|
+
*/
|
|
169
|
+
async exists() {
|
|
170
|
+
try {
|
|
171
|
+
switch (this.helperType) {
|
|
172
|
+
case 'playwright':
|
|
173
|
+
// For Playwright, if we have the element, it exists
|
|
174
|
+
return await this.element.evaluate(el => !!el)
|
|
175
|
+
case 'webdriver':
|
|
176
|
+
// For WebDriver, if we have the element, it exists
|
|
177
|
+
return true
|
|
178
|
+
case 'puppeteer':
|
|
179
|
+
// For Puppeteer, if we have the element, it exists
|
|
180
|
+
return await this.element.evaluate(el => !!el)
|
|
181
|
+
default:
|
|
182
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
183
|
+
}
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get bounding box of the element
|
|
191
|
+
* @returns {Promise<Object>} Bounding box with x, y, width, height properties
|
|
192
|
+
*/
|
|
193
|
+
async getBoundingBox() {
|
|
194
|
+
switch (this.helperType) {
|
|
195
|
+
case 'playwright':
|
|
196
|
+
return this.element.boundingBox()
|
|
197
|
+
case 'webdriver':
|
|
198
|
+
const rect = await this.element.getRect()
|
|
199
|
+
return {
|
|
200
|
+
x: rect.x,
|
|
201
|
+
y: rect.y,
|
|
202
|
+
width: rect.width,
|
|
203
|
+
height: rect.height,
|
|
204
|
+
}
|
|
205
|
+
case 'puppeteer':
|
|
206
|
+
return this.element.boundingBox()
|
|
207
|
+
default:
|
|
208
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Click the element
|
|
214
|
+
* @param {Object} options Click options
|
|
215
|
+
* @returns {Promise<void>}
|
|
216
|
+
*/
|
|
217
|
+
async click(options = {}) {
|
|
218
|
+
switch (this.helperType) {
|
|
219
|
+
case 'playwright':
|
|
220
|
+
return this.element.click(options)
|
|
221
|
+
case 'webdriver':
|
|
222
|
+
return this.element.click()
|
|
223
|
+
case 'puppeteer':
|
|
224
|
+
return this.element.click(options)
|
|
225
|
+
default:
|
|
226
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Type text into the element
|
|
232
|
+
* @param {string} text Text to type
|
|
233
|
+
* @param {Object} options Type options
|
|
234
|
+
* @returns {Promise<void>}
|
|
235
|
+
*/
|
|
236
|
+
async type(text, options = {}) {
|
|
237
|
+
switch (this.helperType) {
|
|
238
|
+
case 'playwright':
|
|
239
|
+
return this.element.type(text, options)
|
|
240
|
+
case 'webdriver':
|
|
241
|
+
return this.element.setValue(text)
|
|
242
|
+
case 'puppeteer':
|
|
243
|
+
return this.element.type(text, options)
|
|
244
|
+
default:
|
|
245
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Find first child element matching the locator
|
|
251
|
+
* @param {string|Object} locator Element locator
|
|
252
|
+
* @returns {Promise<WebElement|null>} WebElement instance or null if not found
|
|
253
|
+
*/
|
|
254
|
+
async $(locator) {
|
|
255
|
+
let childElement
|
|
256
|
+
|
|
257
|
+
switch (this.helperType) {
|
|
258
|
+
case 'playwright':
|
|
259
|
+
childElement = await this.element.$(this._normalizeLocator(locator))
|
|
260
|
+
break
|
|
261
|
+
case 'webdriver':
|
|
262
|
+
try {
|
|
263
|
+
childElement = await this.element.$(this._normalizeLocator(locator))
|
|
264
|
+
} catch (e) {
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
break
|
|
268
|
+
case 'puppeteer':
|
|
269
|
+
childElement = await this.element.$(this._normalizeLocator(locator))
|
|
270
|
+
break
|
|
271
|
+
default:
|
|
272
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return childElement ? new WebElement(childElement, this.helper) : null
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Find all child elements matching the locator
|
|
280
|
+
* @param {string|Object} locator Element locator
|
|
281
|
+
* @returns {Promise<WebElement[]>} Array of WebElement instances
|
|
282
|
+
*/
|
|
283
|
+
async $$(locator) {
|
|
284
|
+
let childElements
|
|
285
|
+
|
|
286
|
+
switch (this.helperType) {
|
|
287
|
+
case 'playwright':
|
|
288
|
+
childElements = await this.element.$$(this._normalizeLocator(locator))
|
|
289
|
+
break
|
|
290
|
+
case 'webdriver':
|
|
291
|
+
childElements = await this.element.$$(this._normalizeLocator(locator))
|
|
292
|
+
break
|
|
293
|
+
case 'puppeteer':
|
|
294
|
+
childElements = await this.element.$$(this._normalizeLocator(locator))
|
|
295
|
+
break
|
|
296
|
+
default:
|
|
297
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return childElements.map(el => new WebElement(el, this.helper))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Normalize locator for element search
|
|
305
|
+
* @param {string|Object} locator Locator to normalize
|
|
306
|
+
* @returns {string} Normalized CSS selector
|
|
307
|
+
* @private
|
|
308
|
+
*/
|
|
309
|
+
_normalizeLocator(locator) {
|
|
310
|
+
if (typeof locator === 'string') {
|
|
311
|
+
return locator
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (typeof locator === 'object') {
|
|
315
|
+
// Handle CodeceptJS locator objects
|
|
316
|
+
if (locator.css) return locator.css
|
|
317
|
+
if (locator.xpath) return locator.xpath
|
|
318
|
+
if (locator.id) return `#${locator.id}`
|
|
319
|
+
if (locator.name) return `[name="${locator.name}"]`
|
|
320
|
+
if (locator.className) return `.${locator.className}`
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return locator.toString()
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
module.exports = WebElement
|
|
@@ -72,10 +72,11 @@ class JSONResponse extends Helper {
|
|
|
72
72
|
if (!this.helpers[this.options.requestHelper]) {
|
|
73
73
|
throw new Error(`Error setting JSONResponse, helper ${this.options.requestHelper} is not enabled in config, helpers: ${Object.keys(this.helpers)}`)
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
const origOnResponse = this.helpers[this.options.requestHelper].config.onResponse;
|
|
76
76
|
this.helpers[this.options.requestHelper].config.onResponse = response => {
|
|
77
|
-
this.response = response
|
|
78
|
-
|
|
77
|
+
this.response = response;
|
|
78
|
+
if (typeof origOnResponse === 'function') origOnResponse(response);
|
|
79
|
+
};
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
_before() {
|
|
@@ -349,7 +350,25 @@ class JSONResponse extends Helper {
|
|
|
349
350
|
for (const key in expected) {
|
|
350
351
|
assert(key in actual, `Key "${key}" not found in ${JSON.stringify(actual)}`)
|
|
351
352
|
if (typeof expected[key] === 'object' && expected[key] !== null) {
|
|
352
|
-
|
|
353
|
+
if (Array.isArray(expected[key])) {
|
|
354
|
+
// Handle array comparison: each expected element should have a match in actual array
|
|
355
|
+
assert(Array.isArray(actual[key]), `Expected array for key "${key}", but got ${typeof actual[key]}`)
|
|
356
|
+
for (const expectedItem of expected[key]) {
|
|
357
|
+
let found = false
|
|
358
|
+
for (const actualItem of actual[key]) {
|
|
359
|
+
try {
|
|
360
|
+
this._assertContains(actualItem, expectedItem)
|
|
361
|
+
found = true
|
|
362
|
+
break
|
|
363
|
+
} catch (err) {
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
assert(found, `No matching element found in array for ${JSON.stringify(expectedItem)}`)
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
this._assertContains(actual[key], expected[key])
|
|
371
|
+
}
|
|
353
372
|
} else {
|
|
354
373
|
assert.deepStrictEqual(actual[key], expected[key], `Values for key "${key}" don't match`)
|
|
355
374
|
}
|
|
@@ -37,7 +37,20 @@ class Mochawesome extends Helper {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
_test(test) {
|
|
40
|
-
|
|
40
|
+
// If this is a retried test, we want to add context to the retried test
|
|
41
|
+
// but also potentially preserve context from the original test
|
|
42
|
+
const originalTest = test.retriedTest && test.retriedTest()
|
|
43
|
+
if (originalTest) {
|
|
44
|
+
// This is a retried test - use the retried test for context
|
|
45
|
+
currentTest = { test }
|
|
46
|
+
|
|
47
|
+
// Optionally copy context from original test if it exists
|
|
48
|
+
// Note: mochawesome context is stored in test.ctx, but we need to be careful
|
|
49
|
+
// not to break the mocha context structure
|
|
50
|
+
} else {
|
|
51
|
+
// Normal test (not a retry)
|
|
52
|
+
currentTest = { test }
|
|
53
|
+
}
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
_failed(test) {
|
|
@@ -64,7 +77,16 @@ class Mochawesome extends Helper {
|
|
|
64
77
|
|
|
65
78
|
addMochawesomeContext(context) {
|
|
66
79
|
if (currentTest === '') currentTest = { test: currentSuite.ctx.test }
|
|
67
|
-
|
|
80
|
+
|
|
81
|
+
// For retried tests, make sure we're adding context to the current (retried) test
|
|
82
|
+
// not the original test
|
|
83
|
+
let targetTest = currentTest
|
|
84
|
+
if (currentTest.test && currentTest.test.retriedTest && currentTest.test.retriedTest()) {
|
|
85
|
+
// This test has been retried, make sure we're using the current test for context
|
|
86
|
+
targetTest = { test: currentTest.test }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return this._addContext(targetTest, context)
|
|
68
90
|
}
|
|
69
91
|
}
|
|
70
92
|
|