1import * as Cloudflare from "alchemy/Cloudflare";
2import * as Test from "alchemy/Test/Bun";
3import { expect } from "bun:test";
4import * as Effect from "effect/Effect";
5import * as Redacted from "effect/Redacted";
6import * as Schedule from "effect/Schedule";
7import * as HttpBody from "effect/http/HttpBody";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import Stack from "../alchemy.run.ts";
11import { WORKFLOW_SECRET_VALUE } from "../src/NotifyWorkflow.ts";
12
13const { 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.
21const stack = beforeAll(deploy(Stack), { timeout: 600_000 });
22
23afterAll.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.
29const { executeWhenReady } = Test;
30
31test(
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 */
46test(
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 */
105test(
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 */
123test(
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 */
225test(
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);