Skip to content

Commit

Permalink
test_runner: recieve and pass AbortSignal
Browse files Browse the repository at this point in the history
  • Loading branch information
MoLow committed Jul 14, 2022
1 parent 660d17d commit 9e647d2
Show file tree
Hide file tree
Showing 8 changed files with 549 additions and 80 deletions.
86 changes: 36 additions & 50 deletions lib/internal/main/test_runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,14 @@ const {
ArrayPrototypePush,
ArrayPrototypeSlice,
ArrayPrototypeSort,
Promise,
PromiseAll,
SafeArrayIterator,
SafePromiseAll,
SafeSet,
} = primordials;
const {
prepareMainThreadExecution,
} = require('internal/bootstrap/pre_execution');
const { spawn } = require('child_process');
const { readdirSync, statSync } = require('fs');
const { finished } = require('internal/streams/end-of-stream');
const console = require('internal/console/global');
const {
codes: {
Expand All @@ -30,6 +27,7 @@ const {
doesPathMatchFilter,
} = require('internal/test_runner/utils');
const { basename, join, resolve } = require('path');
const { once } = require('events');
const kFilterArgs = ['--test'];

prepareMainThreadExecution(false);
Expand Down Expand Up @@ -102,53 +100,41 @@ function filterExecArgv(arg) {
}

function runTestFile(path) {
return test(path, () => {
return new Promise((resolve, reject) => {
const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
ArrayPrototypePush(args, path);

const child = spawn(process.execPath, args);
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
// instead of just displaying it all if the child fails.
let stdout = '';
let stderr = '';
let err;

child.on('error', (error) => {
err = error;
});

child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');

child.stdout.on('data', (chunk) => {
stdout += chunk;
});

child.stderr.on('data', (chunk) => {
stderr += chunk;
});

child.once('exit', async (code, signal) => {
if (code !== 0 || signal !== null) {
if (!err) {
await PromiseAll(new SafeArrayIterator([finished(child.stderr), finished(child.stdout)]));
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed);
err.exitCode = code;
err.signal = signal;
err.stdout = stdout;
err.stderr = stderr;
// The stack will not be useful since the failures came from tests
// in a child process.
err.stack = undefined;
}

return reject(err);
}

resolve();
});
return test(path, async (t) => {
const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
ArrayPrototypePush(args, path);

const child = spawn(process.execPath, args, { signal: t.signal });
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
// instead of just displaying it all if the child fails.
let err;

child.on('error', (error) => {
err = error;
});

child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
const { 0: { code, signal }, 1: stdout, 2: stderr } = await SafePromiseAll([
once(child, 'exit', { signal: t.signal }),
child.stdout.toArray({ signal: t.signal }),
child.stderr.toArray({ signal: t.signal }),
]);

if (code !== 0 || signal !== null) {
if (!err) {
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed);
err.exitCode = code;
err.signal = signal;
err.stdout = stdout.join('');
err.stderr = stderr.join('');
// The stack will not be useful since the failures came from tests
// in a child process.
err.stack = undefined;
}

throw err;
}
});
}

Expand Down
113 changes: 86 additions & 27 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ const {
PromiseResolve,
ReflectApply,
SafeMap,
PromiseRace,
SafePromiseRace,
Symbol,
} = primordials;
const { AsyncResource } = require('async_hooks');
const { once } = require('events');
const { AbortController } = require('internal/abort_controller');
const {
codes: {
ERR_TEST_FAILURE,
},
kIsNodeError,
AbortError,
} = require('internal/errors');
const { getOptionValue } = require('internal/options');
const { TapStream } = require('internal/test_runner/tap_stream');
Expand All @@ -26,7 +30,7 @@ const {
kEmptyObject,
} = require('internal/util');
const { isPromise } = require('internal/util/types');
const { isUint32 } = require('internal/validators');
const { isUint32, validateAbortSignal } = require('internal/validators');
const { setTimeout } = require('timers/promises');
const { cpus } = require('os');
const { bigint: hrtime } = process.hrtime;
Expand All @@ -44,20 +48,19 @@ const testOnlyFlag = !isTestRunner && getOptionValue('--test-only');
// TODO(cjihrig): Use uv_available_parallelism() once it lands.
const rootConcurrency = isTestRunner ? cpus().length : 1;

const kShouldAbort = Symbol('kShouldAbort');

function testTimeout(promise, timeout) {

function stopTest(timeout, signal) {
if (timeout === kDefaultTimeout) {
return promise;
}
return PromiseRace([
promise,
setTimeout(timeout, null, { ref: false }).then(() => {
throw new ERR_TEST_FAILURE(
`test timed out after ${timeout}ms`,
kTestTimeoutFailure
);
}),
]);
return once(signal, 'abort');
}
return setTimeout(timeout, null, { ref: false, signal }).then(() => {
throw new ERR_TEST_FAILURE(
`test timed out after ${timeout}ms`,
kTestTimeoutFailure
);
});
}

class TestContext {
Expand All @@ -67,6 +70,10 @@ class TestContext {
this.#test = test;
}

get signal() {
return this.#test.signal;
}

diagnostic(message) {
this.#test.diagnostic(message);
}
Expand All @@ -92,11 +99,14 @@ class TestContext {
}

class Test extends AsyncResource {
#abortController;
#outerSignal;

constructor(options) {
super('Test');

let { fn, name, parent, skip } = options;
const { concurrency, only, timeout, todo } = options;
const { concurrency, only, timeout, todo, signal } = options;

if (typeof fn !== 'function') {
fn = noop;
Expand Down Expand Up @@ -149,6 +159,14 @@ class Test extends AsyncResource {
fn = noop;
}

this.#abortController = new AbortController();
this.#outerSignal = signal;
this.signal = this.#abortController.signal;

validateAbortSignal(signal, 'options.signal');
this.#outerSignal?.addEventListener('abort', this.#abortHandler);


this.fn = fn;
this.name = name;
this.parent = parent;
Expand Down Expand Up @@ -242,7 +260,8 @@ class Test extends AsyncResource {

// If this test has already ended, attach this test to the root test so
// that the error can be properly reported.
if (this.finished) {
const preventAddingSubtests = this.finished || this.buildPhaseFinished;
if (preventAddingSubtests) {
while (parent.parent !== null) {
parent = parent.parent;
}
Expand All @@ -254,7 +273,7 @@ class Test extends AsyncResource {
parent.waitingOn = test.testNumber;
}

if (this.finished) {
if (preventAddingSubtests) {
test.startTime = test.startTime || hrtime();
test.fail(
new ERR_TEST_FAILURE(
Expand All @@ -268,18 +287,23 @@ class Test extends AsyncResource {
return test;
}

cancel() {
#abortHandler = () => {
this.cancel(this.#outerSignal?.reason || new AbortError('The test was aborted'));
};

cancel(error) {
if (this.endTime !== null) {
return;
}

this.fail(
this.fail(error ||
new ERR_TEST_FAILURE(
'test did not finish before its parent and was cancelled',
kCancelledByParent
)
);
this.cancelled = true;
this.#abortController.abort();
}

fail(err) {
Expand Down Expand Up @@ -330,6 +354,16 @@ class Test extends AsyncResource {
return this.run();
}

[kShouldAbort]() {
if (this.signal.aborted) {
return true;
}
if (this.#outerSignal?.aborted) {
this.cancel(this.#outerSignal.reason || new AbortError('The test was aborted'));
return true;
}
}

getRunArgs() {
const ctx = new TestContext(this);
return { ctx, args: [ctx] };
Expand All @@ -339,7 +373,13 @@ class Test extends AsyncResource {
this.parent.activeSubtests++;
this.startTime = hrtime();

if (this[kShouldAbort]()) {
this.postRun();
return;
}

try {
const stopPromise = stopTest(this.timeout, this.signal);
const { args, ctx } = this.getRunArgs();
ArrayPrototypeUnshift(args, this.fn, ctx); // Note that if it's not OK to mutate args, we need to first clone it.

Expand All @@ -355,13 +395,19 @@ class Test extends AsyncResource {
'passed a callback but also returned a Promise',
kCallbackAndPromisePresent
));
await testTimeout(ret, this.timeout);
await SafePromiseRace([ret, stopPromise]);
} else {
await testTimeout(promise, this.timeout);
await SafePromiseRace([PromiseResolve(promise), stopPromise]);
}
} else {
// This test is synchronous or using Promises.
await testTimeout(ReflectApply(this.runInAsyncScope, this, args), this.timeout);
const promise = ReflectApply(this.runInAsyncScope, this, args);
await SafePromiseRace([PromiseResolve(promise), stopPromise]);
}

if (this[kShouldAbort]()) {
this.postRun();
return;
}

this.pass();
Expand Down Expand Up @@ -410,6 +456,8 @@ class Test extends AsyncResource {
this.fail(new ERR_TEST_FAILURE(msg, kSubtestsFailed));
}

this.#outerSignal?.removeEventListener('abort', this.#abortHandler);

if (this.parent !== null) {
this.parent.activeSubtests--;
this.parent.addReadySubtest(this);
Expand Down Expand Up @@ -477,20 +525,21 @@ class Test extends AsyncResource {
class ItTest extends Test {
constructor(opt) { super(opt); } // eslint-disable-line no-useless-constructor
getRunArgs() {
return { ctx: {}, args: [] };
return { ctx: { signal: this.signal }, args: [] };
}
}
class Suite extends Test {
constructor(options) {
super(options);

try {
this.buildSuite = this.runInAsyncScope(this.fn);
const context = { signal: this.signal };
this.buildSuite = this.runInAsyncScope(this.fn, context, [context]);
} catch (err) {
this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure));
}
this.fn = () => {};
this.finished = true; // Forbid adding subtests to this suite
this.buildPhaseFinished = true;
}

start() {
Expand All @@ -505,11 +554,21 @@ class Suite extends Test {
}
this.parent.activeSubtests++;
this.startTime = hrtime();

if (this[kShouldAbort]()) {
this.subtests = [];
this.postRun();
return;
}

const stopPromise = stopTest(this.timeout, this.signal);
const subtests = this.skipped || this.error ? [] : this.subtests;
await testTimeout(ArrayPrototypeReduce(subtests, async (prev, subtest) => {
const promise = ArrayPrototypeReduce(subtests, async (prev, subtest) => {
await prev;
await subtest.run();
}, PromiseResolve()), this.timeout);
}, PromiseResolve());

await SafePromiseRace([promise, stopPromise]);
this.pass();
this.postRun();
}
Expand Down
Loading

0 comments on commit 9e647d2

Please sign in to comment.