1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Data from "effect/Data";
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 * as pathe from "pathe";
10
11// Mirrors the marker logged by `fixtures/tail/tail-producer.ts` on every
12// request. Duplicated (not imported) because the fixture's default export
13// must be its only export — extra named exports become workerd top-level
14// exports and fail startup validation.
15const TAIL_MARKER = "alchemy-local-tail-marker";
16
17// `dev: true` runs local providers behind the RPC sidecar proxy by default,
18// matching the process topology of the real `alchemy dev` command.
19const { test } = Test.make({
20 providers: Cloudflare.providers(),
21 dev: true,
22});
23
24const logLevel = Effect.provideService(
25 MinimumLogLevel,
26 process.env.DEBUG ? "Debug" : "Info",
27);
28
29class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
30 status: number;
31}> {}
32
33/** GET a route, retrying until the freshly started workerd serves a 200. */
34const getTextReady = (url: string) =>
35 Effect.gen(function* () {
36 const client = yield* HttpClient.HttpClient;
37 const res = yield* client.get(url).pipe(
38 Effect.flatMap((res) =>
39 res.status === 200
40 ? Effect.succeed(res)
41 : Effect.fail(new WorkerNotReady({ status: res.status })),
42 ),
43 Effect.retry({
44 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
45 schedule: Schedule.max([
46 Schedule.min([
47 Schedule.exponential("500 millis"),
48 Schedule.spaced("2 seconds"),
49 ]),
50 Schedule.recurs(10),
51 ]),
52 }),
53 );
54 return yield* res.text;
55 }).pipe(Effect.orDie);
56
57/**
58 * Under `alchemy dev` a Worker's `tailConsumers` lower into workerd's native
59 * `tails` service designators: each consumer script name resolves through
60 * the dev registry proxy — the same path cross-worker service bindings take —
61 * and the producer's trace events are delivered to the consumer's `tail()`
62 * handler after each response, exactly like the deployed `tail_consumers`
63 * setting. The consumer records each batch into a locally-emulated KV
64 * namespace and serves it back on `/events`, so this exercises the full
65 * local roundtrip with zero cloud calls.
66 */
67test.provider(
68 "local producer delivers trace events to its local tail consumer",
69 (stack) =>
70 Effect.gen(function* () {
71 yield* stack.destroy();
72
73 const deployed = yield* stack.deploy(
74 Effect.gen(function* () {
75 const events = yield* Cloudflare.KV.Namespace("TailEvents");
76 const consumer = yield* Cloudflare.Worker("TailConsumer", {
77 main: pathe.resolve(
78 import.meta.dirname,
79 "fixtures/tail/tail-consumer.ts",
80 ),
81 env: { EVENTS: events },
82 });
83 const producer = yield* Cloudflare.Worker("TailProducer", {
84 main: pathe.resolve(
85 import.meta.dirname,
86 "fixtures/tail/tail-producer.ts",
87 ),
88 tailConsumers: [consumer],
89 });
90 return { events, consumer, producer };
91 }),
92 );
93
94 // Dev markers: the KV namespace is locally emulated (no cloud call
95 // ran), and both workers serve from the local dev proxy.
96 expect(deployed.events.namespaceId).toMatch(/^dev:/);
97 expect(deployed.consumer.url).toMatch(/^http:\/\/localhost:\d+$/);
98 expect(deployed.producer.url).toMatch(/^http:\/\/localhost:\d+$/);
99
100 // Attribute parity with the live provider: the consumer is recorded
101 // by script name.
102 expect(deployed.producer.tailConsumers).toEqual([
103 { service: deployed.consumer.workerName },
104 ]);
105
106 // Readiness probes for both freshly started workerds.
107 expect(yield* getTextReady(deployed.consumer.url!)).toBe(
108 "tail-consumer-ok",
109 );
110 expect(yield* getTextReady(deployed.producer.url!)).toBe("producer-ok");
111
112 // Drive the producer and poll the consumer's read route until a trace
113 // batch carrying the producer's log marker arrives. Delivery is
114 // near-immediate once both are registered, but the producer's registry
115 // proxy may pick the consumer up asynchronously — each poll re-invokes
116 // the producer so early dropped events don't strand the test.
117 const client = yield* HttpClient.HttpClient;
118 const batches = yield* Effect.gen(function* () {
119 yield* client.get(deployed.producer.url!).pipe(
120 Effect.flatMap((res) => res.text),
121 Effect.orDie,
122 );
123 const res = yield* client
124 .get(`${deployed.consumer.url}/events`)
125 .pipe(Effect.orDie);
126 const body = (yield* res.json.pipe(Effect.orDie)) as {
127 batches?: unknown;
128 };
129 return Array.isArray(body.batches) ? (body.batches as string[]) : [];
130 }).pipe(
131 Effect.repeat({
132 schedule: Schedule.spaced("1 second"),
133 until: (batches): boolean =>
134 batches.some((batch) => batch.includes(TAIL_MARKER)),
135 times: 20,
136 }),
137 );
138
139 const batch = batches.find((batch) => batch.includes(TAIL_MARKER));
140 expect(batch).toBeDefined();
141 // The recorded batch is the JSON-serialized TraceItem array from the
142 // producer invocation — outcome + the console.log marker.
143 const items = JSON.parse(batch!) as {
144 outcome?: string;
145 logs?: { message?: unknown[] }[];
146 }[];
147 expect(
148 items.some((item) =>
149 item.logs?.some((log) => log.message?.includes(TAIL_MARKER)),
150 ),
151 ).toBe(true);
152
153 yield* stack.destroy();
154 }).pipe(logLevel),
155 {
156 tags: [
157 "provider:cloudflare",
158 "provider:cloudflare:kv",
159 "provider:cloudflare:worker",
160 "local",
161 ],
162 timeout: 180_000,
163 },
164);