| 1 | /** |
| 2 | * Cross-version FQN-migration regression test for the beta.68 StaticSite |
| 3 | * incident (#1053 / #1108). |
| 4 | * |
| 5 | * A temp project deploys a StaticSite with the PUBLISHED alchemy@2.0.0-beta.67 |
| 6 | * (whose StaticSite persists its Worker at the `<id>/Worker` FQN), then the |
| 7 | * CURRENT workspace version re-deploys over the same local state. The |
| 8 | * `renamedFrom(`${id}/Worker`)` migration must move the state row to `<id>` |
| 9 | * and re-brand the worker in place — the worker must NOT be deleted or |
| 10 | * recreated. |
| 11 | */ |
| 12 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 13 | import { Credentials } from "@/Cloudflare/Credentials.ts"; |
| 14 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 15 | import * as Test from "@/Test/Alchemy"; |
| 16 | import { expect } from "alchemy-test"; |
| 17 | import * as Effect from "effect/Effect"; |
| 18 | import * as FileSystem from "effect/FileSystem"; |
| 19 | import * as Path from "effect/Path"; |
| 20 | import * as Redacted from "effect/Redacted"; |
| 21 | import { spawn } from "node:child_process"; |
| 22 | import * as pathe from "pathe"; |
| 23 | import { |
| 24 | expectWorkerExists, |
| 25 | waitForWorkerToBeDeleted, |
| 26 | } from "../Utils/Worker.ts"; |
| 27 | |
| 28 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 29 | |
| 30 | const repoRoot = pathe.resolve(import.meta.dirname, "../../../../.."); |
| 31 | const workspaceCli = pathe.join(repoRoot, "packages/alchemy/bin/alchemy.js"); |
| 32 | |
| 33 | const STACK = "B67MigrationTest"; |
| 34 | const STAGE = "b67mig"; |
| 35 | |
| 36 | /** |
| 37 | * Run a command to completion without blocking the runner's event loop |
| 38 | * (the suite shares one bun process; a sync spawn would stall every |
| 39 | * concurrently running test). Fails with the combined output on a |
| 40 | * non-zero exit. |
| 41 | */ |
| 42 | const run = (options: { |
| 43 | cmd: string; |
| 44 | args: string[]; |
| 45 | cwd: string; |
| 46 | env?: Record<string, string>; |
| 47 | }): Effect.Effect<string, Error> => |
| 48 | Effect.callback<string, Error>((resume) => { |
| 49 | const child = spawn(options.cmd, options.args, { |
| 50 | cwd: options.cwd, |
| 51 | env: { ...process.env, NO_COLOR: "1", ...options.env }, |
| 52 | stdio: ["ignore", "pipe", "pipe"], |
| 53 | }); |
| 54 | let output = ""; |
| 55 | child.stdout.on("data", (chunk) => (output += chunk)); |
| 56 | child.stderr.on("data", (chunk) => (output += chunk)); |
| 57 | child.once("error", (error) => resume(Effect.fail(error))); |
| 58 | child.once("close", (code) => |
| 59 | resume( |
| 60 | code === 0 |
| 61 | ? Effect.succeed(output) |
| 62 | : Effect.fail( |
| 63 | new Error( |
| 64 | `${options.cmd} ${options.args.join(" ")} exited ${code}:\n${output}`, |
| 65 | ), |
| 66 | ), |
| 67 | ), |
| 68 | ); |
| 69 | return Effect.sync(() => child.kill("SIGKILL")); |
| 70 | }); |
| 71 | |
| 72 | /** The stack program, valid for both beta.67 and the current version. */ |
| 73 | const stackProgram = (alchemyImport: string, cloudflareImport: string) => |
| 74 | [ |
| 75 | `import * as Alchemy from "${alchemyImport}";`, |
| 76 | `import * as Cloudflare from "${cloudflareImport}";`, |
| 77 | "", |
| 78 | "export default Alchemy.Stack(", |
| 79 | ` "${STACK}",`, |
| 80 | " { providers: Cloudflare.providers(), state: Alchemy.localState() },", |
| 81 | // A pure-static site: no `main`, no bundling — the smallest surface |
| 82 | // that exists identically in both versions. |
| 83 | ' Cloudflare.Website.StaticSite("Site", {', |
| 84 | ' command: "bash build.sh",', |
| 85 | ' outdir: "dist",', |
| 86 | " }),", |
| 87 | ");", |
| 88 | "", |
| 89 | ].join("\n"); |
| 90 | |
| 91 | test.provider.skipIf(!!process.env.FAST)( |
| 92 | "a beta.67 StaticSite migrates to the current version without recreating the worker", |
| 93 | () => |
| 94 | Effect.gen(function* () { |
| 95 | const fs = yield* FileSystem.FileSystem; |
| 96 | const path = yield* Path.Path; |
| 97 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 98 | |
| 99 | // The published beta.67 predates the per-provider profile store, so |
| 100 | // it cannot read the credentials this process resolved from the |
| 101 | // profile. Environment credentials are the contract that is stable |
| 102 | // across versions: hand it the resolved credentials under `CI=1`. |
| 103 | const credentials = yield* yield* Credentials; |
| 104 | const legacyCliEnv: Record<string, string> = { |
| 105 | CI: "1", |
| 106 | CLOUDFLARE_ACCOUNT_ID: accountId, |
| 107 | ...(credentials.type === "apiKey" |
| 108 | ? { |
| 109 | CLOUDFLARE_API_KEY: Redacted.value(credentials.apiKey), |
| 110 | CLOUDFLARE_EMAIL: credentials.email, |
| 111 | } |
| 112 | : { |
| 113 | // An OAuth access token is a bearer token like an API token. |
| 114 | CLOUDFLARE_API_TOKEN: Redacted.value( |
| 115 | credentials.type === "apiToken" |
| 116 | ? credentials.apiToken |
| 117 | : credentials.accessToken, |
| 118 | ), |
| 119 | }), |
| 120 | }; |
| 121 | |
| 122 | const dir = yield* fs.makeTempDirectory({ prefix: "alchemy-b67-mig-" }); |
| 123 | const stateFile = (fqn: string) => |
| 124 | // LocalState layout: .alchemy/state/<stack>/<stage>/<encodeFqn>.json |
| 125 | path.join(dir, ".alchemy", "state", STACK, STAGE, `${fqn}.json`); |
| 126 | const readRow = (fqn: string) => |
| 127 | fs |
| 128 | .readFileString(stateFile(fqn)) |
| 129 | .pipe(Effect.map((raw) => JSON.parse(raw))); |
| 130 | |
| 131 | // ── Fixture ───────────────────────────────────────────────────────── |
| 132 | yield* fs.writeFileString( |
| 133 | path.join(dir, "package.json"), |
| 134 | JSON.stringify( |
| 135 | { |
| 136 | name: "b67-migration-fixture", |
| 137 | private: true, |
| 138 | dependencies: { |
| 139 | alchemy: "2.0.0-beta.67", |
| 140 | // Match the beta.67 release lockfile, independently of the |
| 141 | // current workspace's Effect version. |
| 142 | effect: "4.0.0-beta.100", |
| 143 | "@effect/platform-node": "4.0.0-beta.100", |
| 144 | "@effect/platform-bun": "4.0.0-beta.100", |
| 145 | }, |
| 146 | // Prerelease ranges otherwise pull newer adapters that import |
| 147 | // APIs absent from the legacy Effect runtime (e.g. ByteSize). |
| 148 | overrides: { |
| 149 | effect: "4.0.0-beta.100", |
| 150 | "@effect/platform-node": "4.0.0-beta.100", |
| 151 | "@effect/platform-bun": "4.0.0-beta.100", |
| 152 | "@effect/platform-node-shared": "4.0.0-beta.100", |
| 153 | "@effect/sql-d1": "4.0.0-beta.100", |
| 154 | "@effect/vitest": "4.0.0-beta.100", |
| 155 | }, |
| 156 | }, |
| 157 | null, |
| 158 | 2, |
| 159 | ), |
| 160 | ); |
| 161 | yield* fs.writeFileString( |
| 162 | path.join(dir, "index.html"), |
| 163 | "<h1>b67-migration</h1>", |
| 164 | ); |
| 165 | yield* fs.writeFileString( |
| 166 | path.join(dir, "build.sh"), |
| 167 | "mkdir -p dist && cp index.html dist/index.html", |
| 168 | ); |
| 169 | // beta.67 entry: resolves `alchemy` from the temp node_modules. |
| 170 | yield* fs.writeFileString( |
| 171 | path.join(dir, "alchemy.run.ts"), |
| 172 | stackProgram("alchemy", "alchemy/Cloudflare"), |
| 173 | ); |
| 174 | // Current-version entry: resolves the WORKSPACE sources directly, so |
| 175 | // no linking dance is needed and `effect` is the workspace instance. |
| 176 | yield* fs.writeFileString( |
| 177 | path.join(dir, "alchemy.current.run.ts"), |
| 178 | stackProgram( |
| 179 | pathe.join(repoRoot, "packages/alchemy/src/index.ts"), |
| 180 | pathe.join(repoRoot, "packages/alchemy/src/Cloudflare/index.ts"), |
| 181 | ), |
| 182 | ); |
| 183 | |
| 184 | // ── Phase 1: deploy with the published beta.67 ────────────────────── |
| 185 | yield* run({ cmd: "bun", args: ["install"], cwd: dir }); |
| 186 | yield* run({ |
| 187 | cmd: "bun", |
| 188 | args: [ |
| 189 | "node_modules/alchemy/bin/cli.js", |
| 190 | "deploy", |
| 191 | "--stage", |
| 192 | STAGE, |
| 193 | "--yes", |
| 194 | ], |
| 195 | cwd: dir, |
| 196 | env: legacyCliEnv, |
| 197 | }); |
| 198 | |
| 199 | // beta.67 persisted the Worker at the legacy `Site/Worker` FQN. |
| 200 | const before = yield* readRow("Site__Worker"); |
| 201 | const workerName: string = before.attr.workerName; |
| 202 | expect(before.fqn).toEqual("Site/Worker"); |
| 203 | expect(workerName).toBeDefined(); |
| 204 | yield* expectWorkerExists(workerName, accountId); |
| 205 | |
| 206 | // ── Phase 2: re-deploy with the CURRENT workspace version ─────────── |
| 207 | const output = yield* run({ |
| 208 | cmd: "bun", |
| 209 | args: [ |
| 210 | workspaceCli, |
| 211 | "deploy", |
| 212 | "./alchemy.current.run.ts", |
| 213 | "--stage", |
| 214 | STAGE, |
| 215 | "--yes", |
| 216 | ], |
| 217 | cwd: dir, |
| 218 | }); |
| 219 | |
| 220 | // The plan surfaced the migration and never planned a create/delete. |
| 221 | expect(output).toContain("renamed from Site/Worker"); |
| 222 | expect(output).not.toContain("to create"); |
| 223 | expect(output).not.toContain("to delete"); |
| 224 | |
| 225 | // The row moved: same instanceId, same physical worker, new FQN. |
| 226 | const after = yield* readRow("Site"); |
| 227 | expect(after.instanceId).toEqual(before.instanceId); |
| 228 | expect(after.attr.workerName).toEqual(workerName); |
| 229 | expect(after.logicalId).toEqual("Site"); |
| 230 | expect(yield* fs.exists(stateFile("Site__Worker"))).toBe(false); |
| 231 | |
| 232 | // THE assertion: the worker survived the upgrade. |
| 233 | yield* expectWorkerExists(workerName, accountId); |
| 234 | |
| 235 | // ── Cleanup ───────────────────────────────────────────────────────── |
| 236 | yield* run({ |
| 237 | cmd: "bun", |
| 238 | args: [ |
| 239 | workspaceCli, |
| 240 | "destroy", |
| 241 | "./alchemy.current.run.ts", |
| 242 | "--stage", |
| 243 | STAGE, |
| 244 | "--yes", |
| 245 | ], |
| 246 | cwd: dir, |
| 247 | }); |
| 248 | yield* waitForWorkerToBeDeleted(workerName, accountId); |
| 249 | yield* fs.remove(dir, { recursive: true }); |
| 250 | }), |
| 251 | { |
| 252 | tags: [ |
| 253 | "provider:cloudflare", |
| 254 | "provider:cloudflare:website", |
| 255 | "provider:cloudflare:worker", |
| 256 | "live", |
| 257 | ], |
| 258 | timeout: 600_000, |
| 259 | }, |
| 260 | ); |