test(cli): add e2e smoke test for sense list + query #164
@@ -28,6 +28,7 @@
|
||||
"devDependencies": {
|
||||
"@rslib/core": "^0.21.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@uncaged/nerve-daemon": "workspace:*",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
Generated
+3
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user