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 "../../Cloudflare/Website/vite-spa-fixture",
25);
26const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
27const fixtureEntries = ["index.html", "package.json", "src"];
28
29const waitUntilGone = (id: number) =>
30 servers.getServer({ id }).pipe(
31 Effect.as("found" as const),
32 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
33 Effect.repeat({
34 schedule: Schedule.spaced("1 second"),
35 until: (status) => status === "gone",
36 times: 10,
37 }),
38 );
39
40test.provider.skipIf(!hasHetznerCreds)(
41 "Vite SPA: deploy, GET /, destroy, gone",
42 (stack) =>
43 Effect.gen(function* () {
44 yield* stack.destroy();
45
46 const rootDir = yield* cloneFixture(fixtureDir, {
47 prefix: "alchemy-vite-hetzner-",
48 tempRoot,
49 entries: fixtureEntries,
50 });
51
52 const deployed = yield* stack.deploy(
53 Effect.gen(function* () {
54 const site = yield* Hetzner.Website.Vite("Web", {
55 rootDir,
56 memo: {
57 include: ["index.html", "src/**", "package.json"],
58 },
59 });
60 return { site };
61 }),
62 );
63
64 const url = deployed.site.url;
65 expect(url).toBeDefined();
66 expect(url).toMatch(/^http:\/\//);
67 expect(deployed.site.service).toBeDefined();
68 expect(deployed.site.server).toBeDefined();
69 expect(deployed.site.service!.serverId).toEqual(
70 deployed.site.server!.serverId,
71 );
72
73 yield* expectUrlContains(`${url!}/`, "Vite SPA fixture", {
74 timeout: "30 seconds",
75 label: "vite spa index",
76 });
77 yield* expectUrlContains(
78 `${url!}/missing-client-route`,
79 "Vite SPA fixture",
80 {
81 timeout: "15 seconds",
82 label: "vite spa fallback",
83 },
84 );
85
86 const client = yield* HttpClient.HttpClient;
87 const health = yield* client.get(`${url!}/health`).pipe(
88 Effect.flatMap((res) =>
89 res.status === 200
90 ? res.text
91 : Effect.fail(new Error(`health returned ${res.status}`)),
92 ),
93 Effect.retry({
94 schedule: Schedule.exponential("500 millis"),
95 times: 10,
96 }),
97 );
98 expect(health).toContain("ok");
99
100 const serverId = deployed.site.server!.serverId;
101 yield* stack.destroy();
102 const gone = yield* waitUntilGone(serverId);
103 expect(gone).toEqual("gone");
104 }).pipe(
105 logLevel,
106 Effect.ensuring(stack.destroy().pipe(Effect.orDie)),
107 Effect.catchTag(["PreconditionFailed", "Forbidden"], (error) =>
108 Effect.logWarning(`skipping: Hetzner quota (${error._tag})`),
109 ),
110 ),
111 {
112 tags: [
113 "provider:hetzner",
114 "provider:hetzner:service",
115 "provider:hetzner:website",
116 "live",
117 ],
118 timeout: 180_000,
119 },
120);