test(cli): e2e logs command (#161) #170
@@ -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[] = [];
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* E2E test for `nerve logs` command (#161).
|
||||
*
|
||||
* The logs command reads from a plain text log file at
|
||||
* `<nerveRoot>/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<never>((_, 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user