mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-17 21:10:54 +00:00
* Plugins: preserve scoped ids and reserve bundled duplicates * Changelog: add plugin scoped id note * Plugins: harden scoped install ids * Plugins: reserve scoped install dirs * Plugins: migrate legacy scoped update ids
381 lines
11 KiB
TypeScript
381 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const installPluginFromNpmSpecMock = vi.fn();
|
|
const resolveBundledPluginSourcesMock = vi.fn();
|
|
|
|
vi.mock("./install.js", () => ({
|
|
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpecMock(...args),
|
|
resolvePluginInstallDir: (pluginId: string) => `/tmp/${pluginId}`,
|
|
PLUGIN_INSTALL_ERROR_CODE: {
|
|
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
|
|
},
|
|
}));
|
|
|
|
vi.mock("./bundled-sources.js", () => ({
|
|
resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args),
|
|
}));
|
|
|
|
describe("updateNpmInstalledPlugins", () => {
|
|
beforeEach(() => {
|
|
installPluginFromNpmSpecMock.mockReset();
|
|
resolveBundledPluginSourcesMock.mockReset();
|
|
});
|
|
|
|
it("skips integrity drift checks for unpinned npm specs during dry-run updates", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "opik-openclaw",
|
|
targetDir: "/tmp/opik-openclaw",
|
|
version: "0.2.6",
|
|
extensions: ["index.ts"],
|
|
});
|
|
|
|
const { updateNpmInstalledPlugins } = await import("./update.js");
|
|
await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
installs: {
|
|
"opik-openclaw": {
|
|
source: "npm",
|
|
spec: "@opik/opik-openclaw",
|
|
integrity: "sha512-old",
|
|
installPath: "/tmp/opik-openclaw",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["opik-openclaw"],
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
spec: "@opik/opik-openclaw",
|
|
expectedIntegrity: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps integrity drift checks for exact-version npm specs during dry-run updates", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "opik-openclaw",
|
|
targetDir: "/tmp/opik-openclaw",
|
|
version: "0.2.6",
|
|
extensions: ["index.ts"],
|
|
});
|
|
|
|
const { updateNpmInstalledPlugins } = await import("./update.js");
|
|
await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
installs: {
|
|
"opik-openclaw": {
|
|
source: "npm",
|
|
spec: "@opik/opik-openclaw@0.2.5",
|
|
integrity: "sha512-old",
|
|
installPath: "/tmp/opik-openclaw",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["opik-openclaw"],
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
spec: "@opik/opik-openclaw@0.2.5",
|
|
expectedIntegrity: "sha512-old",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("formats package-not-found updates with a stable message", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: false,
|
|
code: "npm_package_not_found",
|
|
error: "Package not found on npm: @openclaw/missing.",
|
|
});
|
|
|
|
const { updateNpmInstalledPlugins } = await import("./update.js");
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
installs: {
|
|
missing: {
|
|
source: "npm",
|
|
spec: "@openclaw/missing",
|
|
installPath: "/tmp/missing",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["missing"],
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "missing",
|
|
status: "error",
|
|
message: "Failed to check missing: npm package not found for @openclaw/missing.",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("falls back to raw installer error for unknown error codes", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: false,
|
|
code: "invalid_npm_spec",
|
|
error: "unsupported npm spec: github:evil/evil",
|
|
});
|
|
|
|
const { updateNpmInstalledPlugins } = await import("./update.js");
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
installs: {
|
|
bad: {
|
|
source: "npm",
|
|
spec: "github:evil/evil",
|
|
installPath: "/tmp/bad",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["bad"],
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(result.outcomes).toEqual([
|
|
{
|
|
pluginId: "bad",
|
|
status: "error",
|
|
message: "Failed to check bad: unsupported npm spec: github:evil/evil",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("migrates legacy unscoped install keys when a scoped npm package updates", async () => {
|
|
installPluginFromNpmSpecMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "@openclaw/voice-call",
|
|
targetDir: "/tmp/openclaw-voice-call",
|
|
version: "0.0.2",
|
|
extensions: ["index.ts"],
|
|
});
|
|
|
|
const { updateNpmInstalledPlugins } = await import("./update.js");
|
|
const result = await updateNpmInstalledPlugins({
|
|
config: {
|
|
plugins: {
|
|
allow: ["voice-call"],
|
|
deny: ["voice-call"],
|
|
slots: { memory: "voice-call" },
|
|
entries: {
|
|
"voice-call": {
|
|
enabled: false,
|
|
hooks: { allowPromptInjection: false },
|
|
},
|
|
},
|
|
installs: {
|
|
"voice-call": {
|
|
source: "npm",
|
|
spec: "@openclaw/voice-call",
|
|
installPath: "/tmp/voice-call",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
pluginIds: ["voice-call"],
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
spec: "@openclaw/voice-call",
|
|
expectedPluginId: "voice-call",
|
|
}),
|
|
);
|
|
expect(result.config.plugins?.allow).toEqual(["@openclaw/voice-call"]);
|
|
expect(result.config.plugins?.deny).toEqual(["@openclaw/voice-call"]);
|
|
expect(result.config.plugins?.slots?.memory).toBe("@openclaw/voice-call");
|
|
expect(result.config.plugins?.entries?.["@openclaw/voice-call"]).toEqual({
|
|
enabled: false,
|
|
hooks: { allowPromptInjection: false },
|
|
});
|
|
expect(result.config.plugins?.entries?.["voice-call"]).toBeUndefined();
|
|
expect(result.config.plugins?.installs?.["@openclaw/voice-call"]).toMatchObject({
|
|
source: "npm",
|
|
spec: "@openclaw/voice-call",
|
|
installPath: "/tmp/openclaw-voice-call",
|
|
version: "0.0.2",
|
|
});
|
|
expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("syncPluginsForUpdateChannel", () => {
|
|
beforeEach(() => {
|
|
installPluginFromNpmSpecMock.mockReset();
|
|
resolveBundledPluginSourcesMock.mockReset();
|
|
});
|
|
|
|
it("keeps bundled path installs on beta without reinstalling from npm", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(
|
|
new Map([
|
|
[
|
|
"feishu",
|
|
{
|
|
pluginId: "feishu",
|
|
localPath: "/app/extensions/feishu",
|
|
npmSpec: "@openclaw/feishu",
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
const { syncPluginsForUpdateChannel } = await import("./update.js");
|
|
const result = await syncPluginsForUpdateChannel({
|
|
channel: "beta",
|
|
config: {
|
|
plugins: {
|
|
load: { paths: ["/app/extensions/feishu"] },
|
|
installs: {
|
|
feishu: {
|
|
source: "path",
|
|
sourcePath: "/app/extensions/feishu",
|
|
installPath: "/app/extensions/feishu",
|
|
spec: "@openclaw/feishu",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
expect(result.changed).toBe(false);
|
|
expect(result.summary.switchedToNpm).toEqual([]);
|
|
expect(result.config.plugins?.load?.paths).toEqual(["/app/extensions/feishu"]);
|
|
expect(result.config.plugins?.installs?.feishu?.source).toBe("path");
|
|
});
|
|
|
|
it("repairs bundled install metadata when the load path is re-added", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(
|
|
new Map([
|
|
[
|
|
"feishu",
|
|
{
|
|
pluginId: "feishu",
|
|
localPath: "/app/extensions/feishu",
|
|
npmSpec: "@openclaw/feishu",
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
const { syncPluginsForUpdateChannel } = await import("./update.js");
|
|
const result = await syncPluginsForUpdateChannel({
|
|
channel: "beta",
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [] },
|
|
installs: {
|
|
feishu: {
|
|
source: "path",
|
|
sourcePath: "/app/extensions/feishu",
|
|
installPath: "/tmp/old-feishu",
|
|
spec: "@openclaw/feishu",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.config.plugins?.load?.paths).toEqual(["/app/extensions/feishu"]);
|
|
expect(result.config.plugins?.installs?.feishu).toMatchObject({
|
|
source: "path",
|
|
sourcePath: "/app/extensions/feishu",
|
|
installPath: "/app/extensions/feishu",
|
|
spec: "@openclaw/feishu",
|
|
});
|
|
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("forwards an explicit env to bundled plugin source resolution", async () => {
|
|
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
|
|
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
|
|
|
|
const { syncPluginsForUpdateChannel } = await import("./update.js");
|
|
await syncPluginsForUpdateChannel({
|
|
channel: "beta",
|
|
config: {},
|
|
workspaceDir: "/workspace",
|
|
env,
|
|
});
|
|
|
|
expect(resolveBundledPluginSourcesMock).toHaveBeenCalledWith({
|
|
workspaceDir: "/workspace",
|
|
env,
|
|
});
|
|
});
|
|
|
|
it("uses the provided env when matching bundled load and install paths", async () => {
|
|
const bundledHome = "/tmp/openclaw-home";
|
|
resolveBundledPluginSourcesMock.mockReturnValue(
|
|
new Map([
|
|
[
|
|
"feishu",
|
|
{
|
|
pluginId: "feishu",
|
|
localPath: `${bundledHome}/plugins/feishu`,
|
|
npmSpec: "@openclaw/feishu",
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
const previousHome = process.env.HOME;
|
|
process.env.HOME = "/tmp/process-home";
|
|
try {
|
|
const { syncPluginsForUpdateChannel } = await import("./update.js");
|
|
const result = await syncPluginsForUpdateChannel({
|
|
channel: "beta",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_HOME: bundledHome,
|
|
HOME: "/tmp/ignored-home",
|
|
},
|
|
config: {
|
|
plugins: {
|
|
load: { paths: ["~/plugins/feishu"] },
|
|
installs: {
|
|
feishu: {
|
|
source: "path",
|
|
sourcePath: "~/plugins/feishu",
|
|
installPath: "~/plugins/feishu",
|
|
spec: "@openclaw/feishu",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config.plugins?.load?.paths).toEqual(["~/plugins/feishu"]);
|
|
expect(result.config.plugins?.installs?.feishu).toMatchObject({
|
|
source: "path",
|
|
sourcePath: "~/plugins/feishu",
|
|
installPath: "~/plugins/feishu",
|
|
});
|
|
} finally {
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME;
|
|
} else {
|
|
process.env.HOME = previousHome;
|
|
}
|
|
}
|
|
});
|
|
});
|