diff --git a/src/classes/TestResult.js b/src/classes/TestResult.js index 0ec24df..3e90f35 100644 --- a/src/classes/TestResult.js +++ b/src/classes/TestResult.js @@ -188,7 +188,20 @@ export default class TestResult extends BubblingEventTarget { this.stats.total = this.test.testCount; this.stats.pending = this.stats.total; 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 }, + )); let tests = this.test.tests; let childOptions = Object.assign({}, this.options); @@ -218,48 +231,38 @@ export default class TestResult extends BubblingEventTarget { this.tests = this.test.tests?.map(t => new TestResult(t, this, childOptions)); - delay(1) - .then(async () => { - let error = this.parent?.error; + delay(1).then(async () => { + let error = this.parent?.error; - if (!error && !this.options.signal?.aborted) { - try { - await this.test.beforeAll?.(); - } - catch (e) { - e.source = "beforeAll"; - error = e; - } + if (!error && !this.options.signal?.aborted) { + try { + await this.test.beforeAll?.(); } - - if (error) { - this.error = error; + catch (e) { + e.source = "beforeAll"; + error = e; } + } - if (this.test.isTest) { - if (this.test.skip && this.test.skip !== "fail") { - this.skip(); - } - else if (error) { - this.details = [`${error.source}: ${error.message}`]; - this.skip(); - } - else { - this.run(); - } - } + if (error) { + this.error = error; + } - return Promise.allSettled((this.tests ?? []).map(test => test.runAll())); - }) - .then(() => this.finished) - .finally(async () => { - if (!this.parent?.error) { - try { - await this.test.afterAll?.(); - } - catch {} + if (this.test.isTest) { + if (this.test.skip && this.test.skip !== "fail") { + this.skip(); + } + else if (error) { + this.details = [`${error.source}: ${error.message}`]; + this.skip(); } - }); + else { + this.run(); + } + } + + return Promise.allSettled((this.tests ?? []).map(test => test.runAll())); + }); return this; } diff --git a/src/env/node.js b/src/env/node.js index 9791f49..fe3b525 100644 --- a/src/env/node.js +++ b/src/env/node.js @@ -370,21 +370,12 @@ export default { }, 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) { - 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 (result, options, event, root) { - if (options.signal?.aborted) { + if (options.signal?.aborted || target !== root) { return; } if (!isInteractive) { - if (root.stats.pending === 0) { + root.finished.then(() => { let messages = root.toString(options); let tree = getTree(messages).toString(); tree = process.stdout.isTTY ? format(tree) : stripFormatting(tree); @@ -392,7 +383,16 @@ export default { 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 (result, options, event, root) { + if (options.signal?.aborted || !isInteractive) { return; } diff --git a/tests/run.js b/tests/run.js index 1d1c532..14fe730 100644 --- a/tests/run.js +++ b/tests/run.js @@ -24,6 +24,68 @@ export default { }, ], }, + { + name: "afterAll lifecycle", + tests: [ + { + name: "Runs at every level, bottom-up, before result.finished resolves", + async run () { + 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(); + await result.finished; + return ran; + }, + expect: ["level2", "level1", "root"], + }, + { + name: "Completes before process.exit in --ci mode (issue #168)", + skip: typeof globalThis.process === "undefined", + async run () { + let { spawnSync } = await import("node:child_process"); + let { writeFileSync, rmSync } = await import("node:fs"); + let { tmpdir } = await import("node:os"); + let { join } = await import("node:path"); + + let fixture = join(tmpdir(), "htest-168.js"); + writeFileSync( + fixture, + `export default { async afterAll () { await new Promise(r => setTimeout(r, 50)); process.stderr.write("[test] afterAll ran"); }, tests: [{ run: () => 1, expect: 1 }] };`, + ); + + let { stderr } = spawnSync( + process.execPath, + [join(process.cwd(), "bin/htest.js"), fixture, "--ci"], + { encoding: "utf8" }, + ); + rmSync(fixture); + + return stderr.includes("[test] afterAll ran"); + }, + expect: true, + }, + ], + }, { name: "Aborting", tests: [