1import * as Cloudflare from "@/Cloudflare";
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 * as HttpClientRequest from "effect/http/HttpClientRequest";
10import type * as HttpClientResponse from "effect/http/HttpClientResponse";
11import Stack from "./fixtures/stack.ts";
12
13/**
14 * Artifacts ("Git for agents") is a beta product whose native Worker binding +
15 * implicit-namespace REST surface require the account to be onboarded to the
16 * beta. The standing test account IS entitled, so this suite runs live by
17 * default (verified green: deploy → create/list/get/delete round-trip → destroy
18 * for both the effect-native and async invocation styles).
19 *
20 * If an account is NOT onboarded, repo creation is rejected at runtime; set
21 * `CLOUDFLARE_TEST_ARTIFACTS=0` to skip the entire suite skip-clean on such an
22 * account.
23 */
24const ARTIFACTS_ENABLED = process.env.CLOUDFLARE_TEST_ARTIFACTS !== "0";
25
26const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
27 providers: Cloudflare.providers(),
28 state: Cloudflare.state(),
29});
30
31const HOOK_TIMEOUT = 300_000;
32// Must cover the ~150s `ready` readiness budget plus the round-trip itself.
33const TEST_TIMEOUT = 240_000;
34
35const logLevel = Effect.provideService(
36 MinimumLogLevel,
37 process.env.DEBUG ? "Debug" : "Info",
38);
39
40class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
41 status: number;
42 body: string;
43}> {
44 override get message() {
45 return `status=${this.status} body=${this.body.slice(0, 500)}`;
46 }
47}
48
49// Bounded spaced schedule — caps total cold-start wait so a real failure
50// surfaces fast instead of riding to the test timeout. ~150s: fresh
51// workers.dev URLs 404 well past a minute under full-suite deploy load.
52const ready = Schedule.max([Schedule.spaced("2 seconds"), Schedule.recurs(75)]);
53
54/** Retry an HTTP call until it returns 200 (rides out cold-start 404s). */
55const untilOk = <E, R>(
56 eff: Effect.Effect<HttpClientResponse.HttpClientResponse, E, R>,
57) =>
58 eff.pipe(
59 Effect.flatMap((res) =>
60 res.status === 200
61 ? Effect.succeed(res)
62 : res.text.pipe(
63 Effect.flatMap((body) =>
64 Effect.fail(new WorkerNotReady({ status: res.status, body })),
65 ),
66 ),
67 ),
68 Effect.retry({
69 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
70 schedule: ready,
71 }),
72 );
73
74const body = <T>(res: HttpClientResponse.HttpClientResponse) =>
75 res.json.pipe(Effect.map((b) => b as T));
76
77const createRepo = (base: string, name: string) =>
78 untilOk(
79 HttpClient.execute(
80 HttpClientRequest.post(`${base}/create?name=${encodeURIComponent(name)}`),
81 ),
82 ).pipe(
83 Effect.flatMap(
84 body<{
85 name: string;
86 remote: string;
87 defaultBranch: string;
88 hasToken: boolean;
89 }>,
90 ),
91 );
92
93const listRepos = (base: string) =>
94 untilOk(HttpClient.get(`${base}/list`)).pipe(
95 Effect.flatMap(body<{ names: string[]; total: number }>),
96 );
97
98const getRepo = (base: string, name: string) =>
99 untilOk(HttpClient.get(`${base}/get?name=${encodeURIComponent(name)}`)).pipe(
100 Effect.flatMap(body<{ found: boolean }>),
101 );
102
103const deleteRepo = (base: string, name: string) =>
104 untilOk(
105 HttpClient.execute(
106 HttpClientRequest.make("DELETE")(
107 `${base}/delete?name=${encodeURIComponent(name)}`,
108 ),
109 ),
110 ).pipe(Effect.flatMap(body<{ deleted: boolean }>));
111
112/**
113 * Drive the full client surface over one worker base URL: create a repo,
114 * confirm it shows up in `list` and `get`, then `delete` it and confirm it is
115 * gone. `label` namespaces the repo so the two style-runs stay independent
116 * against the one shared namespace.
117 */
118const exercise = (label: string, base: string) =>
119 Effect.gen(function* () {
120 const repo = `${label}-repo`;
121
122 // Pre-clean: an interrupted earlier run can leave the deterministic
123 // repo behind (Artifacts repos live outside the account nuke's view),
124 // and a leaked repo makes create fail "already exists" forever after.
125 // getRepo also rides out worker cold-start via its readiness retry.
126 if ((yield* getRepo(base, repo)).found) {
127 yield* deleteRepo(base, repo);
128 }
129
130 const created = yield* createRepo(base, repo);
131 expect(created.name).toBe(repo);
132 expect(created.defaultBranch).toBe("main");
133 expect(created.remote).toContain("https://");
134 expect(created.hasToken).toBe(true);
135
136 const listed = yield* listRepos(base);
137 expect(listed.names).toContain(repo);
138
139 expect((yield* getRepo(base, repo)).found).toBe(true);
140
141 expect((yield* deleteRepo(base, repo)).deleted).toBe(true);
142 expect((yield* getRepo(base, repo)).found).toBe(false);
143 });
144
145// `beforeAll` has no `.skipIf`, so the deploy is gated inside the effect: when
146// the Artifacts beta flag is unset, skip the (entitlement-blocked) deploy and
147// return empty URLs. The tests below are `skipIf`-gated on the same flag, so
148// they never read these placeholder URLs.
149const stack = beforeAll(
150 ARTIFACTS_ENABLED
151 ? deploy(Stack)
152 : Effect.succeed({ effectWorkerUrl: "", asyncWorkerUrl: "" }),
153 { timeout: HOOK_TIMEOUT },
154);
155afterAll.skipIf(!ARTIFACTS_ENABLED || !!process.env.NO_DESTROY)(
156 destroy(Stack),
157 { timeout: HOOK_TIMEOUT },
158);
159
160// Effect-native worker: `Cloudflare.Artifacts.ReadWriteNamespace(Repos)` + `ReadWriteNamespaceBinding`.
161test.skipIf(!ARTIFACTS_ENABLED)(
162 "effect binding: create / list / get / delete round-trip",
163 Effect.gen(function* () {
164 const out = yield* stack;
165 yield* exercise("effect", out.effectWorkerUrl);
166 }).pipe(logLevel),
167 {
168 tags: [
169 "provider:cloudflare",
170 "provider:cloudflare:artifacts",
171 "provider:cloudflare:worker",
172 "live",
173 ],
174 timeout: TEST_TIMEOUT,
175 },
176);
177
178// Async worker: namespace declared on `env: { REPOS }`, used from plain async fetch.
179test.skipIf(!ARTIFACTS_ENABLED)(
180 "async binding: create / list / get / delete round-trip",
181 Effect.gen(function* () {
182 const out = yield* stack;
183 yield* exercise("async", out.asyncWorkerUrl);
184 }).pipe(logLevel),
185 {
186 tags: [
187 "provider:cloudflare",
188 "provider:cloudflare:artifacts",
189 "provider:cloudflare:worker",
190 "live",
191 ],
192 timeout: TEST_TIMEOUT,
193 },
194);