mirror of https://github.com/openclaw/openclaw.git
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs";
|
|
import { bundledDistPluginFile } from "../../test/helpers/bundled-plugin-paths.js";
|
|
import { discoverOpenClawPlugins } from "./discovery.js";
|
|
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
function makeRepoRoot(prefix: string): string {
|
|
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
tempDirs.push(repoRoot);
|
|
return repoRoot;
|
|
}
|
|
|
|
function createDistPluginDir(repoRoot: string, pluginId: string) {
|
|
const distPluginDir = path.join(repoRoot, "dist", "extensions", pluginId);
|
|
fs.mkdirSync(distPluginDir, { recursive: true });
|
|
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 distRuntimeImportPath(pluginId: string, relativePath = "index.js"): string {
|
|
return `../../../${bundledDistPluginFile(pluginId, relativePath)}`;
|
|
}
|
|
|
|
function expectRuntimePluginWrapperContains(params: {
|
|
repoRoot: string;
|
|
pluginId: string;
|
|
relativePath?: string;
|
|
expectedImport: string;
|
|
}) {
|
|
const runtimePath = path.join(
|
|
params.repoRoot,
|
|
"dist-runtime",
|
|
"extensions",
|
|
params.pluginId,
|
|
params.relativePath ?? "index.js",
|
|
);
|
|
expect(fs.existsSync(runtimePath)).toBe(true);
|
|
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 });
|
|
}
|
|
});
|
|
|
|
describe("stageBundledPluginRuntime", () => {
|
|
it("stages bundled dist plugins as runtime wrappers and links staged dist node_modules", () => {
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-");
|
|
const distPluginDir = createDistPluginDir(repoRoot, "diffs");
|
|
fs.mkdirSync(path.join(repoRoot, "dist"), { recursive: true });
|
|
fs.mkdirSync(path.join(distPluginDir, "node_modules", "@pierre", "diffs"), {
|
|
recursive: true,
|
|
});
|
|
setupRepoFiles(repoRoot, {
|
|
[bundledDistPluginFile("diffs", "index.js")]: "export default {}\n",
|
|
[bundledDistPluginFile("diffs", "node_modules/@pierre/diffs/index.js")]:
|
|
"export default {}\n",
|
|
});
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", "diffs");
|
|
expectRuntimePluginWrapperContains({
|
|
repoRoot,
|
|
pluginId: "diffs",
|
|
expectedImport: distRuntimeImportPath("diffs"),
|
|
});
|
|
expect(fs.lstatSync(path.join(runtimePluginDir, "node_modules")).isSymbolicLink()).toBe(true);
|
|
expect(fs.realpathSync(path.join(runtimePluginDir, "node_modules"))).toBe(
|
|
fs.realpathSync(path.join(distPluginDir, "node_modules")),
|
|
);
|
|
expect(fs.existsSync(path.join(distPluginDir, "node_modules"))).toBe(true);
|
|
});
|
|
|
|
it("writes wrappers that forward plugin entry imports into canonical dist files", async () => {
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-chunks-");
|
|
createDistPluginDir(repoRoot, "diffs");
|
|
setupRepoFiles(repoRoot, {
|
|
"dist/chunk-abc.js": "export const value = 1;\n",
|
|
[bundledDistPluginFile("diffs", "index.js")]: "export { value } from '../../chunk-abc.js';\n",
|
|
});
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
const runtimeEntryPath = path.join(repoRoot, "dist-runtime", "extensions", "diffs", "index.js");
|
|
expectRuntimePluginWrapperContains({
|
|
repoRoot,
|
|
pluginId: "diffs",
|
|
expectedImport: distRuntimeImportPath("diffs"),
|
|
});
|
|
expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "chunk-abc.js"))).toBe(false);
|
|
|
|
const runtimeModule = await import(`${pathToFileURL(runtimeEntryPath).href}?t=${Date.now()}`);
|
|
expect(runtimeModule.value).toBe(1);
|
|
});
|
|
|
|
it("stages root runtime sidecars that bundled plugin boundaries resolve directly", () => {
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-sidecars-");
|
|
createDistPluginDir(repoRoot, "whatsapp");
|
|
setupRepoFiles(repoRoot, {
|
|
[bundledDistPluginFile("whatsapp", "index.js")]: "export default {};\n",
|
|
[bundledDistPluginFile("whatsapp", "light-runtime-api.js")]: "export const light = true;\n",
|
|
[bundledDistPluginFile("whatsapp", "runtime-api.js")]: "export const heavy = true;\n",
|
|
});
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
expectRuntimePluginWrapperContains({
|
|
repoRoot,
|
|
pluginId: "whatsapp",
|
|
relativePath: "light-runtime-api.js",
|
|
expectedImport: distRuntimeImportPath("whatsapp", "light-runtime-api.js"),
|
|
});
|
|
expectRuntimePluginWrapperContains({
|
|
repoRoot,
|
|
pluginId: "whatsapp",
|
|
relativePath: "runtime-api.js",
|
|
expectedImport: distRuntimeImportPath("whatsapp", "runtime-api.js"),
|
|
});
|
|
});
|
|
|
|
it("keeps plugin command registration on the canonical dist graph when loaded from dist-runtime", async () => {
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-commands-");
|
|
const distPluginDir = path.join(repoRoot, "dist", "extensions", "demo");
|
|
const distCommandsDir = path.join(repoRoot, "dist", "plugins");
|
|
fs.mkdirSync(distPluginDir, { recursive: true });
|
|
fs.mkdirSync(distCommandsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(repoRoot, "package.json"), '{ "type": "module" }\n', "utf8");
|
|
fs.writeFileSync(
|
|
path.join(distCommandsDir, "commands.js"),
|
|
[
|
|
"const registry = globalThis.__openclawTestPluginCommands ??= new Map();",
|
|
"export function registerPluginCommand(pluginId, command) {",
|
|
" registry.set(`/${command.name.toLowerCase()}`, { ...command, pluginId });",
|
|
"}",
|
|
"export function clearPluginCommands() {",
|
|
" registry.clear();",
|
|
"}",
|
|
"export function getPluginCommandSpecs(provider) {",
|
|
" if (provider && provider !== 'telegram' && provider !== 'discord') return [];",
|
|
" return Array.from(registry.values()).map((command) => ({",
|
|
" name: command.nativeNames?.[provider] ?? command.nativeNames?.default ?? command.name,",
|
|
" description: command.description,",
|
|
" acceptsArgs: command.acceptsArgs ?? false,",
|
|
" }));",
|
|
"}",
|
|
"export function matchPluginCommand(commandBody) {",
|
|
" const [commandName, ...rest] = commandBody.trim().split(/\\s+/u);",
|
|
" const command = registry.get(commandName.toLowerCase());",
|
|
" if (!command) return null;",
|
|
" return { command, args: rest.length > 0 ? rest.join(' ') : undefined };",
|
|
"}",
|
|
"export async function executePluginCommand(params) {",
|
|
" return params.command.handler({ args: params.args });",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(distPluginDir, "index.js"),
|
|
[
|
|
"import { registerPluginCommand } from '../../plugins/commands.js';",
|
|
"",
|
|
"export function registerDemoCommand() {",
|
|
" registerPluginCommand('demo-plugin', {",
|
|
" name: 'pair',",
|
|
" description: 'Pair a device',",
|
|
" acceptsArgs: true,",
|
|
" nativeNames: { telegram: 'pair', discord: 'pair' },",
|
|
" handler: async ({ args }) => ({ text: `paired:${args ?? ''}` }),",
|
|
" });",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
const runtimeEntryPath = path.join(repoRoot, "dist-runtime", "extensions", "demo", "index.js");
|
|
const canonicalCommandsPath = path.join(repoRoot, "dist", "plugins", "commands.js");
|
|
|
|
expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "plugins", "commands.js"))).toBe(
|
|
false,
|
|
);
|
|
|
|
const runtimeModule = await import(`${pathToFileURL(runtimeEntryPath).href}?t=${Date.now()}`);
|
|
const commandsModule = (await import(
|
|
`${pathToFileURL(canonicalCommandsPath).href}?t=${Date.now()}`
|
|
)) as {
|
|
clearPluginCommands: () => void;
|
|
getPluginCommandSpecs: (provider?: string) => Array<{
|
|
name: string;
|
|
description: string;
|
|
acceptsArgs: boolean;
|
|
}>;
|
|
matchPluginCommand: (commandBody: string) => {
|
|
command: { handler: ({ args }: { args?: string }) => Promise<{ text: string }> };
|
|
args?: string;
|
|
} | null;
|
|
executePluginCommand: (params: {
|
|
command: { handler: ({ args }: { args?: string }) => Promise<{ text: string }> };
|
|
args?: string;
|
|
}) => Promise<{ text: string }>;
|
|
};
|
|
|
|
commandsModule.clearPluginCommands();
|
|
runtimeModule.registerDemoCommand();
|
|
|
|
expect(commandsModule.getPluginCommandSpecs("telegram")).toEqual([
|
|
{ name: "pair", description: "Pair a device", acceptsArgs: true },
|
|
]);
|
|
expect(commandsModule.getPluginCommandSpecs("discord")).toEqual([
|
|
{ name: "pair", description: "Pair a device", acceptsArgs: true },
|
|
]);
|
|
|
|
const match = commandsModule.matchPluginCommand("/pair now");
|
|
expect(match).not.toBeNull();
|
|
expect(match?.args).toBe("now");
|
|
await expect(
|
|
commandsModule.executePluginCommand({
|
|
command: match!.command,
|
|
args: match?.args,
|
|
}),
|
|
).resolves.toEqual({ text: "paired:now" });
|
|
});
|
|
|
|
it("copies package metadata files but symlinks other non-js plugin artifacts into the runtime overlay", () => {
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-assets-");
|
|
createDistPluginDir(repoRoot, "diffs");
|
|
setupRepoFiles(repoRoot, {
|
|
[bundledDistPluginFile("diffs", "package.json")]: JSON.stringify(
|
|
{ name: "@openclaw/diffs", openclaw: { extensions: ["./index.js"] } },
|
|
null,
|
|
2,
|
|
),
|
|
[bundledDistPluginFile("diffs", "openclaw.plugin.json")]: "{}\n",
|
|
[bundledDistPluginFile("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",
|
|
"extensions",
|
|
"diffs",
|
|
"package.json",
|
|
);
|
|
expect(fs.lstatSync(runtimePackagePath).isSymbolicLink()).toBe(false);
|
|
expect(fs.readFileSync(runtimePackagePath, "utf8")).toContain('"extensions": [');
|
|
});
|
|
|
|
it("preserves package metadata needed for bundled plugin discovery from dist-runtime", () => {
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-discovery-");
|
|
const runtimeExtensionsDir = path.join(repoRoot, "dist-runtime", "extensions");
|
|
createDistPluginDir(repoRoot, "demo");
|
|
setupRepoFiles(repoRoot, {
|
|
[bundledDistPluginFile("demo", "package.json")]: JSON.stringify(
|
|
{
|
|
name: "@openclaw/demo",
|
|
openclaw: {
|
|
extensions: ["./main.js"],
|
|
setupEntry: "./setup.js",
|
|
startup: {
|
|
deferConfiguredChannelFullLoadUntilAfterListen: true,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
[bundledDistPluginFile("demo", "openclaw.plugin.json")]: JSON.stringify(
|
|
{
|
|
id: "demo",
|
|
channels: ["demo"],
|
|
configSchema: { type: "object" },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
[bundledDistPluginFile("demo", "main.js")]: "export default {};\n",
|
|
[bundledDistPluginFile("demo", "setup.js")]: "export default {};\n",
|
|
});
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
const env = {
|
|
...process.env,
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: runtimeExtensionsDir,
|
|
};
|
|
const discovery = discoverOpenClawPlugins({
|
|
env,
|
|
cache: false,
|
|
});
|
|
const manifestRegistry = loadPluginManifestRegistry({
|
|
env,
|
|
cache: false,
|
|
candidates: discovery.candidates,
|
|
diagnostics: discovery.diagnostics,
|
|
});
|
|
const expectedRuntimeMainPath = fs.realpathSync(
|
|
path.join(runtimeExtensionsDir, "demo", "main.js"),
|
|
);
|
|
const expectedRuntimeSetupPath = fs.realpathSync(
|
|
path.join(runtimeExtensionsDir, "demo", "setup.js"),
|
|
);
|
|
|
|
expect(discovery.candidates).toHaveLength(1);
|
|
expect(fs.realpathSync(discovery.candidates[0]?.source ?? "")).toBe(expectedRuntimeMainPath);
|
|
expect(fs.realpathSync(discovery.candidates[0]?.setupSource ?? "")).toBe(
|
|
expectedRuntimeSetupPath,
|
|
);
|
|
expect(fs.realpathSync(manifestRegistry.plugins[0]?.setupSource ?? "")).toBe(
|
|
expectedRuntimeSetupPath,
|
|
);
|
|
expect(manifestRegistry.plugins[0]?.startupDeferConfiguredChannelFullLoadUntilAfterListen).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("removes stale runtime plugin directories that are no longer in dist", () => {
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-stale-");
|
|
const staleRuntimeDir = path.join(repoRoot, "dist-runtime", "extensions", "stale");
|
|
fs.mkdirSync(staleRuntimeDir, { recursive: true });
|
|
fs.writeFileSync(path.join(staleRuntimeDir, "index.js"), "stale\n", "utf8");
|
|
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
expect(fs.existsSync(staleRuntimeDir)).toBe(false);
|
|
});
|
|
|
|
it("removes dist-runtime when the built bundled plugin tree is absent", () => {
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-missing-");
|
|
const runtimeRoot = path.join(repoRoot, "dist-runtime", "extensions", "diffs");
|
|
fs.mkdirSync(runtimeRoot, { recursive: true });
|
|
|
|
stageBundledPluginRuntime({ repoRoot });
|
|
|
|
expect(fs.existsSync(path.join(repoRoot, "dist-runtime"))).toBe(false);
|
|
});
|
|
|
|
it("tolerates EEXIST when an identical runtime symlink is materialized concurrently", () => {
|
|
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-eexist-");
|
|
createDistPluginDir(repoRoot, "feishu");
|
|
setupRepoFiles(repoRoot, {
|
|
[bundledDistPluginFile("feishu", "index.js")]: "export default {}\n",
|
|
[bundledDistPluginFile("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) => {
|
|
const linkPath = String(link);
|
|
if (linkPath.endsWith(path.join("skills", "feishu-doc", "SKILL.md"))) {
|
|
const err = Object.assign(new Error("file already exists"), { code: "EEXIST" });
|
|
realSymlinkSync(String(target), linkPath, type);
|
|
throw err;
|
|
}
|
|
return realSymlinkSync(String(target), linkPath, type);
|
|
}) as typeof fs.symlinkSync);
|
|
|
|
expect(() => stageBundledPluginRuntime({ repoRoot })).not.toThrow();
|
|
|
|
const runtimeSkillPath = path.join(
|
|
repoRoot,
|
|
"dist-runtime",
|
|
"extensions",
|
|
"feishu",
|
|
"skills",
|
|
"feishu-doc",
|
|
"SKILL.md",
|
|
);
|
|
expect(fs.lstatSync(runtimeSkillPath).isSymbolicLink()).toBe(true);
|
|
expect(fs.readFileSync(runtimeSkillPath, "utf8")).toBe("# Feishu Doc\n");
|
|
|
|
symlinkSpy.mockRestore();
|
|
});
|
|
});
|