1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import { poll } from "@/Util/poll.ts";
5import * as Test from "@/Test/Alchemy";
6import * as wfp from "@distilled.cloud/cloudflare/workers-for-platforms";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: Cloudflare.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21// The scoped API token the test harness mints propagates eventually-
22// consistently across Cloudflare's edge — ride out 403 blips
23// (`Forbidden`, declared in the distilled error union) on the test's
24// own out-of-band verification calls.
25const getNamespace = (accountId: string, name: string) =>
26 wfp.getDispatchNamespace({ accountId, dispatchNamespace: name }).pipe(
27 Effect.retry({
28 while: (e) => e._tag === "Forbidden",
29 schedule: Schedule.exponential("500 millis"),
30 times: 8,
31 }),
32 );
33
34// A missing namespace surfaces as `DispatchNamespaceNotFound`
35// (Cloudflare error code 100119) — that's the success condition here.
36const expectNamespaceGone = (accountId: string, name: string) =>
37 getNamespace(accountId, name).pipe(
38 Effect.flatMap(() => Effect.fail({ _tag: "NamespaceNotDeleted" } as const)),
39 Effect.catchTag("DispatchNamespaceNotFound", () => Effect.void),
40 Effect.retry({
41 while: (e) => e._tag === "NamespaceNotDeleted",
42 schedule: Schedule.max([
43 Schedule.exponential("500 millis"),
44 Schedule.recurs(10),
45 ]),
46 }),
47 );
48
49const getScript = (accountId: string, namespace: string, scriptName: string) =>
50 wfp
51 .getDispatchNamespaceScript({
52 accountId,
53 dispatchNamespace: namespace,
54 scriptName,
55 })
56 .pipe(
57 Effect.retry({
58 while: (e) => e._tag === "Forbidden",
59 schedule: Schedule.exponential("500 millis"),
60 times: 8,
61 }),
62 );
63
64// A deleted script comes back as a success with `script: null` while its
65// namespace still exists, and as `DispatchNamespaceNotFound` once the
66// namespace itself has been destroyed — both are the success condition.
67const expectScriptGone = (
68 accountId: string,
69 namespace: string,
70 scriptName: string,
71) =>
72 getScript(accountId, namespace, scriptName).pipe(
73 Effect.flatMap((response) =>
74 response.script
75 ? Effect.fail({ _tag: "ScriptNotDeleted" } as const)
76 : Effect.void,
77 ),
78 Effect.catchTag(
79 ["DispatchNamespaceNotFound", "DispatchNamespaceScriptNotFound"],
80 () => Effect.void,
81 ),
82 Effect.retry({
83 while: (e) => e._tag === "ScriptNotDeleted",
84 schedule: Schedule.max([
85 Schedule.exponential("500 millis"),
86 Schedule.recurs(10),
87 ]),
88 }),
89 );
90
91test.provider(
92 "create, replace, and destroy a dispatch namespace",
93 (stack) =>
94 Effect.gen(function* () {
95 const { accountId } = yield* yield* CloudflareEnvironment;
96
97 yield* stack.destroy();
98
99 const namespace = yield* stack.deploy(
100 Cloudflare.WorkersForPlatforms.DispatchNamespace("Namespace", {
101 name: "alchemy-wfp-test-ns",
102 }),
103 );
104
105 expect(namespace.namespaceId).toBeTruthy();
106 expect(namespace.name).toEqual("alchemy-wfp-test-ns");
107 expect(namespace.accountId).toEqual(accountId);
108 expect(namespace.scriptCount).toEqual(0);
109
110 // Read-after-create: a brand-new namespace can briefly 404 from the
111 // out-of-band read under load — ride that out before asserting.
112 const live = yield* getNamespace(accountId, "alchemy-wfp-test-ns").pipe(
113 Effect.retry({
114 while: (e) => e._tag === "DispatchNamespaceNotFound",
115 schedule: Schedule.exponential("500 millis"),
116 times: 8,
117 }),
118 );
119 expect(live.namespaceName).toEqual("alchemy-wfp-test-ns");
120 expect(live.namespaceId).toEqual(namespace.namespaceId);
121
122 // The name is the namespace's identity — changing it must replace.
123 const replaced = yield* stack.deploy(
124 Cloudflare.WorkersForPlatforms.DispatchNamespace("Namespace", {
125 name: "alchemy-wfp-test-ns-v2",
126 }),
127 );
128 expect(replaced.namespaceId).not.toEqual(namespace.namespaceId);
129 expect(replaced.name).toEqual("alchemy-wfp-test-ns-v2");
130 yield* expectNamespaceGone(accountId, "alchemy-wfp-test-ns");
131
132 yield* stack.destroy();
133
134 yield* expectNamespaceGone(accountId, "alchemy-wfp-test-ns-v2");
135 }).pipe(logLevel),
136 {
137 tags: [
138 "provider:cloudflare",
139 "provider:cloudflare:workersforplatforms",
140 "live",
141 ],
142 timeout: 120_000,
143 },
144);
145
146const namespaceName = "alchemy-wfp-test-script-ns";
147const scriptName = "alchemy-wfp-customer-a";
148
149// One program deploying the namespace and a user script into it. The
150// script's props reference the namespace's output name, so the engine
151// orders script-last on deploy (and first on destroy).
152const program = () =>
153 Effect.gen(function* () {
154 const namespace = yield* Cloudflare.WorkersForPlatforms.DispatchNamespace(
155 "ScriptNs",
156 {
157 name: namespaceName,
158 },
159 );
160
161 return { namespace };
162 });
163
164test.provider(
165 "upload, update in place, and destroy a namespace script",
166 (stack) =>
167 Effect.gen(function* () {
168 const { accountId } = yield* yield* CloudflareEnvironment;
169
170 yield* stack.destroy();
171
172 const initial = yield* stack.deploy(program());
173
174 // Changing the module body re-uploads in place — same identity,
175 // new etag.
176 const updated = yield* stack.deploy(program());
177
178 expect(updated.namespace.namespaceId).toEqual(
179 initial.namespace.namespaceId,
180 );
181
182 yield* stack.destroy();
183
184 yield* expectScriptGone(accountId, namespaceName, scriptName);
185 yield* expectNamespaceGone(accountId, namespaceName);
186 }).pipe(logLevel),
187 {
188 tags: [
189 "provider:cloudflare",
190 "provider:cloudflare:workersforplatforms",
191 "live",
192 ],
193 timeout: 120_000,
194 },
195);
196
197const assetsNamespaceName = "alchemy-wfp-assets-test-ns";
198
199// Read-after-create: a just-uploaded script can briefly 404 (or come back
200// with `script: null`) from the out-of-band read — poll (bounded) until the
201// script is visible with `has_assets` set.
202const expectScriptWithAssets = (
203 accountId: string,
204 namespace: string,
205 scriptName: string,
206) =>
207 poll({
208 description: "namespace script is visible with assets",
209 effect: getScript(accountId, namespace, scriptName).pipe(
210 Effect.map((response) => response.script),
211 Effect.catchTag(
212 ["DispatchNamespaceNotFound", "DispatchNamespaceScriptNotFound"],
213 () => Effect.succeed(null),
214 ),
215 ),
216 predicate: (script) => script?.id === scriptName && !!script.hasAssets,
217 schedule: Schedule.max([
218 Schedule.exponential("500 millis"),
219 Schedule.recurs(10),
220 ]),
221 });
222
223// One program deploying the namespace and a user Worker with static assets
224// into it. The Worker's props reference the namespace's output name, so the
225// engine orders the Worker last on deploy (and first on destroy).
226const assetsProgram = (assetsDir: string) =>
227 Effect.gen(function* () {
228 const namespace = yield* Cloudflare.WorkersForPlatforms.DispatchNamespace(
229 "AssetsNs",
230 { name: assetsNamespaceName },
231 );
232 const worker = yield* Cloudflare.Worker("WfpAssetsWorker", {
233 namespace: namespace.name,
234 script: `export default { fetch: () => new Response("ok") };`,
235 assets: assetsDir,
236 });
237 return { namespace, worker };
238 });
239
240// The asset upload session must be created on the dispatch-namespace
241// endpoint — an account-level session's JWT is scoped to the wrong script
242// identity and the namespaced script PUT rejects it, on both the fresh
243// deploy and the changed-asset redeploy.
244test.provider(
245 "uploads assets for a Worker deployed into a dispatch namespace",
246 (stack) =>
247 Effect.gen(function* () {
248 const { accountId } = yield* yield* CloudflareEnvironment;
249 const fs = yield* FileSystem.FileSystem;
250 const path = yield* Path.Path;
251
252 yield* stack.destroy();
253
254 const dir = yield* fs.makeTempDirectory({
255 prefix: "alchemy-wfp-assets-",
256 });
257 yield* fs.writeFileString(
258 path.join(dir, "index.html"),
259 "<html><body>alchemy-wfp-assets-index</body></html>",
260 );
261
262 const initial = yield* stack.deploy(assetsProgram(dir));
263 const workerName = initial.worker.workerName;
264
265 yield* expectScriptWithAssets(accountId, assetsNamespaceName, workerName);
266
267 yield* fs.writeFileString(
268 path.join(dir, "index.html"),
269 "<html><body>alchemy-wfp-assets-index-v2</body></html>",
270 );
271 const updated = yield* stack.deploy(assetsProgram(dir));
272 expect(updated.worker.workerName).toEqual(workerName);
273
274 yield* expectScriptWithAssets(accountId, assetsNamespaceName, workerName);
275
276 yield* stack.destroy();
277
278 yield* expectScriptGone(accountId, assetsNamespaceName, workerName);
279 yield* expectNamespaceGone(accountId, assetsNamespaceName);
280 }).pipe(logLevel),
281 {
282 tags: [
283 "provider:cloudflare",
284 "provider:cloudflare:worker",
285 "provider:cloudflare:workersforplatforms",
286 "live",
287 ],
288 timeout: 240_000,
289 },
290);
291
292test.provider(
293 "list enumerates the deployed dispatch namespace",
294 (stack) =>
295 Effect.gen(function* () {
296 const { accountId } = yield* yield* CloudflareEnvironment;
297
298 yield* stack.destroy();
299
300 const deployed = yield* stack.deploy(
301 Cloudflare.WorkersForPlatforms.DispatchNamespace("ListNs", {
302 name: "alchemy-wfp-test-list-ns",
303 }),
304 );
305
306 const provider = yield* Provider.findProvider(
307 Cloudflare.WorkersForPlatforms.DispatchNamespace,
308 );
309
310 // A just-created namespace can lag the account-wide list under load —
311 // poll until it shows up (bounded) instead of asserting immediately.
312 const all = yield* poll({
313 description: "list() includes the deployed dispatch namespace",
314 effect: provider.list(),
315 predicate: (all) =>
316 all.some((ns) => ns.namespaceId === deployed.namespaceId),
317 schedule: Schedule.max([
318 Schedule.spaced("2 seconds"),
319 Schedule.recurs(20),
320 ]),
321 });
322
323 const match = all.find((ns) => ns.namespaceId === deployed.namespaceId);
324 expect(match).toBeDefined();
325 expect(match?.name).toEqual("alchemy-wfp-test-list-ns");
326 expect(match?.accountId).toEqual(accountId);
327
328 yield* stack.destroy();
329
330 yield* expectNamespaceGone(accountId, "alchemy-wfp-test-list-ns");
331 }).pipe(logLevel),
332 {
333 tags: [
334 "provider:cloudflare",
335 "provider:cloudflare:workersforplatforms",
336 "live",
337 ],
338 timeout: 120_000,
339 },
340);