From 4dde1015151f0bfcea746dad2df909a55c8b2713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Mon, 27 Apr 2026 14:37:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(e2e):=20nerve=20sense=20list=20?= =?UTF-8?q?=E2=80=94=20mock=20daemon=20IPC=20+=20full=20CLI=20path=20(clos?= =?UTF-8?q?es=20#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/src/__tests__/sense-list-e2e.test.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 packages/cli/src/__tests__/sense-list-e2e.test.ts diff --git a/packages/cli/src/__tests__/sense-list-e2e.test.ts b/packages/cli/src/__tests__/sense-list-e2e.test.ts new file mode 100644 index 0000000..757eb76 --- /dev/null +++ b/packages/cli/src/__tests__/sense-list-e2e.test.ts @@ -0,0 +1,149 @@ +/** + * 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"; + +vi.mock("../workspace.js", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + isRunning: vi.fn(() => true), + getSocketPath: vi.fn(() => join(tmpdir(), "nerve-sense-list-e2e-sock-placeholder.sock")), + }; +}); + +import { senseCommand } from "../commands/sense.js"; +import { getSocketPath, isRunning } 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> | 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.mocked(getSocketPath).mockReturnValue(sockPath); + vi.mocked(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((resolve) => { + ipcServer?.listen(sockPath, resolve); + }); + }); + + afterEach(async () => { + stdoutSpy?.mockRestore(); + stdoutSpy = null; + + if (ipcServer !== null) { + await new Promise((resolve) => { + ipcServer?.close(() => resolve()); + }); + ipcServer = null; + } + + if (prevHome === undefined) { + process.env.HOME = undefined; + } 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()); + }); +}); -- 2.43.0 From cad51d306bfcfc86c683b51ba936cb1006ef6760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Mon, 27 Apr 2026 14:56:07 +0800 Subject: [PATCH 2/2] fix: use vi.spyOn + delete process.env.HOME per review --- .../cli/src/__tests__/sense-list-e2e.test.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/__tests__/sense-list-e2e.test.ts b/packages/cli/src/__tests__/sense-list-e2e.test.ts index 757eb76..422d06a 100644 --- a/packages/cli/src/__tests__/sense-list-e2e.test.ts +++ b/packages/cli/src/__tests__/sense-list-e2e.test.ts @@ -15,17 +15,8 @@ import type { SenseInfo } from "@uncaged/nerve-core"; import { runCommand } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("../workspace.js", async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - isRunning: vi.fn(() => true), - getSocketPath: vi.fn(() => join(tmpdir(), "nerve-sense-list-e2e-sock-placeholder.sock")), - }; -}); - import { senseCommand } from "../commands/sense.js"; -import { getSocketPath, isRunning } from "../workspace.js"; +import * as workspace from "../workspace.js"; describe("nerve sense list CLI (runCommand + temp HOME + mock IPC socket)", () => { let prevHome: string | undefined; @@ -87,8 +78,8 @@ reflexes: sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-list-ipc-e2e-")); sockPath = join(sockDir, "nerve.sock"); - vi.mocked(getSocketPath).mockReturnValue(sockPath); - vi.mocked(isRunning).mockReturnValue(true); + vi.spyOn(workspace, "getSocketPath").mockReturnValue(sockPath); + vi.spyOn(workspace, "isRunning").mockReturnValue(true); ipcServer = createServer((socket) => { socket.on("data", (chunk: Buffer) => { @@ -121,7 +112,8 @@ reflexes: } if (prevHome === undefined) { - process.env.HOME = undefined; + // biome-ignore lint/performance/noDelete: semantically correct for env cleanup + delete process.env.HOME; } else { process.env.HOME = prevHome; } -- 2.43.0