Skip to content

Commit 2e0c640

Browse files
committed
test_runner: recieve and pass AbortSignal
1 parent 7d3126e commit 2e0c640

File tree

4 files changed

+392
-68
lines changed

4 files changed

+392
-68
lines changed

lib/internal/main/test_runner.js

Lines changed: 36 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ const {
66
ArrayPrototypePush,
77
ArrayPrototypeSlice,
88
ArrayPrototypeSort,
9-
Promise,
109
PromiseAll,
1110
SafeArrayIterator,
1211
SafeSet,
@@ -16,7 +15,6 @@ const {
1615
} = require('internal/bootstrap/pre_execution');
1716
const { spawn } = require('child_process');
1817
const { readdirSync, statSync } = require('fs');
19-
const { finished } = require('internal/streams/end-of-stream');
2018
const console = require('internal/console/global');
2119
const {
2220
codes: {
@@ -30,6 +28,7 @@ const {
3028
doesPathMatchFilter,
3129
} = require('internal/test_runner/utils');
3230
const { basename, join, resolve } = require('path');
31+
const { once } = require('events');
3332
const kFilterArgs = ['--test'];
3433

3534
prepareMainThreadExecution(false);
@@ -102,53 +101,42 @@ function filterExecArgv(arg) {
102101
}
103102

104103
function runTestFile(path) {
105-
return test(path, () => {
106-
return new Promise((resolve, reject) => {
107-
const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
108-
ArrayPrototypePush(args, path);
109-
110-
const child = spawn(process.execPath, args);
111-
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
112-
// instead of just displaying it all if the child fails.
113-
let stdout = '';
114-
let stderr = '';
115-
let err;
116-
117-
child.on('error', (error) => {
118-
err = error;
119-
});
120-
121-
child.stdout.setEncoding('utf8');
122-
child.stderr.setEncoding('utf8');
123-
124-
child.stdout.on('data', (chunk) => {
125-
stdout += chunk;
126-
});
127-
128-
child.stderr.on('data', (chunk) => {
129-
stderr += chunk;
130-
});
131-
132-
child.once('exit', async (code, signal) => {
133-
if (code !== 0 || signal !== null) {
134-
if (!err) {
135-
await PromiseAll(new SafeArrayIterator([finished(child.stderr), finished(child.stdout)]));
136-
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed);
137-
err.exitCode = code;
138-
err.signal = signal;
139-
err.stdout = stdout;
140-
err.stderr = stderr;
141-
// The stack will not be useful since the failures came from tests
142-
// in a child process.
143-
err.stack = undefined;
144-
}
145-
146-
return reject(err);
147-
}
148-
149-
resolve();
150-
});
104+
return test(path, async (t) => {
105+
const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
106+
ArrayPrototypePush(args, path);
107+
108+
const child = spawn(process.execPath, args, { signal: t.signal });
109+
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
110+
// instead of just displaying it all if the child fails.
111+
// let stderr = '';
112+
let err;
113+
114+
child.on('error', (error) => {
115+
err = error;
151116
});
117+
118+
child.stdout.setEncoding('utf8');
119+
child.stderr.setEncoding('utf8');
120+
const { 0: { code, signal }, 1: stdout, 2: stderr } = await PromiseAll(new SafeArrayIterator([
121+
once(child, 'exit', { signal: t.signal }),
122+
child.stdout.toArray({ signal: t.signal }),
123+
child.stderr.toArray({ signal: t.signal }),
124+
]));
125+
126+
if (code !== 0 || signal !== null) {
127+
if (!err) {
128+
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed);
129+
err.exitCode = code;
130+
err.signal = signal;
131+
err.stdout = stdout.join('');
132+
err.stderr = stderr.join('');
133+
// The stack will not be useful since the failures came from tests
134+
// in a child process.
135+
err.stack = undefined;
136+
}
137+
138+
throw err;
139+
}
152140
});
153141
}
154142

lib/internal/test_runner/test.js

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,19 @@ const {
88
Number,
99
PromiseResolve,
1010
ReflectApply,
11+
SafeArrayIterator,
1112
SafeMap,
1213
PromiseRace,
1314
} = primordials;
1415
const { AsyncResource } = require('async_hooks');
16+
const { once } = require('events');
17+
const { AbortController } = require('internal/abort_controller');
1518
const {
1619
codes: {
1720
ERR_TEST_FAILURE,
1821
},
1922
kIsNodeError,
23+
AbortError,
2024
} = require('internal/errors');
2125
const { getOptionValue } = require('internal/options');
2226
const { TapStream } = require('internal/test_runner/tap_stream');
@@ -26,7 +30,7 @@ const {
2630
kEmptyObject,
2731
} = require('internal/util');
2832
const { isPromise } = require('internal/util/types');
29-
const { isUint32 } = require('internal/validators');
33+
const { isUint32, validateAbortSignal } = require('internal/validators');
3034
const { setTimeout } = require('timers/promises');
3135
const { cpus } = require('os');
3236
const { bigint: hrtime } = process.hrtime;
@@ -45,19 +49,16 @@ const testOnlyFlag = !isTestRunner && getOptionValue('--test-only');
4549
const rootConcurrency = isTestRunner ? cpus().length : 1;
4650

4751

48-
function testTimeout(promise, timeout) {
52+
function stopTest(timeout, signal) {
4953
if (timeout === kDefaultTimeout) {
50-
return promise;
51-
}
52-
return PromiseRace([
53-
promise,
54-
setTimeout(timeout, null, { ref: false }).then(() => {
55-
throw new ERR_TEST_FAILURE(
56-
`test timed out after ${timeout}ms`,
57-
kTestTimeoutFailure
58-
);
59-
}),
60-
]);
54+
return once(signal, 'abort');
55+
}
56+
return setTimeout(timeout, null, { ref: false, signal }).then(() => {
57+
throw new ERR_TEST_FAILURE(
58+
`test timed out after ${timeout}ms`,
59+
kTestTimeoutFailure
60+
);
61+
});
6162
}
6263

6364
class TestContext {
@@ -67,6 +68,10 @@ class TestContext {
6768
this.#test = test;
6869
}
6970

71+
get signal() {
72+
return this.#test.signal;
73+
}
74+
7075
diagnostic(message) {
7176
this.#test.diagnostic(message);
7277
}
@@ -92,11 +97,14 @@ class TestContext {
9297
}
9398

9499
class Test extends AsyncResource {
100+
#abortController;
101+
#outerSignal;
102+
95103
constructor(options) {
96104
super('Test');
97105

98106
let { fn, name, parent, skip } = options;
99-
const { concurrency, only, timeout, todo } = options;
107+
const { concurrency, only, timeout, todo, signal } = options;
100108

101109
if (typeof fn !== 'function') {
102110
fn = noop;
@@ -149,6 +157,14 @@ class Test extends AsyncResource {
149157
fn = noop;
150158
}
151159

160+
this.#abortController = new AbortController();
161+
this.#outerSignal = signal;
162+
this.signal = this.#abortController.signal;
163+
164+
validateAbortSignal(signal, 'options.signal');
165+
this.#outerSignal?.addEventListener('abort', this.#abortHandler);
166+
167+
152168
this.fn = fn;
153169
this.name = name;
154170
this.parent = parent;
@@ -268,18 +284,23 @@ class Test extends AsyncResource {
268284
return test;
269285
}
270286

271-
cancel() {
287+
#abortHandler = () => {
288+
this.cancel(this.#outerSignal?.reason || new AbortError('The test was aborted'));
289+
};
290+
291+
cancel(error) {
272292
if (this.endTime !== null) {
273293
return;
274294
}
275295

276-
this.fail(
296+
this.fail(error ||
277297
new ERR_TEST_FAILURE(
278298
'test did not finish before its parent and was cancelled',
279299
kCancelledByParent
280300
)
281301
);
282302
this.cancelled = true;
303+
this.#abortController.abort();
283304
}
284305

285306
fail(err) {
@@ -329,6 +350,15 @@ class Test extends AsyncResource {
329350

330351
return this.run();
331352
}
353+
#shouldAbort() {
354+
if (this.signal.aborted) {
355+
return true;
356+
}
357+
if (this.#outerSignal?.aborted) {
358+
this.cancel(this.#outerSignal.reason || new AbortError('The test was aborted'));
359+
return true;
360+
}
361+
}
332362

333363
getRunArgs() {
334364
const ctx = new TestContext(this);
@@ -339,9 +369,15 @@ class Test extends AsyncResource {
339369
this.parent.activeSubtests++;
340370
this.startTime = hrtime();
341371

372+
if (this.#shouldAbort()) {
373+
this.postRun();
374+
return;
375+
}
376+
342377
try {
378+
const stopPromise = stopTest(this.timeout, this.signal);
343379
const { args, ctx } = this.getRunArgs();
344-
ArrayPrototypeUnshift(args, this.fn, ctx); // Note that if it's not OK to mutate args, we need to first clone it.
380+
ArrayPrototypeUnshift(args, this.fn, ctx);
345381

346382
if (this.fn.length === args.length - 1) {
347383
// This test is using legacy Node.js error first callbacks.
@@ -355,13 +391,19 @@ class Test extends AsyncResource {
355391
'passed a callback but also returned a Promise',
356392
kCallbackAndPromisePresent
357393
));
358-
await testTimeout(ret, this.timeout);
394+
await PromiseRace(SafeArrayIterator([ret, stopPromise]));
359395
} else {
360-
await testTimeout(promise, this.timeout);
396+
await PromiseRace(SafeArrayIterator([promise, stopPromise]));
361397
}
362398
} else {
363399
// This test is synchronous or using Promises.
364-
await testTimeout(ReflectApply(this.runInAsyncScope, this, args), this.timeout);
400+
const promise = ReflectApply(this.runInAsyncScope, this, args);
401+
await PromiseRace(new SafeArrayIterator([promise, stopPromise]));
402+
}
403+
404+
if (this.#shouldAbort()) {
405+
this.postRun();
406+
return;
365407
}
366408

367409
this.pass();
@@ -409,6 +451,8 @@ class Test extends AsyncResource {
409451
this.fail(new ERR_TEST_FAILURE(msg, kSubtestsFailed));
410452
}
411453

454+
this.#outerSignal?.removeEventListener('abort', this.#abortHandler);
455+
412456
if (this.parent !== null) {
413457
this.parent.activeSubtests--;
414458
this.parent.addReadySubtest(this);

test/message/test_runner_abort.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Flags: --no-warnings
2+
'use strict';
3+
require('../common');
4+
const test = require('node:test');
5+
6+
test('promise timeout signal', { signal: AbortSignal.timeout(1) }, async (t) => {
7+
await Promise.all([
8+
t.test('ok 1', async () => {}),
9+
t.test('ok 2', () => {}),
10+
t.test('ok 3', { signal: t.signal }, async () => {}),
11+
t.test('ok 4', { signal: t.signal }, () => {}),
12+
t.test('not ok 1', () => new Promise(() => {})),
13+
t.test('not ok 2', (t, done) => {}),
14+
t.test('not ok 3', { signal: t.signal }, () => new Promise(() => {})),
15+
t.test('not ok 4', { signal: t.signal }, (t, done) => {}),
16+
t.test('not ok 5', { signal: t.signal }, (t, done) => {
17+
t.signal.addEventListener('abort', done);
18+
}),
19+
]);
20+
});
21+
22+
test('promise abort signal', { signal: AbortSignal.abort() }, async (t) => {
23+
t.test('should not appear', () => {});
24+
});
25+
26+
test('callback timeout signal', { signal: AbortSignal.timeout(1) }, (t, done) => {
27+
t.test('ok 1', async () => {});
28+
t.test('ok 2', () => {});
29+
t.test('ok 3', { signal: t.signal }, async () => {});
30+
t.test('ok 4', { signal: t.signal }, () => {});
31+
t.test('not ok 1', () => new Promise(() => {}));
32+
t.test('not ok 2', (t, done) => {});
33+
t.test('not ok 3', { signal: t.signal }, () => new Promise(() => {}));
34+
t.test('not ok 4', { signal: t.signal }, (t, done) => {});
35+
t.test('not ok 5', { signal: t.signal }, (t, done) => {
36+
t.signal.addEventListener('abort', done);
37+
});
38+
});
39+
40+
test('callback abort signal', { signal: AbortSignal.abort() }, (t, done) => {
41+
t.test('should not appear', () => {});
42+
});
43+
44+
setTimeout(() => {}, 1000);

0 commit comments

Comments
 (0)