From 44f20b3fb01821aa3994ba25a90ec65d60325772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 27 Apr 2026 06:12:58 +0000 Subject: [PATCH] test(cli): add e2e smoke test for sense list + query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies the full daemon → sense → signal → query pipeline works end-to-end using a real kernel with a mock sense worker. Refs #154 --- packages/cli/package.json | 1 + packages/cli/src/__tests__/e2e-harness.ts | 293 +++++++++++++++++++ packages/cli/src/__tests__/e2e-smoke.test.ts | 73 +++++ pnpm-lock.yaml | 3 + 4 files changed, 370 insertions(+) create mode 100644 packages/cli/src/__tests__/e2e-harness.ts create mode 100644 packages/cli/src/__tests__/e2e-smoke.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 4ad4728..baf214a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,6 +28,7 @@ "devDependencies": { "@rslib/core": "^0.21.3", "@types/node": "^22.0.0", + "@uncaged/nerve-daemon": "workspace:*", "vitest": "^4.1.5" } } diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts new file mode 100644 index 0000000..e89f042 --- /dev/null +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -0,0 +1,293 @@ +/** + * Shared E2E harness: temp HOME layout (`.uncaged-nerve`), real kernel + sense worker, + * IPC socket for CLI, and `runCommand` helpers with captured stdio. + * + * ## Signal persistence (CLI `nerve sense list`) + * + * The kernel appends a `source: "sense", type: "signal"` row to `data/logs.db` when a + * worker emits a signal (see `packages/daemon/src/kernel.ts`). The daemon also + * auto-persists each signal into a `_signals` table in the per-sense SQLite DB + * (see `runtime.persistSignal` in `packages/daemon/src/sense-runtime.ts`). + * `listSenses()` reads `lastSignalTimestamp` from the kernel's in-memory state, + * while `sense query` reads from the `_signals` table (or a user-defined preview table). + * + * ## Timeout guard (vitest) + * + * Always tear down the daemon in `afterEach` so a failed assertion does not leave a + * kernel and worker children running. Optionally race `stopTestDaemon` against a timer + * so CI does not hang if shutdown stalls: + * + * ```ts + * import { afterEach } from "vitest"; + * import { stopTestDaemon, type TestDaemonHandle } from "./e2e-harness.js"; + * + * let daemon: TestDaemonHandle | null = null; + * + * afterEach(async () => { + * const h = daemon; + * daemon = null; + * if (h === null) return; + * await Promise.race([ + * stopTestDaemon(h), + * new Promise((_, reject) => + * setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000), + * ), + * ]); + * }); + * ``` + */ + +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import type { NerveConfig } from "@uncaged/nerve-core"; +import { createKernel } from "@uncaged/nerve-daemon"; +import type { Kernel } from "@uncaged/nerve-daemon"; +import { defineCommand, runCommand } from "citty"; + +import { senseCommand } from "../commands/sense.js"; + +const require = createRequire(import.meta.url); +const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json")); +const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js"); + +const nerveYamlTemplate = `senses: + counter: + group: e2e + +reflexes: [] + +workflows: {} + +max_rounds: 10 + +api: + port: null + token: null + host: 127.0.0.1 +`; + +/** Empty migration — counter sense uses only `_signals` (auto-created by daemon). */ +const counterMigration = `-- no-op migration for e2e counter sense +SELECT 1; +`; + +/** + * Minimal counter sense — each compute returns an incrementing count. + * Does NOT touch the DB directly; signal persistence is handled by the daemon + * (`runtime.persistSignal`) which writes to `_signals` automatically. + */ +const counterIndexJs = `let _count = 0; +export async function compute(_db, _peers, _options) { + _count += 1; + return { count: _count }; +} +`; + +const e2eRootCommand = defineCommand({ + meta: { name: "nerve", description: "e2e" }, + subCommands: { + sense: senseCommand, + }, +}); + +function defaultTestConfig(): NerveConfig { + return { + senses: { + counter: { group: "e2e", throttle: null, timeout: null, gracePeriod: null }, + }, + reflexes: [], + workflows: {}, + maxRounds: 10, + api: { port: null, token: null, host: "127.0.0.1" }, + }; +} + +function writeWorkspaceLayout(nerveRoot: string): void { + mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true }); + mkdirSync(join(nerveRoot, "data", "blobs"), { recursive: true }); + mkdirSync(join(nerveRoot, "senses", "counter", "migrations"), { recursive: true }); + writeFileSync(join(nerveRoot, "nerve.yaml"), nerveYamlTemplate, "utf8"); + writeFileSync( + join(nerveRoot, "senses", "counter", "migrations", "001.sql"), + counterMigration, + "utf8", + ); + writeFileSync(join(nerveRoot, "senses", "counter", "index.js"), counterIndexJs, "utf8"); +} + +export type TestDaemonHandle = { + fakeHome: string; + nerveRoot: string; + socketPath: string; + kernel: Kernel; +}; + +/** Reserved for future overrides; pass `null` today. */ +export type StartTestDaemonOpts = null; + +/** + * Poll until predicate returns true, or reject after `timeoutMs`. + * (Same idea as `packages/daemon/src/__tests__/kernel-integration.test.ts`.) + */ +export async function pollUntil( + predicate: () => boolean, + timeoutMs: number, + intervalMs = 50, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`pollUntil timed out after ${String(timeoutMs)}ms`)), + timeoutMs, + ); + const check = setInterval(() => { + if (predicate()) { + clearTimeout(timer); + clearInterval(check); + resolve(); + } + }, intervalMs); + }); +} + +/** + * Creates `fakeHome`, lays out `fakeHome/.uncaged-nerve` (nerve.yaml + counter sense), + * starts a real kernel (sense-worker child + IPC on `nerve.sock`), writes `nerve.pid` + * to the current test process so `isRunning()` succeeds under that HOME, and awaits + * `kernel.ready`. + */ +export async function startTestDaemon( + _opts: StartTestDaemonOpts = null, +): Promise { + void _opts; + if (!existsSync(senseWorkerScript)) { + throw new Error( + `Missing "${senseWorkerScript}". Run \`pnpm --filter @uncaged/nerve-daemon build\` (cli package "pretest" runs this automatically).`, + ); + } + + const fakeHome = mkdtempSync(join(tmpdir(), "nerve-cli-e2e-")); + const nerveRoot = join(fakeHome, ".uncaged-nerve"); + writeWorkspaceLayout(nerveRoot); + + const config = defaultTestConfig(); + const socketPath = join(nerveRoot, "nerve.sock"); + const kernel = createKernel(config, nerveRoot, { + workerScript: senseWorkerScript, + ipcSocketPath: socketPath, + enableFileWatcher: false, + }); + + await kernel.ready; + writeFileSync(join(nerveRoot, "nerve.pid"), String(process.pid), "utf8"); + + return { fakeHome, nerveRoot, socketPath, kernel }; +} + +/** Stops the kernel (workers + IPC) and removes the temp HOME tree. */ +export async function stopTestDaemon(handle: TestDaemonHandle): Promise { + await handle.kernel.stop(); + try { + rmSync(handle.fakeHome, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +} + +export type CliRunResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => void { + const orig = stream.write.bind(stream) as ( + chunk: string | Uint8Array, + encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void), + cb?: (err: Error | null | undefined) => void, + ) => boolean; + + stream.write = (( + chunk: string | Uint8Array, + encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void), + cb?: (err: Error | null | undefined) => void, + ) => { + if (typeof chunk === "string") { + sink.push(chunk); + } else { + sink.push(Buffer.from(chunk).toString("utf8")); + } + if (typeof encodingOrCb === "function") { + encodingOrCb(null); + return true; + } + if (cb !== undefined) { + cb(null); + } + return true; + }) as typeof stream.write; + + return () => { + stream.write = orig as typeof stream.write; + }; +} + +/** + * Runs `nerve ` for the subset wired here (`sense` subcommands), with + * `process.env.HOME` pointing at `handle.fakeHome` so `getNerveRoot()` resolves to the + * test workspace. Captures stdout/stderr; sets `exitCode` when `process.exit` is invoked + * or on thrown errors. + */ +export async function runCli(handle: TestDaemonHandle, args: string[]): Promise { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const restoreOut = patchWriteStream(process.stdout, stdoutChunks); + const restoreErr = patchWriteStream(process.stderr, stderrChunks); + + const prevHome = process.env.HOME; + process.env.HOME = handle.fakeHome; + + let exitCode = 0; + const origExit = process.exit; + process.exit = ((code?: number) => { + exitCode = typeof code === "number" ? code : 0; + throw new ProcessExitError(exitCode); + }) as typeof process.exit; + + try { + await runCommand(e2eRootCommand, { rawArgs: args }); + } catch (e) { + if (e instanceof ProcessExitError) { + exitCode = e.code; + } else { + exitCode = 1; + stderrChunks.push(e instanceof Error ? e.message : String(e)); + } + } finally { + process.exit = origExit; + if (prevHome === undefined) { + process.env.HOME = undefined; + } else { + process.env.HOME = prevHome; + } + restoreOut(); + restoreErr(); + } + + return { + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + exitCode, + }; +} + +class ProcessExitError extends Error { + readonly code: number; + constructor(code: number) { + super(`process.exit(${String(code)})`); + this.name = "ProcessExitError"; + this.code = code; + } +} diff --git a/packages/cli/src/__tests__/e2e-smoke.test.ts b/packages/cli/src/__tests__/e2e-smoke.test.ts new file mode 100644 index 0000000..c00c21c --- /dev/null +++ b/packages/cli/src/__tests__/e2e-smoke.test.ts @@ -0,0 +1,73 @@ +/** + * Smoke test: start a real daemon with a counter sense, trigger it, + * then verify CLI commands can list and query the persisted signal. + */ + +import { afterEach, describe, expect, it } from "vitest"; + +import { + type TestDaemonHandle, + pollUntil, + runCli, + startTestDaemon, + stopTestDaemon, +} from "./e2e-harness.js"; + +describe("e2e smoke", () => { + let daemon: TestDaemonHandle | null = null; + + afterEach(async () => { + const h = daemon; + daemon = null; + if (h === null) return; + await Promise.race([ + stopTestDaemon(h), + new Promise((_, reject) => + setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000), + ), + ]); + }); + + it("sense list + sense query after trigger", { timeout: 30_000 }, async () => { + daemon = await startTestDaemon(); + + // Trigger counter sense via IPC + const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]); + expect(triggerResult.exitCode).toBe(0); + expect(triggerResult.stdout).toContain("Triggered"); + + // Wait for signal to be persisted (_signals table in the sense DB) + const { existsSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { DatabaseSync } = await import("node:sqlite"); + + const dbPath = join(daemon.nerveRoot, "data", "senses", "counter.db"); + await pollUntil(() => { + if (!existsSync(dbPath)) return false; + try { + const db = new DatabaseSync(dbPath, { readOnly: true }); + const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as + | { cnt: number } + | undefined; + db.close(); + return row !== undefined && row.cnt > 0; + } catch { + return false; + } + }, 10_000); + + // nerve sense list — should show counter with a last signal timestamp + const listResult = await runCli(daemon, ["sense", "list"]); + expect(listResult.exitCode).toBe(0); + expect(listResult.stdout).toContain("counter"); + expect(listResult.stdout).toContain("last signal:"); + // Should NOT say "(never)" since we triggered and persisted + expect(listResult.stdout).not.toContain("(never)"); + + // nerve sense query counter — should return rows from _signals + const queryResult = await runCli(daemon, ["sense", "query", "counter"]); + expect(queryResult.exitCode).toBe(0); + // Should have actual data rows (not "(0 rows)") + expect(queryResult.stdout).not.toContain("(0 rows)"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2619ee5..8e02bdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.17 + '@uncaged/nerve-daemon': + specifier: workspace:* + version: link:../daemon vitest: specifier: ^4.1.5 version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3))