1import * as machines from "@distilled.cloud/fly-io/machines";
2import * as Fly from "@/Fly";
3import { sameStopConfig } from "@/Fly/replicas.ts";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import { MinimumLogLevel } from "effect/References";
10import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
11import {
12 assertAppGone,
13 assertMachineGone,
14 assertOnlyMachine,
15 getText,
16 initialText,
17 startTraffic,
18 websiteChecks,
19 websiteServices,
20} from "./fixtures/deployment.ts";
21
22const { test } = Test.make({ providers: Fly.providers() });
23
24const logLevel = Effect.provideService(
25 MinimumLogLevel,
26 process.env.DEBUG ? "Debug" : "Info",
27);
28
29for (const bluegreen of [true, false]) {
30 test.provider(
31 bluegreen
32 ? "R08/R02 Vite SPA: v1 to v2 under un-retried traffic, forwarded policies, destroy, gone"
33 : "R06 Vite SPA: omitted deploy/shutdown keeps default rolling Machine identity",
34 (stack) =>
35 Effect.gen(function* () {
36 yield* stack.destroy();
37 const fs = yield* FileSystem.FileSystem;
38 const path = yield* Path.Path;
39 const rootDir = yield* cloneFixture(
40 path.resolve(
41 import.meta.dirname,
42 "../../Cloudflare/Website/vite-spa-fixture",
43 ),
44 {
45 prefix: "alchemy-vite-fly-",
46 tempRoot: path.resolve(import.meta.dirname, "../../../.tmp"),
47 entries: ["index.html", "package.json", "src"],
48 },
49 );
50 const index = path.join(rootDir, "index.html");
51 const original = yield* fs.readFileString(index);
52 const writeVersion = (version: string) =>
53 fs.writeFileString(
54 index,
55 original.replaceAll(
56 "Vite SPA fixture",
57 `Vite SPA fixture ${version}`,
58 ),
59 );
60 const deploy = () =>
61 stack.deploy(
62 Effect.gen(function* () {
63 const site = yield* Fly.Website.Vite("Web", {
64 rootDir,
65 ...(bluegreen
66 ? {
67 deploy: {
68 strategy: "bluegreen" as const,
69 healthTimeout: "90 seconds" as const,
70 },
71 shutdown: {
72 signal: "SIGTERM" as const,
73 timeout: "10 seconds" as const,
74 },
75 checks: websiteChecks("/index.html"),
76 services: websiteServices("/index.html"),
77 }
78 : {}),
79 memo: { include: ["index.html", "src/**", "package.json"] },
80 });
81 return { site };
82 }),
83 );
84 let appName: string | undefined;
85 yield* Effect.gen(function* () {
86 yield* writeVersion("v1");
87 const first = yield* deploy();
88 appName = first.site.app!.appName;
89 const url = first.site.url!;
90 const oldId = first.site.service!.machineId;
91 expect(url).toBe(`https://${appName}.fly.dev`);
92 const firstBody = yield* initialText(`${url}/`);
93 expect(firstBody).toContain("Vite SPA fixture v1");
94 const observed = yield* machines.getMachine({
95 app_name: appName,
96 machine_id: oldId,
97 });
98 expect(
99 observed.config?.env?.ALCHEMY_FLY_SHUTDOWN_TIMEOUT_MS,
100 ).toBeUndefined();
101 if (bluegreen) {
102 expect(observed.config?.metadata?.["alchemy.phase"]).toBe("active");
103 expect(observed.config?.stop_config?.signal).toBe("SIGTERM");
104 expect(
105 sameStopConfig(observed.config?.stop_config, {
106 signal: "SIGTERM",
107 timeout: "10000ms",
108 }),
109 ).toBe(true);
110 expect(observed.config?.checks?.website?.path).toBe("/index.html");
111 expect(observed.config?.services?.[0]?.checks?.[0]?.path).toBe(
112 "/index.html",
113 );
114 } else {
115 expect(
116 observed.config?.metadata?.["alchemy.generation"],
117 ).toBeUndefined();
118 }
119 const traffic = bluegreen
120 ? yield* startTraffic(`${url}/`)
121 : undefined;
122 if (traffic)
123 yield* traffic.waitFor((body) =>
124 body.includes("Vite SPA fixture v1"),
125 );
126 yield* writeVersion("v2");
127 const second = yield* deploy();
128 const newId = second.site.service!.machineId;
129 expect(second.site.url).toBe(url);
130 const secondBody = yield* getText(`${url}/`);
131 expect(secondBody).toContain("Vite SPA fixture v2");
132 if (traffic) {
133 yield* traffic.waitFor((body) =>
134 body.includes("Vite SPA fixture v2"),
135 );
136 const samples = yield* traffic.finish;
137 for (const body of samples)
138 expect([firstBody, secondBody]).toContain(body);
139 expect(newId).not.toBe(oldId);
140 yield* assertMachineGone(appName, oldId);
141 } else {
142 expect(newId).toBe(oldId);
143 }
144 yield* assertOnlyMachine(appName, newId);
145 }).pipe(
146 Effect.scoped,
147 Effect.ensuring(
148 Effect.gen(function* () {
149 yield* stack.destroy();
150 if (appName) yield* assertAppGone(appName);
151 }).pipe(Effect.orDie),
152 ),
153 );
154 }).pipe(logLevel),
155 // Two real frontend/image builds, registry pushes, routing overlap and teardown.
156 {
157 tags: [
158 "provider:fly",
159 "provider:fly:machine",
160 "provider:fly:website",
161 "live",
162 ],
163 timeout: 720_000,
164 },
165 );
166}