test(FN-1279): expand child-process runtime and worker coverage
- Refactor ChildProcessRuntime tests with typed fork/IPC mocks and deterministic command-response helpers - Add lifecycle and resilience assertions for startup failures, stop idempotency, heartbeat checks, and exponential restart backoff - Verify IPC event forwarding, metrics fallback behavior, and not-accessible runtime API guards - Add child-process-worker tests covering START/STOP/GET_STATUS/GET_METRICS handlers, runtime event bridging, and SIGTERM/SIGINT shutdown paths
This commit is contained in:
@@ -1,79 +1,189 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import type { CentralCore } from "@fusion/core";
|
import type { CentralCore, Task } from "@fusion/core";
|
||||||
import { ChildProcessRuntime } from "./child-process-runtime.js";
|
import { ChildProcessRuntime } from "./child-process-runtime.js";
|
||||||
import { IpcHost } from "../ipc/ipc-host.js";
|
import type {
|
||||||
import type { ProjectRuntimeConfig } from "../project-runtime.js";
|
ProjectRuntimeConfig,
|
||||||
|
RuntimeMetrics,
|
||||||
|
RuntimeStatus,
|
||||||
|
} from "../project-runtime.js";
|
||||||
|
import {
|
||||||
|
START_RUNTIME,
|
||||||
|
STOP_RUNTIME,
|
||||||
|
GET_METRICS,
|
||||||
|
TASK_CREATED,
|
||||||
|
TASK_MOVED,
|
||||||
|
TASK_UPDATED,
|
||||||
|
ERROR_EVENT,
|
||||||
|
HEALTH_CHANGED,
|
||||||
|
OK,
|
||||||
|
ERROR,
|
||||||
|
PONG,
|
||||||
|
} from "../ipc/ipc-protocol.js";
|
||||||
|
|
||||||
|
type Listener = (...args: any[]) => void;
|
||||||
|
|
||||||
|
type CommandMessage = {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
payload: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockChildOptions = {
|
||||||
|
pingResults?: boolean[];
|
||||||
|
metricsResponse?: RuntimeMetrics;
|
||||||
|
sendCallbackErrors?: Partial<Record<string, Error>>;
|
||||||
|
markKilledOnSigterm?: boolean;
|
||||||
|
emitExitOnKill?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type MockChildProcess = {
|
type MockChildProcess = {
|
||||||
on: ReturnType<typeof vi.fn>;
|
on: ReturnType<typeof vi.fn>;
|
||||||
kill: ReturnType<typeof vi.fn>;
|
|
||||||
killed: boolean;
|
|
||||||
connected: boolean;
|
|
||||||
send: ReturnType<typeof vi.fn>;
|
send: ReturnType<typeof vi.fn>;
|
||||||
|
kill: ReturnType<typeof vi.fn>;
|
||||||
disconnect: ReturnType<typeof vi.fn>;
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
emit: (event: string, ...args: unknown[]) => void;
|
||||||
|
connected: boolean;
|
||||||
|
killed: boolean;
|
||||||
|
sentMessages: CommandMessage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMockChildProcess = (): MockChildProcess => {
|
const forkedChildren: MockChildProcess[] = [];
|
||||||
|
const queuedForkOptions: MockChildOptions[] = [];
|
||||||
|
|
||||||
|
function createMockChildProcess(options: MockChildOptions = {}): MockChildProcess {
|
||||||
|
const listeners = new Map<string, Listener[]>();
|
||||||
|
const pingResults = [...(options.pingResults ?? [])];
|
||||||
|
|
||||||
const child: MockChildProcess = {
|
const child: MockChildProcess = {
|
||||||
on: vi.fn(),
|
on: vi.fn((event: string, handler: Listener) => {
|
||||||
kill: vi.fn((signal?: string | number) => {
|
const existing = listeners.get(event) ?? [];
|
||||||
if (signal === "SIGTERM" || signal === "SIGKILL") {
|
existing.push(handler);
|
||||||
child.killed = true;
|
listeners.set(event, existing);
|
||||||
|
return child;
|
||||||
|
}),
|
||||||
|
send: vi.fn((message: CommandMessage, callback?: (error: Error | null) => void) => {
|
||||||
|
child.sentMessages.push(message);
|
||||||
|
|
||||||
|
const sendError = options.sendCallbackErrors?.[message.type];
|
||||||
|
if (sendError) {
|
||||||
|
callback?.(sendError);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
callback?.(null);
|
||||||
|
|
||||||
|
const respond = (type: string, payload: unknown) => {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
child.emit("message", {
|
||||||
|
type,
|
||||||
|
id: message.id,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.type === START_RUNTIME) {
|
||||||
|
respond(OK, { data: { status: "active" } });
|
||||||
|
} else if (message.type === STOP_RUNTIME) {
|
||||||
|
respond(OK, { data: { stopped: true } });
|
||||||
|
} else if (message.type === GET_METRICS) {
|
||||||
|
respond(OK, {
|
||||||
|
data:
|
||||||
|
options.metricsResponse ??
|
||||||
|
{
|
||||||
|
inFlightTasks: 4,
|
||||||
|
activeAgents: 2,
|
||||||
|
lastActivityAt: "2026-04-08T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (message.type === "PING") {
|
||||||
|
const pingOk = pingResults.shift() ?? true;
|
||||||
|
if (pingOk) {
|
||||||
|
respond(PONG, { timestamp: "2026-04-08T00:00:00.000Z" });
|
||||||
|
} else {
|
||||||
|
respond(ERROR, { message: "Ping failed", code: "PING_FAILED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
killed: false,
|
kill: vi.fn((signal?: string | number) => {
|
||||||
connected: true,
|
if (signal === "SIGKILL" || (signal === "SIGTERM" && options.markKilledOnSigterm !== false)) {
|
||||||
send: vi.fn((_message: unknown, callback?: (error: Error | null) => void) => {
|
child.killed = true;
|
||||||
callback?.(null);
|
}
|
||||||
|
|
||||||
|
if (options.emitExitOnKill) {
|
||||||
|
child.emit("exit", signal === "SIGKILL" ? 137 : 0, typeof signal === "string" ? signal : null);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
disconnect: vi.fn(() => {
|
disconnect: vi.fn(() => {
|
||||||
child.connected = false;
|
child.connected = false;
|
||||||
|
child.emit("disconnect");
|
||||||
}),
|
}),
|
||||||
|
emit: (event: string, ...args: unknown[]) => {
|
||||||
|
for (const handler of listeners.get(event) ?? []) {
|
||||||
|
handler(...(args as any[]));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
connected: true,
|
||||||
|
killed: false,
|
||||||
|
sentMessages: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return child;
|
return child;
|
||||||
};
|
}
|
||||||
|
|
||||||
const { forkMock, forkedChildren } = vi.hoisted(() => {
|
const mockFork = vi.fn(() => {
|
||||||
const forkedChildren: MockChildProcess[] = [];
|
const options = queuedForkOptions.shift() ?? {};
|
||||||
const forkMock = vi.fn(() => {
|
const child = createMockChildProcess(options);
|
||||||
const child: MockChildProcess = {
|
forkedChildren.push(child);
|
||||||
on: vi.fn(),
|
return child;
|
||||||
kill: vi.fn((signal?: string | number) => {
|
|
||||||
if (signal === "SIGTERM" || signal === "SIGKILL") {
|
|
||||||
child.killed = true;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
killed: false,
|
|
||||||
connected: true,
|
|
||||||
send: vi.fn((_message: unknown, callback?: (error: Error | null) => void) => {
|
|
||||||
callback?.(null);
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
disconnect: vi.fn(() => {
|
|
||||||
child.connected = false;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
forkedChildren.push(child);
|
|
||||||
return child;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { forkMock, forkedChildren };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock child_process
|
|
||||||
vi.mock("node:child_process", () => ({
|
vi.mock("node:child_process", () => ({
|
||||||
fork: forkMock,
|
fork: (...args: unknown[]) => (mockFork as (...mockArgs: unknown[]) => unknown)(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function queueChild(options: MockChildOptions = {}): void {
|
||||||
|
queuedForkOptions.push(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestChild(): MockChildProcess {
|
||||||
|
const child = forkedChildren.at(-1);
|
||||||
|
if (!child) {
|
||||||
|
throw new Error("Expected a forked child process");
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessages(child: MockChildProcess, type: string): CommandMessage[] {
|
||||||
|
return child.sentMessages.filter((message) => message.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockTask(id: string): Task {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: `${id} title`,
|
||||||
|
description: `${id} description`,
|
||||||
|
column: "todo",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
createdAt: "2026-04-08T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||||
|
size: "M",
|
||||||
|
reviewLevel: 1,
|
||||||
|
log: [],
|
||||||
|
attachments: [],
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
describe("ChildProcessRuntime", () => {
|
describe("ChildProcessRuntime", () => {
|
||||||
let runtime: ChildProcessRuntime;
|
let runtime: ChildProcessRuntime;
|
||||||
let runtimeAny: any;
|
let runtimeAny: any;
|
||||||
let mockCentralCore: CentralCore;
|
|
||||||
const testConfig: ProjectRuntimeConfig = {
|
const testConfig: ProjectRuntimeConfig = {
|
||||||
projectId: "proj_test123",
|
projectId: "proj_test123",
|
||||||
workingDirectory: "/tmp/test-project",
|
workingDirectory: "/tmp/test-project",
|
||||||
@@ -83,7 +193,11 @@ describe("ChildProcessRuntime", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockCentralCore = {
|
mockFork.mockClear();
|
||||||
|
forkedChildren.length = 0;
|
||||||
|
queuedForkOptions.length = 0;
|
||||||
|
|
||||||
|
const mockCentralCore = {
|
||||||
getGlobalConcurrencyState: vi.fn().mockResolvedValue({
|
getGlobalConcurrencyState: vi.fn().mockResolvedValue({
|
||||||
globalMaxConcurrent: 4,
|
globalMaxConcurrent: 4,
|
||||||
currentlyActive: 0,
|
currentlyActive: 0,
|
||||||
@@ -94,226 +208,477 @@ describe("ChildProcessRuntime", () => {
|
|||||||
|
|
||||||
runtime = new ChildProcessRuntime(testConfig, mockCentralCore);
|
runtime = new ChildProcessRuntime(testConfig, mockCentralCore);
|
||||||
runtimeAny = runtime as any;
|
runtimeAny = runtime as any;
|
||||||
|
|
||||||
vi.spyOn(IpcHost.prototype, "sendCommand").mockResolvedValue(undefined);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
try {
|
try {
|
||||||
await runtime.stop();
|
await runtime.stop();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors during cleanup
|
// Ignore cleanup failures
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.clearAllMocks();
|
|
||||||
forkedChildren.length = 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("lifecycle", () => {
|
describe("startup sequence", () => {
|
||||||
it("should start with status 'stopped'", () => {
|
it("transitions stopped → starting → active, forks worker path, and sends START_RUNTIME config", async () => {
|
||||||
|
queueChild();
|
||||||
|
|
||||||
|
const transitions: RuntimeStatus[] = [];
|
||||||
|
runtime.on("health-changed", (data) => transitions.push(data.status));
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
expect(transitions).toEqual(["starting", "active"]);
|
||||||
|
expect(runtime.getStatus()).toBe("active");
|
||||||
|
expect(mockFork).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/child-process-worker\.(ts|js)$/),
|
||||||
|
[],
|
||||||
|
expect.objectContaining({
|
||||||
|
silent: true,
|
||||||
|
execArgv: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const startMessages = getMessages(child, START_RUNTIME);
|
||||||
|
expect(startMessages).toHaveLength(1);
|
||||||
|
expect(startMessages[0]?.payload).toEqual({ config: testConfig });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets status to errored and emits error when startup fails", async () => {
|
||||||
|
queueChild({
|
||||||
|
sendCallbackErrors: {
|
||||||
|
[START_RUNTIME]: new Error("start send failed"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorSpy = vi.fn();
|
||||||
|
runtime.on("error", errorSpy);
|
||||||
|
|
||||||
|
await expect(runtime.start()).rejects.toThrow("Failed to send command: start send failed");
|
||||||
|
expect(runtime.getStatus()).toBe("errored");
|
||||||
|
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(errorSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when start() is called in non-stopped states", async () => {
|
||||||
|
const blockedStates: RuntimeStatus[] = ["starting", "active", "stopping"];
|
||||||
|
|
||||||
|
for (const status of blockedStates) {
|
||||||
|
runtimeAny.status = status;
|
||||||
|
await expect(runtime.start()).rejects.toThrow(`Cannot start runtime: current status is ${status}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shutdown sequence", () => {
|
||||||
|
it("transitions active → stopping → stopped and sends STOP_RUNTIME with timeout", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
const transitions: RuntimeStatus[] = [];
|
||||||
|
runtime.on("health-changed", (data) => transitions.push(data.status));
|
||||||
|
|
||||||
|
await runtime.stop();
|
||||||
|
|
||||||
|
expect(transitions).toEqual(["stopping", "stopped"]);
|
||||||
|
expect(runtime.getStatus()).toBe("stopped");
|
||||||
|
expect(getMessages(child, STOP_RUNTIME)).toHaveLength(1);
|
||||||
|
expect(getMessages(child, STOP_RUNTIME)[0]?.payload).toEqual({ timeoutMs: 30000 });
|
||||||
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent and does not send duplicate STOP_RUNTIME commands", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
await runtime.stop();
|
||||||
|
await runtime.stop();
|
||||||
|
|
||||||
|
expect(getMessages(child, STOP_RUNTIME)).toHaveLength(1);
|
||||||
|
expect(child.kill).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns without error when stop() is called while already stopped", async () => {
|
||||||
|
await expect(runtime.stop()).resolves.toBeUndefined();
|
||||||
expect(runtime.getStatus()).toBe("stopped");
|
expect(runtime.getStatus()).toBe("stopped");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when getting TaskStore", () => {
|
it("handles stop() gracefully when IPC is already disconnected", async () => {
|
||||||
|
queueChild();
|
||||||
|
runtime.on("error", () => {
|
||||||
|
// swallow asynchronous error events from disconnection path
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
child.connected = false;
|
||||||
|
child.emit("disconnect");
|
||||||
|
|
||||||
|
await expect(runtime.stop()).resolves.toBeUndefined();
|
||||||
|
expect(runtime.getStatus()).toBe("stopped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("force-kills with SIGKILL after 5s timeout when child remains alive", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
queueChild({ markKilledOnSigterm: false });
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
await runtime.stop();
|
||||||
|
|
||||||
|
// Keep a live child reference so the delayed SIGKILL callback can execute the force-kill path.
|
||||||
|
runtimeAny.child = child;
|
||||||
|
child.killed = false;
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||||
|
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("health monitoring and restart", () => {
|
||||||
|
it("starts health monitoring after start() and performs periodic pings", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
queueChild({ pingResults: [true, true] });
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
expect(getMessages(child, "PING")).toHaveLength(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getMessages(child, "PING")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets missed heartbeat count to 0 after a successful ping", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
queueChild({ pingResults: [false, true] });
|
||||||
|
runtime.on("error", () => {
|
||||||
|
// swallow
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(runtimeAny.healthMonitor.getMissedHeartbeats()).toBe(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(runtimeAny.healthMonitor.getMissedHeartbeats()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers handleUnhealthy after three missed heartbeats", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
queueChild({ pingResults: [false, false, false] });
|
||||||
|
|
||||||
|
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
await vi.advanceTimersByTimeAsync(15000);
|
||||||
|
|
||||||
|
expect(unhealthySpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses exponential restart delays: 1000ms, 5000ms, 15000ms", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
runtimeAny.status = "active";
|
||||||
|
|
||||||
|
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||||
|
|
||||||
|
runtimeAny.handleUnhealthy();
|
||||||
|
runtimeAny.handleUnhealthy();
|
||||||
|
runtimeAny.handleUnhealthy();
|
||||||
|
|
||||||
|
const delays = timeoutSpy.mock.calls.map((call) => Number(call[1]));
|
||||||
|
expect(delays.slice(0, 3)).toEqual([1000, 5000, 15000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to errored and emits error after max restart attempts", () => {
|
||||||
|
runtimeAny.status = "active";
|
||||||
|
|
||||||
|
const errorSpy = vi.fn();
|
||||||
|
runtime.on("error", errorSpy);
|
||||||
|
|
||||||
|
runtimeAny.handleUnhealthy();
|
||||||
|
runtimeAny.handleUnhealthy();
|
||||||
|
runtimeAny.handleUnhealthy();
|
||||||
|
runtimeAny.handleUnhealthy();
|
||||||
|
|
||||||
|
expect(runtime.getStatus()).toBe("errored");
|
||||||
|
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(errorSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
||||||
|
expect((errorSpy.mock.calls[0]?.[0] as Error).message).toContain("max restart attempts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets restart attempt counter after a successful health check", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
queueChild({ pingResults: [true] });
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
runtimeAny.healthMonitor.incrementRestartAttempts();
|
||||||
|
runtimeAny.healthMonitor.incrementRestartAttempts();
|
||||||
|
expect(runtimeAny.healthMonitor.getRestartAttempts()).toBe(2);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
expect(runtimeAny.healthMonitor.getRestartAttempts()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops health checks after stop()", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
queueChild({ pingResults: [true, true, true] });
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
const pingCountBeforeStop = getMessages(child, "PING").length;
|
||||||
|
|
||||||
|
await runtime.stop();
|
||||||
|
await vi.advanceTimersByTimeAsync(20000);
|
||||||
|
|
||||||
|
expect(getMessages(child, "PING").length).toBe(pingCountBeforeStop);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("child process exit and disconnect", () => {
|
||||||
|
it("unexpected child exit while active triggers restart handling", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
const child = getLatestChild();
|
||||||
|
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||||
|
|
||||||
|
child.emit("exit", 1, null);
|
||||||
|
|
||||||
|
expect(unhealthySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("child exit while stopping does not trigger restart", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
const child = getLatestChild();
|
||||||
|
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||||
|
runtimeAny.status = "stopping";
|
||||||
|
|
||||||
|
child.emit("exit", 1, null);
|
||||||
|
|
||||||
|
expect(unhealthySpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("child exit while stopped does not trigger restart", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
const child = getLatestChild();
|
||||||
|
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||||
|
runtimeAny.status = "stopped";
|
||||||
|
|
||||||
|
child.emit("exit", 1, null);
|
||||||
|
|
||||||
|
expect(unhealthySpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("IPC disconnect while active triggers restart handling", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
const child = getLatestChild();
|
||||||
|
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||||
|
|
||||||
|
child.emit("disconnect");
|
||||||
|
|
||||||
|
expect(unhealthySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("IPC disconnect while stopping does not trigger restart", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
const child = getLatestChild();
|
||||||
|
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
|
||||||
|
runtimeAny.status = "stopping";
|
||||||
|
|
||||||
|
child.emit("disconnect");
|
||||||
|
|
||||||
|
expect(unhealthySpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("event forwarding", () => {
|
||||||
|
it("forwards TASK_CREATED as task:created", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
const task = createMockTask("FN-1279-A");
|
||||||
|
const createdSpy = vi.fn();
|
||||||
|
runtime.on("task:created", createdSpy);
|
||||||
|
|
||||||
|
child.emit("message", {
|
||||||
|
type: TASK_CREATED,
|
||||||
|
id: "evt-created",
|
||||||
|
payload: { task },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createdSpy).toHaveBeenCalledWith(task);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards TASK_MOVED as task:moved with { task, from, to } shape", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
const task = createMockTask("FN-1279-B");
|
||||||
|
const movedSpy = vi.fn();
|
||||||
|
runtime.on("task:moved", movedSpy);
|
||||||
|
|
||||||
|
child.emit("message", {
|
||||||
|
type: TASK_MOVED,
|
||||||
|
id: "evt-moved",
|
||||||
|
payload: { task, from: "todo", to: "in-progress" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(movedSpy).toHaveBeenCalledWith({ task, from: "todo", to: "in-progress" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards TASK_UPDATED as task:updated", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
const task = createMockTask("FN-1279-C");
|
||||||
|
const updatedSpy = vi.fn();
|
||||||
|
runtime.on("task:updated", updatedSpy);
|
||||||
|
|
||||||
|
child.emit("message", {
|
||||||
|
type: TASK_UPDATED,
|
||||||
|
id: "evt-updated",
|
||||||
|
payload: { task },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedSpy).toHaveBeenCalledWith(task);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards ERROR_EVENT as Error instance and preserves error code", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
const errorSpy = vi.fn();
|
||||||
|
runtime.on("error", errorSpy);
|
||||||
|
|
||||||
|
child.emit("message", {
|
||||||
|
type: ERROR_EVENT,
|
||||||
|
id: "evt-error",
|
||||||
|
payload: { message: "worker failed", code: "WORKER_FAILURE" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const forwardedError = errorSpy.mock.calls[0]?.[0] as Error & { code?: string };
|
||||||
|
expect(forwardedError).toBeInstanceOf(Error);
|
||||||
|
expect(forwardedError.message).toBe("worker failed");
|
||||||
|
expect(forwardedError.code).toBe("WORKER_FAILURE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies HEALTH_CHANGED payload to status and emits health-changed", async () => {
|
||||||
|
queueChild();
|
||||||
|
await runtime.start();
|
||||||
|
const child = getLatestChild();
|
||||||
|
|
||||||
|
const healthSpy = vi.fn();
|
||||||
|
runtime.on("health-changed", healthSpy);
|
||||||
|
healthSpy.mockClear();
|
||||||
|
|
||||||
|
child.emit("message", {
|
||||||
|
type: HEALTH_CHANGED,
|
||||||
|
id: "evt-health",
|
||||||
|
payload: { status: "paused", previous: "active" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtime.getStatus()).toBe("paused");
|
||||||
|
expect(healthSpy).toHaveBeenCalledWith({ status: "paused", previous: "active" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("metrics and inaccessible accessors", () => {
|
||||||
|
it("returns cached metrics when IPC is disconnected", () => {
|
||||||
|
runtimeAny.lastMetrics = {
|
||||||
|
inFlightTasks: 9,
|
||||||
|
activeAgents: 3,
|
||||||
|
lastActivityAt: "2026-04-08T01:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const metrics = runtime.getMetrics();
|
||||||
|
|
||||||
|
expect(metrics.inFlightTasks).toBe(9);
|
||||||
|
expect(metrics.activeAgents).toBe(3);
|
||||||
|
expect(typeof metrics.lastActivityAt).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates cached metrics when GET_METRICS response is received", async () => {
|
||||||
|
queueChild({
|
||||||
|
metricsResponse: {
|
||||||
|
inFlightTasks: 12,
|
||||||
|
activeAgents: 5,
|
||||||
|
lastActivityAt: "2026-04-08T02:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
runtime.getMetrics();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(runtimeAny.lastMetrics).toEqual({
|
||||||
|
inFlightTasks: 12,
|
||||||
|
activeAgents: 5,
|
||||||
|
lastActivityAt: "2026-04-08T02:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores GET_METRICS IPC errors and returns the last known metrics", async () => {
|
||||||
|
queueChild({
|
||||||
|
sendCallbackErrors: {
|
||||||
|
[GET_METRICS]: new Error("metrics unavailable"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
runtimeAny.lastMetrics = {
|
||||||
|
inFlightTasks: 21,
|
||||||
|
activeAgents: 8,
|
||||||
|
lastActivityAt: "2026-04-08T03:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const metrics = runtime.getMetrics();
|
||||||
|
|
||||||
|
expect(metrics.inFlightTasks).toBe(21);
|
||||||
|
expect(metrics.activeAgents).toBe(8);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(runtimeAny.lastMetrics).toEqual({
|
||||||
|
inFlightTasks: 21,
|
||||||
|
activeAgents: 8,
|
||||||
|
lastActivityAt: "2026-04-08T03:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getTaskStore() always throws not accessible error", () => {
|
||||||
expect(() => runtime.getTaskStore()).toThrow("not accessible in ChildProcessRuntime");
|
expect(() => runtime.getTaskStore()).toThrow("not accessible in ChildProcessRuntime");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when getting Scheduler", () => {
|
it("getScheduler() always throws not accessible error", () => {
|
||||||
expect(() => runtime.getScheduler()).toThrow("not accessible in ChildProcessRuntime");
|
expect(() => runtime.getScheduler()).toThrow("not accessible in ChildProcessRuntime");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return metrics even when stopped", () => {
|
|
||||||
const metrics = runtime.getMetrics();
|
|
||||||
expect(metrics.inFlightTasks).toBe(0);
|
|
||||||
expect(metrics.activeAgents).toBe(0);
|
|
||||||
expect(metrics.lastActivityAt).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("configuration", () => {
|
|
||||||
it("should store projectId in config", () => {
|
|
||||||
expect(testConfig.projectId).toBe("proj_test123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should store workingDirectory in config", () => {
|
|
||||||
expect(testConfig.workingDirectory).toBe("/tmp/test-project");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have child-process isolation mode", () => {
|
|
||||||
expect(testConfig.isolationMode).toBe("child-process");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("event handling", () => {
|
|
||||||
it("should support health-changed event", () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
runtime.on("health-changed", handler);
|
|
||||||
|
|
||||||
// The constructor may emit health-changed, so we just verify
|
|
||||||
// the event listener can be registered
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support error event", () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
runtime.on("error", handler);
|
|
||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("timer lifecycle and generation safety", () => {
|
|
||||||
it("cancels SIGKILL path after stop and never force-kills", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
await runtime.start();
|
|
||||||
const child = forkedChildren.at(-1);
|
|
||||||
expect(child).toBeDefined();
|
|
||||||
|
|
||||||
const errorHandler = vi.fn();
|
|
||||||
runtime.on("error", errorHandler);
|
|
||||||
|
|
||||||
await runtime.stop();
|
|
||||||
vi.advanceTimersByTime(6000);
|
|
||||||
|
|
||||||
expect(child?.kill).toHaveBeenCalledWith("SIGTERM");
|
|
||||||
expect(child?.kill).not.toHaveBeenCalledWith("SIGKILL");
|
|
||||||
expect(errorHandler).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels prior SIGKILL timer when killChild is called again", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
|
||||||
const firstChild = createMockChildProcess();
|
|
||||||
runtimeAny.child = firstChild;
|
|
||||||
|
|
||||||
runtimeAny.killChild();
|
|
||||||
const firstTimer = runtimeAny.sigkillTimer;
|
|
||||||
expect(firstTimer).not.toBeNull();
|
|
||||||
|
|
||||||
const secondChild = createMockChildProcess();
|
|
||||||
runtimeAny.child = secondChild;
|
|
||||||
runtimeAny.killChild();
|
|
||||||
|
|
||||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(firstTimer);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(6000);
|
|
||||||
|
|
||||||
expect(firstChild.kill).toHaveBeenCalledWith("SIGTERM");
|
|
||||||
expect(secondChild.kill).toHaveBeenCalledWith("SIGTERM");
|
|
||||||
expect(secondChild.kill).not.toHaveBeenCalledWith("SIGKILL");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prevents stale SIGKILL timers from killing a newer generation child", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const oldChild = createMockChildProcess();
|
|
||||||
runtimeAny.child = oldChild;
|
|
||||||
runtimeAny.generation = 10;
|
|
||||||
|
|
||||||
runtimeAny.killChild();
|
|
||||||
|
|
||||||
const replacementChild = createMockChildProcess();
|
|
||||||
runtimeAny.generation = 11;
|
|
||||||
runtimeAny.child = replacementChild;
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(6000);
|
|
||||||
|
|
||||||
expect(oldChild.kill).toHaveBeenCalledWith("SIGTERM");
|
|
||||||
expect(replacementChild.kill).not.toHaveBeenCalledWith("SIGKILL");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels restart timer on stop", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
runtimeAny.status = "active";
|
|
||||||
const spawnSpy = vi.spyOn(runtimeAny, "spawnChild").mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
runtimeAny.handleUnhealthy();
|
|
||||||
await runtime.stop();
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(20000);
|
|
||||||
|
|
||||||
expect(spawnSpy).not.toHaveBeenCalled();
|
|
||||||
expect(forkMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prevents stale restart callbacks when generation changes", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
runtimeAny.status = "active";
|
|
||||||
const killSpy = vi.spyOn(runtimeAny, "killChild").mockImplementation(() => {});
|
|
||||||
const spawnSpy = vi.spyOn(runtimeAny, "spawnChild").mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
runtimeAny.handleUnhealthy();
|
|
||||||
runtimeAny.generation += 1;
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
|
|
||||||
expect(killSpy).not.toHaveBeenCalled();
|
|
||||||
expect(spawnSpy).not.toHaveBeenCalled();
|
|
||||||
expect(runtimeAny.restartTimer).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prevents restart callbacks while stopping/stopped", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
runtimeAny.status = "active";
|
|
||||||
const killSpy = vi.spyOn(runtimeAny, "killChild").mockImplementation(() => {});
|
|
||||||
const spawnSpy = vi.spyOn(runtimeAny, "spawnChild").mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
runtimeAny.handleUnhealthy();
|
|
||||||
runtimeAny.status = "stopping";
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
|
|
||||||
expect(killSpy).not.toHaveBeenCalled();
|
|
||||||
expect(spawnSpy).not.toHaveBeenCalled();
|
|
||||||
expect(runtimeAny.restartTimer).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clearAllTimers cancels both SIGKILL and restart timers together via stop", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
runtimeAny.status = "active";
|
|
||||||
const child = createMockChildProcess();
|
|
||||||
runtimeAny.child = child;
|
|
||||||
|
|
||||||
const spawnSpy = vi.spyOn(runtimeAny, "spawnChild").mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
runtimeAny.killChild();
|
|
||||||
runtimeAny.handleUnhealthy();
|
|
||||||
|
|
||||||
expect(runtimeAny.sigkillTimer).not.toBeNull();
|
|
||||||
expect(runtimeAny.restartTimer).not.toBeNull();
|
|
||||||
|
|
||||||
await runtime.stop();
|
|
||||||
vi.advanceTimersByTime(20000);
|
|
||||||
|
|
||||||
expect(child.kill).toHaveBeenCalledTimes(1);
|
|
||||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
||||||
expect(child.kill).not.toHaveBeenCalledWith("SIGKILL");
|
|
||||||
expect(spawnSpy).not.toHaveBeenCalled();
|
|
||||||
expect(runtimeAny.sigkillTimer).toBeNull();
|
|
||||||
expect(runtimeAny.restartTimer).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("increments generation on each spawnChild call", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
expect(runtimeAny.generation).toBe(0);
|
|
||||||
|
|
||||||
await runtimeAny.spawnChild();
|
|
||||||
expect(runtimeAny.generation).toBe(1);
|
|
||||||
|
|
||||||
runtimeAny.killChild();
|
|
||||||
await runtimeAny.spawnChild();
|
|
||||||
expect(runtimeAny.generation).toBe(2);
|
|
||||||
|
|
||||||
expect(forkMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
368
packages/engine/src/runtimes/child-process-worker.test.ts
Normal file
368
packages/engine/src/runtimes/child-process-worker.test.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import type { RuntimeMetrics, RuntimeStatus, ProjectRuntimeConfig } from "../project-runtime.js";
|
||||||
|
import {
|
||||||
|
START_RUNTIME,
|
||||||
|
STOP_RUNTIME,
|
||||||
|
GET_STATUS,
|
||||||
|
GET_METRICS,
|
||||||
|
ERROR_EVENT,
|
||||||
|
} from "../ipc/ipc-protocol.js";
|
||||||
|
|
||||||
|
const mockState = vi.hoisted(() => ({
|
||||||
|
ipcWorkers: [] as any[],
|
||||||
|
runtimes: [] as any[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../logger.js", () => ({
|
||||||
|
runtimeLog: {
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@fusion/core", () => ({
|
||||||
|
CentralCore: class MockCentralCore {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../ipc/ipc-worker.js", () => {
|
||||||
|
class MockIpcWorker {
|
||||||
|
handlers = new Map<string, (payload: unknown) => Promise<unknown> | unknown>();
|
||||||
|
onCommand = vi.fn((type: string, handler: (payload: unknown) => Promise<unknown> | unknown) => {
|
||||||
|
this.handlers.set(type, handler);
|
||||||
|
});
|
||||||
|
sendEvent = vi.fn();
|
||||||
|
shutdown = vi.fn();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
mockState.ipcWorkers.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { IpcWorker: MockIpcWorker };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./in-process-runtime.js", () => {
|
||||||
|
class MockInProcessRuntime {
|
||||||
|
status: RuntimeStatus = "stopped";
|
||||||
|
metrics: RuntimeMetrics = {
|
||||||
|
inFlightTasks: 1,
|
||||||
|
activeAgents: 1,
|
||||||
|
lastActivityAt: "2026-04-08T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
listeners = new Map<string, Array<(...args: any[]) => void>>();
|
||||||
|
|
||||||
|
start = vi.fn(async () => {
|
||||||
|
this.status = "active";
|
||||||
|
});
|
||||||
|
|
||||||
|
stop = vi.fn(async () => {
|
||||||
|
this.status = "stopped";
|
||||||
|
});
|
||||||
|
|
||||||
|
getStatus = vi.fn(() => this.status);
|
||||||
|
|
||||||
|
getMetrics = vi.fn(() => this.metrics);
|
||||||
|
|
||||||
|
on = vi.fn((event: string, handler: (...args: any[]) => void) => {
|
||||||
|
const existing = this.listeners.get(event) ?? [];
|
||||||
|
existing.push(handler);
|
||||||
|
this.listeners.set(event, existing);
|
||||||
|
return this;
|
||||||
|
});
|
||||||
|
|
||||||
|
emit(event: string, ...args: any[]) {
|
||||||
|
for (const handler of this.listeners.get(event) ?? []) {
|
||||||
|
handler(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public config: ProjectRuntimeConfig,
|
||||||
|
public centralCore: unknown
|
||||||
|
) {
|
||||||
|
mockState.runtimes.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { InProcessRuntime: MockInProcessRuntime };
|
||||||
|
});
|
||||||
|
|
||||||
|
type MockWorker = {
|
||||||
|
handlers: Map<string, (payload: unknown) => Promise<unknown> | unknown>;
|
||||||
|
onCommand: ReturnType<typeof vi.fn>;
|
||||||
|
sendEvent: ReturnType<typeof vi.fn>;
|
||||||
|
shutdown: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockRuntime = {
|
||||||
|
config: ProjectRuntimeConfig;
|
||||||
|
centralCore: {
|
||||||
|
getGlobalConcurrencyState?: () => Promise<unknown>;
|
||||||
|
recordTaskCompletion?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
status: RuntimeStatus;
|
||||||
|
metrics: RuntimeMetrics;
|
||||||
|
start: ReturnType<typeof vi.fn>;
|
||||||
|
stop: ReturnType<typeof vi.fn>;
|
||||||
|
getStatus: ReturnType<typeof vi.fn>;
|
||||||
|
getMetrics: ReturnType<typeof vi.fn>;
|
||||||
|
on: ReturnType<typeof vi.fn>;
|
||||||
|
emit: (event: string, ...args: unknown[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConfig: ProjectRuntimeConfig = {
|
||||||
|
projectId: "proj_worker_test",
|
||||||
|
workingDirectory: "/tmp/test-worker",
|
||||||
|
isolationMode: "in-process",
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadWorkerModule(): Promise<MockWorker> {
|
||||||
|
await import("./child-process-worker.js");
|
||||||
|
|
||||||
|
const ipcWorker = mockState.ipcWorkers.at(-1) as MockWorker | undefined;
|
||||||
|
if (!ipcWorker) {
|
||||||
|
throw new Error("Expected child-process-worker to instantiate IpcWorker");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ipcWorker;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHandler<T = unknown>(
|
||||||
|
worker: MockWorker,
|
||||||
|
type: string
|
||||||
|
): (payload: unknown) => Promise<T> {
|
||||||
|
const handler = worker.handlers.get(type);
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error(`Missing handler for ${type}`);
|
||||||
|
}
|
||||||
|
return handler as (payload: unknown) => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("child-process-worker", () => {
|
||||||
|
const originalProcessSend = process.send;
|
||||||
|
let sigtermBaseline: Function[] = [];
|
||||||
|
let sigintBaseline: Function[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockState.ipcWorkers.length = 0;
|
||||||
|
mockState.runtimes.length = 0;
|
||||||
|
|
||||||
|
sigtermBaseline = process.listeners("SIGTERM");
|
||||||
|
sigintBaseline = process.listeners("SIGINT");
|
||||||
|
|
||||||
|
(process as NodeJS.Process & { send?: (...args: unknown[]) => unknown }).send = vi.fn(() => true);
|
||||||
|
vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const listener of process.listeners("SIGTERM")) {
|
||||||
|
if (!sigtermBaseline.includes(listener)) {
|
||||||
|
process.removeListener("SIGTERM", listener as (...args: any[]) => void);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const listener of process.listeners("SIGINT")) {
|
||||||
|
if (!sigintBaseline.includes(listener)) {
|
||||||
|
process.removeListener("SIGINT", listener as (...args: any[]) => void);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalProcessSend) {
|
||||||
|
process.send = originalProcessSend;
|
||||||
|
} else {
|
||||||
|
delete (process as NodeJS.Process & { send?: unknown }).send;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("instantiates IpcWorker and registers START/STOP/GET_STATUS/GET_METRICS handlers", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
|
||||||
|
expect(mockState.ipcWorkers).toHaveLength(1);
|
||||||
|
expect(worker.onCommand).toHaveBeenCalledTimes(4);
|
||||||
|
expect(worker.onCommand).toHaveBeenCalledWith(START_RUNTIME, expect.any(Function));
|
||||||
|
expect(worker.onCommand).toHaveBeenCalledWith(STOP_RUNTIME, expect.any(Function));
|
||||||
|
expect(worker.onCommand).toHaveBeenCalledWith(GET_STATUS, expect.any(Function));
|
||||||
|
expect(worker.onCommand).toHaveBeenCalledWith(GET_METRICS, expect.any(Function));
|
||||||
|
expect(worker.handlers.size).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("START_RUNTIME creates and starts InProcessRuntime, then returns status", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const startHandler = getHandler<{ status: RuntimeStatus }>(worker, START_RUNTIME);
|
||||||
|
|
||||||
|
const result = await startHandler({ config: testConfig });
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: "active" });
|
||||||
|
expect(mockState.runtimes).toHaveLength(1);
|
||||||
|
|
||||||
|
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||||
|
expect(runtime.config).toEqual(testConfig);
|
||||||
|
expect(runtime.start).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runtime.getStatus).toHaveBeenCalled();
|
||||||
|
expect(typeof runtime.centralCore.getGlobalConcurrencyState).toBe("function");
|
||||||
|
expect(typeof runtime.centralCore.recordTaskCompletion).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("START_RUNTIME throws if runtime is already started", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const startHandler = getHandler(worker, START_RUNTIME);
|
||||||
|
|
||||||
|
await startHandler({ config: testConfig });
|
||||||
|
await expect(startHandler({ config: testConfig })).rejects.toThrow("Runtime already started");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("START_RUNTIME forwards runtime events via ipcWorker.sendEvent", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const startHandler = getHandler(worker, START_RUNTIME);
|
||||||
|
|
||||||
|
await startHandler({ config: testConfig });
|
||||||
|
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||||
|
|
||||||
|
const task = {
|
||||||
|
id: "FN-1279",
|
||||||
|
title: "task",
|
||||||
|
description: "desc",
|
||||||
|
column: "todo",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
createdAt: "2026-04-08T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||||
|
size: "M",
|
||||||
|
reviewLevel: 1,
|
||||||
|
log: [],
|
||||||
|
attachments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
runtime.emit("task:created", task);
|
||||||
|
runtime.emit("task:moved", { task, from: "todo", to: "in-progress" });
|
||||||
|
runtime.emit("task:updated", task);
|
||||||
|
const runtimeError = new Error("runtime boom") as Error & { code?: string };
|
||||||
|
runtimeError.code = "RUNTIME_ERR";
|
||||||
|
runtime.emit("error", runtimeError);
|
||||||
|
runtime.emit("health-changed", { status: "active", previous: "starting" });
|
||||||
|
|
||||||
|
expect(worker.sendEvent).toHaveBeenCalledWith("TASK_CREATED", { task });
|
||||||
|
expect(worker.sendEvent).toHaveBeenCalledWith("TASK_MOVED", {
|
||||||
|
task,
|
||||||
|
from: "todo",
|
||||||
|
to: "in-progress",
|
||||||
|
});
|
||||||
|
expect(worker.sendEvent).toHaveBeenCalledWith("TASK_UPDATED", { task });
|
||||||
|
expect(worker.sendEvent).toHaveBeenCalledWith(ERROR_EVENT, {
|
||||||
|
message: "runtime boom",
|
||||||
|
code: "RUNTIME_ERR",
|
||||||
|
});
|
||||||
|
expect(worker.sendEvent).toHaveBeenCalledWith("HEALTH_CHANGED", {
|
||||||
|
status: "active",
|
||||||
|
previous: "starting",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("STOP_RUNTIME stops existing runtime and returns stopped true", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const startHandler = getHandler(worker, START_RUNTIME);
|
||||||
|
const stopHandler = getHandler<{ stopped: boolean }>(worker, STOP_RUNTIME);
|
||||||
|
|
||||||
|
await startHandler({ config: testConfig });
|
||||||
|
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||||
|
|
||||||
|
const result = await stopHandler({ timeoutMs: 12345 });
|
||||||
|
|
||||||
|
expect(result).toEqual({ stopped: true });
|
||||||
|
expect(runtime.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("STOP_RUNTIME throws when runtime has not been started", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const stopHandler = getHandler(worker, STOP_RUNTIME);
|
||||||
|
|
||||||
|
await expect(stopHandler({ timeoutMs: 30000 })).rejects.toThrow("Runtime not started");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET_STATUS returns stopped when runtime is null", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const getStatusHandler = getHandler<{ status: RuntimeStatus }>(worker, GET_STATUS);
|
||||||
|
|
||||||
|
await expect(getStatusHandler({})).resolves.toEqual({ status: "stopped" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET_STATUS returns runtime status when runtime exists", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const startHandler = getHandler(worker, START_RUNTIME);
|
||||||
|
const getStatusHandler = getHandler<{ status: RuntimeStatus }>(worker, GET_STATUS);
|
||||||
|
|
||||||
|
await startHandler({ config: testConfig });
|
||||||
|
|
||||||
|
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||||
|
runtime.status = "paused";
|
||||||
|
|
||||||
|
await expect(getStatusHandler({})).resolves.toEqual({ status: "paused" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET_METRICS returns default metrics when runtime is null", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const getMetricsHandler = getHandler<RuntimeMetrics>(worker, GET_METRICS);
|
||||||
|
|
||||||
|
const result = await getMetricsHandler({});
|
||||||
|
|
||||||
|
expect(result.inFlightTasks).toBe(0);
|
||||||
|
expect(result.activeAgents).toBe(0);
|
||||||
|
expect(typeof result.lastActivityAt).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET_METRICS returns runtime metrics when runtime exists", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const startHandler = getHandler(worker, START_RUNTIME);
|
||||||
|
const getMetricsHandler = getHandler<RuntimeMetrics>(worker, GET_METRICS);
|
||||||
|
|
||||||
|
await startHandler({ config: testConfig });
|
||||||
|
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||||
|
runtime.metrics = {
|
||||||
|
inFlightTasks: 7,
|
||||||
|
activeAgents: 4,
|
||||||
|
lastActivityAt: "2026-04-08T05:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(getMetricsHandler({})).resolves.toEqual(runtime.metrics);
|
||||||
|
expect(runtime.getMetrics).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SIGTERM stops runtime and shuts down IPC worker", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const startHandler = getHandler(worker, START_RUNTIME);
|
||||||
|
|
||||||
|
await startHandler({ config: testConfig });
|
||||||
|
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||||
|
|
||||||
|
process.emit("SIGTERM");
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(runtime.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(worker.shutdown).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SIGINT stops runtime and shuts down IPC worker", async () => {
|
||||||
|
const worker = await loadWorkerModule();
|
||||||
|
const startHandler = getHandler(worker, START_RUNTIME);
|
||||||
|
|
||||||
|
await startHandler({ config: testConfig });
|
||||||
|
const runtime = mockState.runtimes[0] as MockRuntime;
|
||||||
|
|
||||||
|
process.emit("SIGINT");
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(runtime.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(worker.shutdown).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user