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 { MinimumLogLevel } from "effect/References";
7import * as pathe from "pathe";
8import { cloneFixture } from "../Utils/Fixture.ts";
9import { expectUrlContains } from "../Utils/Http.ts";
10import {
11 expectWorkerExists,
12 waitForWorkerToBeDeleted,
13} from "../Utils/Worker.ts";
14
15const { test } = Test.make({ providers: Cloudflare.providers() });
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22const fixtureDir = pathe.resolve(
23 import.meta.dirname,
24 "fixtures",
25 "octane-spa-app",
26);
27
28// Keep the temp clone under the alchemy package (same convention as the
29// Vite tests) so the project root stays representable relative to cwd —
30// and so `octane`/`@octanejs/vite-plugin` resolve from the workspace's
31// hoisted node_modules when the fixture's own tree has none.
32const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
33
34const fixtureEntries = [
35 ".gitignore",
36 "package.json",
37 "vite.config.ts",
38 "index.html",
39 "src",
40 "public",
41];
42
43const memoInclude = [
44 "src/**",
45 "public/**",
46 "index.html",
47 "vite.config.ts",
48 "package.json",
49];
50
51/** The shell marker baked into the fixture's `index.html` head. */
52const SPA_SHELL_MARKER = "octane-spa-shell";
53
54// ─────────────────────────────────────────────────────────────────────
55// Octane SPA via `Website.Vite` — the documented path for route-less,
56// client-only Octane apps (no `octane.config.ts`): the `octane()`
57// compiler plugin in the app's own `vite.config.ts` composes with the
58// injected Cloudflare Vite plugin, the project builds as a plain Vite
59// SPA (assets only), and `notFoundHandling: "single-page-application"`
60// makes deep links serve the app shell so the client router/app boots.
61// Fullstack Octane apps (routes + SSR) are covered by Octane.test.ts.
62// ─────────────────────────────────────────────────────────────────────
63describe.concurrent(
64 "OctaneSpa",
65 {
66 tags: [
67 "provider:cloudflare",
68 "provider:cloudflare:website",
69 "provider:cloudflare:worker",
70 "live",
71 ],
72 },
73 () => {
74 test.provider(
75 "Vite: client-only Octane app deploys as a SPA with shell fallback",
76 (stack) =>
77 Effect.gen(function* () {
78 const { accountId } = yield* yield* CloudflareEnvironment;
79
80 yield* stack.destroy();
81
82 const rootDir = yield* cloneFixture(fixtureDir, {
83 prefix: "alchemy-octane-spa-",
84 tempRoot,
85 entries: fixtureEntries,
86 });
87
88 const site = yield* stack.deploy(
89 Effect.gen(function* () {
90 return yield* Cloudflare.Website.Vite("OctaneSpaSite", {
91 rootDir,
92 workersDev: { enabled: true, previewsEnabled: true },
93 memo: { include: memoInclude },
94 assets: {
95 notFoundHandling: "single-page-application",
96 },
97 });
98 }),
99 );
100
101 expect(site.url).toBeDefined();
102 expect(site.hash?.input).toBeDefined();
103 yield* expectWorkerExists(site.workerName, accountId);
104
105 // (a) `/` serves the app shell (identified by the head marker).
106 // The page markup renders only in the browser, so its marker must
107 // be absent from the raw HTML.
108 const shellBody = yield* expectUrlContains(
109 `${site.url!}/`,
110 SPA_SHELL_MARKER,
111 {
112 timeout: "120 seconds",
113 label: "SPA shell at /",
114 },
115 );
116 expect(shellBody).not.toContain("OCTANE_SPA_PAGE_MARKER");
117
118 // (b) A hard GET to an unregistered deep route serves the shell
119 // with a 200 (`expectUrlContains` requires `res.ok`) — the SPA
120 // fallback that lets the client-side app boot on any URL.
121 const deepBody = yield* expectUrlContains(
122 `${site.url!}/widgets/42`,
123 SPA_SHELL_MARKER,
124 {
125 timeout: "60 seconds",
126 label: "deep link serves SPA shell",
127 },
128 );
129 expect(deepBody).not.toContain("OCTANE_SPA_PAGE_MARKER");
130
131 // (c) The hydrating client bundle serves and carries the content
132 // the browser renders: extract the hashed module the shell loads
133 // and assert the page marker is inside it.
134 const scriptSrc = shellBody.match(/\/assets\/[^"']+\.js/)?.[0];
135 expect(scriptSrc).toBeDefined();
136 yield* expectUrlContains(
137 `${site.url!}${scriptSrc!}`,
138 "OCTANE_SPA_PAGE_MARKER",
139 {
140 timeout: "60 seconds",
141 label: "client bundle carries the page content",
142 },
143 );
144
145 // (d) A `public/` asset serves its own bytes.
146 const robots = yield* expectUrlContains(
147 `${site.url!}/robots.txt`,
148 "User-agent",
149 {
150 timeout: "60 seconds",
151 label: "static asset with SPA handling",
152 },
153 );
154 expect(robots).not.toContain(SPA_SHELL_MARKER);
155
156 yield* stack.destroy();
157 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
158 }).pipe(logLevel),
159 { timeout: 360_000 },
160 );
161 },
162);