Summary
In non-interactive mode (--ci, or any non-TTY environment like piped output / CI runners), afterAll hooks do not execute. The done event handler in the node environment calls process.exit() synchronously the moment stats.pending === 0, which fires before the .finally(() => afterAll()) microtask queued in TestResult#runAll has a chance to run.
Repro
// test.js
import { writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
let tmpDir = "./.afterall-repro";
export default {
name: "afterAll repro",
beforeAll () {
mkdirSync(tmpDir, { recursive: true });
writeFileSync(`${tmpDir}/file.txt`, "x");
},
afterAll () {
console.error("afterAll RAN");
rmSync(tmpDir, { recursive: true, force: true });
},
tests: [
{ name: "trivial", run: () => 1, expect: 1 },
],
};
$ npx htest test.js --ci
afterAll repro ✅ 1/1 PASS (0.x ms)
└── PASS trivial (0.x ms)
$ ls .afterall-repro/
file.txt # <-- afterAll never logged, dir not cleaned up
Interactive mode (TTY stdout + stdin, no --ci) is unaffected — there's no process.exit() on that path; afterAll runs as expected.
Root cause
In src/env/node.js#done() (around line 195+), the non-interactive branch is:
if (!isInteractive) {
if (root.stats.pending === 0) {
...
process.exit(root.stats.fail > 0 ? 1 : 0);
}
}
In src/classes/TestResult.js#runAll(), afterAll is queued in a trailing .finally:
delay(1)
.then(async () => { /* beforeAll */ ...
return Promise.allSettled((this.tests ?? []).map(test => test.runAll()));
})
.then(() => this.finished)
.finally(async () => {
try { await this.test.afterAll?.(); } catch {}
});
Order of events when the final leaf test completes:
- The stats listener (registered first in the constructor) decrements
pending to 0 and dispatches finish.
- The
finish handler resolves this.finished — but .then/.finally continuations are scheduled as microtasks, not run synchronously.
- The env's
done listener (registered later in run.js) fires next and calls process.exit() synchronously.
- The process exits. The microtask carrying
afterAll never runs.
Suggested fixes
A few options, in increasing invasiveness:
- Await the trailing chain. Make
runAll() return the trailing chain (or expose a done promise that resolves after afterAll), and have the env await it before exiting.
- Defer
process.exit until at least one microtask tick after finish fires — e.g., queueMicrotask(() => process.exit(...)) or Promise.resolve().then(...).then(() => process.exit(...)). Cheap, but fragile (depends on .finally resolving in one tick).
- Move
afterAll into the done flow so it runs before the exit decision.
(1) is the most robust; the env stops being responsible for guessing when cleanup is done.
Impact
Any project that uses afterAll for tmp-dir cleanup, server teardown, or other test-runtime resource release will leak when run in CI or piped contexts. Easy to miss because tests still report passing, and re-runs often clobber stale artifacts.
Environment
- htest 0.0.27
- Node.js 22.x
- macOS 25.5.0 (Darwin)
Summary
In non-interactive mode (
--ci, or any non-TTY environment like piped output / CI runners),afterAllhooks do not execute. Thedoneevent handler in the node environment callsprocess.exit()synchronously the momentstats.pending === 0, which fires before the.finally(() => afterAll())microtask queued inTestResult#runAllhas a chance to run.Repro
$ npx htest test.js --ci afterAll repro ✅ 1/1 PASS (0.x ms) └── PASS trivial (0.x ms) $ ls .afterall-repro/ file.txt # <-- afterAll never logged, dir not cleaned upInteractive mode (TTY stdout + stdin, no
--ci) is unaffected — there's noprocess.exit()on that path;afterAllruns as expected.Root cause
In
src/env/node.js#done()(around line 195+), the non-interactive branch is:In
src/classes/TestResult.js#runAll(),afterAllis queued in a trailing.finally:Order of events when the final leaf test completes:
pendingto 0 and dispatchesfinish.finishhandler resolvesthis.finished— but.then/.finallycontinuations are scheduled as microtasks, not run synchronously.donelistener (registered later inrun.js) fires next and callsprocess.exit()synchronously.afterAllnever runs.Suggested fixes
A few options, in increasing invasiveness:
runAll()return the trailing chain (or expose adonepromise that resolves afterafterAll), and have the envawaitit before exiting.process.exituntil at least one microtask tick afterfinishfires — e.g.,queueMicrotask(() => process.exit(...))orPromise.resolve().then(...).then(() => process.exit(...)). Cheap, but fragile (depends on.finallyresolving in one tick).afterAllinto thedoneflow so it runs before the exit decision.(1) is the most robust; the env stops being responsible for guessing when cleanup is done.
Impact
Any project that uses
afterAllfor tmp-dir cleanup, server teardown, or other test-runtime resource release will leak when run in CI or piped contexts. Easy to miss because tests still report passing, and re-runs often clobber stale artifacts.Environment