test(cli): add e2e smoke test for sense list + query #164

Merged
xiaomo merged 1 commits from test/154-e2e-harness into main 2026-04-27 06:27:38 +00:00
4 changed files with 370 additions and 0 deletions
+1
View File
@@ -28,6 +28,7 @@
"devDependencies": {
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0",
"@uncaged/nerve-daemon": "workspace:*",
"vitest": "^4.1.5"
}
}
+293
View File
@@ -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<never>((_, 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<void> {
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<TestDaemonHandle> {
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<void> {
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 <args>` 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<CliRunResult> {
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;
}
}
@@ -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<never>((_, 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)");
});
});
+3
View File
@@ -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))