test(cli): e2e logs command (#161) #170

Merged
xiaomo merged 1 commits from test/161-logs into main 2026-04-27 06:54:44 +00:00
2 changed files with 158 additions and 7 deletions
+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[] = [];
+133
View File
@@ -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");
});
});