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 * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as pathe from "pathe";
11import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
12import { expectUrlContains } from "../../Cloudflare/Utils/Http.ts";
13
14const { test } = Test.make({ providers: Hetzner.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21const hasHetznerCreds = !!process.env.HCLOUD_TOKEN;
22
23const fixtureDir = pathe.resolve(
24 import.meta.dirname,
25 "../../AWS/Website/fixtures/octane-app",
26);
27const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
28const fixtureEntries = [
29 ".gitignore",
30 "package.json",
31 "octane.config.ts",
32 "vite.config.ts",
33 "index.html",
34 "src",
35 "public",
36];
37
38const waitUntilGone = (id: number) =>
39 servers.getServer({ id }).pipe(
40 Effect.as("found" as const),
41 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
42 Effect.repeat({
43 schedule: Schedule.spaced("1 second"),
44 until: (status) => status === "gone",
45 times: 10,
46 }),
47 );
48
49test.provider.skipIf(!hasHetznerCreds)(
50 "Octane: deploy, GET /, destroy, gone",
51 (stack) =>
52 Effect.gen(function* () {
53 yield* stack.destroy();
54
55 const rootDir = yield* cloneFixture(fixtureDir, {
56 prefix: "alchemy-octane-hetzner-",
57 tempRoot,
58 entries: fixtureEntries,
59 });
60
61 const fs = yield* FileSystem.FileSystem;
62 const pathMod = yield* Path.Path;
63 const configPath = pathMod.join(rootDir, "octane.config.ts");
64 const raw = yield* fs.readFileString(configPath);
65 expect(raw).not.toContain("adapter:");
66 expect(raw).not.toContain("@alchemy.run/frontend-frameworks");
67
68 const deployed = yield* stack.deploy(
69 Effect.gen(function* () {
70 const site = yield* Hetzner.Website.Octane("Web", {
71 rootDir,
72 memo: {
73 include: [
74 "src/**",
75 "public/**",
76 "package.json",
77 "octane.config.ts",
78 "vite.config.ts",
79 "index.html",
80 ],
81 },
82 });
83 return { site };
84 }),
85 );
86
87 expect(yield* fs.readFileString(configPath)).toBe(raw);
88 const url = deployed.site.url;
89 expect(url).toBeDefined();
90 expect(url).toMatch(/^http:\/\//);
91 expect(deployed.site.service).toBeDefined();
92 expect(deployed.site.server).toBeDefined();
93
94 yield* expectUrlContains(`${url!}/`, "OCTANE_AWS_PAGE_MARKER", {
95 timeout: "90 seconds",
96 label: "home page",
97 });
98 yield* expectUrlContains(
99 `${url!}/api/hello?echo=roundtrip`,
100 "OCTANE_AWS_API_MARKER",
101 {
102 timeout: "30 seconds",
103 label: "api route",
104 },
105 );
106
107 const serverId = deployed.site.server!.serverId;
108 yield* stack.destroy();
109 const gone = yield* waitUntilGone(serverId);
110 expect(gone).toEqual("gone");
111 }).pipe(
112 logLevel,
113 Effect.ensuring(stack.destroy().pipe(Effect.orDie)),
114 Effect.catchTag(["PreconditionFailed", "Forbidden"], (error) =>
115 Effect.logWarning(`skipping: Hetzner quota (${error._tag})`),
116 ),
117 ),
118 {
119 tags: [
120 "provider:hetzner",
121 "provider:hetzner:service",
122 "provider:hetzner:website",
123 "live",
124 ],
125 timeout: 180000,
126 },
127);