mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-23 23:22:32 +00:00
test: dedupe plugin runtime utility suites
This commit is contained in:
@@ -14,6 +14,15 @@ import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
|
||||
describe("Claude bundle plugin inspect integration", () => {
|
||||
let rootDir: string;
|
||||
|
||||
function expectLoadedClaudeManifest() {
|
||||
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw new Error("expected Claude bundle manifest to load");
|
||||
}
|
||||
return result.manifest;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-"));
|
||||
|
||||
@@ -113,13 +122,7 @@ describe("Claude bundle plugin inspect integration", () => {
|
||||
});
|
||||
|
||||
it("loads the full Claude bundle manifest with all capabilities", () => {
|
||||
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const m = result.manifest;
|
||||
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");
|
||||
@@ -127,57 +130,39 @@ describe("Claude bundle plugin inspect integration", () => {
|
||||
});
|
||||
|
||||
it("resolves skills from skills, commands, and agents paths", () => {
|
||||
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.manifest.skills).toContain("skill-packs");
|
||||
expect(result.manifest.skills).toContain("extra-commands");
|
||||
const manifest = expectLoadedClaudeManifest();
|
||||
expect(manifest.skills).toContain("skill-packs");
|
||||
expect(manifest.skills).toContain("extra-commands");
|
||||
// Agent and output style dirs are merged into skills so their .md files are discoverable
|
||||
expect(result.manifest.skills).toContain("agents");
|
||||
expect(result.manifest.skills).toContain("output-styles");
|
||||
expect(manifest.skills).toContain("agents");
|
||||
expect(manifest.skills).toContain("output-styles");
|
||||
});
|
||||
|
||||
it("resolves hooks from default and declared paths", () => {
|
||||
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = expectLoadedClaudeManifest();
|
||||
// Default hooks/hooks.json path + declared custom-hooks
|
||||
expect(result.manifest.hooks).toContain("hooks/hooks.json");
|
||||
expect(result.manifest.hooks).toContain("custom-hooks");
|
||||
expect(manifest.hooks).toContain("hooks/hooks.json");
|
||||
expect(manifest.hooks).toContain("custom-hooks");
|
||||
});
|
||||
|
||||
it("detects settings files", () => {
|
||||
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.manifest.settingsFiles).toEqual(["settings.json"]);
|
||||
expect(expectLoadedClaudeManifest().settingsFiles).toEqual(["settings.json"]);
|
||||
});
|
||||
|
||||
it("detects all bundle capabilities", () => {
|
||||
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = result.manifest.capabilities;
|
||||
expect(caps).toContain("skills");
|
||||
expect(caps).toContain("commands");
|
||||
expect(caps).toContain("agents");
|
||||
expect(caps).toContain("hooks");
|
||||
expect(caps).toContain("mcpServers");
|
||||
expect(caps).toContain("lspServers");
|
||||
expect(caps).toContain("outputStyles");
|
||||
expect(caps).toContain("settings");
|
||||
const caps = expectLoadedClaudeManifest().capabilities;
|
||||
expect(caps).toEqual(
|
||||
expect.arrayContaining([
|
||||
"skills",
|
||||
"commands",
|
||||
"agents",
|
||||
"hooks",
|
||||
"mcpServers",
|
||||
"lspServers",
|
||||
"outputStyles",
|
||||
"settings",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("inspects MCP runtime support with supported and unsupported servers", () => {
|
||||
|
||||
@@ -11,17 +11,27 @@ afterEach(async () => {
|
||||
await tempHarness.cleanup();
|
||||
});
|
||||
|
||||
async function withBundleHomeEnv<T>(
|
||||
prefix: string,
|
||||
run: (params: { homeDir: string; workspaceDir: string }) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
||||
try {
|
||||
const homeDir = await tempHarness.createTempDir(`${prefix}-home-`);
|
||||
const workspaceDir = await tempHarness.createTempDir(`${prefix}-workspace-`);
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
return await run({ homeDir, workspaceDir });
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("loadEnabledClaudeBundleCommands", () => {
|
||||
it("loads enabled Claude bundle markdown commands and skips disabled-model-invocation entries", async () => {
|
||||
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
||||
try {
|
||||
const homeDir = await tempHarness.createTempDir("openclaw-bundle-commands-home-");
|
||||
const workspaceDir = await tempHarness.createTempDir("openclaw-bundle-commands-workspace-");
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
await withBundleHomeEnv("openclaw-bundle-commands", async ({ homeDir, workspaceDir }) => {
|
||||
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "compound-bundle");
|
||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
||||
await fs.mkdir(path.join(pluginRoot, "commands", "workflows"), { recursive: true });
|
||||
@@ -87,8 +97,6 @@ describe("loadEnabledClaudeBundleCommands", () => {
|
||||
]),
|
||||
);
|
||||
expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,17 +34,27 @@ afterEach(async () => {
|
||||
await tempHarness.cleanup();
|
||||
});
|
||||
|
||||
async function withBundleHomeEnv<T>(
|
||||
prefix: string,
|
||||
run: (params: { homeDir: string; workspaceDir: string }) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
||||
try {
|
||||
const homeDir = await tempHarness.createTempDir(`${prefix}-home-`);
|
||||
const workspaceDir = await tempHarness.createTempDir(`${prefix}-workspace-`);
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
return await run({ homeDir, workspaceDir });
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("loadEnabledBundleMcpConfig", () => {
|
||||
it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => {
|
||||
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
||||
try {
|
||||
const homeDir = await tempHarness.createTempDir("openclaw-bundle-mcp-home-");
|
||||
const workspaceDir = await tempHarness.createTempDir("openclaw-bundle-mcp-workspace-");
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
await withBundleHomeEnv("openclaw-bundle-mcp", async ({ homeDir, workspaceDir }) => {
|
||||
const { pluginRoot, serverPath } = await createBundleProbePlugin(homeDir);
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
@@ -76,21 +86,11 @@ describe("loadEnabledBundleMcpConfig", () => {
|
||||
normalizePathForAssertion(resolvedServerPath),
|
||||
);
|
||||
await expectResolvedPathEqual(loadedServer.cwd, resolvedPluginRoot);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("merges inline bundle MCP servers and skips disabled bundles", async () => {
|
||||
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
||||
try {
|
||||
const homeDir = await tempHarness.createTempDir("openclaw-bundle-inline-home-");
|
||||
const workspaceDir = await tempHarness.createTempDir("openclaw-bundle-inline-workspace-");
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
await withBundleHomeEnv("openclaw-bundle-inline", async ({ homeDir, workspaceDir }) => {
|
||||
const enabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-enabled");
|
||||
const disabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-disabled");
|
||||
await fs.mkdir(path.join(enabledRoot, ".claude-plugin"), { recursive: true });
|
||||
@@ -146,88 +146,77 @@ describe("loadEnabledBundleMcpConfig", () => {
|
||||
|
||||
expect(loaded.config.mcpServers.enabledProbe).toBeDefined();
|
||||
expect(loaded.config.mcpServers.disabledProbe).toBeUndefined();
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves inline Claude MCP paths from the plugin root and expands CLAUDE_PLUGIN_ROOT", async () => {
|
||||
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
||||
try {
|
||||
const homeDir = await tempHarness.createTempDir("openclaw-bundle-inline-placeholder-home-");
|
||||
const workspaceDir = await tempHarness.createTempDir(
|
||||
"openclaw-bundle-inline-placeholder-workspace-",
|
||||
);
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "inline-claude");
|
||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "inline-claude",
|
||||
mcpServers: {
|
||||
inlineProbe: {
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/server.sh",
|
||||
args: ["${CLAUDE_PLUGIN_ROOT}/servers/probe.mjs", "./local-probe.mjs"],
|
||||
cwd: "${CLAUDE_PLUGIN_ROOT}",
|
||||
env: {
|
||||
PLUGIN_ROOT: "${CLAUDE_PLUGIN_ROOT}",
|
||||
await withBundleHomeEnv(
|
||||
"openclaw-bundle-inline-placeholder",
|
||||
async ({ homeDir, workspaceDir }) => {
|
||||
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "inline-claude");
|
||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "inline-claude",
|
||||
mcpServers: {
|
||||
inlineProbe: {
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/server.sh",
|
||||
args: ["${CLAUDE_PLUGIN_ROOT}/servers/probe.mjs", "./local-probe.mjs"],
|
||||
cwd: "${CLAUDE_PLUGIN_ROOT}",
|
||||
env: {
|
||||
PLUGIN_ROOT: "${CLAUDE_PLUGIN_ROOT}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const loaded = loadEnabledBundleMcpConfig({
|
||||
workspaceDir,
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"inline-claude": { enabled: true },
|
||||
const loaded = loadEnabledBundleMcpConfig({
|
||||
workspaceDir,
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"inline-claude": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
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 : {};
|
||||
});
|
||||
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 : {};
|
||||
|
||||
expect(loaded.diagnostics).toEqual([]);
|
||||
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);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
expect(loaded.diagnostics).toEqual([]);
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,63 @@ function makeRepoRoot(prefix: string): string {
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function createOpenClawRoot(params: {
|
||||
prefix: string;
|
||||
hasExtensions?: boolean;
|
||||
hasSrc?: boolean;
|
||||
hasDistRuntimeExtensions?: boolean;
|
||||
hasDistExtensions?: boolean;
|
||||
hasGitCheckout?: boolean;
|
||||
}) {
|
||||
const repoRoot = makeRepoRoot(params.prefix);
|
||||
if (params.hasExtensions) {
|
||||
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });
|
||||
}
|
||||
if (params.hasSrc) {
|
||||
fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true });
|
||||
}
|
||||
if (params.hasDistRuntimeExtensions) {
|
||||
fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true });
|
||||
}
|
||||
if (params.hasDistExtensions) {
|
||||
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
|
||||
}
|
||||
if (params.hasGitCheckout) {
|
||||
fs.writeFileSync(path.join(repoRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf8");
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function expectResolvedBundledDir(params: {
|
||||
cwd: string;
|
||||
expectedDir: string;
|
||||
argv1?: string;
|
||||
bundledDirOverride?: string;
|
||||
vitest?: string;
|
||||
}) {
|
||||
vi.spyOn(process, "cwd").mockReturnValue(params.cwd);
|
||||
process.argv[1] = params.argv1 ?? "/usr/bin/env";
|
||||
if (params.vitest === undefined) {
|
||||
delete process.env.VITEST;
|
||||
} else {
|
||||
process.env.VITEST = params.vitest;
|
||||
}
|
||||
if (params.bundledDirOverride === undefined) {
|
||||
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = params.bundledDirOverride;
|
||||
}
|
||||
|
||||
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
|
||||
fs.realpathSync(params.expectedDir),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalBundledDir === undefined) {
|
||||
@@ -34,124 +91,94 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("resolveBundledPluginsDir", () => {
|
||||
it("prefers the staged runtime bundled plugin tree from the package root", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-bundled-dir-runtime-");
|
||||
fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
|
||||
process.argv[1] = "/usr/bin/env";
|
||||
|
||||
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
|
||||
fs.realpathSync(path.join(repoRoot, "dist-runtime", "extensions")),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to built dist/extensions in installed package roots", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-bundled-dir-dist-");
|
||||
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
|
||||
process.argv[1] = "/usr/bin/env";
|
||||
|
||||
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
|
||||
fs.realpathSync(path.join(repoRoot, "dist", "extensions")),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers source extensions under vitest to avoid stale staged plugins", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-bundled-dir-vitest-");
|
||||
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
|
||||
process.env.VITEST = "true";
|
||||
process.argv[1] = "/usr/bin/env";
|
||||
|
||||
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
|
||||
fs.realpathSync(path.join(repoRoot, "extensions")),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers source extensions in a git checkout even without vitest env", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-bundled-dir-git-");
|
||||
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
|
||||
fs.writeFileSync(path.join(repoRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
|
||||
delete process.env.VITEST;
|
||||
process.argv[1] = "/usr/bin/env";
|
||||
|
||||
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
|
||||
fs.realpathSync(path.join(repoRoot, "extensions")),
|
||||
);
|
||||
it.each([
|
||||
[
|
||||
"prefers the staged runtime bundled plugin tree from the package root",
|
||||
{
|
||||
prefix: "openclaw-bundled-dir-runtime-",
|
||||
hasDistRuntimeExtensions: true,
|
||||
hasDistExtensions: true,
|
||||
},
|
||||
{
|
||||
expectedRelativeDir: path.join("dist-runtime", "extensions"),
|
||||
},
|
||||
],
|
||||
[
|
||||
"falls back to built dist/extensions in installed package roots",
|
||||
{
|
||||
prefix: "openclaw-bundled-dir-dist-",
|
||||
hasDistExtensions: true,
|
||||
},
|
||||
{
|
||||
expectedRelativeDir: path.join("dist", "extensions"),
|
||||
},
|
||||
],
|
||||
[
|
||||
"prefers source extensions under vitest to avoid stale staged plugins",
|
||||
{
|
||||
prefix: "openclaw-bundled-dir-vitest-",
|
||||
hasExtensions: true,
|
||||
hasDistRuntimeExtensions: true,
|
||||
hasDistExtensions: true,
|
||||
},
|
||||
{
|
||||
expectedRelativeDir: "extensions",
|
||||
vitest: "true",
|
||||
},
|
||||
],
|
||||
[
|
||||
"prefers source extensions in a git checkout even without vitest env",
|
||||
{
|
||||
prefix: "openclaw-bundled-dir-git-",
|
||||
hasExtensions: true,
|
||||
hasSrc: true,
|
||||
hasDistRuntimeExtensions: true,
|
||||
hasDistExtensions: true,
|
||||
hasGitCheckout: true,
|
||||
},
|
||||
{
|
||||
expectedRelativeDir: "extensions",
|
||||
},
|
||||
],
|
||||
] as const)("%s", (_name, layout, expectation) => {
|
||||
const repoRoot = createOpenClawRoot(layout);
|
||||
expectResolvedBundledDir({
|
||||
cwd: repoRoot,
|
||||
expectedDir: path.join(repoRoot, expectation.expectedRelativeDir),
|
||||
vitest: "vitest" in expectation ? expectation.vitest : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the running CLI package root over an unrelated cwd checkout", () => {
|
||||
const installedRoot = makeRepoRoot("openclaw-bundled-dir-installed-");
|
||||
fs.mkdirSync(path.join(installedRoot, "dist", "extensions"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(installedRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const installedRoot = createOpenClawRoot({
|
||||
prefix: "openclaw-bundled-dir-installed-",
|
||||
hasDistExtensions: true,
|
||||
});
|
||||
const cwdRepoRoot = createOpenClawRoot({
|
||||
prefix: "openclaw-bundled-dir-cwd-",
|
||||
hasExtensions: true,
|
||||
hasSrc: true,
|
||||
hasGitCheckout: true,
|
||||
});
|
||||
|
||||
const cwdRepoRoot = makeRepoRoot("openclaw-bundled-dir-cwd-");
|
||||
fs.mkdirSync(path.join(cwdRepoRoot, "extensions"), { recursive: true });
|
||||
fs.mkdirSync(path.join(cwdRepoRoot, "src"), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwdRepoRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(cwdRepoRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.spyOn(process, "cwd").mockReturnValue(cwdRepoRoot);
|
||||
process.argv[1] = path.join(installedRoot, "openclaw.mjs");
|
||||
|
||||
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
|
||||
fs.realpathSync(path.join(installedRoot, "dist", "extensions")),
|
||||
);
|
||||
expectResolvedBundledDir({
|
||||
cwd: cwdRepoRoot,
|
||||
argv1: path.join(installedRoot, "openclaw.mjs"),
|
||||
expectedDir: path.join(installedRoot, "dist", "extensions"),
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the running installed package when the override path is stale", () => {
|
||||
const installedRoot = makeRepoRoot("openclaw-bundled-dir-override-");
|
||||
fs.mkdirSync(path.join(installedRoot, "dist", "extensions"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(installedRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const installedRoot = createOpenClawRoot({
|
||||
prefix: "openclaw-bundled-dir-override-",
|
||||
hasDistExtensions: true,
|
||||
});
|
||||
|
||||
process.argv[1] = path.join(installedRoot, "openclaw.mjs");
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(installedRoot, "missing-extensions");
|
||||
|
||||
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
|
||||
fs.realpathSync(path.join(installedRoot, "dist", "extensions")),
|
||||
);
|
||||
expectResolvedBundledDir({
|
||||
cwd: process.cwd(),
|
||||
argv1: path.join(installedRoot, "openclaw.mjs"),
|
||||
bundledDirOverride: path.join(installedRoot, "missing-extensions"),
|
||||
expectedDir: path.join(installedRoot, "dist", "extensions"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,66 +85,75 @@ function resolveAllowedPackageNamesForId(pluginId: string): string[] {
|
||||
return ALLOWED_PACKAGE_SUFFIXES.map((suffix) => `@openclaw/${pluginId}${suffix}`);
|
||||
}
|
||||
|
||||
function expectNoBundledPluginNamingMismatches(params: {
|
||||
message: string;
|
||||
collectMismatches: (records: BundledPluginRecord[]) => string[];
|
||||
}) {
|
||||
const mismatches = params.collectMismatches(readBundledPluginRecords());
|
||||
expect(mismatches, `${params.message}\nFound: ${mismatches.join(", ") || "<none>"}`).toEqual([]);
|
||||
}
|
||||
|
||||
describe("bundled plugin naming guardrails", () => {
|
||||
it("keeps bundled workspace package names anchored to the plugin id", () => {
|
||||
const mismatches = readBundledPluginRecords()
|
||||
.filter(
|
||||
({ packageName, manifestId }) =>
|
||||
!resolveAllowedPackageNamesForId(manifestId).includes(packageName),
|
||||
)
|
||||
.map(
|
||||
({ dirName, packageName, manifestId }) => `${dirName}: ${packageName} (id=${manifestId})`,
|
||||
);
|
||||
|
||||
expect(
|
||||
mismatches,
|
||||
`Bundled extension package names must stay anchored to the manifest id via @openclaw/<id> or an approved suffix (${ALLOWED_PACKAGE_SUFFIXES.join(", ")}). Update the plugin naming docs and this invariant before adding a new naming form.\nFound: ${mismatches.join(", ") || "<none>"}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bundled workspace directories aligned with the plugin id unless explicitly allowlisted", () => {
|
||||
const mismatches = readBundledPluginRecords()
|
||||
.filter(
|
||||
({ dirName, manifestId }) => (DIR_ID_EXCEPTIONS.get(dirName) ?? dirName) !== manifestId,
|
||||
)
|
||||
.map(({ dirName, manifestId }) => `${dirName} -> ${manifestId}`);
|
||||
|
||||
expect(
|
||||
mismatches,
|
||||
`Bundled extension directory names should match openclaw.plugin.json:id. If a legacy exception is unavoidable, add it to DIR_ID_EXCEPTIONS with a comment.\nFound: ${mismatches.join(", ") || "<none>"}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bundled openclaw.install.npmSpec aligned with the package name", () => {
|
||||
const mismatches = readBundledPluginRecords()
|
||||
.filter(
|
||||
({ installNpmSpec, packageName }) =>
|
||||
typeof installNpmSpec === "string" && installNpmSpec !== packageName,
|
||||
)
|
||||
.map(
|
||||
({ dirName, packageName, installNpmSpec }) =>
|
||||
`${dirName}: package=${packageName}, npmSpec=${installNpmSpec}`,
|
||||
);
|
||||
|
||||
expect(
|
||||
mismatches,
|
||||
`Bundled openclaw.install.npmSpec values must match the package name so install/update paths stay deterministic.\nFound: ${mismatches.join(", ") || "<none>"}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bundled channel ids aligned with the canonical plugin id", () => {
|
||||
const mismatches = readBundledPluginRecords()
|
||||
.filter(
|
||||
({ channelId, manifestId }) => typeof channelId === "string" && channelId !== manifestId,
|
||||
)
|
||||
.map(
|
||||
({ dirName, manifestId, channelId }) =>
|
||||
`${dirName}: channel=${channelId}, id=${manifestId}`,
|
||||
);
|
||||
|
||||
expect(
|
||||
mismatches,
|
||||
`Bundled openclaw.channel.id values must match openclaw.plugin.json:id for the owning plugin.\nFound: ${mismatches.join(", ") || "<none>"}`,
|
||||
).toEqual([]);
|
||||
it.each([
|
||||
{
|
||||
name: "keeps bundled workspace package names anchored to the plugin id",
|
||||
message: `Bundled extension package names must stay anchored to the manifest id via @openclaw/<id> or an approved suffix (${ALLOWED_PACKAGE_SUFFIXES.join(", ")}). Update the plugin naming docs and this invariant before adding a new naming form.`,
|
||||
collectMismatches: (records: BundledPluginRecord[]) =>
|
||||
records
|
||||
.filter(
|
||||
({ packageName, manifestId }) =>
|
||||
!resolveAllowedPackageNamesForId(manifestId).includes(packageName),
|
||||
)
|
||||
.map(
|
||||
({ dirName, packageName, manifestId }) =>
|
||||
`${dirName}: ${packageName} (id=${manifestId})`,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "keeps bundled workspace directories aligned with the plugin id unless explicitly allowlisted",
|
||||
message:
|
||||
"Bundled extension directory names should match openclaw.plugin.json:id. If a legacy exception is unavoidable, add it to DIR_ID_EXCEPTIONS with a comment.",
|
||||
collectMismatches: (records: BundledPluginRecord[]) =>
|
||||
records
|
||||
.filter(
|
||||
({ dirName, manifestId }) => (DIR_ID_EXCEPTIONS.get(dirName) ?? dirName) !== manifestId,
|
||||
)
|
||||
.map(({ dirName, manifestId }) => `${dirName} -> ${manifestId}`),
|
||||
},
|
||||
{
|
||||
name: "keeps bundled openclaw.install.npmSpec aligned with the package name",
|
||||
message:
|
||||
"Bundled openclaw.install.npmSpec values must match the package name so install/update paths stay deterministic.",
|
||||
collectMismatches: (records: BundledPluginRecord[]) =>
|
||||
records
|
||||
.filter(
|
||||
({ installNpmSpec, packageName }) =>
|
||||
typeof installNpmSpec === "string" && installNpmSpec !== packageName,
|
||||
)
|
||||
.map(
|
||||
({ dirName, packageName, installNpmSpec }) =>
|
||||
`${dirName}: package=${packageName}, npmSpec=${installNpmSpec}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "keeps bundled channel ids aligned with the canonical plugin id",
|
||||
message:
|
||||
"Bundled openclaw.channel.id values must match openclaw.plugin.json:id for the owning plugin.",
|
||||
collectMismatches: (records: BundledPluginRecord[]) =>
|
||||
records
|
||||
.filter(
|
||||
({ channelId, manifestId }) =>
|
||||
typeof channelId === "string" && channelId !== manifestId,
|
||||
)
|
||||
.map(
|
||||
({ dirName, manifestId, channelId }) =>
|
||||
`${dirName}: channel=${channelId}, id=${manifestId}`,
|
||||
),
|
||||
},
|
||||
] as const)("$name", ({ message, collectMismatches }) => {
|
||||
expectNoBundledPluginNamingMismatches({
|
||||
message,
|
||||
collectMismatches,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,31 @@ vi.mock("./manifest.js", () => ({
|
||||
loadPluginManifest: (...args: unknown[]) => loadPluginManifestMock(...args),
|
||||
}));
|
||||
|
||||
function setBundledDiscoveryCandidates(candidates: unknown[]) {
|
||||
discoverOpenClawPluginsMock.mockReturnValue({
|
||||
candidates,
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
|
||||
function expectBundledSourceLookup(
|
||||
lookup: Parameters<typeof findBundledPluginSource>[0]["lookup"],
|
||||
expected:
|
||||
| {
|
||||
pluginId: string;
|
||||
localPath: string;
|
||||
}
|
||||
| undefined,
|
||||
) {
|
||||
const resolved = findBundledPluginSource({ lookup });
|
||||
if (!expected) {
|
||||
expect(resolved).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
expect(resolved?.pluginId).toBe(expected.pluginId);
|
||||
expect(resolved?.localPath).toBe(expected.localPath);
|
||||
}
|
||||
|
||||
describe("bundled plugin sources", () => {
|
||||
beforeEach(() => {
|
||||
discoverOpenClawPluginsMock.mockReset();
|
||||
@@ -77,30 +102,50 @@ describe("bundled plugin sources", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("finds bundled source by npm spec", () => {
|
||||
discoverOpenClawPluginsMock.mockReturnValue({
|
||||
candidates: [
|
||||
{
|
||||
origin: "bundled",
|
||||
rootDir: "/app/extensions/feishu",
|
||||
packageName: "@openclaw/feishu",
|
||||
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
it.each([
|
||||
[
|
||||
"finds bundled source by npm spec",
|
||||
{ kind: "npmSpec", value: "@openclaw/feishu" } as const,
|
||||
{ pluginId: "feishu", localPath: "/app/extensions/feishu" },
|
||||
],
|
||||
[
|
||||
"returns undefined for missing npm spec",
|
||||
{ kind: "npmSpec", value: "@openclaw/not-found" } as const,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"finds bundled source by plugin id",
|
||||
{ kind: "pluginId", value: "diffs" } as const,
|
||||
{ pluginId: "diffs", localPath: "/app/extensions/diffs" },
|
||||
],
|
||||
[
|
||||
"returns undefined for missing plugin id",
|
||||
{ kind: "pluginId", value: "not-found" } as const,
|
||||
undefined,
|
||||
],
|
||||
] as const)("%s", (_name, lookup, expected) => {
|
||||
setBundledDiscoveryCandidates([
|
||||
{
|
||||
origin: "bundled",
|
||||
rootDir: "/app/extensions/feishu",
|
||||
packageName: "@openclaw/feishu",
|
||||
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
|
||||
},
|
||||
{
|
||||
origin: "bundled",
|
||||
rootDir: "/app/extensions/diffs",
|
||||
packageName: "@openclaw/diffs",
|
||||
packageManifest: { install: { npmSpec: "@openclaw/diffs" } },
|
||||
},
|
||||
]);
|
||||
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "feishu" } });
|
||||
|
||||
const resolved = findBundledPluginSource({
|
||||
lookup: { kind: "npmSpec", value: "@openclaw/feishu" },
|
||||
});
|
||||
const missing = findBundledPluginSource({
|
||||
lookup: { kind: "npmSpec", value: "@openclaw/not-found" },
|
||||
});
|
||||
|
||||
expect(resolved?.pluginId).toBe("feishu");
|
||||
expect(resolved?.localPath).toBe("/app/extensions/feishu");
|
||||
expect(missing).toBeUndefined();
|
||||
loadPluginManifestMock.mockImplementation((rootDir: string) => ({
|
||||
ok: true,
|
||||
manifest: {
|
||||
id: rootDir === "/app/extensions/diffs" ? "diffs" : "feishu",
|
||||
},
|
||||
}));
|
||||
expectBundledSourceLookup(lookup, expected);
|
||||
});
|
||||
|
||||
it("forwards an explicit env to bundled discovery helpers", () => {
|
||||
@@ -131,32 +176,6 @@ describe("bundled plugin sources", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("finds bundled source by plugin id", () => {
|
||||
discoverOpenClawPluginsMock.mockReturnValue({
|
||||
candidates: [
|
||||
{
|
||||
origin: "bundled",
|
||||
rootDir: "/app/extensions/diffs",
|
||||
packageName: "@openclaw/diffs",
|
||||
packageManifest: { install: { npmSpec: "@openclaw/diffs" } },
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "diffs" } });
|
||||
|
||||
const resolved = findBundledPluginSource({
|
||||
lookup: { kind: "pluginId", value: "diffs" },
|
||||
});
|
||||
const missing = findBundledPluginSource({
|
||||
lookup: { kind: "pluginId", value: "not-found" },
|
||||
});
|
||||
|
||||
expect(resolved?.pluginId).toBe("diffs");
|
||||
expect(resolved?.localPath).toBe("/app/extensions/diffs");
|
||||
expect(missing).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses a pre-resolved bundled map for repeated lookups", () => {
|
||||
const bundled = new Map([
|
||||
[
|
||||
|
||||
@@ -6,25 +6,36 @@ import {
|
||||
} from "./bundled-web-search.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
|
||||
function resolveManifestBundledWebSearchPluginIds() {
|
||||
return loadPluginManifestRegistry({})
|
||||
.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.origin === "bundled" && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0,
|
||||
)
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveRegistryBundledWebSearchPluginIds() {
|
||||
return listBundledWebSearchProviders()
|
||||
.map(({ pluginId }) => pluginId)
|
||||
.filter((value, index, values) => values.indexOf(value) === index)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
describe("bundled web search metadata", () => {
|
||||
it("keeps bundled web search compat ids aligned with bundled manifests", () => {
|
||||
const bundledWebSearchPluginIds = loadPluginManifestRegistry({})
|
||||
.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.origin === "bundled" && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0,
|
||||
)
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
expect(resolveBundledWebSearchPluginIds({})).toEqual(bundledWebSearchPluginIds);
|
||||
});
|
||||
|
||||
it("keeps bundled web search fast-path ids aligned with the registry", () => {
|
||||
expect([...BUNDLED_WEB_SEARCH_PLUGIN_IDS]).toEqual(
|
||||
listBundledWebSearchProviders()
|
||||
.map(({ pluginId }) => pluginId)
|
||||
.filter((value, index, values) => values.indexOf(value) === index)
|
||||
.toSorted((left, right) => left.localeCompare(right)),
|
||||
);
|
||||
it.each([
|
||||
[
|
||||
"keeps bundled web search compat ids aligned with bundled manifests",
|
||||
resolveBundledWebSearchPluginIds({}),
|
||||
resolveManifestBundledWebSearchPluginIds(),
|
||||
],
|
||||
[
|
||||
"keeps bundled web search fast-path ids aligned with the registry",
|
||||
[...BUNDLED_WEB_SEARCH_PLUGIN_IDS],
|
||||
resolveRegistryBundledWebSearchPluginIds(),
|
||||
],
|
||||
] as const)("%s", (_name, actual, expected) => {
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,38 @@ vi.mock("./bundled-compat.js", () => ({
|
||||
|
||||
let resolvePluginCapabilityProviders: typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProviders;
|
||||
|
||||
function expectBundledCompatLoadPath(params: {
|
||||
cfg: OpenClawConfig;
|
||||
allowlistCompat: { plugins: { allow: string[] } };
|
||||
enablementCompat: {
|
||||
plugins: {
|
||||
allow: string[];
|
||||
entries: { openai: { enabled: boolean } };
|
||||
};
|
||||
};
|
||||
}) {
|
||||
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith({
|
||||
config: params.cfg,
|
||||
env: process.env,
|
||||
});
|
||||
expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({
|
||||
config: params.cfg,
|
||||
pluginIds: ["openai"],
|
||||
});
|
||||
expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({
|
||||
config: params.allowlistCompat,
|
||||
pluginIds: ["openai"],
|
||||
});
|
||||
expect(mocks.withBundledPluginVitestCompat).toHaveBeenCalledWith({
|
||||
config: params.enablementCompat,
|
||||
pluginIds: ["openai"],
|
||||
env: process.env,
|
||||
});
|
||||
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith({
|
||||
config: params.enablementCompat,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolvePluginCapabilityProviders", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
@@ -116,25 +148,10 @@ describe("resolvePluginCapabilityProviders", () => {
|
||||
|
||||
resolvePluginCapabilityProviders({ key, cfg });
|
||||
|
||||
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith({
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
});
|
||||
expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({
|
||||
config: cfg,
|
||||
pluginIds: ["openai"],
|
||||
});
|
||||
expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({
|
||||
config: allowlistCompat,
|
||||
pluginIds: ["openai"],
|
||||
});
|
||||
expect(mocks.withBundledPluginVitestCompat).toHaveBeenCalledWith({
|
||||
config: enablementCompat,
|
||||
pluginIds: ["openai"],
|
||||
env: process.env,
|
||||
});
|
||||
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith({
|
||||
config: enablementCompat,
|
||||
expectBundledCompatLoadPath({
|
||||
cfg,
|
||||
allowlistCompat,
|
||||
enablementCompat,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,16 @@ vi.mock("./manifest-registry.js", () => ({
|
||||
|
||||
import { resolveGatewayStartupPluginIds } from "./channel-plugin-ids.js";
|
||||
|
||||
function expectStartupPluginIds(config: OpenClawConfig, expected: readonly string[]) {
|
||||
expect(
|
||||
resolveGatewayStartupPluginIds({
|
||||
config,
|
||||
workspaceDir: "/tmp",
|
||||
env: process.env,
|
||||
}),
|
||||
).toEqual(expected);
|
||||
}
|
||||
|
||||
describe("resolveGatewayStartupPluginIds", () => {
|
||||
beforeEach(() => {
|
||||
listPotentialConfiguredChannelIds.mockReset().mockReturnValue(["demo-channel"]);
|
||||
@@ -64,67 +74,46 @@ describe("resolveGatewayStartupPluginIds", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes configured channels, explicit bundled sidecars, and enabled non-bundled sidecars", () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"demo-bundled-sidecar": { enabled: true },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "demo-cli/demo-model" },
|
||||
models: {
|
||||
"demo-cli/demo-model": {},
|
||||
it.each([
|
||||
[
|
||||
"includes configured channels, explicit bundled sidecars, and enabled non-bundled sidecars",
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"demo-bundled-sidecar": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
resolveGatewayStartupPluginIds({
|
||||
config,
|
||||
workspaceDir: "/tmp",
|
||||
env: process.env,
|
||||
}),
|
||||
).toEqual([
|
||||
"demo-channel",
|
||||
"demo-provider-plugin",
|
||||
"demo-bundled-sidecar",
|
||||
"demo-global-sidecar",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not pull default-on bundled non-channel plugins into startup", () => {
|
||||
const config = {} as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
resolveGatewayStartupPluginIds({
|
||||
config,
|
||||
workspaceDir: "/tmp",
|
||||
env: process.env,
|
||||
}),
|
||||
).toEqual(["demo-channel", "demo-global-sidecar"]);
|
||||
});
|
||||
|
||||
it("auto-loads bundled plugins referenced by configured provider ids", () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
"demo-provider": {
|
||||
baseUrl: "https://example.com",
|
||||
models: [],
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "demo-cli/demo-model" },
|
||||
models: {
|
||||
"demo-cli/demo-model": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
resolveGatewayStartupPluginIds({
|
||||
config,
|
||||
workspaceDir: "/tmp",
|
||||
env: process.env,
|
||||
}),
|
||||
).toEqual(["demo-channel", "demo-provider-plugin", "demo-global-sidecar"]);
|
||||
} as OpenClawConfig,
|
||||
["demo-channel", "demo-provider-plugin", "demo-bundled-sidecar", "demo-global-sidecar"],
|
||||
],
|
||||
[
|
||||
"does not pull default-on bundled non-channel plugins into startup",
|
||||
{} as OpenClawConfig,
|
||||
["demo-channel", "demo-global-sidecar"],
|
||||
],
|
||||
[
|
||||
"auto-loads bundled plugins referenced by configured provider ids",
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
"demo-provider": {
|
||||
baseUrl: "https://example.com",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
["demo-channel", "demo-provider-plugin", "demo-global-sidecar"],
|
||||
],
|
||||
] as const)("%s", (_name, config, expected) => {
|
||||
expectStartupPluginIds(config, expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,14 @@ vi.mock("./loader.js", () => ({
|
||||
|
||||
import { registerPluginCliCommands } from "./cli.js";
|
||||
|
||||
function createProgram(existingCommandName?: string) {
|
||||
const program = new Command();
|
||||
if (existingCommandName) {
|
||||
program.command(existingCommandName);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("registerPluginCliCommands", () => {
|
||||
beforeEach(() => {
|
||||
mocks.memoryRegister.mockClear();
|
||||
@@ -38,8 +46,7 @@ describe("registerPluginCliCommands", () => {
|
||||
});
|
||||
|
||||
it("skips plugin CLI registrars when commands already exist", () => {
|
||||
const program = new Command();
|
||||
program.command("memory");
|
||||
const program = createProgram("memory");
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
registerPluginCliCommands(program, {} as any);
|
||||
@@ -49,7 +56,7 @@ describe("registerPluginCliCommands", () => {
|
||||
});
|
||||
|
||||
it("forwards an explicit env to plugin loading", () => {
|
||||
const program = new Command();
|
||||
const program = createProgram();
|
||||
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
|
||||
|
||||
registerPluginCliCommands(program, {} as OpenClawConfig, env);
|
||||
|
||||
@@ -3,73 +3,92 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { enablePluginInConfig } from "./enable.js";
|
||||
|
||||
describe("enablePluginInConfig", () => {
|
||||
it("enables a plugin entry", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const result = enablePluginInConfig(cfg, "google");
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.google?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("adds plugin to allowlist when allowlist is configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
allow: ["memory-core"],
|
||||
it.each([
|
||||
{
|
||||
name: "enables a plugin entry",
|
||||
cfg: {} as OpenClawConfig,
|
||||
pluginId: "google",
|
||||
expectedEnabled: true,
|
||||
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
|
||||
expect(result.config.plugins?.entries?.google?.enabled).toBe(true);
|
||||
},
|
||||
};
|
||||
const result = enablePluginInConfig(cfg, "google");
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toEqual(["memory-core", "google"]);
|
||||
});
|
||||
|
||||
it("refuses enable when plugin is denylisted", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
deny: ["google"],
|
||||
},
|
||||
};
|
||||
const result = enablePluginInConfig(cfg, "google");
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("blocked by denylist");
|
||||
});
|
||||
|
||||
it("writes built-in channels to channels.<id>.enabled and plugins.entries", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const result = enablePluginInConfig(cfg, "telegram");
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.config.channels?.telegram?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("adds built-in channel id to allowlist when allowlist is configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
allow: ["memory-core"],
|
||||
},
|
||||
};
|
||||
const result = enablePluginInConfig(cfg, "telegram");
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.config.channels?.telegram?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toEqual(["memory-core", "telegram"]);
|
||||
});
|
||||
|
||||
it("re-enables built-in channels after explicit plugin-level disable", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "adds plugin to allowlist when allowlist is configured",
|
||||
cfg: {
|
||||
plugins: {
|
||||
allow: ["memory-core"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pluginId: "google",
|
||||
expectedEnabled: true,
|
||||
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
|
||||
expect(result.config.plugins?.allow).toEqual(["memory-core", "google"]);
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
},
|
||||
{
|
||||
name: "refuses enable when plugin is denylisted",
|
||||
cfg: {
|
||||
plugins: {
|
||||
deny: ["google"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pluginId: "google",
|
||||
expectedEnabled: false,
|
||||
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
|
||||
expect(result.reason).toBe("blocked by denylist");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "writes built-in channels to channels.<id>.enabled and plugins.entries",
|
||||
cfg: {} as OpenClawConfig,
|
||||
pluginId: "telegram",
|
||||
expectedEnabled: true,
|
||||
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
|
||||
expect(result.config.channels?.telegram?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "adds built-in channel id to allowlist when allowlist is configured",
|
||||
cfg: {
|
||||
plugins: {
|
||||
allow: ["memory-core"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pluginId: "telegram",
|
||||
expectedEnabled: true,
|
||||
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
|
||||
expect(result.config.channels?.telegram?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toEqual(["memory-core", "telegram"]);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "re-enables built-in channels after explicit plugin-level disable",
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
telegram: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pluginId: "telegram",
|
||||
expectedEnabled: true,
|
||||
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
|
||||
expect(result.config.channels?.telegram?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true);
|
||||
},
|
||||
};
|
||||
const result = enablePluginInConfig(cfg, "telegram");
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.config.channels?.telegram?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true);
|
||||
},
|
||||
])("$name", ({ cfg, pluginId, expectedEnabled, assert }) => {
|
||||
const result = enablePluginInConfig(cfg, pluginId);
|
||||
expect(result.enabled).toBe(expectedEnabled);
|
||||
assert(result);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { startLazyPluginServiceModule } from "./lazy-service-module.js";
|
||||
|
||||
function createAsyncHookMock() {
|
||||
return vi.fn(async () => {});
|
||||
}
|
||||
|
||||
describe("startLazyPluginServiceModule", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.OPENCLAW_LAZY_SERVICE_SKIP;
|
||||
@@ -8,8 +12,8 @@ describe("startLazyPluginServiceModule", () => {
|
||||
});
|
||||
|
||||
it("starts the default module and returns its stop hook", async () => {
|
||||
const start = vi.fn(async () => {});
|
||||
const stop = vi.fn(async () => {});
|
||||
const start = createAsyncHookMock();
|
||||
const stop = createAsyncHookMock();
|
||||
|
||||
const handle = await startLazyPluginServiceModule({
|
||||
loadDefaultModule: async () => ({
|
||||
@@ -28,7 +32,7 @@ describe("startLazyPluginServiceModule", () => {
|
||||
|
||||
it("honors skip env before loading the module", async () => {
|
||||
process.env.OPENCLAW_LAZY_SERVICE_SKIP = "1";
|
||||
const loadDefaultModule = vi.fn(async () => ({ startDefault: vi.fn(async () => {}) }));
|
||||
const loadDefaultModule = vi.fn(async () => ({ startDefault: createAsyncHookMock() }));
|
||||
|
||||
const handle = await startLazyPluginServiceModule({
|
||||
skipEnvVar: "OPENCLAW_LAZY_SERVICE_SKIP",
|
||||
@@ -42,12 +46,12 @@ describe("startLazyPluginServiceModule", () => {
|
||||
|
||||
it("uses the override module when configured", async () => {
|
||||
process.env.OPENCLAW_LAZY_SERVICE_OVERRIDE = "virtual:service";
|
||||
const start = vi.fn(async () => {});
|
||||
const start = createAsyncHookMock();
|
||||
const loadOverrideModule = vi.fn(async () => ({ startOverride: start }));
|
||||
|
||||
await startLazyPluginServiceModule({
|
||||
overrideEnvVar: "OPENCLAW_LAZY_SERVICE_OVERRIDE",
|
||||
loadDefaultModule: async () => ({ startDefault: vi.fn(async () => {}) }),
|
||||
loadDefaultModule: async () => ({ startDefault: createAsyncHookMock() }),
|
||||
loadOverrideModule,
|
||||
startExportNames: ["startOverride", "startDefault"],
|
||||
});
|
||||
|
||||
@@ -61,39 +61,26 @@ describe("buildSingleProviderApiKeyCatalog", () => {
|
||||
|
||||
expect(result).toEqual({ provider: "z.ai", id: "glm-4.7" });
|
||||
});
|
||||
|
||||
it("returns null when api key is missing", async () => {
|
||||
const result = await buildSingleProviderApiKeyCatalog({
|
||||
ctx: createCatalogContext({}),
|
||||
providerId: "test-provider",
|
||||
buildProvider: () => createProviderConfig(),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("adds api key to the built provider", async () => {
|
||||
const result = await buildSingleProviderApiKeyCatalog({
|
||||
ctx: createCatalogContext({
|
||||
it.each([
|
||||
["returns null when api key is missing", createCatalogContext({}), undefined, null],
|
||||
[
|
||||
"adds api key to the built provider",
|
||||
createCatalogContext({
|
||||
apiKeys: { "test-provider": "secret-key" },
|
||||
}),
|
||||
providerId: "test-provider",
|
||||
buildProvider: async () => createProviderConfig(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://default.example/v1",
|
||||
models: [],
|
||||
apiKey: "secret-key",
|
||||
undefined,
|
||||
{
|
||||
provider: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://default.example/v1",
|
||||
models: [],
|
||||
apiKey: "secret-key",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit base url when allowed", async () => {
|
||||
const result = await buildSingleProviderApiKeyCatalog({
|
||||
ctx: createCatalogContext({
|
||||
],
|
||||
[
|
||||
"prefers explicit base url when allowed",
|
||||
createCatalogContext({
|
||||
apiKeys: { "test-provider": "secret-key" },
|
||||
config: {
|
||||
models: {
|
||||
@@ -106,19 +93,25 @@ describe("buildSingleProviderApiKeyCatalog", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
true,
|
||||
{
|
||||
provider: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://override.example/v1/",
|
||||
models: [],
|
||||
apiKey: "secret-key",
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const)("%s", async (_name, ctx, allowExplicitBaseUrl, expected) => {
|
||||
const result = await buildSingleProviderApiKeyCatalog({
|
||||
ctx,
|
||||
providerId: "test-provider",
|
||||
buildProvider: () => createProviderConfig(),
|
||||
allowExplicitBaseUrl: true,
|
||||
allowExplicitBaseUrl,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://override.example/v1/",
|
||||
models: [],
|
||||
apiKey: "secret-key",
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("matches explicit base url config across canonical provider aliases", async () => {
|
||||
|
||||
@@ -34,54 +34,66 @@ function makeModelProviderConfig(overrides?: Partial<ModelProviderConfig>): Mode
|
||||
}
|
||||
|
||||
describe("groupPluginDiscoveryProvidersByOrder", () => {
|
||||
it("groups providers by declared order and sorts labels within each group", () => {
|
||||
const grouped = groupPluginDiscoveryProvidersByOrder([
|
||||
makeProvider({ id: "late-b", label: "Zulu" }),
|
||||
makeProvider({ id: "late-a", label: "Alpha" }),
|
||||
makeProvider({ id: "paired", label: "Paired", order: "paired" }),
|
||||
makeProvider({ id: "profile", label: "Profile", order: "profile" }),
|
||||
makeProvider({ id: "simple", label: "Simple", order: "simple" }),
|
||||
]);
|
||||
it.each([
|
||||
{
|
||||
name: "groups providers by declared order and sorts labels within each group",
|
||||
providers: [
|
||||
makeProvider({ id: "late-b", label: "Zulu" }),
|
||||
makeProvider({ id: "late-a", label: "Alpha" }),
|
||||
makeProvider({ id: "paired", label: "Paired", order: "paired" }),
|
||||
makeProvider({ id: "profile", label: "Profile", order: "profile" }),
|
||||
makeProvider({ id: "simple", label: "Simple", order: "simple" }),
|
||||
],
|
||||
expected: {
|
||||
simple: ["simple"],
|
||||
profile: ["profile"],
|
||||
paired: ["paired"],
|
||||
late: ["late-a", "late-b"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses the legacy discovery hook when catalog is absent",
|
||||
providers: [
|
||||
makeProvider({ id: "legacy", label: "Legacy", order: "profile", mode: "discovery" }),
|
||||
],
|
||||
expected: {
|
||||
simple: [],
|
||||
profile: ["legacy"],
|
||||
paired: [],
|
||||
late: [],
|
||||
},
|
||||
},
|
||||
] as const)("$name", ({ providers, expected }) => {
|
||||
const grouped = groupPluginDiscoveryProvidersByOrder([...providers]);
|
||||
|
||||
expect(grouped.simple.map((provider) => provider.id)).toEqual(["simple"]);
|
||||
expect(grouped.profile.map((provider) => provider.id)).toEqual(["profile"]);
|
||||
expect(grouped.paired.map((provider) => provider.id)).toEqual(["paired"]);
|
||||
expect(grouped.late.map((provider) => provider.id)).toEqual(["late-a", "late-b"]);
|
||||
});
|
||||
|
||||
it("uses the legacy discovery hook when catalog is absent", () => {
|
||||
const grouped = groupPluginDiscoveryProvidersByOrder([
|
||||
makeProvider({ id: "legacy", label: "Legacy", order: "profile", mode: "discovery" }),
|
||||
]);
|
||||
|
||||
expect(grouped.profile.map((provider) => provider.id)).toEqual(["legacy"]);
|
||||
expect(grouped.simple.map((provider) => provider.id)).toEqual(expected.simple);
|
||||
expect(grouped.profile.map((provider) => provider.id)).toEqual(expected.profile);
|
||||
expect(grouped.paired.map((provider) => provider.id)).toEqual(expected.paired);
|
||||
expect(grouped.late.map((provider) => provider.id)).toEqual(expected.late);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePluginDiscoveryResult", () => {
|
||||
it("maps a single provider result to the plugin id", () => {
|
||||
const provider = makeProvider({ id: "Ollama" });
|
||||
const normalized = normalizePluginDiscoveryResult({
|
||||
provider,
|
||||
it.each([
|
||||
{
|
||||
name: "maps a single provider result to the plugin id",
|
||||
provider: makeProvider({ id: "Ollama" }),
|
||||
result: {
|
||||
provider: makeModelProviderConfig({
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized).toEqual({
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
expected: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes keys for multi-provider discovery results", () => {
|
||||
const normalized = normalizePluginDiscoveryResult({
|
||||
},
|
||||
{
|
||||
name: "normalizes keys for multi-provider discovery results",
|
||||
provider: makeProvider({ id: "ignored" }),
|
||||
result: {
|
||||
providers: {
|
||||
@@ -89,14 +101,16 @@ describe("normalizePluginDiscoveryResult", () => {
|
||||
"": makeModelProviderConfig({ baseUrl: "http://ignored" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized).toEqual({
|
||||
vllm: {
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
models: [],
|
||||
expected: {
|
||||
vllm: {
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
] as const)("$name", ({ provider, result, expected }) => {
|
||||
const normalized = normalizePluginDiscoveryResult({ provider, result });
|
||||
expect(normalized).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,12 +22,9 @@ function makeProvider(overrides: Partial<ProviderPlugin>): ProviderPlugin {
|
||||
}
|
||||
|
||||
describe("normalizeRegisteredProvider", () => {
|
||||
it("drops invalid and duplicate auth methods, and clears bad wizard method bindings", () => {
|
||||
const { diagnostics, pushDiagnostic } = collectDiagnostics();
|
||||
|
||||
const provider = normalizeRegisteredProvider({
|
||||
pluginId: "demo-plugin",
|
||||
source: "/tmp/demo/index.ts",
|
||||
it.each([
|
||||
{
|
||||
name: "drops invalid and duplicate auth methods, and clears bad wizard method bindings",
|
||||
provider: makeProvider({
|
||||
id: " demo ",
|
||||
label: " Demo Provider ",
|
||||
@@ -68,66 +65,58 @@ describe("normalizeRegisteredProvider", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
pushDiagnostic,
|
||||
});
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
id: "demo",
|
||||
label: "Demo Provider",
|
||||
aliases: ["alias-one"],
|
||||
deprecatedProfileIds: ["demo:legacy"],
|
||||
envVars: ["DEMO_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "primary",
|
||||
label: "Primary",
|
||||
wizard: {
|
||||
choiceId: "demo-primary",
|
||||
modelAllowlist: {
|
||||
allowedKeys: ["demo/model"],
|
||||
initialSelections: ["demo/model"],
|
||||
message: "Demo models",
|
||||
expectedProvider: {
|
||||
id: "demo",
|
||||
label: "Demo Provider",
|
||||
aliases: ["alias-one"],
|
||||
deprecatedProfileIds: ["demo:legacy"],
|
||||
envVars: ["DEMO_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "primary",
|
||||
label: "Primary",
|
||||
wizard: {
|
||||
choiceId: "demo-primary",
|
||||
modelAllowlist: {
|
||||
allowedKeys: ["demo/model"],
|
||||
initialSelections: ["demo/model"],
|
||||
message: "Demo models",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceId: "demo-choice",
|
||||
},
|
||||
modelPicker: {
|
||||
label: "Demo models",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedDiagnostics: [
|
||||
{
|
||||
level: "error",
|
||||
message: 'provider "demo" auth method duplicated id "primary"',
|
||||
},
|
||||
{
|
||||
level: "error",
|
||||
message: 'provider "demo" auth method missing id',
|
||||
},
|
||||
{
|
||||
level: "warn",
|
||||
message:
|
||||
'provider "demo" setup method "missing" not found; falling back to available methods',
|
||||
},
|
||||
{
|
||||
level: "warn",
|
||||
message:
|
||||
'provider "demo" model-picker method "missing" not found; falling back to available methods',
|
||||
},
|
||||
],
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceId: "demo-choice",
|
||||
},
|
||||
modelPicker: {
|
||||
label: "Demo models",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(diagnostics.map((diag) => ({ level: diag.level, message: diag.message }))).toEqual([
|
||||
{
|
||||
level: "error",
|
||||
message: 'provider "demo" auth method duplicated id "primary"',
|
||||
},
|
||||
{
|
||||
level: "error",
|
||||
message: 'provider "demo" auth method missing id',
|
||||
},
|
||||
{
|
||||
level: "warn",
|
||||
message:
|
||||
'provider "demo" setup method "missing" not found; falling back to available methods',
|
||||
},
|
||||
{
|
||||
level: "warn",
|
||||
message:
|
||||
'provider "demo" model-picker method "missing" not found; falling back to available methods',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops wizard metadata when a provider has no auth methods", () => {
|
||||
const { diagnostics, pushDiagnostic } = collectDiagnostics();
|
||||
|
||||
const provider = normalizeRegisteredProvider({
|
||||
pluginId: "demo-plugin",
|
||||
source: "/tmp/demo/index.ts",
|
||||
},
|
||||
{
|
||||
name: "drops wizard metadata when a provider has no auth methods",
|
||||
provider: makeProvider({
|
||||
wizard: {
|
||||
setup: {
|
||||
@@ -138,15 +127,39 @@ describe("normalizeRegisteredProvider", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
pushDiagnostic,
|
||||
});
|
||||
assert: (
|
||||
provider: ReturnType<typeof normalizeRegisteredProvider>,
|
||||
diagnostics: PluginDiagnostic[],
|
||||
) => {
|
||||
expect(provider?.wizard).toBeUndefined();
|
||||
expect(diagnostics.map((diag) => diag.message)).toEqual([
|
||||
'provider "demo" setup metadata ignored because it has no auth methods',
|
||||
'provider "demo" model-picker metadata ignored because it has no auth methods',
|
||||
]);
|
||||
},
|
||||
},
|
||||
] as const)(
|
||||
"$name",
|
||||
({ provider: inputProvider, expectedProvider, expectedDiagnostics, assert }) => {
|
||||
const { diagnostics, pushDiagnostic } = collectDiagnostics();
|
||||
const provider = normalizeRegisteredProvider({
|
||||
pluginId: "demo-plugin",
|
||||
source: "/tmp/demo/index.ts",
|
||||
provider: inputProvider,
|
||||
pushDiagnostic,
|
||||
});
|
||||
|
||||
expect(provider?.wizard).toBeUndefined();
|
||||
expect(diagnostics.map((diag) => diag.message)).toEqual([
|
||||
'provider "demo" setup metadata ignored because it has no auth methods',
|
||||
'provider "demo" model-picker metadata ignored because it has no auth methods',
|
||||
]);
|
||||
});
|
||||
if (assert) {
|
||||
assert(provider, diagnostics);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(provider).toMatchObject(expectedProvider);
|
||||
expect(diagnostics.map((diag) => ({ level: diag.level, message: diag.message }))).toEqual(
|
||||
expectedDiagnostics,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers catalog when a provider registers both catalog and discovery", () => {
|
||||
const { diagnostics, pushDiagnostic } = collectDiagnostics();
|
||||
|
||||
@@ -14,6 +14,16 @@ vi.mock("./manifest-registry.js", () => ({
|
||||
let resolveOwningPluginIdsForProvider: typeof import("./providers.js").resolveOwningPluginIdsForProvider;
|
||||
let resolvePluginProviders: typeof import("./providers.runtime.js").resolvePluginProviders;
|
||||
|
||||
function getLastLoadPluginsCall(): Record<string, unknown> {
|
||||
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
|
||||
expect(call).toBeDefined();
|
||||
return (call ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function cloneOptions<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
describe("resolvePluginProviders", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
@@ -56,33 +66,102 @@ describe("resolvePluginProviders", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("can augment restrictive allowlists for bundled provider compatibility", () => {
|
||||
resolvePluginProviders({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
it.each([
|
||||
{
|
||||
name: "can augment restrictive allowlists for bundled provider compatibility",
|
||||
options: {
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
},
|
||||
},
|
||||
bundledProviderAllowlistCompat: true,
|
||||
},
|
||||
bundledProviderAllowlistCompat: true,
|
||||
});
|
||||
expectedAllow: ["openrouter", "google", "kilocode", "moonshot"],
|
||||
expectedEntries: {
|
||||
google: { enabled: true },
|
||||
kilocode: { enabled: true },
|
||||
moonshot: { enabled: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "does not reintroduce the retired google auth plugin id into compat allowlists",
|
||||
options: {
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
},
|
||||
},
|
||||
bundledProviderAllowlistCompat: true,
|
||||
},
|
||||
expectedAllow: ["google"],
|
||||
unexpectedAllow: ["google-gemini-cli-auth"],
|
||||
},
|
||||
{
|
||||
name: "does not inject non-bundled provider plugin ids into compat allowlists",
|
||||
options: {
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
},
|
||||
},
|
||||
bundledProviderAllowlistCompat: true,
|
||||
},
|
||||
unexpectedAllow: ["workspace-provider"],
|
||||
},
|
||||
{
|
||||
name: "scopes bundled provider compat expansion to the requested plugin ids",
|
||||
options: {
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
},
|
||||
},
|
||||
bundledProviderAllowlistCompat: true,
|
||||
onlyPluginIds: ["moonshot"],
|
||||
},
|
||||
expectedAllow: ["openrouter", "moonshot"],
|
||||
unexpectedAllow: ["google", "kilocode"],
|
||||
expectedOnlyPluginIds: ["moonshot"],
|
||||
},
|
||||
] as const)(
|
||||
"$name",
|
||||
({ options, expectedAllow, expectedEntries, expectedOnlyPluginIds, unexpectedAllow }) => {
|
||||
resolvePluginProviders(
|
||||
cloneOptions(options) as unknown as Parameters<typeof resolvePluginProviders>[0],
|
||||
);
|
||||
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
allow: expect.arrayContaining(["openrouter", "google", "kilocode", "moonshot"]),
|
||||
entries: expect.objectContaining({
|
||||
google: { enabled: true },
|
||||
kilocode: { enabled: true },
|
||||
moonshot: { enabled: true },
|
||||
}),
|
||||
}),
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cache: false,
|
||||
activate: false,
|
||||
...(expectedOnlyPluginIds ? { onlyPluginIds: expectedOnlyPluginIds } : {}),
|
||||
}),
|
||||
cache: false,
|
||||
activate: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
);
|
||||
|
||||
const call = getLastLoadPluginsCall();
|
||||
const config = call.config as
|
||||
| {
|
||||
plugins?: {
|
||||
allow?: string[];
|
||||
entries?: Record<string, { enabled?: boolean }>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const allow = config?.plugins?.allow ?? [];
|
||||
|
||||
if (expectedAllow) {
|
||||
expect(allow).toEqual(expect.arrayContaining([...expectedAllow]));
|
||||
}
|
||||
if (expectedEntries) {
|
||||
expect(config?.plugins?.entries).toEqual(expect.objectContaining(expectedEntries));
|
||||
}
|
||||
for (const disallowedPluginId of unexpectedAllow ?? []) {
|
||||
expect(allow).not.toContain(disallowedPluginId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("can enable bundled provider plugins under Vitest when no explicit plugin config exists", () => {
|
||||
resolvePluginProviders({
|
||||
env: { VITEST: "1" } as NodeJS.ProcessEnv,
|
||||
@@ -131,67 +210,6 @@ describe("resolvePluginProviders", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reintroduce the retired google auth plugin id into compat allowlists", () => {
|
||||
resolvePluginProviders({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
},
|
||||
},
|
||||
bundledProviderAllowlistCompat: true,
|
||||
});
|
||||
|
||||
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
|
||||
const allow = call?.config?.plugins?.allow;
|
||||
|
||||
expect(allow).toContain("google");
|
||||
expect(allow).not.toContain("google-gemini-cli-auth");
|
||||
});
|
||||
|
||||
it("does not inject non-bundled provider plugin ids into compat allowlists", () => {
|
||||
resolvePluginProviders({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
},
|
||||
},
|
||||
bundledProviderAllowlistCompat: true,
|
||||
});
|
||||
|
||||
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
|
||||
const allow = call?.config?.plugins?.allow;
|
||||
|
||||
expect(allow).not.toContain("workspace-provider");
|
||||
});
|
||||
|
||||
it("scopes bundled provider compat expansion to the requested plugin ids", () => {
|
||||
resolvePluginProviders({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
},
|
||||
},
|
||||
bundledProviderAllowlistCompat: true,
|
||||
onlyPluginIds: ["moonshot"],
|
||||
});
|
||||
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onlyPluginIds: ["moonshot"],
|
||||
config: expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
allow: expect.arrayContaining(["openrouter", "moonshot"]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
|
||||
const allow = call?.config?.plugins?.allow;
|
||||
expect(allow).not.toContain("google");
|
||||
expect(allow).not.toContain("kilocode");
|
||||
});
|
||||
|
||||
it("loads only provider plugins on the provider runtime path", () => {
|
||||
resolvePluginProviders({
|
||||
bundledProviderAllowlistCompat: true,
|
||||
@@ -203,7 +221,6 @@ describe("resolvePluginProviders", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps provider ids to owning plugin ids via manifests", () => {
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [
|
||||
|
||||
@@ -23,11 +23,25 @@ describe("runtime live state guardrails", () => {
|
||||
for (const [relativePath, guard] of Object.entries(LIVE_RUNTIME_STATE_GUARDS)) {
|
||||
const source = readFileSync(resolve(repoRoot, relativePath), "utf8");
|
||||
|
||||
for (const required of guard.required) {
|
||||
expect(source, `${relativePath} missing ${required}`).toContain(required);
|
||||
}
|
||||
for (const forbidden of guard.forbidden) {
|
||||
expect(source, `${relativePath} must not contain ${forbidden}`).not.toContain(forbidden);
|
||||
const assertions = [
|
||||
...guard.required.map((needle) => ({
|
||||
type: "required" as const,
|
||||
needle,
|
||||
message: `${relativePath} missing ${needle}`,
|
||||
})),
|
||||
...guard.forbidden.map((needle) => ({
|
||||
type: "forbidden" as const,
|
||||
needle,
|
||||
message: `${relativePath} must not contain ${needle}`,
|
||||
})),
|
||||
];
|
||||
|
||||
for (const assertion of assertions) {
|
||||
if (assertion.type === "required") {
|
||||
expect(source, assertion.message).toContain(assertion.needle);
|
||||
} else {
|
||||
expect(source, assertion.message).not.toContain(assertion.needle);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
setActivePluginRegistry,
|
||||
} from "./runtime.js";
|
||||
|
||||
function createRegistryWithChannel(pluginId = "demo-channel") {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const plugin = { id: pluginId, meta: {} } as never;
|
||||
registry.channels = [{ plugin }] as never;
|
||||
return { registry, plugin };
|
||||
}
|
||||
|
||||
describe("channel registry pinning", () => {
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
@@ -24,8 +31,7 @@ describe("channel registry pinning", () => {
|
||||
});
|
||||
|
||||
it("preserves pinned channel registry across setActivePluginRegistry calls", () => {
|
||||
const startup = createEmptyPluginRegistry();
|
||||
startup.channels = [{ plugin: { id: "demo-channel" } }] as never;
|
||||
const { registry: startup } = createRegistryWithChannel();
|
||||
setActivePluginRegistry(startup);
|
||||
pinActivePluginChannelRegistry(startup);
|
||||
|
||||
@@ -38,17 +44,13 @@ describe("channel registry pinning", () => {
|
||||
});
|
||||
|
||||
it("re-pin invalidates cached channel lookups", () => {
|
||||
const setup = createEmptyPluginRegistry();
|
||||
const setupPlugin = { id: "demo-channel", meta: {} } as never;
|
||||
setup.channels = [{ plugin: setupPlugin }] as never;
|
||||
const { registry: setup, plugin: setupPlugin } = createRegistryWithChannel();
|
||||
setActivePluginRegistry(setup);
|
||||
pinActivePluginChannelRegistry(setup);
|
||||
|
||||
expect(getChannelPlugin("demo-channel")).toBe(setupPlugin);
|
||||
|
||||
const full = createEmptyPluginRegistry();
|
||||
const fullPlugin = { id: "demo-channel", meta: {} } as never;
|
||||
full.channels = [{ plugin: fullPlugin }] as never;
|
||||
const { registry: full, plugin: fullPlugin } = createRegistryWithChannel();
|
||||
setActivePluginRegistry(full);
|
||||
|
||||
expect(getChannelPlugin("demo-channel")).toBe(setupPlugin);
|
||||
@@ -62,42 +64,47 @@ describe("channel registry pinning", () => {
|
||||
expect(getChannelPlugin("demo-channel")).toBe(fullPlugin);
|
||||
});
|
||||
|
||||
it("updates channel registry on swap when not pinned", () => {
|
||||
const first = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(first);
|
||||
expect(getActivePluginChannelRegistry()).toBe(first);
|
||||
|
||||
const second = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(second);
|
||||
expect(getActivePluginChannelRegistry()).toBe(second);
|
||||
});
|
||||
|
||||
it("release restores live-tracking behavior", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "updates channel registry on swap when not pinned",
|
||||
pin: false,
|
||||
releasePinnedRegistry: false,
|
||||
expectDuringPin: false,
|
||||
expectAfterSwap: "second",
|
||||
},
|
||||
{
|
||||
name: "release restores live-tracking behavior",
|
||||
pin: true,
|
||||
releasePinnedRegistry: true,
|
||||
expectDuringPin: true,
|
||||
expectAfterSwap: "second",
|
||||
},
|
||||
{
|
||||
name: "release is a no-op when the pinned registry does not match",
|
||||
pin: true,
|
||||
releasePinnedRegistry: false,
|
||||
expectDuringPin: true,
|
||||
expectAfterSwap: "first",
|
||||
},
|
||||
] as const)("$name", ({ pin, releasePinnedRegistry, expectDuringPin, expectAfterSwap }) => {
|
||||
const startup = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(startup);
|
||||
pinActivePluginChannelRegistry(startup);
|
||||
|
||||
const replacement = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(replacement);
|
||||
expect(getActivePluginChannelRegistry()).toBe(startup);
|
||||
|
||||
releasePinnedPluginChannelRegistry(startup);
|
||||
// After release, the channel registry should follow the active registry.
|
||||
expect(getActivePluginChannelRegistry()).toBe(replacement);
|
||||
});
|
||||
|
||||
it("release is a no-op when the pinned registry does not match", () => {
|
||||
const startup = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(startup);
|
||||
pinActivePluginChannelRegistry(startup);
|
||||
|
||||
const unrelated = createEmptyPluginRegistry();
|
||||
releasePinnedPluginChannelRegistry(unrelated);
|
||||
|
||||
// Pin is still held — unrelated release was ignored.
|
||||
const replacement = createEmptyPluginRegistry();
|
||||
if (pin) {
|
||||
pinActivePluginChannelRegistry(startup);
|
||||
}
|
||||
|
||||
setActivePluginRegistry(replacement);
|
||||
expect(getActivePluginChannelRegistry()).toBe(startup);
|
||||
expect(getActivePluginChannelRegistry()).toBe(expectDuringPin ? startup : replacement);
|
||||
|
||||
if (pin) {
|
||||
releasePinnedPluginChannelRegistry(releasePinnedRegistry ? startup : unrelated);
|
||||
}
|
||||
|
||||
expect(getActivePluginChannelRegistry()).toBe(
|
||||
expectAfterSwap === "second" ? replacement : startup,
|
||||
);
|
||||
});
|
||||
|
||||
it("requireActivePluginChannelRegistry creates a registry when none exists", () => {
|
||||
|
||||
@@ -11,6 +11,19 @@ import {
|
||||
setActivePluginRegistry,
|
||||
} from "./runtime.js";
|
||||
|
||||
function createRegistryWithRoute(path: string) {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.httpRoutes.push({
|
||||
path,
|
||||
auth: "plugin",
|
||||
match: path === "/plugins/diffs" ? "prefix" : "exact",
|
||||
handler: () => true,
|
||||
pluginId: path === "/plugins/diffs" ? "diffs" : "demo",
|
||||
source: "test",
|
||||
});
|
||||
return registry;
|
||||
}
|
||||
|
||||
describe("plugin runtime route registry", () => {
|
||||
afterEach(() => {
|
||||
releasePinnedPluginHttpRouteRegistry();
|
||||
@@ -51,47 +64,25 @@ describe("plugin runtime route registry", () => {
|
||||
expect(getActivePluginHttpRouteRegistryVersion()).toBe(routeVersionBeforeRepin + 1);
|
||||
});
|
||||
|
||||
it("falls back to the provided registry when the pinned route registry has no routes", () => {
|
||||
const startupRegistry = createEmptyPluginRegistry();
|
||||
const explicitRegistry = createEmptyPluginRegistry();
|
||||
explicitRegistry.httpRoutes.push({
|
||||
path: "/demo",
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
handler: () => true,
|
||||
pluginId: "demo",
|
||||
source: "test",
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "falls back to the provided registry when the pinned route registry has no routes",
|
||||
pinnedRegistry: createEmptyPluginRegistry(),
|
||||
explicitRegistry: createRegistryWithRoute("/demo"),
|
||||
expected: "explicit",
|
||||
},
|
||||
{
|
||||
name: "prefers the pinned route registry when it already owns routes",
|
||||
pinnedRegistry: createRegistryWithRoute("/bluebubbles-webhook"),
|
||||
explicitRegistry: createRegistryWithRoute("/plugins/diffs"),
|
||||
expected: "pinned",
|
||||
},
|
||||
] as const)("$name", ({ pinnedRegistry, explicitRegistry, expected }) => {
|
||||
setActivePluginRegistry(pinnedRegistry);
|
||||
pinActivePluginHttpRouteRegistry(pinnedRegistry);
|
||||
|
||||
setActivePluginRegistry(startupRegistry);
|
||||
pinActivePluginHttpRouteRegistry(startupRegistry);
|
||||
|
||||
expect(resolveActivePluginHttpRouteRegistry(explicitRegistry)).toBe(explicitRegistry);
|
||||
});
|
||||
|
||||
it("prefers the pinned route registry when it already owns routes", () => {
|
||||
const startupRegistry = createEmptyPluginRegistry();
|
||||
const explicitRegistry = createEmptyPluginRegistry();
|
||||
startupRegistry.httpRoutes.push({
|
||||
path: "/bluebubbles-webhook",
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
handler: () => true,
|
||||
pluginId: "bluebubbles",
|
||||
source: "test",
|
||||
});
|
||||
explicitRegistry.httpRoutes.push({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
handler: () => true,
|
||||
pluginId: "diffs",
|
||||
source: "test",
|
||||
});
|
||||
|
||||
setActivePluginRegistry(startupRegistry);
|
||||
pinActivePluginHttpRouteRegistry(startupRegistry);
|
||||
|
||||
expect(resolveActivePluginHttpRouteRegistry(explicitRegistry)).toBe(startupRegistry);
|
||||
expect(resolveActivePluginHttpRouteRegistry(explicitRegistry)).toBe(
|
||||
expected === "pinned" ? pinnedRegistry : explicitRegistry,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,18 +11,22 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("gateway request scope", () => {
|
||||
async function importGatewayRequestScopeModule() {
|
||||
return await import("./gateway-request-scope.js");
|
||||
}
|
||||
|
||||
it("reuses AsyncLocalStorage across reloaded module instances", async () => {
|
||||
const first = await import("./gateway-request-scope.js");
|
||||
const first = await importGatewayRequestScopeModule();
|
||||
|
||||
await first.withPluginRuntimeGatewayRequestScope(TEST_SCOPE, async () => {
|
||||
vi.resetModules();
|
||||
const second = await import("./gateway-request-scope.js");
|
||||
const second = await importGatewayRequestScopeModule();
|
||||
expect(second.getPluginRuntimeGatewayRequestScope()).toEqual(TEST_SCOPE);
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches plugin id to the active scope", async () => {
|
||||
const runtimeScope = await import("./gateway-request-scope.js");
|
||||
const runtimeScope = await importGatewayRequestScopeModule();
|
||||
|
||||
await runtimeScope.withPluginRuntimeGatewayRequestScope(TEST_SCOPE, async () => {
|
||||
await runtimeScope.withPluginRuntimePluginIdScope("voice-call", async () => {
|
||||
|
||||
@@ -11,42 +11,52 @@ import {
|
||||
setGatewaySubagentRuntime,
|
||||
} from "./index.js";
|
||||
|
||||
function createCommandResult() {
|
||||
return {
|
||||
pid: 12345,
|
||||
stdout: "hello\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
noOutputTimedOut: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin runtime command execution", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearGatewaySubagentRuntime();
|
||||
});
|
||||
|
||||
it("exposes runtime.system.runCommandWithTimeout by default", async () => {
|
||||
const commandResult = {
|
||||
pid: 12345,
|
||||
stdout: "hello\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
noOutputTimedOut: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
const runCommandWithTimeoutMock = vi
|
||||
.spyOn(execModule, "runCommandWithTimeout")
|
||||
.mockResolvedValue(commandResult);
|
||||
it.each([
|
||||
{
|
||||
name: "exposes runtime.system.runCommandWithTimeout by default",
|
||||
mockKind: "resolve" as const,
|
||||
expected: "resolve" as const,
|
||||
},
|
||||
{
|
||||
name: "forwards runtime.system.runCommandWithTimeout errors",
|
||||
mockKind: "reject" as const,
|
||||
expected: "reject" as const,
|
||||
},
|
||||
] as const)("$name", async ({ mockKind, expected }) => {
|
||||
const commandResult = createCommandResult();
|
||||
const runCommandWithTimeoutMock = vi.spyOn(execModule, "runCommandWithTimeout");
|
||||
if (mockKind === "resolve") {
|
||||
runCommandWithTimeoutMock.mockResolvedValue(commandResult);
|
||||
} else {
|
||||
runCommandWithTimeoutMock.mockRejectedValue(new Error("boom"));
|
||||
}
|
||||
|
||||
const runtime = createPluginRuntime();
|
||||
await expect(
|
||||
runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }),
|
||||
).resolves.toEqual(commandResult);
|
||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["echo", "hello"], { timeoutMs: 1000 });
|
||||
});
|
||||
|
||||
it("forwards runtime.system.runCommandWithTimeout errors", async () => {
|
||||
const runCommandWithTimeoutMock = vi
|
||||
.spyOn(execModule, "runCommandWithTimeout")
|
||||
.mockRejectedValue(new Error("boom"));
|
||||
const runtime = createPluginRuntime();
|
||||
await expect(
|
||||
runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }),
|
||||
).rejects.toThrow("boom");
|
||||
const command = runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 });
|
||||
if (expected === "resolve") {
|
||||
await expect(command).resolves.toEqual(commandResult);
|
||||
} else {
|
||||
await expect(command).rejects.toThrow("boom");
|
||||
}
|
||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["echo", "hello"], { timeoutMs: 1000 });
|
||||
});
|
||||
|
||||
@@ -56,25 +66,56 @@ describe("plugin runtime command execution", () => {
|
||||
expect(runtime.events.onSessionTranscriptUpdate).toBe(onSessionTranscriptUpdate);
|
||||
});
|
||||
|
||||
it("exposes runtime.mediaUnderstanding helpers and keeps stt as an alias", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "exposes runtime.mediaUnderstanding helpers and keeps stt as an alias",
|
||||
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
|
||||
expect(typeof runtime.mediaUnderstanding.runFile).toBe("function");
|
||||
expect(typeof runtime.mediaUnderstanding.describeImageFile).toBe("function");
|
||||
expect(typeof runtime.mediaUnderstanding.describeImageFileWithModel).toBe("function");
|
||||
expect(typeof runtime.mediaUnderstanding.describeVideoFile).toBe("function");
|
||||
expect(runtime.mediaUnderstanding.transcribeAudioFile).toBe(
|
||||
runtime.stt.transcribeAudioFile,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exposes runtime.imageGeneration helpers",
|
||||
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
|
||||
expect(typeof runtime.imageGeneration.generate).toBe("function");
|
||||
expect(typeof runtime.imageGeneration.listProviders).toBe("function");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exposes runtime.webSearch helpers",
|
||||
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
|
||||
expect(typeof runtime.webSearch.listProviders).toBe("function");
|
||||
expect(typeof runtime.webSearch.search).toBe("function");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exposes runtime.agent host helpers",
|
||||
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
|
||||
expect(runtime.agent.defaults).toEqual({
|
||||
model: DEFAULT_MODEL,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(typeof runtime.agent.runEmbeddedPiAgent).toBe("function");
|
||||
expect(typeof runtime.agent.resolveAgentDir).toBe("function");
|
||||
expect(typeof runtime.agent.session.resolveSessionFilePath).toBe("function");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider",
|
||||
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
|
||||
expect(runtime.modelAuth).toBeDefined();
|
||||
expect(typeof runtime.modelAuth.getApiKeyForModel).toBe("function");
|
||||
expect(typeof runtime.modelAuth.resolveApiKeyForProvider).toBe("function");
|
||||
},
|
||||
},
|
||||
] as const)("$name", ({ assert }) => {
|
||||
const runtime = createPluginRuntime();
|
||||
expect(typeof runtime.mediaUnderstanding.runFile).toBe("function");
|
||||
expect(typeof runtime.mediaUnderstanding.describeImageFile).toBe("function");
|
||||
expect(typeof runtime.mediaUnderstanding.describeImageFileWithModel).toBe("function");
|
||||
expect(typeof runtime.mediaUnderstanding.describeVideoFile).toBe("function");
|
||||
expect(runtime.mediaUnderstanding.transcribeAudioFile).toBe(runtime.stt.transcribeAudioFile);
|
||||
});
|
||||
|
||||
it("exposes runtime.imageGeneration helpers", () => {
|
||||
const runtime = createPluginRuntime();
|
||||
expect(typeof runtime.imageGeneration.generate).toBe("function");
|
||||
expect(typeof runtime.imageGeneration.listProviders).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes runtime.webSearch helpers", () => {
|
||||
const runtime = createPluginRuntime();
|
||||
expect(typeof runtime.webSearch.listProviders).toBe("function");
|
||||
expect(typeof runtime.webSearch.search).toBe("function");
|
||||
assert(runtime);
|
||||
});
|
||||
|
||||
it("exposes runtime.system.requestHeartbeatNow", () => {
|
||||
@@ -82,24 +123,6 @@ describe("plugin runtime command execution", () => {
|
||||
expect(runtime.system.requestHeartbeatNow).toBe(requestHeartbeatNow);
|
||||
});
|
||||
|
||||
it("exposes runtime.agent host helpers", () => {
|
||||
const runtime = createPluginRuntime();
|
||||
expect(runtime.agent.defaults).toEqual({
|
||||
model: DEFAULT_MODEL,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(typeof runtime.agent.runEmbeddedPiAgent).toBe("function");
|
||||
expect(typeof runtime.agent.resolveAgentDir).toBe("function");
|
||||
expect(typeof runtime.agent.session.resolveSessionFilePath).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider", () => {
|
||||
const runtime = createPluginRuntime();
|
||||
expect(runtime.modelAuth).toBeDefined();
|
||||
expect(typeof runtime.modelAuth.getApiKeyForModel).toBe("function");
|
||||
expect(typeof runtime.modelAuth.resolveApiKeyForProvider).toBe("function");
|
||||
});
|
||||
|
||||
it("modelAuth wrappers strip agentDir and store to prevent credential steering", async () => {
|
||||
// The wrappers should not forward agentDir or store from plugin callers.
|
||||
// We verify this by checking the wrapper functions exist and are not the
|
||||
|
||||
Reference in New Issue
Block a user