1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import { deepEqual } from "@/Diff";
5import * as Provider from "@/Provider";
6import { isResourceState, State, type ResourceState } from "@/State";
7import * as Test from "@/Test/Alchemy";
8import { stripNullFields, stripUndefinedFields } from "@/Util/data";
9import * as zaraz from "@distilled.cloud/cloudflare/zaraz";
10import { describe, expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import { MinimumLogLevel } from "effect/References";
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const zoneId = process.env.CLOUDFLARE_TEST_ZARAZ_ZONE_ID;
21const zoneName =
22 process.env.CLOUDFLARE_TEST_ZARAZ_ZONE_NAME ?? "alchemy-test-2.us";
23
24// Zaraz needs no entitlement (available on all plans), but its config is a
25// zone-wide singleton these tests mutate in place — they only run against a
26// dedicated opt-in zone via CLOUDFLARE_TEST_ZARAZ_ZONE_ID (+ optional _NAME).
27// All cases mutate the same zone-wide Zaraz config singleton; run them
28// serially so they don't corrupt each other under the global concurrent
29// test config.
30describe.sequential(
31 "Config",
32 { tags: ["provider:cloudflare", "provider:cloudflare:zaraz", "live"] },
33 () => {
34 test.provider.skipIf(!zoneId)(
35 "updates and retains a zone-level Zaraz config",
36 (stack) =>
37 Effect.gen(function* () {
38 yield* stack.destroy();
39
40 const original = yield* zaraz.getConfig({ zoneId: zoneId! });
41 const toggledDataLayer = !original.dataLayer;
42
43 yield* Effect.gen(function* () {
44 const updated = yield* stack.deploy(
45 Effect.gen(function* () {
46 return yield* Cloudflare.Zaraz.Config("Config", {
47 zone: { zoneId: zoneId!, name: zoneName },
48 dataLayer: toggledDataLayer,
49 });
50 }),
51 );
52
53 expect(updated.zoneId).toEqual(zoneId);
54 expect(updated.dataLayer).toEqual(toggledDataLayer);
55
56 const liveUpdated = yield* zaraz.getConfig({ zoneId: zoneId! });
57 expect(liveUpdated.dataLayer).toEqual(toggledDataLayer);
58
59 const restored = yield* stack.deploy(
60 Effect.gen(function* () {
61 return yield* Cloudflare.Zaraz.Config("Config", {
62 zone: { zoneId: zoneId!, name: zoneName },
63 dataLayer: original.dataLayer,
64 });
65 }),
66 );
67
68 expect(restored.dataLayer).toEqual(original.dataLayer);
69
70 yield* stack.destroy();
71
72 const liveRetained = yield* zaraz.getConfig({ zoneId: zoneId! });
73 expect(liveRetained.dataLayer).toEqual(original.dataLayer);
74 }).pipe(
75 Effect.ensuring(
76 zaraz
77 .putConfig(toPutConfig(zoneId!, original))
78 .pipe(Effect.ignore),
79 ),
80 );
81 }).pipe(logLevel),
82 { timeout: 120_000 },
83 );
84
85 // Same gate as above: mutates the zone-wide Zaraz singleton, so it requires
86 // the dedicated opt-in zone from CLOUDFLARE_TEST_ZARAZ_ZONE_ID.
87 test.provider.skipIf(!zoneId)(
88 "delete true resets Zaraz config to defaults",
89 (stack) =>
90 Effect.gen(function* () {
91 yield* stack.destroy();
92
93 const original = yield* zaraz.getConfig({ zoneId: zoneId! });
94 const originalWorkflow = yield* zaraz.getWorkflow({
95 zoneId: zoneId!,
96 });
97 const defaults = yield* zaraz.getDefault({ zoneId: zoneId! });
98
99 yield* Effect.gen(function* () {
100 yield* stack.deploy(
101 Effect.gen(function* () {
102 return yield* Cloudflare.Zaraz.Config("Config", {
103 zone: { zoneId: zoneId!, name: zoneName },
104 dataLayer: !defaults.dataLayer,
105 workflow: "preview",
106 delete: true,
107 });
108 }),
109 );
110
111 const liveUpdated = yield* zaraz.getConfig({ zoneId: zoneId! });
112 expect(liveUpdated.dataLayer).toEqual(!defaults.dataLayer);
113
114 yield* stack.destroy();
115
116 const liveDeleted = yield* zaraz.getConfig({ zoneId: zoneId! });
117 expect(liveDeleted.dataLayer).toEqual(defaults.dataLayer);
118 const liveDeletedWorkflow = yield* zaraz.getWorkflow({
119 zoneId: zoneId!,
120 });
121 expect(liveDeletedWorkflow).toEqual("realtime");
122 }).pipe(
123 Effect.ensuring(
124 Effect.gen(function* () {
125 yield* zaraz
126 .putConfig(toPutConfig(zoneId!, original))
127 .pipe(Effect.ignore);
128 yield* zaraz
129 .putZaraz({ zoneId: zoneId!, workflow: originalWorkflow })
130 .pipe(Effect.ignore);
131 }),
132 ),
133 );
134 }).pipe(logLevel),
135 { timeout: 120_000 },
136 );
137
138 // Same gate as above: mutates the zone-wide Zaraz singleton, so it requires
139 // the dedicated opt-in zone from CLOUDFLARE_TEST_ZARAZ_ZONE_ID.
140 test.provider.skipIf(!zoneId)(
141 "updates and retains Zaraz workflow mode",
142 (stack) =>
143 Effect.gen(function* () {
144 yield* stack.destroy();
145
146 const original = yield* zaraz.getWorkflow({ zoneId: zoneId! });
147 const workflow = original === "realtime" ? "preview" : "realtime";
148
149 yield* Effect.gen(function* () {
150 const updated = yield* stack.deploy(
151 Effect.gen(function* () {
152 return yield* Cloudflare.Zaraz.Config("Config", {
153 zone: { zoneId: zoneId!, name: zoneName },
154 workflow,
155 });
156 }),
157 );
158
159 expect(updated.workflow).toEqual(workflow);
160
161 const liveUpdated = yield* zaraz.getWorkflow({ zoneId: zoneId! });
162 expect(liveUpdated).toEqual(workflow);
163
164 yield* stack.destroy();
165
166 const liveRetained = yield* zaraz.getWorkflow({ zoneId: zoneId! });
167 expect(liveRetained).toEqual(workflow);
168 }).pipe(
169 Effect.ensuring(
170 zaraz
171 .putZaraz({ zoneId: zoneId!, workflow: original })
172 .pipe(Effect.ignore),
173 ),
174 );
175 }).pipe(logLevel),
176 { timeout: 120_000 },
177 );
178
179 // Canonical `list()` test (zone-scoped singleton): there is no account-wide
180 // API for the per-zone Zaraz config, so `list()` enumerates every zone via
181 // `listAllZones` and reads the singleton in each. Read-only — asserts the
182 // result is well-typed, non-empty, and contains the standing test zone.
183 test.provider(
184 "list enumerates Zaraz config across all zones",
185 (stack) =>
186 Effect.gen(function* () {
187 const { accountId } = yield* yield* CloudflareEnvironment;
188 const zone = yield* findZoneByName({ accountId, name: zoneName });
189 if (!zone) {
190 return yield* Effect.die(
191 new Error(`zone "${zoneName}" not found in account`),
192 );
193 }
194
195 yield* stack.destroy();
196
197 const provider = yield* Provider.findProvider(
198 Cloudflare.Zaraz.Config,
199 );
200 const all = yield* provider.list();
201
202 expect(all.length).toBeGreaterThan(0);
203 const row = all.find((c) => c.zoneId === zone.id);
204 expect(row).toBeDefined();
205 expect(typeof row!.zoneId).toBe("string");
206 expect(typeof row!.dataLayer).toBe("boolean");
207 expect(typeof row!.zarazVersion).toBe("number");
208 expect(row!.settings).toBeDefined();
209 expect(row!.workflow).toBeDefined();
210
211 yield* stack.destroy();
212 }).pipe(logLevel),
213 { tags: ["provider:cloudflare:zone"], timeout: 120_000 },
214 );
215
216 // Ungated like the `list()` test: the deploy passes NO mutable props, so
217 // reconcile observes the zone config, computes an identical desired config,
218 // and skips putConfig/putZaraz entirely — the standing zone's singleton is
219 // never mutated (capture-and-restore below is just a safety net).
220 test.provider(
221 "recovers a half-created config whose creating-state lost Output-valued props (#736)",
222 (stack) =>
223 Effect.gen(function* () {
224 yield* stack.destroy();
225
226 const { accountId } = yield* yield* CloudflareEnvironment;
227 const zone = yield* findZoneByName({ accountId, name: zoneName });
228 if (!zone) {
229 return yield* Effect.die(
230 new Error(`zone "${zoneName}" not found in account`),
231 );
232 }
233
234 const original = yield* zaraz.getConfig({ zoneId: zone.id });
235
236 yield* Effect.gen(function* () {
237 const deployConfig = () =>
238 stack.deploy(
239 Effect.gen(function* () {
240 // No mutable props: reconcile is a pure observe (no put).
241 return yield* Cloudflare.Zaraz.Config("Config", {
242 zone: { zoneId: zone.id, name: zoneName },
243 });
244 }),
245 );
246
247 const created = yield* deployConfig();
248 expect(created.zoneId).toEqual(zone.id);
249
250 // Rewrite the persisted row into the wedged shape an interrupted
251 // deploy leaves behind: `creating`, no attributes, and the
252 // Output-valued `zone` prop lost in the round-trip (#736).
253 const state = yield* yield* State;
254 const stage = stack.stage;
255 const fqns = yield* state.list({ stack: stack.name, stage });
256 const rows = yield* Effect.forEach(fqns, (fqn) =>
257 state
258 .get({ stack: stack.name, stage, fqn })
259 .pipe(Effect.map((row) => ({ fqn, row }))),
260 );
261 const wedged = rows.find(
262 (r): r is { fqn: string; row: ResourceState } =>
263 isResourceState(r.row) &&
264 r.row.resourceType === "Cloudflare.Zaraz.Config",
265 );
266 if (!wedged) {
267 return yield* Effect.die(
268 new Error(
269 "no Cloudflare.Zaraz.Config state row found after deploy",
270 ),
271 );
272 }
273 yield* state.set({
274 stack: stack.name,
275 stage,
276 fqn: wedged.fqn,
277 value: {
278 ...wedged.row,
279 status: "creating",
280 attr: undefined,
281 props: {
282 ...wedged.row.props,
283 zone: undefined,
284 },
285 },
286 });
287
288 // Before the fix this crashed in read's recovery path with
289 // `TypeError: undefined is not an object (evaluating 'zone.name')`.
290 const recovered = yield* deployConfig();
291 expect(recovered.zoneId).toEqual(created.zoneId);
292 expect(recovered.dataLayer).toEqual(created.dataLayer);
293 expect(recovered.workflow).toEqual(created.workflow);
294
295 yield* stack.destroy();
296 }).pipe(
297 Effect.ensuring(
298 // Safety net: restore only if something actually changed, so the
299 // happy path leaves the standing zone's singleton untouched.
300 Effect.gen(function* () {
301 const current = yield* zaraz.getConfig({ zoneId: zone.id });
302 if (!deepEqual(current, original)) {
303 yield* zaraz.putConfig(toPutConfig(zone.id, original));
304 }
305 }).pipe(Effect.ignore),
306 ),
307 );
308 }).pipe(logLevel),
309 { tags: ["provider:cloudflare:zone"], timeout: 240_000 },
310 );
311 },
312);
313
314type ConfigResponse = zaraz.GetConfigResponse | zaraz.PutConfigResponse;
315
316const toPutConfig = (
317 zoneId: string,
318 config: ConfigResponse,
319): zaraz.PutConfigRequest =>
320 stripUndefinedFields({
321 zoneId,
322 dataLayer: config.dataLayer,
323 debugKey: config.debugKey,
324 settings: stripNullFields(config.settings),
325 tools: config.tools,
326 triggers: config.triggers,
327 variables: config.variables,
328 zarazVersion: config.zarazVersion,
329 analytics: config.analytics ? stripNullFields(config.analytics) : undefined,
330 consent: config.consent ? stripNullFields(config.consent) : undefined,
331 historyChange: config.historyChange ?? undefined,
332 }) as zaraz.PutConfigRequest;