Skip to content

Commit 0e5cc7d

Browse files
dnlupnovemberborn
andauthored
Run test files in worker threads
Add `--worker-threads` to use worker_threads to run tests. By default the option is enabled. Co-authored-by: Mark Wubben <[email protected]>
1 parent 5eea608 commit 0e5cc7d

File tree

26 files changed

+767
-609
lines changed

26 files changed

+767
-609
lines changed

docs/01-writing-tests.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ You must define all tests synchronously. They can't be defined inside `setTimeou
88

99
AVA tries to run test files with their current working directory set to the directory that contains your `package.json` file.
1010

11-
## Process isolation
11+
## Test isolation
1212

13-
Each test file is run in a separate Node.js process. This allows you to change the global state or overriding a built-in in one test file, without affecting another. It's also great for performance on modern multi-core processors, allowing multiple test files to execute in parallel.
13+
AVA 3 runs each test file in a separate Node.js process. This allows you to change the global state or overriding a built-in in one test file, without affecting another.
14+
15+
AVA 4 runs each test file in a new worker thread, though you can fall back to AVA 3's behavior of running in separate processes.
1416

1517
AVA will set `process.env.NODE_ENV` to `test`, unless the `NODE_ENV` environment variable has been set. This is useful if the code you're testing has test defaults (for example when picking what database to connect to). It may cause your code or its dependencies to behave differently though. Note that `'NODE_ENV' in process.env` will always be `true`.
1618

docs/05-command-line.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Options:
2727
--help Show help [boolean]
2828
--concurrency, -c Max number of test files running at the same time
2929
(default: CPU cores) [number]
30+
--no-worker-threads Don't use worker threads [boolean] (AVA 4 only)
3031
--fail-fast Stop after first test failure [boolean]
3132
--match, -m Only run tests with matching title (can be repeated)
3233
[string]

