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 pathe from "pathe";
9import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
10import { expectUrlContains } from "../../Cloudflare/Utils/Http.ts";
11import { prepareNextjsFixture } from "../../Cloudflare/Website/TypeScriptCompat.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/nextjs-app",
25);
26const fixtureEntries = [
27 ".gitignore",
28 "package.json",
29 "next.config.ts",
30 "tsconfig.json",
31 "app",
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 "Nextjs: deploy, GET /, destroy, gone",
48 (stack) =>
49 Effect.gen(function* () {
50 yield* stack.destroy();
51
52 // Clone OUTSIDE the repo (OS temp dir): an in-workspace clone makes
53 // Next treat the alchemy monorepo as the workspace root and look up
54 // the root's typescript (catalog:build = tsgo, which has no JS compiler).
55 const rootDir = yield* cloneFixture(fixtureDir, {
56 prefix: "alchemy-nextjs-hetzner-",
57 entries: fixtureEntries,
58 });
59 yield* prepareNextjsFixture(rootDir);
60
61 const deployed = yield* stack.deploy(
62 Effect.gen(function* () {
63 const site = yield* Hetzner.Website.Nextjs("Web", {
64 rootDir,
65 memo: {
66 include: [
67 "app/**",
68 "public/**",
69 "package.json",
70 "next.config.ts",
71 "tsconfig.json",
72 ],
73 },
74 });
75 return { site };
76 }),
77 );
78
79 const url = deployed.site.url;
80 expect(url).toBeDefined();
81 expect(url).toMatch(/^http:\/\//);
82 expect(deployed.site.service).toBeDefined();
83 expect(deployed.site.server).toBeDefined();
84
85 yield* expectUrlContains(`${url!}/`, "NEXTJS_AWS_PAGE_MARKER", {
86 timeout: "90 seconds",
87 label: "home page",
88 });
89 yield* expectUrlContains(
90 `${url!}/api/hello?echo=roundtrip`,
91 "NEXTJS_AWS_API_MARKER",
92 {
93 timeout: "30 seconds",
94 label: "api route",
95 },
96 );
97 yield* expectUrlContains(`${url!}/static`, "NEXTJS_AWS_STATIC_MARKER", {
98 timeout: "30 seconds",
99 label: "extra route",
100 });
101
102 const serverId = deployed.site.server!.serverId;
103 yield* stack.destroy();
104 const gone = yield* waitUntilGone(serverId);
105 expect(gone).toEqual("gone");
106 }).pipe(
107 logLevel,
108 Effect.ensuring(stack.destroy().pipe(Effect.orDie)),
109 Effect.catchTag(["PreconditionFailed", "Forbidden"], (error) =>
110 Effect.logWarning(`skipping: Hetzner quota (${error._tag})`),
111 ),
112 ),
113 {
114 tags: [
115 "provider:hetzner",
116 "provider:hetzner:service",
117 "provider:hetzner:website",
118 "live",
119 ],
120 timeout: 240000,
121 },
122);