1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
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 Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import * as pathe from "pathe";
13import { cloneFixture } from "../Utils/Fixture.ts";
14import { expectUrlContains } from "../Utils/Http.ts";
15import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts";
16
17const { test } = Test.make({ providers: Cloudflare.providers(), dev: true });
18
19const logLevel = Effect.provideService(
20 MinimumLogLevel,
21 process.env.DEBUG ? "Debug" : "Info",
22);
23
24const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "octane-app");
25const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
26
27const fixtureEntries = [
28 ".gitignore",
29 "package.json",
30 "octane.config.ts",
31 "vite.config.ts",
32 "index.html",
33 "src",
34 "public",
35];
36
37const memoInclude = [
38 "src/**",
39 "public/**",
40 "index.html",
41 "octane.config.ts",
42 "vite.config.ts",
43 "package.json",
44];
45
46// Tests are independent (per-test scratch stacks, private fixture clones),
47// so run them concurrently; suites are sequential by default.
48describe.concurrent(
49 "Octane dev",
50 { tags: ["provider:cloudflare", "provider:cloudflare:website"] },
51 () => {
52 test.provider(
53 "Octane dev: local dev server renders SSR and picks up edits",
54 (stack) =>
55 Effect.gen(function* () {
56 const fs = yield* FileSystem.FileSystem;
57 const path = yield* Path.Path;
58
59 yield* stack.destroy();
60
61 const rootDir = yield* cloneFixture(fixtureDir, {
62 prefix: "alchemy-octane-dev-",
63 tempRoot,
64 entries: fixtureEntries,
65 });
66
67 const site = yield* stack.deploy(
68 Cloudflare.Website.Octane("OctaneLocal", {
69 rootDir,
70 dev: { port: 0 },
71 memo: { include: memoInclude },
72 }),
73 );
74
75 // Local identity: the url points at the alchemy dev proxy — no
76 // cloud Worker exists.
77 expect(site.url).toBeDefined();
78 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
79
80 // SSR page rendered by Octane's dev middleware behind the proxy.
81 yield* expectUrlContains(`${site.url!}/`, "OCTANE_PAGE_MARKER", {
82 timeout: "120 seconds",
83 label: "octane dev SSR home page",
84 });
85
86 // Server route through the same middleware. Octane's dev server
87 // supplies no `context.platform` (upstream limitation), so the
88 // handler degrades to a null binding — the route itself must work.
89 const hello = yield* fetchJsonReady<{
90 marker: string;
91 binding: string | null;
92 hasWaitUntil: boolean;
93 }>(`${site.url!}/api/hello`);
94 expect(hello.marker).toBe("api-route-ok");
95 expect(hello.binding).toBeNull();
96 expect(hello.hasWaitUntil).toBe(false);
97
98 // ── HMR: edit the page in place. The stack is NOT re-applied — the
99 // dev server must pick the change up and serve it through the same
100 // alchemy proxy ──────────────────────────────────────────────────────
101 const appPath = path.join(rootDir, "src/App.tsx");
102 const app = yield* fs.readFileString(appPath);
103 yield* fs.writeFileString(
104 appPath,
105 app.replace("OCTANE_PAGE_MARKER", "OCTANE_PAGE_MARKER_V2"),
106 );
107
108 yield* expectUrlContains(`${site.url!}/`, "OCTANE_PAGE_MARKER_V2", {
109 timeout: "90 seconds",
110 label: "octane dev SSR page after edit",
111 });
112
113 yield* stack.destroy();
114 }).pipe(logLevel),
115 { tags: ["local"], timeout: 300_000 },
116 );
117
118 /**
119 * `Alchemy.remote()` opts the whole site OUT of local emulation: even under
120 * `alchemy dev` the Worker builds and deploys to real Cloudflare (pinning
121 * the stamped-mode delete path on destroy). This is the supported way to
122 * exercise bindings during dev while Octane's dev middleware cannot serve
123 * `context.platform`.
124 */
125 test.provider(
126 "Octane dev: Alchemy.remote() deploys the real Worker",
127 (stack) =>
128 Effect.gen(function* () {
129 const { accountId } = yield* yield* CloudflareEnvironment;
130
131 yield* stack.destroy();
132
133 const rootDir = yield* cloneFixture(fixtureDir, {
134 prefix: "alchemy-octane-dev-remote-",
135 tempRoot,
136 entries: fixtureEntries,
137 });
138
139 const bindingMarker = "octane-dev-remote-marker";
140
141 const site = yield* stack.deploy(
142 Cloudflare.Website.Octane("OctaneRemoteSite", {
143 rootDir,
144 workersDev: { enabled: true, previewsEnabled: true },
145 memo: { include: memoInclude },
146 env: {
147 TEST_BINDING: bindingMarker,
148 },
149 }).pipe(Alchemy.remote()),
150 );
151
152 // A real deployment: workers.dev URL, not the local proxy.
153 expect(site.url).toBeDefined();
154 expect(site.url).not.toMatch(/^http:\/\/localhost/);
155
156 yield* expectUrlContains(`${site.url!}/`, "OCTANE_PAGE_MARKER", {
157 timeout: "120 seconds",
158 label: "remote() SSR home page in dev mode",
159 });
160
161 // The deployed worker carries the real binding through
162 // `context.platform.env`.
163 const hello = yield* fetchJsonReady<{
164 marker: string;
165 binding: string | null;
166 hasWaitUntil: boolean;
167 }>(`${site.url!}/api/hello`);
168 expect(hello.marker).toBe("api-route-ok");
169 expect(hello.binding).toBe(bindingMarker);
170 expect(hello.hasWaitUntil).toBe(true);
171
172 yield* stack.destroy();
173
174 // The stamped-live row deletes the real Worker even in a dev run.
175 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
176 }).pipe(logLevel),
177 { tags: ["provider:cloudflare:worker", "live"], timeout: 600_000 },
178 );
179 },
180);
181
182/** GET `url` until it answers 200 with a JSON body. */
183const fetchJsonReady = <T>(url: string) =>
184 Effect.gen(function* () {
185 const client = yield* HttpClient.HttpClient;
186 return yield* client.get(url).pipe(
187 Effect.flatMap((res) =>
188 res.status === 200
189 ? Effect.flatMap(res.text, (body) =>
190 Effect.try({
191 try: () => JSON.parse(body) as T,
192 catch: () => new Error(`non-json body: ${body}`),
193 }),
194 )
195 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
196 ),
197 Effect.retry({
198 schedule: Schedule.min([
199 Schedule.exponential("500 millis"),
200 Schedule.spaced("2 seconds"),
201 ]),
202 times: 10,
203 }),
204 );
205 });