co-mono/packages/coding-agent/test/extensions-discovery.test.ts
Mario Zechner 9794868b38 Implement extension discovery with package.json manifest support
Discovery rules:
1. extensions/*.ts or *.js - direct files
2. extensions/*/index.ts or index.js - subdirectory with index
3. extensions/*/package.json with pi field - load declared paths

No recursion beyond one level. Complex packages use package.json manifest.

Added PiManifest type for future theme/skill bundling support.

17 tests covering all discovery scenarios.

refs #454
2026-01-04 23:24:24 +01:00

296 lines
9.9 KiB
TypeScript

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
describe("extensions discovery", () => {
let tempDir: string;
let extensionsDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ext-test-"));
extensionsDir = path.join(tempDir, "extensions");
fs.mkdirSync(extensionsDir);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const extensionCode = `
export default function(pi) {
pi.registerCommand("test", { handler: async () => {} });
}
`;
const extensionCodeWithTool = (toolName: string) => `
import { Type } from "@sinclair/typebox";
export default function(pi) {
pi.registerTool({
name: "${toolName}",
label: "${toolName}",
description: "Test tool",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "ok" }] }),
});
}
`;
it("discovers direct .ts files in extensions/", async () => {
fs.writeFileSync(path.join(extensionsDir, "foo.ts"), extensionCode);
fs.writeFileSync(path.join(extensionsDir, "bar.ts"), extensionCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(2);
expect(result.extensions.map((e) => path.basename(e.path)).sort()).toEqual(["bar.ts", "foo.ts"]);
});
it("discovers direct .js files in extensions/", async () => {
fs.writeFileSync(path.join(extensionsDir, "foo.js"), extensionCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(path.basename(result.extensions[0].path)).toBe("foo.js");
});
it("discovers subdirectory with index.ts", async () => {
const subdir = path.join(extensionsDir, "my-extension");
fs.mkdirSync(subdir);
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toContain("my-extension");
expect(result.extensions[0].path).toContain("index.ts");
});
it("discovers subdirectory with index.js", async () => {
const subdir = path.join(extensionsDir, "my-extension");
fs.mkdirSync(subdir);
fs.writeFileSync(path.join(subdir, "index.js"), extensionCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toContain("index.js");
});
it("prefers index.ts over index.js", async () => {
const subdir = path.join(extensionsDir, "my-extension");
fs.mkdirSync(subdir);
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
fs.writeFileSync(path.join(subdir, "index.js"), extensionCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toContain("index.ts");
});
it("discovers subdirectory with package.json pi field", async () => {
const subdir = path.join(extensionsDir, "my-package");
const srcDir = path.join(subdir, "src");
fs.mkdirSync(subdir);
fs.mkdirSync(srcDir);
fs.writeFileSync(path.join(srcDir, "main.ts"), extensionCode);
fs.writeFileSync(
path.join(subdir, "package.json"),
JSON.stringify({
name: "my-package",
pi: {
extensions: ["./src/main.ts"],
},
}),
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toContain("src");
expect(result.extensions[0].path).toContain("main.ts");
});
it("package.json can declare multiple extensions", async () => {
const subdir = path.join(extensionsDir, "my-package");
fs.mkdirSync(subdir);
fs.writeFileSync(path.join(subdir, "ext1.ts"), extensionCode);
fs.writeFileSync(path.join(subdir, "ext2.ts"), extensionCode);
fs.writeFileSync(
path.join(subdir, "package.json"),
JSON.stringify({
name: "my-package",
pi: {
extensions: ["./ext1.ts", "./ext2.ts"],
},
}),
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(2);
});
it("package.json with pi field takes precedence over index.ts", async () => {
const subdir = path.join(extensionsDir, "my-package");
fs.mkdirSync(subdir);
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCodeWithTool("from-index"));
fs.writeFileSync(path.join(subdir, "custom.ts"), extensionCodeWithTool("from-custom"));
fs.writeFileSync(
path.join(subdir, "package.json"),
JSON.stringify({
name: "my-package",
pi: {
extensions: ["./custom.ts"],
},
}),
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toContain("custom.ts");
// Verify the right tool was registered
expect(result.extensions[0].tools.has("from-custom")).toBe(true);
expect(result.extensions[0].tools.has("from-index")).toBe(false);
});
it("ignores package.json without pi field, falls back to index.ts", async () => {
const subdir = path.join(extensionsDir, "my-package");
fs.mkdirSync(subdir);
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
fs.writeFileSync(
path.join(subdir, "package.json"),
JSON.stringify({
name: "my-package",
version: "1.0.0",
}),
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toContain("index.ts");
});
it("ignores subdirectory without index or package.json", async () => {
const subdir = path.join(extensionsDir, "not-an-extension");
fs.mkdirSync(subdir);
fs.writeFileSync(path.join(subdir, "helper.ts"), extensionCode);
fs.writeFileSync(path.join(subdir, "utils.ts"), extensionCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(0);
});
it("does not recurse beyond one level", async () => {
const subdir = path.join(extensionsDir, "container");
const nested = path.join(subdir, "nested");
fs.mkdirSync(subdir);
fs.mkdirSync(nested);
fs.writeFileSync(path.join(nested, "index.ts"), extensionCode);
// No index.ts or package.json in container/
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(0);
});
it("handles mixed direct files and subdirectories", async () => {
// Direct file
fs.writeFileSync(path.join(extensionsDir, "direct.ts"), extensionCode);
// Subdirectory with index
const subdir1 = path.join(extensionsDir, "with-index");
fs.mkdirSync(subdir1);
fs.writeFileSync(path.join(subdir1, "index.ts"), extensionCode);
// Subdirectory with package.json
const subdir2 = path.join(extensionsDir, "with-manifest");
fs.mkdirSync(subdir2);
fs.writeFileSync(path.join(subdir2, "entry.ts"), extensionCode);
fs.writeFileSync(path.join(subdir2, "package.json"), JSON.stringify({ pi: { extensions: ["./entry.ts"] } }));
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(3);
});
it("skips non-existent paths declared in package.json", async () => {
const subdir = path.join(extensionsDir, "my-package");
fs.mkdirSync(subdir);
fs.writeFileSync(path.join(subdir, "exists.ts"), extensionCode);
fs.writeFileSync(
path.join(subdir, "package.json"),
JSON.stringify({
pi: {
extensions: ["./exists.ts", "./missing.ts"],
},
}),
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toContain("exists.ts");
});
it("loads extensions and registers commands", async () => {
fs.writeFileSync(path.join(extensionsDir, "with-command.ts"), extensionCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].commands.has("test")).toBe(true);
});
it("loads extensions and registers tools", async () => {
fs.writeFileSync(path.join(extensionsDir, "with-tool.ts"), extensionCodeWithTool("my-tool"));
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].tools.has("my-tool")).toBe(true);
});
it("reports errors for invalid extension code", async () => {
fs.writeFileSync(path.join(extensionsDir, "invalid.ts"), "this is not valid typescript export");
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].path).toContain("invalid.ts");
expect(result.extensions).toHaveLength(0);
});
it("handles explicitly configured paths", async () => {
const customPath = path.join(tempDir, "custom-location", "my-ext.ts");
fs.mkdirSync(path.dirname(customPath), { recursive: true });
fs.writeFileSync(customPath, extensionCode);
const result = await discoverAndLoadExtensions([customPath], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toContain("my-ext.ts");
});
});