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 pages from "@distilled.cloud/cloudflare/pages";
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
19const zoneName =
20 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
21
22// Deterministic per-test names (never derived from Date.now() or
23// randomness). Each test owns disjoint identifiers so reruns never collide.
24const PROJECT_CRUD = "alchemy-e3-pages-dom-crud";
25const PROJECT_REPLACE = "alchemy-e3-pages-dom-repl";
26const PROJECT_LIST = "alchemy-e3-pages-dom-list";
27const DOMAIN_LIST = `alchemy-pages-list.${zoneName}`;
28const DOMAIN_CRUD = `alchemy-pages-crud.${zoneName}`;
29const DOMAIN_REPLACE_A = `alchemy-pages-replace-a.${zoneName}`;
30const DOMAIN_REPLACE_B = `alchemy-pages-replace-b.${zoneName}`;
31
32// The scoped API token the test harness mints propagates eventually-
33// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
34// declared in the distilled error union) on the test's own out-of-band
35// verification calls.
36const forbiddenRetry = {
37 while: (e: { _tag: string }) => e._tag === "Forbidden",
38 schedule: Schedule.exponential("500 millis"),
39 times: 8,
40} as const;
41
42const getDomain = (
43 accountId: string,
44 projectName: string,
45 domainName: string,
46) =>
47 pages
48 .getProjectDomain({ accountId, projectName, domainName })
49 .pipe(Effect.retry(forbiddenRetry));
50
51// Attaching a domain is asynchronous: the attachment, its zone tag and
52// validation/verification blocks propagate across Cloudflare's edge a beat
53// after `create` returns. Poll `getProjectDomain` with a bounded schedule,
54// riding out the brief `PagesDomainNotFound` window, until the domain is
55// observable with a status. We assert reachability — NOT a terminal `active`
56// status, since certificate issuance needs a real CNAME and stays pending.
57const waitForDomain = (
58 accountId: string,
59 projectName: string,
60 domainName: string,
61) =>
62 pages.getProjectDomain({ accountId, projectName, domainName }).pipe(
63 Effect.retry({
64 while: (e) => e._tag === "Forbidden" || e._tag === "PagesDomainNotFound",
65 schedule: Schedule.max([
66 Schedule.exponential("500 millis"),
67 Schedule.recurs(10),
68 ]),
69 }),
70 );
71
72const expectDomainGone = (
73 accountId: string,
74 projectName: string,
75 domainName: string,
76) =>
77 getDomain(accountId, projectName, domainName).pipe(
78 Effect.flatMap(() => Effect.fail({ _tag: "DomainNotDeleted" } as const)),
79 // A detached domain surfaces as `PagesDomainNotFound` (code 8000021);
80 // if the whole project is gone the API reports `ProjectNotFound`
81 // (code 8000007). Both mean the attachment no longer exists.
82 Effect.catchTag("PagesDomainNotFound", () => Effect.void),
83 Effect.catchTag("ProjectNotFound", () => Effect.void),
84 Effect.retry({
85 while: (e) => e._tag === "DomainNotDeleted",
86 schedule: Schedule.max([
87 Schedule.exponential("500 millis"),
88 Schedule.recurs(10),
89 ]),
90 }),
91 );
92
93// Purge leftovers from interrupted runs so deterministically-named tests
94// start from a clean slate (deleting the project detaches its domains).
95const purgeProject = (accountId: string, projectName: string) =>
96 pages.deleteProject({ accountId, projectName }).pipe(
97 Effect.catchTag("ProjectNotFound", () => Effect.void),
98 Effect.retry(forbiddenRetry),
99 );
100
101test.provider(
102 "attach and detach a custom domain",
103 (stack) =>
104 Effect.gen(function* () {
105 const { accountId } = yield* yield* CloudflareEnvironment;
106
107 yield* stack.destroy();
108 yield* purgeProject(accountId, PROJECT_CRUD);
109
110 const { project, domain } = yield* stack.deploy(
111 Effect.gen(function* () {
112 const project = yield* Cloudflare.Pages.Project("DomainCrudProject", {
113 name: PROJECT_CRUD,
114 }).pipe(adopt(true));
115 const domain = yield* Cloudflare.Pages.Domain("CrudDomain", {
116 projectName: project.name,
117 name: DOMAIN_CRUD,
118 }).pipe(adopt(true));
119 return { project, domain };
120 }),
121 );
122
123 expect(domain.domainId).toBeDefined();
124 expect(domain.accountId).toEqual(accountId);
125 expect(domain.projectName).toEqual(project.name);
126 expect(domain.name).toEqual(DOMAIN_CRUD);
127 // Certificate issuance is asynchronous — the domain legitimately stays
128 // `initializing`/`pending` (the cert authority, validation/verification
129 // blocks and zone tag fill in over time). This test exercises CRUD, not
130 // certificate issuance, so only assert the attachment exists with a
131 // status, never a terminal `active`.
132 expect(domain.status).toBeTruthy();
133 expect(domain.createdOn).toBeTruthy();
134
135 // The attachment propagates a beat after `create` returns — poll until
136 // it is observable rather than asserting it is immediately readable.
137 const live = yield* waitForDomain(accountId, project.name, DOMAIN_CRUD);
138 expect(live.domainId).toEqual(domain.domainId);
139 expect(live.name).toEqual(DOMAIN_CRUD);
140 expect(live.status).toBeTruthy();
141
142 // Redeploying identical props is a no-op (same attachment).
143 const noop = yield* stack.deploy(
144 Effect.gen(function* () {
145 const project = yield* Cloudflare.Pages.Project("DomainCrudProject", {
146 name: PROJECT_CRUD,
147 }).pipe(adopt(true));
148 const domain = yield* Cloudflare.Pages.Domain("CrudDomain", {
149 projectName: project.name,
150 name: DOMAIN_CRUD,
151 }).pipe(adopt(true));
152 return { project, domain };
153 }),
154 );
155 expect(noop.domain.domainId).toEqual(domain.domainId);
156
157 yield* stack.destroy();
158
159 yield* expectDomainGone(accountId, PROJECT_CRUD, DOMAIN_CRUD);
160 }).pipe(
161 logLevel,
162 // Guarantee teardown even if an assertion throws mid-test, so a failed
163 // run never leaves a dangling Pages.Domain / Pages.Project behind.
164 // `stack.destroy()` tears down everything in the scratch stack (domain
165 // then project); the next run's start-of-test purge is the backstop.
166 Effect.ensuring(stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore)),
167 ),
168 { tags: ["provider:cloudflare", "provider:cloudflare:pages", "live"] },
169);
170
171test.provider(
172 "changing the domain name triggers replacement",
173 (stack) =>
174 Effect.gen(function* () {
175 const { accountId } = yield* yield* CloudflareEnvironment;
176
177 yield* stack.destroy();
178 yield* purgeProject(accountId, PROJECT_REPLACE);
179
180 const initial = yield* stack.deploy(
181 Effect.gen(function* () {
182 const project = yield* Cloudflare.Pages.Project("DomainReplProject", {
183 name: PROJECT_REPLACE,
184 }).pipe(adopt(true));
185 const domain = yield* Cloudflare.Pages.Domain("ReplaceDomain", {
186 projectName: project.name,
187 name: DOMAIN_REPLACE_A,
188 }).pipe(adopt(true));
189 return domain;
190 }),
191 );
192
193 expect(initial.name).toEqual(DOMAIN_REPLACE_A);
194
195 const replaced = yield* stack.deploy(
196 Effect.gen(function* () {
197 const project = yield* Cloudflare.Pages.Project("DomainReplProject", {
198 name: PROJECT_REPLACE,
199 }).pipe(adopt(true));
200 const domain = yield* Cloudflare.Pages.Domain("ReplaceDomain", {
201 projectName: project.name,
202 name: DOMAIN_REPLACE_B,
203 }).pipe(adopt(true));
204 return domain;
205 }),
206 );
207
208 // The domain name is the attachment's identity — a new physical
209 // attachment exists.
210 expect(replaced.domainId).not.toEqual(initial.domainId);
211 expect(replaced.name).toEqual(DOMAIN_REPLACE_B);
212
213 // The old domain was detached as part of the replacement.
214 yield* expectDomainGone(accountId, PROJECT_REPLACE, DOMAIN_REPLACE_A);
215
216 const live = yield* getDomain(
217 accountId,
218 PROJECT_REPLACE,
219 DOMAIN_REPLACE_B,
220 );
221 expect(live.domainId).toEqual(replaced.domainId);
222
223 yield* stack.destroy();
224
225 yield* expectDomainGone(accountId, PROJECT_REPLACE, DOMAIN_REPLACE_B);
226 }).pipe(logLevel),
227 { tags: ["provider:cloudflare", "provider:cloudflare:pages", "live"] },
228);
229
230// Canonical `list()` test (parent fan-out): domains have no account-wide
231// enumeration API, so `list()` enumerates every Pages project and lists each
232// project's domains. Deploy a project + domain, then assert the attachment
233// appears in the exhaustively-paginated result with its full `read`
234// Attributes shape.
235//
236// SKIP-GATED on a distilled schema bug: `pages.listProjects` fails to decode
237// the live response with
238// CloudflareHttpError { status: 200, statusText: "Schema decode failed" }
239// because `ListProjectsResponse.result[].canonicalDeployment` and
240// `.latestDeployment` declare `source` as a *required* `Schema.Struct`
241// (pages.ts ~1540 / ~2002), but Cloudflare omits `source` on deployment
242// objects. NEEDED PATCH: make `source` optional/nullable on both deployment
243// shapes in the `listProjects` response schema, regenerate the `pages`
244// service, then drop this gate. Re-enable with CLOUDFLARE_TEST_PAGES_LIST=1.
245test.provider.skipIf(!process.env.CLOUDFLARE_TEST_PAGES_LIST)(
246 "list enumerates domains across all Pages projects",
247 (stack) =>
248 Effect.gen(function* () {
249 const { accountId } = yield* yield* CloudflareEnvironment;
250
251 yield* stack.destroy();
252 yield* purgeProject(accountId, PROJECT_LIST);
253
254 const { domain } = yield* stack.deploy(
255 Effect.gen(function* () {
256 const project = yield* Cloudflare.Pages.Project("DomainListProject", {
257 name: PROJECT_LIST,
258 }).pipe(adopt(true));
259 const domain = yield* Cloudflare.Pages.Domain("ListDomain", {
260 projectName: project.name,
261 name: DOMAIN_LIST,
262 }).pipe(adopt(true));
263 return { domain };
264 }),
265 );
266
267 const provider = yield* Provider.findProvider(Cloudflare.Pages.Domain);
268 const all = yield* provider.list();
269
270 const found = all.find((d) => d.domainId === domain.domainId);
271 expect(found).toBeDefined();
272 expect(found?.name).toEqual(DOMAIN_LIST);
273 expect(found?.projectName).toEqual(PROJECT_LIST);
274 expect(found?.accountId).toEqual(accountId);
275
276 yield* stack.destroy();
277
278 yield* expectDomainGone(accountId, PROJECT_LIST, DOMAIN_LIST);
279 }).pipe(logLevel),
280 { tags: ["provider:cloudflare", "provider:cloudflare:pages", "live"] },
281);