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 `QuerySearchLocal` — the current-credentials HTTP implementation of
19// the `QuerySearch` binding — from inside an Action. A web-crawler instance
20// needs no service token (unlike an R2 source), so teardown stays fast. The
21// Action reads instance metadata (`info`) and indexing stats (`stats`), both
22// available immediately after create (no waiting on async indexing), proving
23// the client talks to the live AI Search REST API with the current creds.
24test.provider(
25 "QuerySearchLocal: read instance info/stats from an Action",
26 (stack) =>
27 Effect.gen(function* () {
28 yield* stack.destroy();
29
30 // Warm the crawl target first — AI Search fetches the seed synchronously
31 // when it validates a web-crawler 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 instance = yield* Cloudflare.AI.SearchInstance("Search", {
51 type: "web-crawler",
52 source: target.url.as<string>(),
53 sourceParams: {
54 webCrawler: {
55 // Cloudflare renamed `crawl` → `discover` and removed
56 // `crawlOptions` from the API. The fixture serves a
57 // sitemap, so discovery always finds content.
58 parseType: "discover",
59 },
60 },
61 });
62
63 const Probe = Action(
64 "Probe",
65 Effect.gen(function* () {
66 const search = yield* Cloudflare.AI.QuerySearch(instance);
67 const instanceId = yield* instance.instanceId;
68 const namespace = yield* instance.namespace;
69
70 return Effect.fn(function* () {
71 const info = yield* search.info().pipe(
72 Effect.retry({
73 schedule: Schedule.exponential("500 millis"),
74 times: 6,
75 }),
76 );
77 const stats = yield* search.stats().pipe(
78 Effect.retry({
79 schedule: Schedule.exponential("500 millis"),
80 times: 6,
81 }),
82 );
83 return {
84 instanceId: yield* instanceId,
85 namespace: yield* namespace,
86 info,
87 stats,
88 };
89 });
90 }).pipe(Effect.provide(Cloudflare.AI.QuerySearchLocal)),
91 );
92
93 return yield* Probe({});
94 }),
95 );
96
97 expect(out.instanceId).toBeTruthy();
98 // `info()` round-trips the AI Search REST read through the current creds.
99 expect(out.info.id).toEqual(out.instanceId);
100 expect(out.info.type).toEqual("web-crawler");
101 expect(out.info.namespace).toEqual(out.namespace);
102 // `stats()` returns the indexing status counts object.
103 expect(out.stats).toBeTypeOf("object");
104
105 yield* stack.destroy();
106 }).pipe(logLevel),
107 {
108 tags: [
109 "provider:cloudflare",
110 "provider:cloudflare:ai",
111 "provider:cloudflare:worker",
112 "live",
113 ],
114 timeout: 300_000,
115 },
116);