Skip to content

afterAll never runs in non-interactive mode (process.exit races the .finally chain) #168

Description

@DmitrySharabin

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:

  1. The stats listener (registered first in the constructor) decrements pending to 0 and dispatches finish.
  2. The finish handler resolves this.finished — but .then/.finally continuations are scheduled as microtasks, not run synchronously.
  3. The env's done listener (registered later in run.js) fires next and calls process.exit() synchronously.
  4. The process exits. The microtask carrying afterAll never runs.

Suggested fixes

A few options, in increasing invasiveness:

  1. 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.
  2. 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).
  3. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions