test: dedupe plugin bundle and discovery suites

This commit is contained in:
Peter Steinberger
2026-03-28 04:25:53 +00:00
parent 8465ddc1cc
commit 04792e6c44
12 changed files with 736 additions and 621 deletions

View File

@@ -14,6 +14,71 @@ import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
describe("Claude bundle plugin inspect integration", () => {
let rootDir: string;
function writeFixtureText(relativePath: string, value: string) {
fs.mkdirSync(path.dirname(path.join(rootDir, relativePath)), { recursive: true });
fs.writeFileSync(path.join(rootDir, relativePath), value, "utf-8");
}
function writeFixtureJson(relativePath: string, value: unknown) {
writeFixtureText(relativePath, JSON.stringify(value));
}
function setupClaudeInspectFixture() {
for (const relativeDir of [
".claude-plugin",
"skill-packs/demo",
"extra-commands/cmd",
"hooks",
"custom-hooks",
"agents",
"output-styles",
]) {
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",
},
},
});
writeFixtureJson("settings.json", { thinkingLevel: "high" });
writeFixtureJson(".lsp.json", {
lspServers: {
"typescript-lsp": {
command: "typescript-language-server",
args: ["--stdio"],
},
},
});
}
function expectLoadedClaudeManifest() {
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
@@ -38,96 +103,7 @@ describe("Claude bundle plugin inspect integration", () => {
beforeAll(() => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-"));
// .claude-plugin/plugin.json
const manifestDir = path.join(rootDir, ".claude-plugin");
fs.mkdirSync(manifestDir, { recursive: true });
fs.writeFileSync(
path.join(manifestDir, "plugin.json"),
JSON.stringify({
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",
}),
"utf-8",
);
// skills/demo/SKILL.md
const skillDir = path.join(rootDir, "skill-packs", "demo");
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(
path.join(skillDir, "SKILL.md"),
"---\nname: demo\ndescription: A demo skill\n---\nDo something useful.",
"utf-8",
);
// commands/cmd/SKILL.md
const cmdDir = path.join(rootDir, "extra-commands", "cmd");
fs.mkdirSync(cmdDir, { recursive: true });
fs.writeFileSync(
path.join(cmdDir, "SKILL.md"),
"---\nname: cmd\ndescription: A command skill\n---\nRun a command.",
"utf-8",
);
// hooks/hooks.json (default hook path)
const hooksDir = path.join(rootDir, "hooks");
fs.mkdirSync(hooksDir, { recursive: true });
fs.writeFileSync(path.join(hooksDir, "hooks.json"), '{"hooks":[]}', "utf-8");
// custom-hooks/ (manifest-declared hook path)
fs.mkdirSync(path.join(rootDir, "custom-hooks"), { recursive: true });
// .mcp.json with a stdio MCP server
fs.writeFileSync(
path.join(rootDir, ".mcp.json"),
JSON.stringify({
mcpServers: {
"test-stdio-server": {
command: "echo",
args: ["hello"],
},
"test-sse-server": {
url: "http://localhost:3000/sse",
},
},
}),
"utf-8",
);
// settings.json
fs.writeFileSync(
path.join(rootDir, "settings.json"),
JSON.stringify({ thinkingLevel: "high" }),
"utf-8",
);
// agents/ directory
fs.mkdirSync(path.join(rootDir, "agents"), { recursive: true });
// .lsp.json with a stdio LSP server
fs.writeFileSync(
path.join(rootDir, ".lsp.json"),
JSON.stringify({
lspServers: {
"typescript-lsp": {
command: "typescript-language-server",
args: ["--stdio"],
},
},
}),
"utf-8",
);
// output-styles/ directory
fs.mkdirSync(path.join(rootDir, "output-styles"), { recursive: true });
setupClaudeInspectFixture();
});
afterAll(() => {

View File

@@ -1,9 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { loadEnabledClaudeBundleCommands } from "./bundle-commands.js";
import { createBundleMcpTempHarness } from "./bundle-mcp.test-support.js";
import { createBundleMcpTempHarness, withBundleHomeEnv } from "./bundle-mcp.test-support.js";
const tempHarness = createBundleMcpTempHarness();
@@ -11,24 +10,6 @@ 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();
}
}
async function writeClaudeBundleCommandFixture(params: {
homeDir: string;
pluginId: string;
@@ -57,65 +38,69 @@ async function writeClaudeBundleCommandFixture(params: {
describe("loadEnabledClaudeBundleCommands", () => {
it("loads enabled Claude bundle markdown commands and skips disabled-model-invocation entries", async () => {
await withBundleHomeEnv("openclaw-bundle-commands", async ({ homeDir, workspaceDir }) => {
await writeClaudeBundleCommandFixture({
homeDir,
pluginId: "compound-bundle",
commands: [
{
relativePath: "commands/office-hours.md",
contents: [
"---",
"description: Help with scoping and architecture",
"---",
"Give direct engineering advice.",
],
},
{
relativePath: "commands/workflows/review.md",
contents: [
"---",
"name: workflows:review",
"description: Run a structured review",
"---",
"Review the code. $ARGUMENTS",
],
},
{
relativePath: "commands/disabled.md",
contents: ["---", "disable-model-invocation: true", "---", "Do not load me."],
},
],
});
await withBundleHomeEnv(
tempHarness,
"openclaw-bundle-commands",
async ({ homeDir, workspaceDir }) => {
await writeClaudeBundleCommandFixture({
homeDir,
pluginId: "compound-bundle",
commands: [
{
relativePath: "commands/office-hours.md",
contents: [
"---",
"description: Help with scoping and architecture",
"---",
"Give direct engineering advice.",
],
},
{
relativePath: "commands/workflows/review.md",
contents: [
"---",
"name: workflows:review",
"description: Run a structured review",
"---",
"Review the code. $ARGUMENTS",
],
},
{
relativePath: "commands/disabled.md",
contents: ["---", "disable-model-invocation: true", "---", "Do not load me."],
},
],
});
const commands = loadEnabledClaudeBundleCommands({
workspaceDir,
cfg: {
plugins: {
entries: {
"compound-bundle": { enabled: true },
const commands = loadEnabledClaudeBundleCommands({
workspaceDir,
cfg: {
plugins: {
entries: {
"compound-bundle": { enabled: true },
},
},
},
},
});
});
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",
}),
]),
);
expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false);
});
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",
}),
]),
);
expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false);
},
);
});
});

