diff --git a/packages/cli/src/__tests__/e2e-daemon-lifecycle.test.ts b/packages/cli/src/__tests__/e2e-daemon-lifecycle.test.ts new file mode 100644 index 0000000..0255f03 --- /dev/null +++ b/packages/cli/src/__tests__/e2e-daemon-lifecycle.test.ts @@ -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((_, 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."); + }, + ); +}); diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index e89f042..f863f47 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -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 { - 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 { + 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 ` 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 ` 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 { const stdoutChunks: string[] = [];