1/** @effect-diagnostics anyUnknownInErrorContext:off */
2
3import bun from "bun:test";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Scope from "effect/Scope";
7import type { HookOptions } from "node:test";
8
9import type { AlchemyContext } from "../AlchemyContext.ts";
10import type { CompiledStack } from "../Stack.ts";
11import type { Stage } from "../Stage.ts";
12import * as Core from "./Core.ts";
13
14export {
15 executeWhenReady,
16 getWhenReady,
17 guardContentType,
18 guardedFetchLayer,
19 rpcClientLayer,
20 WorkerNotReady,
21 type EdgeGuardOptions,
22 type WhenReadyOptions,
23} from "./Http.ts";
24
25export type MakeOptions<ROut = any> = Core.MakeOptions<ROut>;
26export type ScratchStack = Core.ScratchStack;
27export type TestEffect<A, R = never> = Core.TestEffect<A, R>;
28export const defaultStage = Core.defaultStage;
29export const resolveStage = Core.resolveStage;
30
31export interface TestApi {
32 test: TestFn;
33 beforeAll: BeforeAllFn;
34 beforeEach: BeforeEachFn;
35 afterAll: AfterAllFn;
36 afterEach: AfterEachFn;
37 deploy: Core.Deploy;
38 destroy: (
39 stack: TestEffect<CompiledStack, Stage | AlchemyContext>,
40 options?: { stage?: string; include?: never; exclude?: never },
41 ) => ReturnType<typeof Core.destroy>;
42}
43
44interface TestFn {
45 (name: string, eff: TestEffect<void>, options?: bun.TestOptions): void;
46 skip: (
47 name: string,
48 eff: TestEffect<void>,
49 options?: bun.TestOptions,
50 ) => void;
51 skipIf: (
52 condition: boolean,
53 ) => (name: string, eff: TestEffect<void>, options?: bun.TestOptions) => void;
54 only: (
55 name: string,
56 eff: TestEffect<void>,
57 options?: bun.TestOptions,
58 ) => void;
59 todo: (
60 name: string,
61 eff: TestEffect<void>,
62 options?: bun.TestOptions,
63 ) => void;
64 provider: ProviderFn;
65}
66
67interface ProviderFn {
68 (
69 name: string,
70 fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
71 options?: bun.TestOptions,
72 ): void;
73 skip: (
74 name: string,
75 fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
76 options?: bun.TestOptions,
77 ) => void;
78 skipIf: (
79 condition: boolean,
80 ) => (
81 name: string,
82 fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
83 options?: bun.TestOptions,
84 ) => void;
85}
86
87interface BeforeAllFn {
88 <A>(eff: TestEffect<A>, options?: HookOptions): Effect.Effect<A>;
89}
90
91interface BeforeEachFn {
92 (eff: TestEffect<void>, options?: HookOptions): void;
93}
94
95interface AfterAllFn {
96 (eff: TestEffect<any>, options?: HookOptions): void;
97 skipIf: (
98 predicate: boolean,
99 ) => (eff: TestEffect<any>, options?: HookOptions) => void;
100}
101
102interface AfterEachFn {
103 (eff: TestEffect<void>, options?: HookOptions): void;
104}
105
106const DEFAULT_HOOK_TIMEOUT: HookOptions = { timeout: 120_000 };
107
108/**
109 * Build the per-file test API. Configure providers / state once at the top of
110 * the test file:
111 *
112 * ```ts
113 * import * as Test from "alchemy/Test/Bun";
114 * import * as Cloudflare from "alchemy/Cloudflare";
115 *
116 * const { test, deploy, destroy, beforeAll, afterAll } = Test.make({
117 * providers: Cloudflare.providers(),
118 * state: Cloudflare.state(),
119 * });
120 * ```
121 */
122export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
123 // Single scope shared across `beforeAll`, every `test`, and `afterAll`.
124 // Scoped resources in dev mode (the Cloudflare sidecar process and its
125 // workerd children) must outlive a single `Effect.runPromise` boundary,
126 // otherwise the proxy is killed the moment `beforeAll(deploy(Stack))`
127 // resolves and every later `HttpClient.get(workerUrl)` hits a dead port.
128 // The scope is closed by `destroy(...)` (or never — the next test run
129 // reclaims any leaked sidecar via the lock file).
130 const sharedScope = Scope.makeUnsafe("sequential");
131 // In dev mode, run local providers behind one file-scoped RPC sidecar
132 // (the `alchemy dev` topology). Lives in its own scope — NOT sharedScope,
133 // which `destroy(Stack)` closes mid-file in self-contained tests — and is
134 // closed by the fallback afterAll below.
135 const sidecar = Core.makeSidecarHandle(options);
136 const runEff = <A>(eff: TestEffect<A>) =>
137 Core.run(eff, options, sharedScope, sidecar);
138
139 const test = ((name, eff, opts) => {
140 bun.test(name, () => runEff(eff), opts);
141 }) as TestFn;
142
143 test.skip = (name, eff, opts) => {
144 bun.test.skip(name, () => runEff(eff), opts);
145 };
146 test.skipIf = (condition) => (name, eff, opts) => {
147 bun.test.skipIf(condition)(name, () => runEff(eff), opts);
148 };
149 test.only = (name, eff, opts) => {
150 bun.test.only(name, () => runEff(eff), opts);
151 };
152 test.todo = (name, eff, opts) => {
153 bun.test.todo(name, () => runEff(eff), opts);
154 };
155
156 const runProvider = (
157 name: string,
158 fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
159 ) => {
160 const scratch = Core.scratchStack(options, name);
161 return Core.run(
162 Core.withProviders(fn(scratch), options, scratch.name),
163 { ...options, state: scratch.state },
164 sharedScope,
165 sidecar,
166 );
167 };
168
169 const provider = ((name, fn, opts) => {
170 bun.test(name, () => runProvider(name, fn), opts);
171 }) as ProviderFn;
172 provider.skip = (name, fn, opts) => {
173 bun.test.skip(name, () => runProvider(name, fn), opts);
174 };
175 provider.skipIf = (condition) => (name, fn, opts) => {
176 bun.test.skipIf(condition)(name, () => runProvider(name, fn), opts);
177 };
178 test.provider = provider;
179
180 const beforeAll: BeforeAllFn = <A>(
181 eff: TestEffect<A>,
182 hookOptions?: HookOptions,
183 ) => {
184 let result: A;
185 bun.beforeAll(
186 () => runEff(eff).then((v) => (result = v)),
187 hookOptions ?? DEFAULT_HOOK_TIMEOUT,
188 );
189 return Effect.sync(() => result);
190 };
191
192 const beforeEach: BeforeEachFn = (eff, hookOptions) => {
193 bun.beforeEach(() => runEff(eff), hookOptions);
194 };
195
196 // bun:test stops running later `afterAll` hooks once one throws, which
197 // would skip the fallback cleanup hook below — leaking the shared scope
198 // and the sidecar for the rest of the process whenever a teardown
199 // assertion fails. Guard every user teardown: on failure, run the
200 // (idempotent) cleanup before rethrowing so the failure still fails the
201 // suite. (`closeAll` is initialized below; hooks only run after `make`
202 // returns.)
203 const guardTeardown = (eff: TestEffect<any>) => () =>
204 runEff(eff).catch(async (error) => {
205 await Effect.runPromise(closeAll);
206 throw error;
207 });
208
209 const afterAll = ((eff, hookOptions) => {
210 bun.afterAll(guardTeardown(eff), hookOptions ?? DEFAULT_HOOK_TIMEOUT);
211 }) as AfterAllFn;
212 afterAll.skipIf = (predicate) => (eff, hookOptions) => {
213 if (predicate) return;
214 bun.afterAll(guardTeardown(eff), hookOptions ?? DEFAULT_HOOK_TIMEOUT);
215 };
216
217 const afterEach: AfterEachFn = (eff, hookOptions) => {
218 bun.afterEach(() => runEff(eff), hookOptions);
219 };
220
221 // `destroy(Stack)` needs the dev sidecar alive so it can call `sidecar.stop`
222 // for each worker. We close the shared scope only AFTER destroy completes.
223 // `Scope.close` on an already-closed scope is a no-op, so it's safe for both
224 // the destroy wrapper AND the fallback cleanup hook below to call it.
225 const closeScope = Effect.suspend(() =>
226 Scope.close(sharedScope, Exit.void),
227 ).pipe(Effect.ignore);
228
229 // Fallback cleanup: if the user never calls `destroy(Stack)` (e.g.
230 // `NO_DESTROY=1`), nothing else closes the shared scope and the sidecar
231 // child process leaks past the test process. Register an `afterAll` that
232 // closes it (and the RPC sidecar, which lives in its own scope so that
233 // mid-file `destroy(Stack)` calls can't kill it for later tests). We defer
234 // registration to a microtask so it runs AFTER any user-registered
235 // `afterAll` (including `destroy(Stack)`); bun runs afterAll hooks in
236 // registration order.
237 const closeAll = sidecar
238 ? Effect.andThen(closeScope, sidecar.close)
239 : closeScope;
240 queueMicrotask(() => {
241 bun.afterAll(() => Effect.runPromise(closeAll), DEFAULT_HOOK_TIMEOUT);
242 });
243
244 return {
245 test,
246 beforeAll,
247 beforeEach,
248 afterAll,
249 afterEach,
250 deploy: Core.makeDeploy(options, sharedScope),
251 destroy: (stack, callOpts) =>
252 Core.destroy(options, stack, { ...callOpts, scope: sharedScope }).pipe(
253 Effect.ensuring(closeScope),
254 ),
255 };
256};