Skip to content

Fix afterAll skipped in --ci#170

Open
DmitrySharabin wants to merge 1 commit into
mainfrom
fix/168-afterall-completion
Open

Fix afterAll skipped in --ci#170
DmitrySharabin wants to merge 1 commit into
mainfrom
fix/168-afterall-completion

Conversation

@DmitrySharabin

@DmitrySharabin DmitrySharabin commented Jun 23, 2026

Copy link
Copy Markdown
Member

Closes #168.

In --ci (non-interactive) mode, process.exit() fires synchronously the moment stats.pending === 0, before the trailing .finally(() => afterAll()) microtask in TestResult#runAll can resolve. As a result, afterAll hooks never run.

Fix

In src/env/node.js: process.exit(N)process.exitCode = N. Setting the exit code lets the event loop drain — Node runs the queued .finally(afterAll) microtask, then exits with the set code because nothing else keeps it alive.

No changes to src/classes/TestResult.js. result.finished and afterAll keep their existing meanings (per the discussion above).

Drive-by

tests/run.js had a pre-existing leak: setInterval(() => resolve("foo"), 100) was used where setTimeout was intended, leaving a recurring 100 ms timer that's never cleared. process.exit() masked it; process.exitCode exposes it (the runner would hang). One-line fix: setIntervalsetTimeout.

Out of scope

Bottom-up afterAll ordering for nested groups (which an earlier draft of this PR also addressed via TestResult.js changes) — tracked separately as #178.

Test plan

@netlify

netlify Bot commented Jun 23, 2026

Copy link
Copy Markdown

Deploy Preview for h-test ready!

Name Link
🔨 Latest commit 20bc39b
🔍 Latest deploy log https://app.netlify.com/projects/h-test/deploys/6a3eb0a69150290008066cfb
😎 Deploy Preview https://deploy-preview-170--h-test.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@DmitrySharabin

Copy link
Copy Markdown
Member Author

Reviewer guide — the diff is noisier than the change

The semantic change is 3 hunks. The rest is re-indentation and code movement. Skim those 3 hunks; everything else is byte-identical to main, just shifted.


1. src/classes/TestResult.jsfinish listener now awaits children + runs own afterAll

Was a 1-line resolve; now wraps the lifecycle.

main L190–191 → PR L190–204:

 		this.finished = new Promise(resolve =>
-			this.addEventListener("finish", resolve, { once: true }));
+			this.addEventListener(
+				"finish",
+				async () => {
+					await Promise.allSettled((this.tests ?? []).map(t => t.finished));
+					if (!this.parent?.error) {
+						try {
+							await this.test.afterAll?.();
+						}
+						catch {}
+					}
+					resolve();
+				},
+				{ once: true },
+			));

2. src/classes/TestResult.js.then(() => this.finished).finally(...afterAll) deleted

Its body moved into hunk (1). Deleted from main L254–262:

 			return Promise.allSettled((this.tests ?? []).map(test => test.runAll()));
-		})
-		.then(() => this.finished)
-		.finally(async () => {
-			if (!this.parent?.error) {
-				try {
-					await this.test.afterAll?.();
-				}
-				catch {}
-			}
-		});
+		});

The rest of the delay(1).then(async () => { ... }) body — main L222–252 vs PR L235–264 — is byte-identical, just unindented by one tab because the chained .then().finally() is gone. Nothing to review there.


3. src/env/node.js — CI exit moves from donestart, wrapped in root.finished.then(...)

This is the actual bug fix: exit can no longer fire before afterAll resolves.

start handler — main L371–380 → PR L371–393:

 	start (target, options, event, root) {
 		// `start` bubbles — skip descendants so we only fire once, on the root's own start.
-		if (options.signal?.aborted || !isInteractive || target !== root) {
+		if (options.signal?.aborted || target !== root) {
 			return;
 		}

+		if (!isInteractive) {
+			root.finished.then(() => {
+				let messages = root.toString(options);
+				let tree = getTree(messages).toString();
+				tree = process.stdout.isTTY ? format(tree) : stripFormatting(tree);
+
+				console[root.stats.fail > 0 ? "error" : "log"](tree);
+
+				process.exit(root.stats.fail > 0 ? 1 : 0);
+			});
+			return;
+		}
+
 		currentRoot = root;
 		// Open the interactive tree now so progress shows immediately — otherwise nothing renders until the first `done` event.
 		interactiveTree(root, options, { rerun: () => rerun(options) });
 	},

done handler — main L381–397 → PR L394–401: the print/exit block deleted (lifted into start above), and the !isInteractive short-circuit folded into the top guard.

 	done (result, options, event, root) {
-		if (options.signal?.aborted) {
+		if (options.signal?.aborted || !isInteractive) {
 			return;
 		}

-		if (!isInteractive) {
-			if (root.stats.pending === 0) {
-				let messages = root.toString(options);
-				let tree = getTree(messages).toString();
-				tree = process.stdout.isTTY ? format(tree) : stripFormatting(tree);
-
-				console[root.stats.fail > 0 ? "error" : "log"](tree);
-
-				process.exit(root.stats.fail > 0 ? 1 : 0);
-			}
-			return;
-		}
-
 		currentRoot = root;
 		interactiveTree(root, options, { rerun: () => rerun(options) });
 	},

TL;DR: review the 3 hunks above. Everything else GitHub flags is either unindentation (TestResult.js) or unchanged identifiers around the moved block (node.js).

@LeaVerou

LeaVerou commented Jun 26, 2026

Copy link
Copy Markdown
Member

I'm not sure about this behavior. I think I'd expect afterAll() to run after the test is done. This seems to be fixing a bug by changing afterAll() to mean something else, which doesn't seem necessary?

@DmitrySharabin

Copy link
Copy Markdown
Member Author

I'm not sure about this behavior. I think I'd expect afterAll() to run after the test is done. This seems to be fixing a bug by changing afterAll() to mean something else, which doesn't seem necessary?

Thinking about it more, I tend to agree with you. The test IS actually done before we run afterAll. I wonder if the test is not started while we do beforeAll. Hm. Do we consider beforeEach/afterEach parts of the test? It's like, should we dispatch start/done before/after them, respectively? I don't remember what we do now. But we probably need to establish the correct behavior. I'll research what others do.

@DmitrySharabin
DmitrySharabin force-pushed the fix/168-afterall-completion branch from e7128eb to e02c79f Compare June 26, 2026 14:59
@DmitrySharabin DmitrySharabin changed the title Await afterAll before resolving result.finished Run afterAll before dispatching finish Jun 26, 2026
@DmitrySharabin
DmitrySharabin marked this pull request as draft June 26, 2026 15:21
@DmitrySharabin
DmitrySharabin force-pushed the fix/168-afterall-completion branch from e02c79f to 20bc39b Compare June 26, 2026 17:02
@DmitrySharabin DmitrySharabin changed the title Run afterAll before dispatching finish Fix afterAll skipped in --ci Jun 26, 2026
@DmitrySharabin

DmitrySharabin commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

Drastically simplified to address the particular bug.

@DmitrySharabin
DmitrySharabin marked this pull request as ready for review June 26, 2026 17:04
@DmitrySharabin
DmitrySharabin requested a review from LeaVerou June 26, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants