Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const config = {

- `timeout` — default per-test timeout in seconds; a test is killed if it stops responding.
- `mocha` — [Mocha options](https://mochajs.org/#configuring-mocha-nodejs), including extra reporters. See [Reporters](/reports).
- `workerInitializationDelay` — delay in milliseconds between spinning up parallel workers to prevent CPU spikes and stagger browser startup. Defaults to `200`. Set to `0` to disable.

**BDD**

Expand Down
2 changes: 2 additions & 0 deletions docs/parallel.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ npx codeceptjs run-workers 4

Steps are not streamed to the console in this mode — output from separate threads can't be interleaved cleanly. While workers run, CodeceptJS sets `process.env.RUNS_WITH_WORKERS=true`, so plugins and helpers can branch on it. All `run` options work here too: `--grep "@smoke"`, `-c codecept.conf.js`, `--debug`, and the rest.

By default, workers are created with a staggered delay of 200ms to prevent CPU spikes and stagger browser initializations. You can adjust this via `workerInitializationDelay` in your configuration.

### Distribution strategies

`--by` controls how tests spread across workers:
Expand Down
34 changes: 14 additions & 20 deletions lib/command/workers/runTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,7 @@ let config
// Load test and run
initPromise = (async function () {
try {
// Add staggered delay at the very start to prevent resource conflicts
// Longer delay for browser initialization conflicts
const delay = (workerIndex - 1) * 2000 // 0ms, 2s, 4s, etc.
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}


// Import modules dynamically to avoid ES Module loader race conditions in Node 22.x
const eventModule = await import('../../event.js')
const containerModule = await import('../../container.js')
Expand All @@ -153,9 +147,9 @@ initPromise = (async function () {
Codecept = CodeceptModule.default
fixErrorStack = typescriptModule.fixErrorStack
loadTests = loadTestsModule.default

const overrideConfigs = tryOrDefault(() => JSON.parse(options.override), {})

let baseConfig
try {
// IMPORTANT: await is required here since getConfig is async
Expand All @@ -172,14 +166,14 @@ initPromise = (async function () {
await new Promise(resolve => setTimeout(resolve, 100))
process.exit(1)
}

// important deep merge so dynamic things e.g. functions on config are not overridden
config = deepMerge(baseConfig, overrideConfigs)

// Pass workerIndex as child option for output.process() to display worker prefix
const optsWithChild = { ...options, child: workerIndex }
codecept = new Codecept(config, optsWithChild)

try {
await codecept.init(testRoot)
} catch (initErr) {
Expand All @@ -193,7 +187,7 @@ initPromise = (async function () {
process.stderr.write(`${initErr.stack}\n`)
process.exit(1)
}

codecept.loadTests()
mocha = container.mocha()

Expand Down Expand Up @@ -279,7 +273,7 @@ async function runPoolTests() {
const messageHandler = async eventData => {
// Remove handler immediately to prevent duplicate processing
parentPort?.off('message', messageHandler)

if (eventData.type === 'TEST_ASSIGNED') {
// In pool mode with ESM, we receive test FILE paths instead of UIDs
// because UIDs are not stable across different mocha instances
Expand All @@ -289,7 +283,7 @@ async function runPoolTests() {
// Create a fresh Mocha instance for each test file
container.createMocha()
const mocha = container.mocha()

// Load only the assigned test file
mocha.files = [testIdentifier]
await loadTests(mocha)
Expand Down Expand Up @@ -348,7 +342,7 @@ async function runPoolTests() {

// Set up handler BEFORE sending request to avoid race condition
parentPort?.on('message', messageHandler)

// Now send the request
sendToParentThread({ type: 'REQUEST_TEST', workerIndex })
})
Expand Down Expand Up @@ -391,13 +385,13 @@ async function runPoolTests() {
function filterTestById(testUid) {
// In pool mode with ESM, test files are already loaded once at initialization
// We just need to filter the existing mocha suite to only include the target test

// Get the existing mocha instance
const mocha = container.mocha()

// Save reference to all suites before clearing
const allSuites = [...mocha.suite.suites]

// Clear suites and tests but preserve other mocha settings
mocha.suite.suites = []
mocha.suite.tests = []
Expand All @@ -406,10 +400,10 @@ function filterTestById(testUid) {
let foundTest = false
for (const suite of allSuites) {
const originalTests = [...suite.tests]

// Check if this suite has our target test
const targetTest = originalTests.find(test => test.uid === testUid)

if (targetTest) {
// Create a filtered suite with only the target test
suite.tests = [targetTest]
Expand Down
10 changes: 10 additions & 0 deletions lib/workers.js
Original file line number Diff line number Diff line change
Expand Up @@ -508,10 +508,20 @@ class Workers extends EventEmitter {
// Create workers and set up message handlers immediately (not in recorder queue)
// This prevents a race condition where workers start sending messages before handlers are attached
const workerThreads = []
const staggerDelay = this.codecept.config.workerInitializationDelay !== undefined
? this.codecept.config.workerInitializationDelay
: 200

for (const worker of this.workers) {
const workerThread = createWorker(worker, this.isPoolMode)
this._listenWorkerEvents(workerThread)
workerThreads.push(workerThread)

// Stagger worker creation to prevent CPU spikes
// from massive V8 isolate creation and naturally stagger browser init
if (this.workers.length > 1 && staggerDelay > 0) {
await new Promise(resolve => setTimeout(resolve, staggerDelay))
}
}

recorder.add('workers started', () => {
Expand Down