Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
f3d3968
Revert "build: add temporal test on GHA windows"
aduh95 Feb 24, 2026
999bf22
repl: keep reference count for `process.on('newListener')`
addaleax Feb 22, 2026
0072b7f
meta: bump actions/stale from 10.1.1 to 10.2.0
dependabot[bot] Feb 22, 2026
59a726a
meta: bump step-security/harden-runner from 2.14.1 to 2.14.2
dependabot[bot] Feb 22, 2026
21d4baf
meta: bump github/codeql-action from 4.32.0 to 4.32.4
dependabot[bot] Feb 22, 2026
f279233
tools: roll back to x86 runner on `scorecard.yml`
aduh95 Feb 24, 2026
dc12a25
doc: rename invalid `function` parameter
Renegade334 Feb 24, 2026
9b483fb
deps: update minimatch to 10.2.2
nodejs-github-bot Feb 24, 2026
192c038
util: add fast path to stripVTControlCharacters
privatenumber Feb 24, 2026
46a6192
doc: support toolchain Visual Studio 2022 & 2026 + Windows 11 SDK
MikeMcC399 Feb 24, 2026
3337b09
crypto: fix potential null pointer dereference when BIO_meth_new() fails
ndossche Feb 24, 2026
243e6b2
test_runner: replace native methods with primordials
Ayoub-Mabrouk Feb 24, 2026
0d97ec4
test_runner: expose worker ID for concurrent test execution
thisalihassan Feb 24, 2026
b771529
child_process: add tracing channel for spawn
marcopiraccini Feb 25, 2026
f53a32a
deps: update acorn to 8.16.0
nodejs-github-bot Feb 25, 2026
4d411d7
deps: update acorn-walk to 8.3.5
nodejs-github-bot Feb 25, 2026
705bbd6
deps: update simdjson to 4.3.1
nodejs-github-bot Feb 25, 2026
dc384f9
crypto: fix handling of null BUF_MEM* in ToV8Value()
ndossche Feb 25, 2026
33a364c
doc: explicitly mention Slack handle
RafaelGSS Feb 25, 2026
46ee1ed
src: add C++ support for diagnostics channels
RafaelGSS Feb 13, 2026
9ddd1a9
src,permission: add --permission-audit
RafaelGSS Feb 17, 2026
ea2df2a
stream: fix pipeTo to defer writes per WHATWG spec
mcollina Feb 26, 2026
fce2930
test_runner: expose expectFailure message
Han5991 Feb 26, 2026
a32a598
crypto: fix missing nullptr check on RSA_new()
ndossche Feb 19, 2026
4890d6b
test_runner: run afterEach on runtime skip
igor-shevelenkov Feb 26, 2026
3c94b56
inspector: unwrap internal/debugger/inspect imports
Renegade334 Feb 26, 2026
0a96a16
tools: bump minimatch from 3.1.2 to 3.1.3 in /tools/eslint
dependabot[bot] Feb 26, 2026
604456c
test: avoid flaky debugger restart waits
inoway46 Feb 27, 2026
8a24c17
lib: improve argument handling in Blob constructor
Ms2ger Feb 27, 2026
7c72a31
test: skip strace test with shared openssl
richardlau Feb 27, 2026
940b58c
buffer: optimize buffer.concat performance
mertcanaltin Feb 27, 2026
4c181e2
sqlite: add limits property to DatabaseSync
mertcanaltin Feb 27, 2026
57dc092
deps: upgrade npm to 11.11.0
npm-cli-bot Feb 27, 2026
31e7936
tools: revert tools GHA workflow to ubuntu-latest
richardlau Feb 28, 2026
e55edde
build, doc: use new api doc tooling
flakey5 Feb 27, 2026
bf1ed7e
tls: forward keepAlive, keepAliveInitialDelay, noDelay to socket
tadjik1 Feb 28, 2026
7508540
doc: update DEP0040 (punycode) to application type deprecation
MikeMcC399 Feb 28, 2026
e6b131f
doc: fix module.stripTypeScriptTypes indentation
Renegade334 Feb 25, 2026
ad96a65
test: skip `test-url` on `--shared-ada` builds
aduh95 Mar 1, 2026
aa0c7b0
test: remove unnecessary `process.exit` calls from test files
aduh95 Mar 1, 2026
a28744c
tools: fix permissions for merve update script
richardlau Mar 1, 2026
ca78ebb
doc: fix small logic error in DETECT_MODULE_SYNTAX
Renegade334 Mar 1, 2026
54a055a
tools: bump minimatch from 3.1.2 to 3.1.3 in `/tools/clang-format`
dependabot[bot] Mar 1, 2026
8aa2fde
deps: update minimatch to 10.2.4
nodejs-github-bot Mar 1, 2026
4e54c10
doc: separate in-types and out-types in SQLite conversion docs
Renegade334 Mar 2, 2026
51ded81
deps: update undici to 7.22.0
nodejs-github-bot Mar 2, 2026
0f15079
tools: remove custom logic for skipping `test-strace-openat-openssl`
aduh95 Mar 2, 2026
0589b0e
build: fix GN for new merve dep
codebytere Mar 2, 2026
aee2a18
src: fix flags argument offset in JSUdpWrap
cuiweixie Feb 23, 2026
dafdc0a
http: validate headers in writeEarlyHints
rsclarke Mar 2, 2026
bdc1894
doc: expand SECURITY.md with non-vulnerability examples
RafaelGSS Mar 2, 2026
3d160cd
module: run require.resolve through module.registerHooks()
joyeecheung Mar 2, 2026
6259abc
http: validate ClientRequest path on set
mcollina Mar 2, 2026
746d0ce
tools: fix parsing of commit trailers in `lint-release-proposal` GHA
aduh95 Mar 2, 2026
ae94abf
2026-03-03, Version 25.8.0 (Current)
nodejs-github-bot Mar 2, 2026
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
Prev Previous commit
Next Next commit
test_runner: expose worker ID for concurrent test execution
This adds support for identifying which worker is running a test file
when tests execute concurrently, similar to JEST_WORKER_ID in Jest,
VITEST_POOL_ID in Vitest, and MOCHA_WORKER_ID in Mocha.

