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 */
12import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
13import { Credentials } from "@/Cloudflare/Credentials.ts";
14import * as Cloudflare from "@/Cloudflare/index.ts";
15import * as Test from "@/Test/Alchemy";
16import { expect } from "alchemy-test";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Path from "effect/Path";
20import * as Redacted from "effect/Redacted";
21import { spawn } from "node:child_process";
22import * as pathe from "pathe";
23import {
24 expectWorkerExists,
25 waitForWorkerToBeDeleted,
26} from "../Utils/Worker.ts";
27
28const { test } = Test.make({ providers: Cloudflare.providers() });
29
30const repoRoot = pathe.resolve(import.meta.dirname, "../../../../..");
31const workspaceCli = pathe.join(repoRoot, "packages/alchemy/bin/alchemy.js");
32
33const STACK = "B67MigrationTest";
34const 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 */
42const 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. */
73const 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
91test.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);