1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import * as pathe from "pathe";
12
13// `dev: true` runs local providers behind the RPC sidecar proxy by default,
14// matching the process topology of the real `alchemy dev` command.
15const { test } = Test.make({
16 providers: Cloudflare.providers(),
17 dev: true,
18});
19
20const logLevel = Effect.provideService(
21 MinimumLogLevel,
22 process.env.DEBUG ? "Debug" : "Info",
23);
24
25/** 8x4 solid red PNG (checked-in fixture, generated once with Sharp). */
26const PNG_RED_8X4 = Uint8Array.from(
27 Buffer.from(
28 "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAECAIAAAA8r+mnAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVR4nGP4z8CAFWEXJUsCAFpeH+EeQoQoAAAAAElFTkSuQmCC",
29 "base64",
30 ),
31);
32
33class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
34 status: number;
35 body: string;
36}> {}
37
38/**
39 * POST bytes to a fixture route, retrying until the worker serves a 200
40 * (fresh dev workers can briefly 503 while workerd boots). Non-200s carry the
41 * body so a persistent fixture error surfaces.
42 */
43const postBytesReady = (url: string, bytes: Uint8Array) =>
44 Effect.gen(function* () {
45 const client = yield* HttpClient.HttpClient;
46 return yield* client
47 .execute(
48 HttpClientRequest.post(url).pipe(
49 HttpClientRequest.bodyUint8Array(bytes),
50 ),
51 )
52 .pipe(
53 Effect.flatMap((res) =>
54 res.status === 200
55 ? Effect.succeed(res)
56 : res.text.pipe(
57 Effect.flatMap((body) =>
58 Effect.fail(new WorkerNotReady({ status: res.status, body })),
59 ),
60 ),
61 ),
62 Effect.retry({
63 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
64 schedule: Schedule.max([
65 Schedule.min([
66 Schedule.exponential("500 millis"),
67 Schedule.spaced("2 seconds"),
68 ]),
69 Schedule.recurs(10),
70 ]),
71 }),
72 );
73 }).pipe(Effect.orDie);
74
75const fixtureMain = pathe.resolve(
76 import.meta.dirname,
77 "fixtures/local-worker.ts",
78);
79
80interface InfoResponse {
81 format: string;
82 fileSize: number;
83 width: number;
84 height: number;
85}
86
87/**
88 * Under `alchemy dev` the `images` binding is emulated by cloudflare-runtime:
89 * the wrapped `cloudflare-internal:images-api` module targets a local service
90 * that runs pixel work (resize/rotate/transcode) via Sharp on this machine —
91 * no Cloudflare credentials or preview session are involved for the binding.
92 */
93test.provider(
94 "images binding round-trips against the local Sharp simulator",
95 (stack) =>
96 Effect.gen(function* () {
97 yield* stack.destroy();
98
99 const deployed = yield* stack.deploy(
100 Effect.gen(function* () {
101 const worker = yield* Cloudflare.Worker("images-local-worker", {
102 main: fixtureMain,
103 env: { IMAGES: Cloudflare.Images.Images("IMAGES") },
104 });
105 return { url: worker.url.as<string>() };
106 }),
107 );
108
109 // Served from the local dev proxy — proof the worker runs locally.
110 expect(deployed.url).toMatch(/^http:\/\/localhost:\d+$/);
111
112 // info() reads format and dimensions through the local simulator.
113 const infoRes = yield* postBytesReady(
114 `${deployed.url}/info`,
115 PNG_RED_8X4,
116 );
117 const info = (yield* infoRes.json) as unknown as InfoResponse;
118 expect(info.format).toBe("image/png");
119 expect(info.width).toBe(8);
120 expect(info.height).toBe(4);
121
122 // input().transform({ width }).output() resizes via Sharp; the output
123 // is a real PNG (magic bytes)...
124 const transformRes = yield* postBytesReady(
125 `${deployed.url}/transform?width=4&format=image/png`,
126 PNG_RED_8X4,
127 );
128 expect(transformRes.headers["content-type"]).toBe("image/png");
129 const outputBytes = new Uint8Array(yield* transformRes.arrayBuffer);
130 expect(Array.from(outputBytes.slice(0, 4))).toEqual([
131 0x89, 0x50, 0x4e, 0x47,
132 ]);
133
134 // ...and info() on the output confirms the resize (contain-fit keeps
135 // the 2:1 aspect ratio: 8x4 -> 4x2).
136 const outputInfoRes = yield* postBytesReady(
137 `${deployed.url}/info`,
138 outputBytes,
139 );
140 const outputInfo = (yield* outputInfoRes.json) as unknown as InfoResponse;
141 expect(outputInfo.format).toBe("image/png");
142 expect(outputInfo.width).toBe(4);
143 expect(outputInfo.height).toBe(2);
144
145 // GIF output surfaces the documented local-mode 415 (code 9520).
146 const gifRes = yield* postBytesReady(
147 `${deployed.url}/transform?format=image/gif`,
148 PNG_RED_8X4,
149 );
150 const gifBody = (yield* gifRes.json) as {
151 error: boolean;
152 code: number;
153 message: string;
154 };
155 expect(gifBody.error).toBe(true);
156 expect(gifBody.code).toBe(9520);
157 expect(gifBody.message).toContain(
158 "GIF output is not supported in local mode",
159 );
160
161 yield* stack.destroy();
162 }).pipe(logLevel),
163 {
164 tags: [
165 "provider:cloudflare",
166 "provider:cloudflare:images",
167 "provider:cloudflare:worker",
168 "local",
169 ],
170 timeout: 180_000,
171 },
172);
173
174/**
175 * `Alchemy.remote()` opts the binding OUT of local emulation: the
176 * locally-served worker drives the real Images service through the
177 * remote-bindings preview session. The live suite
178 * (test/Cloudflare/Images/Images.test.ts) covers deployed-binding behavior;
179 * this leg pins the dev-mode remote lowering path.
180 */
181test.provider(
182 "Alchemy.remote() proxies the binding to the real Images service",
183 (stack) =>
184 Effect.gen(function* () {
185 yield* stack.destroy();
186
187 const deployed = yield* stack.deploy(
188 Effect.gen(function* () {
189 const worker = yield* Cloudflare.Worker("images-remote-worker", {
190 main: fixtureMain,
191 env: {
192 IMAGES: Cloudflare.Images.Images("IMAGES").pipe(Alchemy.remote()),
193 },
194 });
195 return { url: worker.url.as<string>() };
196 }),
197 );
198
199 // Still served locally — only the binding is remote.
200 expect(deployed.url).toMatch(/^http:\/\/localhost:\d+$/);
201
202 const infoRes = yield* postBytesReady(
203 `${deployed.url}/info`,
204 PNG_RED_8X4,
205 );
206 const info = (yield* infoRes.json) as unknown as InfoResponse;
207 expect(info.format).toBe("image/png");
208 expect(info.width).toBe(8);
209 expect(info.height).toBe(4);
210
211 yield* stack.destroy();
212 }).pipe(logLevel),
213 {
214 tags: [
215 "provider:cloudflare",
216 "provider:cloudflare:images",
217 "provider:cloudflare:worker",
218 "live",
219 ],
220 timeout: 180_000,
221 },
222);