When running with --test-isolation=process (default), each test file
runs in a separate child process and receives a unique worker ID from
1 to N. When running with --test-isolation=none, all tests run in the
same process and the worker ID is always 1.

This enables users to allocate separate resources (databases, ports,
etc.) for each test worker to avoid conflicts during concurrent
execution.

Changes:
- Add WorkerIdPool class to manage worker ID allocation and reuse
- Set NODE_TEST_WORKER_ID environment variable for child processes
- Add context.workerId getter to TestContext class
- Add tests for worker ID functionality
- Add documentation for context.workerId

Fixes: #55842
PR-URL: #61394
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Pietro Marchini <pietro.marchini94@gmail.com>
Reviewed-By: Jacob Smith <jacob@frende.me>
  • Loading branch information
thisalihassan authored and aduh95 committed Feb 28, 2026
commit 0d97ec40442d139efc0e645ee36a9ebe9b5ca869
33 changes: 33 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -3804,6 +3804,39 @@ added: v25.0.0

Number of times the test has been attempted.

### `context.workerId`

<!-- YAML
added: REPLACEME
-->

* Type: {number|undefined}

The unique identifier of the worker running the current test file. This value is
derived from the `NODE_TEST_WORKER_ID` environment variable. When running tests
with `--test-isolation=process` (the default), each test file runs in a separate
child process and is assigned a worker ID from 1 to N, where N is the number of
concurrent workers. When running with `--test-isolation=none`, all tests run in
the same process and the worker ID is always 1. This value is `undefined` when
not running in a test context.

This property is useful for splitting resources (like database connections or
server ports) across concurrent test files:

```mjs
import { test } from 'node:test';
import { process } from 'node:process';

test('database operations', async (t) => {
// Worker ID is available via context
console.log(`Running in worker ${t.workerId}`);

// Or via environment variable (available at import time)
const workerId = process.env.NODE_TEST_WORKER_ID;
// Use workerId to allocate separate resources per worker
});
```

### `context.plan(count[,options])`

