You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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: setInterval → setTimeout.
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.
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.js — finish listener now awaits children + runs own afterAll
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 done → start, 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).
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?
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #168.
In
--ci(non-interactive) mode,process.exit()fires synchronously the momentstats.pending === 0, before the trailing.finally(() => afterAll())microtask inTestResult#runAllcan resolve. As a result,afterAllhooks 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.finishedandafterAllkeep their existing meanings (per the discussion above).Drive-by
tests/run.jshad a pre-existing leak:setInterval(() => resolve("foo"), 100)was used wheresetTimeoutwas intended, leaving a recurring 100 ms timer that's never cleared.process.exit()masked it;process.exitCodeexposes it (the runner would hang). One-line fix:setInterval→setTimeout.Out of scope
Bottom-up
afterAllordering for nested groups (which an earlier draft of this PR also addressed viaTestResult.jschanges) — tracked separately as #178.Test plan
--ci) workstests/run.js(spawnshtest --cion a fixture with asyncafterAll, asserts cleanup output reached stderr)