1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Alchemy from "@/index.ts";
4import * as Test from "@/Test/Alchemy";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import { MinimumLogLevel } from "effect/References";
10import * as pathe from "pathe";
11import { cloneFixture } from "../Utils/Fixture.ts";
12import { expectUrlContains } from "../Utils/Http.ts";
13import {
14 expectWorkerExists,
15 findWorker,
16 waitForWorkerToBeDeleted,
17} from "../Utils/Worker.ts";
18
19// `dev: true` runs local providers behind the RPC sidecar proxy by default,
20// matching the process topology of the real `alchemy dev` command.
21const { test } = Test.make({ providers: Cloudflare.providers(), dev: true });
22
23const logLevel = Effect.provideService(
24 MinimumLogLevel,
25 process.env.DEBUG ? "Debug" : "Info",
26);
27
28const fixtureDir = pathe.resolve(import.meta.dirname, "staticsite-fixture");
29const workerEntry = pathe.resolve(import.meta.dirname, "fixtures/worker.ts");
30const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
31
32const staticSiteProps = (cwd: string): Cloudflare.Website.StaticSiteProps => ({
33 command: "bash build.sh",
34 shell: true,
35 cwd,
36 outdir: "dist",
37 main: workerEntry,
38 workersDev: true,
39 compatibility: { date: "2024-01-01" },
40});
41
42const htmlPage = (marker: string) => `<!doctype html>
43<html>
44 <head><title>${marker}</title></head>
45 <body><h1>${marker}</h1></body>
46</html>
47`;
48
49// Tests are independent (per-test scratch stacks, private fixture clones),
50// so run them concurrently; suites are sequential by default.
51describe.concurrent(
52 "StaticSite dev",
53 {
54 tags: [
55 "provider:cloudflare",
56 "provider:cloudflare:website",
57 "provider:cloudflare:worker",
58 ],
59 },
60 () => {
61 /**
62 * The `dev.command` path: `alchemy dev` skips the build entirely and spawns
63 * the command as a long-lived child in the sidecar (`Command.Dev`). The URL
64 * the child prints to stdout becomes the site's `url`, and the Worker opts
65 * out of local emulation (`dev: { mode: "external" }`) — the dev server IS
66 * the site.
67 */
68 test.provider(
69 "StaticSite dev: dev.command serves the site through the external dev server",
70 (stack) =>
71 Effect.gen(function* () {
72 const { accountId } = yield* yield* CloudflareEnvironment;
73 const fs = yield* FileSystem.FileSystem;
74 const path = yield* Path.Path;
75
76 yield* stack.destroy();
77
78 const cwd = yield* cloneFixture(fixtureDir, {
79 prefix: "alchemy-staticsite-dev-cmd-",
80 tempRoot,
81 entries: ["src", "build.sh", "serve.mjs", ".gitignore"],
82 });
83
84 const marker = "staticsite-dev-command-marker";
85 yield* fs.writeFileString(
86 path.join(cwd, "src", "index.html"),
87 htmlPage(marker),
88 );
89
90 const site = yield* stack.deploy(
91 Effect.gen(function* () {
92 return yield* Cloudflare.Website.StaticSite("DevCmdSite", {
93 ...staticSiteProps(cwd),
94 dev: {
95 command: "bun serve.mjs",
96 env: { DEV_MARKER: "staticsite-dev-env-marker" },
97 },
98 });
99 }),
100 );
101
102 // The site's url is the dev server's own localhost address,
103 // extracted from the child's stdout.
104 expect(site.url).toBeDefined();
105 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
106
107 // Content serves through the dev server straight from `src/`.
108 yield* expectUrlContains(`${site.url!}/`, marker, {
109 timeout: "60 seconds",
110 label: "dev.command index",
111 });
112 yield* expectUrlContains(`${site.url!}/index.html`, marker, {
113 timeout: "30 seconds",
114 label: "dev.command explicit path",
115 });
116
117 // `dev.env` reached the spawned child process.
118 yield* expectUrlContains(
119 `${site.url!}/__dev-env`,
120 "staticsite-dev-env-marker",
121 {
122 timeout: "30 seconds",
123 label: "dev.command env passthrough",
124 },
125 );
126
127 // The build command was skipped — `dist/` was never produced.
128 expect(yield* fs.exists(path.join(cwd, "dist"))).toBe(false);
129
130 // No cloud Worker was created (external dev mode still records the
131 // real worker name in its stub attributes).
132 expect(site.workerName).toBeDefined();
133 expect(yield* findWorker(site.workerName, accountId)).toBeUndefined();
134
135 yield* stack.destroy();
136 }).pipe(logLevel),
137 { tags: ["live"], timeout: 300_000 },
138 );
139
140 /**
141 * Without `dev.command`, a dev run falls back to build mode: the build
142 * command runs (producing `dist/`) and the Worker serves the built assets
143 * from a local simulator — the `url` is a localhost dev-proxy address and
144 * no cloud Worker exists.
145 */
146 test.provider(
147 "StaticSite dev: without dev.command the build runs and a local Worker serves the assets",
148 (stack) =>
149 Effect.gen(function* () {
150 const { accountId } = yield* yield* CloudflareEnvironment;
151 const fs = yield* FileSystem.FileSystem;
152 const path = yield* Path.Path;
153
154 yield* stack.destroy();
155
156 const cwd = yield* cloneFixture(fixtureDir, {
157 prefix: "alchemy-staticsite-dev-build-",
158 tempRoot,
159 entries: ["src", "build.sh", ".gitignore"],
160 });
161
162 const marker = "staticsite-dev-build-marker";
163 yield* fs.writeFileString(
164 path.join(cwd, "src", "index.html"),
165 htmlPage(marker),
166 );
167
168 const site = yield* stack.deploy(
169 Effect.gen(function* () {
170 return yield* Cloudflare.Website.StaticSite(
171 "DevBuildSite",
172 staticSiteProps(cwd),
173 );
174 }),
175 );
176
177 // Local identity: the url points at the local dev proxy.
178 expect(site.url).toBeDefined();
179 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
180
181 // The build ran — `dist/` exists with the built page.
182 expect(yield* fs.exists(path.join(cwd, "dist", "index.html"))).toBe(
183 true,
184 );
185
186 // The built assets serve through the local Worker simulator.
187 yield* expectUrlContains(`${site.url!}/index.html`, marker, {
188 timeout: "60 seconds",
189 label: "dev build-mode assets",
190 });
191
192 // No cloud Worker was created.
193 expect(yield* findWorker(site.workerName, accountId)).toBeUndefined();
194
195 yield* stack.destroy();
196 }).pipe(logLevel),
197 { tags: ["live"], timeout: 300_000 },
198 );
199
200 /**
201 * `Alchemy.remote()` opts the whole site OUT of local emulation: even in a
202 * dev run, the build executes and the Worker deploys to real Cloudflare.
203 * Destroy deletes the cloud Worker (the state row is stamped live).
204 */
205 test.provider(
206 "StaticSite dev: Alchemy.remote() deploys the real Worker and destroy removes it",
207 (stack) =>
208 Effect.gen(function* () {
209 const { accountId } = yield* yield* CloudflareEnvironment;
210 const fs = yield* FileSystem.FileSystem;
211 const path = yield* Path.Path;
212
213 yield* stack.destroy();
214
215 const cwd = yield* cloneFixture(fixtureDir, {
216 prefix: "alchemy-staticsite-dev-remote-",
217 tempRoot,
218 entries: ["src", "build.sh", ".gitignore"],
219 });
220
221 const marker = "staticsite-dev-remote-marker";
222 yield* fs.writeFileString(
223 path.join(cwd, "src", "index.html"),
224 htmlPage(marker),
225 );
226
227 const site = yield* stack.deploy(
228 Effect.gen(function* () {
229 return yield* Cloudflare.Website.StaticSite(
230 "RemoteSite",
231 staticSiteProps(cwd),
232 ).pipe(Alchemy.remote());
233 }),
234 );
235
236 // Real identity: a non-local URL and a Worker that exists on
237 // Cloudflare.
238 expect(site.url).toBeDefined();
239 expect(site.url).not.toMatch(/^http:\/\/localhost/);
240 yield* expectWorkerExists(site.workerName, accountId);
241
242 // The real workers.dev URL serves the built assets.
243 yield* expectUrlContains(`${site.url!}/index.html`, marker, {
244 timeout: "120 seconds",
245 label: "remote() site marker",
246 });
247
248 yield* stack.destroy();
249
250 // The cloud Worker is gone after destroy (stamped-mode delete).
251 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
252 }).pipe(logLevel),
253 { tags: ["live"], timeout: 300_000 },
254 );
255 },
256);