docs/06-configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Arguments passed to the CLI will always take precedence over the CLI options con
4747
- `match`: not typically useful in the `package.json` configuration, but equivalent to [specifying `--match` on the CLI](./05-command-line.md#running-tests-with-matching-titles)
4848
- `cache`: cache compiled files under `node_modules/.cache/ava`. If `false`, files are cached in a temporary directory instead
4949
- `concurrency`: max number of test files running at the same time (default: CPU cores)
50+
- `workerThreads`: use worker threads to run tests (requires AVA 4, enabled by default). If `false`, tests will run in child processes (how AVA 3 behaves)
5051
- `failFast`: stop running further tests once a test fails
5152
- `failWithoutAssertions`: if `false`, does not fail a test if it doesn't run [assertions](./03-assertions.md)
5253
- `environmentVariables`: specifies environment variables to be made available to the tests. The environment variables defined here override the ones from `process.env`

lib/api.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,10 @@ class Api extends Emittery {
212212
}
213213

214214
const lineNumbers = getApplicableLineNumbers(globs.normalizeFileForMatching(apiOptions.projectDir, file), filter);
215+
// Removing `providers` field because they cannot be transfered to the worker threads.
216+
const {providers, ...forkOptions} = apiOptions;
215217
const options = {
216-
...apiOptions,
218+
...forkOptions,
217219
providerStates,
218220
lineNumbers,
219221
recordNewSnapshots: !isCi,

lib/cli.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ const FLAGS = {
3535
description: 'Only run tests with matching title (can be repeated)',
3636
type: 'string'
3737
},
38+
'no-worker-threads': {
39+
coerce: coerceLastValue,
40+
description: 'Don\'t use worker threads',
41+
type: 'boolean'
42+
},
3843
'node-arguments': {
3944
coerce: coerceLastValue,
4045
description: 'Additional Node.js arguments for launching worker processes (specify as a single string)',
@@ -184,7 +189,13 @@ exports.run = async () => { // eslint-disable-line complexity
184189
.help();
185190

186191
const combined = {...conf};
192+
187193
for (const flag of Object.keys(FLAGS)) {
194+
if (flag === 'no-worker-threads' && Reflect.has(argv, 'worker-threads')) {
195+
combined.workerThreads = argv['worker-threads'];
196+
continue;
197+
}
198+
188199
if (Reflect.has(argv, flag)) {
189200
if (flag === 'fail-fast') {
190201
combined.failFast = argv[flag];
@@ -387,6 +398,7 @@ exports.run = async () => { // eslint-disable-line complexity
387398
cacheEnabled: combined.cache !== false,
388399
chalkOptions,
389400
concurrency: combined.concurrency || 0,
401+
workerThreads: combined.workerThreads !== false,
390402
debug,
391403
environmentVariables,
392404
experiments,

lib/fork.js

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
'use strict';
2+
const {Worker} = require('worker_threads');
23
const childProcess = require('child_process');
34
const path = require('path');
45
const fs = require('fs');
@@ -12,7 +13,7 @@ if (fs.realpathSync(__filename) !== __filename) {
1213
// In case the test file imports a different AVA install,
1314
// the presence of this variable allows it to require this one instead
1415
const AVA_PATH = path.resolve(__dirname, '..');
15-
const WORKER_PATH = require.resolve('./worker/subprocess');
16+
const WORKER_PATH = require.resolve('./worker/base.js');
1617

1718
class SharedWorkerChannel extends Emittery {
1819
constructor({channelId, filename, initialData}, sendToFork) {
@@ -59,7 +60,51 @@ class SharedWorkerChannel extends Emittery {
5960

6061
let forkCounter = 0;
6162

63+
const createWorker = (options, execArgv) => {
64+
let worker;
65+
let postMessage;
66+
let close;
67+
if (options.workerThreads) {
68+
worker = new Worker(WORKER_PATH, {
69+
argv: options.workerArgv,
70+
env: {NODE_ENV: 'test', ...process.env, ...options.environmentVariables, AVA_PATH},
71+
execArgv,
72+
workerData: {
73+
options
74+
},
75+
trackUnmanagedFds: true,
76+
stdin: true,
77+
stdout: true,
78+
stderr: true
79+
});
80+
postMessage = worker.postMessage.bind(worker);
81+
close = async () => {
82+
try {
83+
await worker.terminate();
84+
} finally {
85+
// No-op
86+
}
87+
};
88+
} else {
89+
worker = childProcess.fork(WORKER_PATH, options.workerArgv, {
90+
cwd: options.projectDir,
91+
silent: true,
92+
env: {NODE_ENV: 'test', ...process.env, ...options.environmentVariables, AVA_PATH},
93+
execArgv
94+
});
95+
postMessage = controlFlow(worker);
96+
close = async () => worker.kill();
97+
}
98+
99+
return {
100+
worker,
101+
postMessage,
102+
close
103+
};
104+
};
105+
62106
module.exports = (file, options, execArgv = process.execArgv) => {
107+
// TODO: this can be changed to use `threadId` when using worker_threads
63108
const forkId = `fork/${++forkCounter}`;
64109
const sharedWorkerChannels = new Map();
65110

@@ -79,27 +124,19 @@ module.exports = (file, options, execArgv = process.execArgv) => {
79124
...options
80125
};
81126

82-
const subprocess = childProcess.fork(WORKER_PATH, options.workerArgv, {
83-
cwd: options.projectDir,
84-
silent: true,
85-
env: {NODE_ENV: 'test', ...process.env, ...options.environmentVariables, AVA_PATH},
86-
execArgv
87-
});
88-
89-
subprocess.stdout.on('data', chunk => {
127+
const {worker, postMessage, close} = createWorker(options, execArgv);
128+
worker.stdout.on('data', chunk => {
90129
emitStateChange({type: 'worker-stdout', chunk});
91130
});
92131

93-
subprocess.stderr.on('data', chunk => {
132+
worker.stderr.on('data', chunk => {
94133
emitStateChange({type: 'worker-stderr', chunk});
95134
});
96135

97-
const bufferedSend = controlFlow(subprocess);
98-
99136
let forcedExit = false;
100137
const send = evt => {
101138
if (!finished && !forcedExit) {
102-
bufferedSend({ava: evt});
139+
postMessage({ava: evt});
103140
}
104141
};
105142

@@ -109,7 +146,7 @@ module.exports = (file, options, execArgv = process.execArgv) => {
109146
resolve();
110147
};
111148

112-
subprocess.on('message', message => {
149+
worker.on('message', message => {
113150
if (!message.ava) {
114151
return;
115152
}
@@ -118,6 +155,7 @@ module.exports = (file, options, execArgv = process.execArgv) => {
118155
case 'ready-for-options':
119156
send({type: 'options', options});
120157
break;
158+
121159
case 'shared-worker-connect': {
122160
const channel = new SharedWorkerChannel(message.ava, send);
123161
sharedWorkerChannels.set(channel.id, channel);
@@ -136,12 +174,12 @@ module.exports = (file, options, execArgv = process.execArgv) => {
136174
}
137175
});
138176

139-
subprocess.on('error', error => {
177+
worker.on('error', error => {
140178
emitStateChange({type: 'worker-failed', err: error});
141179
finish();
142180
});
143181

144-
subprocess.on('exit', (code, signal) => {
182+
worker.on('exit', (code, signal) => {
145183
if (forcedExit) {
146184
emitStateChange({type: 'worker-finished', forcedExit});
147185
} else if (code > 0) {
@@ -163,7 +201,7 @@ module.exports = (file, options, execArgv = process.execArgv) => {
163201

164202
exit() {
165203
forcedExit = true;
166-
subprocess.kill();
204+
close();
167205
},
168206

169207
notifyOfPeerFailure() {

lib/worker/subprocess.js renamed to lib/worker/base.js

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
'use strict';
22
const {pathToFileURL} = require('url');
3+
const path = require('path');
34
const currentlyUnhandled = require('currently-unhandled')();
5+
const {isRunningInThread, isRunningInChildProcess} = require('./utils');
46

5-
require('./ensure-forked'); // eslint-disable-line import/no-unassigned-import
7+
// Check if the test is being run without AVA cli
8+
if (!isRunningInChildProcess && !isRunningInThread) {
9+
const chalk = require('chalk'); // Use default Chalk instance.
10+
if (process.argv[1]) {
11+
const fp = path.relative('.', process.argv[1]);
612

7-
const ipc = require('./ipc');
13+
console.log();
14+
console.error(`Test files must be run with the AVA CLI:\n\n ${chalk.grey.dim('$')} ${chalk.cyan('ava ' + fp)}\n`);
815

9-
ipc.send({type: 'ready-for-options'});
10-
ipc.options.then(async options => {
16+
process.exit(1);
17+
} else {
18+
throw new Error('The ’ava’ module can only be imported in test files');
19+
}
20+
}
21+
22+
const channel = require('./channel');
23+
24+
const run = async options => {
1125
require('./options').set(options);
1226
require('../chalk').set(options.chalkOptions);
1327

@@ -31,8 +45,8 @@ ipc.options.then(async options => {
3145
}
3246

3347
dependencyTracking.flush();
34-
await ipc.flush();
35-
process.exit(); // eslint-disable-line unicorn/no-process-exit
48+
await channel.flush();
49+
process.exit();
3650
}
3751

3852
// TODO: Initialize providers here, then pass to lineNumberSelection() so they
@@ -44,7 +58,7 @@ ipc.options.then(async options => {
4458
lineNumbers: options.lineNumbers
4559
});
4660
} catch (error) {
47-
ipc.send({type: 'line-number-selection-error', err: serializeError('Line number selection error', false, error, options.file)});
61+
channel.send({type: 'line-number-selection-error', err: serializeError('Line number selection error', false, error, options.file)});
4862
checkSelectedByLineNumbers = () => false;
4963
}
5064

@@ -63,7 +77,7 @@ ipc.options.then(async options => {
6377
updateSnapshots: options.updateSnapshots
6478
});
6579

66-
ipc.peerFailed.then(() => { // eslint-disable-line promise/prefer-await-to-then
80+
channel.peerFailed.then(() => { // eslint-disable-line promise/prefer-await-to-then
6781
runner.interrupt();
6882
});
6983

@@ -75,31 +89,31 @@ ipc.options.then(async options => {
7589
});
7690

7791
runner.on('dependency', dependencyTracking.track);
78-
runner.on('stateChange', state => ipc.send(state));
92+
runner.on('stateChange', state => channel.send(state));
7993

8094
runner.on('error', error => {
81-
ipc.send({type: 'internal-error', err: serializeError('Internal runner error', false, error, runner.file)});
95+
channel.send({type: 'internal-error', err: serializeError('Internal runner error', false, error, runner.file)});
8296
exit(1);
8397
});
8498

8599
runner.on('finish', async () => {
86100
try {
87101
const {cannotSave, touchedFiles} = runner.saveSnapshotState();
88102
if (cannotSave) {
89-
ipc.send({type: 'snapshot-error'});
103+
channel.send({type: 'snapshot-error'});
90104
} else if (touchedFiles) {
91-
ipc.send({type: 'touched-files', files: touchedFiles});
105+
channel.send({type: 'touched-files', files: touchedFiles});
92106
}
93107
} catch (error) {
94-
ipc.send({type: 'internal-error', err: serializeError('Internal runner error', false, error, runner.file)});
108+
channel.send({type: 'internal-error', err: serializeError('Internal runner error', false, error, runner.file)});
95109
exit(1);
96110
return;
97111
}
98112

99113
try {
100114
await Promise.all(sharedWorkerTeardowns.map(fn => fn()));
101115
} catch (error) {
102-
ipc.send({type: 'uncaught-exception', err: serializeError('Shared worker teardown error', false, error, runner.file)});
116+
channel.send({type: 'uncaught-exception', err: serializeError('Shared worker teardown error', false, error, runner.file)});
103117
exit(1);
104118
return;
105119
}
@@ -108,7 +122,7 @@ ipc.options.then(async options => {
108122
currentlyUnhandled()
109123
.filter(rejection => !attributedRejections.has(rejection.promise))
110124
.forEach(rejection => {
111-
ipc.send({type: 'unhandled-rejection', err: serializeError('Unhandled rejection', true, rejection.reason, runner.file)});
125+
channel.send({type: 'unhandled-rejection', err: serializeError('Unhandled rejection', true, rejection.reason, runner.file)});
112126
});
113127

114128
exit(0);
@@ -120,7 +134,7 @@ ipc.options.then(async options => {
120134
return;
121135
}
122136

123-
ipc.send({type: 'uncaught-exception', err: serializeError('Uncaught exception', true, error, runner.file)});
137+
channel.send({type: 'uncaught-exception', err: serializeError('Uncaught exception', true, error, runner.file)});
124138
exit(1);
125139
});
126140

@@ -131,7 +145,7 @@ ipc.options.then(async options => {
131145
};
132146

133147
exports.registerSharedWorker = (filename, initialData, teardown) => {
134-
const {channel, forceUnref, ready} = ipc.registerSharedWorker(filename, initialData);
148+
const {channel: sharedWorkerChannel, forceUnref, ready} = channel.registerSharedWorker(filename, initialData);
135149
runner.waitForReady.push(ready);
136150
sharedWorkerTeardowns.push(async () => {
137151
try {
@@ -140,7 +154,7 @@ ipc.options.then(async options => {
140154
forceUnref();
141155
}
142156
});
143-
return channel;
157+
return sharedWorkerChannel;
144158
};
145159

146160
// Store value to prevent required modules from modifying it.
@@ -215,23 +229,35 @@ ipc.options.then(async options => {
215229
await load(testPath);
216230

217231
if (accessedRunner) {
218-
// Unreference the IPC channel if the test file required AVA. This stops it
232+
// Unreference the channel if the test file required AVA. This stops it
219233
// from keeping the event loop busy, which means the `beforeExit` event can be
220234
// used to detect when tests stall.
221-
ipc.unref();
235+
channel.unref();
222236
} else {
223-
ipc.send({type: 'missing-ava-import'});
237+
channel.send({type: 'missing-ava-import'});
224238
exit(1);
225239
}
226240
} catch (error) {
227-
ipc.send({type: 'uncaught-exception', err: serializeError('Uncaught exception', true, error, runner.file)});
241+
channel.send({type: 'uncaught-exception', err: serializeError('Uncaught exception', true, error, runner.file)});
228242
exit(1);
229243
}
230-
}).catch(error => {
244+
};
245+
246+
const onError = error => {
231247
// There shouldn't be any errors, but if there are we may not have managed
232248
// to bootstrap enough code to serialize them. Re-throw and let the process
233249
// crash.
234250
setImmediate(() => {
235251
throw error;
236252
});
237-
});
253+
};
254+
255+
if (isRunningInThread) {
256+
const {workerData} = require('worker_threads');
257+
const {options} = workerData;
258+
delete workerData.options; // Don't allow user code access.
259+
run(options).catch(onError);
260+
} else if (isRunningInChildProcess) {
261+
channel.send({type: 'ready-for-options'});
262+
channel.options.then(run).catch(onError); // eslint-disable-line promise/prefer-await-to-then
263+
}

0 commit comments

Comments
 (0)