1import * as Alchemy from "alchemy";
2import * as Hetzner from "alchemy/Hetzner";
3import * as Test from "alchemy/Test/Bun";
4import { expect } from "bun:test";
5import * as Console from "effect/Console";
6import * as Effect from "effect/Effect";
7import Stack from "../alchemy.run.ts";
8
9// A fresh Hetzner deployment (and the Hetzner Server behind it) can serve
10// transient 404/5xx responses while it propagates. `Test.getWhenReady`
11// fails on that cold-start window and retries until the site serves a
12// real response.
13const { getWhenReady } = Test;
14
15const hasCreds = !!process.env.HCLOUD_TOKEN;
16
17const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
18 providers: Hetzner.providers(),
19 state: Alchemy.localState(),
20 profile: process.env.ALCHEMY_PROFILE,
21});
22
23// The first deploy runs the full Waku build AND creates a Hetzner
24// distribution (~5-10 minutes), so give the hook far more headroom than
25// the default 120s.
26if (!hasCreds) {
27 test.skip("skipped without HCLOUD_TOKEN", Effect.void);
28} else {
29 const stack = beforeAll(deploy(Stack).pipe(Effect.tap(Console.log)), {
30 timeout: 600_000,
31 });
32 // Deleting the Hetzner deployment takes several minutes too.
33 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
34 timeout: 600_000,
35 });
36
37 const base = Effect.map(stack, ({ url }) => {
38 if (!url) throw new Error("expected the site to expose a url");
39 return String(url).replace(/\/+$/, "");
40 });
41
42 test(
43 "deploys and exposes a url",
44 Effect.gen(function* () {
45 const { url } = yield* stack;
46 expect(url).toBeString();
47 }),
48 { timeout: 180_000 },
49 );
50
51 test(
52 "serves the server-rendered home page",
53 Effect.gen(function* () {
54 const url = yield* base;
55 const res = yield* getWhenReady(url);
56 expect(res.status).toBe(200);
57 const html = yield* res.text;
58 // The `GREETING` env value from alchemy.run.ts, read via `getEnv` in the
59 // dynamic RSC page — proves the Hetzner Server rendered it at request time.
60 expect(html).toContain("Hello from Waku on Hetzner!");
61 expect(html).toContain(
62 "This page is rendered by the server on every request.",
63 );
64 }),
65 { timeout: 180_000 },
66 );
67
68 test(
69 "compiles tailwind from waku.config.ts",
70 Effect.gen(function* () {
71 const url = yield* base;
72 const res = yield* getWhenReady(url);
73 expect(res.status).toBe(200);
74 const html = yield* res.text;
75 // The utility class from src/pages/index.tsx made it into the markup.
76 expect(html).toContain("text-3xl");
77
78 // Locate the emitted stylesheet. Waku links the compiled CSS bundle in
79 // the document head; the @tailwindcss/vite plugin registered via
80 // waku.config.ts's `vite` field is what compiles it.
81 const links = [...html.matchAll(/<link\b[^>]*>/g)]
82 .map((m) => m[0])
83 .filter((tag) => /rel="stylesheet"/.test(tag))
84 .map((tag) => /href="([^"]+)"/.exec(tag)?.[1])
85 .filter((href): href is string => !!href);
86 expect(links.length).toBeGreaterThan(0);
87
88 let compiled = "";
89 for (const href of links) {
90 const cssUrl = href.startsWith("http")
91 ? href
92 : `${url}${href.startsWith("/") ? "" : "/"}${href}`;
93 const cssRes = yield* getWhenReady(cssUrl);
94 expect(cssRes.status).toBe(200);
95 compiled += yield* cssRes.text;
96 }
97
98 // The compiled rule proves tailwind ran through the plugin from
99 // waku.config.ts — not just that the class name appears in markup.
100 expect(compiled).toContain(".text-3xl");
101 expect(compiled).toContain("font-bold");
102 }),
103 { timeout: 180_000 },
104 );
105
106 test(
107 "serves a static asset from public/",
108 Effect.gen(function* () {
109 const url = yield* base;
110 const res = yield* getWhenReady(`${url}/hello.txt`);
111 expect(res.status).toBe(200);
112 expect(yield* res.text).toContain("hello from public/");
113 }),
114 { timeout: 180_000 },
115 );
116}