Summary
In nested groups, afterAll hooks are not guaranteed to run bottom-up. Each TestResult.runAll() chain queues its own .finally(afterAll) independently, so a parent's afterAll can fire concurrently with — and complete before — an async child's afterAll. This contradicts the convention in Jest, Vitest, and Mocha (innermost teardown completes, including async work, before the parent's starts).
Repro
let ran = [];
let test = new Test({
afterAll: () => ran.push("root"),
tests: [
{ afterAll: () => ran.push("level1"), tests: [
{
async afterAll () {
await new Promise(r => setTimeout(r, 50));
ran.push("level2");
},
tests: [
{ run: () => "foo", expect: "foo" },
{ run: () => "bar", expect: "bar" },
],
},
]},
],
});
let result = new TestResult(test, null);
result.runAll();
// wait until everything settles
await new Promise(r => setTimeout(r, 200));
console.log(ran);
// Actual: ["level1", "root", "level2"] — root's afterAll ran before the deepest async afterAll
// Expected: ["level2", "level1", "root"] — bottom-up
Root cause
In TestResult#runAll:
delay(1)
.then(async () => {
... beforeAll, run ...
return Promise.allSettled((this.tests ?? []).map(test => test.runAll()));
})
.then(() => this.finished)
.finally(async () => { ... afterAll ... });
runAll() returns this synchronously, so the inner Promise.allSettled([...]) resolves immediately — it doesn't actually wait for children's chains. Each level then awaits its own this.finished (the finish event = pending === 0) and queues its own .finally(afterAll) in parallel with its descendants'. Sync afterAlls happen to land in the right microtask order most of the time; async afterAlls do not.
Suggested fix
Make runAll() async-returning so await Promise.allSettled((this.tests ?? []).map(test => test.runAll())) actually waits for children's full chains before running the parent's afterAll. This keeps result.finished semantics unchanged (still resolves on the finish event, before afterAll).
Notes
Split off from #168 (afterAll not running in --ci) — the PR there originally rolled both fixes together via a change to result.finished semantics, which conflated two separate concerns. #168 is fixed minimally via process.exitCode; this issue tracks the ordering fix.
Summary
In nested groups,
afterAllhooks are not guaranteed to run bottom-up. EachTestResult.runAll()chain queues its own.finally(afterAll)independently, so a parent'safterAllcan fire concurrently with — and complete before — an async child'safterAll. This contradicts the convention in Jest, Vitest, and Mocha (innermost teardown completes, including async work, before the parent's starts).Repro
Root cause
In
TestResult#runAll:runAll()returnsthissynchronously, so the innerPromise.allSettled([...])resolves immediately — it doesn't actually wait for children's chains. Each level then awaits its ownthis.finished(thefinishevent =pending === 0) and queues its own.finally(afterAll)in parallel with its descendants'. SyncafterAlls happen to land in the right microtask order most of the time; asyncafterAlls do not.Suggested fix
Make
runAll()async-returning soawait Promise.allSettled((this.tests ?? []).map(test => test.runAll()))actually waits for children's full chains before running the parent'safterAll. This keepsresult.finishedsemantics unchanged (still resolves on thefinishevent, beforeafterAll).Notes
Split off from #168 (
afterAllnot running in--ci) — the PR there originally rolled both fixes together via a change toresult.finishedsemantics, which conflated two separate concerns. #168 is fixed minimally viaprocess.exitCode; this issue tracks the ordering fix.