1/** @effect-diagnostics anyUnknownInErrorContext:off */
2
3/**
4 * Test adapter for the `alchemy-test` runner (see `packages/alchemy-test`).
5 *
6 * Same shape as {@link "./Vitest.ts"} / {@link "./Bun.ts"}, but registers
7 * tests as raw Effects with the alchemy-test harness so the single-process
8 * runner can inject a buffering Logger/Console per test and manage
9 * concurrency + timeouts itself.
10 */
11import {
12 currentFile,
13 exclusiveOf,
14 registerHook,
15 registerTest,
16 retryOf,
17 tagsOf,
18 optInTagsOf,
19 timeoutOf,
20 type TestOptions,
21} from "alchemy-test";
22import * as Effect from "effect/Effect";
23import * as Exit from "effect/Exit";
24import * as Scope from "effect/Scope";
25
26import type { AlchemyContext } from "../AlchemyContext.ts";
27import type { CompiledStack } from "../Stack.ts";
28import type { Stage } from "../Stage.ts";
29import * as Core from "./Core.ts";
30
31export {
32 executeWhenReady,
33 getWhenReady,
34 guardContentType,
35 guardedFetchLayer,
36 rpcClientLayer,
37 WorkerNotReady,
38 type EdgeGuardOptions,
39 type WhenReadyOptions,
40} from "./Http.ts";
41
42export type MakeOptions<ROut = any> = Core.MakeOptions<ROut>;
43export type ScratchStack = Core.ScratchStack;
44export type TestEffect<A, R = never> = Core.TestEffect<A, R>;
45export const ALCHEMY_TEST_DEV = Core.ALCHEMY_TEST_DEV;
46export const resolveDev = Core.resolveDev;
47export const defaultStage = Core.defaultStage;
48export const resolveStage = Core.resolveStage;
49
50interface TestFn {
51 (name: string, eff: TestEffect<void>, options?: TestOptions): void;
52 skip: (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
53 skipIf: (
54 condition: boolean,
55 ) => (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
56 only: (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
57 todo: (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
58 provider: ProviderFn;
59}
60
61interface ProviderFn {
62 (
63 name: string,
64 fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
65 options?: TestOptions,
66 ): void;
67 skip: (
68 name: string,
69 fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
70 options?: TestOptions,
71 ) => void;
72 skipIf: (
73 condition: boolean,
74 ) => (
75 name: string,
76 fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
77 options?: TestOptions,
78 ) => void;
79 todo: (
80 name: string,
81 fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
82 options?: TestOptions,
83 ) => void;
84}
85
86interface BeforeAllFn {
87 <A>(eff: TestEffect<A>, options?: TestOptions): Effect.Effect<A>;
88}
89
90interface BeforeEachFn {
91 (eff: TestEffect<void>, options?: TestOptions): void;
92}
93
94interface AfterAllFn {
95 (eff: TestEffect<any>, options?: TestOptions): void;
96 skipIf: (
97 predicate: boolean,
98 ) => (eff: TestEffect<any>, options?: TestOptions) => void;
99}
100
101interface AfterEachFn {
102 (eff: TestEffect<void>, options?: TestOptions): void;
103}
104
105export interface TestApi {
106 test: TestFn;
107 beforeAll: BeforeAllFn;
108 beforeEach: BeforeEachFn;
109 afterAll: AfterAllFn;
110 afterEach: AfterEachFn;
111 deploy: Core.Deploy;
112 destroy: (
113 stack: TestEffect<CompiledStack, Stage | AlchemyContext>,
114 options?: { stage?: string; include?: never; exclude?: never },
115 ) => ReturnType<typeof Core.destroy>;
116}
117
118const DEFAULT_TIMEOUT = 120_000;
119
120/**
121 * Build the per-file test API. Configure providers / state once at the top of
122 * the test file:
123 *
124 * ```ts
125 * import * as Test from "@/Test/Alchemy";
126 * import * as Cloudflare from "@/Cloudflare";
127 *
128 * const { test, deploy, destroy, beforeAll, afterAll } = Test.make({
129 * providers: Cloudflare.providers(),
130 * state: Cloudflare.state(),
131 * });
132 * ```
133 */
134export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
135 // Single scope shared across `beforeAll`, every `test`, and `afterAll`.
136 // Scoped resources in dev mode (the Cloudflare dev proxy and its workerd
137 // children) must outlive a single test boundary, otherwise the proxy is
138 // killed the moment `beforeAll(deploy(Stack))` resolves and every later
139 // `HttpClient.get(workerUrl)` hits a dead port. The scope is closed by
140 // `destroy(...)` (or by the fallback afterAll below).
141 const sharedScope = Scope.makeUnsafe("sequential");
142 // In dev mode, run local providers behind one file-scoped RPC sidecar
143 // (the `alchemy dev` topology). Lives in its own scope — NOT sharedScope,
144 // which `destroy(Stack)` closes mid-file in self-contained tests — and is
145 // closed by the fallback afterAll below.
146 const sidecar = Core.makeSidecarHandle(options);
147 const wrap = <A>(eff: TestEffect<A>) =>
148 Core.toEffect(eff, options, sharedScope, sidecar);
149
150 const addTest = (
151 name: string,
152 eff: TestEffect<void>,
153 opts: TestOptions | undefined,
154 mode: "run" | "skip" | "only" | "todo",
155 ) =>
156 registerTest({
157 name,
158 mode,
159 exclusive: exclusiveOf(opts),
160 retry: retryOf(opts),
161 timeout: timeoutOf(opts),
162 tags: tagsOf(opts),
163 optInTags: optInTagsOf(opts),
164 body: mode === "skip" || mode === "todo" ? undefined : () => wrap(eff),
165 });
166
167 const test = ((name, eff, opts) => {
168 addTest(name, eff, opts, "run");
169 }) as TestFn;
170
171 test.skip = (name, eff, opts) => addTest(name, eff, opts, "skip");
172 test.skipIf = (condition) => (name, eff, opts) =>
173 addTest(name, eff, opts, condition ? "skip" : "run");
174 test.only = (name, eff, opts) => addTest(name, eff, opts, "only");
175 test.todo = (name, eff, opts) => addTest(name, eff, opts, "todo");
176
177 const wrapProvider = (
178 name: string,
179 fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
180 file: string | undefined,
181 ) => {
182 // Durable, file-namespaced scratch state (`.alchemy/state`). A run that
183 // dies mid-delete — e.g. the runner abandons teardown 10s after a test
184 // timeout while a CloudFront disable-wait is still in flight — leaves
185 // its `deleting` rows on disk, so the NEXT run's leading
186 // `stack.destroy()` (or the ensuring teardown below) resumes and drains
187 // them instead of planning "no changes" and silently orphaning the
188 // cloud resource.
189 const scratch = Core.scratchStack(options, name, file);
190 // Guarantee teardown. `test.provider` has no built-in cleanup, so a body
191 // that fails (assertion, API error like a 409/Unauthorized) or is
192 // interrupted (timeout) BEFORE its trailing `stack.destroy()` would
193 // otherwise leak every cloud resource it deployed.
194 // `scratch.destroy()` is idempotent — a no-op when the body already
195 // destroyed, and it reclaims the orphans otherwise. `Effect.ensuring`
196 // runs the finalizer on success, failure, AND interruption.
197 const body = Core.withProviders(fn(scratch), options, scratch.name).pipe(
198 Effect.ensuring(scratch.destroy().pipe(Effect.ignore)),
199 );
200 return Core.toEffect(
201 body,
202 { ...options, state: scratch.state },
203 sharedScope,
204 sidecar,
205 );
206 };
207
208 const addProvider = (
209 name: string,
210 fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
211 opts: TestOptions | undefined,
212 mode: "run" | "skip" | "todo",
213 ) => {
214 // Captured at registration (module evaluation during collection) — the
215 // collection context is gone by the time the body runs.
216 const file = currentFile();
217 registerTest({
218 name,
219 mode,
220 exclusive: exclusiveOf(opts),
221 retry: retryOf(opts),
222 timeout: timeoutOf(opts),
223 tags: tagsOf(opts),
224 optInTags: optInTagsOf(opts),
225 body: mode === "run" ? () => wrapProvider(name, fn, file) : undefined,
226 });
227 };
228
229 const provider = ((name, fn, opts) => {
230 addProvider(name, fn, opts, "run");
231 }) as ProviderFn;
232 provider.skip = (name, fn, opts) => addProvider(name, fn, opts, "skip");
233 provider.skipIf = (condition) => (name, fn, opts) =>
234 addProvider(name, fn, opts, condition ? "skip" : "run");
235 provider.todo = (name, fn, opts) => addProvider(name, fn, opts, "todo");
236 test.provider = provider;
237
238 const beforeAll: BeforeAllFn = <A>(
239 eff: TestEffect<A>,
240 hookOptions?: TestOptions,
241 ) => {
242 let result: A;
243 registerHook("beforeAll", {
244 body: () =>
245 wrap(eff).pipe(
246 Effect.map((value) => {
247 result = value;
248 }),
249 ),
250 timeout: timeoutOf(hookOptions) ?? DEFAULT_TIMEOUT,
251 exclusive: exclusiveOf(hookOptions),
252 });
253 return Effect.sync(() => result);
254 };
255
256 const beforeEach: BeforeEachFn = (eff, hookOptions) => {
257 registerHook("beforeEach", {
258 body: () => wrap(eff),
259 timeout: timeoutOf(hookOptions),
260 });
261 };
262
263 const afterAll = ((eff, hookOptions) => {
264 registerHook("afterAll", {
265 body: () => wrap(eff),
266 timeout: timeoutOf(hookOptions) ?? DEFAULT_TIMEOUT,
267 exclusive: exclusiveOf(hookOptions),
268 });
269 }) as AfterAllFn;
270 afterAll.skipIf = (predicate) => (eff, hookOptions) => {
271 if (predicate) return;
272 registerHook("afterAll", {
273 body: () => wrap(eff),
274 timeout: timeoutOf(hookOptions) ?? DEFAULT_TIMEOUT,
275 exclusive: exclusiveOf(hookOptions),
276 });
277 };
278
279 const afterEach: AfterEachFn = (eff, hookOptions) => {
280 registerHook("afterEach", {
281 body: () => wrap(eff),
282 timeout: timeoutOf(hookOptions),
283 });
284 };
285
286 // `destroy(Stack)` needs the dev sidecar alive so it can call `sidecar.stop`
287 // for each worker. We close the shared scope only AFTER destroy completes.
288 // `Scope.close` on an already-closed scope is a no-op, so it's safe for both
289 // the destroy wrapper AND the fallback cleanup hook below to call it.
290 const closeScope = Effect.suspend(() =>
291 Scope.close(sharedScope, Exit.void),
292 ).pipe(Effect.ignore);
293
294 // Fallback cleanup: if the user never calls `destroy(Stack)` (e.g.
295 // `NO_DESTROY=1`), nothing else closes the shared scope and the sidecar
296 // child process leaks past the test run. Register an `afterAll` that
297 // closes it (and the RPC sidecar, which lives in its own scope so that
298 // mid-file `destroy(Stack)` calls can't kill it for later tests). We defer
299 // registration to a microtask so it runs AFTER any user-registered
300 // `afterAll` (including `destroy(Stack)`); the runner executes afterAll
301 // hooks in registration order, and file collection flushes microtasks
302 // before sealing the file's suite tree and advancing to the next file.
303 const closeAll = sidecar
304 ? Effect.andThen(closeScope, sidecar.close)
305 : closeScope;
306 queueMicrotask(() => {
307 registerHook("afterAll", {
308 body: () => closeAll,
309 timeout: DEFAULT_TIMEOUT,
310 });
311 });
312
313 return {
314 test,
315 beforeAll,
316 beforeEach,
317 afterAll,
318 afterEach,
319 deploy: Core.makeDeploy(options, sharedScope),
320 destroy: (stack, callOpts) =>
321 Core.destroy(options, stack, { ...callOpts, scope: sharedScope }).pipe(
322 Effect.ensuring(closeScope),
323 ),
324 };
325};