diff --git a/packages/alchemy-test/src/Runner.ts b/packages/alchemy-test/src/Runner.ts index 7c8a58e3cf..589c78c12b 100644 --- a/packages/alchemy-test/src/Runner.ts +++ b/packages/alchemy-test/src/Runner.ts @@ -670,13 +670,24 @@ const runSuite: (suite: Suite, ctx: ExecContext) => Effect.Effect = const runAfterAll = Effect.fn(function* (suite: Suite, ctx: ExecContext) { if (suite.afterAll.length === 0) return; yield* ctx.emit({ _tag: "HookStart", file: ctx.file, hook: "afterAll" }); - const exit = yield* runHooks(suite.afterAll, ctx.options.timeout).pipe( - withCapture(ctx.fileLogs), - Effect.exit, - ); + // Unlike beforeAll (where a failure invalidates everything after it), + // every teardown hook runs even when an earlier one fails: a failing + // teardown assertion must not drop later cleanup — in particular + // Test.make's fallback hook that closes the shared scope and local + // provider sidecar, which registers last and would otherwise leak the + // sidecar for the rest of the process. Failures aggregate. + const exits = yield* Effect.forEach(suite.afterAll, (hook) => + Effect.suspend(hook.body).pipe( + Effect.timeout(Duration.millis(hook.timeout ?? ctx.options.timeout)), + Effect.exit, + ), + ).pipe(withCapture(ctx.fileLogs)); yield* ctx.emit({ _tag: "HookEnd", file: ctx.file, hook: "afterAll" }); - if (Exit.isFailure(exit)) { - const error = `afterAll hook failed:\n${prettyCause(exit.cause)}`; + const failures = exits.filter(Exit.isFailure); + if (failures.length > 0) { + const error = `afterAll hook failed:\n${failures + .map((exit) => prettyCause(exit.cause)) + .join("\n")}`; ctx.fileErrors.push(error); } }); diff --git a/packages/alchemy-test/test/Runner.test.ts b/packages/alchemy-test/test/Runner.test.ts index fce50d90d0..81a5961baa 100644 --- a/packages/alchemy-test/test/Runner.test.ts +++ b/packages/alchemy-test/test/Runner.test.ts @@ -141,3 +141,53 @@ it("fails the process for every hook kind and preserves hook output", async () = await rm(root, { recursive: true, force: true }); } }); + +it("runs every afterAll hook even when an earlier one fails", async () => { + // Regression: `runAfterAll` used to short-circuit on the first failing + // hook, so a failing teardown assertion silently dropped every later + // afterAll — in particular Test.make's fallback hook that closes the + // shared scope and local provider sidecar, leaking the sidecar for the + // rest of the process. All teardown hooks must run; failures aggregate. + const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-afterall-")); + try { + await writeFile( + resolve(root, "teardown-chain.test.ts"), + ` + import { it, registerHook } from ${JSON.stringify(apiUrl)}; + import * as Effect from ${JSON.stringify(effectUrl)}; + registerHook("afterAll", { body: () => Effect.gen(function* () { + return yield* Effect.fail(new Error("first-teardown-failed")); + }) }); + registerHook("afterAll", { body: () => Effect.gen(function* () { + yield* Effect.log("second-teardown-ran"); + }) }); + it("body", () => {}); + `, + ); + + const child = Bun.spawn( + [process.execPath, cli, root, "--retry", "0", "--concurrency", "1"], + { + cwd: root, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1" }, + }, + ); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const output = `${stdout}\n${stderr}`; + + // The failure is reported and fails the run… + expect(exitCode).toBe(1); + expect(output).toContain("afterAll hook failed:"); + expect(output).toContain("first-teardown-failed"); + // …and the later teardown hook still ran. + expect(output).toContain("second-teardown-ran"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/alchemy/src/Test/Bun.ts b/packages/alchemy/src/Test/Bun.ts index a5e95db0b5..463d8248bd 100644 --- a/packages/alchemy/src/Test/Bun.ts +++ b/packages/alchemy/src/Test/Bun.ts @@ -194,12 +194,25 @@ export const make = (options: MakeOptions): TestApi => { bun.beforeEach(() => runEff(eff), hookOptions); }; + // bun:test stops running later `afterAll` hooks once one throws, which + // would skip the fallback cleanup hook below — leaking the shared scope + // and the sidecar for the rest of the process whenever a teardown + // assertion fails. Guard every user teardown: on failure, run the + // (idempotent) cleanup before rethrowing so the failure still fails the + // suite. (`closeAll` is initialized below; hooks only run after `make` + // returns.) + const guardTeardown = (eff: TestEffect) => () => + runEff(eff).catch(async (error) => { + await Effect.runPromise(closeAll); + throw error; + }); + const afterAll = ((eff, hookOptions) => { - bun.afterAll(() => runEff(eff), hookOptions ?? DEFAULT_HOOK_TIMEOUT); + bun.afterAll(guardTeardown(eff), hookOptions ?? DEFAULT_HOOK_TIMEOUT); }) as AfterAllFn; afterAll.skipIf = (predicate) => (eff, hookOptions) => { if (predicate) return; - bun.afterAll(() => runEff(eff), hookOptions ?? DEFAULT_HOOK_TIMEOUT); + bun.afterAll(guardTeardown(eff), hookOptions ?? DEFAULT_HOOK_TIMEOUT); }; const afterEach: AfterEachFn = (eff, hookOptions) => { diff --git a/packages/alchemy/test/Test/Bun.test.ts b/packages/alchemy/test/Test/Bun.test.ts new file mode 100644 index 0000000000..96c7c40f14 --- /dev/null +++ b/packages/alchemy/test/Test/Bun.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "alchemy-test"; +import { fileURLToPath } from "node:url"; + +const fixturesDir = fileURLToPath(new URL("./fixtures/", import.meta.url)); + +describe("Bun adapter fallback cleanup", () => { + test( + "closes the shared scope when a user afterAll throws", + async () => { + // bun:test stops the afterAll chain on the first throw, so without + // the adapter's teardown guard the microtask-registered fallback + // (which closes the shared scope + sidecar) never runs. The fixture's + // shared-scope finalizer printing proves the guard closed the scope; + // the non-zero exit proves the teardown failure still fails the run. + const child = Bun.spawn( + ["bun", "test", "./bun-teardown-guard.fixture.ts"], + { + cwd: fixturesDir, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1", ALCHEMY_DEV: "" }, + }, + ); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const output = `${stdout}\n${stderr}`; + + expect(exitCode).not.toBe(0); + expect(output).toContain("teardown-assertion-failed"); + const userHook = output.indexOf("BUN_GUARD:user-afterAll-throws"); + const finalizer = output.indexOf("BUN_GUARD:shared-scope-finalizer-ran"); + expect(userHook).toBeGreaterThanOrEqual(0); + // The cleanup ran, and ran after the failing user teardown. + expect(finalizer).toBeGreaterThan(userHook); + }, + { timeout: 60_000 }, + ); +}); diff --git a/packages/alchemy/test/Test/fixtures/bun-teardown-guard.fixture.ts b/packages/alchemy/test/Test/fixtures/bun-teardown-guard.fixture.ts new file mode 100644 index 0000000000..2417cc726d --- /dev/null +++ b/packages/alchemy/test/Test/fixtures/bun-teardown-guard.fixture.ts @@ -0,0 +1,29 @@ +// Run by `bun test ./` from Bun.test.ts — the `.fixture.ts` suffix +// keeps alchemy-test from collecting it. Exercises `alchemy/Test/Bun`'s +// teardown guard: bun:test stops running later `afterAll` hooks once one +// throws, so the adapter must run its fallback cleanup (closing the shared +// scope and sidecar) itself before rethrowing — otherwise a failing +// teardown assertion leaks them for the rest of the process. +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Test from "@/Test/Bun.ts"; + +const { test, beforeAll, afterAll } = Test.make({ + providers: Layer.empty as never, +}); + +// Attaches to the adapter's shared scope (provided to every hook/test by +// `Core.toEffect`), so this line printing proves the fallback cleanup ran. +beforeAll( + Effect.addFinalizer(() => + Effect.sync(() => console.log("BUN_GUARD:shared-scope-finalizer-ran")), + ), +); + +afterAll( + Effect.sync(() => { + console.log("BUN_GUARD:user-afterAll-throws"); + }).pipe(Effect.andThen(Effect.fail(new Error("teardown-assertion-failed")))), +); + +test("body", Effect.void);