Files
openclaw/src/cli/plugins-authoring-command.test.ts
2026-05-17 11:45:18 +01:00

335 lines
9.8 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { defineToolPlugin, getToolPluginMetadata } from "../plugin-sdk/tool-plugin.js";
import {
buildToolPluginManifest,
buildToolPluginPackageManifest,
loadToolPlugin,
runPluginsInitCommand,
validateToolPluginProject,
} from "./plugins-authoring-command.js";
function createDemoMetadata() {
const entry = defineToolPlugin({
id: "demo-tools",
name: "Demo Tools",
description: "Demo tool plugin.",
tools: (tool) => [
tool({
name: "demo_echo",
description: "Echo input.",
parameters: Type.Object({ input: Type.String() }),
execute: ({ input }) => ({ input }),
}),
],
});
const metadata = getToolPluginMetadata(entry);
if (!metadata) {
throw new Error("missing metadata");
}
return metadata;
}
function createOptionalDemoMetadata() {
const entry = defineToolPlugin({
id: "optional-demo-tools",
name: "Optional Demo Tools",
description: "Optional demo tool plugin.",
tools: (tool) => [
tool({
name: "demo_optional_echo",
description: "Echo input.",
parameters: Type.Object({ input: Type.String() }),
optional: true,
execute: ({ input }) => ({ input }),
}),
],
});
const metadata = getToolPluginMetadata(entry);
if (!metadata) {
throw new Error("missing metadata");
}
return metadata;
}
describe("plugin authoring commands", () => {
it("generates manifest metadata from defineToolPlugin metadata", () => {
const metadata = createDemoMetadata();
expect(buildToolPluginManifest({ metadata, packageManifest: { version: "1.2.3" } })).toEqual({
id: "demo-tools",
name: "Demo Tools",
description: "Demo tool plugin.",
version: "1.2.3",
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
activation: { onStartup: true },
contracts: { tools: ["demo_echo"] },
});
});
it("generates optional tool metadata for optional tool plugins", () => {
const metadata = createOptionalDemoMetadata();
expect(buildToolPluginManifest({ metadata, packageManifest: { version: "1.2.3" } })).toEqual({
id: "optional-demo-tools",
name: "Optional Demo Tools",
description: "Optional demo tool plugin.",
version: "1.2.3",
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
activation: { onStartup: true },
contracts: { tools: ["demo_optional_echo"] },
toolMetadata: {
demo_optional_echo: { optional: true },
},
});
});
it("preserves manifest-owned metadata while updating generated fields", () => {
const metadata = createOptionalDemoMetadata();
const existingManifest = {
id: "old-id",
name: "Old name",
uiHints: { apiKey: { secret: true } },
contracts: {
tools: ["stale_tool"],
agentToolResultMiddleware: ["existing-middleware"],
},
toolMetadata: {
demo_optional_echo: {
authSignals: [{ provider: "demo", envVars: ["DEMO_API_KEY"] }],
configSignals: [{ rootPath: "plugins.entries.optional-demo-tools.config.apiKey" }],
},
stale_tool: {
optional: true,
},
},
};
const manifest = buildToolPluginManifest({
metadata,
packageManifest: { version: "1.2.3" },
existingManifest,
});
expect(manifest).toMatchObject({
id: "optional-demo-tools",
name: "Optional Demo Tools",
uiHints: { apiKey: { secret: true } },
contracts: {
tools: ["demo_optional_echo"],
agentToolResultMiddleware: ["existing-middleware"],
},
toolMetadata: {
demo_optional_echo: {
optional: true,
authSignals: [{ provider: "demo", envVars: ["DEMO_API_KEY"] }],
configSignals: [{ rootPath: "plugins.entries.optional-demo-tools.config.apiKey" }],
},
},
});
expect((manifest.toolMetadata as Record<string, unknown>).stale_tool).toBeUndefined();
expect(
validateToolPluginProject({
metadata,
entry: "./src/index.ts",
manifest,
packageManifest: { version: "1.2.3", openclaw: { extensions: ["./src/index.ts"] } },
}),
).toEqual([]);
});
it("drops stale manifest-owned tool metadata when no generated metadata remains", () => {
const metadata = createDemoMetadata();
const packageManifest = { version: "1.2.3", openclaw: { extensions: ["./src/index.ts"] } };
const manifest = buildToolPluginManifest({
metadata,
packageManifest,
existingManifest: {
id: "demo-tools",
name: "Demo Tools",
toolMetadata: {
stale_tool: { optional: true },
},
},
});
expect(manifest.toolMetadata).toBeUndefined();
expect(
validateToolPluginProject({
metadata,
entry: "./src/index.ts",
manifest,
packageManifest,
}),
).toEqual([]);
});
it("aligns package metadata with the selected runtime extension entry", () => {
expect(
buildToolPluginPackageManifest({
packageManifest: {
name: "demo",
openclaw: { setupEntry: "./setup.ts", extensions: ["./src/other.ts"] },
},
entry: "./src/index.ts",
}),
).toEqual({
name: "demo",
openclaw: {
setupEntry: "./setup.ts",
extensions: ["./src/index.ts"],
},
});
});
it("validates manifest tools and package entry metadata", () => {
const metadata = createDemoMetadata();
const packageManifest = { version: "1.2.3", openclaw: { extensions: ["./src/index.ts"] } };
expect(
validateToolPluginProject({
metadata,
entry: "./src/index.ts",
manifest: buildToolPluginManifest({ metadata, packageManifest }),
packageManifest,
}),
).toEqual([]);
});
it("reports stale manifest contracts", () => {
const metadata = createDemoMetadata();
expect(
validateToolPluginProject({
metadata,
entry: "./src/index.ts",
manifest: {
id: "demo-tools",
configSchema: {},
contracts: { tools: ["other_tool"] },
},
packageManifest: { openclaw: { extensions: ["./src/index.ts"] } },
}),
).toEqual([
"openclaw.plugin.json generated metadata is stale. Run openclaw plugins build.",
"openclaw.plugin.json contracts.tools is missing: demo_echo",
"openclaw.plugin.json contracts.tools has no matching defineToolPlugin tool: other_tool",
]);
});
it("reports missing entries with an author-facing path", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-missing-"));
await expect(
loadToolPlugin({ rootDir: tmpDir, entryPath: path.join(tmpDir, "dist/index.js") }),
).rejects.toThrow("plugin entry not found: ./dist/index.js");
});
it("loads source entries that import the OpenClaw plugin SDK package subpath", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-source-"));
const sourceDir = path.join(tmpDir, "src");
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify(
{
name: "openclaw-plugin-source-demo",
type: "module",
openclaw: { extensions: ["./src/index.ts"] },
},
null,
2,
),
);
fs.writeFileSync(
path.join(sourceDir, "index.ts"),
`import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
export default defineToolPlugin({
id: "source-demo",
name: "Source Demo",
description: "Source demo plugin.",
tools: (tool) => [
tool({
name: "source_echo",
description: "Echo input.",
parameters: { type: "object", additionalProperties: false, properties: {} },
execute: async () => ({ ok: true }),
}),
],
});
`,
);
const loaded = await loadToolPlugin({
rootDir: tmpDir,
entryPath: path.join(sourceDir, "index.ts"),
});
expect(loaded.metadata.id).toBe("source-demo");
expect(loaded.metadata.tools.map((tool) => tool.name)).toEqual(["source_echo"]);
});
it("scaffolds a dist-entry tool plugin project", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-init-"));
const projectDir = path.join(tmpDir, "stock-quotes");
await runPluginsInitCommand("stock-quotes", {
directory: projectDir,
name: 'Stock "Quotes"',
});
expect(fs.readFileSync(path.join(projectDir, "src/index.ts"), "utf8")).toContain(
'name: "Stock \\"Quotes\\""',
);
expect(
JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")),
).toMatchObject({
dependencies: {
typebox: "^1.1.38",
},
peerDependencies: {
openclaw: ">=2026.5.17",
},
devDependencies: {
openclaw: "latest",
typescript: "^5.9.0",
vitest: "^3.2.0",
},
scripts: {
"plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js",
"plugin:validate": "npm run build && openclaw plugins validate --entry ./dist/index.js",
},
openclaw: {
extensions: ["./dist/index.js"],
},
});
expect(
JSON.parse(fs.readFileSync(path.join(projectDir, "openclaw.plugin.json"), "utf8")),
).toMatchObject({
id: "stock-quotes",
name: 'Stock "Quotes"',
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
contracts: { tools: ["echo"] },
});
expect(fs.readFileSync(path.join(projectDir, "src/index.test.ts"), "utf8")).toContain(
"getToolPluginMetadata",
);
});
});