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[] = []; diff --git a/packages/cli/src/__tests__/e2e-logs.test.ts b/packages/cli/src/__tests__/e2e-logs.test.ts new file mode 100644 index 0000000..1c63fe6 --- /dev/null +++ b/packages/cli/src/__tests__/e2e-logs.test.ts @@ -0,0 +1,133 @@ +/** + * E2E test for `nerve logs` command (#161). + * + * The logs command reads from a plain text log file at + * `/logs/nerve.log`. Since the e2e harness starts the kernel + * in-process (not as a detached daemon), no log file is created automatically. + * We manually write test log content to the expected path. + */ + +import { mkdirSync, writeFileSync } 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"; + +/** Generate N log lines for testing. */ +function generateLogLines(count: number): string[] { + const lines: string[] = []; + for (let i = 1; i <= count; i++) { + const ts = new Date(Date.UTC(2025, 0, 1, 0, 0, i)).toISOString(); + lines.push(`${ts} [INFO] log entry ${i}`); + } + return lines; +} + +/** Write fake log content to the daemon log path. */ +function writeTestLogFile(nerveRoot: string, lines: string[]): void { + const logsDir = join(nerveRoot, "logs"); + mkdirSync(logsDir, { recursive: true }); + writeFileSync(join(logsDir, "nerve.log"), `${lines.join("\n")}\n`, "utf8"); +} + +describe("e2e logs", () => { + 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("shows log file not found when no log exists", { timeout: 30_000 }, async () => { + daemon = await startTestDaemon(); + + // No log file written — command should fail + const result = await runCli(daemon, ["logs"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Log file not found"); + }); + + it("displays last N lines (tail mode)", { timeout: 30_000 }, async () => { + daemon = await startTestDaemon(); + + const lines = generateLogLines(10); + writeTestLogFile(daemon.nerveRoot, lines); + + // Default: last 50 lines, but we only have 10 + const result = await runCli(daemon, ["logs"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("log entry 1"); + expect(result.stdout).toContain("log entry 10"); + expect(result.stdout).toContain("lines 1-10 of 10"); + }); + + it("respects -n flag to limit lines", { timeout: 30_000 }, async () => { + daemon = await startTestDaemon(); + + const lines = generateLogLines(20); + writeTestLogFile(daemon.nerveRoot, lines); + + // Request only last 5 lines + const result = await runCli(daemon, ["logs", "-n", "5"]); + expect(result.exitCode).toBe(0); + + // Should show lines 16-20 (last 5) + expect(result.stdout).toContain("log entry 16"); + expect(result.stdout).toContain("log entry 20"); + expect(result.stdout).toContain("lines 16-20 of 20"); + + // Should NOT contain earlier lines + expect(result.stdout).not.toContain("log entry 1\n"); + expect(result.stdout).not.toContain("log entry 15\n"); + }); + + it("supports --offset for pagination", { timeout: 30_000 }, async () => { + daemon = await startTestDaemon(); + + const lines = generateLogLines(20); + writeTestLogFile(daemon.nerveRoot, lines); + + // Offset 5 means start at line 5, show default 50 (will get 5-20) + const result = await runCli(daemon, ["logs", "--offset", "5", "-n", "5"]); + expect(result.exitCode).toBe(0); + + // Should show lines 5-9 + expect(result.stdout).toContain("log entry 5"); + expect(result.stdout).toContain("log entry 9"); + expect(result.stdout).toContain("lines 5-9 of 20"); + }); + + it("shows pagination hint when earlier lines exist", { timeout: 30_000 }, async () => { + daemon = await startTestDaemon(); + + const lines = generateLogLines(100); + writeTestLogFile(daemon.nerveRoot, lines); + + // Request last 10 lines — there should be a "previous page" hint + const result = await runCli(daemon, ["logs", "-n", "10"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Earlier lines available"); + expect(result.stdout).toContain("nerve logs --offset"); + }); + + it("shows empty message for empty log file", { timeout: 30_000 }, async () => { + daemon = await startTestDaemon(); + + // Write an empty log file + const logsDir = join(daemon.nerveRoot, "logs"); + mkdirSync(logsDir, { recursive: true }); + writeFileSync(join(logsDir, "nerve.log"), "", "utf8"); + + const result = await runCli(daemon, ["logs"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Log file is empty"); + }); +});