// Plugin tool contract tests cover bundled plugin tool schemas and invocation contracts. import fs from "node:fs"; import path from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; import { listGitTrackedFiles, toRepoPath, toRepoRelativePath, } from "../../test-utils/repo-files.js"; type PluginManifestFile = { id?: unknown; contracts?: { tools?: unknown; }; }; function walkFiles(dir: string): string[] { const gitFiles = listGitFiles(dir); if (gitFiles) { return gitFiles; } const files: string[] = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) { continue; } const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...walkFiles(entryPath)); continue; } files.push(entryPath); } return files; } function repoRelativePath(filePath: string): string { return toRepoRelativePath(process.cwd(), filePath); } function isSkippedRepoPath(relativePath: string): boolean { return relativePath .split("/") .some((part) => part === "node_modules" || part === "dist" || part.startsWith(".")); } function listGitFiles(dir: string): string[] | null { const relativeDir = repoRelativePath(dir); if (!relativeDir || relativeDir.startsWith("..") || path.isAbsolute(relativeDir)) { return null; } const files = listGitTrackedFiles({ pathspecs: relativeDir }); if (!files) { return null; } return files .filter((line) => !isSkippedRepoPath(line)) .map((line) => path.join(process.cwd(), ...line.split("/"))) .filter((filePath) => fs.existsSync(filePath)) .toSorted(); } function listGitPluginManifestPaths(extensionsDir: string): string[] | null { const relativeDir = repoRelativePath(extensionsDir); if (!relativeDir || relativeDir.startsWith("..") || path.isAbsolute(relativeDir)) { return null; } const files = listGitTrackedFiles({ pathspecs: relativeDir }); if (!files) { return null; } return files .filter((line) => /^extensions\/[^/]+\/openclaw\.plugin\.json$/u.test(line)) .map((line) => path.join(process.cwd(), ...line.split("/"))) .filter((filePath) => fs.existsSync(filePath)) .toSorted(); } function listPluginManifestPaths(extensionsDir: string): string[] { const gitPaths = listGitPluginManifestPaths(extensionsDir); if (gitPaths) { return gitPaths; } return fs .readdirSync(extensionsDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) .map((entry) => path.join(extensionsDir, entry.name, "openclaw.plugin.json")) .filter((manifestPath) => fs.existsSync(manifestPath)); } function isProductionSource(filePath: string): boolean { if (!/\.(?:cjs|mjs|js|ts|tsx)$/.test(filePath)) { return false; } const normalized = toRepoPath(filePath); return !/(\.test\.|\.spec\.|\/__tests__\/|\/test-support\/)/.test(normalized); } function readBalancedCallArguments(source: string, openParenIndex: number): string | undefined { let depth = 0; let quote: '"' | "'" | "`" | undefined; let escaped = false; for (let index = openParenIndex; index < source.length; index += 1) { const char = source[index]; if (!char) { continue; } if (quote) { if (escaped) { escaped = false; continue; } if (char === "\\") { escaped = true; continue; } if (char === quote) { quote = undefined; } continue; } if (char === '"' || char === "'" || char === "`") { quote = char; continue; } if (char === "(" || char === "{" || char === "[") { depth += 1; continue; } if (char === ")" || char === "}" || char === "]") { depth -= 1; if (depth === 0 && char === ")") { return source.slice(openParenIndex + 1, index); } } } return undefined; } function listRegisterToolCalls(source: string): string[] { const calls: string[] = []; const pattern = /\bregisterTool\s*\(/g; let match: RegExpExecArray | null; while ((match = pattern.exec(source))) { const openParenIndex = source.indexOf("(", match.index); const args = readBalancedCallArguments(source, openParenIndex); if (args !== undefined) { calls.push(args); } } return calls; } function splitTopLevelArgs(args: string): string[] { const parts: string[] = []; let depth = 0; let quote: '"' | "'" | "`" | undefined; let escaped = false; let start = 0; for (let index = 0; index < args.length; index += 1) { const char = args[index]; if (!char) { continue; } if (quote) { if (escaped) { escaped = false; continue; } if (char === "\\") { escaped = true; continue; } if (char === quote) { quote = undefined; } continue; } if (char === '"' || char === "'" || char === "`") { quote = char; continue; } if (char === "(" || char === "{" || char === "[") { depth += 1; continue; } if (char === ")" || char === "}" || char === "]") { depth -= 1; continue; } if (char === "," && depth === 0) { const part = args.slice(start, index).trim(); if (part.length > 0) { parts.push(part); } start = index + 1; } } const part = args.slice(start).trim(); if (part.length > 0) { parts.push(part); } return parts; } function extractStringLiterals(source: string): string[] { const names: string[] = []; const pattern = /["']([^"']+)["']/g; let match: RegExpExecArray | null; while ((match = pattern.exec(source))) { if (match[1]) { names.push(match[1]); } } return names; } function extractStaticRegisteredToolNamesFromObject(source: string): string[] { const names = new Set(); const namesPattern = /\bnames\s*:\s*\[([\s\S]*?)\]/g; let namesMatch: RegExpExecArray | null; while ((namesMatch = namesPattern.exec(source))) { for (const name of extractStringLiterals(namesMatch[1] ?? "")) { names.add(name); } } const namePattern = /\bname\s*:\s*["']([^"']+)["']/g; let nameMatch: RegExpExecArray | null; while ((nameMatch = namePattern.exec(source))) { if (nameMatch[1]) { names.add(nameMatch[1]); } } return [...names]; } function extractStaticRegisteredToolNames(callArgs: string): string[] { const args = splitTopLevelArgs(callArgs); const names = new Set(); const firstArg = args[0]?.trim() ?? ""; const optionsArg = args[1]?.trim() ?? ""; if (firstArg.startsWith("{")) { for (const name of extractStaticRegisteredToolNamesFromObject(firstArg)) { names.add(name); } } if (optionsArg.startsWith("{")) { for (const name of extractStaticRegisteredToolNamesFromObject(optionsArg)) { names.add(name); } } return [...names]; } function readManifest(manifestPath: string): PluginManifestFile { return JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as PluginManifestFile; } function normalizeManifestTools(value: unknown): string[] { if (!Array.isArray(value)) { return []; } return value.filter((item): item is string => typeof item === "string" && item.trim() !== ""); } describe("bundled plugin tool manifest contracts", () => { let toolContractFailures: string[] = []; beforeAll(() => { listGitTrackedFiles({ pathspecs: "extensions" }); toolContractFailures = collectToolContractFailures(path.join(process.cwd(), "extensions")); }); it("lists plugin tool contract inputs from git without walking extension roots", () => { const extensionsDir = path.join(process.cwd(), "extensions"); expectNoReaddirSyncDuring(() => { const manifestPaths = listPluginManifestPaths(extensionsDir); const sourceFiles = manifestPaths[0] ? walkFiles(path.dirname(manifestPaths[0])).filter(isProductionSource) : []; expect(manifestPaths.length).toBeGreaterThan(0); expect(sourceFiles.length).toBeGreaterThan(0); }); }); it("declares every production registerTool owner in contracts.tools", () => { expect(toolContractFailures).toStrictEqual([]); }); }); function collectToolContractFailures(extensionsDir: string): string[] { const failures: string[] = []; for (const manifestPath of listPluginManifestPaths(extensionsDir)) { const pluginDir = path.dirname(manifestPath); const manifest = readManifest(manifestPath); const pluginId = typeof manifest.id === "string" ? manifest.id : path.basename(pluginDir); const declaredTools = new Set(normalizeManifestTools(manifest.contracts?.tools)); const registeredNames = new Set(); let registerCallCount = 0; for (const filePath of walkFiles(pluginDir).filter(isProductionSource)) { const source = fs.readFileSync(filePath, "utf-8"); for (const call of listRegisterToolCalls(source)) { registerCallCount += 1; for (const name of extractStaticRegisteredToolNames(call)) { registeredNames.add(name); } } } if (registerCallCount === 0) { continue; } if (declaredTools.size === 0) { failures.push(`${pluginId}: registers agent tools but has no contracts.tools`); continue; } const missing = [...registeredNames].filter((name) => !declaredTools.has(name)).toSorted(); if (missing.length > 0) { failures.push(`${pluginId}: missing contracts.tools for ${missing.join(", ")}`); } } return failures; }