| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import { expect } from "alchemy-test"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import { MinimumLogLevel } from "effect/References"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | import * as HttpClient from "effect/http/HttpClient"; |
| 9 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 10 | import type * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 11 | import 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 | */ |
| 24 | const ARTIFACTS_ENABLED = process.env.CLOUDFLARE_TEST_ARTIFACTS !== "0"; |
| 25 | |
| 26 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 27 | providers: Cloudflare.providers(), |
| 28 | state: Cloudflare.state(), |
| 29 | }); |
| 30 | |
| 31 | const HOOK_TIMEOUT = 300_000; |
| 32 | // Must cover the ~150s `ready` readiness budget plus the round-trip itself. |
| 33 | const TEST_TIMEOUT = 240_000; |
| 34 | |
| 35 | const logLevel = Effect.provideService( |
| 36 | MinimumLogLevel, |
| 37 | process.env.DEBUG ? "Debug" : "Info", |
| 38 | ); |
| 39 | |
| 40 | class 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. |
| 52 | const 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). */ |
| 55 | const 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 | |
| 74 | const body = <T>(res: HttpClientResponse.HttpClientResponse) => |
| 75 | res.json.pipe(Effect.map((b) => b as T)); |
| 76 | |
| 77 | const 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 | |
| 93 | const listRepos = (base: string) => |
| 94 | untilOk(HttpClient.get(`${base}/list`)).pipe( |
| 95 | Effect.flatMap(body<{ names: string[]; total: number }>), |
| 96 | ); |
| 97 | |
| 98 | const getRepo = (base: string, name: string) => |
| 99 | untilOk(HttpClient.get(`${base}/get?name=${encodeURIComponent(name)}`)).pipe( |
| 100 | Effect.flatMap(body<{ found: boolean }>), |
| 101 | ); |
| 102 | |
| 103 | const 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 | */ |
| 118 | const 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. |
| 149 | const stack = beforeAll( |
| 150 | ARTIFACTS_ENABLED |
| 151 | ? deploy(Stack) |
| 152 | : Effect.succeed({ effectWorkerUrl: "", asyncWorkerUrl: "" }), |
| 153 | { timeout: HOOK_TIMEOUT }, |
| 154 | ); |
| 155 | afterAll.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`. |
| 161 | test.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. |
| 179 | test.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 | ); |