1import { Action } from "@/Action";
2import * as Cloudflare from "@/Cloudflare";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import AiSearchCrawlTargetWorker from "./fixtures/crawl-target-worker.ts";
10
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// Exercise `QuerySearchNamespaceLocal` — the current-credentials HTTP
19// implementation of the `QuerySearchNamespace` binding — from inside an Action.
20// A custom namespace holds a web-crawler instance (no service token, fast
21// teardown). The Action lists the namespace's instances and reads one back via
22// `.get(instanceId).info()`, proving the namespace client talks to the live AI
23// Search REST API with the current creds.
24test.provider(
25 "QuerySearchNamespaceLocal: list + get an instance from an Action",
26 (stack) =>
27 Effect.gen(function* () {
28 yield* stack.destroy();
29
30 // Warm the crawl target first — AI Search validates a web-crawler seed
31 // synchronously at create time.
32 const warmed = yield* stack.deploy(
33 Effect.gen(function* () {
34 return yield* AiSearchCrawlTargetWorker;
35 }),
36 );
37 const client = yield* HttpClient.HttpClient;
38 yield* client.get(warmed.url as string).pipe(
39 Effect.flatMap((res) =>
40 res.status === 200
41 ? Effect.succeed(res)
42 : Effect.fail(new Error(`crawl target not ready: ${res.status}`)),
43 ),
44 Effect.retry({ schedule: Schedule.spaced("3 seconds"), times: 20 }),
45 );
46
47 const out = yield* stack.deploy(
48 Effect.gen(function* () {
49 const target = yield* AiSearchCrawlTargetWorker;
50 const namespace = yield* Cloudflare.AI.SearchNamespace(
51 "SearchNs",
52 {},
53 );
54 const instance = yield* Cloudflare.AI.SearchInstance("Search", {
55 type: "web-crawler",
56 source: target.url.as<string>(),
57 namespace: namespace.name,
58 sourceParams: {
59 webCrawler: {
60 // Cloudflare renamed `crawl` → `discover` and removed
61 // `crawlOptions` from the API. The fixture serves a
62 // sitemap, so discovery always finds content.
63 parseType: "discover",
64 },
65 },
66 });
67
68 const Probe = Action(
69 "Probe",
70 Effect.gen(function* () {
71 const ns = yield* Cloudflare.AI.QuerySearchNamespace(namespace);
72 const instanceId = yield* instance.instanceId;
73
74 return Effect.fn(function* () {
75 const id = yield* instanceId;
76 const list = yield* ns.list().pipe(
77 Effect.retry({
78 schedule: Schedule.exponential("500 millis"),
79 times: 6,
80 }),
81 );
82 const info = yield* ns
83 .get(id)
84 .info()
85 .pipe(
86 Effect.retry({
87 schedule: Schedule.exponential("500 millis"),
88 times: 6,
89 }),
90 );
91 return {
92 instanceId: id,
93 ids: list.result.map((i) => i.id),
94 info,
95 };
96 });
97 }).pipe(Effect.provide(Cloudflare.AI.QuerySearchNamespaceLocal)),
98 );
99
100 return yield* Probe({});
101 }),
102 );
103
104 expect(out.instanceId).toBeTruthy();
105 // `list()` enumerates the namespace's instances via the current creds.
106 expect(out.ids).toContain(out.instanceId);
107 // `.get(id).info()` scopes a single-instance read to this namespace.
108 expect(out.info.id).toEqual(out.instanceId);
109 expect(out.info.type).toEqual("web-crawler");
110
111 yield* stack.destroy();
112 }).pipe(logLevel),
113 {
114 tags: [
115 "provider:cloudflare",
116 "provider:cloudflare:ai",
117 "provider:cloudflare:worker",
118 "live",
119 ],
120 timeout: 300_000,
121 },
122);