test: dedupe plugin bundle discovery suites

This commit is contained in:
Peter Steinberger 2026-03-28 06:02:38 +00:00
parent 69b54cbb1f
commit 89bb2cf03e
18 changed files with 789 additions and 438 deletions

View File

@ -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,
});
});
});

View File

@ -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);
},
);

View File

@ -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", () => {

View File

@ -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(

View File

@ -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")!,
],
});
},
);
});

View File

@ -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);
});
});

View File

@ -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 () => {

View File

@ -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([]);
}

View File

@ -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"),

View File

@ -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,

View File

@ -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 });
});
});

View File

@ -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);
});
});

View File

@ -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", () => {

View File

@ -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"]);
});
});

View File

@ -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 () => {

View File

@ -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 });
});
});

View File

@ -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();

View File

@ -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,
});
},
);
});