mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 05:01:31 +00:00
335 lines
9.8 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|