diff --git a/packages/alchemy/src/Test/Vitest.ts b/packages/alchemy/src/Test/Vitest.ts index 9a509bec13..71235ac4de 100644 --- a/packages/alchemy/src/Test/Vitest.ts +++ b/packages/alchemy/src/Test/Vitest.ts @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; import { afterAll as vitestAfterAll, afterEach as vitestAfterEach, + aroundAll as vitestAroundAll, beforeAll as vitestBeforeAll, beforeEach as vitestBeforeEach, } from "vitest"; @@ -227,18 +228,19 @@ export const make = (options: MakeOptions): TestApi => { // Fallback cleanup: if the user never calls `destroy(Stack)` (e.g. // `NO_DESTROY=1`), nothing else closes the shared scope and the sidecar - // child process leaks past the test process. Register an `afterAll` that - // closes it (and the RPC sidecar, which lives in its own scope so that - // mid-file `destroy(Stack)` calls can't kill it for later tests). We defer - // registration to a microtask so it runs AFTER any user-registered - // `afterAll` (including `destroy(Stack)`); vitest runs afterAll hooks in - // registration order. + // child process leaks past the test process. `aroundAll` owns the complete + // suite lifecycle, so cleanup runs after suite hooks and their cleanups even + // when setup, a test, or teardown fails. Hook ordering does not affect it. const closeAll = sidecar ? Effect.andThen(closeScope, sidecar.close) : closeScope; - queueMicrotask(() => { - vitestAfterAll(() => Effect.runPromise(closeAll), DEFAULT_TIMEOUT); - }); + vitestAroundAll(async (runSuite) => { + try { + await runSuite(); + } finally { + await Effect.runPromise(closeAll); + } + }, DEFAULT_TIMEOUT); return { test, diff --git a/packages/alchemy/test/Test/Vitest.test.ts b/packages/alchemy/test/Test/Vitest.test.ts new file mode 100644 index 0000000000..2579873d8d --- /dev/null +++ b/packages/alchemy/test/Test/Vitest.test.ts @@ -0,0 +1,90 @@ +import { PlatformServices } from "@/Util/PlatformServices.ts"; +import { exec } from "@/Util/exec.ts"; +import { describe, expect, test } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { fileURLToPath } from "node:url"; + +const fixturesDirectory = new URL("./fixtures/", import.meta.url); +const configPath = fileURLToPath( + new URL("vitest-cleanup-order.config.ts", fixturesDirectory), +); + +const runVitestFixture = ( + fixtureName: string, + hooks: "stack" | "list" = "stack", +) => + exec( + ChildProcess.make( + "pnpm", + [ + "exec", + "vitest", + "run", + fileURLToPath(new URL(fixtureName, fixturesDirectory)), + "--config", + configPath, + `--sequence.hooks=${hooks}`, + "--reporter=verbose", + ], + { shell: false }, + ), + ).pipe(Effect.scoped); + +const processOutput = (result: { + readonly stdout: string; + readonly stderr: string; +}) => `${result.stdout}\n${result.stderr}`; + +describe("Vitest fallback cleanup", () => { + test.live( + "runs after user afterAll hooks for stack and list ordering", + () => + Effect.gen(function* () { + for (const hooks of ["stack", "list"] as const) { + const result = yield* runVitestFixture( + "vitest-cleanup-order.fixture.ts", + hooks, + ); + + expect(result.exitCode).toBe(0); + expect(processOutput(result)).toContain( + "VITEST_CLEANUP_ORDER:user afterAll,alchemy fallback cleanup", + ); + } + }).pipe(Effect.provide(PlatformServices)), + { timeout: 30_000 }, + ); + + test.live( + "runs when a later beforeAll hook fails", + () => + Effect.gen(function* () { + const result = yield* runVitestFixture( + "vitest-cleanup-after-failed-before-all.fixture.ts", + ); + const output = processOutput(result); + + expect(result.exitCode).toBe(1); + expect(output).toContain("EXPECTED_BEFORE_ALL_FAILURE"); + expect(output).toContain("VITEST_CLEANUP_AFTER_FAILED_BEFORE_ALL"); + }).pipe(Effect.provide(PlatformServices)), + { timeout: 30_000 }, + ); + + test.live( + "runs when an afterAll hook fails", + () => + Effect.gen(function* () { + const result = yield* runVitestFixture( + "vitest-cleanup-after-failed-after-all.fixture.ts", + ); + const output = processOutput(result); + + expect(result.exitCode).toBe(1); + expect(output).toContain("EXPECTED_AFTER_ALL_FAILURE"); + expect(output).toContain("VITEST_CLEANUP_AFTER_FAILED_AFTER_ALL"); + }).pipe(Effect.provide(PlatformServices)), + { timeout: 30_000 }, + ); +}); diff --git a/packages/alchemy/test/Test/fixtures/vitest-cleanup-after-failed-after-all.fixture.ts b/packages/alchemy/test/Test/fixtures/vitest-cleanup-after-failed-after-all.fixture.ts new file mode 100644 index 0000000000..0e3924375e --- /dev/null +++ b/packages/alchemy/test/Test/fixtures/vitest-cleanup-after-failed-after-all.fixture.ts @@ -0,0 +1,20 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { afterAll } from "vitest"; +import * as Test from "alchemy/Test/Vitest"; + +const testApi = Test.make({ providers: Layer.empty, dev: false }); + +testApi.beforeAll( + Effect.addFinalizer(() => + Effect.sync(() => { + console.log("VITEST_CLEANUP_AFTER_FAILED_AFTER_ALL"); + }), + ), +); + +testApi.test("runs before afterAll fails", Effect.void); + +afterAll(() => { + throw new Error("EXPECTED_AFTER_ALL_FAILURE"); +}); diff --git a/packages/alchemy/test/Test/fixtures/vitest-cleanup-after-failed-before-all.fixture.ts b/packages/alchemy/test/Test/fixtures/vitest-cleanup-after-failed-before-all.fixture.ts new file mode 100644 index 0000000000..575ce998c0 --- /dev/null +++ b/packages/alchemy/test/Test/fixtures/vitest-cleanup-after-failed-before-all.fixture.ts @@ -0,0 +1,20 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeAll } from "vitest"; +import * as Test from "alchemy/Test/Vitest"; + +const testApi = Test.make({ providers: Layer.empty, dev: false }); + +testApi.beforeAll( + Effect.addFinalizer(() => + Effect.sync(() => { + console.log("VITEST_CLEANUP_AFTER_FAILED_BEFORE_ALL"); + }), + ), +); + +beforeAll(() => { + throw new Error("EXPECTED_BEFORE_ALL_FAILURE"); +}); + +testApi.test("is skipped after beforeAll fails", Effect.void); diff --git a/packages/alchemy/test/Test/fixtures/vitest-cleanup-order.config.ts b/packages/alchemy/test/Test/fixtures/vitest-cleanup-order.config.ts new file mode 100644 index 0000000000..05679b0704 --- /dev/null +++ b/packages/alchemy/test/Test/fixtures/vitest-cleanup-order.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "alchemy/Test/Vitest": fileURLToPath( + new URL("../../../src/Test/Vitest.ts", import.meta.url), + ), + }, + }, + test: { + include: ["**/*.fixture.ts"], + sequence: { hooks: "stack" }, + }, +}); diff --git a/packages/alchemy/test/Test/fixtures/vitest-cleanup-order.fixture.ts b/packages/alchemy/test/Test/fixtures/vitest-cleanup-order.fixture.ts new file mode 100644 index 0000000000..aff398e275 --- /dev/null +++ b/packages/alchemy/test/Test/fixtures/vitest-cleanup-order.fixture.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { afterAll, expect } from "vitest"; +import * as Test from "alchemy/Test/Vitest"; + +const lifecycleEvents: string[] = []; +const testApi = Test.make({ providers: Layer.empty, dev: false }); + +const reportLifecycleEvents = () => + console.log(`VITEST_CLEANUP_ORDER:${lifecycleEvents.join(",")}`); + +testApi.beforeAll( + Effect.addFinalizer(() => + Effect.sync(() => { + lifecycleEvents.push("alchemy fallback cleanup"); + reportLifecycleEvents(); + }), + ), +); + +testApi.test("runs a test before cleanup", Effect.void); + +afterAll(() => { + expect(lifecycleEvents).toEqual([]); + lifecycleEvents.push("user afterAll"); +});