69d3acda5d
- Add LLM client abstraction (src/llm-client.ts) with factory pattern - Add OpenAI-compatible external client (src/external-llm-client.ts) - Add Kimi.com client using Anthropic-based API (src/kimi-llm-client.ts) - Add Pi native LLM stub (src/pi-llm-client.ts) for future ExtensionAPI wiring - Add SHA-256 disk cache at ~/.cache/pi-project-map/ (src/llm-cache.ts) - Add parallel batching with p-limit, retry + exponential backoff (src/llm-batch.ts) - Rewrite llm-extract.ts to use real LLM calls with structured prompts - File-level: PURPOSE, DEPS, CONCEPTS - Package-level: ROLE, ARCH - Context truncation, 50KB skip, cache before LLM call - Wire CLI with --llm-provider, --llm-model, --llm-base-url flags - Update config.ts with llmProvider, llmBaseUrl fields - Update init.ts and patch.ts to accept optional LLMClient - Add sample project fixture for manual testing - Add tests: llm-cache (3), llm-batch (5), llm-integration (8 with real Kimi API), pi-extension (14 mocked) - All 56 tests pass
246 lines
7.2 KiB
TypeScript
246 lines
7.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
// Mock child_process before importing the extension
|
|
vi.mock("child_process", () => ({
|
|
execSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("fs", async () => {
|
|
const actual = await vi.importActual<typeof import("fs")>("fs");
|
|
return {
|
|
...actual,
|
|
readFileSync: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
ExtensionAPI: class {},
|
|
}));
|
|
|
|
vi.mock("typebox", () => ({
|
|
Type: {
|
|
Object: (props: unknown) => props,
|
|
Optional: (prop: unknown) => prop,
|
|
String: (opts: unknown) => ({ type: "string", ...opts }),
|
|
},
|
|
}));
|
|
|
|
import extension from "../pi-extension.js";
|
|
import { execSync } from "child_process";
|
|
import { readFileSync } from "fs";
|
|
|
|
describe("pi-extension", () => {
|
|
let registeredTools: Record<string, any> = {};
|
|
let registeredEvents: Record<string, any> = {};
|
|
let mockCtx: any;
|
|
let mockNotify: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
registeredTools = {};
|
|
registeredEvents = {};
|
|
mockNotify = vi.fn();
|
|
mockCtx = {
|
|
cwd: "/home/project",
|
|
ui: { notify: mockNotify },
|
|
};
|
|
|
|
const mockPi = {
|
|
registerTool: vi.fn((tool: any) => {
|
|
registeredTools[tool.name] = tool;
|
|
}),
|
|
on: vi.fn((event: string, handler: any) => {
|
|
registeredEvents[event] = handler;
|
|
}),
|
|
};
|
|
|
|
extension(mockPi);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("tool registration", () => {
|
|
it("registers 4 tools", () => {
|
|
expect(Object.keys(registeredTools)).toHaveLength(4);
|
|
expect(registeredTools).toHaveProperty("project_map_init");
|
|
expect(registeredTools).toHaveProperty("project_map_patch");
|
|
expect(registeredTools).toHaveProperty("project_map_validate");
|
|
expect(registeredTools).toHaveProperty("project_map_reinit");
|
|
});
|
|
|
|
it("registers session_start and before_agent_start events", () => {
|
|
expect(registeredEvents).toHaveProperty("session_start");
|
|
expect(registeredEvents).toHaveProperty("before_agent_start");
|
|
});
|
|
});
|
|
|
|
describe("project_map_init tool", () => {
|
|
it("calls npx project-map init with default cwd", async () => {
|
|
vi.mocked(execSync).mockReturnValue("Generated 5 .pi-map.md files\n");
|
|
const tool = registeredTools.project_map_init;
|
|
const result = await tool.execute("tool-1", {}, null, null, mockCtx);
|
|
expect(execSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("npx project-map init"),
|
|
expect.objectContaining({ cwd: "/home/project" }),
|
|
);
|
|
expect(result.details.success).toBe(true);
|
|
});
|
|
|
|
it("calls npx project-map init with custom path", async () => {
|
|
vi.mocked(execSync).mockReturnValue("Generated 3 .pi-map.md files\n");
|
|
const tool = registeredTools.project_map_init;
|
|
const result = await tool.execute(
|
|
"tool-1",
|
|
{ path: "./src" },
|
|
null,
|
|
null,
|
|
mockCtx,
|
|
);
|
|
expect(execSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("npx project-map init ./src"),
|
|
expect.anything(),
|
|
);
|
|
expect(result.details.success).toBe(true);
|
|
});
|
|
|
|
it("reports failure on error", async () => {
|
|
vi.mocked(execSync).mockImplementation(() => {
|
|
const err = new Error("Command failed") as any;
|
|
err.stdout = "";
|
|
err.stderr = "No such file";
|
|
throw err;
|
|
});
|
|
const tool = registeredTools.project_map_init;
|
|
const result = await tool.execute("tool-1", {}, null, null, mockCtx);
|
|
expect(result.details.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("project_map_patch tool", () => {
|
|
it("calls npx project-map patch with file path", async () => {
|
|
vi.mocked(execSync).mockReturnValue("Patched src/foo.ts\n");
|
|
const tool = registeredTools.project_map_patch;
|
|
const result = await tool.execute(
|
|
"tool-1",
|
|
{ file_path: "src/components/Button.tsx" },
|
|
null,
|
|
null,
|
|
mockCtx,
|
|
);
|
|
expect(execSync).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
"npx project-map patch src/components/Button.tsx",
|
|
),
|
|
expect.anything(),
|
|
);
|
|
expect(result.details.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("project_map_validate tool", () => {
|
|
it("calls npx project-map validate and reports clean", async () => {
|
|
vi.mocked(execSync).mockReturnValue("All .pi-map.md files are clean.\n");
|
|
const tool = registeredTools.project_map_validate;
|
|
const result = await tool.execute("tool-1", {}, null, null, mockCtx);
|
|
expect(execSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("npx project-map validate"),
|
|
expect.anything(),
|
|
);
|
|
expect(result.details.clean).toBe(true);
|
|
});
|
|
|
|
it("reports not clean when output lacks 'clean'", async () => {
|
|
vi.mocked(execSync).mockReturnValue("Found 2 discrepancies\n");
|
|
const tool = registeredTools.project_map_validate;
|
|
const result = await tool.execute("tool-1", {}, null, null, mockCtx);
|
|
expect(result.details.clean).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("project_map_reinit tool", () => {
|
|
it("calls npx project-map reinit with path", async () => {
|
|
vi.mocked(execSync).mockReturnValue("Regenerated 7 files\n");
|
|
const tool = registeredTools.project_map_reinit;
|
|
const result = await tool.execute(
|
|
"tool-1",
|
|
{ path: "./src" },
|
|
null,
|
|
null,
|
|
mockCtx,
|
|
);
|
|
expect(execSync).toHaveBeenCalledWith(
|
|
expect.stringContaining("npx project-map reinit ./src"),
|
|
expect.anything(),
|
|
);
|
|
expect(result.details.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("session_start event", () => {
|
|
it("notifies when dirty .pi-map.md files exist", async () => {
|
|
vi.mocked(execSync).mockReturnValueOnce(
|
|
"./src/.pi-map.md\n./tests/.pi-map.md\n",
|
|
); // findPiMapFiles
|
|
vi.mocked(readFileSync)
|
|
.mockReturnValueOnce("## dirty\n2024-01-01: patched\n") // dirty
|
|
.mockReturnValueOnce("## dirty\n-\n"); // clean
|
|
|
|
const handler = registeredEvents.session_start;
|
|
await handler(null, mockCtx);
|
|
|
|
expect(mockNotify).toHaveBeenCalledWith(
|
|
expect.stringContaining("1 dirty packages detected"),
|
|
"warning",
|
|
);
|
|
});
|
|
|
|
it("does not notify when all clean", async () => {
|
|
vi.mocked(execSync).mockReturnValueOnce("./src/.pi-map.md\n");
|
|
vi.mocked(readFileSync).mockReturnValueOnce("## dirty\n-\n");
|
|
|
|
const handler = registeredEvents.session_start;
|
|
await handler(null, mockCtx);
|
|
|
|
expect(mockNotify).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does nothing when no maps exist", async () => {
|
|
vi.mocked(execSync).mockImplementation(() => {
|
|
throw new Error("no maps");
|
|
});
|
|
|
|
const handler = registeredEvents.session_start;
|
|
await handler(null, mockCtx);
|
|
|
|
expect(mockNotify).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("before_agent_start event", () => {
|
|
it("injects hint when .pi-map.md files exist", async () => {
|
|
vi.mocked(execSync).mockReturnValueOnce("./src/.pi-map.md\n");
|
|
vi.mocked(readFileSync).mockReturnValueOnce("content");
|
|
|
|
const handler = registeredEvents.before_agent_start;
|
|
const result = await handler(null, mockCtx);
|
|
|
|
expect(result).toHaveProperty("message");
|
|
expect(result.message.content).toContain("project_map_patch");
|
|
expect(result.message.display).toBe(false);
|
|
});
|
|
|
|
it("returns empty object when no maps exist", async () => {
|
|
vi.mocked(execSync).mockImplementation(() => {
|
|
throw new Error("no maps");
|
|
});
|
|
|
|
const handler = registeredEvents.before_agent_start;
|
|
const result = await handler(null, mockCtx);
|
|
|
|
expect(result).toEqual({});
|
|
});
|
|
});
|
|
});
|