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