1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import { MinimumLogLevel } from "effect/References";
9import * as pathe from "pathe";
10import { cloneFixture } from "../Utils/Fixture.ts";
11import { expectDirectStatus, expectUrlContains } from "../Utils/Http.ts";
12import {
13 expectWorkerExists,
14 waitForWorkerToBeDeleted,
15} from "../Utils/Worker.ts";
16
17const { test } = Test.make({ providers: Cloudflare.providers() });
18
19const logLevel = Effect.provideService(
20 MinimumLogLevel,
21 process.env.DEBUG ? "Debug" : "Info",
22);
23
24// Same rationale as Vite.test.ts: Vite's `vite:build-html` plugin expresses
25// emitted asset paths relative to `cwd`, so the temp clone has to live under
26// the same workspace root.
27const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
28
29const fixtureDir = pathe.resolve(import.meta.dirname, "foldkit-fixture");
30const workerFixtureDir = pathe.resolve(
31 import.meta.dirname,
32 "foldkit-worker-fixture",
33);
34
35const fixtureEntries = ["index.html", "package.json", "vite.config.ts", "src"];
36
37// Restrict the input memo to fixture sources so the test isn't re-hashing
38// the whole monorepo on every deploy.
39const memoInclude = ["index.html", "src/**", "package.json", "vite.config.ts"];
40
41const foldkitProps = (rootDir: string) => ({
42 rootDir,
43 workersDev: true,
44 compatibility: {
45 date: "2024-09-23",
46 flags: ["nodejs_compat"],
47 },
48 memo: { include: memoInclude },
49});
50
51const htmlPage = (marker: string) => `<!doctype html>
52<html lang="en">
53 <head>
54 <meta charset="UTF-8" />
55 <title>${marker}</title>
56 </head>
57 <body>
58 <div id="root"></div>
59 <script type="module" src="./src/entry.ts"></script>
60 </body>
61</html>
62`;
63
64describe.concurrent(
65 "Foldkit",
66 {
67 tags: [
68 "provider:cloudflare",
69 "provider:cloudflare:website",
70 "provider:cloudflare:worker",
71 "live",
72 ],
73 },
74 () => {
75 // The resource's reason to exist: a Foldkit app routes on the client, so
76 // `notFoundHandling` defaults to `single-page-application` and deep links
77 // boot the app without the caller configuring anything. The same project
78 // through `Website.Vite` 404s on `/counter/42` unless `assets` is passed
79 // by hand.
80 test.provider(
81 "Foldkit: deploys with SPA fallback by default",
82 (stack) =>
83 Effect.gen(function* () {
84 const { accountId } = yield* yield* CloudflareEnvironment;
85
86 yield* stack.destroy();
87
88 const rootDir = yield* cloneFixture(fixtureDir, {
89 prefix: "alchemy-foldkit-default-",
90 tempRoot,
91 entries: fixtureEntries,
92 });
93
94 const site = yield* stack.deploy(
95 Effect.gen(function* () {
96 // Deliberately no `assets` — the default is what's under test.
97 return yield* Cloudflare.Website.Foldkit(
98 "FixFoldkitDefault",
99 foldkitProps(rootDir),
100 );
101 }),
102 );
103
104 expect(site.url).toBeDefined();
105 expect(site.hash?.input).toBeDefined();
106 yield* expectWorkerExists(site.workerName, accountId);
107
108 yield* expectUrlContains(`${site.url!}/`, "Foldkit Fixture", {
109 timeout: "120 seconds",
110 label: "foldkit index",
111 });
112 // Deep link falls back to index.html so client-side routing can boot.
113 yield* expectUrlContains(
114 `${site.url!}/counter/42`,
115 "Foldkit Fixture",
116 {
117 timeout: "60 seconds",
118 label: "foldkit spa fallback",
119 },
120 );
121
122 yield* stack.destroy();
123 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
124 }).pipe(logLevel),
125 { timeout: 360_000 },
126 );
127
128 // An explicit `assets` must win over the built-in default rather than be
129 // overridden by it — a spread in the wrong order would silently ignore
130 // whatever the caller passed.
131 test.provider(
132 "Foldkit: an explicit assets config overrides the SPA default",
133 (stack) =>
134 Effect.gen(function* () {
135 const { accountId } = yield* yield* CloudflareEnvironment;
136
137 yield* stack.destroy();
138
139 const rootDir = yield* cloneFixture(fixtureDir, {
140 prefix: "alchemy-foldkit-override-",
141 tempRoot,
142 entries: fixtureEntries,
143 });
144
145 const site = yield* stack.deploy(
146 Effect.gen(function* () {
147 return yield* Cloudflare.Website.Foldkit("FixFoldkitOverride", {
148 ...foldkitProps(rootDir),
149 assets: {
150 notFoundHandling: "none",
151 },
152 });
153 }),
154 );
155
156 expect(site.url).toBeDefined();
157 yield* expectWorkerExists(site.workerName, accountId);
158
159 // The app itself still serves...
160 yield* expectUrlContains(`${site.url!}/`, "Foldkit Fixture", {
161 timeout: "120 seconds",
162 label: "foldkit override index",
163 });
164 // ...but with `notFoundHandling: "none"` the deep link is a miss
165 // rather than an index.html fallback.
166 yield* expectDirectStatus(`${site.url!}/counter/42`, 404, {
167 timeout: "60 seconds",
168 label: "foldkit override deep link",
169 });
170
171 yield* stack.destroy();
172 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
173 }).pipe(logLevel),
174 { timeout: 360_000 },
175 );
176
177 // A Foldkit deployment may carry a Worker entry in front of the assets
178 // (API routes, error reporting, Durable Objects). The client build still
179 // serves through the ASSETS binding, and the SPA fallback still applies
180 // behind it.
181 test.provider(
182 "Foldkit: a custom main entry serves API routes alongside the app",
183 (stack) =>
184 Effect.gen(function* () {
185 const { accountId } = yield* yield* CloudflareEnvironment;
186
187 yield* stack.destroy();
188
189 const rootDir = yield* cloneFixture(workerFixtureDir, {
190 prefix: "alchemy-foldkit-worker-",
191 tempRoot,
192 entries: fixtureEntries,
193 });
194
195 const site = yield* stack.deploy(
196 Effect.gen(function* () {
197 return yield* Cloudflare.Website.Foldkit("FixFoldkitWorker", {
198 ...foldkitProps(rootDir),
199 main: "src/worker.ts",
200 assets: {
201 runWorkerFirst: ["/api/*"],
202 },
203 env: {
204 GREETING: "foldkit-worker-fixture",
205 },
206 });
207 }),
208 );
209
210 expect(site.url).toBeDefined();
211 yield* expectWorkerExists(site.workerName, accountId);
212
213 // The Worker entry answers its own route from the binding.
214 yield* expectUrlContains(
215 `${site.url!}/api/hello`,
216 "foldkit-worker-fixture",
217 { timeout: "120 seconds", label: "foldkit worker api" },
218 );
219 // Everything else passes through to the assets binding.
220 yield* expectUrlContains(`${site.url!}/`, "Foldkit Fixture", {
221 timeout: "60 seconds",
222 label: "foldkit worker index",
223 });
224 // `runWorkerFirst` is merged over the SPA default, not instead of
225 // it — the deep link still falls back through `env.ASSETS.fetch`.
226 yield* expectUrlContains(
227 `${site.url!}/counter/42`,
228 "Foldkit Fixture",
229 {
230 timeout: "60 seconds",
231 label: "foldkit worker spa fallback",
232 },
233 );
234
235 yield* stack.destroy();
236 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
237 }).pipe(logLevel),
238 { timeout: 360_000 },
239 );
240
241 // Editing a source file must change the input hash so the next deploy
242 // rebuilds — the memo is keyed on the project tree, not on wall time.
243 test.provider(
244 "Foldkit: editing a source file republishes the assets",
245 (stack) =>
246 Effect.gen(function* () {
247 const { accountId } = yield* yield* CloudflareEnvironment;
248 const fs = yield* FileSystem.FileSystem;
249 const path = yield* Path.Path;
250
251 yield* stack.destroy();
252
253 const rootDir = yield* cloneFixture(fixtureDir, {
254 prefix: "alchemy-foldkit-edit-",
255 tempRoot,
256 entries: fixtureEntries,
257 });
258 const indexPath = path.join(rootDir, "index.html");
259
260 const v1Marker = "foldkit-v1-marker";
261 yield* fs.writeFileString(indexPath, htmlPage(v1Marker));
262
263 const site1 = yield* stack.deploy(
264 Effect.gen(function* () {
265 return yield* Cloudflare.Website.Foldkit(
266 "FixFoldkitEdit",
267 foldkitProps(rootDir),
268 );
269 }),
270 );
271
272 expect(site1.hash?.input).toBeDefined();
273 yield* expectUrlContains(`${site1.url!}/`, v1Marker, {
274 timeout: "120 seconds",
275 label: "foldkit edit v1",
276 });
277
278 const v2Marker = "foldkit-v2-marker";
279 yield* fs.writeFileString(indexPath, htmlPage(v2Marker));
280
281 const site2 = yield* stack.deploy(
282 Effect.gen(function* () {
283 return yield* Cloudflare.Website.Foldkit(
284 "FixFoldkitEdit",
285 foldkitProps(rootDir),
286 );
287 }),
288 );
289
290 expect(site2.hash?.input).toBeDefined();
291 expect(site2.hash?.input).not.toEqual(site1.hash?.input);
292 yield* expectUrlContains(`${site2.url!}/`, v2Marker, {
293 timeout: "60 seconds",
294 label: "foldkit edit v2",
295 });
296
297 yield* stack.destroy();
298 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
299 }).pipe(logLevel),
300 { timeout: 360_000 },
301 );
302 },
303);