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 Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import Stack from "../alchemy.run.ts";
10
11const { getWhenReady } = Test;
12
13class AssetNotReady extends Data.TaggedError("AssetNotReady")<{
14 body: string;
15}> {}
16
17const getBodyWhenReady = (url: string, expected: string) =>
18 Effect.gen(function* () {
19 const res = yield* getWhenReady(url);
20 expect(res.status).toBe(200);
21 const body = yield* res.text;
22 if (!body.includes(expected)) {
23 return yield* Effect.fail(new AssetNotReady({ body }));
24 }
25 return body;
26 }).pipe(
27 Effect.retry({
28 while: (error) => error instanceof AssetNotReady,
29 schedule: Schedule.min([
30 Schedule.exponential("500 millis"),
31 Schedule.spaced("3 seconds"),
32 ]),
33 times: 10,
34 }),
35 );
36
37const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
38 providers: Hetzner.providers(),
39 state: Alchemy.localState(),
40 profile: process.env.ALCHEMY_PROFILE,
41});
42
43// Pre-deploy cleanup must not close the suite's shared runtime scope.
44const stack = beforeAll(
45 Effect.sync(Test.defaultStage).pipe(
46 Effect.flatMap((stage) => Alchemy.destroy({ stack: Stack, stage })),
47 Effect.andThen(deploy(Stack)),
48 Effect.tap(Console.log),
49 ),
50 {
51 timeout: 600_000,
52 },
53);
54afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
55 timeout: 600_000,
56});
57
58const base = Effect.map(stack, ({ url }) => {
59 if (!url) throw new Error("expected the site to expose a url");
60 return String(url).replace(/\/+$/, "");
61});
62
63test(
64 "deploys and exposes a url",
65 Effect.gen(function* () {
66 const { url } = yield* stack;
67 expect(url).toBeString();
68 }),
69 { timeout: 180_000 },
70);
71
72test(
73 "serves the home page",
74 Effect.gen(function* () {
75 const url = yield* base;
76 const html = yield* getBodyWhenReady(url, "Hello from vinext on Hetzner!");
77 expect(html).toContain("vinext on Hetzner");
78 }),
79 { timeout: 180_000 },
80);
81
82test(
83 "serves the ISR page",
84 Effect.gen(function* () {
85 const url = yield* base;
86 const html = yield* getBodyWhenReady(`${url}/isr`, "ISR");
87 expect(html).toContain("revalidate 60s");
88 }),
89 { timeout: 180_000 },
90);
91
92test(
93 "serves the dynamic API route",
94 Effect.gen(function* () {
95 const url = yield* base;
96 const res = yield* getWhenReady(`${url}/api/hello?name=Alchemy`);
97 expect(res.status).toBe(200);
98 const body = yield* res.json;
99 expect(body).toEqual({
100 name: "Alchemy",
101 greeting: "Hello from vinext on Hetzner!",
102 });
103 }),
104 { timeout: 180_000 },
105);
106
107test(
108 "serves a static asset from public/",
109 Effect.gen(function* () {
110 const url = yield* base;
111 const body = yield* getBodyWhenReady(`${url}/robots.txt`, "User-agent: *");
112 expect(body).toContain("User-agent: *");
113 }),
114 { timeout: 180_000 },
115);