This repository has been archived on 2026-06-01. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
nerve/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts
T
xiaoju 5e054facb2 refactor(daemon): stateful sense engine — JSON state, remove Signal Bus
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
2026-05-01 09:50:46 +00:00

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);
});