mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 03:11:10 +00:00
test: dedupe plugin bundle and discovery suites
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user