1import * as Hetzner from "@/Hetzner";
2import * as Test from "@/Test/Alchemy";
3import * as servers from "@distilled.cloud/hetzner/servers";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as pathe from "pathe";
10import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
11import { expectUrlContains } from "../../Cloudflare/Utils/Http.ts";
12
13const { test } = Test.make({ providers: Hetzner.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const hasHetznerCreds = !!process.env.HCLOUD_TOKEN;
21
22const fixtureDir = pathe.resolve(
23 import.meta.dirname,
24 "../../AWS/Website/fixtures/astro-app",
25);
26const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
27const fixtureEntries = [
28 ".gitignore",
29 "package.json",
30 "astro.config.mjs",
31 "src",
32 "public",
33];
34
35const waitUntilGone = (id: number) =>
36 servers.getServer({ id }).pipe(
37 Effect.as("found" as const),
38 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
39 Effect.repeat({
40 schedule: Schedule.spaced("1 second"),
41 until: (status) => status === "gone",
42 times: 10,
43 }),
44 );
45
46test.provider.skipIf(!hasHetznerCreds)(
47 "Astro SSR: GET / and a dynamic API route",
48 (stack) =>
49 Effect.gen(function* () {
50 yield* stack.destroy();
51
52 const rootDir = yield* cloneFixture(fixtureDir, {
53 prefix: "alchemy-astro-hetzner-",
54 tempRoot,
55 entries: fixtureEntries,
56 });
57
58 const deployed = yield* stack.deploy(
59 Effect.gen(function* () {
60 const site = yield* Hetzner.Website.Astro("Web", {
61 rootDir,
62 memo: {
63 include: [
64 "src/**",
65 "public/**",
66 "package.json",
67 "astro.config.mjs",
68 ],
69 },
70 });
71 return { site };
72 }),
73 );
74
75 const url = deployed.site.url;
76 expect(url).toBeDefined();
77 expect(url).toMatch(/^http:\/\//);
78 expect(deployed.site.service).toBeDefined();
79 expect(deployed.site.server).toBeDefined();
80
81 yield* expectUrlContains(`${url!}/`, "ASTRO_AWS_PAGE_MARKER", {
82 timeout: "90 seconds",
83 label: "astro ssr home",
84 });
85 yield* expectUrlContains(
86 `${url!}/api/hello?echo=roundtrip`,
87 "ASTRO_AWS_API_MARKER",
88 {
89 timeout: "30 seconds",
90 label: "astro api route",
91 },
92 );
93
94 const client = yield* HttpClient.HttpClient;
95 const health = yield* client.get(`${url!}/health`).pipe(
96 Effect.flatMap((res) =>
97 res.status === 200
98 ? res.text
99 : Effect.fail(new Error(`health returned ${res.status}`)),
100 ),
101 Effect.retry({
102 schedule: Schedule.exponential("500 millis"),
103 times: 10,
104 }),
105 );
106 expect(health).toContain("ok");
107
108 const serverId = deployed.site.server!.serverId;
109 yield* stack.destroy();
110 const gone = yield* waitUntilGone(serverId);
111 expect(gone).toEqual("gone");
112 }).pipe(
113 logLevel,
114 Effect.ensuring(stack.destroy().pipe(Effect.orDie)),
115 Effect.catchTag(["PreconditionFailed", "Forbidden"], (error) =>
116 Effect.logWarning(`skipping: Hetzner quota (${error._tag})`),
117 ),
118 ),
119 {
120 tags: [
121 "provider:hetzner",
122 "provider:hetzner:service",
123 "provider:hetzner:website",
124 "live",
125 ],
126 timeout: 180_000,
127 },
128);