test(cli): e2e validate & init (#162) #171
@@ -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<CliRunResult> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user