diff --git a/doc/api/test.md b/doc/api/test.md index 52620899191142..91424b1c373b37 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -303,6 +303,9 @@ added: v18.0.0 * `todo` {boolean|string} If truthy, the test marked as `TODO`. If a string is provided, that string is displayed in the test results as the reason why the test is `TODO`. **Default:** `false`. + * `timeout` {number} A number of milliseconds the test will fail after. + If unspecified, subtests inherit this value from their parent. + **Default:** `30_000`. * `fn` {Function|AsyncFunction} The function under test. This first argument to this function is a [`TestContext`][] object. If the test uses callbacks, the callback function is passed as the second argument. **Default:** A no-op @@ -449,6 +452,9 @@ added: v18.0.0 * `todo` {boolean|string} If truthy, the test marked as `TODO`. If a string is provided, that string is displayed in the test results as the reason why the test is `TODO`. **Default:** `false`. + * `timeout` {number} A number of milliseconds the test will fail after. + If unspecified, subtests inherit this value from their parent. + **Default:** `30_000`. * `fn` {Function|AsyncFunction} The function under test. This first argument to this function is a [`TestContext`][] object. If the test uses callbacks, the callback function is passed as the second argument. **Default:** A no-op diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 2876b3d66640cd..ed3eca13ffe38c 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -5,6 +5,7 @@ const { FunctionPrototype, Number, SafeMap, + PromiseRace, } = primordials; const { AsyncResource } = require('async_hooks'); const { @@ -29,12 +30,27 @@ const kMultipleCallbackInvocations = 'multipleCallbackInvocations'; const kParentAlreadyFinished = 'parentAlreadyFinished'; const kSubtestsFailed = 'subtestsFailed'; const kTestCodeFailure = 'testCodeFailure'; +const kTestTimeoutFailure = 'testTimeoutFailure'; const kDefaultIndent = ' '; const noop = FunctionPrototype; const isTestRunner = getOptionValue('--test'); const testOnlyFlag = !isTestRunner && getOptionValue('--test-only'); // TODO(cjihrig): Use uv_available_parallelism() once it lands. const rootConcurrency = isTestRunner ? cpus().length : 1; +const rootTimeout = 30_0000; + + +function PromiseTimeout(promise, timeout) { + const deferred = createDeferredPromise(); + setTimeout(() => deferred.reject(new ERR_TEST_FAILURE( + `test timed out after ${timeout}ms`, + kTestTimeoutFailure + )), timeout).unref(); + return PromiseRace([ + promise, + deferred.promise, + ]); +} class TestContext { #test; @@ -71,7 +87,7 @@ class Test extends AsyncResource { super('Test'); let { fn, name, parent, skip } = options; - const { concurrency, only, todo } = options; + const { concurrency, only, todo, timeout } = options; if (typeof fn !== 'function') { fn = noop; @@ -93,6 +109,7 @@ class Test extends AsyncResource { this.reporter = new TapStream(); this.runOnlySubtests = this.only; this.testNumber = 0; + this.timeout = rootTimeout; } else { const indent = parent.parent === null ? parent.indent : parent.indent + parent.indentString; @@ -104,12 +121,17 @@ class Test extends AsyncResource { this.reporter = parent.reporter; this.runOnlySubtests = !this.only; this.testNumber = parent.subtests.length + 1; + this.timeout = parent.timeout; } if (isUint32(concurrency) && concurrency !== 0) { this.concurrency = concurrency; } + if (isUint32(timeout) && timeout !== 0) { + this.timeout = timeout; + } + if (testOnlyFlag && !this.only) { skip = '\'only\' option not set'; } @@ -337,13 +359,13 @@ class Test extends AsyncResource { 'passed a callback but also returned a Promise', kCallbackAndPromisePresent )); - await ret; + await PromiseTimeout(ret, this.timeout); } else { - await promise; + await PromiseTimeout(promise, this.timeout); } } else { // This test is synchronous or using Promises. - await this.runInAsyncScope(this.fn, ctx, ctx); + await PromiseTimeout(this.runInAsyncScope(this.fn, ctx, ctx), this.timeout); } this.pass(); diff --git a/test/message/test_runner_output.js b/test/message/test_runner_output.js index c586199f0d9d31..341675ee383722 100644 --- a/test/message/test_runner_output.js +++ b/test/message/test_runner_output.js @@ -328,3 +328,13 @@ test('subtest sync throw fails', async (t) => { throw new Error('thrown from subtest sync throw fails at second'); }); }); + +test('timed out test', { timeout: 500 }, async (t) => { + return new Promise((resolve) => { + setTimeout(resolve, 1000) + }); +}); + +test('callback timed out test', { timeout: 500 }, (t, done) => { + setTimeout(done, 1000) +}); \ No newline at end of file diff --git a/test/message/test_runner_output.out b/test/message/test_runner_output.out index 2f6e2502749068..b901e76f20bff8 100644 --- a/test/message/test_runner_output.out +++ b/test/message/test_runner_output.out @@ -562,8 +562,24 @@ not ok 56 - subtest sync throw fails error: '2 subtests failed' code: 'ERR_TEST_FAILURE' ... +# Subtest: timed out test +not ok 57 - timed out test + --- + duration_ms: * + failureType: 'testTimeoutFailure' + error: 'test timed out after 500ms' + code: 'ERR_TEST_FAILURE' + ... +# Subtest: callback timed out test +not ok 58 - callback timed out test + --- + duration_ms: * + failureType: 'testTimeoutFailure' + error: 'test timed out after 500ms' + code: 'ERR_TEST_FAILURE' + ... # Subtest: invalid subtest fail -not ok 57 - invalid subtest fail +not ok 59 - invalid subtest fail --- duration_ms: * failureType: 'parentAlreadyFinished' @@ -572,16 +588,16 @@ not ok 57 - invalid subtest fail stack: |- * ... -1..57 +1..59 # Warning: Test "unhandled rejection - passes but warns" generated asynchronous activity after the test ended. This activity created the error "Error: rejected from unhandled rejection fail" and would have caused the test to fail, but instead triggered an unhandledRejection event. # Warning: Test "async unhandled rejection - passes but warns" generated asynchronous activity after the test ended. This activity created the error "Error: rejected from async unhandled rejection fail" and would have caused the test to fail, but instead triggered an unhandledRejection event. # Warning: Test "immediate throw - passes but warns" generated asynchronous activity after the test ended. This activity created the error "Error: thrown from immediate throw fail" and would have caused the test to fail, but instead triggered an uncaughtException event. # Warning: Test "immediate reject - passes but warns" generated asynchronous activity after the test ended. This activity created the error "Error: rejected from immediate reject fail" and would have caused the test to fail, but instead triggered an unhandledRejection event. # Warning: Test "callback called twice in different ticks" generated asynchronous activity after the test ended. This activity created the error "Error [ERR_TEST_FAILURE]: callback invoked multiple times" and would have caused the test to fail, but instead triggered an uncaughtException event. # Warning: Test "callback async throw after done" generated asynchronous activity after the test ended. This activity created the error "Error: thrown from callback async throw after done" and would have caused the test to fail, but instead triggered an uncaughtException event. -# tests 57 +# tests 59 # pass 24 -# fail 18 +# fail 20 # skipped 10 # todo 5 # duration_ms *