| 1 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 2 | import * as Test from "alchemy/Test/Bun"; |
| 3 | import { expect } from "bun:test"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Redacted from "effect/Redacted"; |
| 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import * as HttpBody from "effect/http/HttpBody"; |
| 8 | import * as HttpClient from "effect/http/HttpClient"; |
| 9 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 10 | import Stack from "../alchemy.run.ts"; |
| 11 | import { WORKFLOW_SECRET_VALUE } from "../src/NotifyWorkflow.ts"; |
| 12 | |
| 13 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 14 | providers: Cloudflare.providers(), |
| 15 | state: Cloudflare.state(), |
| 16 | // dev: true, |
| 17 | }); |
| 18 | |
| 19 | // This stack deploys a Container (Sandbox) whose image build + push can take |
| 20 | // well over the default 120s hook budget, so give deploy/destroy more room. |
| 21 | const stack = beforeAll(deploy(Stack), { timeout: 600_000 }); |
| 22 | |
| 23 | afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), { |
| 24 | timeout: 600_000, |
| 25 | }); |
| 26 | |
| 27 | // A fresh `workers.dev` URL transiently 404s/5xxs while the route propagates; |
| 28 | // `HttpClient.execute` resolves on those, so retry until the worker answers. |
| 29 | const { executeWhenReady } = Test; |
| 30 | |
| 31 | test( |
| 32 | "integ", |
| 33 | Effect.gen(function* () { |
| 34 | const { url } = yield* stack; |
| 35 | |
| 36 | expect(url).toBeString(); |
| 37 | }), |
| 38 | ); |
| 39 | |
| 40 | /** |
| 41 | * Better Auth on D1 (auto-migrated at deploy): sign-up + sign-in through |
| 42 | * the `/auth/*` routes served by `auth.fetch`, asserting a session cookie |
| 43 | * comes back. The assets auth panel (`index.html`) drives the same routes |
| 44 | * from the browser. |
| 45 | */ |
| 46 | test( |
| 47 | "better auth: sign-up and sign-in on D1", |
| 48 | Effect.gen(function* () { |
| 49 | const { url } = yield* stack; |
| 50 | const email = "auth-integ@example.com"; |
| 51 | const password = "password1234"; |
| 52 | |
| 53 | const post = (path: string, body: unknown) => |
| 54 | Effect.tryPromise(async (signal) => { |
| 55 | const response = await fetch(`${url}${path}`, { |
| 56 | method: "POST", |
| 57 | signal, |
| 58 | headers: { "content-type": "application/json" }, |
| 59 | body: JSON.stringify(body), |
| 60 | }); |
| 61 | return { |
| 62 | status: response.status, |
| 63 | body: await response.text(), |
| 64 | cookies: response.headers.getSetCookie(), |
| 65 | }; |
| 66 | }); |
| 67 | |
| 68 | // A leftover user from a prior NO_DESTROY run is fine — sign-in is the |
| 69 | // real assertion. Retries ride out workers.dev propagation. |
| 70 | yield* post("/auth/sign-up/email", { email, password, name: "Integ" }).pipe( |
| 71 | Effect.filterOrFail( |
| 72 | (r) => r.status === 200 || r.body.includes("USER_ALREADY_EXISTS"), |
| 73 | (r) => new Error(`sign-up failed: ${r.status} ${r.body.slice(0, 200)}`), |
| 74 | ), |
| 75 | Effect.retry({ schedule: Schedule.exponential("1 second"), times: 8 }), |
| 76 | ); |
| 77 | |
| 78 | const signIn = yield* post("/auth/sign-in/email", { email, password }); |
| 79 | expect(signIn.status).toBe(200); |
| 80 | expect(signIn.cookies.length).toBeGreaterThan(0); |
| 81 | }), |
| 82 | { timeout: 120_000 }, |
| 83 | ); |
| 84 | |
| 85 | /** |
| 86 | * Regression guard for https://github.com/alchemy-run/alchemy/pull/172 |
| 87 | * |
| 88 | * The stack now includes two Workers (`Api` and `SecondaryApi`) that both |
| 89 | * bind the same `Agent` Durable Object, which in turn binds the `Sandbox` |
| 90 | * Container. Each `yield* Agent` runs the DO's outer init, calling |
| 91 | * `Cloudflare.Container(Sandbox)` once per Worker, so the Sandbox |
| 92 | * ContainerApplication receives two bindings sharing one `namespaceId`. |
| 93 | * |
| 94 | * Before the dedupe fix, `getDurableObjects` counted those as two distinct |
| 95 | * namespaces and the deploy in `beforeAll` died with: |
| 96 | * |
| 97 | * "A Container can only be bound to one Durable Object namespace. |
| 98 | * Found 2 namespaces in bindings: <id>, <id>" |
| 99 | * |
| 100 | * If the deploy ever starts failing again, the whole suite stops at |
| 101 | * `beforeAll` — that is the regression signal. This case just asserts the |
| 102 | * second Worker showed up with a URL so a silent regression that drops the |
| 103 | * binding still surfaces here. |
| 104 | */ |
| 105 | test( |
| 106 | "two workers binding the same container deploy without dedup error", |
| 107 | Effect.gen(function* () { |
| 108 | const { secondaryApiUrl } = yield* stack; |
| 109 | expect(secondaryApiUrl).toBeString(); |
| 110 | }), |
| 111 | ); |
| 112 | |
| 113 | /** |
| 114 | * Regression guard for https://github.com/alchemy-run/alchemy/pull/71 |
| 115 | * |
| 116 | * `NotifyWorkflow` accesses `Cloudflare.Workers.WorkerEnvironment` inside its body and |
| 117 | * performs a KV roundtrip via `env.KV.put` / `env.KV.get`. If the fix from #71 |
| 118 | * is ever reverted, the body Effect loses the `WorkerEnvironment` service and |
| 119 | * dies with `Service not found: Cloudflare.Workers.WorkerEnvironment` on the |
| 120 | * first `yield* Cloudflare.Workers.WorkerEnvironment` — the workflow instance never |
| 121 | * reaches `complete`, and this test times out or surfaces the `errored` status. |
| 122 | */ |
| 123 | test( |
| 124 | "workflow body can access WorkerEnvironment and exercise env bindings", |
| 125 | Effect.gen(function* () { |
| 126 | const { url } = yield* stack; |
| 127 | |
| 128 | interface WorkflowStatus { |
| 129 | status: string; |
| 130 | output?: { secret?: string }; |
| 131 | error?: unknown; |
| 132 | } |
| 133 | |
| 134 | // Start a fresh workflow instance and poll it to a terminal state. A |
| 135 | // freshly-deployed worker occasionally errors a step while its bindings |
| 136 | // are still propagating, so this effect FAILS on any non-complete |
| 137 | // terminal state, letting the outer retry take another swing with a |
| 138 | // brand-new instance rather than flaking on a transient `errored`. |
| 139 | const runOnce = Effect.gen(function* () { |
| 140 | const roomId = `smoke-${Date.now()}`; |
| 141 | |
| 142 | // A freshly-deployed worker transiently 404s (route still propagating) |
| 143 | // or 5xxs (bindings still settling) on the workflow routes, so retry |
| 144 | // through the cold-start window instead of asserting `200` on the first |
| 145 | // hit. Only a non-cold-start status reaches the `expect` below. |
| 146 | const startResponse = yield* executeWhenReady( |
| 147 | HttpClientRequest.post(`${url}/workflow/start/${roomId}`), |
| 148 | ); |
| 149 | expect(startResponse.status).toBe(200); |
| 150 | |
| 151 | const { instanceId } = (yield* startResponse.json) as { |
| 152 | instanceId: string; |
| 153 | }; |
| 154 | expect(instanceId).toBeString(); |
| 155 | |
| 156 | const client = yield* HttpClient.HttpClient; |
| 157 | const lastStatus = yield* client |
| 158 | .get(`${url}/workflow/status/${instanceId}`) |
| 159 | .pipe( |
| 160 | // Only decode JSON on a 200; a transient 5xx (HTML error page) while |
| 161 | // the worker settles is treated as non-terminal so the poll keeps |
| 162 | // swinging instead of dying on a JSON decode error. |
| 163 | Effect.flatMap((res) => |
| 164 | res.status === 200 |
| 165 | ? (res.json as Effect.Effect<unknown, unknown>).pipe( |
| 166 | Effect.map((body) => body as WorkflowStatus), |
| 167 | ) |
| 168 | : Effect.succeed({ status: "pending" } as WorkflowStatus), |
| 169 | ), |
| 170 | Effect.repeat({ |
| 171 | schedule: Schedule.spaced("2 seconds"), |
| 172 | until: (s) => s.status === "complete" || s.status === "errored", |
| 173 | times: 30, |
| 174 | }), |
| 175 | ); |
| 176 | |
| 177 | // Surface a non-complete terminal state as a failure so the outer retry |
| 178 | // can restart with a fresh instance. |
| 179 | if (lastStatus.status !== "complete") { |
| 180 | return yield* Effect.fail( |
| 181 | new Error( |
| 182 | `workflow ${lastStatus.status}: ${JSON.stringify(lastStatus.error)}`, |
| 183 | ), |
| 184 | ); |
| 185 | } |
| 186 | return lastStatus; |
| 187 | }); |
| 188 | |
| 189 | // Cloudflare can briefly route workflow invocations to a worker version |
| 190 | // that predates the final upload (e.g. the pre-create stub), which |
| 191 | // errors instances with "The entrypoint name Notifier was not found in |
| 192 | // this worker" until the deployed version propagates. Each errored |
| 193 | // attempt terminates within a few seconds, so give propagation a |
| 194 | // bounded ~45s of fresh instances rather than 3 swings in 16s. |
| 195 | const lastStatus = yield* runOnce.pipe( |
| 196 | Effect.retry({ schedule: Schedule.spaced("3 seconds"), times: 6 }), |
| 197 | ); |
| 198 | |
| 199 | expect(lastStatus.status).toBe("complete"); |
| 200 | expect(lastStatus.error).toBeFalsy(); |
| 201 | |
| 202 | // Prove the `Alchemy.Secret(...)` bound at plantime made it all the |
| 203 | // way through to the workflow body's runtime read. The workflow body |
| 204 | // unwraps `Redacted.value(secret)` and embeds it in the returned |
| 205 | // `processed` payload. |
| 206 | expect(lastStatus.output?.secret).toBe( |
| 207 | Redacted.value(WORKFLOW_SECRET_VALUE), |
| 208 | ); |
| 209 | }), |
| 210 | { timeout: 120_000 }, |
| 211 | ); |
| 212 | |
| 213 | /** |
| 214 | * Queue producer→consumer round-trip via the Effect-style |
| 215 | * `Cloudflare.Queues.consumeQueueMessages(Queue, handler)` API. |
| 216 | * |
| 217 | * Producer: `POST /queue/send` returns `{ sent: { id, text, sentAt } }` |
| 218 | * after enqueuing a message. |
| 219 | * |
| 220 | * Consumer: the worker's queue() handler (registered via subscribe in |
| 221 | * src/Api.ts) writes the message body to R2 at `/queue/<id>`. The |
| 222 | * route `GET /queue/result/<id>` reads it back. Cloudflare's queue |
| 223 | * dispatch is async and best-effort, so we poll for up to 60s. |
| 224 | */ |
| 225 | test( |
| 226 | "queue producer→consumer round-trip via consumeQueueMessages()", |
| 227 | Effect.gen(function* () { |
| 228 | const { url } = yield* stack; |
| 229 | const text = `hello-${Date.now()}`; |
| 230 | type Message = { id: string; text: string; sentAt: number }; |
| 231 | |
| 232 | const send = Effect.gen(function* () { |
| 233 | const sendResponse = yield* executeWhenReady( |
| 234 | HttpClientRequest.post(`${url}/queue/send`).pipe( |
| 235 | HttpClientRequest.setBody(HttpBody.text(text)), |
| 236 | ), |
| 237 | ); |
| 238 | expect(sendResponse.status).toBe(202); |
| 239 | const { sent } = (yield* sendResponse.json) as { sent: Message }; |
| 240 | expect(sent.id).toBeTypeOf("string"); |
| 241 | return sent; |
| 242 | }); |
| 243 | |
| 244 | const sent: Message[] = [yield* send]; |
| 245 | |
| 246 | // First delivery on a freshly created queue+consumer can lag well past |
| 247 | // the settled steady state (consumer attachment propagates through |
| 248 | // Cloudflare's queue subsystem asynchronously). Poll for ANY of the |
| 249 | // messages we sent, re-sending every ~20s in case an early message was |
| 250 | // published into the propagation window; the consumer persists each |
| 251 | // message to R2 keyed by its id, so the first one to land wins. |
| 252 | let polls = 0; |
| 253 | const findConsumed = Effect.gen(function* () { |
| 254 | polls++; |
| 255 | if (polls % 10 === 0 && sent.length < 5) { |
| 256 | sent.push(yield* send); |
| 257 | } |
| 258 | for (const message of sent) { |
| 259 | const resultResponse = yield* HttpClient.get( |
| 260 | `${url}/queue/result/${message.id}`, |
| 261 | ); |
| 262 | if (resultResponse.status === 200) { |
| 263 | return (yield* resultResponse.json) as Message; |
| 264 | } |
| 265 | } |
| 266 | return undefined; |
| 267 | }); |
| 268 | |
| 269 | const consumed = yield* findConsumed.pipe( |
| 270 | Effect.repeat({ |
| 271 | schedule: Schedule.spaced("2 seconds"), |
| 272 | until: (message) => message !== undefined, |
| 273 | times: 75, |
| 274 | }), |
| 275 | ); |
| 276 | |
| 277 | expect(consumed).toBeDefined(); |
| 278 | expect(sent.map((message) => message.id)).toContain(consumed!.id); |
| 279 | expect(consumed!.text).toBe(text); |
| 280 | |
| 281 | // Clean up the consumed R2 entries so afterAll's stack.destroy() |
| 282 | // can delete the bucket — otherwise Cloudflare rejects the |
| 283 | // bucket delete with "bucket is not empty". |
| 284 | yield* Effect.forEach(sent, (message) => |
| 285 | HttpClient.execute( |
| 286 | HttpClientRequest.make("DELETE")(`${url}/queue/result/${message.id}`), |
| 287 | ), |
| 288 | ); |
| 289 | }), |
| 290 | { timeout: 180_000 }, |
| 291 | ); |