1import * as Cloudflare from "alchemy/Cloudflare";
2import * as Test from "alchemy/Test/Bun";
3import { expect } from "bun:test";
4import * as Console from "effect/Console";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import Stack from "../alchemy.run.ts";
9
10// Fresh `workers.dev` URLs transiently 404 while the route propagates.
11// `Test.getWhenReady` fails on that cold-start window and retries until the
12// worker serves a real response.
13const { getWhenReady } = Test;
14
15class AssetNotReady extends Data.TaggedError("AssetNotReady")<{
16 body: string;
17}> {}
18
19// Retry until the served body actually contains the expected marker — a 200
20// alone can't distinguish "still propagating" from "served".
21const getBodyWhenReady = (url: string, expected: string) =>
22 Effect.gen(function* () {
23 const res = yield* getWhenReady(url);
24 expect(res.status).toBe(200);
25 const body = yield* res.text;
26 if (!body.includes(expected)) {
27 return yield* Effect.fail(new AssetNotReady({ body }));
28 }
29 return body;
30 }).pipe(
31 Effect.retry({
32 while: (error) => error instanceof AssetNotReady,
33 schedule: Schedule.max([
34 Schedule.min([
35 Schedule.exponential("500 millis"),
36 Schedule.spaced("3 seconds"),
37 ]),
38 Schedule.recurs(20),
39 ]),
40 }),
41 );
42
43const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
44 providers: Cloudflare.providers(),
45 state: Cloudflare.state(),
46});
47
48// The first deploy runs the full Octane (vite) build, so give the hook more
49// headroom than the default 120s.
50const stack = beforeAll(deploy(Stack).pipe(Effect.tap(Console.log)), {
51 timeout: 600_000,
52});
53afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
54
55const base = Effect.map(stack, ({ url }) => {
56 if (!url) throw new Error("expected the site to expose a workers.dev url");
57 return url.replace(/\/+$/, "");
58});
59
60test(
61 "deploys and exposes a url",
62 Effect.gen(function* () {
63 const { url } = yield* stack;
64 expect(url).toBeString();
65 }),
66 { timeout: 180_000 },
67);
68
69test(
70 "serves the server-rendered home page",
71 Effect.gen(function* () {
72 const url = yield* base;
73 const html = yield* getBodyWhenReady(url, "Octane on Cloudflare Workers");
74 expect(html).toContain("Octane on Cloudflare Workers");
75 expect(html).toContain("Server-rendered by Octane, deployed by Alchemy.");
76 }),
77 { timeout: 180_000 },
78);
79
80test(
81 "compiles tailwind from vite.config.ts",
82 Effect.gen(function* () {
83 const url = yield* base;
84 // The utility class in the markup proves the page shipped with Tailwind
85 // classes; wait until the deployed HTML includes it.
86 const html = yield* getBodyWhenReady(url, "text-3xl");
87
88 // Octane's production server injects a per-route stylesheet link from the
89 // Vite client manifest. The compiled rule for the utility only exists if
90 // the @tailwindcss/vite plugin from the project's own vite.config.ts ran.
91 const link = html.match(
92 /<link[^>]*rel="stylesheet"[^>]*href="([^"]+)"[^>]*>/,
93 );
94 expect(link).not.toBeNull();
95 const href = link![1]!;
96 const cssUrl = href.startsWith("http")
97 ? href
98 : `${url}${href.startsWith("/") ? "" : "/"}${href}`;
99 const css = yield* getBodyWhenReady(cssUrl, ".text-3xl");
100 expect(css).toContain(".text-3xl");
101 }),
102 { timeout: 180_000 },
103);