mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-28 00:43:57 +00:00
test: dedupe plugin bundle discovery suites
This commit is contained in:
@@ -23,6 +23,18 @@ describe("Claude bundle plugin inspect integration", () => {
|
|||||||
writeFixtureText(relativePath, JSON.stringify(value));
|
writeFixtureText(relativePath, JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeFixtureEntries(
|
||||||
|
entries: Readonly<Record<string, string | Record<string, unknown>>>,
|
||||||
|
) {
|
||||||
|
Object.entries(entries).forEach(([relativePath, value]) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
writeFixtureText(relativePath, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeFixtureJson(relativePath, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setupClaudeInspectFixture() {
|
function setupClaudeInspectFixture() {
|
||||||
for (const relativeDir of [
|
for (const relativeDir of [
|
||||||
".claude-plugin",
|
".claude-plugin",
|
||||||
@@ -36,44 +48,42 @@ describe("Claude bundle plugin inspect integration", () => {
|
|||||||
fs.mkdirSync(path.join(rootDir, relativeDir), { recursive: true });
|
fs.mkdirSync(path.join(rootDir, relativeDir), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFixtureJson(".claude-plugin/plugin.json", {
|
writeFixtureEntries({
|
||||||
name: "Test Claude Plugin",
|
".claude-plugin/plugin.json": {
|
||||||
description: "Integration test fixture for Claude bundle inspection",
|
name: "Test Claude Plugin",
|
||||||
version: "1.0.0",
|
description: "Integration test fixture for Claude bundle inspection",
|
||||||
skills: ["skill-packs"],
|
version: "1.0.0",
|
||||||
commands: "extra-commands",
|
skills: ["skill-packs"],
|
||||||
agents: "agents",
|
commands: "extra-commands",
|
||||||
hooks: "custom-hooks",
|
agents: "agents",
|
||||||
mcpServers: ".mcp.json",
|
hooks: "custom-hooks",
|
||||||
lspServers: ".lsp.json",
|
mcpServers: ".mcp.json",
|
||||||
outputStyles: "output-styles",
|
lspServers: ".lsp.json",
|
||||||
});
|
outputStyles: "output-styles",
|
||||||
writeFixtureText(
|
},
|
||||||
"skill-packs/demo/SKILL.md",
|
"skill-packs/demo/SKILL.md":
|
||||||
"---\nname: demo\ndescription: A demo skill\n---\nDo something useful.",
|
"---\nname: demo\ndescription: A demo skill\n---\nDo something useful.",
|
||||||
);
|
"extra-commands/cmd/SKILL.md":
|
||||||
writeFixtureText(
|
"---\nname: cmd\ndescription: A command skill\n---\nRun a command.",
|
||||||
"extra-commands/cmd/SKILL.md",
|
"hooks/hooks.json": '{"hooks":[]}',
|
||||||
"---\nname: cmd\ndescription: A command skill\n---\nRun a command.",
|
".mcp.json": {
|
||||||
);
|
mcpServers: {
|
||||||
writeFixtureText("hooks/hooks.json", '{"hooks":[]}');
|
"test-stdio-server": {
|
||||||
writeFixtureJson(".mcp.json", {
|
command: "echo",
|
||||||
mcpServers: {
|
args: ["hello"],
|
||||||
"test-stdio-server": {
|
},
|
||||||
command: "echo",
|
"test-sse-server": {
|
||||||
args: ["hello"],
|
url: "http://localhost:3000/sse",
|
||||||
},
|
},
|
||||||
"test-sse-server": {
|
|
||||||
url: "http://localhost:3000/sse",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
"settings.json": { thinkingLevel: "high" },
|
||||||
writeFixtureJson("settings.json", { thinkingLevel: "high" });
|
".lsp.json": {
|
||||||
writeFixtureJson(".lsp.json", {
|
lspServers: {
|
||||||
lspServers: {
|
"typescript-lsp": {
|
||||||
"typescript-lsp": {
|
command: "typescript-language-server",
|
||||||
command: "typescript-language-server",
|
args: ["--stdio"],
|
||||||
args: ["--stdio"],
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -119,6 +129,27 @@ describe("Claude bundle plugin inspect integration", () => {
|
|||||||
expectNoDiagnostics(params.actual.diagnostics);
|
expectNoDiagnostics(params.actual.diagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inspectClaudeBundleRuntimeSupport(kind: "mcp" | "lsp"): {
|
||||||
|
supportedServerNames: string[];
|
||||||
|
unsupportedServerNames: string[];
|
||||||
|
diagnostics: unknown[];
|
||||||
|
hasSupportedStdioServer?: boolean;
|
||||||
|
hasStdioServer?: boolean;
|
||||||
|
} {
|
||||||
|
if (kind === "mcp") {
|
||||||
|
return inspectBundleMcpRuntimeSupport({
|
||||||
|
pluginId: "test-claude-plugin",
|
||||||
|
rootDir,
|
||||||
|
bundleFormat: "claude",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return inspectBundleLspRuntimeSupport({
|
||||||
|
pluginId: "test-claude-plugin",
|
||||||
|
rootDir,
|
||||||
|
bundleFormat: "claude",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-"));
|
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-"));
|
||||||
setupClaudeInspectFixture();
|
setupClaudeInspectFixture();
|
||||||
@@ -130,10 +161,12 @@ describe("Claude bundle plugin inspect integration", () => {
|
|||||||
|
|
||||||
it("loads the full Claude bundle manifest with all capabilities", () => {
|
it("loads the full Claude bundle manifest with all capabilities", () => {
|
||||||
const m = expectLoadedClaudeManifest();
|
const m = expectLoadedClaudeManifest();
|
||||||
expect(m.name).toBe("Test Claude Plugin");
|
expect(m).toMatchObject({
|
||||||
expect(m.description).toBe("Integration test fixture for Claude bundle inspection");
|
name: "Test Claude Plugin",
|
||||||
expect(m.version).toBe("1.0.0");
|
description: "Integration test fixture for Claude bundle inspection",
|
||||||
expect(m.bundleFormat).toBe("claude");
|
version: "1.0.0",
|
||||||
|
bundleFormat: "claude",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -170,33 +203,27 @@ describe("Claude bundle plugin inspect integration", () => {
|
|||||||
expectClaudeManifestField({ field, includes });
|
expectClaudeManifestField({ field, includes });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inspects MCP runtime support with supported and unsupported servers", () => {
|
it.each([
|
||||||
const mcp = inspectBundleMcpRuntimeSupport({
|
{
|
||||||
pluginId: "test-claude-plugin",
|
name: "inspects MCP runtime support with supported and unsupported servers",
|
||||||
rootDir,
|
kind: "mcp" as const,
|
||||||
bundleFormat: "claude",
|
|
||||||
});
|
|
||||||
|
|
||||||
expectBundleRuntimeSupport({
|
|
||||||
actual: mcp,
|
|
||||||
supportedServerNames: ["test-stdio-server"],
|
supportedServerNames: ["test-stdio-server"],
|
||||||
unsupportedServerNames: ["test-sse-server"],
|
unsupportedServerNames: ["test-sse-server"],
|
||||||
hasSupportedKey: "hasSupportedStdioServer",
|
hasSupportedKey: "hasSupportedStdioServer" as const,
|
||||||
});
|
},
|
||||||
});
|
{
|
||||||
|
name: "inspects LSP runtime support with stdio server",
|
||||||
it("inspects LSP runtime support with stdio server", () => {
|
kind: "lsp" as const,
|
||||||
const lsp = inspectBundleLspRuntimeSupport({
|
|
||||||
pluginId: "test-claude-plugin",
|
|
||||||
rootDir,
|
|
||||||
bundleFormat: "claude",
|
|
||||||
});
|
|
||||||
|
|
||||||
expectBundleRuntimeSupport({
|
|
||||||
actual: lsp,
|
|
||||||
supportedServerNames: ["typescript-lsp"],
|
supportedServerNames: ["typescript-lsp"],
|
||||||
unsupportedServerNames: [],
|
unsupportedServerNames: [],
|
||||||
hasSupportedKey: "hasStdioServer",
|
hasSupportedKey: "hasStdioServer" as const,
|
||||||
|
},
|
||||||
|
])("$name", ({ kind, supportedServerNames, unsupportedServerNames, hasSupportedKey }) => {
|
||||||
|
expectBundleRuntimeSupport({
|
||||||
|
actual: inspectClaudeBundleRuntimeSupport(kind),
|
||||||
|
supportedServerNames,
|
||||||
|
unsupportedServerNames,
|
||||||
|
hasSupportedKey,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { loadEnabledClaudeBundleCommands } from "./bundle-commands.js";
|
import { loadEnabledClaudeBundleCommands } from "./bundle-commands.js";
|
||||||
import { createBundleMcpTempHarness, withBundleHomeEnv } from "./bundle-mcp.test-support.js";
|
import {
|
||||||
|
createEnabledPluginEntries,
|
||||||
|
createBundleMcpTempHarness,
|
||||||
|
withBundleHomeEnv,
|
||||||
|
writeBundleTextFiles,
|
||||||
|
writeClaudeBundleManifest,
|
||||||
|
} from "./bundle-mcp.test-support.js";
|
||||||
|
|
||||||
const tempHarness = createBundleMcpTempHarness();
|
const tempHarness = createBundleMcpTempHarness();
|
||||||
|
|
||||||
@@ -15,24 +19,33 @@ async function writeClaudeBundleCommandFixture(params: {
|
|||||||
pluginId: string;
|
pluginId: string;
|
||||||
commands: Array<{ relativePath: string; contents: string[] }>;
|
commands: Array<{ relativePath: string; contents: string[] }>;
|
||||||
}) {
|
}) {
|
||||||
const pluginRoot = path.join(params.homeDir, ".openclaw", "extensions", params.pluginId);
|
const pluginRoot = await writeClaudeBundleManifest({
|
||||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
homeDir: params.homeDir,
|
||||||
await fs.writeFile(
|
pluginId: params.pluginId,
|
||||||
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
manifest: { name: params.pluginId },
|
||||||
`${JSON.stringify({ name: params.pluginId }, null, 2)}\n`,
|
});
|
||||||
"utf-8",
|
await writeBundleTextFiles(
|
||||||
);
|
pluginRoot,
|
||||||
await Promise.all(
|
Object.fromEntries(
|
||||||
params.commands.map(async (command) => {
|
params.commands.map((command) => [
|
||||||
await fs.mkdir(path.dirname(path.join(pluginRoot, command.relativePath)), {
|
command.relativePath,
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(pluginRoot, command.relativePath),
|
|
||||||
[...command.contents, ""].join("\n"),
|
[...command.contents, ""].join("\n"),
|
||||||
"utf-8",
|
]),
|
||||||
);
|
),
|
||||||
}),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectEnabledClaudeBundleCommands(
|
||||||
|
commands: ReturnType<typeof loadEnabledClaudeBundleCommands>,
|
||||||
|
expected: Array<{
|
||||||
|
pluginId: string;
|
||||||
|
rawName: string;
|
||||||
|
description: string;
|
||||||
|
promptTemplate: string;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
expect(commands).toEqual(
|
||||||
|
expect.arrayContaining(expected.map((entry) => expect.objectContaining(entry))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,29 +89,25 @@ describe("loadEnabledClaudeBundleCommands", () => {
|
|||||||
workspaceDir,
|
workspaceDir,
|
||||||
cfg: {
|
cfg: {
|
||||||
plugins: {
|
plugins: {
|
||||||
entries: {
|
entries: createEnabledPluginEntries(["compound-bundle"]),
|
||||||
"compound-bundle": { enabled: true },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(commands).toEqual(
|
expectEnabledClaudeBundleCommands(commands, [
|
||||||
expect.arrayContaining([
|
{
|
||||||
expect.objectContaining({
|
pluginId: "compound-bundle",
|
||||||
pluginId: "compound-bundle",
|
rawName: "office-hours",
|
||||||
rawName: "office-hours",
|
description: "Help with scoping and architecture",
|
||||||
description: "Help with scoping and architecture",
|
promptTemplate: "Give direct engineering advice.",
|
||||||
promptTemplate: "Give direct engineering advice.",
|
},
|
||||||
}),
|
{
|
||||||
expect.objectContaining({
|
pluginId: "compound-bundle",
|
||||||
pluginId: "compound-bundle",
|
rawName: "workflows:review",
|
||||||
rawName: "workflows:review",
|
description: "Run a structured review",
|
||||||
description: "Run a structured review",
|
promptTemplate: "Review the code. $ARGUMENTS",
|
||||||
promptTemplate: "Review the code. $ARGUMENTS",
|
},
|
||||||
}),
|
]);
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false);
|
expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,18 +36,22 @@ function writeBundleManifest(
|
|||||||
relativePath: string,
|
relativePath: string,
|
||||||
manifest: Record<string, unknown>,
|
manifest: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
mkdirSafe(path.dirname(path.join(rootDir, relativePath)));
|
writeBundleFixtureFile(rootDir, relativePath, manifest);
|
||||||
fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(manifest), "utf-8");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeJsonFile(rootDir: string, relativePath: string, value: unknown) {
|
function writeBundleFixtureFile(rootDir: string, relativePath: string, value: unknown) {
|
||||||
mkdirSafe(path.dirname(path.join(rootDir, relativePath)));
|
mkdirSafe(path.dirname(path.join(rootDir, relativePath)));
|
||||||
fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(value), "utf-8");
|
fs.writeFileSync(
|
||||||
|
path.join(rootDir, relativePath),
|
||||||
|
typeof value === "string" ? value : JSON.stringify(value),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeTextFile(rootDir: string, relativePath: string, value: string) {
|
function writeBundleFixtureFiles(rootDir: string, files: Readonly<Record<string, unknown>>) {
|
||||||
mkdirSafe(path.dirname(path.join(rootDir, relativePath)));
|
Object.entries(files).forEach(([relativePath, value]) => {
|
||||||
fs.writeFileSync(path.join(rootDir, relativePath), value, "utf-8");
|
writeBundleFixtureFile(rootDir, relativePath, value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupBundleFixture(params: {
|
function setupBundleFixture(params: {
|
||||||
@@ -61,12 +65,8 @@ function setupBundleFixture(params: {
|
|||||||
for (const relativeDir of params.dirs ?? []) {
|
for (const relativeDir of params.dirs ?? []) {
|
||||||
mkdirSafe(path.join(params.rootDir, relativeDir));
|
mkdirSafe(path.join(params.rootDir, relativeDir));
|
||||||
}
|
}
|
||||||
for (const [relativePath, value] of Object.entries(params.jsonFiles ?? {})) {
|
writeBundleFixtureFiles(params.rootDir, params.jsonFiles ?? {});
|
||||||
writeJsonFile(params.rootDir, relativePath, value);
|
writeBundleFixtureFiles(params.rootDir, params.textFiles ?? {});
|
||||||
}
|
|
||||||
for (const [relativePath, value] of Object.entries(params.textFiles ?? {})) {
|
|
||||||
writeTextFile(params.rootDir, relativePath, value);
|
|
||||||
}
|
|
||||||
if (params.manifestRelativePath && params.manifest) {
|
if (params.manifestRelativePath && params.manifest) {
|
||||||
writeBundleManifest(params.rootDir, params.manifestRelativePath, params.manifest);
|
writeBundleManifest(params.rootDir, params.manifestRelativePath, params.manifest);
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,25 @@ function setupClaudeHookFixture(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectBundleManifest(params: {
|
||||||
|
rootDir: string;
|
||||||
|
bundleFormat: "codex" | "claude" | "cursor";
|
||||||
|
expected: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
expect(detectBundleManifestFormat(params.rootDir)).toBe(params.bundleFormat);
|
||||||
|
expect(expectLoadedManifest(params.rootDir, params.bundleFormat)).toMatchObject(params.expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectClaudeHookResolution(params: {
|
||||||
|
rootDir: string;
|
||||||
|
expectedHooks: readonly string[];
|
||||||
|
hasHooksCapability: boolean;
|
||||||
|
}) {
|
||||||
|
const manifest = expectLoadedManifest(params.rootDir, "claude");
|
||||||
|
expect(manifest.hooks).toEqual(params.expectedHooks);
|
||||||
|
expect(manifest.capabilities.includes("hooks")).toBe(params.hasHooksCapability);
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanupTrackedTempDirs(tempDirs);
|
cleanupTrackedTempDirs(tempDirs);
|
||||||
});
|
});
|
||||||
@@ -266,10 +285,11 @@ describe("bundle manifest parsing", () => {
|
|||||||
const rootDir = makeTempDir();
|
const rootDir = makeTempDir();
|
||||||
setup(rootDir);
|
setup(rootDir);
|
||||||
|
|
||||||
expect(detectBundleManifestFormat(rootDir)).toBe(bundleFormat);
|
expectBundleManifest({
|
||||||
expect(expectLoadedManifest(rootDir, bundleFormat)).toMatchObject(
|
rootDir,
|
||||||
typeof expected === "function" ? expected(rootDir) : expected,
|
bundleFormat,
|
||||||
);
|
expected: typeof expected === "function" ? expected(rootDir) : expected,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -294,9 +314,11 @@ describe("bundle manifest parsing", () => {
|
|||||||
] as const)("$name", ({ setupKind, expectedHooks, hasHooksCapability }) => {
|
] as const)("$name", ({ setupKind, expectedHooks, hasHooksCapability }) => {
|
||||||
const rootDir = makeTempDir();
|
const rootDir = makeTempDir();
|
||||||
setupClaudeHookFixture(rootDir, setupKind);
|
setupClaudeHookFixture(rootDir, setupKind);
|
||||||
const manifest = expectLoadedManifest(rootDir, "claude");
|
expectClaudeHookResolution({
|
||||||
expect(manifest.hooks).toEqual(expectedHooks);
|
rootDir,
|
||||||
expect(manifest.capabilities.includes("hooks")).toBe(hasHooksCapability);
|
expectedHooks,
|
||||||
|
hasHooksCapability,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not misclassify native index plugins as manifestless Claude bundles", () => {
|
it("does not misclassify native index plugins as manifestless Claude bundles", () => {
|
||||||
|
|||||||
@@ -26,17 +26,52 @@ export function createBundleMcpTempHarness() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBundleProbePlugin(homeDir: string) {
|
export function resolveBundlePluginRoot(homeDir: string, pluginId: string) {
|
||||||
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe");
|
return path.join(homeDir, ".openclaw", "extensions", pluginId);
|
||||||
const serverPath = path.join(pluginRoot, "servers", "probe.mjs");
|
}
|
||||||
|
|
||||||
|
export async function writeClaudeBundleManifest(params: {
|
||||||
|
homeDir: string;
|
||||||
|
pluginId: string;
|
||||||
|
manifest: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const pluginRoot = resolveBundlePluginRoot(params.homeDir, params.pluginId);
|
||||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
||||||
await fs.mkdir(path.dirname(serverPath), { recursive: true });
|
|
||||||
await fs.writeFile(serverPath, "export {};\n", "utf-8");
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
||||||
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
|
`${JSON.stringify(params.manifest, null, 2)}\n`,
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
return pluginRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeBundleTextFiles(
|
||||||
|
rootDir: string,
|
||||||
|
files: Readonly<Record<string, string>>,
|
||||||
|
) {
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(files).map(async ([relativePath, contents]) => {
|
||||||
|
const filePath = path.join(rootDir, relativePath);
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fs.writeFile(filePath, contents, "utf-8");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEnabledPluginEntries(pluginIds: readonly string[]) {
|
||||||
|
return Object.fromEntries(pluginIds.map((pluginId) => [pluginId, { enabled: true }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBundleProbePlugin(homeDir: string) {
|
||||||
|
const pluginRoot = resolveBundlePluginRoot(homeDir, "bundle-probe");
|
||||||
|
const serverPath = path.join(pluginRoot, "servers", "probe.mjs");
|
||||||
|
await fs.mkdir(path.dirname(serverPath), { recursive: true });
|
||||||
|
await fs.writeFile(serverPath, "export {};\n", "utf-8");
|
||||||
|
await writeClaudeBundleManifest({
|
||||||
|
homeDir,
|
||||||
|
pluginId: "bundle-probe",
|
||||||
|
manifest: { name: "bundle-probe" },
|
||||||
|
});
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(pluginRoot, ".mcp.json"),
|
path.join(pluginRoot, ".mcp.json"),
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import type { OpenClawConfig } from "../config/config.js";
|
|||||||
import { isRecord } from "../utils.js";
|
import { isRecord } from "../utils.js";
|
||||||
import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js";
|
import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js";
|
||||||
import {
|
import {
|
||||||
|
createEnabledPluginEntries,
|
||||||
createBundleMcpTempHarness,
|
createBundleMcpTempHarness,
|
||||||
createBundleProbePlugin,
|
createBundleProbePlugin,
|
||||||
withBundleHomeEnv,
|
withBundleHomeEnv,
|
||||||
|
writeClaudeBundleManifest,
|
||||||
} from "./bundle-mcp.test-support.js";
|
} from "./bundle-mcp.test-support.js";
|
||||||
|
|
||||||
function getServerArgs(value: unknown): unknown[] | undefined {
|
function getServerArgs(value: unknown): unknown[] | undefined {
|
||||||
@@ -44,24 +46,43 @@ afterEach(async () => {
|
|||||||
function createEnabledBundleConfig(pluginIds: string[]): OpenClawConfig {
|
function createEnabledBundleConfig(pluginIds: string[]): OpenClawConfig {
|
||||||
return {
|
return {
|
||||||
plugins: {
|
plugins: {
|
||||||
entries: Object.fromEntries(pluginIds.map((pluginId) => [pluginId, { enabled: true }])),
|
entries: createEnabledPluginEntries(pluginIds),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeInlineClaudeBundleManifest(params: {
|
async function expectInlineBundleMcpServer(params: {
|
||||||
homeDir: string;
|
loadedServer: unknown;
|
||||||
pluginId: string;
|
pluginRoot: string;
|
||||||
manifest: Record<string, unknown>;
|
commandRelativePath: string;
|
||||||
|
argRelativePaths: readonly string[];
|
||||||
}) {
|
}) {
|
||||||
const pluginRoot = path.join(params.homeDir, ".openclaw", "extensions", params.pluginId);
|
const loadedArgs = getServerArgs(params.loadedServer);
|
||||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
const loadedCommand = isRecord(params.loadedServer) ? params.loadedServer.command : undefined;
|
||||||
await fs.writeFile(
|
const loadedCwd = isRecord(params.loadedServer) ? params.loadedServer.cwd : undefined;
|
||||||
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
const loadedEnv =
|
||||||
`${JSON.stringify(params.manifest, null, 2)}\n`,
|
isRecord(params.loadedServer) && isRecord(params.loadedServer.env)
|
||||||
"utf-8",
|
? params.loadedServer.env
|
||||||
|
: {};
|
||||||
|
|
||||||
|
await expectResolvedPathEqual(loadedCwd, params.pluginRoot);
|
||||||
|
expect(typeof loadedCommand).toBe("string");
|
||||||
|
expect(loadedArgs).toHaveLength(params.argRelativePaths.length);
|
||||||
|
expect(typeof loadedEnv.PLUGIN_ROOT).toBe("string");
|
||||||
|
if (typeof loadedCommand !== "string" || typeof loadedCwd !== "string") {
|
||||||
|
throw new Error("expected inline bundled MCP server to expose command and cwd");
|
||||||
|
}
|
||||||
|
expect(normalizePathForAssertion(path.relative(loadedCwd, loadedCommand))).toBe(
|
||||||
|
normalizePathForAssertion(params.commandRelativePath),
|
||||||
);
|
);
|
||||||
return pluginRoot;
|
expect(
|
||||||
|
loadedArgs?.map((entry) =>
|
||||||
|
typeof entry === "string"
|
||||||
|
? normalizePathForAssertion(path.relative(loadedCwd, entry))
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
).toEqual([...params.argRelativePaths]);
|
||||||
|
await expectResolvedPathEqual(loadedEnv.PLUGIN_ROOT, params.pluginRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("loadEnabledBundleMcpConfig", () => {
|
describe("loadEnabledBundleMcpConfig", () => {
|
||||||
@@ -110,7 +131,7 @@ describe("loadEnabledBundleMcpConfig", () => {
|
|||||||
tempHarness,
|
tempHarness,
|
||||||
"openclaw-bundle-inline",
|
"openclaw-bundle-inline",
|
||||||
async ({ homeDir, workspaceDir }) => {
|
async ({ homeDir, workspaceDir }) => {
|
||||||
await writeInlineClaudeBundleManifest({
|
await writeClaudeBundleManifest({
|
||||||
homeDir,
|
homeDir,
|
||||||
pluginId: "inline-enabled",
|
pluginId: "inline-enabled",
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -123,7 +144,7 @@ describe("loadEnabledBundleMcpConfig", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeInlineClaudeBundleManifest({
|
await writeClaudeBundleManifest({
|
||||||
homeDir,
|
homeDir,
|
||||||
pluginId: "inline-disabled",
|
pluginId: "inline-disabled",
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -142,7 +163,7 @@ describe("loadEnabledBundleMcpConfig", () => {
|
|||||||
cfg: {
|
cfg: {
|
||||||
plugins: {
|
plugins: {
|
||||||
entries: {
|
entries: {
|
||||||
...createEnabledBundleConfig(["inline-enabled"]).plugins?.entries,
|
...createEnabledPluginEntries(["inline-enabled"]),
|
||||||
"inline-disabled": { enabled: false },
|
"inline-disabled": { enabled: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -160,7 +181,7 @@ describe("loadEnabledBundleMcpConfig", () => {
|
|||||||
tempHarness,
|
tempHarness,
|
||||||
"openclaw-bundle-inline-placeholder",
|
"openclaw-bundle-inline-placeholder",
|
||||||
async ({ homeDir, workspaceDir }) => {
|
async ({ homeDir, workspaceDir }) => {
|
||||||
const pluginRoot = await writeInlineClaudeBundleManifest({
|
const pluginRoot = await writeClaudeBundleManifest({
|
||||||
homeDir,
|
homeDir,
|
||||||
pluginId: "inline-claude",
|
pluginId: "inline-claude",
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -183,34 +204,17 @@ describe("loadEnabledBundleMcpConfig", () => {
|
|||||||
cfg: createEnabledBundleConfig(["inline-claude"]),
|
cfg: createEnabledBundleConfig(["inline-claude"]),
|
||||||
});
|
});
|
||||||
const loadedServer = loaded.config.mcpServers.inlineProbe;
|
const loadedServer = loaded.config.mcpServers.inlineProbe;
|
||||||
const loadedArgs = getServerArgs(loadedServer);
|
|
||||||
const loadedCommand = isRecord(loadedServer) ? loadedServer.command : undefined;
|
|
||||||
const loadedCwd = isRecord(loadedServer) ? loadedServer.cwd : undefined;
|
|
||||||
const loadedEnv =
|
|
||||||
isRecord(loadedServer) && isRecord(loadedServer.env) ? loadedServer.env : {};
|
|
||||||
|
|
||||||
expectNoDiagnostics(loaded.diagnostics);
|
expectNoDiagnostics(loaded.diagnostics);
|
||||||
await expectResolvedPathEqual(loadedCwd, pluginRoot);
|
await expectInlineBundleMcpServer({
|
||||||
expect(typeof loadedCommand).toBe("string");
|
loadedServer,
|
||||||
expect(loadedArgs).toHaveLength(2);
|
pluginRoot,
|
||||||
expect(typeof loadedEnv.PLUGIN_ROOT).toBe("string");
|
commandRelativePath: path.join("bin", "server.sh"),
|
||||||
if (typeof loadedCommand !== "string" || typeof loadedCwd !== "string") {
|
argRelativePaths: [
|
||||||
throw new Error("expected inline bundled MCP server to expose command and cwd");
|
normalizePathForAssertion(path.join("servers", "probe.mjs"))!,
|
||||||
}
|
normalizePathForAssertion("local-probe.mjs")!,
|
||||||
expect(normalizePathForAssertion(path.relative(loadedCwd, loadedCommand))).toBe(
|
],
|
||||||
normalizePathForAssertion(path.join("bin", "server.sh")),
|
});
|
||||||
);
|
|
||||||
expect(
|
|
||||||
loadedArgs?.map((entry) =>
|
|
||||||
typeof entry === "string"
|
|
||||||
? normalizePathForAssertion(path.relative(loadedCwd, entry))
|
|
||||||
: entry,
|
|
||||||
),
|
|
||||||
).toEqual([
|
|
||||||
normalizePathForAssertion(path.join("servers", "probe.mjs")),
|
|
||||||
normalizePathForAssertion("local-probe.mjs"),
|
|
||||||
]);
|
|
||||||
await expectResolvedPathEqual(loadedEnv.PLUGIN_ROOT, pluginRoot);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -104,6 +104,17 @@ function expectInstalledBundledDirScenario(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectInstalledBundledDirScenarioCase(
|
||||||
|
createScenario: () => {
|
||||||
|
installedRoot: string;
|
||||||
|
cwd?: string;
|
||||||
|
argv1?: string;
|
||||||
|
bundledDirOverride?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
expectInstalledBundledDirScenario(createScenario());
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
if (originalBundledDir === undefined) {
|
if (originalBundledDir === undefined) {
|
||||||
@@ -181,34 +192,42 @@ describe("resolveBundledPluginsDir", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers the running CLI package root over an unrelated cwd checkout", () => {
|
it.each([
|
||||||
const installedRoot = createOpenClawRoot({
|
{
|
||||||
prefix: "openclaw-bundled-dir-installed-",
|
name: "prefers the running CLI package root over an unrelated cwd checkout",
|
||||||
hasDistExtensions: true,
|
createScenario: () => {
|
||||||
});
|
const installedRoot = createOpenClawRoot({
|
||||||
const cwdRepoRoot = createOpenClawRoot({
|
prefix: "openclaw-bundled-dir-installed-",
|
||||||
prefix: "openclaw-bundled-dir-cwd-",
|
hasDistExtensions: true,
|
||||||
hasExtensions: true,
|
});
|
||||||
hasSrc: true,
|
const cwdRepoRoot = createOpenClawRoot({
|
||||||
hasGitCheckout: true,
|
prefix: "openclaw-bundled-dir-cwd-",
|
||||||
});
|
hasExtensions: true,
|
||||||
|
hasSrc: true,
|
||||||
expectInstalledBundledDirScenario({
|
hasGitCheckout: true,
|
||||||
installedRoot,
|
});
|
||||||
cwd: cwdRepoRoot,
|
return {
|
||||||
argv1: path.join(installedRoot, "openclaw.mjs"),
|
installedRoot,
|
||||||
});
|
cwd: cwdRepoRoot,
|
||||||
});
|
argv1: path.join(installedRoot, "openclaw.mjs"),
|
||||||
|
};
|
||||||
it("falls back to the running installed package when the override path is stale", () => {
|
},
|
||||||
const installedRoot = createOpenClawRoot({
|
},
|
||||||
prefix: "openclaw-bundled-dir-override-",
|
{
|
||||||
hasDistExtensions: true,
|
name: "falls back to the running installed package when the override path is stale",
|
||||||
});
|
createScenario: () => {
|
||||||
expectInstalledBundledDirScenario({
|
const installedRoot = createOpenClawRoot({
|
||||||
installedRoot,
|
prefix: "openclaw-bundled-dir-override-",
|
||||||
argv1: path.join(installedRoot, "openclaw.mjs"),
|
hasDistExtensions: true,
|
||||||
bundledDirOverride: path.join(installedRoot, "missing-extensions"),
|
});
|
||||||
});
|
return {
|
||||||
|
installedRoot,
|
||||||
|
argv1: path.join(installedRoot, "openclaw.mjs"),
|
||||||
|
bundledDirOverride: path.join(installedRoot, "missing-extensions"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const)("$name", ({ createScenario }) => {
|
||||||
|
expectInstalledBundledDirScenarioCase(createScenario);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,19 @@ async function writeGeneratedMetadataModule(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectGeneratedMetadataModuleState(params: {
|
||||||
|
repoRoot: string;
|
||||||
|
check?: boolean;
|
||||||
|
expected: { changed?: boolean; wrote?: boolean };
|
||||||
|
}) {
|
||||||
|
const result = await writeGeneratedMetadataModule({
|
||||||
|
repoRoot: params.repoRoot,
|
||||||
|
...(params.check ? { check: true } : {}),
|
||||||
|
});
|
||||||
|
expect(result).toEqual(expect.objectContaining(params.expected));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
describe("bundled plugin metadata", () => {
|
describe("bundled plugin metadata", () => {
|
||||||
it(
|
it(
|
||||||
"matches the generated metadata snapshot",
|
"matches the generated metadata snapshot",
|
||||||
@@ -127,12 +140,16 @@ describe("bundled plugin metadata", () => {
|
|||||||
configSchema: { type: "object" },
|
configSchema: { type: "object" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const initial = await writeGeneratedMetadataModule({ repoRoot: tempRoot });
|
await expectGeneratedMetadataModuleState({
|
||||||
expect(initial.wrote).toBe(true);
|
repoRoot: tempRoot,
|
||||||
|
expected: { wrote: true },
|
||||||
|
});
|
||||||
|
|
||||||
const current = await writeGeneratedMetadataModule({ repoRoot: tempRoot, check: true });
|
await expectGeneratedMetadataModuleState({
|
||||||
expect(current.changed).toBe(false);
|
repoRoot: tempRoot,
|
||||||
expect(current.wrote).toBe(false);
|
check: true,
|
||||||
|
expected: { changed: false, wrote: false },
|
||||||
|
});
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(tempRoot, "src/plugins/bundled-plugin-metadata.generated.ts"),
|
path.join(tempRoot, "src/plugins/bundled-plugin-metadata.generated.ts"),
|
||||||
@@ -140,9 +157,11 @@ describe("bundled plugin metadata", () => {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
const stale = await writeGeneratedMetadataModule({ repoRoot: tempRoot, check: true });
|
await expectGeneratedMetadataModuleState({
|
||||||
expect(stale.changed).toBe(true);
|
repoRoot: tempRoot,
|
||||||
expect(stale.wrote).toBe(false);
|
check: true,
|
||||||
|
expected: { changed: true, wrote: false },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges generated channel schema metadata with manifest-owned channel config fields", async () => {
|
it("merges generated channel schema metadata with manifest-owned channel config fields", async () => {
|
||||||
|
|||||||
@@ -88,11 +88,17 @@ function resolveAllowedPackageNamesForId(pluginId: string): string[] {
|
|||||||
return ALLOWED_PACKAGE_SUFFIXES.map((suffix) => `@openclaw/${pluginId}${suffix}`);
|
return ALLOWED_PACKAGE_SUFFIXES.map((suffix) => `@openclaw/${pluginId}${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveBundledPluginMismatches(
|
||||||
|
collectMismatches: (records: BundledPluginRecord[]) => string[],
|
||||||
|
) {
|
||||||
|
return collectMismatches(readBundledPluginRecords());
|
||||||
|
}
|
||||||
|
|
||||||
function expectNoBundledPluginNamingMismatches(params: {
|
function expectNoBundledPluginNamingMismatches(params: {
|
||||||
message: string;
|
message: string;
|
||||||
collectMismatches: (records: BundledPluginRecord[]) => string[];
|
collectMismatches: (records: BundledPluginRecord[]) => string[];
|
||||||
}) {
|
}) {
|
||||||
const mismatches = params.collectMismatches(readBundledPluginRecords());
|
const mismatches = resolveBundledPluginMismatches(params.collectMismatches);
|
||||||
expect(mismatches, `${params.message}\nFound: ${mismatches.join(", ") || "<none>"}`).toEqual([]);
|
expect(mismatches, `${params.message}\nFound: ${mismatches.join(", ") || "<none>"}`).toEqual([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ function expectGeneratedAuthEnvVarModuleState(params: {
|
|||||||
expect(result.wrote).toBe(params.expectedWrote);
|
expect(result.wrote).toBe(params.expectedWrote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectGeneratedAuthEnvVarCheckMode(tempRoot: string) {
|
||||||
|
expectGeneratedAuthEnvVarModuleState({
|
||||||
|
tempRoot,
|
||||||
|
expectedChanged: false,
|
||||||
|
expectedWrote: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function expectBundledProviderEnvVars(expected: Record<string, readonly string[]>) {
|
function expectBundledProviderEnvVars(expected: Record<string, readonly string[]>) {
|
||||||
expect(
|
expect(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
@@ -42,6 +50,12 @@ function expectBundledProviderEnvVars(expected: Record<string, readonly string[]
|
|||||||
).toEqual(expected);
|
).toEqual(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectMissingBundledProviderEnvVars(providerIds: readonly string[]) {
|
||||||
|
providerIds.forEach((providerId) => {
|
||||||
|
expect(providerId in BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("bundled provider auth env vars", () => {
|
describe("bundled provider auth env vars", () => {
|
||||||
it("matches the generated manifest snapshot", () => {
|
it("matches the generated manifest snapshot", () => {
|
||||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toEqual(
|
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toEqual(
|
||||||
@@ -60,7 +74,7 @@ describe("bundled provider auth env vars", () => {
|
|||||||
openai: ["OPENAI_API_KEY"],
|
openai: ["OPENAI_API_KEY"],
|
||||||
fal: ["FAL_KEY"],
|
fal: ["FAL_KEY"],
|
||||||
});
|
});
|
||||||
expect("openai-codex" in BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toBe(false);
|
expectMissingBundledProviderEnvVars(["openai-codex"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports check mode for stale generated artifacts", () => {
|
it("supports check mode for stale generated artifacts", () => {
|
||||||
@@ -79,11 +93,7 @@ describe("bundled provider auth env vars", () => {
|
|||||||
});
|
});
|
||||||
expect(initial.wrote).toBe(true);
|
expect(initial.wrote).toBe(true);
|
||||||
|
|
||||||
expectGeneratedAuthEnvVarModuleState({
|
expectGeneratedAuthEnvVarCheckMode(tempRoot);
|
||||||
tempRoot,
|
|
||||||
expectedChanged: false,
|
|
||||||
expectedWrote: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(tempRoot, "src/plugins/bundled-provider-auth-env-vars.generated.ts"),
|
path.join(tempRoot, "src/plugins/bundled-provider-auth-env-vars.generated.ts"),
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ function setBundledLookupFixture() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createResolvedBundledSource(params: {
|
||||||
|
pluginId: string;
|
||||||
|
localPath: string;
|
||||||
|
npmSpec?: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
pluginId: params.pluginId,
|
||||||
|
localPath: params.localPath,
|
||||||
|
npmSpec: params.npmSpec ?? `@openclaw/${params.pluginId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function expectBundledSourceLookup(
|
function expectBundledSourceLookup(
|
||||||
lookup: Parameters<typeof findBundledPluginSource>[0]["lookup"],
|
lookup: Parameters<typeof findBundledPluginSource>[0]["lookup"],
|
||||||
expected:
|
expected:
|
||||||
@@ -88,6 +100,19 @@ function expectBundledSourceLookup(
|
|||||||
expect(resolved?.localPath).toBe(expected.localPath);
|
expect(resolved?.localPath).toBe(expected.localPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectBundledSourceLookupCase(params: {
|
||||||
|
lookup: Parameters<typeof findBundledPluginSource>[0]["lookup"];
|
||||||
|
expected:
|
||||||
|
| {
|
||||||
|
pluginId: string;
|
||||||
|
localPath: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}) {
|
||||||
|
setBundledLookupFixture();
|
||||||
|
expectBundledSourceLookup(params.lookup, params.expected);
|
||||||
|
}
|
||||||
|
|
||||||
describe("bundled plugin sources", () => {
|
describe("bundled plugin sources", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
discoverOpenClawPluginsMock.mockReset();
|
discoverOpenClawPluginsMock.mockReset();
|
||||||
@@ -122,11 +147,12 @@ describe("bundled plugin sources", () => {
|
|||||||
const map = resolveBundledPluginSources({});
|
const map = resolveBundledPluginSources({});
|
||||||
|
|
||||||
expect(Array.from(map.keys())).toEqual(["feishu", "msteams"]);
|
expect(Array.from(map.keys())).toEqual(["feishu", "msteams"]);
|
||||||
expect(map.get("feishu")).toEqual({
|
expect(map.get("feishu")).toEqual(
|
||||||
pluginId: "feishu",
|
createResolvedBundledSource({
|
||||||
localPath: "/app/extensions/feishu",
|
pluginId: "feishu",
|
||||||
npmSpec: "@openclaw/feishu",
|
localPath: "/app/extensions/feishu",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -151,8 +177,7 @@ describe("bundled plugin sources", () => {
|
|||||||
undefined,
|
undefined,
|
||||||
],
|
],
|
||||||
] as const)("%s", (_name, lookup, expected) => {
|
] as const)("%s", (_name, lookup, expected) => {
|
||||||
setBundledLookupFixture();
|
expectBundledSourceLookupCase({ lookup, expected });
|
||||||
expectBundledSourceLookup(lookup, expected);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards an explicit env to bundled discovery helpers", () => {
|
it("forwards an explicit env to bundled discovery helpers", () => {
|
||||||
@@ -184,11 +209,10 @@ describe("bundled plugin sources", () => {
|
|||||||
const bundled = new Map([
|
const bundled = new Map([
|
||||||
[
|
[
|
||||||
"feishu",
|
"feishu",
|
||||||
{
|
createResolvedBundledSource({
|
||||||
pluginId: "feishu",
|
pluginId: "feishu",
|
||||||
localPath: "/app/extensions/feishu",
|
localPath: "/app/extensions/feishu",
|
||||||
npmSpec: "@openclaw/feishu",
|
}),
|
||||||
},
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -197,11 +221,12 @@ describe("bundled plugin sources", () => {
|
|||||||
bundled,
|
bundled,
|
||||||
lookup: { kind: "pluginId", value: "feishu" },
|
lookup: { kind: "pluginId", value: "feishu" },
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual(
|
||||||
pluginId: "feishu",
|
createResolvedBundledSource({
|
||||||
localPath: "/app/extensions/feishu",
|
pluginId: "feishu",
|
||||||
npmSpec: "@openclaw/feishu",
|
localPath: "/app/extensions/feishu",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
findBundledPluginSourceInMap({
|
findBundledPluginSourceInMap({
|
||||||
bundled,
|
bundled,
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ function expectBundledWebSearchIds(actual: readonly string[], expected: readonly
|
|||||||
expect(actual).toEqual(expected);
|
expect(actual).toEqual(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectBundledWebSearchAlignment(params: {
|
||||||
|
actual: readonly string[];
|
||||||
|
expected: readonly string[];
|
||||||
|
}) {
|
||||||
|
expectBundledWebSearchIds(params.actual, params.expected);
|
||||||
|
}
|
||||||
|
|
||||||
describe("bundled web search metadata", () => {
|
describe("bundled web search metadata", () => {
|
||||||
it.each([
|
it.each([
|
||||||
[
|
[
|
||||||
@@ -40,6 +47,6 @@ describe("bundled web search metadata", () => {
|
|||||||
resolveRegistryBundledWebSearchPluginIds(),
|
resolveRegistryBundledWebSearchPluginIds(),
|
||||||
],
|
],
|
||||||
] as const)("%s", (_name, actual, expected) => {
|
] as const)("%s", (_name, actual, expected) => {
|
||||||
expectBundledWebSearchIds(actual, expected);
|
expectBundledWebSearchAlignment({ actual, expected });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ function createVoiceCommand(overrides: Partial<Parameters<typeof registerPluginC
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerVoiceCommandForTest(
|
||||||
|
overrides: Partial<Parameters<typeof registerPluginCommand>[1]> = {},
|
||||||
|
) {
|
||||||
|
return registerPluginCommand("demo-plugin", createVoiceCommand(overrides));
|
||||||
|
}
|
||||||
|
|
||||||
function resolveBindingConversationFromCommand(
|
function resolveBindingConversationFromCommand(
|
||||||
params: Parameters<typeof __testing.resolveBindingConversationFromCommand>[0],
|
params: Parameters<typeof __testing.resolveBindingConversationFromCommand>[0],
|
||||||
) {
|
) {
|
||||||
@@ -47,6 +53,50 @@ function expectCommandMatch(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectProviderCommandSpecs(
|
||||||
|
provider: Parameters<typeof getPluginCommandSpecs>[0],
|
||||||
|
expectedNames: readonly string[],
|
||||||
|
) {
|
||||||
|
expect(getPluginCommandSpecs(provider)).toEqual(
|
||||||
|
expectedNames.map((name) => ({
|
||||||
|
name,
|
||||||
|
description: "Demo command",
|
||||||
|
acceptsArgs: false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectProviderCommandSpecCases(
|
||||||
|
cases: ReadonlyArray<{
|
||||||
|
provider: Parameters<typeof getPluginCommandSpecs>[0];
|
||||||
|
expectedNames: readonly string[];
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
cases.forEach(({ provider, expectedNames }) => {
|
||||||
|
expectProviderCommandSpecs(provider, expectedNames);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectUnsupportedBindingApiResult(result: { text?: string }) {
|
||||||
|
expect(result.text).toBe(
|
||||||
|
JSON.stringify({
|
||||||
|
requested: {
|
||||||
|
status: "error",
|
||||||
|
message: "This command cannot bind the current conversation.",
|
||||||
|
},
|
||||||
|
current: null,
|
||||||
|
detached: { removed: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectBindingConversationCase(
|
||||||
|
params: Parameters<typeof resolveBindingConversationFromCommand>[0],
|
||||||
|
expected: ReturnType<typeof resolveBindingConversationFromCommand>,
|
||||||
|
) {
|
||||||
|
expect(resolveBindingConversationFromCommand(params)).toEqual(expected);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePluginRegistry(createTestRegistry([]));
|
setActivePluginRegistry(createTestRegistry([]));
|
||||||
});
|
});
|
||||||
@@ -110,40 +160,21 @@ describe("registerPluginCommand", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("supports provider-specific native command aliases", () => {
|
it("supports provider-specific native command aliases", () => {
|
||||||
const result = registerPluginCommand(
|
const result = registerVoiceCommandForTest({
|
||||||
"demo-plugin",
|
nativeNames: {
|
||||||
createVoiceCommand({
|
default: "talkvoice",
|
||||||
nativeNames: {
|
discord: "discordvoice",
|
||||||
default: "talkvoice",
|
},
|
||||||
discord: "discordvoice",
|
description: "Demo command",
|
||||||
},
|
});
|
||||||
description: "Demo command",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
expect(getPluginCommandSpecs()).toEqual([
|
expectProviderCommandSpecCases([
|
||||||
{
|
{ provider: undefined, expectedNames: ["talkvoice"] },
|
||||||
name: "talkvoice",
|
{ provider: "discord", expectedNames: ["discordvoice"] },
|
||||||
description: "Demo command",
|
{ provider: "telegram", expectedNames: ["talkvoice"] },
|
||||||
acceptsArgs: false,
|
{ provider: "slack", expectedNames: [] },
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
expect(getPluginCommandSpecs("discord")).toEqual([
|
|
||||||
{
|
|
||||||
name: "discordvoice",
|
|
||||||
description: "Demo command",
|
|
||||||
acceptsArgs: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(getPluginCommandSpecs("telegram")).toEqual([
|
|
||||||
{
|
|
||||||
name: "talkvoice",
|
|
||||||
description: "Demo command",
|
|
||||||
acceptsArgs: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(getPluginCommandSpecs("slack")).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shares plugin commands across duplicate module instances", async () => {
|
it("shares plugin commands across duplicate module instances", async () => {
|
||||||
@@ -183,17 +214,14 @@ describe("registerPluginCommand", () => {
|
|||||||
it.each(["/talkvoice now", "/discordvoice now"] as const)(
|
it.each(["/talkvoice now", "/discordvoice now"] as const)(
|
||||||
"matches provider-specific native alias %s back to the canonical command",
|
"matches provider-specific native alias %s back to the canonical command",
|
||||||
(commandBody) => {
|
(commandBody) => {
|
||||||
const result = registerPluginCommand(
|
const result = registerVoiceCommandForTest({
|
||||||
"demo-plugin",
|
nativeNames: {
|
||||||
createVoiceCommand({
|
default: "talkvoice",
|
||||||
nativeNames: {
|
discord: "discordvoice",
|
||||||
default: "talkvoice",
|
},
|
||||||
discord: "discordvoice",
|
description: "Demo command",
|
||||||
},
|
acceptsArgs: true,
|
||||||
description: "Demo command",
|
});
|
||||||
acceptsArgs: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
expectCommandMatch(commandBody, {
|
expectCommandMatch(commandBody, {
|
||||||
@@ -349,7 +377,7 @@ describe("registerPluginCommand", () => {
|
|||||||
expected: null,
|
expected: null,
|
||||||
},
|
},
|
||||||
] as const)("$name", ({ params, expected }) => {
|
] as const)("$name", ({ params, expected }) => {
|
||||||
expect(resolveBindingConversationFromCommand(params)).toEqual(expected);
|
expectBindingConversationCase(params, expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not expose binding APIs to plugin commands on unsupported channels", async () => {
|
it("does not expose binding APIs to plugin commands on unsupported channels", async () => {
|
||||||
@@ -401,15 +429,6 @@ describe("registerPluginCommand", () => {
|
|||||||
accountId: "default",
|
accountId: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.text).toBe(
|
expectUnsupportedBindingApiResult(result);
|
||||||
JSON.stringify({
|
|
||||||
requested: {
|
|
||||||
status: "error",
|
|
||||||
message: "This command cannot bind the current conversation.",
|
|
||||||
},
|
|
||||||
current: null,
|
|
||||||
detached: { removed: false },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ function expectSafeParseCases(
|
|||||||
expect(cases.map(([value]) => safeParse?.(value))).toEqual(cases.map(([, expected]) => expected));
|
expect(cases.map(([value]) => safeParse?.(value))).toEqual(cases.map(([, expected]) => expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectJsonSchema(
|
||||||
|
result: ReturnType<typeof buildPluginConfigSchema>,
|
||||||
|
expected: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
expect(result.jsonSchema).toMatchObject(expected);
|
||||||
|
}
|
||||||
|
|
||||||
describe("buildPluginConfigSchema", () => {
|
describe("buildPluginConfigSchema", () => {
|
||||||
it("builds json schema when toJSONSchema is available", () => {
|
it("builds json schema when toJSONSchema is available", () => {
|
||||||
const schema = z.strictObject({ enabled: z.boolean().default(true) });
|
const schema = z.strictObject({ enabled: z.boolean().default(true) });
|
||||||
const result = buildPluginConfigSchema(schema);
|
const result = buildPluginConfigSchema(schema);
|
||||||
expect(result.jsonSchema).toMatchObject({
|
expectJsonSchema(result, {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
properties: { enabled: { type: "boolean", default: true } },
|
properties: { enabled: { type: "boolean", default: true } },
|
||||||
@@ -51,7 +58,7 @@ describe("buildPluginConfigSchema", () => {
|
|||||||
it("falls back when toJSONSchema is missing", () => {
|
it("falls back when toJSONSchema is missing", () => {
|
||||||
const legacySchema = {} as unknown as Parameters<typeof buildPluginConfigSchema>[0];
|
const legacySchema = {} as unknown as Parameters<typeof buildPluginConfigSchema>[0];
|
||||||
const result = buildPluginConfigSchema(legacySchema);
|
const result = buildPluginConfigSchema(legacySchema);
|
||||||
expect(result.jsonSchema).toEqual({ type: "object", additionalProperties: true });
|
expectJsonSchema(result, { type: "object", additionalProperties: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses zod runtime parsing by default", () => {
|
it("uses zod runtime parsing by default", () => {
|
||||||
|
|||||||
@@ -185,6 +185,54 @@ function expectCandidatePresence(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectCandidateOrder(
|
||||||
|
candidates: Array<{ idHint: string }>,
|
||||||
|
expectedIds: readonly string[],
|
||||||
|
) {
|
||||||
|
expect(candidates.map((candidate) => candidate.idHint)).toEqual(expectedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectBundleCandidateMatch(params: {
|
||||||
|
candidates: Array<{
|
||||||
|
idHint?: string;
|
||||||
|
format?: string;
|
||||||
|
bundleFormat?: string;
|
||||||
|
source?: string;
|
||||||
|
rootDir?: string;
|
||||||
|
}>;
|
||||||
|
idHint: string;
|
||||||
|
bundleFormat: string;
|
||||||
|
source: string;
|
||||||
|
expectRootDir?: boolean;
|
||||||
|
}) {
|
||||||
|
const bundle = findCandidateById(params.candidates, params.idHint);
|
||||||
|
expect(bundle).toBeDefined();
|
||||||
|
expect(bundle).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
idHint: params.idHint,
|
||||||
|
format: "bundle",
|
||||||
|
bundleFormat: params.bundleFormat,
|
||||||
|
source: params.source,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (params.expectRootDir) {
|
||||||
|
expect(normalizePathForAssertion(bundle?.rootDir)).toBe(
|
||||||
|
normalizePathForAssertion(fs.realpathSync(params.source)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectCachedDiscoveryPair(params: {
|
||||||
|
first: ReturnType<typeof discoverWithCachedEnv>;
|
||||||
|
second: ReturnType<typeof discoverWithCachedEnv>;
|
||||||
|
assert: (
|
||||||
|
first: ReturnType<typeof discoverWithCachedEnv>,
|
||||||
|
second: ReturnType<typeof discoverWithCachedEnv>,
|
||||||
|
) => void;
|
||||||
|
}) {
|
||||||
|
params.assert(params.first, params.second);
|
||||||
|
}
|
||||||
|
|
||||||
async function expectRejectedPackageExtensionEntry(params: {
|
async function expectRejectedPackageExtensionEntry(params: {
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
setup: (stateDir: string) => boolean | void;
|
setup: (stateDir: string) => boolean | void;
|
||||||
@@ -227,10 +275,7 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
fs.writeFileSync(path.join(workspaceExt, "beta.ts"), "export default function () {}", "utf-8");
|
fs.writeFileSync(path.join(workspaceExt, "beta.ts"), "export default function () {}", "utf-8");
|
||||||
|
|
||||||
const { candidates } = await discoverWithStateDir(stateDir, { workspaceDir });
|
const { candidates } = await discoverWithStateDir(stateDir, { workspaceDir });
|
||||||
|
expectCandidateIds(candidates, { includes: ["alpha", "beta"] });
|
||||||
const ids = candidates.map((c) => c.idHint);
|
|
||||||
expect(ids).toContain("alpha");
|
|
||||||
expect(ids).toContain("beta");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves tilde workspace dirs against the provided env", () => {
|
it("resolves tilde workspace dirs against the provided env", () => {
|
||||||
@@ -249,9 +294,7 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.candidates.some((candidate) => candidate.idHint === "tilde-workspace")).toBe(
|
expectCandidatePresence(result, { present: ["tilde-workspace"] });
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores backup and disabled plugin directories in scanned roots", async () => {
|
it("ignores backup and disabled plugin directories in scanned roots", async () => {
|
||||||
@@ -276,12 +319,10 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
fs.writeFileSync(path.join(liveDir, "index.ts"), "export default function () {}", "utf-8");
|
fs.writeFileSync(path.join(liveDir, "index.ts"), "export default function () {}", "utf-8");
|
||||||
|
|
||||||
const { candidates } = await discoverWithStateDir(stateDir, {});
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
||||||
|
expectCandidateIds(candidates, {
|
||||||
const ids = candidates.map((candidate) => candidate.idHint);
|
includes: ["live"],
|
||||||
expect(ids).toContain("live");
|
excludes: ["feishu.backup-20260222", "telegram.disabled.20260222", "discord.bak"],
|
||||||
expect(ids).not.toContain("feishu.backup-20260222");
|
});
|
||||||
expect(ids).not.toContain("telegram.disabled.20260222");
|
|
||||||
expect(ids).not.toContain("discord.bak");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads package extension packs", async () => {
|
it("loads package extension packs", async () => {
|
||||||
@@ -340,8 +381,7 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { candidates } = await discoverWithStateDir(stateDir, {});
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
||||||
|
expectCandidateOrder(candidates, ["opik-openclaw"]);
|
||||||
expect(candidates.map((candidate) => candidate.idHint)).toEqual(["opik-openclaw"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -461,22 +501,14 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
const stateDir = makeTempDir();
|
const stateDir = makeTempDir();
|
||||||
const bundleDir = setup(stateDir);
|
const bundleDir = setup(stateDir);
|
||||||
const { candidates } = await discoverWithStateDir(stateDir, {});
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
||||||
const bundle = findCandidateById(candidates, idHint);
|
|
||||||
|
|
||||||
expect(bundle).toBeDefined();
|
expectBundleCandidateMatch({
|
||||||
expect(bundle).toEqual(
|
candidates,
|
||||||
expect.objectContaining({
|
idHint,
|
||||||
idHint,
|
bundleFormat,
|
||||||
format: "bundle",
|
source: bundleDir,
|
||||||
bundleFormat,
|
expectRootDir,
|
||||||
source: bundleDir,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (expectRootDir) {
|
|
||||||
expect(normalizePathForAssertion(bundle?.rootDir)).toBe(
|
|
||||||
normalizePathForAssertion(fs.realpathSync(bundleDir)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -777,7 +809,7 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
},
|
},
|
||||||
] as const)("$name", ({ setup }) => {
|
] as const)("$name", ({ setup }) => {
|
||||||
const { first, second, assert } = setup();
|
const { first, second, assert } = setup();
|
||||||
assert(first, second);
|
expectCachedDiscoveryPair({ first, second, assert });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats configured load-path order as cache-significant", () => {
|
it("treats configured load-path order as cache-significant", () => {
|
||||||
@@ -798,7 +830,7 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(first.candidates.map((candidate) => candidate.idHint)).toEqual(["alpha", "beta"]);
|
expectCandidateOrder(first.candidates, ["alpha", "beta"]);
|
||||||
expect(second.candidates.map((candidate) => candidate.idHint)).toEqual(["beta", "alpha"]);
|
expectCandidateOrder(second.candidates, ["beta", "alpha"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ async function writeRemoteMarketplaceFixture(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeLocalMarketplaceFixture(params: {
|
||||||
|
rootDir: string;
|
||||||
|
manifest: unknown;
|
||||||
|
pluginDir?: string;
|
||||||
|
}) {
|
||||||
|
if (params.pluginDir) {
|
||||||
|
await fs.mkdir(params.pluginDir, { recursive: true });
|
||||||
|
}
|
||||||
|
return writeMarketplaceManifest(params.rootDir, params.manifest);
|
||||||
|
}
|
||||||
|
|
||||||
function mockRemoteMarketplaceClone(params: { manifest: unknown; pluginDir?: string }) {
|
function mockRemoteMarketplaceClone(params: { manifest: unknown; pluginDir?: string }) {
|
||||||
runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => {
|
runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => {
|
||||||
const repoDir = argv.at(-1);
|
const repoDir = argv.at(-1);
|
||||||
@@ -72,6 +83,65 @@ async function expectRemoteMarketplaceError(params: { manifest: unknown; expecte
|
|||||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
|
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectRemoteMarketplaceInstallResult(result: unknown) {
|
||||||
|
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
|
||||||
|
["git", "clone", "--depth", "1", "https://github.com/owner/repo.git", expect.any(String)],
|
||||||
|
{ timeoutMs: 120_000 },
|
||||||
|
);
|
||||||
|
expect(installPluginFromPathMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: expect.stringMatching(/[\\/]repo[\\/]plugins[\\/]frontend-design$/),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
pluginId: "frontend-design",
|
||||||
|
marketplacePlugin: "frontend-design",
|
||||||
|
marketplaceSource: "owner/repo",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectMarketplaceManifestListing(
|
||||||
|
result: Awaited<ReturnType<typeof import("./marketplace.js").listMarketplacePlugins>>,
|
||||||
|
) {
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error("expected marketplace listing to succeed");
|
||||||
|
}
|
||||||
|
expect(result.sourceLabel.replaceAll("\\", "/")).toContain(".claude-plugin/marketplace.json");
|
||||||
|
expect(result.manifest).toEqual({
|
||||||
|
name: "Example Marketplace",
|
||||||
|
version: "1.0.0",
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "frontend-design",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Design system bundle",
|
||||||
|
source: { kind: "path", path: "./plugins/frontend-design" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectLocalMarketplaceInstallResult(params: {
|
||||||
|
result: unknown;
|
||||||
|
pluginDir: string;
|
||||||
|
marketplaceSource: string;
|
||||||
|
}) {
|
||||||
|
expect(installPluginFromPathMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: params.pluginDir,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(params.result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
pluginId: "frontend-design",
|
||||||
|
marketplacePlugin: "frontend-design",
|
||||||
|
marketplaceSource: params.marketplaceSource,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("marketplace plugins", () => {
|
describe("marketplace plugins", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
installPluginFromPathMock.mockReset();
|
installPluginFromPathMock.mockReset();
|
||||||
@@ -95,38 +165,24 @@ describe("marketplace plugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { listMarketplacePlugins } = await import("./marketplace.js");
|
const { listMarketplacePlugins } = await import("./marketplace.js");
|
||||||
const result = await listMarketplacePlugins({ marketplace: rootDir });
|
expectMarketplaceManifestListing(await listMarketplacePlugins({ marketplace: rootDir }));
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
if (!result.ok) {
|
|
||||||
throw new Error("expected marketplace listing to succeed");
|
|
||||||
}
|
|
||||||
expect(result.sourceLabel.replaceAll("\\", "/")).toContain(".claude-plugin/marketplace.json");
|
|
||||||
expect(result.manifest).toEqual({
|
|
||||||
name: "Example Marketplace",
|
|
||||||
version: "1.0.0",
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
name: "frontend-design",
|
|
||||||
version: "0.1.0",
|
|
||||||
description: "Design system bundle",
|
|
||||||
source: { kind: "path", path: "./plugins/frontend-design" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves relative plugin paths against the marketplace root", async () => {
|
it("resolves relative plugin paths against the marketplace root", async () => {
|
||||||
await withTempDir(async (rootDir) => {
|
await withTempDir(async (rootDir) => {
|
||||||
const pluginDir = path.join(rootDir, "plugins", "frontend-design");
|
const pluginDir = path.join(rootDir, "plugins", "frontend-design");
|
||||||
await fs.mkdir(pluginDir, { recursive: true });
|
const manifestPath = await writeLocalMarketplaceFixture({
|
||||||
const manifestPath = await writeMarketplaceManifest(rootDir, {
|
rootDir,
|
||||||
plugins: [
|
pluginDir,
|
||||||
{
|
manifest: {
|
||||||
name: "frontend-design",
|
plugins: [
|
||||||
source: "./plugins/frontend-design",
|
{
|
||||||
},
|
name: "frontend-design",
|
||||||
],
|
source: "./plugins/frontend-design",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
installPluginFromPathMock.mockResolvedValue({
|
installPluginFromPathMock.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -142,15 +198,9 @@ describe("marketplace plugins", () => {
|
|||||||
plugin: "frontend-design",
|
plugin: "frontend-design",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(installPluginFromPathMock).toHaveBeenCalledWith(
|
expectLocalMarketplaceInstallResult({
|
||||||
expect.objectContaining({
|
result,
|
||||||
path: pluginDir,
|
pluginDir,
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
pluginId: "frontend-design",
|
|
||||||
marketplacePlugin: "frontend-design",
|
|
||||||
marketplaceSource: path.join(rootDir, ".claude-plugin", "marketplace.json"),
|
marketplaceSource: path.join(rootDir, ".claude-plugin", "marketplace.json"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -215,22 +265,7 @@ describe("marketplace plugins", () => {
|
|||||||
plugin: "frontend-design",
|
plugin: "frontend-design",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
|
expectRemoteMarketplaceInstallResult(result);
|
||||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
|
|
||||||
["git", "clone", "--depth", "1", "https://github.com/owner/repo.git", expect.any(String)],
|
|
||||||
{ timeoutMs: 120_000 },
|
|
||||||
);
|
|
||||||
expect(installPluginFromPathMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
path: expect.stringMatching(/[\\/]repo[\\/]plugins[\\/]frontend-design$/),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
pluginId: "frontend-design",
|
|
||||||
marketplacePlugin: "frontend-design",
|
|
||||||
marketplaceSource: "owner/repo",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a structured error for archive downloads with an empty response body", async () => {
|
it("returns a structured error for archive downloads with an empty response body", async () => {
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ function expectMemoryRuntimeLoaded(autoEnabledConfig: unknown) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectMemoryAutoEnableApplied(rawConfig: unknown, autoEnabledConfig: unknown) {
|
||||||
|
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith({
|
||||||
|
config: rawConfig,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
expectMemoryRuntimeLoaded(autoEnabledConfig);
|
||||||
|
}
|
||||||
|
|
||||||
function setAutoEnabledMemoryRuntime() {
|
function setAutoEnabledMemoryRuntime() {
|
||||||
const { rawConfig, autoEnabledConfig } = createMemoryAutoEnableFixture();
|
const { rawConfig, autoEnabledConfig } = createMemoryAutoEnableFixture();
|
||||||
const runtime = createMemoryRuntimeFixture();
|
const runtime = createMemoryRuntimeFixture();
|
||||||
@@ -57,6 +65,37 @@ function setAutoEnabledMemoryRuntime() {
|
|||||||
return { rawConfig, autoEnabledConfig, runtime };
|
return { rawConfig, autoEnabledConfig, runtime };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectNoMemoryRuntimeBootstrap() {
|
||||||
|
expect(applyPluginAutoEnableMock).not.toHaveBeenCalled();
|
||||||
|
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectAutoEnabledMemoryRuntimeCase(params: {
|
||||||
|
run: (rawConfig: unknown) => Promise<unknown>;
|
||||||
|
expectedResult: unknown;
|
||||||
|
}) {
|
||||||
|
const { rawConfig, autoEnabledConfig } = setAutoEnabledMemoryRuntime();
|
||||||
|
const result = await params.run(rawConfig);
|
||||||
|
|
||||||
|
if (params.expectedResult !== undefined) {
|
||||||
|
expect(result).toEqual(params.expectedResult);
|
||||||
|
}
|
||||||
|
expectMemoryAutoEnableApplied(rawConfig, autoEnabledConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCloseMemoryRuntimeCase(params: {
|
||||||
|
config: unknown;
|
||||||
|
setup: () => { closeAllMemorySearchManagers: ReturnType<typeof vi.fn> } | undefined;
|
||||||
|
}) {
|
||||||
|
const runtime = params.setup();
|
||||||
|
await closeActiveMemorySearchManagers(params.config as never);
|
||||||
|
|
||||||
|
if (runtime) {
|
||||||
|
expect(runtime.closeAllMemorySearchManagers).toHaveBeenCalledTimes(1);
|
||||||
|
}
|
||||||
|
expectNoMemoryRuntimeBootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
describe("memory runtime auto-enable loading", () => {
|
describe("memory runtime auto-enable loading", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -94,42 +133,33 @@ describe("memory runtime auto-enable loading", () => {
|
|||||||
expectedResult: { backend: "builtin" },
|
expectedResult: { backend: "builtin" },
|
||||||
},
|
},
|
||||||
] as const)("$name", async ({ run, expectedResult }) => {
|
] as const)("$name", async ({ run, expectedResult }) => {
|
||||||
const { rawConfig, autoEnabledConfig } = setAutoEnabledMemoryRuntime();
|
await expectAutoEnabledMemoryRuntimeCase({ run, expectedResult });
|
||||||
|
|
||||||
const result = await run(rawConfig);
|
|
||||||
|
|
||||||
if (expectedResult !== undefined) {
|
|
||||||
expect(result).toEqual(expectedResult);
|
|
||||||
}
|
|
||||||
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith({
|
|
||||||
config: rawConfig,
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
expectMemoryRuntimeLoaded(autoEnabledConfig);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not bootstrap the memory runtime just to close managers", async () => {
|
it.each([
|
||||||
const rawConfig = {
|
{
|
||||||
plugins: {},
|
name: "does not bootstrap the memory runtime just to close managers",
|
||||||
channels: { memory: { enabled: true } },
|
config: {
|
||||||
};
|
plugins: {},
|
||||||
getMemoryRuntimeMock.mockReturnValue(undefined);
|
channels: { memory: { enabled: true } },
|
||||||
|
},
|
||||||
await closeActiveMemorySearchManagers(rawConfig as never);
|
setup: () => {
|
||||||
|
getMemoryRuntimeMock.mockReturnValue(undefined);
|
||||||
expect(applyPluginAutoEnableMock).not.toHaveBeenCalled();
|
return undefined;
|
||||||
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
|
},
|
||||||
});
|
},
|
||||||
|
{
|
||||||
it("closes an already-registered memory runtime without reloading plugins", async () => {
|
name: "closes an already-registered memory runtime without reloading plugins",
|
||||||
const runtime = {
|
config: {},
|
||||||
closeAllMemorySearchManagers: vi.fn(async () => {}),
|
setup: () => {
|
||||||
};
|
const runtime = {
|
||||||
getMemoryRuntimeMock.mockReturnValue(runtime);
|
closeAllMemorySearchManagers: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
await closeActiveMemorySearchManagers({} as never);
|
getMemoryRuntimeMock.mockReturnValue(runtime);
|
||||||
|
return runtime;
|
||||||
expect(runtime.closeAllMemorySearchManagers).toHaveBeenCalledTimes(1);
|
},
|
||||||
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
|
},
|
||||||
|
] as const)("$name", async ({ config, setup }) => {
|
||||||
|
await expectCloseMemoryRuntimeCase({ config, setup });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ function createMemoryStateSnapshot() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerMemoryState(params: {
|
||||||
|
promptSection?: string[];
|
||||||
|
relativePath?: string;
|
||||||
|
runtime?: ReturnType<typeof createMemoryRuntime>;
|
||||||
|
}) {
|
||||||
|
if (params.promptSection) {
|
||||||
|
registerMemoryPromptSection(() => params.promptSection ?? []);
|
||||||
|
}
|
||||||
|
if (params.relativePath) {
|
||||||
|
const relativePath = params.relativePath;
|
||||||
|
registerMemoryFlushPlanResolver(() => createMemoryFlushPlan(relativePath));
|
||||||
|
}
|
||||||
|
if (params.runtime) {
|
||||||
|
registerMemoryRuntime(params.runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("memory plugin state", () => {
|
describe("memory plugin state", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearMemoryPluginState();
|
clearMemoryPluginState();
|
||||||
@@ -114,10 +131,12 @@ describe("memory plugin state", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("restoreMemoryPluginState swaps both prompt and flush state", () => {
|
it("restoreMemoryPluginState swaps both prompt and flush state", () => {
|
||||||
registerMemoryPromptSection(() => ["first"]);
|
|
||||||
registerMemoryFlushPlanResolver(() => createMemoryFlushPlan("memory/first.md"));
|
|
||||||
const runtime = createMemoryRuntime();
|
const runtime = createMemoryRuntime();
|
||||||
registerMemoryRuntime(runtime);
|
registerMemoryState({
|
||||||
|
promptSection: ["first"],
|
||||||
|
relativePath: "memory/first.md",
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
const snapshot = createMemoryStateSnapshot();
|
const snapshot = createMemoryStateSnapshot();
|
||||||
|
|
||||||
_resetMemoryPluginState();
|
_resetMemoryPluginState();
|
||||||
@@ -130,9 +149,11 @@ describe("memory plugin state", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clearMemoryPluginState resets both registries", () => {
|
it("clearMemoryPluginState resets both registries", () => {
|
||||||
registerMemoryPromptSection(() => ["stale section"]);
|
registerMemoryState({
|
||||||
registerMemoryFlushPlanResolver(() => createMemoryFlushPlan("memory/stale.md"));
|
promptSection: ["stale section"],
|
||||||
registerMemoryRuntime(createMemoryRuntime());
|
relativePath: "memory/stale.md",
|
||||||
|
runtime: createMemoryRuntime(),
|
||||||
|
});
|
||||||
|
|
||||||
clearMemoryPluginState();
|
clearMemoryPluginState();
|
||||||
|
|
||||||
|
|||||||
@@ -31,28 +31,58 @@ function expectIssueMessageIncludes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectSuccessfulValidationValue(params: {
|
||||||
|
input: Parameters<typeof validateJsonSchemaValue>[0];
|
||||||
|
expectedValue: unknown;
|
||||||
|
}) {
|
||||||
|
const result = validateJsonSchemaValue(params.input);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value).toEqual(params.expectedValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectValidationSuccess(params: Parameters<typeof validateJsonSchemaValue>[0]) {
|
||||||
|
const result = validateJsonSchemaValue(params);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectUriValidationCase(params: {
|
||||||
|
input: Parameters<typeof validateJsonSchemaValue>[0];
|
||||||
|
ok: boolean;
|
||||||
|
expectedPath?: string;
|
||||||
|
expectedMessage?: string;
|
||||||
|
}) {
|
||||||
|
if (params.ok) {
|
||||||
|
expectValidationSuccess(params.input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = expectValidationFailure(params.input);
|
||||||
|
const issue = expectValidationIssue(result, params.expectedPath ?? "");
|
||||||
|
expect(issue?.message).toContain(params.expectedMessage ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
describe("schema validator", () => {
|
describe("schema validator", () => {
|
||||||
it("can apply JSON Schema defaults while validating", () => {
|
it("can apply JSON Schema defaults while validating", () => {
|
||||||
const res = validateJsonSchemaValue({
|
expectSuccessfulValidationValue({
|
||||||
cacheKey: "schema-validator.test.defaults",
|
input: {
|
||||||
schema: {
|
cacheKey: "schema-validator.test.defaults",
|
||||||
type: "object",
|
schema: {
|
||||||
properties: {
|
type: "object",
|
||||||
mode: {
|
properties: {
|
||||||
type: "string",
|
mode: {
|
||||||
default: "auto",
|
type: "string",
|
||||||
|
default: "auto",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
value: {},
|
||||||
|
applyDefaults: true,
|
||||||
},
|
},
|
||||||
value: {},
|
expectedValue: { mode: "auto" },
|
||||||
applyDefaults: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
if (res.ok) {
|
|
||||||
expect(res.value).toEqual({ mode: "auto" });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -275,18 +305,12 @@ describe("schema validator", () => {
|
|||||||
])(
|
])(
|
||||||
"supports uri-formatted string schemas: $title",
|
"supports uri-formatted string schemas: $title",
|
||||||
({ params, ok, expectedPath, expectedMessage }) => {
|
({ params, ok, expectedPath, expectedMessage }) => {
|
||||||
const result = validateJsonSchemaValue(params);
|
expectUriValidationCase({
|
||||||
|
input: params,
|
||||||
if (ok) {
|
ok,
|
||||||
expect(result.ok).toBe(true);
|
expectedPath,
|
||||||
return;
|
expectedMessage,
|
||||||
}
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
if (!result.ok) {
|
|
||||||
const issue = expectValidationIssue(result, expectedPath as string);
|
|
||||||
expect(issue?.message).toContain(expectedMessage);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user