mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-14 14:43:16 -06:00
e390781534
* refactor: name subsystem logger exports * refactor(test): distinguish exported test doubles * refactor: consolidate canonical owner helpers * refactor: give cross-domain helpers distinct names * chore(lint): ratchet collision debt baselines * fix(test): complete collision rename consumers * fix(test): update remaining collision mock consumers * fix(test): update transcript reader mock export * refactor: keep embedded logger name at its owner * fix(test): align embedded logger mock with owner * refactor: name shared assistant phase extraction * fix(ui): update assistant phase extractor import * chore(generated): refresh collision and SDK baselines * style(test): format merged plugin mocks * chore(sdk): refresh API content hashes
205 lines
6.4 KiB
TypeScript
205 lines
6.4 KiB
TypeScript
// Covers plugin install path validation and normalization.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { runCommandWithTimeout } from "../process/exec.js";
|
|
import { installPluginFromPath, PLUGIN_INSTALL_ERROR_CODE } from "./install.js";
|
|
import { packToArchive } from "./test-helpers/archive-fixtures.js";
|
|
import { createSyncSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js";
|
|
import {
|
|
createBundleInstallFixtureFactory,
|
|
createDualFormatInstallFixtureFactory,
|
|
} from "./test-helpers/install-fixtures.js";
|
|
|
|
vi.mock("../process/exec.js", () => ({
|
|
runCommandWithTimeout: vi.fn(),
|
|
}));
|
|
|
|
const suiteTempRootTracker = createSyncSuiteTempRootTracker("openclaw-plugin-install-path");
|
|
const setupBundleInstallFixture = createBundleInstallFixtureFactory(
|
|
suiteTempRootTracker.makeTempDir,
|
|
);
|
|
const setupDualFormatInstallFixture = createDualFormatInstallFixtureFactory(
|
|
suiteTempRootTracker.makeTempDir,
|
|
);
|
|
let dualFormatArchiveCase: {
|
|
nodeModulesExists: boolean;
|
|
result: Awaited<ReturnType<typeof installPluginFromPath>>;
|
|
runCalls: unknown[][];
|
|
};
|
|
|
|
function setupNativePluginInstallFixture() {
|
|
const caseDir = suiteTempRootTracker.makeTempDir();
|
|
const stateDir = path.join(caseDir, "state");
|
|
const pluginDir = path.join(caseDir, "plugin-src");
|
|
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify({
|
|
name: "symlink-plugin",
|
|
version: "1.0.0",
|
|
openclaw: { extensions: ["./dist/index.js"] },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: "symlink-plugin",
|
|
configSchema: { type: "object", properties: {} },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n", "utf-8");
|
|
return { caseDir, pluginDir, extensionsDir: path.join(stateDir, "extensions") };
|
|
}
|
|
|
|
afterAll(() => {
|
|
suiteTempRootTracker.cleanup();
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
const { pluginDir, extensionsDir } = setupDualFormatInstallFixture({
|
|
bundleFormat: "claude",
|
|
});
|
|
const archivePath = path.join(suiteTempRootTracker.makeTempDir(), "dual-format.tgz");
|
|
|
|
await packToArchive({
|
|
pkgDir: pluginDir,
|
|
outDir: path.dirname(archivePath),
|
|
outName: path.basename(archivePath),
|
|
});
|
|
|
|
const run = vi.mocked(runCommandWithTimeout);
|
|
run.mockReset();
|
|
run.mockResolvedValue({
|
|
code: 0,
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit",
|
|
});
|
|
|
|
const result = await installPluginFromPath({
|
|
path: archivePath,
|
|
extensionsDir,
|
|
});
|
|
dualFormatArchiveCase = {
|
|
nodeModulesExists: result.ok && fs.existsSync(path.join(result.targetDir, "node_modules")),
|
|
result,
|
|
runCalls: [...run.mock.calls],
|
|
};
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe("installPluginFromPath", () => {
|
|
it("rejects managed plain file plugin installs through path install", async () => {
|
|
const baseDir = suiteTempRootTracker.makeTempDir();
|
|
const extensionsDir = path.join(baseDir, "extensions");
|
|
fs.mkdirSync(extensionsDir, { recursive: true });
|
|
|
|
const sourcePath = path.join(baseDir, "payload.js");
|
|
fs.writeFileSync(sourcePath, "console.log('SAFE');\n", "utf-8");
|
|
|
|
const result = await installPluginFromPath({
|
|
path: sourcePath,
|
|
extensionsDir,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.UNSUPPORTED_PLAIN_FILE_PLUGIN);
|
|
expect(result.error).toBe(
|
|
"Plain file plugin installs are not supported. Install a plugin directory or archive that contains openclaw.plugin.json, or list standalone plugin files in plugins.load.paths.",
|
|
);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"installs local plugin directories when the managed extensions root is a symlink",
|
|
async () => {
|
|
const { caseDir, pluginDir, extensionsDir } = setupNativePluginInstallFixture();
|
|
const realExtensionsDir = path.join(caseDir, "data", "extensions");
|
|
fs.mkdirSync(realExtensionsDir, { recursive: true });
|
|
fs.mkdirSync(path.dirname(extensionsDir), { recursive: true });
|
|
fs.symlinkSync(realExtensionsDir, extensionsDir, "dir");
|
|
|
|
const result = await installPluginFromPath({
|
|
path: pluginDir,
|
|
extensionsDir,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.targetDir).toBe(path.join(extensionsDir, "symlink-plugin"));
|
|
expect(fs.existsSync(path.join(realExtensionsDir, "symlink-plugin", "package.json"))).toBe(
|
|
true,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
format: "agent" as const,
|
|
name: "Portable Sample",
|
|
pluginId: "portable-sample",
|
|
archiveName: "agent-bundle.tgz",
|
|
manifestPath: "plugin.json",
|
|
},
|
|
{
|
|
format: "claude" as const,
|
|
name: "Claude Sample",
|
|
pluginId: "claude-sample",
|
|
archiveName: "claude-bundle.tgz",
|
|
manifestPath: path.join(".claude-plugin", "plugin.json"),
|
|
},
|
|
])(
|
|
"installs $format bundles from an archive path",
|
|
async ({ format, name, pluginId, archiveName, manifestPath }) => {
|
|
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
|
|
bundleFormat: format,
|
|
name,
|
|
});
|
|
const archivePath = path.join(suiteTempRootTracker.makeTempDir(), archiveName);
|
|
|
|
await packToArchive({
|
|
pkgDir: pluginDir,
|
|
outDir: path.dirname(archivePath),
|
|
outName: path.basename(archivePath),
|
|
});
|
|
|
|
const result = await installPluginFromPath({
|
|
path: archivePath,
|
|
extensionsDir,
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.pluginId).toBe(pluginId);
|
|
expect(fs.existsSync(path.join(result.targetDir, manifestPath))).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("prefers native package metadata without installing dependencies for dual-format archives", async () => {
|
|
const { nodeModulesExists, result, runCalls } = dualFormatArchiveCase;
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.pluginId).toBe("native-dual");
|
|
expect(path.basename(result.targetDir)).toBe("native-dual");
|
|
expect(runCalls).toHaveLength(0);
|
|
expect(nodeModulesExists).toBe(false);
|
|
});
|
|
});
|