1import { adopt } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as images from "@distilled.cloud/cloudflare/images";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// Out-of-band read of a variant, riding out eventual-consistency blips
20// where the freshly created variant is not yet visible (`VariantNotFound`,
21// Cloudflare error code 5401).
22const getVariant = (
23 accountId: string,
24 variantId: string,
25 // Optional read-after-write predicate. Cloudflare's variant GET is
26 // eventually consistent, so an immediate read after a create/PATCH can
27 // return stale options; keep polling until the observed variant satisfies
28 // this before asserting on it.
29 until?: (res: images.GetV1VariantResponse) => boolean,
30) =>
31 images.getV1Variant({ accountId, variantId }).pipe(
32 Effect.flatMap((res) =>
33 !until || until(res)
34 ? Effect.succeed(res)
35 : Effect.fail({ _tag: "VariantStale" as const }),
36 ),
37 Effect.retry({
38 // Ride out both "not yet visible" (`VariantNotFound`) and "visible but
39 // stale options" (`VariantStale`) read-after-write windows. Cloudflare's
40 // variant GET is served from an eventually-consistent read replica that,
41 // under full-suite parallel load, can lag a freshly created/patched
42 // variant well past a minute — so the budget is generous (~90s).
43 while: (e) => e._tag === "VariantNotFound" || e._tag === "VariantStale",
44 // Steady spacing (fixed, not exponential — an uncapped exponential
45 // reaches a 64s single delay by the 8th retry and blows the timeout).
46 // Polls every 3s rather than every 2s: under full-suite parallel load
47 // tighter polling only adds to the API request pressure that stretches
48 // these consistency windows in the first place.
49 schedule: Schedule.max([
50 Schedule.fixed("3 seconds"),
51 Schedule.recurs(30),
52 ]),
53 }),
54 );
55
56// Poll until the variant is gone — a missing variant surfaces as the typed
57// `VariantNotFound`, which is the success condition here.
58const expectGone = (accountId: string, variantId: string) =>
59 images.getV1Variant({ accountId, variantId }).pipe(
60 Effect.flatMap(() => Effect.fail({ _tag: "VariantNotDeleted" } as const)),
61 Effect.catchTag("VariantNotFound", () => Effect.void),
62 Effect.retry({
63 while: (e) => e._tag === "VariantNotDeleted",
64 // Bounded fixed spacing (~30s) — see `getVariant` on why the previous
65 // uncapped exponential could run past the test timeout.
66 schedule: Schedule.max([
67 Schedule.fixed("2 seconds"),
68 Schedule.recurs(15),
69 ]),
70 }),
71 );
72
73test.provider.skipIf(!!process.env.FAST)(
74 "create, update in place, and delete a variant",
75 (stack) =>
76 Effect.gen(function* () {
77 const { accountId } = yield* yield* CloudflareEnvironment;
78
79 yield* stack.destroy();
80
81 // Variant names are alphanumeric only (no hyphens/underscores) — use
82 // the logical ID verbatim as the deterministic name.
83 //
84 // Adopt: variants carry no ownership markers, so a variant left in the
85 // cloud by a previously crashed run (state never persisted) is
86 // indistinguishable from a foreign one and `read` reports it `Unowned`.
87 // These tests use fixed names and always run the same way, so take over
88 // any such leftover rather than failing with `OwnedBySomeoneElse`.
89 const created = yield* stack.deploy(
90 Cloudflare.Images.Variant("alchemytestvariant", {
91 fit: "cover",
92 width: 100,
93 height: 100,
94 }).pipe(adopt(true)),
95 );
96
97 expect(created.variantName).toEqual("alchemytestvariant");
98 expect(created.accountId).toEqual(accountId);
99 expect(created.fit).toEqual("cover");
100 expect(created.width).toEqual(100);
101 expect(created.height).toEqual(100);
102 expect(created.metadata).toEqual("none");
103 expect(created.neverRequireSignedURLs).toEqual(false);
104
105 // Out-of-band verification against the live API (ride out read-after-write
106 // lag until the created options are observable).
107 const live = yield* getVariant(
108 accountId,
109 created.variantName,
110 (r) =>
111 r.variant?.options.fit === "cover" &&
112 r.variant?.options.width === 100,
113 );
114 expect(live.variant?.id).toEqual("alchemytestvariant");
115 expect(live.variant?.options.fit).toEqual("cover");
116 expect(live.variant?.options.width).toEqual(100);
117 expect(live.variant?.options.height).toEqual(100);
118
119 // Update mutable options in place — same variant name, PATCHed options.
120 const updated = yield* stack.deploy(
121 Cloudflare.Images.Variant("alchemytestvariant", {
122 fit: "contain",
123 width: 200,
124 height: 100,
125 metadata: "copyright",
126 neverRequireSignedURLs: true,
127 }).pipe(adopt(true)),
128 );
129
130 expect(updated.variantName).toEqual("alchemytestvariant");
131 expect(updated.fit).toEqual("contain");
132 expect(updated.width).toEqual(200);
133 expect(updated.height).toEqual(100);
134 expect(updated.metadata).toEqual("copyright");
135 expect(updated.neverRequireSignedURLs).toEqual(true);
136
137 const patched = yield* getVariant(
138 accountId,
139 updated.variantName,
140 (r) =>
141 r.variant?.options.fit === "contain" &&
142 r.variant?.options.width === 200 &&
143 r.variant?.options.metadata === "copyright" &&
144 r.variant?.neverRequireSignedURLs === true,
145 );
146 expect(patched.variant?.options.fit).toEqual("contain");
147 expect(patched.variant?.options.width).toEqual(200);
148 expect(patched.variant?.options.metadata).toEqual("copyright");
149 expect(patched.variant?.neverRequireSignedURLs).toEqual(true);
150
151 // No-op redeploy — identical props, still the same variant.
152 const noop = yield* stack.deploy(
153 Cloudflare.Images.Variant("alchemytestvariant", {
154 fit: "contain",
155 width: 200,
156 height: 100,
157 metadata: "copyright",
158 neverRequireSignedURLs: true,
159 }).pipe(adopt(true)),
160 );
161 expect(noop.variantName).toEqual("alchemytestvariant");
162
163 yield* stack.destroy();
164
165 yield* expectGone(accountId, "alchemytestvariant");
166
167 // Destroy again — delete is idempotent.
168 yield* stack.destroy();
169 }).pipe(logLevel),
170 // Two sequential out-of-band verifies (post-create, post-update) each ride
171 // out a read-replica lag that can run ~90s under full-suite parallel load;
172 // the 120s default can't hold both plus the deploys/destroys.
173 {
174 tags: ["provider:cloudflare", "provider:cloudflare:images", "live"],
175 timeout: 240_000,
176 },
177);
178
179test.provider(
180 "replace a variant when the name changes",
181 (stack) =>
182 Effect.gen(function* () {
183 const { accountId } = yield* yield* CloudflareEnvironment;
184
185 yield* stack.destroy();
186
187 const initial = yield* stack.deploy(
188 Cloudflare.Images.Variant("ReplaceVariant", {
189 name: "alchemyreplacea",
190 fit: "cover",
191 width: 64,
192 height: 64,
193 }).pipe(adopt(true)),
194 );
195 expect(initial.variantName).toEqual("alchemyreplacea");
196
197 // Renaming the variant replaces it — the name is the API path id.
198 const replaced = yield* stack.deploy(
199 Cloudflare.Images.Variant("ReplaceVariant", {
200 name: "alchemyreplaceb",
201 fit: "cover",
202 width: 64,
203 height: 64,
204 }).pipe(adopt(true)),
205 );
206 expect(replaced.variantName).toEqual("alchemyreplaceb");
207
208 const live = yield* getVariant(accountId, "alchemyreplaceb");
209 expect(live.variant?.id).toEqual("alchemyreplaceb");
210
211 // The old variant is gone after the replacement completes.
212 yield* expectGone(accountId, "alchemyreplacea");
213
214 yield* stack.destroy();
215 yield* expectGone(accountId, "alchemyreplaceb");
216 }).pipe(logLevel),
217 // Same eventual-consistency budget as above: the post-replace verify plus
218 // two gone-assertions can exceed the 120s default under parallel load.
219 {
220 tags: ["provider:cloudflare", "provider:cloudflare:images", "live"],
221 timeout: 240_000,
222 },
223);
224
225// Canonical `list()` test (account collection): deploy a variant, resolve the
226// provider with the typed `Provider.findProvider` helper, then assert the
227// deployed variant appears in the exhaustively-enumerated result. Bracketed by
228// `stack.destroy()` at both ends so the suite leaves no residue.
229//
230// SKIP-GATED on `CLOUDFLARE_TEST_IMAGES_LIST` until a distilled patch lands.
231// distilled mis-types the list-variants response as a single variant, but the
232// real body is a keyed `variants` map, so the strict decode fails and the GET
233// surfaces a `CloudflareHttpError` (status 200, statusText "Schema decode
234// failed"). Recovering from that catch-all is forbidden by the Typed Error
235// Doctrine, so the fix is the distilled response-schema patch in the agent
236// report. Once applied (and distilled `lib` rebuilt) this passes unchanged;
237// set `CLOUDFLARE_TEST_IMAGES_LIST=1` to run it.
238test.provider.skipIf(!process.env.CLOUDFLARE_TEST_IMAGES_LIST)(
239 "list enumerates the deployed variant",
240 (stack) =>
241 Effect.gen(function* () {
242 yield* stack.destroy();
243
244 const deployed = yield* stack.deploy(
245 Cloudflare.Images.Variant("alchemytestlistvariant", {
246 fit: "cover",
247 width: 120,
248 height: 120,
249 }).pipe(adopt(true)),
250 );
251
252 const provider = yield* Provider.findProvider(Cloudflare.Images.Variant);
253 const all = yield* provider.list();
254
255 expect(Array.isArray(all)).toBe(true);
256 expect(all.some((v) => v.variantName === deployed.variantName)).toBe(
257 true,
258 );
259
260 yield* stack.destroy();
261 }).pipe(logLevel),
262 { tags: ["provider:cloudflare", "provider:cloudflare:images", "live"] },
263);