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