5e054facb2
Phase 2 of RFC #308: Stateful Sense refactor. - SenseRuntime uses JSON file persistence instead of SQLite/Drizzle - Sense compute now receives state and returns { state, workflow } - IPC: replaced SignalMessage with ComputeResultMessage - Removed Signal Bus entirely (on[] now uses reverse-index in scheduler) - sense-scheduler.onSenseCompleted() triggers dependent senses - kernel no longer constructs Signal objects or calls routeSenseComputeOutput - Removed drizzle-orm dependency from daemon package Refs #308, closes #310
252 lines
7.1 KiB
TypeScript
252 lines
7.1 KiB
TypeScript
/**
|
|
* Unit tests for kernel.triggerSense() — IPC issue #36.
|
|
*
|
|
* These tests use a mock child_process and a mock LogStore so they do NOT
|
|
* require a real LogStore (node:sqlite) in integration tests.
|
|
*/
|
|
|
|
import { EventEmitter } from "node:events";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import type { NerveConfig } from "@uncaged/nerve-core";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock child_process.fork before importing kernel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockChildren: MockChild[] = [];
|
|
|
|
type MockChild = EventEmitter & {
|
|
send: ReturnType<typeof vi.fn>;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
connected: boolean;
|
|
pid: number;
|
|
};
|
|
|
|
function makeMockChild(pid = 1): MockChild {
|
|
const child = new EventEmitter() as MockChild;
|
|
child.connected = true;
|
|
setImmediate(() => {
|
|
child.emit("message", { type: "ready" });
|
|
});
|
|
child.send = vi.fn((msg: unknown) => {
|
|
if (
|
|
msg !== null &&
|
|
typeof msg === "object" &&
|
|
(msg as Record<string, unknown>).type === "shutdown"
|
|
) {
|
|
setImmediate(() => {
|
|
child.connected = false;
|
|
child.emit("exit", 0, null);
|
|
});
|
|
}
|
|
});
|
|
child.kill = vi.fn((_signal?: string) => {
|
|
child.connected = false;
|
|
child.emit("exit", null, _signal ?? "SIGKILL");
|
|
});
|
|
child.pid = pid;
|
|
return child;
|
|
}
|
|
|
|
vi.mock("node:child_process", () => ({
|
|
fork: vi.fn((_script: string, _args: string[], _opts: unknown) => {
|
|
const child = makeMockChild(mockChildren.length + 1);
|
|
mockChildren.push(child);
|
|
return child;
|
|
}),
|
|
}));
|
|
|
|
// Import after mock is set up
|
|
const { createKernel } = await import("../kernel.js");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock LogStore factory (avoids SQLite I/O in this unit test)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeMockLogStore() {
|
|
return {
|
|
append: vi.fn(),
|
|
query: vi.fn(() => []),
|
|
getMeta: vi.fn(() => null),
|
|
setMeta: vi.fn(),
|
|
upsertWorkflowRun: vi.fn(),
|
|
appendWithWorkflowUpdate: vi.fn(),
|
|
getWorkflowRun: vi.fn(() => null),
|
|
getActiveWorkflowRuns: vi.fn(() => []),
|
|
getAllWorkflowRuns: vi.fn(() => []),
|
|
getTriggerPayload: vi.fn(() => null),
|
|
getThreadEvents: vi.fn(() => []),
|
|
getThreadMessages: vi.fn(() => []),
|
|
getThreadRoundCount: vi.fn(() => 0),
|
|
getThreadRounds: vi.fn(() => []),
|
|
getThreadStartMessage: vi.fn(() => null),
|
|
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
|
|
close: vi.fn(),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|
return {
|
|
senses: {
|
|
"cpu-usage": {
|
|
group: "system",
|
|
throttle: null,
|
|
timeout: null,
|
|
gracePeriod: null,
|
|
interval: null,
|
|
on: [],
|
|
},
|
|
},
|
|
workflows: {},
|
|
maxRounds: 10,
|
|
extract: null,
|
|
api: { port: null, token: null, host: "127.0.0.1" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("kernel.triggerSense()", () => {
|
|
let nerveRoot: string;
|
|
|
|
beforeEach(() => {
|
|
mockChildren.length = 0;
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-trigger-sense-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
rmSync(nerveRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("throws for an unknown sense name", async () => {
|
|
const config = makeConfig();
|
|
const kernel = createKernel(config, nerveRoot, {
|
|
workerScript: null,
|
|
logStore: makeMockLogStore() as never,
|
|
});
|
|
|
|
await vi.runAllTimersAsync();
|
|
expect(() => kernel.triggerSense("no-such-sense")).toThrow(/Unknown sense/);
|
|
|
|
await kernel.stop();
|
|
});
|
|
|
|
it("sends a compute message to the worker for the correct group", async () => {
|
|
const config = makeConfig({
|
|
senses: {
|
|
"cpu-usage": {
|
|
group: "system",
|
|
throttle: null,
|
|
timeout: null,
|
|
gracePeriod: null,
|
|
interval: null,
|
|
on: [],
|
|
},
|
|
"net-io": {
|
|
group: "network",
|
|
throttle: null,
|
|
timeout: null,
|
|
gracePeriod: null,
|
|
interval: null,
|
|
on: [],
|
|
},
|
|
},
|
|
});
|
|
const kernel = createKernel(config, nerveRoot, {
|
|
workerScript: null,
|
|
logStore: makeMockLogStore() as never,
|
|
});
|
|
|
|
await vi.runAllTimersAsync();
|
|
// Two groups → two workers
|
|
expect(mockChildren.length).toBe(2);
|
|
|
|
// Workers are keyed by group: groups iteration order matches the insertion
|
|
// order from Object.values(config.senses). Find the worker for "system".
|
|
const systemWorkerIdx = Array.from(kernel.groups).indexOf("system");
|
|
const systemWorker = mockChildren[systemWorkerIdx];
|
|
|
|
kernel.triggerSense("cpu-usage");
|
|
|
|
expect(systemWorker.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: "compute", sense: "cpu-usage" }),
|
|
);
|
|
|
|
await kernel.stop();
|
|
});
|
|
|
|
it("sends a compute message to the correct worker when multiple senses share a group", async () => {
|
|
const config = makeConfig({
|
|
senses: {
|
|
"cpu-usage": {
|
|
group: "system",
|
|
throttle: null,
|
|
timeout: null,
|
|
gracePeriod: null,
|
|
interval: null,
|
|
on: [],
|
|
},
|
|
"disk-usage": {
|
|
group: "system",
|
|
throttle: null,
|
|
timeout: null,
|
|
gracePeriod: null,
|
|
interval: null,
|
|
on: [],
|
|
},
|
|
},
|
|
});
|
|
const kernel = createKernel(config, nerveRoot, {
|
|
workerScript: null,
|
|
logStore: makeMockLogStore() as never,
|
|
});
|
|
|
|
await vi.runAllTimersAsync();
|
|
// Both senses share the "system" group → one worker only
|
|
expect(mockChildren.length).toBe(1);
|
|
const worker = mockChildren[0];
|
|
|
|
kernel.triggerSense("disk-usage");
|
|
|
|
expect(worker.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: "compute", sense: "disk-usage" }),
|
|
);
|
|
|
|
await kernel.stop();
|
|
});
|
|
|
|
it("does not send to a disconnected worker (does not throw)", async () => {
|
|
// Use real timers so kernel.stop() waitForExit can rely on SIGKILL timeout
|
|
vi.useRealTimers();
|
|
|
|
const config = makeConfig();
|
|
const kernel = createKernel(config, nerveRoot, {
|
|
workerScript: null,
|
|
logStore: makeMockLogStore() as never,
|
|
});
|
|
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
const worker = mockChildren[0];
|
|
worker.connected = false;
|
|
|
|
// Should not throw even when the worker is disconnected
|
|
expect(() => kernel.triggerSense("cpu-usage")).not.toThrow();
|
|
expect(worker.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "compute" }));
|
|
|
|
await kernel.stop();
|
|
}, 10_000);
|
|
});
|