278 lines
9.2 KiB
TypeScript
278 lines
9.2 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import os from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { DevServerProcessManager } from "../dev-server-process.js";
|
|
import { loadDevServerStore, resetDevServerStore } from "../dev-server-store.js";
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 4_000): Promise<void> {
|
|
const start = Date.now();
|
|
while (!predicate()) {
|
|
if (Date.now() - start > timeoutMs) {
|
|
throw new Error("Timed out waiting for condition");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
describe("DevServerProcessManager", () => {
|
|
const tempDirs: string[] = [];
|
|
const managers: DevServerProcessManager[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const manager of managers.splice(0)) {
|
|
try {
|
|
if (manager.isRunning()) {
|
|
await manager.stop();
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
manager.cleanup();
|
|
}
|
|
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
resetDevServerStore();
|
|
});
|
|
|
|
async function createManager(options?: { stopTimeoutMs?: number; probeDelayMs?: number; probeTimeoutMs?: number }) {
|
|
const root = mkdtempSync(join(os.tmpdir(), "fn-dev-process-"));
|
|
tempDirs.push(root);
|
|
const store = await loadDevServerStore(root);
|
|
const manager = new DevServerProcessManager(store, options);
|
|
managers.push(manager);
|
|
return { root, store, manager };
|
|
}
|
|
|
|
it("start() spawns child process and updates state to running", async () => {
|
|
const { root, manager } = await createManager();
|
|
const state = await manager.start("node -e \"setInterval(() => {}, 1000)\"", root);
|
|
|
|
expect(state.status).toBe("running");
|
|
expect(typeof state.pid).toBe("number");
|
|
});
|
|
|
|
it("start() emits started event", async () => {
|
|
const { root, manager } = await createManager();
|
|
|
|
const startedEvent = new Promise<void>((resolve) => {
|
|
manager.once("started", () => resolve());
|
|
});
|
|
|
|
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root);
|
|
await startedEvent;
|
|
});
|
|
|
|
it("start() captures stdout into log buffer", async () => {
|
|
const { root, store, manager } = await createManager();
|
|
|
|
await manager.start("node -e \"console.log('hello from stdout'); setInterval(() => {}, 1000)\"", root);
|
|
|
|
await waitFor(() => store.getState().logHistory.some((line) => line.includes("hello from stdout")));
|
|
|
|
expect(store.getState().logHistory.some((line) => line.includes("hello from stdout"))).toBe(true);
|
|
});
|
|
|
|
it("start() throws if already running", async () => {
|
|
const { root, manager } = await createManager();
|
|
|
|
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root);
|
|
await expect(manager.start("node -e \"setInterval(() => {}, 1000)\"", root)).rejects.toThrow("already running");
|
|
});
|
|
|
|
it("start() throws if command is empty", async () => {
|
|
const { root, manager } = await createManager();
|
|
await expect(manager.start(" ", root)).rejects.toThrow("command is required");
|
|
});
|
|
|
|
it("stop() sends SIGTERM and waits for exit", async () => {
|
|
const { root, store, manager } = await createManager();
|
|
|
|
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root);
|
|
const state = await manager.stop();
|
|
|
|
expect(state.status).toBe("stopped");
|
|
expect(store.getState().status).toBe("stopped");
|
|
expect(store.getState().exitCode).toBeDefined();
|
|
});
|
|
|
|
it("stop() terminates the shell-launched child process tree", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
|
|
const { root, manager } = await createManager();
|
|
const childPidFile = join(root, "managed-child.pid");
|
|
|
|
await manager.start(
|
|
`node -e "require('node:fs').writeFileSync('${childPidFile}', String(process.pid)); setInterval(() => {}, 1000)"`,
|
|
root,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
try {
|
|
return Number.parseInt(readFileSync(childPidFile, "utf8").trim(), 10) > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
const managedChildPid = Number.parseInt(readFileSync(childPidFile, "utf8").trim(), 10);
|
|
expect(isProcessAlive(managedChildPid)).toBe(true);
|
|
|
|
await manager.stop();
|
|
|
|
await waitFor(() => !isProcessAlive(managedChildPid));
|
|
});
|
|
|
|
it("stop() falls back to SIGKILL after timeout", async () => {
|
|
const { root, store, manager } = await createManager({ stopTimeoutMs: 150 });
|
|
|
|
await manager.start(
|
|
"node -e \"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\"",
|
|
root,
|
|
);
|
|
|
|
const state = await manager.stop();
|
|
expect(state.status).toBe("stopped");
|
|
expect(store.getState().status).toBe("stopped");
|
|
});
|
|
|
|
it("stop() returns current state if nothing is running", async () => {
|
|
const { store, manager } = await createManager();
|
|
|
|
const state = await manager.stop();
|
|
expect(state).toEqual(store.getState());
|
|
});
|
|
|
|
it("restart() stops then starts with same command", async () => {
|
|
const { root, store, manager } = await createManager();
|
|
|
|
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root, { scriptId: "dev" });
|
|
const firstPid = store.getState().pid;
|
|
|
|
const state = await manager.restart();
|
|
expect(state.status).toBe("running");
|
|
expect(state.pid).toBeDefined();
|
|
expect(state.scriptId).toBe("dev");
|
|
expect(state.pid).not.toBe(firstPid);
|
|
});
|
|
|
|
it("detects URL from localhost output", async () => {
|
|
const { root, store, manager } = await createManager();
|
|
|
|
await manager.start(
|
|
"node -e \"console.log('Server ready at http://localhost:3000'); setInterval(() => {}, 1000)\"",
|
|
root,
|
|
);
|
|
|
|
await waitFor(() => store.getState().detectedUrl === "http://localhost:3000");
|
|
expect(store.getState().detectedPort).toBe(3000);
|
|
});
|
|
|
|
it("detects URL from 127.0.0.1 output", async () => {
|
|
const { root, store, manager } = await createManager();
|
|
|
|
await manager.start(
|
|
"node -e \"console.log('ready at http://127.0.0.1:4173'); setInterval(() => {}, 1000)\"",
|
|
root,
|
|
);
|
|
|
|
await waitFor(() => store.getState().detectedUrl === "http://127.0.0.1:4173");
|
|
expect(store.getState().detectedPort).toBe(4173);
|
|
});
|
|
|
|
it("detects URL from keyword plus port pattern", async () => {
|
|
const { root, store, manager } = await createManager();
|
|
|
|
await manager.start(
|
|
"node -e \"console.log('Listening on port 5173'); setInterval(() => {}, 1000)\"",
|
|
root,
|
|
);
|
|
|
|
await waitFor(() => store.getState().detectedUrl === "http://localhost:5173");
|
|
expect(store.getState().detectedPort).toBe(5173);
|
|
});
|
|
|
|
it("schedules fallback probing after startup when no URL is announced", async () => {
|
|
const { root, manager } = await createManager({ probeDelayMs: 25, probeTimeoutMs: 5 });
|
|
|
|
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root);
|
|
|
|
expect(manager.hasPendingProbeTimer()).toBe(true);
|
|
await waitFor(() => manager.hasPendingProbeTimer() === false, 3_000);
|
|
});
|
|
|
|
it("clears fallback probe timer when URL is detected from logs", async () => {
|
|
const { root, store, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
|
|
|
|
await manager.start(
|
|
"node -e \"console.log('ready at http://localhost:4321'); setInterval(() => {}, 1000)\"",
|
|
root,
|
|
);
|
|
|
|
await waitFor(() => store.getState().detectedPort === 4321);
|
|
expect(manager.hasPendingProbeTimer()).toBe(false);
|
|
});
|
|
|
|
it("clears fallback probe timer on stop", async () => {
|
|
const { root, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
|
|
|
|
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root);
|
|
expect(manager.hasPendingProbeTimer()).toBe(true);
|
|
|
|
await manager.stop();
|
|
|
|
expect(manager.hasPendingProbeTimer()).toBe(false);
|
|
});
|
|
|
|
it("clears fallback probe timer when process exits naturally", async () => {
|
|
const { root, store, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
|
|
|
|
await manager.start("node -e \"setTimeout(() => process.exit(0), 20)\"", root);
|
|
expect(manager.hasPendingProbeTimer()).toBe(true);
|
|
|
|
await waitFor(() => store.getState().status === "stopped");
|
|
|
|
expect(manager.hasPendingProbeTimer()).toBe(false);
|
|
});
|
|
|
|
it("restarts with a fresh fallback probe timer", async () => {
|
|
const { root, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
|
|
|
|
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root, { scriptId: "dev" });
|
|
expect(manager.hasPendingProbeTimer()).toBe(true);
|
|
|
|
await manager.restart();
|
|
|
|
expect(manager.hasPendingProbeTimer()).toBe(true);
|
|
});
|
|
|
|
it("cleanup() kills process and clears listeners", async () => {
|
|
const { root, manager } = await createManager();
|
|
|
|
await manager.start("node -e \"setInterval(() => {}, 1000)\"", root);
|
|
manager.on("output", () => undefined);
|
|
expect(manager.listenerCount("output")).toBeGreaterThan(0);
|
|
|
|
manager.cleanup();
|
|
await waitFor(() => manager.isRunning() === false);
|
|
|
|
expect(manager.listenerCount("output")).toBe(0);
|
|
});
|
|
});
|