<!-- YAML
Expand Down
51 changes: 51 additions & 0 deletions lib/internal/test_runner/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
ArrayPrototypeSlice,
ArrayPrototypeSome,
ArrayPrototypeSort,
MathMax,
ObjectAssign,
PromisePrototypeThen,
PromiseWithResolvers,
Expand All @@ -23,6 +24,7 @@ const {
SafePromiseAllReturnVoid,
SafePromiseAllSettledReturnVoid,
SafeSet,
String,
StringPrototypeIndexOf,
StringPrototypeSlice,
StringPrototypeStartsWith,
Expand All @@ -33,6 +35,7 @@ const {

const { spawn } = require('child_process');
const { finished } = require('internal/streams/end-of-stream');
const { availableParallelism } = require('os');
const { resolve, sep, isAbsolute } = require('path');
const { DefaultDeserializer, DefaultSerializer } = require('v8');
const { getOptionValue, getOptionsAsFlagsFromBinding } = require('internal/options');
Expand Down Expand Up @@ -117,6 +120,21 @@ const kCanceledTests = new SafeSet()

let kResistStopPropagation;

// Worker ID pool management for concurrent test execution
class WorkerIdPool {
#nextId = 0;
#maxConcurrency;

constructor(maxConcurrency) {
this.#maxConcurrency = maxConcurrency;
}

acquire() {
const id = (this.#nextId++ % this.#maxConcurrency) + 1;
return id;
}
}

function createTestFileList(patterns, cwd) {
const hasUserSuppliedPattern = patterns != null;
if (!patterns || patterns.length === 0) {
Expand Down Expand Up @@ -404,6 +422,15 @@ function runTestFile(path, filesWatcher, opts) {
const args = getRunArgs(path, opts);
const stdio = ['pipe', 'pipe', 'pipe'];
const env = { __proto__: null, NODE_TEST_CONTEXT: 'child-v8', ...(opts.env || process.env) };

// Acquire a worker ID from the pool for process isolation mode
let workerId;
if (opts.workerIdPool) {
workerId = opts.workerIdPool.acquire();
env.NODE_TEST_WORKER_ID = String(workerId);
debug('Assigned worker ID %d to test file: %s', workerId, path);
}

if (watchMode) {
stdio.push('ipc');
env.WATCH_REPORT_DEPENDENCIES = '1';
Expand Down Expand Up @@ -769,6 +796,25 @@ function run(options = kEmptyObject) {
let postRun;
let filesWatcher;
let runFiles;

// Create worker ID pool for concurrent test execution.
// Use concurrency from globalOptions which has been processed by parseCommandLine().
const effectiveConcurrency = globalOptions.concurrency ?? concurrency;
let maxConcurrency = 1;
if (effectiveConcurrency === true) {
maxConcurrency = MathMax(availableParallelism() - 1, 1);
} else if (typeof effectiveConcurrency === 'number') {
maxConcurrency = effectiveConcurrency;
}
const workerIdPool = new WorkerIdPool(maxConcurrency);
debug(
'Created worker ID pool with max concurrency: %d, ' +
'effectiveConcurrency: %s, testFiles: %d',
maxConcurrency,
effectiveConcurrency,
testFiles.length,
);

const opts = {
__proto__: null,
root,
Expand All @@ -786,6 +832,7 @@ function run(options = kEmptyObject) {
execArgv,
rerunFailuresFilePath,
env,
workerIdPool: isolation === 'process' ? workerIdPool : null,
};

if (isolation === 'process') {
Expand All @@ -812,6 +859,10 @@ function run(options = kEmptyObject) {
});
};
} else if (isolation === 'none') {
// For isolation=none, set worker ID to 1 in the current process
process.env.NODE_TEST_WORKER_ID = '1';
debug('Set NODE_TEST_WORKER_ID=1 for isolation=none');

if (watch) {
const absoluteTestFiles = ArrayPrototypeMap(testFiles, (file) => (isAbsolute(file) ? file : resolve(cwd, file)));
filesWatcher = watchFiles(absoluteTestFiles, opts);
Expand Down
5 changes: 5 additions & 0 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,11 @@ class TestContext {
return this.#test.attempt ?? 0;
}

get workerId() {
const envWorkerId = process.env.NODE_TEST_WORKER_ID;
return Number(envWorkerId) || undefined;
}

diagnostic(message) {
this.#test.diagnostic(message);
}
Expand Down
26 changes: 26 additions & 0 deletions test/fixtures/test-runner/worker-id/test-1.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test } from 'node:test';

test('worker ID is available as environment variable', (t) => {
const workerId = process.env.NODE_TEST_WORKER_ID;
if (workerId === undefined) {
throw new Error('NODE_TEST_WORKER_ID should be defined');
}

const id = Number(workerId);
if (isNaN(id) || id < 1) {
throw new Error(`Invalid worker ID: ${workerId}`);
}
});

test('worker ID is available via context', (t) => {
const workerId = t.workerId;
const envWorkerId = process.env.NODE_TEST_WORKER_ID;

if (workerId === undefined) {
throw new Error('context.workerId should be defined');
}

if (workerId !== Number(envWorkerId)) {
throw new Error(`context.workerId (${workerId}) should match NODE_TEST_WORKER_ID (${envWorkerId})`);
}
});
26 changes: 26 additions & 0 deletions test/fixtures/test-runner/worker-id/test-2.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test } from 'node:test';

test('worker ID is available as environment variable', (t) => {
const workerId = process.env.NODE_TEST_WORKER_ID;
if (workerId === undefined) {
throw new Error('NODE_TEST_WORKER_ID should be defined');
}

const id = Number(workerId);
if (isNaN(id) || id < 1) {
throw new Error(`Invalid worker ID: ${workerId}`);
}
});

test('worker ID is available via context', (t) => {
const workerId = t.workerId;
const envWorkerId = process.env.NODE_TEST_WORKER_ID;

if (workerId === undefined) {
throw new Error('context.workerId should be defined');
}

if (workerId !== Number(envWorkerId)) {
throw new Error(`context.workerId (${workerId}) should match NODE_TEST_WORKER_ID (${envWorkerId})`);
}
});
26 changes: 26 additions & 0 deletions test/fixtures/test-runner/worker-id/test-3.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test } from 'node:test';

test('worker ID is available as environment variable', (t) => {
const workerId = process.env.NODE_TEST_WORKER_ID;
if (workerId === undefined) {
throw new Error('NODE_TEST_WORKER_ID should be defined');
}

const id = Number(workerId);
if (isNaN(id) || id < 1) {
throw new Error(`Invalid worker ID: ${workerId}`);
}
});

test('worker ID is available via context', (t) => {
const workerId = t.workerId;
const envWorkerId = process.env.NODE_TEST_WORKER_ID;

if (workerId === undefined) {
throw new Error('context.workerId should be defined');
}

if (workerId !== Number(envWorkerId)) {
throw new Error(`context.workerId (${workerId}) should match NODE_TEST_WORKER_ID (${envWorkerId})`);
}
});
141 changes: 141 additions & 0 deletions test/parallel/test-runner-worker-id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
'use strict';
require('../common');
const fixtures = require('../common/fixtures');
const assert = require('node:assert');
const { spawnSync } = require('node:child_process');
const { test } = require('node:test');

test('NODE_TEST_WORKER_ID is set for concurrent test files', async () => {
const args = [
'--test',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-3.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('NODE_TEST_WORKER_ID is set with explicit concurrency', async () => {
const args = [
'--test',
'--test-concurrency=2',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('NODE_TEST_WORKER_ID is 1 with concurrency=1', async () => {
const args = ['--test', '--test-concurrency=1', fixtures.path('test-runner', 'worker-id', 'test-1.mjs')];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('NODE_TEST_WORKER_ID with explicit isolation=process', async () => {
const args = [
'--test',
'--test-isolation=process',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('NODE_TEST_WORKER_ID is 1 with isolation=none', async () => {
const args = [
'--test',
'--test-isolation=none',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('context.workerId matches NODE_TEST_WORKER_ID', async () => {
const args = ['--test', fixtures.path('test-runner', 'worker-id', 'test-1.mjs')];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

// The fixture tests already verify that context.workerId matches the env var
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('worker IDs are reused when more tests than concurrency', async () => {
const tmpdir = require('../common/tmpdir');
const { writeFileSync } = require('node:fs');
tmpdir.refresh();

// Create 9 separate test files dynamically
const testFiles = [];
const usageFile = tmpdir.resolve('worker-usage.txt');
for (let i = 1; i <= 9; i++) {
const testFile = tmpdir.resolve(`reuse-test-${i}.mjs`);
writeFileSync(
testFile,
`import { test } from 'node:test';
import { appendFileSync } from 'node:fs';

test('track worker ${i}', () => {
const workerId = process.env.NODE_TEST_WORKER_ID;
const usageFile = process.env.WORKER_USAGE_FILE;
appendFileSync(usageFile, workerId + '\\n');
});
`,
);
testFiles.push(testFile);
}

const args = ['--test', '--test-concurrency=3', ...testFiles];
const result = spawnSync(process.execPath, args, {
env: { ...process.env, WORKER_USAGE_FILE: usageFile }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);

// Read and analyze worker IDs used
const { readFileSync } = require('node:fs');
const workerIds = readFileSync(usageFile, 'utf8').trim().split('\n');

// Count occurrences of each worker ID
const workerCounts = {};
workerIds.forEach((id) => {
workerCounts[id] = (workerCounts[id] || 0) + 1;
});

const uniqueWorkers = Object.keys(workerCounts);
assert.strictEqual(
uniqueWorkers.length,
3,
`Should have exactly 3 unique worker IDs, got ${uniqueWorkers.length}: ${uniqueWorkers.join(', ')}`
);

Object.entries(workerCounts).forEach(([id, count]) => {
assert.strictEqual(count, 3, `Worker ID ${id} should be used 3 times, got ${count}`);
});
});