Files
2026-07-13 13:39:47 +08:00

264 lines
7.0 KiB
TypeScript

import { join } from "node:path";
import { tmpdir } from "node:os";
import { EventEmitter } from "node:events";
import { mkdtemp, writeFile } from "node:fs/promises";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
const mockPortfinder = {
getPortPromise: vi.fn<(options?: { port?: number }) => Promise<number>>(),
};
const spawnMock =
vi.fn<
(
command: string,
args?: readonly string[],
options?: Record<string, unknown>,
) => ChildProcessWithoutNullStreams
>();
const killMock =
vi.fn<
(
pid: number,
signal: NodeJS.Signals | string | undefined,
callback: (error?: NodeJS.ErrnoException | null) => void,
) => void
>();
vi.mock("portfinder", () => ({
default: mockPortfinder,
}));
vi.mock("node:child_process", () => ({
spawn: (...args: Parameters<typeof spawnMock>) => spawnMock(...args),
}));
vi.mock("tree-kill", () => ({
default: (...args: Parameters<typeof killMock>) => killMock(...args),
}));
const fetchMock =
vi.fn<
(
input: string | URL,
init?: unknown,
) => Promise<{ ok: boolean; status: number }>
>();
let originalFetch: typeof fetch;
beforeAll(() => {
originalFetch = globalThis.fetch;
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
});
class MockChildProcess extends EventEmitter {
stdout: NodeJS.ReadableStream;
stderr: NodeJS.ReadableStream;
pid: number;
killed = false;
constructor(pid: number) {
super();
this.pid = pid;
this.stdout = new EventEmitter() as unknown as NodeJS.ReadableStream;
this.stderr = new EventEmitter() as unknown as NodeJS.ReadableStream;
}
}
let lastSpawnedChild: MockChildProcess | null = null;
let nextPid = 4_000;
async function createExecutableFixture() {
const dir = await mkdtemp(join(tmpdir(), "service-runner-"));
const baseName = "dummy-service";
const fileName = process.platform === "win32" ? `${baseName}.exe` : baseName;
const filePath = join(dir, fileName);
await writeFile(filePath, "", { mode: 0o755 });
return { dir, name: baseName };
}
const executableFixturePromise = createExecutableFixture();
beforeEach(() => {
vi.clearAllMocks();
lastSpawnedChild = null;
nextPid = 4_000;
mockPortfinder.getPortPromise.mockImplementation(async (options) => {
return (options?.port ?? 0) || 5_000;
});
spawnMock.mockImplementation(() => {
const child = new MockChildProcess(nextPid++);
lastSpawnedChild = child;
return child as unknown as ChildProcessWithoutNullStreams;
});
killMock.mockImplementation((_pid, _signal, callback) => {
if (lastSpawnedChild) {
lastSpawnedChild.killed = true;
queueMicrotask(() => {
lastSpawnedChild?.emit("exit", null, null);
});
}
callback?.(null);
});
fetchMock.mockResolvedValue({
ok: true,
status: 200,
});
});
afterEach(() => {
lastSpawnedChild = null;
});
describe("ServiceRunner", () => {
test("starts service, waits for healthy state, and stops gracefully", async () => {
const { ServiceRunner } = await import("../src/index");
const fixture = await executableFixturePromise;
mockPortfinder.getPortPromise.mockResolvedValueOnce(6_789);
const runner = new ServiceRunner({
executableDir: fixture.dir,
executableName: fixture.name,
preferredPort: 4_321,
});
const initialState = await runner.start();
expect(spawnMock).toHaveBeenCalledTimes(1);
const [, , spawnOptions] = spawnMock.mock.calls[0];
expect(spawnOptions?.env).toMatchObject({
PORT: "6789",
HOST: "127.0.0.1",
});
expect(fetchMock).toHaveBeenCalled();
const requestURL = fetchMock.mock.calls[0][0];
expect(String(requestURL)).toBe("http://127.0.0.1:6789/healthy");
expect(initialState.port).toBe(6_789);
expect(initialState.host).toBe("127.0.0.1");
expect(initialState.started).toBe(true);
expect(runner.isRunning()).toBe(true);
expect(await runner.checkHealth()).toBe(true);
const secondStartState = await runner.start();
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(secondStartState.port).toBe(initialState.port);
mockPortfinder.getPortPromise.mockResolvedValueOnce(9_001);
const restartedState = await runner.restart({
preferredPort: 9_000,
extraEnv: { CUSTOM_FLAG: "1" },
});
expect(spawnMock).toHaveBeenCalledTimes(2);
const [, , secondSpawnOptions] = spawnMock.mock.calls[1];
expect(secondSpawnOptions?.env).toMatchObject({
PORT: "9001",
HOST: "127.0.0.1",
CUSTOM_FLAG: "1",
});
expect(restartedState.port).toBe(9_001);
expect(restartedState.started).toBe(true);
expect(killMock).toHaveBeenCalledTimes(1);
expect(runner.isRunning()).toBe(true);
await runner.stop();
expect(killMock).toHaveBeenCalledTimes(2);
expect(runner.isRunning()).toBe(false);
});
test("resolves host from LAN when internal flag is false", async () => {
const { ServiceRunner } = await import("../src/index");
const fixture = await executableFixturePromise;
const originalFinder = Reflect.get(
ServiceRunner as object,
"findLanIPv4Address",
) as (() => string | undefined) | undefined;
Reflect.set(
ServiceRunner as object,
"findLanIPv4Address",
vi.fn(() => "10.0.0.42"),
);
mockPortfinder.getPortPromise.mockResolvedValueOnce(5_555);
const runner = new ServiceRunner({
executableDir: fixture.dir,
executableName: fixture.name,
internal: false,
});
try {
const state = await runner.start();
expect(state.host).toBe("10.0.0.42");
expect(state.url).toBe("http://10.0.0.42:5555");
expect(fetchMock).toHaveBeenCalled();
const requestURL = fetchMock.mock.calls[0][0];
expect(String(requestURL)).toBe("http://10.0.0.42:5555/healthy");
} finally {
await runner.stop();
if (originalFinder) {
Reflect.set(
ServiceRunner as object,
"findLanIPv4Address",
originalFinder,
);
}
}
});
test("rejects when health checks do not pass within timeout", async () => {
const { ServiceRunner } = await import("../src/index");
const fixture = await executableFixturePromise;
mockPortfinder.getPortPromise.mockResolvedValueOnce(7_001);
fetchMock.mockResolvedValue({
ok: false,
status: 503,
});
const runner = new ServiceRunner({
executableDir: fixture.dir,
executableName: fixture.name,
healthCheckTimeoutMs: 100,
healthCheckIntervalMs: 10,
healthRequestTimeoutMs: 10,
});
vi.useFakeTimers();
const startPromise = runner.start();
await vi.runAllTimersAsync();
await expect(startPromise).rejects.toThrow(/failed health check/i);
expect(runner.isRunning()).toBe(false);
await runner.stop().catch(() => undefined);
vi.useRealTimers();
});
});