diff --git a/packages/cli/src/__tests__/e2e-validate-init.test.ts b/packages/cli/src/__tests__/e2e-validate-init.test.ts new file mode 100644 index 0000000..4898660 --- /dev/null +++ b/packages/cli/src/__tests__/e2e-validate-init.test.ts @@ -0,0 +1,236 @@ +/** + * E2E tests for `nerve validate` and `nerve init` commands (#162). + * No running daemon needed — just temp dirs with HOME manipulation. + */ + +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { defineCommand, runCommand } from "citty"; +import { afterEach, describe, expect, it } from "vitest"; + +import { initCommand } from "../commands/init.js"; +import { validateCommand } from "../commands/validate.js"; + +const testRootCommand = defineCommand({ + meta: { name: "nerve", description: "e2e-validate-init" }, + subCommands: { + validate: validateCommand, + init: initCommand, + }, +}); + +type CliRunResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +class ProcessExitError extends Error { + readonly code: number; + constructor(code: number) { + super(`process.exit(${String(code)})`); + this.name = "ProcessExitError"; + this.code = code; + } +} + +function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => void { + const orig = stream.write.bind(stream) as ( + chunk: string | Uint8Array, + encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void), + cb?: (err: Error | null | undefined) => void, + ) => boolean; + + stream.write = (( + chunk: string | Uint8Array, + encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void), + cb?: (err: Error | null | undefined) => void, + ) => { + if (typeof chunk === "string") { + sink.push(chunk); + } else { + sink.push(Buffer.from(chunk).toString("utf8")); + } + if (typeof encodingOrCb === "function") { + encodingOrCb(null); + return true; + } + if (cb !== undefined) { + cb(null); + } + return true; + }) as typeof stream.write; + + return () => { + stream.write = orig as typeof stream.write; + }; +} + +async function runTestCli(fakeHome: string, args: string[]): Promise { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const restoreOut = patchWriteStream(process.stdout, stdoutChunks); + const restoreErr = patchWriteStream(process.stderr, stderrChunks); + + const prevHome = process.env.HOME; + process.env.HOME = fakeHome; + + let exitCode = 0; + const origExit = process.exit; + process.exit = ((code?: number) => { + exitCode = typeof code === "number" ? code : 0; + throw new ProcessExitError(exitCode); + }) as typeof process.exit; + + try { + await runCommand(testRootCommand, { rawArgs: args }); + } catch (e) { + if (e instanceof ProcessExitError) { + exitCode = e.code; + } else { + exitCode = 1; + stderrChunks.push(e instanceof Error ? e.message : String(e)); + } + } finally { + process.exit = origExit; + if (prevHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = prevHome; + } + restoreOut(); + restoreErr(); + } + + return { + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + exitCode, + }; +} + +const VALID_NERVE_YAML = `senses: + counter: + group: e2e + +reflexes: [] + +workflows: {} + +max_rounds: 10 + +api: + port: null + token: null + host: 127.0.0.1 +`; + +describe("e2e validate", () => { + let fakeHome: string | null = null; + + afterEach(() => { + if (fakeHome !== null) { + rmSync(fakeHome, { recursive: true, force: true }); + fakeHome = null; + } + }); + + it("exits 0 for valid nerve.yaml", { timeout: 10_000 }, async () => { + fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-")); + const nerveRoot = join(fakeHome, ".uncaged-nerve"); + mkdirSync(nerveRoot, { recursive: true }); + writeFileSync(join(nerveRoot, "nerve.yaml"), VALID_NERVE_YAML, "utf8"); + + const result = await runTestCli(fakeHome, ["validate"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("✅"); + expect(result.stdout).toContain("valid"); + }); + + it("exits 1 for invalid nerve.yaml", { timeout: 10_000 }, async () => { + fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-")); + const nerveRoot = join(fakeHome, ".uncaged-nerve"); + mkdirSync(nerveRoot, { recursive: true }); + writeFileSync(join(nerveRoot, "nerve.yaml"), "not: valid: yaml: {{{\n", "utf8"); + + const result = await runTestCli(fakeHome, ["validate"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toBeTruthy(); + }); + + it("exits 1 for malformed config (missing required fields)", { timeout: 10_000 }, async () => { + fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-")); + const nerveRoot = join(fakeHome, ".uncaged-nerve"); + mkdirSync(nerveRoot, { recursive: true }); + writeFileSync(join(nerveRoot, "nerve.yaml"), "foo: bar\n", "utf8"); + + const result = await runTestCli(fakeHome, ["validate"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("❌"); + }); + + it("exits 1 when nerve.yaml is missing", { timeout: 10_000 }, async () => { + fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-")); + // Don't create .uncaged-nerve at all + + const result = await runTestCli(fakeHome, ["validate"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toBeTruthy(); + }); +}); + +describe("e2e init", () => { + let fakeHome: string | null = null; + + afterEach(() => { + if (fakeHome !== null) { + rmSync(fakeHome, { recursive: true, force: true }); + fakeHome = null; + } + }); + + it("init --force creates workspace layout", { timeout: 60_000 }, async () => { + fakeHome = mkdtempSync(join(tmpdir(), "nerve-init-e2e-")); + const nerveRoot = join(fakeHome, ".uncaged-nerve"); + + const result = await runTestCli(fakeHome, ["init", "--force"]); + // init should exit 0 (install/git failures are warnings, not fatal) + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("✅"); + + // Verify key files exist + expect(existsSync(join(nerveRoot, "nerve.yaml"))).toBe(true); + expect(existsSync(join(nerveRoot, "package.json"))).toBe(true); + expect(existsSync(join(nerveRoot, "biome.json"))).toBe(true); + expect(existsSync(join(nerveRoot, ".gitignore"))).toBe(true); + expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "schema.ts"))).toBe(true); + expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "index.js"))).toBe(true); + expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"))).toBe( + true, + ); + }); + + it("generated nerve.yaml passes validate", { timeout: 60_000 }, async () => { + fakeHome = mkdtempSync(join(tmpdir(), "nerve-init-e2e-")); + + await runTestCli(fakeHome, ["init", "--force"]); + + const validateResult = await runTestCli(fakeHome, ["validate"]); + expect(validateResult.exitCode).toBe(0); + expect(validateResult.stdout).toContain("✅"); + expect(validateResult.stdout).toContain("valid"); + }); + + it("init without --force on existing dir exits 1", { timeout: 10_000 }, async () => { + fakeHome = mkdtempSync(join(tmpdir(), "nerve-init-e2e-")); + const nerveRoot = join(fakeHome, ".uncaged-nerve"); + mkdirSync(nerveRoot, { recursive: true }); + writeFileSync(join(nerveRoot, "marker"), "exists", "utf8"); + + const result = await runTestCli(fakeHome, ["init"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("already exists"); + }); +});