1import {
2 CredentialsFromToken,
3 GraphQLLive,
4 toConfig,
5} from "@distilled.cloud/railway";
6import {
7 Sandbox,
8 SandboxProvider,
9 type SandboxProps,
10} from "@/Railway/Sandbox.ts";
11import { describe, expect, it } from "alchemy-test";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as Layer from "effect/Layer";
15import { RailwayEnvironment } from "@/Railway/Environment.ts";
16import * as TestClock from "effect/testing/TestClock";
17import * as HttpClient from "effect/http/HttpClient";
18import * as HttpClientResponse from "effect/http/HttpClientResponse";
19
20const testLayer = (client: HttpClient.HttpClient) =>
21 SandboxProvider().pipe(
22 Layer.provideMerge(
23 Layer.mergeAll(
24 Layer.succeed(
25 RailwayEnvironment,
26 Effect.succeed({
27 ...toConfig({ token: "fixture" }),
28 workspaceId: "workspace",
29 }),
30 ),
31 CredentialsFromToken({ token: "fixture" }),
32 GraphQLLive,
33 Layer.succeed(HttpClient.HttpClient, client),
34 ),
35 ),
36 );
37
38const props: SandboxProps = { environment: { environmentId: "env" } };
39const attrs: Sandbox["Attributes"] = {
40 sandboxId: "box",
41 environmentId: "env",
42 projectId: undefined,
43 region: "us-west2",
44 status: "RUNNING",
45 idleTimeoutMinutes: 5,
46 networkIsolation: "ISOLATED",
47 createdAt: "2026-09-16T00:00:00Z",
48 domains: [],
49};
50const context = {
51 id: "Box",
52 fqn: "Box",
53 instanceId: "instance",
54 bindings: [],
55 session: undefined as never,
56};
57const cloud = (status: Sandbox["Attributes"]["status"]) => ({
58 id: attrs.sandboxId,
59 environmentId: attrs.environmentId,
60 region: attrs.region,
61 status,
62 idleTimeoutMinutes: attrs.idleTimeoutMinutes,
63 networkIsolation: attrs.networkIsolation,
64 createdAt: attrs.createdAt,
65 domains: [],
66});
67const http = (respond: (query: string) => unknown) =>
68 HttpClient.make((request) =>
69 Effect.sync(() => {
70 if (request.body._tag !== "Uint8Array")
71 throw new Error("Expected JSON request");
72 const { query } = JSON.parse(new TextDecoder().decode(request.body.body));
73 return HttpClientResponse.fromWeb(
74 request,
75 Response.json({ data: respond(query) }),
76 );
77 }),
78 );
79
80const immutable: Partial<SandboxProps>[] = [
81 { region: "us-east4-eqdc4a" },
82 { idleTimeoutMinutes: 0 },
83 { networkIsolation: "PRIVATE" },
84 { template: { name: "base" } },
85 { variables: { TOKEN: "fixture" } },
86 { publicDomains: [{ port: 3000 }] },
87 { resources: { cpu: 0.5, memoryGB: 1 } },
88 { sourceSandboxId: "source" },
89];
90
91describe(
92 "Railway Sandbox provider",
93 { tags: ["unit", "provider:railway", "provider:railway:sandbox", "local"] },
94 () => {
95 for (const option of immutable) {
96 const name = Object.keys(option)[0];
97 it.effect(`replaces when ${name} is changed or removed`, () =>
98 Effect.gen(function* () {
99 const provider = yield* Sandbox.Provider;
100 for (const [olds, news] of [
101 [props, { ...props, ...option }],
102 [{ ...props, ...option }, props],
103 ]) {
104 expect(
105 yield* provider.diff!({
106 ...context,
107 olds: olds!,
108 news: news!,
109 output: attrs,
110 oldBindings: [],
111 newBindings: [],
112 }),
113 ).toEqual({ action: "replace" });
114 }
115 }).pipe(
116 Effect.provide(
117 testLayer(
118 http(() => {
119 throw new Error("Diff must not perform I/O");
120 }),
121 ),
122 ),
123 ),
124 );
125 }
126
127 it.effect("ignores variable and route ordering", () =>
128 Effect.gen(function* () {
129 const provider = yield* Sandbox.Provider;
130 const olds = {
131 ...props,
132 variables: { A: "1", B: "2" },
133 template: { variables: { A: "1", B: "2" } },
134 publicDomains: [{ port: 3000 }, { port: 8080, prefix: "api" }],
135 };
136 const news = {
137 ...props,
138 variables: { B: "2", A: "1" },
139 template: { variables: { B: "2", A: "1" } },
140 publicDomains: [{ prefix: "api", port: 8080 }, { port: 3000 }],
141 };
142 expect(
143 yield* provider.diff!({
144 ...context,
145 olds,
146 news,
147 output: attrs,
148 oldBindings: [],
149 newBindings: [],
150 }),
151 ).toBeUndefined();
152 }).pipe(
153 Effect.provide(
154 testLayer(
155 http(() => {
156 throw new Error("Diff must not perform I/O");
157 }),
158 ),
159 ),
160 ),
161 );
162
163 it.effect(
164 "fails rather than returning a sandbox that never becomes ready",
165 () =>
166 Effect.gen(function* () {
167 const provider = yield* Sandbox.Provider;
168 const fiber = yield* provider
169 .reconcile({
170 ...context,
171 news: props,
172 olds: undefined,
173 output: undefined,
174 })
175 .pipe(Effect.flip, Effect.forkChild({ startImmediately: true }));
176 yield* TestClock.adjust("30 seconds");
177 expect(yield* Fiber.join(fiber)).toMatchObject({
178 _tag: "Railway.SandboxPending",
179 sandboxId: "box",
180 status: "CREATING",
181 });
182 }).pipe(
183 Effect.provide(
184 testLayer(
185 http((query) =>
186 query.includes("sandboxCreate")
187 ? { sandboxCreate: cloud("CREATING") }
188 : { sandbox: cloud("CREATING") },
189 ),
190 ),
191 ),
192 ),
193 );
194
195 it.effect(
196 "recreates a missing cached sandbox and preserves an existing adopted one",
197 () => {
198 let creates = 0;
199 let exists = false;
200 return Effect.gen(function* () {
201 const provider = yield* Sandbox.Provider;
202 const recreated = yield* provider.reconcile({
203 ...context,
204 news: props,
205 olds: props,
206 output: attrs,
207 });
208 expect(recreated.status).toBe("RUNNING");
209 expect(creates).toBe(1);
210 yield* provider.reconcile({
211 ...context,
212 news: props,
213 olds: undefined,
214 output: recreated,
215 });
216 expect(creates).toBe(1);
217 }).pipe(
218 Effect.provide(
219 testLayer(
220 http((query) => {
221 if (query.includes("sandboxCreate")) {
222 creates++;
223 exists = true;
224 return { sandboxCreate: cloud("RUNNING") };
225 }
226 return { sandbox: exists ? cloud("RUNNING") : null };
227 }),
228 ),
229 ),
230 );
231 },
232 );
233
234 it.effect(
235 "waits for DESTROYED instead of treating DESTROYING as absent",
236 () => {
237 let reads = 0;
238 return Effect.gen(function* () {
239 const provider = yield* Sandbox.Provider;
240 const fiber = yield* provider
241 .delete({ ...context, olds: props, output: attrs })
242 .pipe(Effect.forkChild({ startImmediately: true }));
243 yield* TestClock.adjust("2 seconds");
244 yield* Fiber.join(fiber);
245 expect(reads).toBe(3);
246 }).pipe(
247 Effect.provide(
248 testLayer(
249 http((query) =>
250 query.includes("sandboxDestroy")
251 ? { sandboxDestroy: cloud("DESTROYING") }
252 : {
253 sandbox: cloud(++reads >= 3 ? "DESTROYED" : "DESTROYING"),
254 },
255 ),
256 ),
257 ),
258 );
259 },
260 );
261 },
262);