Skip to content

Commit

Permalink
test_runner: add timeout support to test plan
Browse files Browse the repository at this point in the history
  • Loading branch information
pmarchini committed Jan 26, 2025
1 parent ae39490 commit 29f5571
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 10 deletions.
58 changes: 48 additions & 10 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ function lazyAssertObject(harness) {
return assertObj;
}

function stopTest(timeout, signal) {
function stopTest(timeout, signal, reason) {
const deferred = PromiseWithResolvers();
const abortListener = addAbortListener(signal, deferred.resolve);
let timer;
Expand All @@ -141,7 +141,7 @@ function stopTest(timeout, signal) {
writable: true,
value: PromisePrototypeThen(deferred.promise, () => {
throw new ERR_TEST_FAILURE(
`test timed out after ${timeout}ms`,
reason || `test timed out after ${timeout}ms`,
kTestTimeoutFailure,
);
}),
Expand Down Expand Up @@ -176,10 +176,26 @@ function testMatchesPattern(test, patterns) {
}

class TestPlan {
constructor(count) {
#timeoutPromise;
#testSignal;

constructor(count, options = kEmptyObject) {
validateUint32(count, 'count');
this.expected = count;
this.actual = 0;
this.timeout = options.timeout;

if (options.signal) {
this.#testSignal = options.signal;
}
if (this.timeout !== undefined) {
validateNumber(this.timeout, 'options.timeout', 0, TIMEOUT_MAX);
this.#timeoutPromise = stopTest(
this.timeout,
this.#testSignal,
`plan timed out after ${this.timeout}ms with ${this.actual} assertions when expecting ${this.expected}`,
);
}
}

check() {
Expand All @@ -194,6 +210,14 @@ class TestPlan {
increaseActualCount() {
this.actual++;
}

get timeoutPromise() {
return this.#timeoutPromise;
}

hasTimeout() {
return this.#timeoutPromise !== undefined;
}
}

class TestContext {
Expand Down Expand Up @@ -232,15 +256,19 @@ class TestContext {
this.#test.diagnostic(message);
}

plan(count) {
plan(count, options) {
if (this.#test.plan !== null) {
throw new ERR_TEST_FAILURE(
'cannot set plan more than once',
kTestCodeFailure,
);
}

this.#test.plan = new TestPlan(count);
this.#test.plan = new TestPlan(count, {
__proto__: null,
...options,
signal: this.#test.signal,
});
}

get assert() {
Expand Down Expand Up @@ -963,28 +991,37 @@ class Test extends AsyncResource {
const runArgs = ArrayPrototypeSlice(args);
ArrayPrototypeUnshift(runArgs, this.fn, ctx);

const promises = [];
if (this.fn.length === runArgs.length - 1) {
// This test is using legacy Node.js error first callbacks.
// This test is using legacy Node.js error-first callbacks.
const { promise, cb } = createDeferredCallback();

ArrayPrototypePush(runArgs, cb);

const ret = ReflectApply(this.runInAsyncScope, this, runArgs);

if (isPromise(ret)) {
this.fail(new ERR_TEST_FAILURE(
'passed a callback but also returned a Promise',
kCallbackAndPromisePresent,
));
await SafePromiseRace([ret, stopPromise]);
ArrayPrototypePush(promises, ret);
} else {
await SafePromiseRace([PromiseResolve(promise), stopPromise]);
ArrayPrototypePush(promises, PromiseResolve(promise));
}
} else {
// This test is synchronous or using Promises.
const promise = ReflectApply(this.runInAsyncScope, this, runArgs);
await SafePromiseRace([PromiseResolve(promise), stopPromise]);
ArrayPrototypePush(promises, PromiseResolve(promise));
}

ArrayPrototypePush(promises, stopPromise);
if (this.plan?.hasTimeout()) {
ArrayPrototypePush(promises, this.plan.timeoutPromise);
}

// Wait for the race to finish
await SafePromiseRace(promises);

this[kShouldAbort]();
this.plan?.check();
this.pass();
Expand All @@ -1004,6 +1041,7 @@ class Test extends AsyncResource {
try { await after(); } catch { /* Ignore error. */ }
} finally {
stopPromise?.[SymbolDispose]();
this.plan?.timeoutPromise?.[SymbolDispose]();

// Do not abort hooks and the root test as hooks instance are shared between tests suite so aborting them will
// cause them to not run for further tests.
Expand Down
34 changes: 34 additions & 0 deletions test/fixtures/test-runner/output/test-runner-plan-timeout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';
const { describe, it } = require('node:test');
const { setTimeout } = require('node:timers/promises');

describe('planning with timeout', () => {
it(`planning should pass if plan it's correct`, async (t) => {
t.plan(1, { timeout: 500_000_000 });
t.assert.ok(true);
});

it(`planning should fail if plan it's incorrect`, async (t) => {
t.plan(1, { timeout: 500_000_000 });
t.assert.ok(true);
t.assert.ok(true);
});

it('planning with timeout', async (t) => {
t.plan(1, { timeout: 2000 });

while (true) {
await setTimeout(5000);
}
});

it('nested planning with timeout', async (t) => {
t.plan(1, { timeout: 2000 });

t.test('nested', async (t) => {
while (true) {
await setTimeout(5000);
}
});
});
});
68 changes: 68 additions & 0 deletions test/fixtures/test-runner/output/test-runner-plan-timeout.snapshot
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
TAP version 13
# Subtest: planning with timeout
# Subtest: planning should pass if plan it's correct
ok 1 - planning should pass if plan it's correct
---
duration_ms: *
type: 'test'
...
# Subtest: planning should fail if plan it's incorrect
not ok 2 - planning should fail if plan it's incorrect
---
duration_ms: *
type: 'test'
location: '/test/fixtures/test-runner/output/test-runner-plan-timeout.js:(LINE):3'
failureType: 'testCodeFailure'
error: 'plan expected 1 assertions but received 2'
code: 'ERR_TEST_FAILURE'
...
# Subtest: planning with timeout
not ok 3 - planning with timeout
---
duration_ms: *
type: 'test'
location: '/test/fixtures/test-runner/output/test-runner-plan-timeout.js:(LINE):3'
failureType: 'testTimeoutFailure'
error: 'plan timed out after 2000ms with 0 assertions when expecting 1'
code: 'ERR_TEST_FAILURE'
...
# Subtest: nested planning with timeout
# Subtest: nested
not ok 1 - nested
---
duration_ms: *
type: 'test'
location: '/test/fixtures/test-runner/output/test-runner-plan-timeout.js:(LINE):7'
failureType: 'cancelledByParent'
error: 'test did not finish before its parent and was cancelled'
code: 'ERR_TEST_FAILURE'
...
1..1
not ok 4 - nested planning with timeout
---
duration_ms: *
type: 'test'
location: '/test/fixtures/test-runner/output/test-runner-plan-timeout.js:(LINE):3'
failureType: 'subtestsFailed'
error: '1 subtest failed'
code: 'ERR_TEST_FAILURE'
...
1..4
not ok 1 - planning with timeout
---
duration_ms: *
type: 'suite'
location: '/test/fixtures/test-runner/output/test-runner-plan-timeout.js:(LINE):1'
failureType: 'subtestsFailed'
error: '3 subtests failed'
code: 'ERR_TEST_FAILURE'
...
1..1
# tests 5
# suites 1
# pass 1
# fail 2
# cancelled 2
# skipped 0
# todo 0
# duration_ms *
4 changes: 4 additions & 0 deletions test/parallel/test-runner-output.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ const tests = [
name: 'test-runner/output/test-runner-plan.js',
flags: ['--test-reporter=tap'],
},
{
name: 'test-runner/output/test-runner-plan-timeout.js',
flags: ['--test-reporter=tap', '--test-force-exit'],
},
process.features.inspector ? {
name: 'test-runner/output/coverage_failure.js',
flags: ['--test-reporter=tap', '--test-coverage-exclude=!test/**'],
Expand Down

0 comments on commit 29f5571

Please sign in to comment.