t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/Command/Dev.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

538 lines
17 KiB
TypeScript

import * as Command from "@/Command/index.ts";
import * as Provider from "@/Provider.ts";
import * as Test from "@/Test/Alchemy";
import { assert, describe, expect, it } from "alchemy-test";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Schedule from "effect/Schedule";
import * as pathe from "pathe";
import { assertDead, lifecycleFixture } from "./fixture/lifecycle-support.ts";
const { test } = Test.make({
// DevServer is provider-agnostic — register it directly without dragging
// in a cloud provider's auth chain.
providers: Command.providers(),
dev: true,
});
// Typed provider errors are an in-process contract: across the `alchemy dev`
// RPC boundary (the default dev-test topology) a failed lifecycle op is
// re-thrown as a flattened defect — `_tag`/`reason` do not survive
// `Schema.Defect` serialization — so assertions on `CommandError`'s shape
// must run against the in-process provider.
const { test: inProcessTest } = Test.make({
providers: Command.providers(),
dev: true,
sidecar: false,
});
const fixtureDir = pathe.resolve(import.meta.dirname, "fixture");
const fixtureScript = pathe.join(fixtureDir, "long-running.cjs");
const urlServerScript = pathe.join(fixtureDir, "url-server.cjs");
const dieScript = pathe.join(fixtureDir, "die.cjs");
// The provider runs `command.split(" ")` and uses `shell: false`, so the
// fixture path must not contain spaces. The in-repo path doesn't, but a CI
// clone under e.g. `C:\Program Files\...` would. Fail loudly with a clear
// message instead of letting the test hang on a misparsed argv.
if (fixtureScript.includes(" ") || urlServerScript.includes(" ")) {
throw new Error(
`DevServer test fixture path contains a space, which the provider's ` +
`argv split cannot represent: ${fixtureScript} / ${urlServerScript}`,
);
}
const isAlive = (pid: number) =>
Effect.sync(() => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
});
const readPidFile = (path: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const content = yield* fs.readFileString(path);
return JSON.parse(content) as { pid: number; marker: string };
});
const waitForPidFile = (path: string, marker: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const exists = yield* fs.exists(path);
if (!exists) {
return yield* Effect.fail(new Error("pid file not yet present"));
}
const parsed = yield* readPidFile(path);
if (parsed.marker !== marker) {
return yield* Effect.fail(
new Error(`pid file marker ${parsed.marker} !== ${marker}`),
);
}
return parsed;
}).pipe(
Effect.retry({
schedule: Schedule.spaced("100 millis"),
times: 100,
}),
);
const waitForDeath = (pid: number) =>
isAlive(pid).pipe(
Effect.flatMap((alive) =>
alive
? Effect.fail(new Error(`pid ${pid} still alive`))
: Effect.succeed(undefined),
),
Effect.retry({
schedule: Schedule.spaced("100 millis"),
times: 50,
}),
);
test.provider(
"list returns [] (non-listable local dev-server process)",
() =>
Effect.gen(function* () {
// DevServer is a local dev-server child process, not a cloud resource —
// there is no remote enumeration API, so list() is the non-listable
// pattern and always returns []. No deploy is needed to observe this.
const provider = yield* Provider.findProvider(Command.Dev);
const all = yield* provider.list();
expect(all).toEqual([]);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"starts the process",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFile = pathe.join(tmp, "pid.json");
yield* stack.deploy(
Command.Dev("Dev", {
command: `node ${fixtureScript}`,
env: { PID_FILE: pidFile, MARKER: "start" },
}),
);
const { pid } = yield* waitForPidFile(pidFile, "start");
expect(yield* isAlive(pid)).toBe(true);
yield* stack.destroy();
yield* waitForDeath(pid);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"runs multiple distinct dev servers concurrently",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFileA = pathe.join(tmp, "pid-a.json");
const pidFileB = pathe.join(tmp, "pid-b.json");
yield* stack.deploy(
Effect.gen(function* () {
yield* Command.Dev("Alpha", {
command: `node ${fixtureScript}`,
env: { PID_FILE: pidFileA, MARKER: "alpha" },
});
yield* Command.Dev("Beta", {
command: `node ${fixtureScript}`,
env: { PID_FILE: pidFileB, MARKER: "beta" },
});
}),
);
const alpha = yield* waitForPidFile(pidFileA, "alpha");
const beta = yield* waitForPidFile(pidFileB, "beta");
expect(alpha.pid).not.toBe(beta.pid);
expect(yield* isAlive(alpha.pid)).toBe(true);
expect(yield* isAlive(beta.pid)).toBe(true);
yield* stack.destroy();
yield* waitForDeath(alpha.pid);
yield* waitForDeath(beta.pid);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"keeps the process running across an unchanged update",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFile = pathe.join(tmp, "pid.json");
const program = Command.Dev("Dev", {
command: `node ${fixtureScript}`,
env: { PID_FILE: pidFile, MARKER: "stable" },
});
yield* stack.deploy(program);
const first = yield* waitForPidFile(pidFile, "stable");
// Re-deploy the same props. Provider hashes match → keep running.
yield* stack.deploy(program);
// Give the provider a moment in case it would (incorrectly) respawn.
yield* Effect.sleep("500 millis");
const second = yield* readPidFile(pidFile);
expect(second.pid).toBe(first.pid);
expect(second.marker).toBe("stable");
expect(yield* isAlive(first.pid)).toBe(true);
yield* stack.destroy();
yield* waitForDeath(first.pid);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"restarts the process when props change",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFile = pathe.join(tmp, "pid.json");
yield* stack.deploy(
Command.Dev("Dev", {
command: `node ${fixtureScript}`,
env: { PID_FILE: pidFile, MARKER: "v1" },
}),
);
const first = yield* waitForPidFile(pidFile, "v1");
expect(yield* isAlive(first.pid)).toBe(true);
// Change the env (and therefore the hash) — provider should kill the
// running process and spawn a fresh one with the new marker.
yield* stack.deploy(
Command.Dev("Dev", {
command: `node ${fixtureScript}`,
env: { PID_FILE: pidFile, MARKER: "v2" },
}),
);
const second = yield* waitForPidFile(pidFile, "v2");
expect(second.pid).not.toBe(first.pid);
expect(yield* isAlive(second.pid)).toBe(true);
yield* waitForDeath(first.pid);
yield* stack.destroy();
yield* waitForDeath(second.pid);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"extracts the first URL printed to stdout",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFile = pathe.join(tmp, "pid.json");
const output = yield* stack.deploy(
Command.Dev("Dev", {
command: `node ${urlServerScript}`,
env: {
PID_FILE: pidFile,
MARKER: "url-stdout",
URL_LINE: "Local: http://localhost:5173/",
},
}),
);
expect(output.url).toBe("http://localhost:5173/");
const { pid } = yield* waitForPidFile(pidFile, "url-stdout");
yield* stack.destroy();
yield* waitForDeath(pid);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"extracts a URL printed to stderr",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFile = pathe.join(tmp, "pid.json");
const output = yield* stack.deploy(
Command.Dev("Dev", {
command: `node ${urlServerScript}`,
env: {
PID_FILE: pidFile,
MARKER: "url-stderr",
URL_LINE: "ready - started server on http://127.0.0.1:3000",
URL_STREAM: "stderr",
},
}),
);
expect(output.url).toBe("http://127.0.0.1:3000");
const { pid } = yield* waitForPidFile(pidFile, "url-stderr");
yield* stack.destroy();
yield* waitForDeath(pid);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"strips ANSI escapes before matching",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFile = pathe.join(tmp, "pid.json");
// Vite-style colored output: " ➜ Local: http://localhost:5173/"
// with green + cyan SGR sequences around the URL.
const ansi = (open: string, body: string) =>
`\x1b[${open}m${body}\x1b[0m`;
const line = ` ➜ ${ansi("32", "Local:")} ${ansi("36", "http://localhost:5173/")}`;
const output = yield* stack.deploy(
Command.Dev("Dev", {
command: `node ${urlServerScript}`,
env: {
PID_FILE: pidFile,
MARKER: "url-ansi",
URL_LINE: line,
},
}),
);
expect(output.url).toBe("http://localhost:5173/");
const { pid } = yield* waitForPidFile(pidFile, "url-ansi");
yield* stack.destroy();
yield* waitForDeath(pid);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"favors a localhost URL over an unrelated URL printed first (#695)",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFile = pathe.join(tmp, "pid.json");
const output = yield* stack.deploy(
Command.Dev("Dev", {
command: `node ${urlServerScript}`,
env: {
PID_FILE: pidFile,
MARKER: "url-favor-local",
// A docs link is printed first (as Astro does on a content
// error), then the dev server prints its own localhost URL.
URL_LINE: "https://docs.astro.build/en/guides/content-collections/",
URL_LINE_2: " ➜ Local: http://localhost:5001/",
URL_DELAY_2_MS: "300",
},
}),
);
expect(output.url).toBe("http://localhost:5001/");
const { pid } = yield* waitForPidFile(pidFile, "url-favor-local");
yield* stack.destroy();
yield* waitForDeath(pid);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"falls back to a non-local URL when no localhost URL appears (#695)",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFile = pathe.join(tmp, "pid.json");
const output = yield* stack.deploy(
Command.Dev("Dev", {
command: `node ${urlServerScript}`,
env: {
PID_FILE: pidFile,
MARKER: "url-fallback",
// Only a non-local URL is ever printed — it should still surface.
URL_LINE: "https://docs.astro.build/en/guides/content-collections/",
},
}),
);
expect(output.url).toBe(
"https://docs.astro.build/en/guides/content-collections/",
);
const { pid } = yield* waitForPidFile(pidFile, "url-fallback");
yield* stack.destroy();
yield* waitForDeath(pid);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"returns undefined when no URL is printed within the timeout",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFile = pathe.join(tmp, "pid.json");
const output = yield* stack.deploy(
Command.Dev("Dev", {
command: `node ${urlServerScript}`,
env: {
PID_FILE: pidFile,
MARKER: "no-url",
// URL_LINE intentionally unset — process stays silent so
// reconcile waits the full URL_EXTRACT_TIMEOUT and falls back.
},
}),
);
expect(output.url).toBeUndefined();
const { pid } = yield* waitForPidFile(pidFile, "no-url");
expect(yield* isAlive(pid)).toBe(true);
yield* stack.destroy();
yield* waitForDeath(pid);
}),
{ tags: ["local"], timeout: 30_000 },
);
test.provider(
"stops the process on destroy",
(stack) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "devcmd-" });
const pidFile = pathe.join(tmp, "pid.json");
yield* stack.deploy(
Command.Dev("Dev", {
command: `node ${fixtureScript}`,
env: { PID_FILE: pidFile, MARKER: "stop" },
}),
);
const { pid } = yield* waitForPidFile(pidFile, "stop");
expect(yield* isAlive(pid)).toBe(true);
yield* stack.destroy();
yield* waitForDeath(pid);
expect(yield* isAlive(pid)).toBe(false);
}),
{ tags: ["local"], timeout: 30_000 },
);
inProcessTest.provider(
"errors when the command fails in first 5 seconds",
(stack) =>
Effect.gen(function* () {
const error = yield* stack
.deploy(
Command.Dev("Dev", {
command: `node ${dieScript}`,
}),
)
.pipe(Effect.flip);
assert(Command.isCommandError(error));
assert(error.reason._tag === "UnexpectedExit");
expect(error.reason.exitCode).toBe(1);
expect(error.reason.stderr).toContain("I'm not feeling it...");
}),
{ tags: ["local"] },
);
describe("extractUrl", { tags: ["unit", "local"] }, () => {
it("returns a plain URL when it is the only match", () => {
expect(Command.extractUrl("Local: http://localhost:5173/")).toBe(
"http://localhost:5173/",
);
});
it("favors a localhost URL over an unrelated URL printed first", () => {
// Astro prints a docs link on a content error before the dev server
// prints its own localhost URL. See issue #695.
const buffer =
"see https://docs.astro.build/en/guides/content-collections/\n" +
" ➜ Local: http://localhost:5001/\n";
expect(Command.extractUrl(buffer)).toBe("http://localhost:5001/");
});
it("favors an IP URL over an unrelated URL printed first", () => {
const buffer =
"update available https://example.com/release\n" +
"ready - started server on http://127.0.0.1:3000\n";
expect(Command.extractUrl(buffer)).toBe("http://127.0.0.1:3000");
});
it("falls back to a non-local URL when no local URL is present", () => {
expect(
Command.extractUrl("docs https://docs.astro.build/en/guides/x/"),
).toBe("https://docs.astro.build/en/guides/x/");
});
it("normalizes a bind-all address to localhost", () => {
// Nuxt prints its *bind* address (`0.0.0.0`), which is not a
// connectable host — consumers of `url` must be able to dial it.
expect(Command.extractUrl("Listening on http://0.0.0.0:3000/")).toBe(
"http://localhost:3000/",
);
expect(Command.extractUrl("Listening on http://[::]:3000/")).toBe(
"http://localhost:3000/",
);
});
it("strips ANSI escapes before matching", () => {
expect(Command.extractUrl("\x1b[36mhttp://localhost:5173/\x1b[0m")).toBe(
"http://localhost:5173/",
);
});
it("returns undefined when there is no URL", () => {
expect(Command.extractUrl("no url here")).toBeUndefined();
});
});
test.provider.skipIf(process.platform === "win32")(
"restart and destroy let wrappers clean their detached children",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const first = yield* lifecycleFixture();
const second = yield* lifecycleFixture();
yield* stack.deploy(Command.Dev("Graceful", first.props));
const old = yield* first.ready;
yield* stack.deploy(Command.Dev("Graceful", second.props));
const current = yield* second.ready;
expect(yield* first.has("wrapper.clean")).toBe(true);
yield* assertDead(old.wrapper);
yield* assertDead(old.leaf);
yield* stack.destroy();
expect(yield* second.has("wrapper.clean")).toBe(true);
yield* assertDead(current.wrapper);
yield* assertDead(current.leaf);
}),
{ tags: ["local"], timeout: 30_000 },
);