| 1 | /** @effect-diagnostics anyUnknownInErrorContext:off */ |
| 2 | |
| 3 | import bun from "bun:test"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; |
| 6 | import * as Scope from "effect/Scope"; |
| 7 | import type { HookOptions } from "node:test"; |
| 8 | |
| 9 | import type { AlchemyContext } from "../AlchemyContext.ts"; |
| 10 | import type { CompiledStack } from "../Stack.ts"; |
| 11 | import type { Stage } from "../Stage.ts"; |
| 12 | import * as Core from "./Core.ts"; |
| 13 | |
| 14 | export { |
| 15 | executeWhenReady, |
| 16 | getWhenReady, |
| 17 | guardContentType, |
| 18 | guardedFetchLayer, |
| 19 | rpcClientLayer, |
| 20 | WorkerNotReady, |
| 21 | type EdgeGuardOptions, |
| 22 | type WhenReadyOptions, |
| 23 | } from "./Http.ts"; |
| 24 | |
| 25 | export type MakeOptions<ROut = any> = Core.MakeOptions<ROut>; |
| 26 | export type ScratchStack = Core.ScratchStack; |
| 27 | export type TestEffect<A, R = never> = Core.TestEffect<A, R>; |
| 28 | export const defaultStage = Core.defaultStage; |
| 29 | export const resolveStage = Core.resolveStage; |
| 30 | |
| 31 | export 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 | |
| 44 | interface 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 | |
| 67 | interface 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 | |
| 87 | interface BeforeAllFn { |
| 88 | <A>(eff: TestEffect<A>, options?: HookOptions): Effect.Effect<A>; |
| 89 | } |
| 90 | |
| 91 | interface BeforeEachFn { |
| 92 | (eff: TestEffect<void>, options?: HookOptions): void; |
| 93 | } |
| 94 | |
| 95 | interface AfterAllFn { |
| 96 | (eff: TestEffect<any>, options?: HookOptions): void; |
| 97 | skipIf: ( |
| 98 | predicate: boolean, |
| 99 | ) => (eff: TestEffect<any>, options?: HookOptions) => void; |
| 100 | } |
| 101 | |
| 102 | interface AfterEachFn { |
| 103 | (eff: TestEffect<void>, options?: HookOptions): void; |
| 104 | } |
| 105 | |
| 106 | const 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 | */ |
| 122 | export 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 | }; |