clanker-agent/packages/coding-agent/test/extensions-discovery.test.ts
Harivansh Rathi 67168d8289 chore: rebrand companion-os to clanker-agent
- Rename all package names from companion-* to clanker-*
- Update npm scopes from @mariozechner to @harivansh-afk
- Rename config directories .companion -> .clanker
- Rename environment variables COMPANION_* -> CLANKER_*
- Update all documentation, README files, and install scripts
- Rename package directories (companion-channels, companion-grind, companion-teams)
- Update GitHub URLs to harivansh-afk/clanker-agent
- Preserve full git history from companion-cloud monorepo
2026-03-26 16:22:52 -04:00

539 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
describe("extensions discovery", () => {
let tempDir: string;
let extensionsDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "clanker-ext-test-"));
extensionsDir = path.join(tempDir, "extensions");
fs.mkdirSync(extensionsDir);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const extensionCode = `
export default function(clanker) {
clanker.registerCommand("test", { handler: async () => {} });
}
`;
const extensionCodeWithTool = (toolName: string) => `
import { Type } from "@sinclair/typebox";
export default function(clanker) {
clanker.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 clanker 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",
clanker: {
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",
clanker: {
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 clanker 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",
clanker: {
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 clanker 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({ clanker: { 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({
clanker: {
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");
});
it("resolves dependencies from extension's own node_modules", async () => {
const extPath = path.join(tempDir, "custom-location", "with-deps");
const nodeModulesDir = path.join(extPath, "node_modules", "ms");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.writeFileSync(
path.join(extPath, "index.ts"),
`
import { Type } from "@sinclair/typebox";
import ms from "ms";
export default function(clanker) {
clanker.registerTool({
name: "parse_duration",
label: "parse_duration",
description: "Parse a duration string",
parameters: Type.Object({ value: Type.String() }),
execute: async (_toolCallId, params) => ({
content: [{ type: "text", text: String(ms(params.value)) }],
}),
});
}
`,
);
fs.writeFileSync(
path.join(extPath, "package.json"),
JSON.stringify({
name: "with-deps",
type: "module",
}),
);
fs.writeFileSync(
path.join(nodeModulesDir, "package.json"),
JSON.stringify({
name: "ms",
type: "module",
exports: "./index.js",
}),
);
fs.writeFileSync(
path.join(nodeModulesDir, "index.js"),
`export default function ms(value) { return value === "1m" ? 60000 : 0; }`,
);
const result = await discoverAndLoadExtensions([extPath], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toContain("with-deps");
// The extension registers a 'parse_duration' tool
expect(result.extensions[0].tools.has("parse_duration")).toBe(true);
});
it("registers message renderers", async () => {
const extCode = `
export default function(clanker) {
clanker.registerMessageRenderer("my-custom-type", (message, options, theme) => {
return null; // Use default rendering
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].messageRenderers.has("my-custom-type")).toBe(
true,
);
});
it("reports error when extension throws during initialization", async () => {
const extCode = `
export default function(clanker) {
throw new Error("Initialization failed!");
}
`;
fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].error).toContain("Initialization failed!");
expect(result.extensions).toHaveLength(0);
});
it("reports error when extension has no default export", async () => {
const extCode = `
export function notDefaultclanker {
clanker.registerCommand("test", { handler: async () => {} });
}
`;
fs.writeFileSync(path.join(extensionsDir, "no-default.ts"), extCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].error).toContain(
"does not export a valid factory function",
);
expect(result.extensions).toHaveLength(0);
});
it("allows multiple extensions to register different tools", async () => {
fs.writeFileSync(
path.join(extensionsDir, "tool-a.ts"),
extensionCodeWithTool("tool-a"),
);
fs.writeFileSync(
path.join(extensionsDir, "tool-b.ts"),
extensionCodeWithTool("tool-b"),
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(2);
const allTools = new Set<string>();
for (const ext of result.extensions) {
for (const name of ext.tools.keys()) {
allTools.add(name);
}
}
expect(allTools.has("tool-a")).toBe(true);
expect(allTools.has("tool-b")).toBe(true);
});
it("loads extension with event handlers", async () => {
const extCode = `
export default function(clanker) {
clanker.on("agent_start", async () => {});
clanker.on("tool_call", async (event) => undefined);
clanker.on("agent_end", async () => {});
}
`;
fs.writeFileSync(path.join(extensionsDir, "with-handlers.ts"), extCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].handlers.has("agent_start")).toBe(true);
expect(result.extensions[0].handlers.has("tool_call")).toBe(true);
expect(result.extensions[0].handlers.has("agent_end")).toBe(true);
});
it("loads extension with shortcuts", async () => {
const extCode = `
export default function(clanker) {
clanker.registerShortcut("ctrl+t", {
description: "Test shortcut",
handler: async (ctx) => {},
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "with-shortcut.ts"), extCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].shortcuts.has("ctrl+t")).toBe(true);
});
it("loads extension with flags", async () => {
const extCode = `
export default function(clanker) {
clanker.registerFlag("my-flag", {
description: "My custom flag",
handler: async (value) => {},
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].flags.has("my-flag")).toBe(true);
});
it("loadExtensions only loads explicit paths without discovery", async () => {
// Create discoverable extensions (would be found by discoverAndLoadExtensions)
fs.writeFileSync(
path.join(extensionsDir, "discovered.ts"),
extensionCodeWithTool("discovered"),
);
// Create explicit extension outside discovery path
const explicitPath = path.join(tempDir, "explicit.ts");
fs.writeFileSync(explicitPath, extensionCodeWithTool("explicit"));
// Use loadExtensions directly to skip discovery
const { loadExtensions } = await import("../src/core/extensions/loader.js");
const result = await loadExtensions([explicitPath], tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].tools.has("explicit")).toBe(true);
expect(result.extensions[0].tools.has("discovered")).toBe(false);
});
it("loadExtensions with no paths loads nothing", async () => {
// Create discoverable extensions (would be found by discoverAndLoadExtensions)
fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCode);
// Use loadExtensions directly with empty paths
const { loadExtensions } = await import("../src/core/extensions/loader.js");
const result = await loadExtensions([], tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(0);
});
});