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
10const { getWhenReady } = Test;
11
12class AssetNotReady extends Data.TaggedError("AssetNotReady")<{
13 body: string;
14}> {}
15
16// While the static-asset manifest is still propagating, Cloudflare can serve
17// placeholder content with a 200 — the status alone can't distinguish
18// "not yet" from "served", so retry until the body matches.
19const getBodyWhenReady = (url: string, expected: string) =>
20 Effect.gen(function* () {
21 const res = yield* getWhenReady(url);
22 expect(res.status).toBe(200);
23 const body = yield* res.text;
24 if (!body.includes(expected)) {
25 return yield* Effect.fail(new AssetNotReady({ body }));
26 }
27 return body;
28 }).pipe(
29 Effect.retry({
30 while: (error) => error instanceof AssetNotReady,
31 schedule: Schedule.max([
32 Schedule.min([
33 Schedule.exponential("500 millis"),
34 Schedule.spaced("3 seconds"),
35 ]),
36 Schedule.recurs(20),
37 ]),
38 }),
39 );
40
41const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
42 providers: Cloudflare.providers(),
43 state: Cloudflare.state(),
44});
45
46// The first deploy runs the full Vite build, so give the hook more headroom
47// than the default 120s.
48const stack = beforeAll(deploy(Stack).pipe(Effect.tap(Console.log)), {
49 timeout: 600_000,
50});
51afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
52
53const base = Effect.map(stack, ({ url }) => {
54 if (!url) throw new Error("expected the site to expose a workers.dev url");
55 return url.replace(/\/+$/, "");
56});
57
58test(
59 "deploys and exposes a url",
60 Effect.gen(function* () {
61 const { url } = yield* stack;
62 expect(url).toBeString();
63 }),
64 { timeout: 180_000 },
65);
66
67// The point of the example: the document carries the page. A client-only
68// deployment would answer with an empty `<div id="root">` here.
69test(
70 "serves markup rendered at the edge",
71 Effect.gen(function* () {
72 const url = yield* base;
73 const html = yield* getBodyWhenReady(url, 'id="count"');
74 expect(html).toContain(">0<");
75 // The title comes from the rendered Document, not from index.html.
76 expect(html).toContain("<title>Counter: 0</title>");
77 }),
78 { timeout: 180_000 },
79);
80
81// Flags are derived from the request, so a different request renders a
82// different page before any JavaScript runs.
83test(
84 "renders request-derived flags",
85 Effect.gen(function* () {
86 const url = yield* base;
87 const html = yield* getBodyWhenReady(`${url}/?count=7`, 'id="count"');
88 expect(html).toContain(">7<");
89 expect(html).toContain("<title>Counter: 7</title>");
90 }),
91 { timeout: 180_000 },
92);
93
94// Hydration compares the stamp before adopting any DOM, so a render without
95// one is a page no client can take over.
96test(
97 "stamps the handoff the client hydrates against",
98 Effect.gen(function* () {
99 const url = yield* base;
100 const html = yield* getBodyWhenReady(url, "data-foldkit-app");
101 expect(html).toContain("data-foldkit-build");
102 // The Flags the server used ride along for the client to decode.
103 expect(html).toContain("data-foldkit-flags");
104 }),
105 { timeout: 180_000 },
106);
107
108// A deep link has no file of its own, so it must reach the Worker and be
109// rendered — not be answered by the asset layer with the template.
110test(
111 "renders a path that matches no file",
112 Effect.gen(function* () {
113 const url = yield* base;
114 const html = yield* getBodyWhenReady(`${url}/anything`, 'id="count"');
115 expect(html).toContain("data-foldkit-app");
116 }),
117 { timeout: 180_000 },
118);