1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Test from "@/Test/Alchemy";
4import * as kv from "@distilled.cloud/cloudflare/kv";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientRequest from "effect/http/HttpClientRequest";
15import * as pathe from "pathe";
16import { cloneFixture } from "../Utils/Fixture.ts";
17import { expectUrlContains } from "../Utils/Http.ts";
18import {
19 expectWorkerExists,
20 waitForWorkerToBeDeleted,
21} from "../Utils/Worker.ts";
22
23const { test } = Test.make({ providers: Cloudflare.providers() });
24
25const logLevel = Effect.provideService(
26 MinimumLogLevel,
27 process.env.DEBUG ? "Debug" : "Info",
28);
29
30const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "octane-app");
31
32// Keep the temp clone under the alchemy package (same convention as the
33// Vite tests) so the project root stays representable relative to cwd —
34// and so `octane`/`@octanejs/*` resolve from the workspace's hoisted
35// node_modules when the fixture's own tree has none.
36const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
37
38const fixtureEntries = [
39 ".gitignore",
40 "package.json",
41 "octane.config.ts",
42 "vite.config.ts",
43 "index.html",
44 "src",
45 "public",
46];
47
48const memoInclude = [
49 "src/**",
50 "public/**",
51 "index.html",
52 "octane.config.ts",
53 "vite.config.ts",
54 "package.json",
55];
56
57const octaneProps = (rootDir: string) => ({
58 rootDir,
59 workersDev: { enabled: true, previewsEnabled: true },
60 memo: {
61 include: memoInclude,
62 },
63});
64
65class NamespaceStillExists extends Data.TaggedError("NamespaceStillExists") {}
66
67const waitForNamespaceToBeDeleted = Effect.fn(function* (
68 namespaceId: string,
69 accountId: string,
70) {
71 yield* kv.getNamespace({ accountId, namespaceId }).pipe(
72 Effect.flatMap(() => Effect.fail(new NamespaceStillExists())),
73 Effect.retry({
74 while: (e): e is NamespaceStillExists =>
75 e instanceof NamespaceStillExists,
76 schedule: Schedule.min([
77 Schedule.exponential(250),
78 Schedule.spaced("2 seconds"),
79 ]),
80 times: 10,
81 }),
82 Effect.catchTag("NamespaceNotFound", () => Effect.void),
83 );
84});
85
86/**
87 * Read `keyName` from the real cloud namespace via distilled's KV API,
88 * retrying through KV's brief read-after-write lag while the key is not
89 * yet visible.
90 */
91const readNamespaceValue = Effect.fn(function* (options: {
92 accountId: string;
93 namespaceId: string;
94 keyName: string;
95}) {
96 const res = yield* kv.getNamespaceValue(options).pipe(
97 Effect.retry({
98 while: (e): boolean => e._tag === "KeyNotFound",
99 schedule: Schedule.min([
100 Schedule.exponential("1 second"),
101 Schedule.spaced("3 seconds"),
102 ]),
103 times: 8,
104 }),
105 );
106 return yield* Effect.tryPromise(() =>
107 new Response(Stream.toReadableStream(res.body) as BodyInit).text(),
108 );
109});
110
111// Tests are independent (per-test scratch stacks, private fixture clones),
112// so run them concurrently; suites are sequential by default.
113describe.concurrent(
114 "Octane",
115 {
116 tags: [
117 "provider:cloudflare",
118 "provider:cloudflare:kv",
119 "provider:cloudflare:website",
120 "provider:cloudflare:worker",
121 "live",
122 ],
123 },
124 () => {
125 test.provider(
126 "Octane: deploys SSR + bindings + static assets and memoizes unchanged rebuilds",
127 (stack) =>
128 Effect.gen(function* () {
129 const { accountId } = yield* yield* CloudflareEnvironment;
130 const fs = yield* FileSystem.FileSystem;
131 const path = yield* Path.Path;
132
133 yield* stack.destroy();
134
135 const rootDir = yield* cloneFixture(fixtureDir, {
136 prefix: "alchemy-octane-",
137 tempRoot,
138 entries: fixtureEntries,
139 });
140
141 const bindingMarker = "octane-binding-marker";
142
143 const deploy = () =>
144 stack.deploy(
145 Effect.gen(function* () {
146 const siteKv = yield* Cloudflare.KV.Namespace("SiteKV");
147 const site = yield* Cloudflare.Website.Octane("OctaneSite", {
148 ...octaneProps(rootDir),
149 env: {
150 TEST_BINDING: bindingMarker,
151 SITE_KV: siteKv,
152 },
153 });
154 return { site, siteKv };
155 }),
156 );
157
158 const { site: site1, siteKv } = yield* deploy();
159
160 expect(site1.url).toBeDefined();
161 expect(site1.hash?.input).toBeDefined();
162 yield* expectWorkerExists(site1.workerName, accountId);
163
164 // SSR page: the adapter-emitted worker renders on asset miss.
165 yield* expectUrlContains(`${site1.url!}/`, "OCTANE_PAGE_MARKER", {
166 timeout: "120 seconds",
167 label: "SSR home page",
168 });
169
170 // Server route: reads `context.platform` — the adapter-forwarded
171 // `{ env, ctx }` pair — proving bindings reach Octane's runtime
172 // contract.
173 const hello = yield* fetchJsonReady<{
174 marker: string;
175 binding: string | null;
176 hasWaitUntil: boolean;
177 }>(`${site1.url!}/api/hello`);
178 expect(hello.marker).toBe("api-route-ok");
179 expect(hello.binding).toBe(bindingMarker);
180 expect(hello.hasWaitUntil).toBe(true);
181
182 // KV binding: the worker's PUT lands in the real namespace and the
183 // worker's GET reads it back through the native binding.
184 const kvKey = "octane-live-key";
185 const kvValue = "octane-live-value";
186 expect(siteKv.namespaceId).toBeDefined();
187 const put = yield* putJsonReady<{ put: boolean; key: string }>(
188 `${site1.url!}/api/kv?key=${kvKey}&value=${kvValue}`,
189 );
190 expect(put.put).toBe(true);
191 const got = yield* fetchJsonReady<{
192 key: string;
193 value: string | null;
194 }>(`${site1.url!}/api/kv?key=${kvKey}`);
195 expect(got.value).toBe(kvValue);
196
197 // Out-of-band: the write is visible through the cloud KV API — the
198 // binding really targeted the namespace this stack provisioned.
199 const observed = yield* readNamespaceValue({
200 accountId,
201 namespaceId: siteKv.namespaceId,
202 keyName: kvKey,
203 });
204 expect(observed).toBe(kvValue);
205
206 // Static asset from `public/`, served asset-first.
207 yield* expectUrlContains(`${site1.url!}/robots.txt`, "User-agent", {
208 timeout: "60 seconds",
209 label: "static asset",
210 });
211
212 // ── deploy 2: no changes ⇒ the rebuild-free input hash matches and
213 // the deploy short-circuits without building ─────────────────────────
214 const { site: site2 } = yield* deploy();
215
216 expect(site2.hash?.input).toBeDefined();
217 expect(site2.hash?.input).toEqual(site1.hash?.input);
218 expect(site2.url).toBe(site1.url);
219
220 // ── deploy 3: edit the page ⇒ the input hash changes and the new
221 // content deploys. The edited marker is asserted on the *dynamic*
222 // SSR page (worker-rendered per request) — a changed static asset at
223 // the same URL can stay stale at a PoP far longer than a
224 // worker-version flip ────────────────────────────────────────────────
225 const appPath = path.join(rootDir, "src/App.tsx");
226 const app = yield* fs.readFileString(appPath);
227 yield* fs.writeFileString(
228 appPath,
229 app.replace("OCTANE_PAGE_MARKER", "OCTANE_PAGE_MARKER_V2"),
230 );
231
232 const { site: site3 } = yield* deploy();
233
234 expect(site3.hash?.input).toBeDefined();
235 expect(site3.hash?.input).not.toEqual(site1.hash?.input);
236 yield* expectUrlContains(`${site3.url!}/`, "OCTANE_PAGE_MARKER_V2", {
237 timeout: "180 seconds",
238 label: "SSR page after edit",
239 });
240
241 yield* stack.destroy();
242 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
243
244 // Destroy must also delete the stack-provisioned KV namespace.
245 yield* waitForNamespaceToBeDeleted(siteKv.namespaceId, accountId);
246 }).pipe(logLevel),
247 { timeout: 600_000 },
248 );
249 },
250);
251
252/**
253 * GET `url` until it answers 200 with a JSON body (fresh workers.dev URLs
254 * take a few seconds to start serving). Mirrors Nuxt.test.ts's helper of
255 * the same name.
256 */
257const fetchJsonReady = <T>(url: string) =>
258 Effect.gen(function* () {
259 const client = yield* HttpClient.HttpClient;
260 return yield* client.get(url).pipe(
261 Effect.flatMap((res) =>
262 res.status === 200
263 ? Effect.flatMap(res.text, (body) =>
264 Effect.try({
265 try: () => JSON.parse(body) as T,
266 catch: () => new Error(`non-json body: ${body}`),
267 }),
268 )
269 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
270 ),
271 Effect.retry({
272 // Capped interval, ~90s total budget (workers.dev / DO propagation).
273 schedule: Schedule.min([
274 Schedule.exponential("500 millis"),
275 Schedule.spaced("2 seconds"),
276 ]),
277 times: 45,
278 }),
279 );
280 });
281
282/** PUT (empty body) to `url` until it answers 200 with a JSON body. */
283const putJsonReady = <T>(url: string) =>
284 HttpClient.execute(HttpClientRequest.put(url)).pipe(
285 Effect.flatMap((res) =>
286 res.status === 200
287 ? Effect.flatMap(res.text, (responseBody) =>
288 Effect.try({
289 try: () => JSON.parse(responseBody) as T,
290 catch: () => new Error(`non-json body: ${responseBody}`),
291 }),
292 )
293 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
294 ),
295 Effect.retry({
296 // Capped interval, ~90s total budget (workers.dev / DO propagation).
297 schedule: Schedule.min([
298 Schedule.exponential("500 millis"),
299 Schedule.spaced("2 seconds"),
300 ]),
301 times: 45,
302 }),
303 );