test(e2e): nerve sense list (closes #155) #166

Merged
xiaonuo merged 2 commits from test/155-sense-list into main 2026-04-27 07:04:56 +00:00
@@ -0,0 +1,141 @@
/**
* Integration test for `nerve sense list` through citty `runCommand` with a temp
* HOME and nerve.yaml. The daemon IPC layer is exercised against a real Unix
* socket mock server (no daemon process). `workspace.isRunning` and
* `getSocketPath` are mocked so the CLI takes the live-daemon code path while
* the socket points at the fake IPC server.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { type Server, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SenseInfo } from "@uncaged/nerve-core";
import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { senseCommand } from "../commands/sense.js";
import * as workspace from "../workspace.js";
describe("nerve sense list CLI (runCommand + temp HOME + mock IPC socket)", () => {
let prevHome: string | undefined;
let fakeHome: string;
let sockDir: string;
let sockPath: string;
let ipcServer: Server | null;
let stdoutSpy: ReturnType<typeof vi.spyOn<typeof process.stdout, "write">> | null;
let listSensesRequests: unknown[];
const LAST_SIGNAL_TS = 1_714_521_600_000; // fixed wall time for stable ISO in assertions
const daemonSenseRow: SenseInfo = {
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
triggers: ["every 5s"],
lastSignalTimestamp: LAST_SIGNAL_TS,
};
function nerveYamlFixture(): string {
return `
senses:
cpu-usage:
group: system
throttle: 5s
timeout: 3s
reflexes:
- sense: cpu-usage
interval: 5s
`.trim();
}
function collectStdout(): string {
if (stdoutSpy === null) return "";
let out = "";
for (const call of stdoutSpy.mock.calls) {
const chunk = call[0];
if (typeof chunk === "string") out += chunk;
else if (Buffer.isBuffer(chunk)) out += chunk.toString("utf8");
}
return out;
}
beforeEach(async () => {
listSensesRequests = [];
ipcServer = null;
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
prevHome = process.env.HOME;
fakeHome = mkdtempSync(join(tmpdir(), "nerve-sense-list-cli-e2e-"));
process.env.HOME = fakeHome;
const nerveRoot = join(fakeHome, ".uncaged-nerve");
mkdirSync(nerveRoot, { recursive: true });
writeFileSync(join(nerveRoot, "nerve.yaml"), `${nerveYamlFixture()}\n`, "utf8");
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-list-ipc-e2e-"));
sockPath = join(sockDir, "nerve.sock");
vi.spyOn(workspace, "getSocketPath").mockReturnValue(sockPath);
vi.spyOn(workspace, "isRunning").mockReturnValue(true);
ipcServer = createServer((socket) => {
socket.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
try {
const req = JSON.parse(line) as { type: string };
if (req.type === "list-senses") {
listSensesRequests.push(req);
socket.write(`${JSON.stringify({ ok: true, senses: [daemonSenseRow] })}\n`);
}
} catch {
// ignore malformed lines
}
});
});
await new Promise<void>((resolve) => {
ipcServer?.listen(sockPath, resolve);
});
});
afterEach(async () => {
stdoutSpy?.mockRestore();
stdoutSpy = null;
if (ipcServer !== null) {
await new Promise<void>((resolve) => {
ipcServer?.close(() => resolve());
});
ipcServer = null;
}
if (prevHome === undefined) {
// biome-ignore lint/performance/noDelete: semantically correct for env cleanup
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
rmSync(fakeHome, { recursive: true, force: true });
rmSync(sockDir, { recursive: true, force: true });
});
it("prints sense list from daemon path with name, group, throttle, triggers, and last signal time", async () => {
// With a real daemon, we would wait for a compute cycle; the mock server
// returns SenseInfo as if one already produced lastSignalTimestamp.
await runCommand(senseCommand, { rawArgs: ["list"] });
expect(listSensesRequests).toHaveLength(1);
expect(listSensesRequests[0]).toMatchObject({ type: "list-senses" });
const out = collectStdout();
expect(out).toContain("cpu-usage");
expect(out).toContain("group: system");
expect(out).toContain("throttle: 5s");
expect(out).toContain("timeout: 3s");
expect(out).toContain("triggers: every 5s");
expect(out).not.toContain("(never)");
expect(out).toContain(new Date(LAST_SIGNAL_TS).toISOString());
});
});