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