View File

@@ -45,30 +45,68 @@ function writeJsonFile(rootDir: string, relativePath: string, value: unknown) {
fs.writeFileSync(path.join(rootDir, relativePath), 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 setupBundleFixture(params: {
rootDir: string;
dirs?: readonly string[];
jsonFiles?: Readonly<Record<string, unknown>>;
textFiles?: Readonly<Record<string, string>>;
manifestRelativePath?: string;
manifest?: Record<string, unknown>;
}) {
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);
}
if (params.manifestRelativePath && params.manifest) {
writeBundleManifest(params.rootDir, params.manifestRelativePath, params.manifest);
}
}
function setupClaudeHookFixture(
rootDir: string,
kind: "default-hooks" | "custom-hooks" | "no-hooks",
) {
mkdirSafe(path.join(rootDir, ".claude-plugin"));
if (kind === "default-hooks") {
mkdirSafe(path.join(rootDir, "hooks"));
writeJsonFile(rootDir, "hooks/hooks.json", { hooks: [] });
writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, {
name: "Hook Plugin",
description: "Claude hooks fixture",
setupBundleFixture({
rootDir,
dirs: [".claude-plugin", "hooks"],
jsonFiles: { "hooks/hooks.json": { hooks: [] } },
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
manifest: {
name: "Hook Plugin",
description: "Claude hooks fixture",
},
});
return;
}
if (kind === "custom-hooks") {
mkdirSafe(path.join(rootDir, "custom-hooks"));
writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, {
name: "Custom Hook Plugin",
hooks: "custom-hooks",
setupBundleFixture({
rootDir,
dirs: [".claude-plugin", "custom-hooks"],
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
manifest: {
name: "Custom Hook Plugin",
hooks: "custom-hooks",
},
});
return;
}
mkdirSafe(path.join(rootDir, "skills"));
writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, { name: "No Hooks" });
setupBundleFixture({
rootDir,
dirs: [".claude-plugin", "skills"],
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
manifest: { name: "No Hooks" },
});
}
afterEach(() => {
@@ -81,23 +119,25 @@ describe("bundle manifest parsing", () => {
name: "detects and loads Codex bundle manifests",
bundleFormat: "codex" as const,
setup: (rootDir: string) => {
mkdirSafe(path.join(rootDir, ".codex-plugin"));
mkdirSafe(path.join(rootDir, "skills"));
mkdirSafe(path.join(rootDir, "hooks"));
writeBundleManifest(rootDir, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, {
name: "Sample Bundle",
description: "Codex fixture",
skills: "skills",
hooks: "hooks",
mcpServers: {
sample: {
command: "node",
args: ["server.js"],
setupBundleFixture({
rootDir,
dirs: [".codex-plugin", "skills", "hooks"],
manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
manifest: {
name: "Sample Bundle",
description: "Codex fixture",
skills: "skills",
hooks: "hooks",
mcpServers: {
sample: {
command: "node",
args: ["server.js"],
},
},
},
apps: {
sample: {
title: "Sample App",
apps: {
sample: {
title: "Sample App",
},
},
},
});
@@ -116,35 +156,35 @@ describe("bundle manifest parsing", () => {
name: "detects and loads Claude bundle manifests from the component layout",
bundleFormat: "claude" as const,
setup: (rootDir: string) => {
for (const relativeDir of [
".claude-plugin",
"skill-packs/starter",
"commands-pack",
"agents-pack",
"hooks-pack",
"mcp",
"lsp",
"styles",
"hooks",
]) {
mkdirSafe(path.join(rootDir, relativeDir));
}
fs.writeFileSync(path.join(rootDir, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8");
fs.writeFileSync(
path.join(rootDir, "settings.json"),
'{"hideThinkingBlock":true}',
"utf-8",
);
writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, {
name: "Claude Sample",
description: "Claude fixture",
skills: ["skill-packs/starter"],
commands: "commands-pack",
agents: "agents-pack",
hooks: "hooks-pack",
mcpServers: "mcp",
lspServers: "lsp",
outputStyles: "styles",
setupBundleFixture({
rootDir,
dirs: [
".claude-plugin",
"skill-packs/starter",
"commands-pack",
"agents-pack",
"hooks-pack",
"mcp",
"lsp",
"styles",
"hooks",
],
textFiles: {
"hooks/hooks.json": '{"hooks":[]}',
"settings.json": '{"hideThinkingBlock":true}',
},
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
manifest: {
name: "Claude Sample",
description: "Claude fixture",
skills: ["skill-packs/starter"],
commands: "commands-pack",
agents: "agents-pack",
hooks: "hooks-pack",
mcpServers: "mcp",
lspServers: "lsp",
outputStyles: "styles",
},
});
},
expected: {
@@ -171,22 +211,20 @@ describe("bundle manifest parsing", () => {
name: "detects and loads Cursor bundle manifests",
bundleFormat: "cursor" as const,
setup: (rootDir: string) => {
for (const relativeDir of [
".cursor-plugin",
"skills",
".cursor/commands",
".cursor/rules",
".cursor/agents",
]) {
mkdirSafe(path.join(rootDir, relativeDir));
}
fs.writeFileSync(path.join(rootDir, ".cursor", "hooks.json"), '{"hooks":[]}', "utf-8");
writeBundleManifest(rootDir, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, {
name: "Cursor Sample",
description: "Cursor fixture",
mcpServers: "./.mcp.json",
setupBundleFixture({
rootDir,
dirs: [".cursor-plugin", "skills", ".cursor/commands", ".cursor/rules", ".cursor/agents"],
textFiles: {
".cursor/hooks.json": '{"hooks":[]}',
".mcp.json": '{"servers":{}}',
},
manifestRelativePath: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
manifest: {
name: "Cursor Sample",
description: "Cursor fixture",
mcpServers: "./.mcp.json",
},
});
fs.writeFileSync(path.join(rootDir, ".mcp.json"), '{"servers":{}}', "utf-8");
},
expected: {
id: "cursor-sample",
@@ -209,13 +247,13 @@ describe("bundle manifest parsing", () => {
name: "detects manifestless Claude bundles from the default layout",
bundleFormat: "claude" as const,
setup: (rootDir: string) => {
mkdirSafe(path.join(rootDir, "commands"));
mkdirSafe(path.join(rootDir, "skills"));
fs.writeFileSync(
path.join(rootDir, "settings.json"),
'{"hideThinkingBlock":true}',
"utf-8",
);
setupBundleFixture({
rootDir,
dirs: ["commands", "skills"],
textFiles: {
"settings.json": '{"hideThinkingBlock":true}',
},
});
},
expected: (rootDir: string) => ({
id: path.basename(rootDir).toLowerCase(),
@@ -263,8 +301,11 @@ describe("bundle manifest parsing", () => {
it("does not misclassify native index plugins as manifestless Claude bundles", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, "commands"));
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {}", "utf-8");
setupBundleFixture({
rootDir,
dirs: ["commands"],
textFiles: { "index.ts": "export default {}" },
});
expect(detectBundleManifestFormat(rootDir)).toBeNull();
});

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { captureEnv } from "../test-utils/env.js";
import { clearPluginDiscoveryCache } from "./discovery.js";
import { clearPluginManifestRegistryCache } from "./manifest-registry.js";
@@ -54,3 +55,22 @@ export async function createBundleProbePlugin(homeDir: string) {
);
return { pluginRoot, serverPath };
}
export async function withBundleHomeEnv<T>(
tempHarness: { createTempDir: (prefix: string) => Promise<string> },
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();
}
}

View File

@@ -2,10 +2,13 @@ import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { captureEnv } from "../test-utils/env.js";
import { isRecord } from "../utils.js";
import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js";
import { createBundleMcpTempHarness, createBundleProbePlugin } from "./bundle-mcp.test-support.js";
import {
createBundleMcpTempHarness,
createBundleProbePlugin,
withBundleHomeEnv,
} from "./bundle-mcp.test-support.js";
function getServerArgs(value: unknown): unknown[] | undefined {
return isRecord(value) && Array.isArray(value.args) ? value.args : undefined;
@@ -38,24 +41,6 @@ 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();
}
}
function createEnabledBundleConfig(pluginIds: string[]): OpenClawConfig {
return {
plugins: {
@@ -81,89 +66,98 @@ async function writeInlineClaudeBundleManifest(params: {
describe("loadEnabledBundleMcpConfig", () => {
it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => {
await withBundleHomeEnv("openclaw-bundle-mcp", async ({ homeDir, workspaceDir }) => {
const { pluginRoot, serverPath } = await createBundleProbePlugin(homeDir);
await withBundleHomeEnv(
tempHarness,
"openclaw-bundle-mcp",
async ({ homeDir, workspaceDir }) => {
const { pluginRoot, serverPath } = await createBundleProbePlugin(homeDir);
const config: OpenClawConfig = {
plugins: {
entries: {
"bundle-probe": { enabled: true },
const config: OpenClawConfig = {
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
},
};
};
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: config,
});
const resolvedServerPath = await fs.realpath(serverPath);
const loadedServer = loaded.config.mcpServers.bundleProbe;
const loadedArgs = getServerArgs(loadedServer);
const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined;
const resolvedPluginRoot = await fs.realpath(pluginRoot);
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: config,
});
const resolvedServerPath = await fs.realpath(serverPath);
const loadedServer = loaded.config.mcpServers.bundleProbe;
const loadedArgs = getServerArgs(loadedServer);
const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined;
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expectNoDiagnostics(loaded.diagnostics);
expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node");
expect(loadedArgs).toHaveLength(1);
expect(loadedServerPath).toBeDefined();
if (!loadedServerPath) {
throw new Error("expected bundled MCP args to include the server path");
}
expect(normalizePathForAssertion(await fs.realpath(loadedServerPath))).toBe(
normalizePathForAssertion(resolvedServerPath),
);
await expectResolvedPathEqual(loadedServer.cwd, resolvedPluginRoot);
});
expectNoDiagnostics(loaded.diagnostics);
expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node");
expect(loadedArgs).toHaveLength(1);
expect(loadedServerPath).toBeDefined();
if (!loadedServerPath) {
throw new Error("expected bundled MCP args to include the server path");
}
expect(normalizePathForAssertion(await fs.realpath(loadedServerPath))).toBe(
normalizePathForAssertion(resolvedServerPath),
);
await expectResolvedPathEqual(loadedServer.cwd, resolvedPluginRoot);
},
);
});
it("merges inline bundle MCP servers and skips disabled bundles", async () => {
await withBundleHomeEnv("openclaw-bundle-inline", async ({ homeDir, workspaceDir }) => {
await writeInlineClaudeBundleManifest({
homeDir,
pluginId: "inline-enabled",
manifest: {
name: "inline-enabled",
mcpServers: {
enabledProbe: {
command: "node",
args: ["./enabled.mjs"],
await withBundleHomeEnv(
tempHarness,
"openclaw-bundle-inline",
async ({ homeDir, workspaceDir }) => {
await writeInlineClaudeBundleManifest({
homeDir,
pluginId: "inline-enabled",
manifest: {
name: "inline-enabled",
mcpServers: {
enabledProbe: {
command: "node",
args: ["./enabled.mjs"],
},
},
},
},
});
await writeInlineClaudeBundleManifest({
homeDir,
pluginId: "inline-disabled",
manifest: {
name: "inline-disabled",
mcpServers: {
disabledProbe: {
command: "node",
args: ["./disabled.mjs"],
});
await writeInlineClaudeBundleManifest({
homeDir,
pluginId: "inline-disabled",
manifest: {
name: "inline-disabled",
mcpServers: {
disabledProbe: {
command: "node",
args: ["./disabled.mjs"],
},
},
},
},
});
});
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: {
plugins: {
entries: {
...createEnabledBundleConfig(["inline-enabled"]).plugins?.entries,
"inline-disabled": { enabled: false },
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: {
plugins: {
entries: {
...createEnabledBundleConfig(["inline-enabled"]).plugins?.entries,
"inline-disabled": { enabled: false },
},
},
},
},
});
});
expect(loaded.config.mcpServers.enabledProbe).toBeDefined();
expect(loaded.config.mcpServers.disabledProbe).toBeUndefined();
});
expect(loaded.config.mcpServers.enabledProbe).toBeDefined();
expect(loaded.config.mcpServers.disabledProbe).toBeUndefined();
},
);
});
it("resolves inline Claude MCP paths from the plugin root and expands CLAUDE_PLUGIN_ROOT", async () => {
await withBundleHomeEnv(
tempHarness,
"openclaw-bundle-inline-placeholder",
async ({ homeDir, workspaceDir }) => {
const pluginRoot = await writeInlineClaudeBundleManifest({

View File

@@ -72,6 +72,23 @@ function expectResolvedBundledDir(params: {
);
}
function expectResolvedBundledDirFromRoot(params: {
repoRoot: string;
expectedRelativeDir: string;
argv1?: string;
bundledDirOverride?: string;
vitest?: string;
cwd?: string;
}) {
expectResolvedBundledDir({
cwd: params.cwd ?? params.repoRoot,
expectedDir: path.join(params.repoRoot, params.expectedRelativeDir),
...(params.argv1 ? { argv1: params.argv1 } : {}),
...(params.bundledDirOverride ? { bundledDirOverride: params.bundledDirOverride } : {}),
...(params.vitest !== undefined ? { vitest: params.vitest } : {}),
});
}
afterEach(() => {
vi.restoreAllMocks();
if (originalBundledDir === undefined) {
@@ -142,10 +159,10 @@ describe("resolveBundledPluginsDir", () => {
],
] 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,
expectResolvedBundledDirFromRoot({
repoRoot,
expectedRelativeDir: expectation.expectedRelativeDir,
...("vitest" in expectation ? { vitest: expectation.vitest } : {}),
});
});
@@ -161,10 +178,11 @@ describe("resolveBundledPluginsDir", () => {
hasGitCheckout: true,
});
expectResolvedBundledDir({
expectResolvedBundledDirFromRoot({
repoRoot: installedRoot,
cwd: cwdRepoRoot,
argv1: path.join(installedRoot, "openclaw.mjs"),
expectedDir: path.join(installedRoot, "dist", "extensions"),
expectedRelativeDir: path.join("dist", "extensions"),
});
});
@@ -174,11 +192,12 @@ describe("resolveBundledPluginsDir", () => {
hasDistExtensions: true,
});
expectResolvedBundledDir({
expectResolvedBundledDirFromRoot({
repoRoot: installedRoot,
cwd: process.cwd(),
argv1: path.join(installedRoot, "openclaw.mjs"),
bundledDirOverride: path.join(installedRoot, "missing-extensions"),
expectedDir: path.join(installedRoot, "dist", "extensions"),
expectedRelativeDir: path.join("dist", "extensions"),
});
});
});

View File

@@ -53,6 +53,23 @@ function setBundledManifestIdsByRoot(manifestIds: Record<string, string>) {
);
}
function setBundledLookupFixture() {
setBundledDiscoveryCandidates([
createBundledCandidate({
rootDir: "/app/extensions/feishu",
packageName: "@openclaw/feishu",
}),
createBundledCandidate({
rootDir: "/app/extensions/diffs",
packageName: "@openclaw/diffs",
}),
]);
setBundledManifestIdsByRoot({
"/app/extensions/feishu": "feishu",
"/app/extensions/diffs": "diffs",
});
}
function expectBundledSourceLookup(
lookup: Parameters<typeof findBundledPluginSource>[0]["lookup"],
expected:
@@ -134,28 +151,12 @@ describe("bundled plugin sources", () => {
undefined,
],
] as const)("%s", (_name, lookup, expected) => {
setBundledDiscoveryCandidates([
createBundledCandidate({
rootDir: "/app/extensions/feishu",
packageName: "@openclaw/feishu",
}),
createBundledCandidate({
rootDir: "/app/extensions/diffs",
packageName: "@openclaw/diffs",
}),
]);
setBundledManifestIdsByRoot({
"/app/extensions/feishu": "feishu",
"/app/extensions/diffs": "diffs",
});
setBundledLookupFixture();
expectBundledSourceLookup(lookup, expected);
});
it("forwards an explicit env to bundled discovery helpers", () => {
discoverOpenClawPluginsMock.mockReturnValue({
candidates: [],
diagnostics: [],
});
setBundledDiscoveryCandidates([]);
const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;

View File

@@ -50,6 +50,52 @@ async function expectClawHubInstallError(params: {
);
}
function createLoggerSpies() {
return {
info: vi.fn(),
warn: vi.fn(),
};
}
function expectClawHubInstallFlow(params: {
baseUrl: string;
version: string;
archivePath: string;
}) {
expect(fetchClawHubPackageDetailMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "demo",
baseUrl: params.baseUrl,
}),
);
expect(fetchClawHubPackageVersionMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "demo",
version: params.version,
}),
);
expect(installPluginFromArchiveMock).toHaveBeenCalledWith(
expect.objectContaining({
archivePath: params.archivePath,
}),
);
}
function expectSuccessfulClawHubInstall(result: unknown) {
expect(result).toMatchObject({
ok: true,
pluginId: "demo",
version: "2026.3.22",
clawhub: {
source: "clawhub",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
integrity: "sha256-demo",
},
});
}
describe("installPluginFromClawHub", () => {
beforeEach(() => {
parseClawHubPluginSpecMock.mockReset();
@@ -107,46 +153,24 @@ describe("installPluginFromClawHub", () => {
});
it("installs a ClawHub code plugin through the archive installer", async () => {
const info = vi.fn();
const warn = vi.fn();
const logger = createLoggerSpies();
const result = await installPluginFromClawHub({
spec: "clawhub:demo",
baseUrl: "https://clawhub.ai",
logger: { info, warn },
logger,
});
expect(fetchClawHubPackageDetailMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "demo",
baseUrl: "https://clawhub.ai",
}),
);
expect(fetchClawHubPackageVersionMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "demo",
version: "2026.3.22",
}),
);
expect(installPluginFromArchiveMock).toHaveBeenCalledWith(
expect.objectContaining({
archivePath: "/tmp/clawhub-demo/archive.zip",
}),
);
expect(result).toMatchObject({
ok: true,
pluginId: "demo",
expectClawHubInstallFlow({
baseUrl: "https://clawhub.ai",
version: "2026.3.22",
clawhub: {
source: "clawhub",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
integrity: "sha256-demo",
},
archivePath: "/tmp/clawhub-demo/archive.zip",
});
expect(info).toHaveBeenCalledWith("ClawHub code-plugin demo@2026.3.22 channel=official");
expect(info).toHaveBeenCalledWith("Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0");
expect(warn).not.toHaveBeenCalled();
expectSuccessfulClawHubInstall(result);
expect(logger.info).toHaveBeenCalledWith("ClawHub code-plugin demo@2026.3.22 channel=official");
expect(logger.info).toHaveBeenCalledWith(
"Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0",
);
expect(logger.warn).not.toHaveBeenCalled();
});
it.each([

View File

@@ -7,9 +7,7 @@ function expectSafeParseCases(
cases: ReadonlyArray<readonly [unknown, unknown]>,
) {
expect(safeParse).toBeDefined();
for (const [value, expected] of cases) {
expect(safeParse?.(value)).toEqual(expected);
}
expect(cases.map(([value]) => safeParse?.(value))).toEqual(cases.map(([, expected]) => expected));
}
describe("buildPluginConfigSchema", () => {

View File

@@ -172,6 +172,42 @@ function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>)
);
}
function expectCandidatePresence(
result: Awaited<ReturnType<typeof discoverOpenClawPlugins>>,
params: { present?: readonly string[]; absent?: readonly string[] },
) {
const ids = result.candidates.map((candidate) => candidate.idHint);
params.present?.forEach((pluginId) => {
expect(ids).toContain(pluginId);
});
params.absent?.forEach((pluginId) => {
expect(ids).not.toContain(pluginId);
});
}
async function expectRejectedPackageExtensionEntry(params: {
stateDir: string;
setup: (stateDir: string) => boolean | void;
expectedDiagnostic?: "escapes" | "none";
expectedId?: string;
}) {
if (params.setup(params.stateDir) === false) {
return;
}
const result = await discoverWithStateDir(params.stateDir, {});
if (params.expectedId) {
expectCandidatePresence(result, { absent: [params.expectedId] });
} else {
expect(result.candidates).toHaveLength(0);
}
if (params.expectedDiagnostic === "escapes") {
expectEscapesPackageDiagnostic(result.diagnostics);
return;
}
expect(result.diagnostics).toEqual([]);
}
afterEach(() => {
clearPluginDiscoveryCache();
cleanupTrackedTempDirs(tempDirs);
@@ -475,99 +511,96 @@ describe("discoverOpenClawPlugins", () => {
expect(hasDiagnosticSourceSuffix(result.diagnostics, bundleMarker)).toBe(true);
});
it("blocks extension entries that escape package directory", async () => {
it.each([
{
name: "blocks extension entries that escape package directory",
expectedDiagnostic: "escapes" as const,
setup: (stateDir: string) => {
const globalExt = path.join(stateDir, "extensions", "escape-pack");
const outside = path.join(stateDir, "outside.js");
mkdirSafe(globalExt);
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/escape-pack",
extensions: ["../../outside.js"],
});
fs.writeFileSync(outside, "export default function () {}", "utf-8");
},
},
{
name: "skips missing package extension entries without escape diagnostics",
expectedDiagnostic: "none" as const,
setup: (stateDir: string) => {
const globalExt = path.join(stateDir, "extensions", "missing-entry-pack");
mkdirSafe(globalExt);
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/missing-entry-pack",
extensions: ["./missing.ts"],
});
},
},
{
name: "rejects package extension entries that escape via symlink",
expectedDiagnostic: "escapes" as const,
expectedId: "pack",
setup: (stateDir: string) => {
const globalExt = path.join(stateDir, "extensions", "pack");
const outsideDir = path.join(stateDir, "outside");
const linkedDir = path.join(globalExt, "linked");
mkdirSafe(globalExt);
mkdirSafe(outsideDir);
fs.writeFileSync(path.join(outsideDir, "escape.ts"), "export default {}", "utf-8");
try {
fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir");
} catch {
return false;
}
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/pack",
extensions: ["./linked/escape.ts"],
});
},
},
{
name: "rejects package extension entries that are hardlinked aliases",
expectedDiagnostic: "escapes" as const,
expectedId: "pack",
setup: (stateDir: string) => {
if (process.platform === "win32") {
return false;
}
const globalExt = path.join(stateDir, "extensions", "pack");
const outsideDir = path.join(stateDir, "outside");
const outsideFile = path.join(outsideDir, "escape.ts");
const linkedFile = path.join(globalExt, "escape.ts");
mkdirSafe(globalExt);
mkdirSafe(outsideDir);
fs.writeFileSync(outsideFile, "export default {}", "utf-8");
try {
fs.linkSync(outsideFile, linkedFile);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return false;
}
throw err;
}
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/pack",
extensions: ["./escape.ts"],
});
},
},
] as const)("$name", async ({ setup, expectedDiagnostic, expectedId }) => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "escape-pack");
const outside = path.join(stateDir, "outside.js");
mkdirSafe(globalExt);
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/escape-pack",
extensions: ["../../outside.js"],
await expectRejectedPackageExtensionEntry({
stateDir,
setup,
expectedDiagnostic,
...(expectedId ? { expectedId } : {}),
});
fs.writeFileSync(outside, "export default function () {}", "utf-8");
const result = await discoverWithStateDir(stateDir, {});
expect(result.candidates).toHaveLength(0);
expectEscapesPackageDiagnostic(result.diagnostics);
});
it("skips missing package extension entries without escape diagnostics", async () => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "missing-entry-pack");
mkdirSafe(globalExt);
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/missing-entry-pack",
extensions: ["./missing.ts"],
});
const result = await discoverWithStateDir(stateDir, {});
expect(result.candidates).toHaveLength(0);
expect(result.diagnostics).toEqual([]);
});
it("rejects package extension entries that escape via symlink", async () => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "pack");
const outsideDir = path.join(stateDir, "outside");
const linkedDir = path.join(globalExt, "linked");
mkdirSafe(globalExt);
mkdirSafe(outsideDir);
fs.writeFileSync(path.join(outsideDir, "escape.ts"), "export default {}", "utf-8");
try {
fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir");
} catch {
return;
}
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/pack",
extensions: ["./linked/escape.ts"],
});
const { candidates, diagnostics } = await discoverWithStateDir(stateDir, {});
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
expectEscapesPackageDiagnostic(diagnostics);
});
it("rejects package extension entries that are hardlinked aliases", async () => {
if (process.platform === "win32") {
return;
}
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "pack");
const outsideDir = path.join(stateDir, "outside");
const outsideFile = path.join(outsideDir, "escape.ts");
const linkedFile = path.join(globalExt, "escape.ts");
mkdirSafe(globalExt);
mkdirSafe(outsideDir);
fs.writeFileSync(outsideFile, "export default {}", "utf-8");
try {
fs.linkSync(outsideFile, linkedFile);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/pack",
extensions: ["./escape.ts"],
});
const { candidates, diagnostics } = await discoverWithStateDir(stateDir, {});
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
expectEscapesPackageDiagnostic(diagnostics);
});
it("ignores package manifests that are hardlinked aliases", async () => {
@@ -692,41 +725,59 @@ describe("discoverOpenClawPlugins", () => {
expect(third.candidates.some((candidate) => candidate.idHint === "cached")).toBe(false);
});
it("does not reuse discovery results across env root changes", () => {
const stateDirA = makeTempDir();
const stateDirB = makeTempDir();
writeStandalonePlugin(path.join(stateDirA, "extensions", "alpha.ts"));
writeStandalonePlugin(path.join(stateDirB, "extensions", "beta.ts"));
const first = discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirA) });
const second = discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirB) });
expect(first.candidates.some((candidate) => candidate.idHint === "alpha")).toBe(true);
expect(first.candidates.some((candidate) => candidate.idHint === "beta")).toBe(false);
expect(second.candidates.some((candidate) => candidate.idHint === "alpha")).toBe(false);
expect(second.candidates.some((candidate) => candidate.idHint === "beta")).toBe(true);
});
it("does not reuse extra-path discovery across env home changes", () => {
const stateDir = makeTempDir();
const homeA = makeTempDir();
const homeB = makeTempDir();
const pluginA = path.join(homeA, "plugins", "demo.ts");
const pluginB = path.join(homeB, "plugins", "demo.ts");
writeStandalonePlugin(pluginA, "export default {}");
writeStandalonePlugin(pluginB, "export default {}");
const first = discoverWithCachedEnv({
extraPaths: ["~/plugins/demo.ts"],
env: buildCachedDiscoveryEnv(stateDir, { HOME: homeA }),
});
const second = discoverWithCachedEnv({
extraPaths: ["~/plugins/demo.ts"],
env: buildCachedDiscoveryEnv(stateDir, { HOME: homeB }),
});
expectCandidateSource(first.candidates, "demo", pluginA);
expectCandidateSource(second.candidates, "demo", pluginB);
it.each([
{
name: "does not reuse discovery results across env root changes",
setup: () => {
const stateDirA = makeTempDir();
const stateDirB = makeTempDir();
writeStandalonePlugin(path.join(stateDirA, "extensions", "alpha.ts"));
writeStandalonePlugin(path.join(stateDirB, "extensions", "beta.ts"));
return {
first: discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirA) }),
second: discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirB) }),
assert: (
first: ReturnType<typeof discoverWithCachedEnv>,
second: ReturnType<typeof discoverWithCachedEnv>,
) => {
expectCandidatePresence(first, { present: ["alpha"], absent: ["beta"] });
expectCandidatePresence(second, { present: ["beta"], absent: ["alpha"] });
},
};
},
},
{
name: "does not reuse extra-path discovery across env home changes",
setup: () => {
const stateDir = makeTempDir();
const homeA = makeTempDir();
const homeB = makeTempDir();
const pluginA = path.join(homeA, "plugins", "demo.ts");
const pluginB = path.join(homeB, "plugins", "demo.ts");
writeStandalonePlugin(pluginA, "export default {}");
writeStandalonePlugin(pluginB, "export default {}");
return {
first: discoverWithCachedEnv({
extraPaths: ["~/plugins/demo.ts"],
env: buildCachedDiscoveryEnv(stateDir, { HOME: homeA }),
}),
second: discoverWithCachedEnv({
extraPaths: ["~/plugins/demo.ts"],
env: buildCachedDiscoveryEnv(stateDir, { HOME: homeB }),
}),
assert: (
first: ReturnType<typeof discoverWithCachedEnv>,
second: ReturnType<typeof discoverWithCachedEnv>,
) => {
expectCandidateSource(first.candidates, "demo", pluginA);
expectCandidateSource(second.candidates, "demo", pluginB);
},
};
},
},
] as const)("$name", ({ setup }) => {
const { first, second, assert } = setup();
assert(first, second);
});
it("treats configured load-path order as cache-significant", () => {

View File

@@ -31,21 +31,36 @@ async function writeMarketplaceManifest(rootDir: string, manifest: unknown): Pro
return manifestPath;
}
function mockRemoteMarketplaceClone(manifest: unknown) {
async function writeRemoteMarketplaceFixture(params: {
repoDir: string;
manifest: unknown;
pluginDir?: string;
}) {
await fs.mkdir(path.join(params.repoDir, ".claude-plugin"), { recursive: true });
if (params.pluginDir) {
await fs.mkdir(path.join(params.repoDir, params.pluginDir), { recursive: true });
}
await fs.writeFile(
path.join(params.repoDir, ".claude-plugin", "marketplace.json"),
JSON.stringify(params.manifest),
);
}
function mockRemoteMarketplaceClone(params: { manifest: unknown; pluginDir?: string }) {
runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => {
const repoDir = argv.at(-1);
expect(typeof repoDir).toBe("string");
await fs.mkdir(path.join(repoDir as string, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(repoDir as string, ".claude-plugin", "marketplace.json"),
JSON.stringify(manifest),
);
await writeRemoteMarketplaceFixture({
repoDir: repoDir as string,
manifest: params.manifest,
...(params.pluginDir ? { pluginDir: params.pluginDir } : {}),
});
return { code: 0, stdout: "", stderr: "", killed: false };
});
}
async function expectRemoteMarketplaceError(params: { manifest: unknown; expectedError: string }) {
mockRemoteMarketplaceClone(params.manifest);
mockRemoteMarketplaceClone({ manifest: params.manifest });
const { listMarketplacePlugins } = await import("./marketplace.js");
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
@@ -175,25 +190,16 @@ describe("marketplace plugins", () => {
});
it("installs remote marketplace plugins from relative paths inside the cloned repo", async () => {
runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => {
const repoDir = argv.at(-1);
expect(typeof repoDir).toBe("string");
await fs.mkdir(path.join(repoDir as string, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(repoDir as string, "plugins", "frontend-design"), {
recursive: true,
});
await fs.writeFile(
path.join(repoDir as string, ".claude-plugin", "marketplace.json"),
JSON.stringify({
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
}),
);
return { code: 0, stdout: "", stderr: "", killed: false };
mockRemoteMarketplaceClone({
pluginDir: path.join("plugins", "frontend-design"),
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
},
});
installPluginFromPathMock.mockResolvedValue({
ok: true,

View File

@@ -21,6 +21,18 @@ function createDistPluginDir(repoRoot: string, pluginId: string) {
return distPluginDir;
}
function writeRepoFile(repoRoot: string, relativePath: string, value: string) {
const fullPath = path.join(repoRoot, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, value, "utf8");
}
function setupRepoFiles(repoRoot: string, files: Readonly<Record<string, string>>) {
for (const [relativePath, value] of Object.entries(files)) {
writeRepoFile(repoRoot, relativePath, value);
}
}
function expectRuntimePluginWrapperContains(params: {
repoRoot: string;
pluginId: string;
@@ -38,6 +50,24 @@ function expectRuntimePluginWrapperContains(params: {
expect(fs.readFileSync(runtimePath, "utf8")).toContain(params.expectedImport);
}
function expectRuntimeArtifactText(params: {
repoRoot: string;
pluginId: string;
relativePath: string;
expectedText: string;
symbolicLink: boolean;
}) {
const runtimePath = path.join(
params.repoRoot,
"dist-runtime",
"extensions",
params.pluginId,
params.relativePath,
);
expect(fs.lstatSync(runtimePath).isSymbolicLink()).toBe(params.symbolicLink);
expect(fs.readFileSync(runtimePath, "utf8")).toBe(params.expectedText);
}
afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
@@ -52,12 +82,10 @@ describe("stageBundledPluginRuntime", () => {
fs.mkdirSync(path.join(distPluginDir, "node_modules", "@pierre", "diffs"), {
recursive: true,
});
fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {}\n", "utf8");
fs.writeFileSync(
path.join(distPluginDir, "node_modules", "@pierre", "diffs", "index.js"),
"export default {}\n",
"utf8",
);
setupRepoFiles(repoRoot, {
"dist/extensions/diffs/index.js": "export default {}\n",
"dist/extensions/diffs/node_modules/@pierre/diffs/index.js": "export default {}\n",
});
stageBundledPluginRuntime({ repoRoot });
@@ -77,16 +105,10 @@ describe("stageBundledPluginRuntime", () => {
it("writes wrappers that forward plugin entry imports into canonical dist files", async () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-chunks-");
createDistPluginDir(repoRoot, "diffs");
fs.writeFileSync(
path.join(repoRoot, "dist", "chunk-abc.js"),
"export const value = 1;\n",
"utf8",
);
fs.writeFileSync(
path.join(repoRoot, "dist", "extensions", "diffs", "index.js"),
"export { value } from '../../chunk-abc.js';\n",
"utf8",
);
setupRepoFiles(repoRoot, {
"dist/chunk-abc.js": "export const value = 1;\n",
"dist/extensions/diffs/index.js": "export { value } from '../../chunk-abc.js';\n",
});
stageBundledPluginRuntime({ repoRoot });
@@ -104,18 +126,12 @@ describe("stageBundledPluginRuntime", () => {
it("stages root runtime sidecars that bundled plugin boundaries resolve directly", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-sidecars-");
const distPluginDir = createDistPluginDir(repoRoot, "whatsapp");
fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(distPluginDir, "light-runtime-api.js"),
"export const light = true;\n",
"utf8",
);
fs.writeFileSync(
path.join(distPluginDir, "runtime-api.js"),
"export const heavy = true;\n",
"utf8",
);
createDistPluginDir(repoRoot, "whatsapp");
setupRepoFiles(repoRoot, {
"dist/extensions/whatsapp/index.js": "export default {};\n",
"dist/extensions/whatsapp/light-runtime-api.js": "export const light = true;\n",
"dist/extensions/whatsapp/runtime-api.js": "export const heavy = true;\n",
});
stageBundledPluginRuntime({ repoRoot });
@@ -242,22 +258,33 @@ describe("stageBundledPluginRuntime", () => {
it("copies package metadata files but symlinks other non-js plugin artifacts into the runtime overlay", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-assets-");
const distPluginDir = path.join(repoRoot, "dist", "extensions", "diffs");
fs.mkdirSync(path.join(distPluginDir, "assets"), { recursive: true });
fs.writeFileSync(
path.join(distPluginDir, "package.json"),
JSON.stringify(
createDistPluginDir(repoRoot, "diffs");
setupRepoFiles(repoRoot, {
"dist/extensions/diffs/package.json": JSON.stringify(
{ name: "@openclaw/diffs", openclaw: { extensions: ["./index.js"] } },
null,
2,
),
"utf8",
);
fs.writeFileSync(path.join(distPluginDir, "openclaw.plugin.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(distPluginDir, "assets", "info.txt"), "ok\n", "utf8");
"dist/extensions/diffs/openclaw.plugin.json": "{}\n",
"dist/extensions/diffs/assets/info.txt": "ok\n",
});
stageBundledPluginRuntime({ repoRoot });
expectRuntimeArtifactText({
repoRoot,
pluginId: "diffs",
relativePath: "openclaw.plugin.json",
expectedText: "{}\n",
symbolicLink: false,
});
expectRuntimeArtifactText({
repoRoot,
pluginId: "diffs",
relativePath: "assets/info.txt",
expectedText: "ok\n",
symbolicLink: true,
});
const runtimePackagePath = path.join(
repoRoot,
"dist-runtime",
@@ -265,38 +292,16 @@ describe("stageBundledPluginRuntime", () => {
"diffs",
"package.json",
);
const runtimeManifestPath = path.join(
repoRoot,
"dist-runtime",
"extensions",
"diffs",
"openclaw.plugin.json",
);
const runtimeAssetPath = path.join(
repoRoot,
"dist-runtime",
"extensions",
"diffs",
"assets",
"info.txt",
);
expect(fs.lstatSync(runtimePackagePath).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(runtimePackagePath, "utf8")).toContain('"extensions": [');
expect(fs.lstatSync(runtimeManifestPath).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(runtimeManifestPath, "utf8")).toBe("{}\n");
expect(fs.lstatSync(runtimeAssetPath).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(runtimeAssetPath, "utf8")).toBe("ok\n");
});
it("preserves package metadata needed for bundled plugin discovery from dist-runtime", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-discovery-");
const distPluginDir = path.join(repoRoot, "dist", "extensions", "demo");
const runtimeExtensionsDir = path.join(repoRoot, "dist-runtime", "extensions");
fs.mkdirSync(distPluginDir, { recursive: true });
fs.writeFileSync(
path.join(distPluginDir, "package.json"),
JSON.stringify(
createDistPluginDir(repoRoot, "demo");
setupRepoFiles(repoRoot, {
"dist/extensions/demo/package.json": JSON.stringify(
{
name: "@openclaw/demo",
openclaw: {
@@ -310,11 +315,7 @@ describe("stageBundledPluginRuntime", () => {
null,
2,
),
"utf8",
);
fs.writeFileSync(
path.join(distPluginDir, "openclaw.plugin.json"),
JSON.stringify(
"dist/extensions/demo/openclaw.plugin.json": JSON.stringify(
{
id: "demo",
channels: ["demo"],
@@ -323,10 +324,9 @@ describe("stageBundledPluginRuntime", () => {
null,
2,
),
"utf8",
);
fs.writeFileSync(path.join(distPluginDir, "main.js"), "export default {};\n", "utf8");
fs.writeFileSync(path.join(distPluginDir, "setup.js"), "export default {};\n", "utf8");
"dist/extensions/demo/main.js": "export default {};\n",
"dist/extensions/demo/setup.js": "export default {};\n",
});
stageBundledPluginRuntime({ repoRoot });
@@ -388,11 +388,11 @@ describe("stageBundledPluginRuntime", () => {
it("tolerates EEXIST when an identical runtime symlink is materialized concurrently", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-eexist-");
const distPluginDir = path.join(repoRoot, "dist", "extensions", "feishu");
const distSkillDir = path.join(distPluginDir, "skills", "feishu-doc");
fs.mkdirSync(distSkillDir, { recursive: true });
fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {}\n", "utf8");
fs.writeFileSync(path.join(distSkillDir, "SKILL.md"), "# Feishu Doc\n", "utf8");
createDistPluginDir(repoRoot, "feishu");
setupRepoFiles(repoRoot, {
"dist/extensions/feishu/index.js": "export default {}\n",
"dist/extensions/feishu/skills/feishu-doc/SKILL.md": "# Feishu Doc\n",
});
const realSymlinkSync = fs.symlinkSync.bind(fs);
const symlinkSpy = vi.spyOn(fs, "symlinkSync").mockImplementation(((target, link, type) => {