1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import { MinimumLogLevel } from "effect/References";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import Stack from "./fixtures/bindings-stack.ts";
9
10const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
11 providers: Cloudflare.providers(),
12});
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19const stack = beforeAll(deploy(Stack), { timeout: 120_000 });
20afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), { timeout: 120_000 });
21
22// Deploying the Worker succeeding at all proves Cloudflare accepted both the
23// `ai_search` and `ai_search_namespace` bindings. The `/bindings` route then
24// confirms they are injected and shaped correctly at runtime.
25test(
26 "worker deploys with ai_search + ai_search_namespace bindings injected",
27 Effect.gen(function* () {
28 const { url } = yield* stack;
29 expect(url).toBeTypeOf("string");
30
31 const client = yield* HttpClient.HttpClient;
32 const res = yield* client.get(`${url}/bindings`).pipe(
33 Effect.flatMap((res) =>
34 res.status === 200
35 ? Effect.succeed(res)
36 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
37 ),
38 // Cap the backoff at 3s so a fresh worker that takes a while to start
39 // serving 200s keeps getting polled steadily, rather than the
40 // unbounded exponential delay overshooting the test timeout.
41 Effect.retry({
42 schedule: Schedule.min([
43 Schedule.exponential("500 millis"),
44 Schedule.spaced("3 seconds"),
45 ]),
46 times: 40,
47 }),
48 );
49
50 const body = (yield* res.json) as {
51 search: string;
52 searchChatCompletions: string;
53 searchSearch: string;
54 ns: string;
55 nsGet: string;
56 };
57
58 // Single-instance `ai_search` binding resolves to an `AiSearchInstance`
59 // object exposing `search()` / `chatCompletions()`.
60 expect(body.search).toBe("object");
61 expect(body.searchChatCompletions).toBe("function");
62 expect(body.searchSearch).toBe("function");
63 // `ai_search_namespace` binding resolves to an `AiSearchNamespace` with
64 // `.get()`.
65 expect(body.ns).toBe("object");
66 expect(body.nsGet).toBe("function");
67 }).pipe(logLevel),
68 {
69 tags: [
70 "provider:cloudflare",
71 "provider:cloudflare:ai",
72 "provider:cloudflare:r2",
73 "provider:cloudflare:worker",
74 "live",
75 ],
76 timeout: 240_000,
77 },
78);
79
80// The Effect worker attaches the same two binding flavors via
81// `Cloudflare.AI.Search.Search(...)` / `Cloudflare.AI.SearchNamespace(...)`
82// and reads them through the Effect-native client. Resolving each client's
83// `raw` runtime handle proves the Effect-first path wires through to the
84// live runtime bindings.
85test(
86 "effect worker resolves ai_search + ai_search_namespace via Effect clients",
87 Effect.gen(function* () {
88 const { effectUrl } = yield* stack;
89 expect(effectUrl).toBeTypeOf("string");
90
91 const client = yield* HttpClient.HttpClient;
92 const res = yield* client.get(`${effectUrl}/bindings`).pipe(
93 Effect.flatMap((res) =>
94 res.status === 200
95 ? Effect.succeed(res)
96 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
97 ),
98 // Cap the backoff at 3s so a fresh worker that takes a while to start
99 // serving 200s keeps getting polled steadily, rather than the
100 // unbounded exponential delay overshooting the test timeout.
101 Effect.retry({
102 schedule: Schedule.min([
103 Schedule.exponential("500 millis"),
104 Schedule.spaced("3 seconds"),
105 ]),
106 times: 40,
107 }),
108 );
109
110 const body = (yield* res.json) as {
111 mode: string;
112 searchRaw: string;
113 searchChatCompletions: string;
114 searchSearch: string;
115 nsRaw: string;
116 nsChatCompletions: string;
117 };
118
119 expect(body.mode).toBe("effect");
120 // `Cloudflare.AI.Search.Search(...).raw` resolves to the runtime
121 // `AiSearchInstance` exposing `search()` / `chatCompletions()`.
122 expect(body.searchRaw).toBe("object");
123 expect(body.searchChatCompletions).toBe("function");
124 expect(body.searchSearch).toBe("function");
125 // `Cloudflare.AI.SearchNamespace(...).raw` resolves to the runtime
126 // `AiSearchNamespace`; `.get(name)` scopes to an instance exposing
127 // `chatCompletions()`. The namespace handle may be a callable runtime
128 // proxy (`typeof` `"function"`) or an object.
129 expect(["object", "function"]).toContain(body.nsRaw);
130 expect(body.nsChatCompletions).toBe("function");
131 }).pipe(logLevel),
132 {
133 tags: [
134 "provider:cloudflare",
135 "provider:cloudflare:ai",
136 "provider:cloudflare:r2",
137 "provider:cloudflare:worker",
138 "live",
139 ],
140 timeout: 240_000,
141 },
142);