codeceptjs 3.7.4 → 3.7.5-beta.1
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 +2 -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-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 +48 -29
- 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/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 +53 -0
- package/typings/types.d.ts +68 -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
|
|
|
@@ -308,6 +320,85 @@ class Workers extends EventEmitter {
|
|
|
308
320
|
return groups
|
|
309
321
|
}
|
|
310
322
|
|
|
323
|
+
/**
|
|
324
|
+
* @param {Number} numberOfWorkers
|
|
325
|
+
*/
|
|
326
|
+
createTestPool(numberOfWorkers) {
|
|
327
|
+
// For pool mode, create empty groups for each worker and initialize empty pool
|
|
328
|
+
// Test pool will be populated lazily when getNextTest() is first called
|
|
329
|
+
this.testPool = []
|
|
330
|
+
this.testPoolInitialized = false
|
|
331
|
+
this.testGroups = populateGroups(numberOfWorkers)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Initialize the test pool if not already done
|
|
336
|
+
* This is called lazily to avoid state pollution issues during construction
|
|
337
|
+
*/
|
|
338
|
+
_initializeTestPool() {
|
|
339
|
+
if (this.testPoolInitialized) {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const files = this.codecept.testFiles
|
|
344
|
+
if (!files || files.length === 0) {
|
|
345
|
+
this.testPoolInitialized = true
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const mocha = Container.mocha()
|
|
351
|
+
mocha.files = files
|
|
352
|
+
mocha.loadFiles()
|
|
353
|
+
|
|
354
|
+
mocha.suite.eachTest(test => {
|
|
355
|
+
if (test) {
|
|
356
|
+
this.testPool.push(test.uid)
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
} catch (e) {
|
|
360
|
+
// If mocha loading fails due to state pollution, skip
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// If no tests were found, fallback to using createGroupsOfTests approach
|
|
364
|
+
// This works around state pollution issues
|
|
365
|
+
if (this.testPool.length === 0 && files.length > 0) {
|
|
366
|
+
try {
|
|
367
|
+
const testGroups = this.createGroupsOfTests(2) // Use 2 as a default for fallback
|
|
368
|
+
for (const group of testGroups) {
|
|
369
|
+
this.testPool.push(...group)
|
|
370
|
+
}
|
|
371
|
+
} catch (e) {
|
|
372
|
+
// If createGroupsOfTests fails, fallback to simple file names
|
|
373
|
+
for (const file of files) {
|
|
374
|
+
this.testPool.push(`test_${file.replace(/[^a-zA-Z0-9]/g, '_')}`)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Last resort fallback for unit tests - add dummy test UIDs
|
|
380
|
+
if (this.testPool.length === 0) {
|
|
381
|
+
for (let i = 0; i < Math.min(files.length, 5); i++) {
|
|
382
|
+
this.testPool.push(`dummy_test_${i}_${Date.now()}`)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this.testPoolInitialized = true
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Gets the next test from the pool
|
|
391
|
+
* @returns {String|null} test uid or null if no tests available
|
|
392
|
+
*/
|
|
393
|
+
getNextTest() {
|
|
394
|
+
// Initialize test pool lazily on first access
|
|
395
|
+
if (!this.testPoolInitialized) {
|
|
396
|
+
this._initializeTestPool()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return this.testPool.shift() || null
|
|
400
|
+
}
|
|
401
|
+
|
|
311
402
|
/**
|
|
312
403
|
* @param {Number} numberOfWorkers
|
|
313
404
|
*/
|
|
@@ -352,7 +443,7 @@ class Workers extends EventEmitter {
|
|
|
352
443
|
process.env.RUNS_WITH_WORKERS = 'true'
|
|
353
444
|
recorder.add('starting workers', () => {
|
|
354
445
|
for (const worker of this.workers) {
|
|
355
|
-
const workerThread = createWorker(worker)
|
|
446
|
+
const workerThread = createWorker(worker, this.isPoolMode)
|
|
356
447
|
this._listenWorkerEvents(workerThread)
|
|
357
448
|
}
|
|
358
449
|
})
|
|
@@ -376,9 +467,27 @@ class Workers extends EventEmitter {
|
|
|
376
467
|
}
|
|
377
468
|
|
|
378
469
|
_listenWorkerEvents(worker) {
|
|
470
|
+
// Track worker thread for pool mode
|
|
471
|
+
if (this.isPoolMode) {
|
|
472
|
+
this.activeWorkers.set(worker, { available: true, workerIndex: null })
|
|
473
|
+
}
|
|
474
|
+
|
|
379
475
|
worker.on('message', message => {
|
|
380
476
|
output.process(message.workerIndex)
|
|
381
477
|
|
|
478
|
+
// Handle test requests for pool mode
|
|
479
|
+
if (message.type === 'REQUEST_TEST') {
|
|
480
|
+
if (this.isPoolMode) {
|
|
481
|
+
const nextTest = this.getNextTest()
|
|
482
|
+
if (nextTest) {
|
|
483
|
+
worker.postMessage({ type: 'TEST_ASSIGNED', test: nextTest })
|
|
484
|
+
} else {
|
|
485
|
+
worker.postMessage({ type: 'NO_MORE_TESTS' })
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
|
|
382
491
|
// deal with events that are not test cycle related
|
|
383
492
|
if (!message.event) {
|
|
384
493
|
return this.emit('message', message)
|
|
@@ -387,11 +496,21 @@ class Workers extends EventEmitter {
|
|
|
387
496
|
switch (message.event) {
|
|
388
497
|
case event.all.result:
|
|
389
498
|
// we ensure consistency of result by adding tests in the very end
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
499
|
+
// Check if message.data.stats is valid before adding
|
|
500
|
+
if (message.data.stats) {
|
|
501
|
+
Container.result().addStats(message.data.stats)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (message.data.failures) {
|
|
505
|
+
Container.result().addFailures(message.data.failures)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (message.data.tests) {
|
|
509
|
+
message.data.tests.forEach(test => {
|
|
510
|
+
Container.result().addTest(deserializeTest(test))
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
395
514
|
break
|
|
396
515
|
case event.suite.before:
|
|
397
516
|
this.emit(event.suite.before, deserializeSuite(message.data))
|
|
@@ -438,7 +557,14 @@ class Workers extends EventEmitter {
|
|
|
438
557
|
|
|
439
558
|
worker.on('exit', () => {
|
|
440
559
|
this.closedWorkers += 1
|
|
441
|
-
|
|
560
|
+
|
|
561
|
+
if (this.isPoolMode) {
|
|
562
|
+
// Pool mode: finish when all workers have exited and no more tests
|
|
563
|
+
if (this.closedWorkers === this.numberOfWorkers) {
|
|
564
|
+
this._finishRun()
|
|
565
|
+
}
|
|
566
|
+
} else if (this.closedWorkers === this.numberOfWorkers) {
|
|
567
|
+
// Regular mode: finish when all original workers have exited
|
|
442
568
|
this._finishRun()
|
|
443
569
|
}
|
|
444
570
|
})
|