1import * as Command from "@/Command/index.ts";
2import * as Provider from "@/Provider.ts";
3import * as Test from "@/Test/Alchemy";
4import { assert, describe, expect, it } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Schedule from "effect/Schedule";
8import * as pathe from "pathe";
9import { assertDead, lifecycleFixture } from "./fixture/lifecycle-support.ts";
10
11const { test } = Test.make({
12 // DevServer is provider-agnostic — register it directly without dragging
13 // in a cloud provider's auth chain.
14 providers: Command.providers(),
15 dev: true,
16});
17
18// Typed provider errors are an in-process contract: across the `alchemy dev`
19// RPC boundary (the default dev-test topology) a failed lifecycle op is
20// re-thrown as a flattened defect — `_tag`/`reason` do not survive
21// `Schema.Defect` serialization — so assertions on `CommandError`'s shape
22// must run against the in-process provider.
23const { test: inProcessTest } = Test.make({
24 providers: Command.providers(),
25 dev: true,
26 sidecar: false,
27});
28
29const fixtureDir = pathe.resolve(import.meta.dirname, "fixture");
30const fixtureScript = pathe.join(fixtureDir, "long-running.cjs");
31const urlServerScript = pathe.join(fixtureDir, "url-server.cjs");
32const dieScript = pathe.join(fixtureDir, "die.cjs");
33
34// The provider runs `command.split(" ")` and uses `shell: false`, so the
35// fixture path must not contain spaces. The in-repo path doesn't, but a CI
36// clone under e.g. `C:\Program Files\...` would. Fail loudly with a clear
37// message instead of letting the test hang on a misparsed argv.
38if (fixtureScript.includes(" ") || urlServerScript.includes(" ")) {
39 throw new Error(
40 `DevServer test fixture path contains a space, which the provider's ` +
41 `argv split cannot represent: ${fixtureScript} / ${urlServerScript}`,
42 );
43}
44
45const isAlive = (pid: number) =>
46 Effect.sync(() => {
47 try {
48 process.kill(pid, 0);
49 return true;
50 } catch {
51 return false;
52 }
53 });
54
55const readPidFile = (path: string) =>
56 Effect.gen(function* () {
57 const fs = yield* FileSystem.FileSystem;
58 const content = yield* fs.readFileString(path);
59 return JSON.parse(content) as { pid: number; marker: string };
60 });
61
62const waitForPidFile = (path: string, marker: string) =>
63 Effect.gen(function* () {
64 const fs = yield* FileSystem.FileSystem;
65 const exists = yield* fs.exists(path);
66 if (!exists) {
67 return yield* Effect.fail(new Error("pid file not yet present"));
68 }
69 const parsed = yield* readPidFile(path);
70 if (parsed.marker !== marker) {
71 return yield* Effect.fail(
72 new Error(`pid file marker ${parsed.marker} !== ${marker}`),
73 );
74 }
75 return parsed;
76 }).pipe(
77 Effect.retry({
78 schedule: Schedule.spaced("100 millis"),
79 times: 100,
80 }),
81 );
82
83const waitForDeath = (pid: number) =>
84 isAlive(pid).pipe(
85 Effect.flatMap((alive) =>
86 alive
87 ? Effect.fail(new Error(`pid ${pid} still alive`))
88 : Effect.succeed(undefined),
89 ),
90 Effect.retry({
91 schedule: Schedule.spaced("100 millis"),
92 times: 50,
93 }),
94 );
95
96test.provider(
97 "list returns [] (non-listable local dev-server process)",
98 () =>
99 Effect.gen(function* () {
100 // DevServer is a local dev-server child process, not a cloud resource —
101 // there is no remote enumeration API, so list() is the non-listable
102 // pattern and always returns []. No deploy is needed to observe this.
103 const provider = yield* Provider.findProvider(Command.Dev);
104 const all = yield* provider.list();
105 expect(all).toEqual([]);
106 }),
107 { tags: ["local"], timeout: 30_000 },
108);
109
110test.provider(
111 "starts the process",
112 (stack) =>
113 Effect.gen(function* () {
114 const fs = yield* FileSystem.FileSystem;
115 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
116 const pidFile = pathe.join(tmp, "pid.json");
117
118 yield* stack.deploy(
119 Command.Dev("Dev", {
120 command: `node ${fixtureScript}`,
121 env: { PID_FILE: pidFile, MARKER: "start" },
122 }),
123 );
124
125 const { pid } = yield* waitForPidFile(pidFile, "start");
126 expect(yield* isAlive(pid)).toBe(true);
127
128 yield* stack.destroy();
129 yield* waitForDeath(pid);
130 }),
131 { tags: ["local"], timeout: 30_000 },
132);
133
134test.provider(
135 "runs multiple distinct dev servers concurrently",
136 (stack) =>
137 Effect.gen(function* () {
138 const fs = yield* FileSystem.FileSystem;
139 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
140 const pidFileA = pathe.join(tmp, "pid-a.json");
141 const pidFileB = pathe.join(tmp, "pid-b.json");
142
143 yield* stack.deploy(
144 Effect.gen(function* () {
145 yield* Command.Dev("Alpha", {
146 command: `node ${fixtureScript}`,
147 env: { PID_FILE: pidFileA, MARKER: "alpha" },
148 });
149 yield* Command.Dev("Beta", {
150 command: `node ${fixtureScript}`,
151 env: { PID_FILE: pidFileB, MARKER: "beta" },
152 });
153 }),
154 );
155
156 const alpha = yield* waitForPidFile(pidFileA, "alpha");
157 const beta = yield* waitForPidFile(pidFileB, "beta");
158
159 expect(alpha.pid).not.toBe(beta.pid);
160 expect(yield* isAlive(alpha.pid)).toBe(true);
161 expect(yield* isAlive(beta.pid)).toBe(true);
162
163 yield* stack.destroy();
164 yield* waitForDeath(alpha.pid);
165 yield* waitForDeath(beta.pid);
166 }),
167 { tags: ["local"], timeout: 30_000 },
168);
169
170test.provider(
171 "keeps the process running across an unchanged update",
172 (stack) =>
173 Effect.gen(function* () {
174 const fs = yield* FileSystem.FileSystem;
175 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
176 const pidFile = pathe.join(tmp, "pid.json");
177
178 const program = Command.Dev("Dev", {
179 command: `node ${fixtureScript}`,
180 env: { PID_FILE: pidFile, MARKER: "stable" },
181 });
182
183 yield* stack.deploy(program);
184 const first = yield* waitForPidFile(pidFile, "stable");
185
186 // Re-deploy the same props. Provider hashes match → keep running.
187 yield* stack.deploy(program);
188 // Give the provider a moment in case it would (incorrectly) respawn.
189 yield* Effect.sleep("500 millis");
190
191 const second = yield* readPidFile(pidFile);
192 expect(second.pid).toBe(first.pid);
193 expect(second.marker).toBe("stable");
194 expect(yield* isAlive(first.pid)).toBe(true);
195
196 yield* stack.destroy();
197 yield* waitForDeath(first.pid);
198 }),
199 { tags: ["local"], timeout: 30_000 },
200);
201
202test.provider(
203 "restarts the process when props change",
204 (stack) =>
205 Effect.gen(function* () {
206 const fs = yield* FileSystem.FileSystem;
207 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
208 const pidFile = pathe.join(tmp, "pid.json");
209
210 yield* stack.deploy(
211 Command.Dev("Dev", {
212 command: `node ${fixtureScript}`,
213 env: { PID_FILE: pidFile, MARKER: "v1" },
214 }),
215 );
216 const first = yield* waitForPidFile(pidFile, "v1");
217 expect(yield* isAlive(first.pid)).toBe(true);
218
219 // Change the env (and therefore the hash) — provider should kill the
220 // running process and spawn a fresh one with the new marker.
221 yield* stack.deploy(
222 Command.Dev("Dev", {
223 command: `node ${fixtureScript}`,
224 env: { PID_FILE: pidFile, MARKER: "v2" },
225 }),
226 );
227 const second = yield* waitForPidFile(pidFile, "v2");
228
229 expect(second.pid).not.toBe(first.pid);
230 expect(yield* isAlive(second.pid)).toBe(true);
231 yield* waitForDeath(first.pid);
232
233 yield* stack.destroy();
234 yield* waitForDeath(second.pid);
235 }),
236 { tags: ["local"], timeout: 30_000 },
237);
238
239test.provider(
240 "extracts the first URL printed to stdout",
241 (stack) =>
242 Effect.gen(function* () {
243 const fs = yield* FileSystem.FileSystem;
244 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
245 const pidFile = pathe.join(tmp, "pid.json");
246
247 const output = yield* stack.deploy(
248 Command.Dev("Dev", {
249 command: `node ${urlServerScript}`,
250 env: {
251 PID_FILE: pidFile,
252 MARKER: "url-stdout",
253 URL_LINE: "Local: http://localhost:5173/",
254 },
255 }),
256 );
257
258 expect(output.url).toBe("http://localhost:5173/");
259
260 const { pid } = yield* waitForPidFile(pidFile, "url-stdout");
261 yield* stack.destroy();
262 yield* waitForDeath(pid);
263 }),
264 { tags: ["local"], timeout: 30_000 },
265);
266
267test.provider(
268 "extracts a URL printed to stderr",
269 (stack) =>
270 Effect.gen(function* () {
271 const fs = yield* FileSystem.FileSystem;
272 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
273 const pidFile = pathe.join(tmp, "pid.json");
274
275 const output = yield* stack.deploy(
276 Command.Dev("Dev", {
277 command: `node ${urlServerScript}`,
278 env: {
279 PID_FILE: pidFile,
280 MARKER: "url-stderr",
281 URL_LINE: "ready - started server on http://127.0.0.1:3000",
282 URL_STREAM: "stderr",
283 },
284 }),
285 );
286
287 expect(output.url).toBe("http://127.0.0.1:3000");
288
289 const { pid } = yield* waitForPidFile(pidFile, "url-stderr");
290 yield* stack.destroy();
291 yield* waitForDeath(pid);
292 }),
293 { tags: ["local"], timeout: 30_000 },
294);
295
296test.provider(
297 "strips ANSI escapes before matching",
298 (stack) =>
299 Effect.gen(function* () {
300 const fs = yield* FileSystem.FileSystem;
301 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
302 const pidFile = pathe.join(tmp, "pid.json");
303
304 // Vite-style colored output: " ➜ Local: http://localhost:5173/"
305 // with green + cyan SGR sequences around the URL.
306 const ansi = (open: string, body: string) =>
307 `\x1b[${open}m${body}\x1b[0m`;
308 const line = ` ➜ ${ansi("32", "Local:")} ${ansi("36", "http://localhost:5173/")}`;
309
310 const output = yield* stack.deploy(
311 Command.Dev("Dev", {
312 command: `node ${urlServerScript}`,
313 env: {
314 PID_FILE: pidFile,
315 MARKER: "url-ansi",
316 URL_LINE: line,
317 },
318 }),
319 );
320
321 expect(output.url).toBe("http://localhost:5173/");
322
323 const { pid } = yield* waitForPidFile(pidFile, "url-ansi");
324 yield* stack.destroy();
325 yield* waitForDeath(pid);
326 }),
327 { tags: ["local"], timeout: 30_000 },
328);
329
330test.provider(
331 "favors a localhost URL over an unrelated URL printed first (#695)",
332 (stack) =>
333 Effect.gen(function* () {
334 const fs = yield* FileSystem.FileSystem;
335 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
336 const pidFile = pathe.join(tmp, "pid.json");
337
338 const output = yield* stack.deploy(
339 Command.Dev("Dev", {
340 command: `node ${urlServerScript}`,
341 env: {
342 PID_FILE: pidFile,
343 MARKER: "url-favor-local",
344 // A docs link is printed first (as Astro does on a content
345 // error), then the dev server prints its own localhost URL.
346 URL_LINE: "https://docs.astro.build/en/guides/content-collections/",
347 URL_LINE_2: " ➜ Local: http://localhost:5001/",
348 URL_DELAY_2_MS: "300",
349 },
350 }),
351 );
352
353 expect(output.url).toBe("http://localhost:5001/");
354
355 const { pid } = yield* waitForPidFile(pidFile, "url-favor-local");
356 yield* stack.destroy();
357 yield* waitForDeath(pid);
358 }),
359 { tags: ["local"], timeout: 30_000 },
360);
361
362test.provider(
363 "falls back to a non-local URL when no localhost URL appears (#695)",
364 (stack) =>
365 Effect.gen(function* () {
366 const fs = yield* FileSystem.FileSystem;
367 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
368 const pidFile = pathe.join(tmp, "pid.json");
369
370 const output = yield* stack.deploy(
371 Command.Dev("Dev", {
372 command: `node ${urlServerScript}`,
373 env: {
374 PID_FILE: pidFile,
375 MARKER: "url-fallback",
376 // Only a non-local URL is ever printed — it should still surface.
377 URL_LINE: "https://docs.astro.build/en/guides/content-collections/",
378 },
379 }),
380 );
381
382 expect(output.url).toBe(
383 "https://docs.astro.build/en/guides/content-collections/",
384 );
385
386 const { pid } = yield* waitForPidFile(pidFile, "url-fallback");
387 yield* stack.destroy();
388 yield* waitForDeath(pid);
389 }),
390 { tags: ["local"], timeout: 30_000 },
391);
392
393test.provider(
394 "returns undefined when no URL is printed within the timeout",
395 (stack) =>
396 Effect.gen(function* () {
397 const fs = yield* FileSystem.FileSystem;
398 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
399 const pidFile = pathe.join(tmp, "pid.json");
400
401 const output = yield* stack.deploy(
402 Command.Dev("Dev", {
403 command: `node ${urlServerScript}`,
404 env: {
405 PID_FILE: pidFile,
406 MARKER: "no-url",
407 // URL_LINE intentionally unset — process stays silent so
408 // reconcile waits the full URL_EXTRACT_TIMEOUT and falls back.
409 },
410 }),
411 );
412
413 expect(output.url).toBeUndefined();
414
415 const { pid } = yield* waitForPidFile(pidFile, "no-url");
416 expect(yield* isAlive(pid)).toBe(true);
417
418 yield* stack.destroy();
419 yield* waitForDeath(pid);
420 }),
421 { tags: ["local"], timeout: 30_000 },
422);
423
424test.provider(
425 "stops the process on destroy",
426 (stack) =>
427 Effect.gen(function* () {
428 const fs = yield* FileSystem.FileSystem;
429 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
430 const pidFile = pathe.join(tmp, "pid.json");
431
432 yield* stack.deploy(
433 Command.Dev("Dev", {
434 command: `node ${fixtureScript}`,
435 env: { PID_FILE: pidFile, MARKER: "stop" },
436 }),
437 );
438 const { pid } = yield* waitForPidFile(pidFile, "stop");
439 expect(yield* isAlive(pid)).toBe(true);
440
441 yield* stack.destroy();
442 yield* waitForDeath(pid);
443 expect(yield* isAlive(pid)).toBe(false);
444 }),
445 { tags: ["local"], timeout: 30_000 },
446);
447
448inProcessTest.provider(
449 "errors when the command fails in first 5 seconds",
450 (stack) =>
451 Effect.gen(function* () {
452 const error = yield* stack
453 .deploy(
454 Command.Dev("Dev", {
455 command: `node ${dieScript}`,
456 }),
457 )
458 .pipe(Effect.flip);
459 assert(Command.isCommandError(error));
460 assert(error.reason._tag === "UnexpectedExit");
461 expect(error.reason.exitCode).toBe(1);
462 expect(error.reason.stderr).toContain("I'm not feeling it...");
463 }),
464 { tags: ["local"] },
465);
466
467describe("extractUrl", { tags: ["unit", "local"] }, () => {
468 it("returns a plain URL when it is the only match", () => {
469 expect(Command.extractUrl("Local: http://localhost:5173/")).toBe(
470 "http://localhost:5173/",
471 );
472 });
473
474 it("favors a localhost URL over an unrelated URL printed first", () => {
475 // Astro prints a docs link on a content error before the dev server
476 // prints its own localhost URL. See issue #695.
477 const buffer =
478 "see https://docs.astro.build/en/guides/content-collections/\n" +
479 " ➜ Local: http://localhost:5001/\n";
480 expect(Command.extractUrl(buffer)).toBe("http://localhost:5001/");
481 });
482
483 it("favors an IP URL over an unrelated URL printed first", () => {
484 const buffer =
485 "update available https://example.com/release\n" +
486 "ready - started server on http://127.0.0.1:3000\n";
487 expect(Command.extractUrl(buffer)).toBe("http://127.0.0.1:3000");
488 });
489
490 it("falls back to a non-local URL when no local URL is present", () => {
491 expect(
492 Command.extractUrl("docs https://docs.astro.build/en/guides/x/"),
493 ).toBe("https://docs.astro.build/en/guides/x/");
494 });
495
496 it("normalizes a bind-all address to localhost", () => {
497 // Nuxt prints its *bind* address (`0.0.0.0`), which is not a
498 // connectable host — consumers of `url` must be able to dial it.
499 expect(Command.extractUrl("Listening on http://0.0.0.0:3000/")).toBe(
500 "http://localhost:3000/",
501 );
502 expect(Command.extractUrl("Listening on http://[::]:3000/")).toBe(
503 "http://localhost:3000/",
504 );
505 });
506
507 it("strips ANSI escapes before matching", () => {
508 expect(Command.extractUrl("\x1b[36mhttp://localhost:5173/\x1b[0m")).toBe(
509 "http://localhost:5173/",
510 );
511 });
512
513 it("returns undefined when there is no URL", () => {
514 expect(Command.extractUrl("no url here")).toBeUndefined();
515 });
516});
517
518test.provider.skipIf(process.platform === "win32")(
519 "restart and destroy let wrappers clean their detached children",
520 (stack) =>
521 Effect.gen(function* () {
522 yield* stack.destroy();
523 const first = yield* lifecycleFixture();
524 const second = yield* lifecycleFixture();
525 yield* stack.deploy(Command.Dev("Graceful", first.props));
526 const old = yield* first.ready;
527 yield* stack.deploy(Command.Dev("Graceful", second.props));
528 const current = yield* second.ready;
529 expect(yield* first.has("wrapper.clean")).toBe(true);
530 yield* assertDead(old.wrapper);
531 yield* assertDead(old.leaf);
532 yield* stack.destroy();
533 expect(yield* second.has("wrapper.clean")).toBe(true);
534 yield* assertDead(current.wrapper);
535 yield* assertDead(current.leaf);
536 }),
537 { tags: ["local"], timeout: 30_000 },
538);