Files
fusion/packages/engine/src/runtimes/in-process-runtime.test.ts
2026-04-01 21:25:08 -07:00

277 lines
8.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task, TaskStore, CentralCore } from "@fusion/core";
import { InProcessRuntime } from "./in-process-runtime.js";
import type { ProjectRuntimeConfig } from "../project-runtime.js";
// Mock the TaskStore class
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
TaskStore: vi.fn().mockImplementation(function(this: TaskStore, rootDir: string) {
const self = this as unknown as Record<string, unknown>;
self.getRootDir = () => rootDir;
self.init = vi.fn().mockResolvedValue(undefined);
self.listTasks = vi.fn().mockResolvedValue([]);
self.getSettings = vi.fn().mockResolvedValue({});
self.getMissionStore = vi.fn().mockReturnValue({
getMissionWithHierarchy: vi.fn().mockReturnValue(null),
findNextPendingSlice: vi.fn().mockReturnValue(null),
activateSlice: vi.fn(),
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
});
self.on = vi.fn().mockReturnValue(self);
self.emit = vi.fn().mockReturnValue(true);
return self;
}),
};
});
// Mock the worktree pool
vi.mock("../worktree-pool.js", async () => {
const actual = await vi.importActual<typeof import("../worktree-pool.js")>("../worktree-pool.js");
return {
...actual,
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
};
});
// Mock the scheduler
vi.mock("../scheduler.js", async () => {
return {
Scheduler: vi.fn().mockImplementation(() => {
const self = {} as Record<string, unknown>;
self.start = vi.fn();
self.stop = vi.fn();
return self;
}),
};
});
// Mock the executor
vi.mock("../executor.js", async () => {
return {
TaskExecutor: vi.fn().mockImplementation(() => {
const self = {} as Record<string, unknown>;
self.resumeOrphaned = vi.fn().mockResolvedValue(undefined);
self.activeWorktrees = new Map();
return self;
}),
};
});
describe("InProcessRuntime", () => {
let runtime: InProcessRuntime;
let mockCentralCore: CentralCore;
const testConfig: ProjectRuntimeConfig = {
projectId: "proj_test123",
workingDirectory: "/tmp/test-project",
isolationMode: "in-process",
maxConcurrent: 2,
maxWorktrees: 4,
};
beforeEach(() => {
// Create mock CentralCore
mockCentralCore = {
getGlobalConcurrencyState: vi.fn().mockResolvedValue({
globalMaxConcurrent: 4,
currentlyActive: 0,
queuedCount: 0,
projectsActive: {},
}),
recordTaskCompletion: vi.fn().mockResolvedValue(undefined),
} as unknown as CentralCore;
runtime = new InProcessRuntime(testConfig, mockCentralCore);
});
afterEach(async () => {
try {
await runtime.stop();
} catch {
// Ignore errors during cleanup
}
vi.clearAllMocks();
});
describe("lifecycle", () => {
it("should start with status 'stopped'", () => {
expect(runtime.getStatus()).toBe("stopped");
});
it("should transition to 'active' after start", async () => {
await runtime.start();
expect(runtime.getStatus()).toBe("active");
});
it("should transition to 'stopped' after stop", async () => {
await runtime.start();
await runtime.stop();
expect(runtime.getStatus()).toBe("stopped");
});
it("should throw if starting when not stopped", async () => {
await runtime.start();
await expect(runtime.start()).rejects.toThrow("Cannot start runtime");
});
it("should handle stop when already stopped", async () => {
// Should not throw
await runtime.stop();
expect(runtime.getStatus()).toBe("stopped");
});
it("should transition through 'starting' during start", async () => {
const statusChanges: string[] = [];
runtime.on("health-changed", (data) => {
statusChanges.push(data.status);
});
await runtime.start();
expect(statusChanges).toContain("starting");
expect(statusChanges).toContain("active");
});
it("should transition through 'stopping' during stop", async () => {
await runtime.start();
const statusChanges: string[] = [];
runtime.on("health-changed", (data) => {
statusChanges.push(data.status);
});
await runtime.stop();
expect(statusChanges).toContain("stopping");
expect(statusChanges).toContain("stopped");
});
});
describe("event forwarding", () => {
it("should emit health-changed on status transitions", async () => {
const healthChangedSpy = vi.fn();
runtime.on("health-changed", healthChangedSpy);
await runtime.start();
expect(healthChangedSpy).toHaveBeenCalled();
const calls = healthChangedSpy.mock.calls;
const lastCall = calls[calls.length - 1][0];
expect(lastCall.status).toBe("active");
expect(lastCall.previous).toBe("starting");
});
it("should emit task:created when task store emits task:created", async () => {
await runtime.start();
const taskCreatedSpy = vi.fn();
runtime.on("task:created", taskCreatedSpy);
// Get the mock TaskStore and simulate an event
const taskStore = runtime.getTaskStore();
const mockTask = { id: "KB-001", title: "Test Task" } as Task;
// Get the registered handler and call it
const onCalls = (taskStore.on as ReturnType<typeof vi.fn>).mock.calls;
const taskCreatedHandler = onCalls.find((call: unknown[]) => call[0] === "task:created");
if (taskCreatedHandler) {
(taskCreatedHandler[1] as (task: Task) => void)(mockTask);
}
expect(taskCreatedSpy).toHaveBeenCalledWith(mockTask);
});
it("should emit task:moved when task store emits task:moved", async () => {
await runtime.start();
const taskMovedSpy = vi.fn();
runtime.on("task:moved", taskMovedSpy);
const taskStore = runtime.getTaskStore();
const mockTask = { id: "KB-001", title: "Test Task" } as Task;
const moveData = { task: mockTask, from: "todo", to: "in-progress" };
const onCalls = (taskStore.on as ReturnType<typeof vi.fn>).mock.calls;
const taskMovedHandler = onCalls.find((call: unknown[]) => call[0] === "task:moved");
if (taskMovedHandler) {
(taskMovedHandler[1] as (data: { task: Task; from: string; to: string }) => void)(moveData);
}
expect(taskMovedSpy).toHaveBeenCalledWith(moveData);
});
});
describe("metrics", () => {
it("should return metrics with default values before start", () => {
const metrics = runtime.getMetrics();
expect(metrics.inFlightTasks).toBe(0);
expect(metrics.activeAgents).toBe(0);
expect(metrics.lastActivityAt).toBeDefined();
});
it("should include memory usage in metrics", () => {
const metrics = runtime.getMetrics();
// Memory usage may or may not be available depending on environment
if (metrics.memoryBytes !== undefined) {
expect(typeof metrics.memoryBytes).toBe("number");
expect(metrics.memoryBytes).toBeGreaterThanOrEqual(0);
}
});
});
describe("accessors", () => {
it("should throw when accessing TaskStore before start", () => {
expect(() => runtime.getTaskStore()).toThrow("TaskStore not initialized");
});
it("should throw when accessing Scheduler before start", () => {
expect(() => runtime.getScheduler()).toThrow("Scheduler not initialized");
});
it("should return TaskStore after start", async () => {
await runtime.start();
const taskStore = runtime.getTaskStore();
expect(taskStore).toBeDefined();
expect(taskStore.getRootDir()).toBe(testConfig.workingDirectory);
});
it("should return Scheduler after start", async () => {
await runtime.start();
const scheduler = runtime.getScheduler();
expect(scheduler).toBeDefined();
});
});
describe("configuration", () => {
it("should store projectId in config", () => {
// Access via the constructor params - runtime is created with testConfig
expect(testConfig.projectId).toBe("proj_test123");
});
it("should store workingDirectory in config", () => {
expect(testConfig.workingDirectory).toBe("/tmp/test-project");
});
it("should store maxConcurrent in config", () => {
expect(testConfig.maxConcurrent).toBe(2);
});
it("should store maxWorktrees in config", () => {
expect(testConfig.maxWorktrees).toBe(4);
});
});
});