test(cli): e2e daemon start/stop/status lifecycle (#159) #169

Merged
xiaomo merged 1 commits from test/159-daemon-lifecycle into main 2026-04-27 06:54:36 +00:00
2 changed files with 96 additions and 7 deletions
@@ -0,0 +1,71 @@
/**
* E2E: daemon start / status / stop lifecycle against the in-process test harness.
*
* Does not invoke the `stop` CLI while the harness PID file points at the current process
* (that would SIGTERM the test runner). After `status` shows running, we stop the kernel
* and remove `nerve.pid`, then assert `status` reports not running; `afterEach` tears down
* the temp HOME.
*/
import { existsSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type TestDaemonHandle, runCli, startTestDaemon, stopTestDaemon } from "./e2e-harness.js";
describe("e2e daemon lifecycle", () => {
let daemon: TestDaemonHandle | null = null;
let kernelAlreadyStopped = false;
afterEach(async () => {
const h = daemon;
const skipKernel = kernelAlreadyStopped;
daemon = null;
kernelAlreadyStopped = false;
if (h === null) return;
await Promise.race([
stopTestDaemon(h, skipKernel),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
),
]);
});
it(
"nerve.pid + kernel up, status running, then stopped + pid cleared, status not running",
{ timeout: 30_000 },
async () => {
daemon = await startTestDaemon();
const pidPath = join(daemon.nerveRoot, "nerve.pid");
expect(existsSync(pidPath)).toBe(true);
const health = daemon.kernel.getHealth();
expect(health.activeGroups).toBeGreaterThan(0);
expect(daemon.kernel.getWorkerPid("e2e")).not.toBeNull();
expect(existsSync(daemon.socketPath)).toBe(true);
const statusUp = await runCli(daemon, ["daemon", "status"]);
expect(statusUp.exitCode).toBe(0);
expect(statusUp.stdout).toContain("running");
expect(statusUp.stdout).toContain("✅ Nerve daemon is running.");
const statusTopLevel = await runCli(daemon, ["status"]);
expect(statusTopLevel.exitCode).toBe(0);
expect(statusTopLevel.stdout).toContain("running");
await daemon.kernel.stop();
kernelAlreadyStopped = true;
unlinkSync(pidPath);
expect(existsSync(pidPath)).toBe(false);
expect(existsSync(daemon.socketPath)).toBe(false);
const statusDown = await runCli(daemon, ["daemon", "status"]);
expect(statusDown.exitCode).toBe(0);
expect(statusDown.stdout).toContain("not running");
expect(statusDown.stdout).toContain("😴 Nerve daemon is not running.");
},
);
});
+25 -7
View File
@@ -47,7 +47,11 @@ import { createKernel } from "@uncaged/nerve-daemon";
import type { Kernel } from "@uncaged/nerve-daemon";
import { defineCommand, runCommand } from "citty";
import { daemonCommand } from "../commands/daemon.js";
import { logsCommand } from "../commands/logs.js";
import { senseCommand } from "../commands/sense.js";
import { statusCommand } from "../commands/status.js";
import { stopCommand } from "../commands/stop.js";
const require = createRequire(import.meta.url);
const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
@@ -90,6 +94,10 @@ const e2eRootCommand = defineCommand({
meta: { name: "nerve", description: "e2e" },
subCommands: {
sense: senseCommand,
logs: logsCommand,
daemon: daemonCommand,
status: statusCommand,
stop: stopCommand,
},
});
@@ -186,9 +194,19 @@ export async function startTestDaemon(
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();
/**
* Stops the kernel (workers + IPC) and removes the temp HOME tree.
*
* @param kernelAlreadyStopped — pass `true` when the test already called `kernel.stop()`
* (e.g. daemon lifecycle e2e); only the temp directory is removed.
*/
export async function stopTestDaemon(
handle: TestDaemonHandle,
kernelAlreadyStopped = false,
): Promise<void> {
if (!kernelAlreadyStopped) {
await handle.kernel.stop();
}
try {
rmSync(handle.fakeHome, { recursive: true, force: true });
} catch {
@@ -235,10 +253,10 @@ function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => voi
}
/**
* 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.
* Runs `nerve <args>` for the subset wired in `e2eRootCommand` (`sense`, `logs`, `daemon`,
* top-level `status` / `stop`), 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[] = [];