diff --git a/docs/content/limitations/builtin-apis/test-runner.mdx b/docs/content/limitations/builtin-apis/test-runner.mdx index 6583a2873..77d3d9a84 100644 --- a/docs/content/limitations/builtin-apis/test-runner.mdx +++ b/docs/content/limitations/builtin-apis/test-runner.mdx @@ -3,3 +3,4 @@ - **TestContext values:** `t.filePath` is the entry file's path when the program was compiled. `t.attempt` is `0`, since a test runs once. `t.passed` is `false` while the body runs and the test's outcome in its `afterEach` and `t.after` hooks. `t.workerId` is `Number(process.env.NODE_TEST_WORKER_ID) || undefined`, as in Node. `t.tags` is `[]`, since the `tags` test option throws `SC2020`; the array is not frozen as Node's is. - **Suite and hook contexts:** a `describe` body may take its `SuiteContext`, which has `name`, `fullName`, `filePath`, `attempt`, `passed` and `diagnostic`. Hooks may take their context, as in Node: the suite's for `before` and `after`, the test's for `beforeEach`, `afterEach`, `t.before` and `t.after`. The `SuiteContext` `signal` and `log` members, and hook `done` callbacks, throw `SC2020`. - **Module-level hooks:** `before` hooks outside any suite run at their `before()` call, and the first top-level test waits for the async ones; `after` hooks outside any suite run after the last top-level test. Both get the root's context, named ``. As in Node, a throwing root `before` fails each top-level test with its error, without running its body, and cancels the tests and suites inside each top-level suite. A throwing root `after` reports the entry file as a failed result, and the exit code is unchanged. +- **Timeouts:** when a test's `{ timeout }` elapses, its running subtests are cancelled, as in Node. Subtests do not take their parent's `timeout` as their own, where Node's do, and the subtests of a timed-out test with `{ concurrency }` above 1 run to completion before the test reports. A body that keeps running after its timeout and calls `t.test` again runs that subtest, where Node reports it as not started. diff --git a/packages/runtime/src/scr_test.c b/packages/runtime/src/scr_test.c index cc52513a6..4217169b8 100644 --- a/packages/runtime/src/scr_test.c +++ b/packages/runtime/src/scr_test.c @@ -125,6 +125,7 @@ struct ScrTestCtx { struct ScrMockTimers *mock_timers; /* t.mock.timers, made on first use; freed at exit */ bool passed; /* t.passed: set when the body passes, as Node's pass() does */ bool listed; /* a suite listed in the failing section (a failing root before) */ + double start_ms; /* when the test started running; NaN before */ }; static SCR_TL ScrTestCtx *scr_test_root = NULL; @@ -230,6 +231,7 @@ static ScrTestCtx *scr_test_node_new(ScrStr *name /*borrowed*/, int mode, t->plan = -1; t->timeout = INFINITY; t->concurrency = 1; + t->start_ms = NAN; #ifdef SCR_RC_AUDIT scr_testctx_live++; #endif @@ -597,6 +599,23 @@ static void scr_test_cancel_unrun(ScrTestCtx *c) { scr_test_report_result(c); } +/* A test that timed out (Node): its subtests that are still running are + * cancelled, innermost first, with their durations so far. Their bodies + * keep running unobserved and report nothing when they settle. */ +static void scr_test_cancel_running(ScrTestCtx *t) { + for (size_t i = 0; i < t->nchildren; i++) { + ScrTestCtx *c = t->children[i]; + if (c->is_suite || c->state != SCR_TS_PENDING || isnan(c->start_ms)) continue; + scr_test_cancel_running(c); + static const char msg[] = "test did not finish before its parent and was cancelled"; + scr_throw_str(scr_str_new(msg, sizeof msg - 1)); + c->err = scr_exc_take(); + c->dur_ms = scr_now_ms() - c->start_ms; + c->state = SCR_TS_CANCELLED; + scr_test_report_result(c); + } +} + static void scr_test_fail_unrun(ScrTestCtx *c, ScrCaught *err) { double start = scr_now_ms(); c->err = scr_caught_retain(err); @@ -689,6 +708,7 @@ static void scr_test_run_node(ScrTestCtx *t) { * afterEach chain. */ ScrTestCtx *prev_parent = scr_test_parent_get(); scr_test_parent_set(t); + t->start_ms = start; if (t->concurrency > 1) scr_test_defer++; bool hooked = scr_test_run_each_hooks(t, SCR_HOOK_BEFORE_EACH, true); /* A concurrent subtest resumes after its beforeEach hooks as many turns @@ -703,10 +723,18 @@ static void scr_test_run_node(ScrTestCtx *t) { failed = true; } else if (t->cb) { scr_test_call(t->cb, t->flags, t, t); + if (t->state != SCR_TS_PENDING) { + /* Its parent timed out and cancelled it while the body ran: the + * result is reported, and the runner has moved on. */ + if (scr_exc_pending()) scr_exc_clear(); + if (t->concurrency > 1) scr_test_defer--; + return; + } if (scr_exc_pending()) { t->err = scr_exc_take(); failed = true; } + if (failed && t->timed_out) scr_test_cancel_running(t); } /* Children a passing body registered without running (bare test() in * the body): run them now, before the parent completes — Node holds diff --git a/tests/fixtures/node-test/cases/timeout-subtests.ts b/tests/fixtures/node-test/cases/timeout-subtests.ts new file mode 100644 index 000000000..91a5417e7 --- /dev/null +++ b/tests/fixtures/node-test/cases/timeout-subtests.ts @@ -0,0 +1,23 @@ +// @exit: 1 +// A test that times out cancels its subtests that are still running, the +// innermost first, with "test did not finish before its parent and was +// cancelled". Subtests that finished keep their results, and the +// cancelled bodies report nothing when they settle later. +import { test } from "node:test"; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +test("parent", { timeout: 40 }, async (t) => { + await t.test("finished first", () => {}); + await t.test("running", async (t) => { + await t.test("inner done", () => {}); + await t.test("inner running", async () => { + await sleep(300); + throw new Error("never reported"); + }); + }); +}); + +test("next", async () => { + await sleep(400); +});