Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions packages/alchemy-test/src/Runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,13 +670,24 @@ const runSuite: (suite: Suite, ctx: ExecContext) => Effect.Effect<void> =
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);
}
});
Expand Down
50 changes: 50 additions & 0 deletions packages/alchemy-test/test/Runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
17 changes: 15 additions & 2 deletions packages/alchemy/src/Test/Bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,25 @@ export const make = <ROut = any>(options: MakeOptions<ROut>): 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<any>) => () =>
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) => {
Expand Down
41 changes: 41 additions & 0 deletions packages/alchemy/test/Test/Bun.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
});
29 changes: 29 additions & 0 deletions packages/alchemy/test/Test/fixtures/bun-teardown-guard.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Run by `bun test ./<path>` 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);
Loading