|
|
@@ -0,0 +1,193 @@
|
|
|
+import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
|
+import { tmpdir } from "node:os";
|
|
|
+import { dirname, join, resolve } from "node:path";
|
|
|
+import { fileURLToPath } from "node:url";
|
|
|
+import { execFileSync } from "node:child_process";
|
|
|
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
|
+
|
|
|
+const testDir = dirname(fileURLToPath(import.meta.url));
|
|
|
+const cliDir = resolve(testDir, "..", "..");
|
|
|
+const cliEntry = resolve(cliDir, "dist", "index.js");
|
|
|
+
|
|
|
+let projectDir: string;
|
|
|
+
|
|
|
+function run(args: string[], options?: { env?: Record<string, string> }): string {
|
|
|
+ return execFileSync("node", [cliEntry, ...args], {
|
|
|
+ cwd: projectDir,
|
|
|
+ encoding: "utf-8",
|
|
|
+ env: {
|
|
|
+ ...process.env,
|
|
|
+ // Prevent global config from leaking into tests
|
|
|
+ HOME: projectDir,
|
|
|
+ ...options?.env,
|
|
|
+ },
|
|
|
+ timeout: 10_000,
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function runStderr(args: string[]): { stdout: string; stderr: string; exitCode: number } {
|
|
|
+ try {
|
|
|
+ const stdout = execFileSync("node", [cliEntry, ...args], {
|
|
|
+ cwd: projectDir,
|
|
|
+ encoding: "utf-8",
|
|
|
+ env: { ...process.env, HOME: projectDir },
|
|
|
+ timeout: 10_000,
|
|
|
+ });
|
|
|
+ return { stdout, stderr: "", exitCode: 0 };
|
|
|
+ } catch (e: unknown) {
|
|
|
+ const err = e as { stdout: string; stderr: string; status: number };
|
|
|
+ return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", exitCode: err.status ?? 1 };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+describe("CLI integration", () => {
|
|
|
+ beforeAll(async () => {
|
|
|
+ projectDir = await mkdtemp(join(tmpdir(), "inkos-cli-test-"));
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await rm(projectDir, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("inkos --version", () => {
|
|
|
+ it("prints version number", () => {
|
|
|
+ const output = run(["--version"]);
|
|
|
+ expect(output.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("inkos --help", () => {
|
|
|
+ it("prints help with command list", () => {
|
|
|
+ const output = run(["--help"]);
|
|
|
+ expect(output).toContain("inkos");
|
|
|
+ expect(output).toContain("init");
|
|
|
+ expect(output).toContain("book");
|
|
|
+ expect(output).toContain("write");
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("inkos init", () => {
|
|
|
+ it("initializes project in current directory", () => {
|
|
|
+ const output = run(["init"]);
|
|
|
+ expect(output).toContain("Project initialized");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("creates inkos.json with correct structure", async () => {
|
|
|
+ const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
|
|
|
+ const config = JSON.parse(raw);
|
|
|
+ expect(config.llm).toBeDefined();
|
|
|
+ expect(config.llm.provider).toBe("openai");
|
|
|
+ expect(config.llm.model).toBe("gpt-4o");
|
|
|
+ expect(config.daemon).toBeDefined();
|
|
|
+ expect(config.notify).toEqual([]);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("creates .env file", async () => {
|
|
|
+ const envContent = await readFile(join(projectDir, ".env"), "utf-8");
|
|
|
+ expect(envContent).toContain("INKOS_LLM_API_KEY");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("creates .gitignore", async () => {
|
|
|
+ const gitignore = await readFile(join(projectDir, ".gitignore"), "utf-8");
|
|
|
+ expect(gitignore).toContain(".env");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("creates books/ and radar/ directories", async () => {
|
|
|
+ const booksStat = await stat(join(projectDir, "books"));
|
|
|
+ expect(booksStat.isDirectory()).toBe(true);
|
|
|
+ const radarStat = await stat(join(projectDir, "radar"));
|
|
|
+ expect(radarStat.isDirectory()).toBe(true);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("inkos init <name>", () => {
|
|
|
+ it("creates project in subdirectory", () => {
|
|
|
+ const output = run(["init", "subproject"]);
|
|
|
+ expect(output).toContain("Project initialized");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("creates inkos.json in subdirectory", async () => {
|
|
|
+ const raw = await readFile(join(projectDir, "subproject", "inkos.json"), "utf-8");
|
|
|
+ const config = JSON.parse(raw);
|
|
|
+ expect(config.name).toBe("subproject");
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("inkos config set", () => {
|
|
|
+ it("sets a top-level config value", () => {
|
|
|
+ const output = run(["config", "set", "name", "test-project"]);
|
|
|
+ expect(output).toContain("Set name = test-project");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("sets a nested config value", async () => {
|
|
|
+ run(["config", "set", "llm.model", "gpt-5"]);
|
|
|
+ const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
|
|
|
+ const config = JSON.parse(raw);
|
|
|
+ expect(config.llm.model).toBe("gpt-5");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("creates intermediate keys for deep paths", async () => {
|
|
|
+ run(["config", "set", "custom.nested.key", "value"]);
|
|
|
+ const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
|
|
|
+ const config = JSON.parse(raw);
|
|
|
+ expect(config.custom.nested.key).toBe("value");
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("inkos config show", () => {
|
|
|
+ it("shows current config as JSON", () => {
|
|
|
+ const output = run(["config", "show"]);
|
|
|
+ const config = JSON.parse(output);
|
|
|
+ expect(config.name).toBe("test-project");
|
|
|
+ expect(config.llm.model).toBe("gpt-5");
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("inkos book list", () => {
|
|
|
+ it("shows no books in empty project", () => {
|
|
|
+ const output = run(["book", "list"]);
|
|
|
+ expect(output).toContain("No books found");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("returns empty array in JSON mode", () => {
|
|
|
+ const output = run(["book", "list", "--json"]);
|
|
|
+ const data = JSON.parse(output);
|
|
|
+ expect(data.books).toEqual([]);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("inkos status", () => {
|
|
|
+ it("shows project status with zero books", () => {
|
|
|
+ const output = run(["status"]);
|
|
|
+ expect(output).toContain("Books: 0");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("returns JSON with --json flag", () => {
|
|
|
+ const output = run(["status", "--json"]);
|
|
|
+ const data = JSON.parse(output);
|
|
|
+ expect(data.project).toBeDefined();
|
|
|
+ expect(data.books).toEqual([]);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("errors for nonexistent book", () => {
|
|
|
+ const { exitCode, stderr } = runStderr(["status", "nonexistent"]);
|
|
|
+ expect(exitCode).not.toBe(0);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("inkos doctor", () => {
|
|
|
+ it("checks environment health", () => {
|
|
|
+ const { stdout } = runStderr(["doctor"]);
|
|
|
+ expect(stdout).toContain("InkOS Doctor");
|
|
|
+ expect(stdout).toContain("Node.js >= 20");
|
|
|
+ expect(stdout).toContain("inkos.json");
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("inkos analytics", () => {
|
|
|
+ it("errors when no book exists", () => {
|
|
|
+ const { exitCode } = runStderr(["analytics"]);
|
|
|
+ expect(exitCode).not.toBe(0);
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|