test(e2e): nerve sense list (closes #155) #166
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user