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 CronTestWorker from "./fixtures/cron/cron-worker.ts";
10
11// `dev: true` runs local providers behind the RPC sidecar proxy by default,
12// matching the process topology of the real `alchemy dev` command.
13const { test } = Test.make({
14 providers: Cloudflare.providers(),
15 dev: true,
16});
17
18const logLevel = Effect.provideService(
19 MinimumLogLevel,
20 process.env.DEBUG ? "Debug" : "Info",
21);
22
23class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
24 status: number;
25}> {}
26
27/**
28 * Under `alchemy dev` the Worker's cron triggers are wired into the local
29 * runtime: cloudflare-runtime starts a Node-side timer per expression AND
30 * exposes Miniflare's manual trigger route
31 * (`/cdn-cgi/handler/scheduled?cron=...&time=...`) on the local worker URL.
32 *
33 * The local timer for `* * * * *` only fires on minute boundaries, so this
34 * test drives the manual route instead of waiting — the route exercises the
35 * exact same entry-worker -> user-worker `scheduled()` dispatch the timer
36 * uses (the timer itself is covered in cloudflare-runtime's own suite,
37 * `test/globals/Scheduled.test.ts`).
38 */
39test.provider(
40 "local cron worker fires scheduled() via the manual trigger route",
41 (stack) =>
42 Effect.gen(function* () {
43 yield* stack.destroy();
44
45 const deployed = yield* stack.deploy(
46 Effect.gen(function* () {
47 const worker = yield* CronTestWorker;
48 return { worker };
49 }),
50 );
51
52 // The dev URL is the local proxy — proof no cloud deploy ran.
53 expect(deployed.worker.url).toMatch(/^http:\/\/localhost:\d+$/);
54 // The cron registered by the `cron()` event source is recorded on the
55 // local attributes, exactly like the live provider records it.
56 expect(deployed.worker.crons).toContain("* * * * *");
57
58 const url = deployed.worker.url;
59 const client = yield* HttpClient.HttpClient;
60
61 // Reset the counter DO. Doubles as a readiness probe for the first
62 // request against a freshly started workerd.
63 yield* Effect.gen(function* () {
64 const res = yield* client.post(`${url}/reset`);
65 if (res.status !== 200) {
66 return yield* Effect.fail(new WorkerNotReady({ status: res.status }));
67 }
68 }).pipe(
69 Effect.retry({
70 schedule: Schedule.exponential("500 millis"),
71 times: 10,
72 }),
73 );
74
75 // Trigger the fire manually. `cron` must match the registered
76 // expression exactly — the runtime bridge dispatches scheduled events
77 // to listeners by expression.
78 const scheduledTime = Date.now();
79 const trigger = yield* client.post(
80 `${url}/cdn-cgi/handler/scheduled?cron=${encodeURIComponent("* * * * *")}&time=${scheduledTime}`,
81 );
82 expect(trigger.status).toBe(200);
83 expect(yield* trigger.text).toBe("ok");
84
85 // The handler records `controller.scheduledTime` on the DO; poll the
86 // worker's /times route until the fire shows up (bounded).
87 const times = yield* Effect.gen(function* () {
88 const res = yield* client.get(`${url}/times`);
89 if (res.status !== 200) return [];
90 const body = (yield* res.json) as { times?: unknown };
91 return Array.isArray(body.times) ? body.times : [];
92 }).pipe(
93 Effect.catch(() => Effect.succeed([] as unknown[])),
94 Effect.repeat({
95 schedule: Schedule.spaced("500 millis"),
96 until: (times): boolean => times.length > 0,
97 times: 10,
98 }),
99 );
100
101 expect(times).toContain(scheduledTime);
102
103 yield* stack.destroy();
104 }).pipe(logLevel),
105 {
106 tags: ["provider:cloudflare", "provider:cloudflare:worker", "local"],
107 timeout: 180_000,
108 },
109);