| 1 | import type { |
| 2 | ResourceSelection, |
| 3 | SelectionOutput, |
| 4 | } from "../ResourceSelection.ts"; |
| 5 | /** @effect-diagnostics anyUnknownInErrorContext:off */ |
| 6 | |
| 7 | import * as Floci from "@alchemy.run/floci"; |
| 8 | import * as Config from "effect/Config"; |
| 9 | import { ConfigProvider } from "effect/ConfigProvider"; |
| 10 | import * as Context from "effect/Context"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Exit from "effect/Exit"; |
| 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Option from "effect/Option"; |
| 15 | import * as Scope from "effect/Scope"; |
| 16 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 17 | import * as HttpClient from "effect/http/HttpClient"; |
| 18 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 19 | |
| 20 | import { DEFAULT_LOCAL_ENDPOINT } from "../AWS/AuthProvider.ts"; |
| 21 | import { flociServices } from "../AWS/Local/FlociServices.ts"; |
| 22 | import { AdoptPolicy } from "../AdoptPolicy.ts"; |
| 23 | import { AlchemyContext, AlchemyContextLive } from "../AlchemyContext.ts"; |
| 24 | import { apply } from "../Apply.ts"; |
| 25 | import { provideFreshArtifactStore } from "../Artifacts.ts"; |
| 26 | import { AuthProviders } from "../Auth/AuthProvider.ts"; |
| 27 | import { CredentialsStoreLive } from "../Auth/Credentials.ts"; |
| 28 | import { ProfileStoreLive } from "../Auth/Profile.ts"; |
| 29 | import { withProfileOverride } from "../Auth/Resolve.ts"; |
| 30 | import * as Interaction from "../Interaction.ts"; |
| 31 | import { userStage } from "../Cli/commands/flags.ts"; |
| 32 | import { LoggingCli } from "../Cli/LoggingCli.ts"; |
| 33 | import { deploy as _deploy } from "../Deploy.ts"; |
| 34 | import { destroy as _destroy } from "../Destroy.ts"; |
| 35 | import type { Input } from "../Input.ts"; |
| 36 | import * as RpcProviderProxy from "../Local/RpcProviderProxy.ts"; |
| 37 | import * as RpcSpawner from "../Local/RpcSpawner.ts"; |
| 38 | import { ALCHEMY_DEV } from "../Phase.ts"; |
| 39 | import * as Plan from "../Plan.ts"; |
| 40 | import { |
| 41 | type CompiledStack, |
| 42 | make as makeStack, |
| 43 | Stack, |
| 44 | type StackEffect, |
| 45 | type StackServices, |
| 46 | } from "../Stack.ts"; |
| 47 | import { Stage } from "../Stage.ts"; |
| 48 | import * as State from "../State/index.ts"; |
| 49 | import { TelemetryLive } from "../Telemetry/Layer.ts"; |
| 50 | import { |
| 51 | loadConfigProvider, |
| 52 | StackConfigOverrides, |
| 53 | } from "../Util/ConfigProvider.ts"; |
| 54 | import { PlatformServices } from "../Util/PlatformServices.ts"; |
| 55 | |
| 56 | /** |
| 57 | * Configuration shared by every test in a file. Pass to `Test.make(...)`. |
| 58 | */ |
| 59 | export interface MakeOptions<ROut = any> { |
| 60 | /** Provider layer for the stack — e.g. `AWS.providers()`, `Cloudflare.providers()`. */ |
| 61 | providers: Layer.Layer<ROut, never, StackServices>; |
| 62 | /** State store for top-level `deploy(Stack)` / `destroy(Stack)`; defaults to {@link State.localState}. */ |
| 63 | state?: Layer.Layer<State.State, never, StackServices>; |
| 64 | /** Override the current profile; otherwise resolved from env or the built-in `default`. */ |
| 65 | profile?: string; |
| 66 | /** |
| 67 | * Default stage for deploy/destroy. Defaults to `test_$USER` (or |
| 68 | * `test_$USERNAME` on Windows, `test_unknown` if neither is set) so two |
| 69 | * people running the same suite against one account don't collide. |
| 70 | * Does **not** read `$ALCHEMY_STAGE` — that is the CLI deploy/dev stage. |
| 71 | */ |
| 72 | stage?: string; |
| 73 | /** |
| 74 | * Engine-level adoption policy for this test run. When `true`, resources |
| 75 | * without prior state will be adopted from the cloud via `provider.read` |
| 76 | * (matching the CLI's `--adopt` flag). Defaults to `false`. |
| 77 | */ |
| 78 | adopt?: boolean; |
| 79 | /** |
| 80 | * Run providers in local-dev mode (matching the CLI's `alchemy dev` flag). |
| 81 | * When `true`, resources like Cloudflare Workers run locally via workerd |
| 82 | * instead of being deployed to the cloud. When omitted, falls back to the |
| 83 | * `ALCHEMY_DEV` environment variable (`"1"` / `"true"` enable it). |
| 84 | * |
| 85 | * {@link ALCHEMY_TEST_DEV} (`ALCHEMY_TEST_DEV=1`) overrides this — use it |
| 86 | * to force an entire existing live suite through local providers without |
| 87 | * editing each `Test.make({ dev: true })`. |
| 88 | */ |
| 89 | dev?: boolean; |
| 90 | /** |
| 91 | * Run local providers behind the RPC sidecar proxy, matching the process |
| 92 | * topology of the real `alchemy dev` command: an {@link RpcProviderProxy} |
| 93 | * is installed, so `RpcProvider.providerServicesEffect` layers (e.g. |
| 94 | * Cloudflare's `localRuntimeServices()`) are EMPTY in the test process and |
| 95 | * RPC-backed providers run their lifecycle in a spawned sidecar process. |
| 96 | * |
| 97 | * Defaults to the resolved `dev` flag — dev tests run the real `alchemy |
| 98 | * dev` topology unless they opt out. In-process dev (`sidecar: false`) |
| 99 | * masks missing main-process lifecycle dependencies (the class of bug |
| 100 | * behind #1007, where D1's local migrations only failed under `alchemy |
| 101 | * dev`); it remains available because it IS still a real production path — |
| 102 | * a plain `alchemy deploy` deleting a `providerMode: "local"` state row |
| 103 | * runs the local provider in-process — and because in-process runs are |
| 104 | * easier to debug. |
| 105 | * |
| 106 | * Fully lazy: only the proxy facade is installed up front. The spawner |
| 107 | * HTTP server starts — and a sidecar child process is forked — on the |
| 108 | * first provider session request (a deploy/destroy building an RPC-backed |
| 109 | * local provider); a dev file that never does starts no processes at all. |
| 110 | * Once started, the sidecar lives for the whole test file (its own scope, |
| 111 | * closed by the adapter's final cleanup hook), so `beforeAll(deploy(Stack))` |
| 112 | * + per-test requests work the same way they do under a real `alchemy dev` |
| 113 | * session. |
| 114 | */ |
| 115 | sidecar?: boolean; |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * The RPC sidecar topology used by the real `alchemy dev` command, in one |
| 120 | * process-local layer: an {@link RpcSpawner} HTTP server that forks sidecar |
| 121 | * processes on demand, and an {@link RpcProviderProxy} pointed at it. |
| 122 | */ |
| 123 | export const sidecarProxy = (options: { profile?: string }) => |
| 124 | Layer.unwrap( |
| 125 | Effect.map(RpcSpawner.RpcSpawner, (spawner) => |
| 126 | RpcProviderProxy.layer(spawner.url), |
| 127 | ), |
| 128 | ).pipe( |
| 129 | Layer.provideMerge( |
| 130 | RpcSpawner.layerServer({ |
| 131 | profile: options.profile ?? process.env.ALCHEMY_PROFILE, |
| 132 | envFile: undefined, |
| 133 | }), |
| 134 | ), |
| 135 | ); |
| 136 | |
| 137 | /** |
| 138 | * Force every `Test.make` into (or out of) local-dev mode, regardless of |
| 139 | * the file's `dev` option. Unset leaves the option / `ALCHEMY_DEV` fallback |
| 140 | * in place. Accepts the usual truthy/falsey strings (`true`/`1`/`yes`/`on`, |
| 141 | * `false`/`0`/`no`/`off`). |
| 142 | */ |
| 143 | export const ALCHEMY_TEST_DEV = Config.Boolean("ALCHEMY_TEST_DEV").pipe( |
| 144 | Config.option, |
| 145 | ); |
| 146 | |
| 147 | /** The `ALCHEMY_TEST_DEV` override, if the env var is set. */ |
| 148 | export const alchemyTestDevOverride = (): Option.Option<boolean> => |
| 149 | Effect.runSync(ALCHEMY_TEST_DEV); |
| 150 | |
| 151 | /** Resolve the effective `dev` flag: `ALCHEMY_TEST_DEV`, then options, then `ALCHEMY_DEV`. */ |
| 152 | export const resolveDev = (options: { dev?: boolean }): boolean => { |
| 153 | const override = alchemyTestDevOverride(); |
| 154 | if (Option.isSome(override)) return override.value; |
| 155 | if (options.dev !== undefined) return options.dev; |
| 156 | return Effect.runSync(ALCHEMY_DEV); |
| 157 | }; |
| 158 | |
| 159 | /** Resolve the effective `sidecar` flag: defaults to the resolved `dev` flag. */ |
| 160 | export const resolveSidecar = (options: MakeOptions): boolean => |
| 161 | options.sidecar ?? resolveDev(options); |
| 162 | |
| 163 | /** |
| 164 | * Default test stage: `ALCHEMY_TEST_STAGE`, then `test_$USER` (or |
| 165 | * `test_$USERNAME` / `test_unknown`). Set an explicit test stage to isolate |
| 166 | * concurrent worktrees that share a cloud account. |
| 167 | * Matches the CLI's `live_$USER` / `dev_$USER` per-developer isolation. |
| 168 | */ |
| 169 | export const defaultStage = (): string => |
| 170 | process.env.ALCHEMY_TEST_STAGE || Effect.runSync(userStage("test")); |
| 171 | |
| 172 | /** File-level stage: a string override, otherwise {@link defaultStage}. */ |
| 173 | export const resolveStage = (options: { stage?: string }): string => |
| 174 | // Configured stacks carry a cross-stack reference proxy, not a stage override. |
| 175 | typeof options.stage === "string" ? options.stage : defaultStage(); |
| 176 | |
| 177 | /** |
| 178 | * The sidecar runtime handed to each adapter's `make(...)`. |
| 179 | * |
| 180 | * `provide` installs a lazy {@link RpcProviderProxy} facade into an effect. |
| 181 | * Installing the facade is free: the spawner HTTP server only starts (and, |
| 182 | * downstream of it, a sidecar child process is only forked) when a provider |
| 183 | * actually requests a session — i.e. when a deploy/destroy builds an |
| 184 | * RPC-backed local provider. A dev file that never does starts nothing. |
| 185 | * |
| 186 | * The spawner (and the sidecar children it forks) is a PROCESS-WIDE |
| 187 | * SINGLETON shared by every test file, refcounted per handle: all files run |
| 188 | * in one bun process, and a per-file sidecar means a per-file bun child that |
| 189 | * imports the entire alchemy + distilled module graph — dozens of concurrent |
| 190 | * files at hundreds of MB each OOMs the machine. Stack isolation is |
| 191 | * preserved because each RPC session carries its own stack environment (see |
| 192 | * `SESSION_ENV_PARAM` in Local/RpcServerEnvironment.ts) and the child builds |
| 193 | * a provider context per stack. The singleton's scope closes when the LAST |
| 194 | * handle closes; `Test.make` runs at collection time (before any test), so |
| 195 | * the refcount cannot dip to zero while later files still need it. Adapters |
| 196 | * run `close` from the same final cleanup hook that closes the shared scope. |
| 197 | */ |
| 198 | export interface SidecarHandle { |
| 199 | readonly provide: <A, E, R>( |
| 200 | eff: Effect.Effect<A, E, R>, |
| 201 | ) => Effect.Effect<A, any, any>; |
| 202 | readonly close: Effect.Effect<void>; |
| 203 | } |
| 204 | |
| 205 | interface SidecarSingleton { |
| 206 | readonly lazy: Layer.Layer<RpcProviderProxy.RpcProviderProxy>; |
| 207 | readonly scope: Scope.Closeable; |
| 208 | refs: number; |
| 209 | } |
| 210 | |
| 211 | const sidecarSingletons = new Map<string, SidecarSingleton>(); |
| 212 | |
| 213 | export const makeSidecarHandle = <ROut = any>( |
| 214 | options: MakeOptions<ROut>, |
| 215 | ): SidecarHandle | undefined => { |
| 216 | if (!resolveSidecar(options)) return undefined; |
| 217 | const key = options.profile ?? process.env.ALCHEMY_PROFILE ?? ""; |
| 218 | let singleton = sidecarSingletons.get(key); |
| 219 | if (singleton === undefined) { |
| 220 | const scope = Scope.makeUnsafe("sequential"); |
| 221 | const memoMap = Layer.makeMemoMapUnsafe(); |
| 222 | const real = sidecarProxy(options); |
| 223 | const lazy = Layer.effect( |
| 224 | RpcProviderProxy.RpcProviderProxy, |
| 225 | Effect.gen(function* () { |
| 226 | // Capture the ambient platform context (provided by `toEffect`) so |
| 227 | // the deferred spawner build can run inside a provider's `get` |
| 228 | // without leaking platform requirements onto the RpcProviderProxy |
| 229 | // interface. Omit Scope: that key is the calling file's sharedScope |
| 230 | // (closed in afterAll). Merging it in would pin the process-wide |
| 231 | // spawner HTTP server to a file that exits while others still need |
| 232 | // it. Provide the sidecar singleton scope instead. |
| 233 | const ambient = Context.omit(Scope.Scope)( |
| 234 | yield* Effect.context<never>(), |
| 235 | ); |
| 236 | const realProxy = Layer.buildWithMemoMap(real, memoMap, scope).pipe( |
| 237 | Effect.map((built) => |
| 238 | Context.get(built, RpcProviderProxy.RpcProviderProxy), |
| 239 | ), |
| 240 | Effect.provideContext(ambient as Context.Context<any>), |
| 241 | Scope.provide(scope), |
| 242 | Effect.orDie, |
| 243 | ); |
| 244 | return RpcProviderProxy.RpcProviderProxy.of({ |
| 245 | get: (providersUrl, providerName) => |
| 246 | Effect.flatMap(realProxy, (proxy) => |
| 247 | proxy.get(providersUrl, providerName), |
| 248 | ), |
| 249 | }); |
| 250 | }), |
| 251 | ); |
| 252 | singleton = { lazy, scope, refs: 0 }; |
| 253 | sidecarSingletons.set(key, singleton); |
| 254 | } |
| 255 | singleton.refs += 1; |
| 256 | const instance = singleton; |
| 257 | let closed = false; |
| 258 | return { |
| 259 | provide: (eff) => Effect.provide(eff, instance.lazy), |
| 260 | close: Effect.suspend(() => { |
| 261 | // Idempotent per handle: destroy(Stack) and the fallback afterAll can |
| 262 | // both run it without double-decrementing. |
| 263 | if (closed) return Effect.void; |
| 264 | closed = true; |
| 265 | instance.refs -= 1; |
| 266 | if (instance.refs > 0) return Effect.void; |
| 267 | if (sidecarSingletons.get(key) === instance) { |
| 268 | sidecarSingletons.delete(key); |
| 269 | } |
| 270 | return Scope.close(instance.scope, Exit.void); |
| 271 | }).pipe(Effect.ignore), |
| 272 | }; |
| 273 | }; |
| 274 | |
| 275 | const overrideAlchemyContext = (overrides: { dev: boolean }) => |
| 276 | Layer.effect( |
| 277 | AlchemyContext, |
| 278 | AlchemyContext.pipe(Effect.map((ctx) => ({ ...ctx, ...overrides }))), |
| 279 | ); |
| 280 | |
| 281 | export type TestEffect<A, Req = never> = StackEffect<A, any, Req>; |
| 282 | |
| 283 | /** |
| 284 | * Floci serves virtual-host data planes on the gateway when the Host |
| 285 | * header is the AWS hostname (`{bucket}.s3-website-{region}.amazonaws.com`, |
| 286 | * `{apiId}.appsync-api.{region}.amazonaws.com`, |
| 287 | * `{apiId}.execute-api.{region}.amazonaws.com`, |
| 288 | * `{distributionId}.cloudfront.net`). Live tests GET those hosts; under |
| 289 | * {@link ALCHEMY_TEST_DEV} rewrite the URL to the emulator and keep the |
| 290 | * Host so the virtual-host filter still fires. |
| 291 | */ |
| 292 | const rewriteAwsVirtualHostToFloci = ( |
| 293 | request: HttpClientRequest.HttpClientRequest, |
| 294 | ): HttpClientRequest.HttpClientRequest => { |
| 295 | let url: URL; |
| 296 | try { |
| 297 | url = new URL(request.url); |
| 298 | } catch { |
| 299 | return request; |
| 300 | } |
| 301 | if ( |
| 302 | !/\.s3-website-[a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname) && |
| 303 | !/\.appsync-api\.[a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname) && |
| 304 | !/\.execute-api\.[a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname) && |
| 305 | !/\.cloudfront\.net$/i.test(url.hostname) |
| 306 | ) { |
| 307 | return request; |
| 308 | } |
| 309 | const rewritten = new URL(url.href); |
| 310 | const endpoint = new URL(DEFAULT_LOCAL_ENDPOINT); |
| 311 | rewritten.protocol = endpoint.protocol; |
| 312 | rewritten.hostname = endpoint.hostname; |
| 313 | rewritten.port = endpoint.port; |
| 314 | return request.pipe( |
| 315 | HttpClientRequest.setUrl(rewritten.toString()), |
| 316 | HttpClientRequest.setHeader("host", url.hostname), |
| 317 | ); |
| 318 | }; |
| 319 | |
| 320 | if (Option.getOrElse(alchemyTestDevOverride(), () => false)) { |
| 321 | // Floci rewrites WebSocket invoke URLs onto wss://127.0.0.1:4566/ws/... |
| 322 | // The gateway cert is self-signed; Bun/Node would otherwise reject the |
| 323 | // upgrade. Scoped to ALCHEMY_TEST_DEV only. |
| 324 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; |
| 325 | // Local workerd trusts no self-signed certs and ignores the flag above |
| 326 | // (it's C++, not Node). Its runtime DOES fold `NODE_EXTRA_CA_CERTS` into |
| 327 | // workerd's outbound `trustedCertificates` (see cloudflare-runtime |
| 328 | // Internet.ts), so point it at the emulator CA bundle that `ensureFloci` |
| 329 | // refreshes on every health check. Set here — before the RPC spawner or |
| 330 | // any vite child forks — so the whole dev process tree inherits it. |
| 331 | process.env.NODE_EXTRA_CA_CERTS ??= Floci.FLOCI_CA_PATH; |
| 332 | } |
| 333 | |
| 334 | const flociWebsiteHttp = Layer.effect( |
| 335 | HttpClient.HttpClient, |
| 336 | Effect.map(HttpClient.HttpClient, (client) => |
| 337 | HttpClient.mapRequest(client, rewriteAwsVirtualHostToFloci), |
| 338 | ), |
| 339 | ).pipe(Layer.provide(FetchHttpClient.layer)); |
| 340 | |
| 341 | const platformLayer = () => |
| 342 | Layer.mergeAll( |
| 343 | PlatformServices, |
| 344 | Option.getOrElse(alchemyTestDevOverride(), () => false) |
| 345 | ? flociWebsiteHttp |
| 346 | : FetchHttpClient.layer, |
| 347 | Layer.provide(ProfileStoreLive, PlatformServices), |
| 348 | Layer.provide(CredentialsStoreLive, PlatformServices), |
| 349 | ); |
| 350 | |
| 351 | const alchemyLayer = Layer.mergeAll( |
| 352 | LoggingCli, |
| 353 | Interaction.layerNonInteractive(), |
| 354 | AlchemyContextLive, |
| 355 | ); |
| 356 | |
| 357 | /** |
| 358 | * Build the per-test runtime and return a self-contained Effect. |
| 359 | * |
| 360 | * Mirrors {@link "../bin/alchemy.js"} composition: ConfigProvider via |
| 361 | * `loadConfigProvider` + `withProfileOverride`, an empty `AuthProviders` |
| 362 | * registry that the user's `providers` layer populates, the platform layers, |
| 363 | * and the configured state store. Adapters wrap this into runner-specific |
| 364 | * thunks (`bun.test` -> `runPromise`, `it.live` -> as-is). |
| 365 | * |
| 366 | * When `scope` is provided, scoped resources (like the Cloudflare dev |
| 367 | * sidecar) survive past this effect and are tied to the lifetime of the |
| 368 | * provided scope instead. The runner is responsible for closing it. |
| 369 | * |
| 370 | * When `scope` is omitted, the effect runs with `Effect.scoped` and any |
| 371 | * scoped resources are torn down as soon as it resolves. |
| 372 | */ |
| 373 | export const toEffect = <A, ROut = any>( |
| 374 | effect: TestEffect<A>, |
| 375 | options: MakeOptions<ROut>, |
| 376 | scope?: Scope.Scope, |
| 377 | sidecar?: SidecarHandle, |
| 378 | ): Effect.Effect<A, any, never> => { |
| 379 | const base = Effect.gen(function* () { |
| 380 | const cfg = yield* loadConfigProvider(Option.none()); |
| 381 | const configProvider = withProfileOverride(cfg, options.profile); |
| 382 | // `ALCHEMY_TEST_DEV=1` forces local providers AND points the test |
| 383 | // process's distilled AWS clients at the emulator. Otherwise |
| 384 | // out-of-band `describeTable` / `getFunction` calls still hit the |
| 385 | // live account and fail with ResourceNotFound. Existing |
| 386 | // `Test.make({ dev: true })` files are unchanged (mixed |
| 387 | // `Alchemy.remote()` suites keep their live SDK). |
| 388 | const body = sidecar ? sidecar.provide(effect) : effect; |
| 389 | const locally = |
| 390 | Option.getOrElse(alchemyTestDevOverride(), () => false) === true |
| 391 | ? Effect.provide(body, flociServices()) |
| 392 | : body; |
| 393 | return yield* locally.pipe( |
| 394 | provideFreshArtifactStore, |
| 395 | Effect.provide(Layer.succeed(ConfigProvider, configProvider)), |
| 396 | Effect.provideService(StackConfigOverrides, { |
| 397 | profile: options.profile, |
| 398 | }), |
| 399 | ); |
| 400 | }).pipe( |
| 401 | Effect.provideService(AdoptPolicy, options.adopt ?? false), |
| 402 | Effect.provide(overrideAlchemyContext({ dev: resolveDev(options) })), |
| 403 | // `options.state` (e.g. `Cloudflare.state()`) itself requires |
| 404 | // `AuthProviders` to read credentials, so AuthProviders must be provided |
| 405 | // AFTER the state layer or the state layer's requirement is never |
| 406 | // satisfied — which surfaces as `Service not found: AuthProviders`. |
| 407 | Effect.provide(options.state ?? State.localState()), |
| 408 | Effect.provideService(AuthProviders, {}), |
| 409 | Effect.provide(Layer.provideMerge(alchemyLayer, platformLayer())), |
| 410 | ); |
| 411 | |
| 412 | return ( |
| 413 | scope === undefined ? Effect.scoped(base) : Scope.provide(base, scope) |
| 414 | ) as Effect.Effect<A, any, never>; |
| 415 | }; |
| 416 | |
| 417 | /** Promise wrapper around {@link toEffect} for `bun.test`-style runners. */ |
| 418 | export const run = <A, ROut = any>( |
| 419 | effect: TestEffect<A>, |
| 420 | options: MakeOptions<ROut>, |
| 421 | scope?: Scope.Scope, |
| 422 | sidecar?: SidecarHandle, |
| 423 | ): Promise<A> => Effect.runPromise(toEffect(effect, options, scope, sidecar)); |
| 424 | |
| 425 | /** |
| 426 | * Wrap an effect so it runs with `options.providers` + a placeholder Stack + |
| 427 | * Stage in scope. Used by `test.provider` so user code can call provider SDK |
| 428 | * APIs (e.g. `DynamoDB.describeTable`) directly inside the test body. |
| 429 | */ |
| 430 | export const withProviders = <A, E, R, ROut>( |
| 431 | effect: Effect.Effect<A, E, R>, |
| 432 | options: MakeOptions<ROut>, |
| 433 | stackName: string, |
| 434 | ): Effect.Effect<A, E, Exclude<R, ROut | Stack | Stage>> => { |
| 435 | // Closest wins: when `ALCHEMY_TEST_DEV=1`, pin the test body's |
| 436 | // distilled AWS clients to the emulator BEFORE `options.providers` |
| 437 | // (which still carries the live `AWSEnvironment`). |
| 438 | const body = |
| 439 | Option.getOrElse(alchemyTestDevOverride(), () => false) === true |
| 440 | ? Effect.provide(effect, flociServices()) |
| 441 | : effect; |
| 442 | const stage = resolveStage(options); |
| 443 | return body.pipe( |
| 444 | Effect.provide( |
| 445 | (options.providers as Layer.Layer<any, never, any>).pipe( |
| 446 | Layer.provideMerge( |
| 447 | Layer.succeed(Stack, { |
| 448 | name: stackName, |
| 449 | stage, |
| 450 | resources: {}, |
| 451 | bindings: {}, |
| 452 | actions: {}, |
| 453 | }), |
| 454 | ), |
| 455 | Layer.provideMerge(Layer.succeed(Stage, stage)), |
| 456 | ), |
| 457 | ), |
| 458 | ) as Effect.Effect<A, E, Exclude<R, ROut | Stack | Stage>>; |
| 459 | }; |
| 460 | |
| 461 | /** |
| 462 | * Curried `deploy` for the test factory: bakes in the configured stage and |
| 463 | * adds the telemetry layer the CLI uses, so `beforeAll(deploy(Stack))` works |
| 464 | * the same way as `alchemy deploy`. |
| 465 | * |
| 466 | * `scope`, when supplied, is forwarded down so the dev sidecar (and other |
| 467 | * scoped resources) lives until the caller closes it instead of dying as |
| 468 | * soon as `deploy` resolves. The test harness uses this to keep workerd |
| 469 | * alive across `beforeAll` → tests → `afterAll`. |
| 470 | */ |
| 471 | export interface DeployCallOptions extends Plan.MakePlanOptions { |
| 472 | stage?: string; |
| 473 | scope?: Scope.Scope; |
| 474 | } |
| 475 | |
| 476 | export type DeployResult<A> = Effect.Effect< |
| 477 | Input.Resolve<A>, |
| 478 | Effect.Error<ReturnType<typeof deployStack<A>>>, |
| 479 | Effect.Services<ReturnType<typeof deployStack<A>>> |
| 480 | >; |
| 481 | |
| 482 | export interface FilteredDeployCallOptions |
| 483 | extends Omit<DeployCallOptions, keyof ResourceSelection>, ResourceSelection {} |
| 484 | |
| 485 | type FullDeployCall = [options?: DeployCallOptions]; |
| 486 | |
| 487 | /** |
| 488 | * Filtered deploys return void; the whole declaration still evaluates. |
| 489 | * With an explicit output type, filtering also requires the Call tuple type. |
| 490 | */ |
| 491 | export interface Deploy { |
| 492 | <A, Call extends [options?: FilteredDeployCallOptions] = FullDeployCall>( |
| 493 | stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>, |
| 494 | ...call: Call |
| 495 | ): DeployResult<SelectionOutput<A, Call[0]>>; |
| 496 | } |
| 497 | |
| 498 | export function deploy< |
| 499 | A, |
| 500 | Call extends [options?: FilteredDeployCallOptions] = FullDeployCall, |
| 501 | >( |
| 502 | options: MakeOptions, |
| 503 | stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>, |
| 504 | ...call: Call |
| 505 | ): DeployResult<SelectionOutput<A, Call[0]>>; |
| 506 | export function deploy<A>( |
| 507 | options: MakeOptions, |
| 508 | stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>, |
| 509 | callOptions?: FilteredDeployCallOptions, |
| 510 | ) { |
| 511 | return deployStack(options, stack, callOptions); |
| 512 | } |
| 513 | |
| 514 | const deployStack = <A>( |
| 515 | options: MakeOptions, |
| 516 | stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>, |
| 517 | callOptions?: FilteredDeployCallOptions, |
| 518 | ) => |
| 519 | _deploy({ |
| 520 | stack: stack as Effect.Effect<CompiledStack<A>, never, any>, |
| 521 | stage: callOptions?.stage ?? resolveStage(options), |
| 522 | dev: resolveDev(options), |
| 523 | scope: callOptions?.scope, |
| 524 | force: callOptions?.force, |
| 525 | include: callOptions?.include, |
| 526 | exclude: callOptions?.exclude, |
| 527 | }).pipe(Effect.provide(TelemetryLive)); |
| 528 | |
| 529 | /** Bind test-file options and the shared runtime scope without erasing output types. */ |
| 530 | export const makeDeploy = ( |
| 531 | options: MakeOptions, |
| 532 | scope: Scope.Scope, |
| 533 | ): Deploy => { |
| 534 | function run< |
| 535 | A, |
| 536 | Call extends [options?: FilteredDeployCallOptions] = FullDeployCall, |
| 537 | >( |
| 538 | stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>, |
| 539 | ...call: Call |
| 540 | ): DeployResult<SelectionOutput<A, Call[0]>>; |
| 541 | function run<A>( |
| 542 | stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>, |
| 543 | callOptions?: FilteredDeployCallOptions, |
| 544 | ) { |
| 545 | return deployStack(options, stack, { ...callOptions, scope }); |
| 546 | } |
| 547 | return run; |
| 548 | }; |
| 549 | |
| 550 | export const destroy = ( |
| 551 | options: MakeOptions, |
| 552 | stack: TestEffect<CompiledStack, Stage | AlchemyContext>, |
| 553 | callOptions?: { |
| 554 | stage?: string; |
| 555 | scope?: Scope.Scope; |
| 556 | include?: never; |
| 557 | exclude?: never; |
| 558 | }, |
| 559 | ) => |
| 560 | _destroy({ |
| 561 | include: callOptions?.include, |
| 562 | exclude: callOptions?.exclude, |
| 563 | stack: stack as Effect.Effect<CompiledStack, never, any>, |
| 564 | stage: callOptions?.stage ?? resolveStage(options), |
| 565 | dev: resolveDev(options), |
| 566 | scope: callOptions?.scope, |
| 567 | }).pipe(Effect.provide(TelemetryLive)); |
| 568 | |
| 569 | /** |
| 570 | * In-test scratch stack handed to `test.provider(name, (stack) => ...)`. |
| 571 | * |
| 572 | * Each scratch stack owns a private state store that is shared between |
| 573 | * successive `deploy`/`destroy` calls AND visible to the user's test body |
| 574 | * (so `yield* State` / `state.get(...)` see the same store the deploys |
| 575 | * mutated). This makes create / update / replace / delete paths exercisable |
| 576 | * without polluting other tests in the same file. |
| 577 | * |
| 578 | * When the adapter can name the test file (the alchemy-test runner), the |
| 579 | * store is DURABLE — rows live under `.alchemy/state/{file}-{test}/{stage}` |
| 580 | * on disk. Durability is what makes an interrupted destroy recoverable: the |
| 581 | * engine persists a `deleting` row before every `provider.delete` and only |
| 582 | * drops it on success, so a run killed mid-delete (e.g. the runner's |
| 583 | * teardown-abandonment after a test timeout) leaves resumable rows that the |
| 584 | * NEXT run's leading `stack.destroy()` (or the `Effect.ensuring` teardown) |
| 585 | * picks up and drains. An in-memory scratch dies with the process, silently |
| 586 | * orphaning every cloud resource whose delete was still in flight. |
| 587 | */ |
| 588 | export interface ScratchStack<ROut = any> { |
| 589 | readonly name: string; |
| 590 | /** Stage this scratch deploys to ({@link resolveStage} of the file options). */ |
| 591 | readonly stage: string; |
| 592 | /** The shared in-memory state Layer for this scratch. @internal */ |
| 593 | readonly state: Layer.Layer<State.State, never, never>; |
| 594 | /** Filters leave other rows and stack outputs untouched; declaration still runs. */ |
| 595 | deploy<A, E, R, Call extends [options?: Plan.FilteredPlanOptions] = []>( |
| 596 | effect: Effect.Effect<A, E, R>, |
| 597 | ...call: Call |
| 598 | ): Effect.Effect< |
| 599 | SelectionOutput<Input.Resolve<A>, Call[0]>, |
| 600 | any, |
| 601 | Exclude<R, ROut | StackServices> |
| 602 | >; |
| 603 | /** |
| 604 | * Build a plan against the scratch's shared state WITHOUT applying it. |
| 605 | * |
| 606 | * Use this to assert on the planned action for a resource (e.g. that a |
| 607 | * downstream dependency stays `noop` when only an upstream resource |
| 608 | * changes) without mutating the cloud. Plans run against whatever state |
| 609 | * prior `deploy(...)` calls persisted. |
| 610 | */ |
| 611 | plan<A, E, R, Call extends [options?: Plan.FilteredPlanOptions] = []>( |
| 612 | effect: Effect.Effect<A, E, R>, |
| 613 | ...call: Call |
| 614 | ): Effect.Effect< |
| 615 | Plan.Plan<SelectionOutput<A, Call[0]>>, |
| 616 | any, |
| 617 | Exclude<R, ROut | StackServices> |
| 618 | >; |
| 619 | destroy(options?: { |
| 620 | include?: never; |
| 621 | exclude?: never; |
| 622 | }): Effect.Effect<void, any, never>; |
| 623 | } |
| 624 | |
| 625 | const sanitizeStackName = (name: string) => |
| 626 | name.replaceAll(/[^a-zA-Z0-9_]/g, "-").replace(/-+/g, "-"); |
| 627 | |
| 628 | /** |
| 629 | * Turn a test-file path (relative to the run root) into a stack-name |
| 630 | * namespace: `test/AWS/Website/Router.test.ts` -> `AWS/Website/Router`. |
| 631 | * Test names repeat across files (e.g. "create and delete bucket with |
| 632 | * default props"), so the durable per-test store MUST be namespaced by file |
| 633 | * or two concurrently-running same-named tests would read, write and — far |
| 634 | * worse — destroy each other's rows. |
| 635 | */ |
| 636 | const scratchNamespace = (file: string) => |
| 637 | file.replace(/^test[/\\]/, "").replace(/\.test\.ts$/, ""); |
| 638 | |
| 639 | /** |
| 640 | * Build a fresh `ScratchStack` for `test.provider`. |
| 641 | * |
| 642 | * With `file` (the registration-time test file, supplied by the |
| 643 | * alchemy-test adapter): the store is the durable `.alchemy/state` local |
| 644 | * store and the stack name is namespaced by file so interrupted destroys |
| 645 | * leave resumable rows for the next run (see {@link ScratchStack}). |
| 646 | * |
| 647 | * Without `file` (bun/vitest adapters, which cannot name their file at |
| 648 | * registration time): falls back to a private in-memory store — isolated, |
| 649 | * but discarded with the process. |
| 650 | */ |
| 651 | export const scratchStack = <ROut>( |
| 652 | options: MakeOptions<ROut>, |
| 653 | name: string, |
| 654 | file?: string, |
| 655 | ): ScratchStack<ROut> => { |
| 656 | const stage = resolveStage(options); |
| 657 | const stackName = sanitizeStackName( |
| 658 | file === undefined ? name : `${scratchNamespace(file)}-${name}`, |
| 659 | ); |
| 660 | const stateLayer: Layer.Layer<State.State> = |
| 661 | file === undefined |
| 662 | ? Layer.succeed(State.State, State.InMemoryService({})) |
| 663 | : Layer.provide(State.localState(), PlatformServices); |
| 664 | |
| 665 | // `withProviders` already pins the test body to Floci, but the stack program |
| 666 | // and its later plan/apply phase run under `AWS.providers()`'s live services. |
| 667 | // Pin both phases separately: Actions execute during apply, after the stack |
| 668 | // program has finished. This override must be inside `compiled.services` so |
| 669 | // Effect's closest-layer precedence selects Floci for Action data-plane calls. |
| 670 | const pinToFloci = <A, E, R>( |
| 671 | effect: Effect.Effect<A, E, R>, |
| 672 | ): Effect.Effect<A, E, R> => |
| 673 | Option.getOrElse(alchemyTestDevOverride(), () => false) |
| 674 | ? (Effect.provide(effect, flociServices()) as Effect.Effect<A, E, R>) |
| 675 | : effect; |
| 676 | |
| 677 | const buildAndApply = ( |
| 678 | effect: Effect.Effect<any, any, any>, |
| 679 | planOptions?: Plan.FilteredPlanOptions, |
| 680 | ) => |
| 681 | (pinToFloci(effect) as Effect.Effect<any, any, never>).pipe( |
| 682 | makeStack({ |
| 683 | name: stackName, |
| 684 | providers: options.providers, |
| 685 | state: stateLayer, |
| 686 | } as any) as any, |
| 687 | Effect.flatMap((compiled: any) => |
| 688 | Plan.make(compiled, planOptions ?? {}).pipe( |
| 689 | Effect.flatMap(apply), |
| 690 | pinToFloci, |
| 691 | Effect.provide(compiled.services), |
| 692 | ), |
| 693 | ), |
| 694 | Effect.provide(Layer.succeed(Stage, stage)), |
| 695 | provideFreshArtifactStore, |
| 696 | ); |
| 697 | |
| 698 | const buildPlan = ( |
| 699 | effect: Effect.Effect<any, any, any>, |
| 700 | planOptions?: Plan.FilteredPlanOptions, |
| 701 | ) => |
| 702 | (pinToFloci(effect) as Effect.Effect<any, any, never>).pipe( |
| 703 | makeStack({ |
| 704 | name: stackName, |
| 705 | providers: options.providers, |
| 706 | state: stateLayer, |
| 707 | } as any) as any, |
| 708 | Effect.flatMap((compiled: any) => |
| 709 | pinToFloci(Plan.make(compiled, planOptions ?? {})).pipe( |
| 710 | Effect.provide(compiled.services), |
| 711 | ), |
| 712 | ), |
| 713 | Effect.provide(Layer.succeed(Stage, stage)), |
| 714 | provideFreshArtifactStore, |
| 715 | ); |
| 716 | |
| 717 | return { |
| 718 | name: stackName, |
| 719 | stage, |
| 720 | state: stateLayer, |
| 721 | deploy: (( |
| 722 | effect: Effect.Effect<any, any, any>, |
| 723 | planOptions?: Plan.FilteredPlanOptions, |
| 724 | ) => buildAndApply(effect, planOptions)) as ScratchStack<ROut>["deploy"], |
| 725 | plan: (( |
| 726 | effect: Effect.Effect<any, any, any>, |
| 727 | planOptions?: Plan.FilteredPlanOptions, |
| 728 | ) => buildPlan(effect, planOptions)) as ScratchStack<ROut>["plan"], |
| 729 | destroy: (callOptions) => |
| 730 | callOptions?.include !== undefined || callOptions?.exclude !== undefined |
| 731 | ? Effect.die( |
| 732 | new Plan.InvalidResourceSelection({ |
| 733 | message: "Filtered destroy is not supported.", |
| 734 | }), |
| 735 | ) |
| 736 | : (Plan.destroy({ name: stackName, stage }).pipe( |
| 737 | Effect.flatMap(apply), |
| 738 | Effect.asVoid, |
| 739 | Effect.provide( |
| 740 | stateLayer.pipe( |
| 741 | Layer.provideMerge( |
| 742 | options.providers as Layer.Layer<any, never, any>, |
| 743 | ), |
| 744 | Layer.provideMerge( |
| 745 | Layer.succeed(Stack, { |
| 746 | name: stackName, |
| 747 | stage, |
| 748 | resources: {}, |
| 749 | bindings: {}, |
| 750 | actions: {}, |
| 751 | }), |
| 752 | ), |
| 753 | Layer.provideMerge(Layer.succeed(Stage, stage)), |
| 754 | ), |
| 755 | ), |
| 756 | provideFreshArtifactStore, |
| 757 | ) as Effect.Effect<void, any, never>), |
| 758 | }; |
| 759 | }; |