mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 10:10:43 +00:00
Merged via squash.
Prepared head SHA: d428fd47f5
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js";
|
|
import { loadInstalledPluginIndex, type InstalledPluginIndex } from "./installed-plugin-index.js";
|
|
import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js";
|
|
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
cleanupTrackedTempDirs(tempDirs);
|
|
});
|
|
|
|
function makeTempDir() {
|
|
return makeTrackedTempDir("openclaw-plugin-registry-snapshot", tempDirs);
|
|
}
|
|
|
|
function createHermeticEnv(rootDir: string): NodeJS.ProcessEnv {
|
|
return {
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(rootDir, "bundled"),
|
|
OPENCLAW_STATE_DIR: path.join(rootDir, "state"),
|
|
OPENCLAW_VERSION: "2026.4.26",
|
|
VITEST: "true",
|
|
};
|
|
}
|
|
|
|
function writeManifestlessClaudeBundle(rootDir: string) {
|
|
fs.mkdirSync(path.join(rootDir, "skills"), { recursive: true });
|
|
fs.writeFileSync(path.join(rootDir, "skills", "SKILL.md"), "# Workspace skill\n", "utf8");
|
|
}
|
|
|
|
function writePackagePlugin(rootDir: string) {
|
|
fs.mkdirSync(rootDir, { recursive: true });
|
|
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default { register() {} };\n", "utf8");
|
|
fs.writeFileSync(
|
|
path.join(rootDir, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: "demo",
|
|
name: "Demo",
|
|
description: "one",
|
|
configSchema: { type: "object" },
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDir, "package.json"),
|
|
JSON.stringify({ name: "demo", version: "1.0.0" }),
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
function writeManagedNpmPlugin(params: {
|
|
stateDir: string;
|
|
packageName: string;
|
|
pluginId: string;
|
|
version: string;
|
|
dependencySpec?: string;
|
|
}): string {
|
|
const npmRoot = path.join(params.stateDir, "npm");
|
|
const rootManifestPath = path.join(npmRoot, "package.json");
|
|
fs.mkdirSync(npmRoot, { recursive: true });
|
|
const rootManifest = fs.existsSync(rootManifestPath)
|
|
? (JSON.parse(fs.readFileSync(rootManifestPath, "utf8")) as {
|
|
dependencies?: Record<string, string>;
|
|
})
|
|
: {};
|
|
fs.writeFileSync(
|
|
rootManifestPath,
|
|
JSON.stringify(
|
|
{
|
|
...rootManifest,
|
|
private: true,
|
|
dependencies: {
|
|
...rootManifest.dependencies,
|
|
[params.packageName]: params.dependencySpec ?? params.version,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
const packageDir = path.join(npmRoot, "node_modules", params.packageName);
|
|
fs.mkdirSync(path.join(packageDir, "dist"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(packageDir, "package.json"),
|
|
JSON.stringify({
|
|
name: params.packageName,
|
|
version: params.version,
|
|
openclaw: { extensions: ["./dist/index.js"] },
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(packageDir, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: params.pluginId,
|
|
configSchema: { type: "object" },
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(path.join(packageDir, "dist", "index.js"), "export {};\n", "utf8");
|
|
return packageDir;
|
|
}
|
|
|
|
function replaceFilePreservingSizeAndMtime(filePath: string, contents: string) {
|
|
const previous = fs.statSync(filePath);
|
|
expect(Buffer.byteLength(contents)).toBe(previous.size);
|
|
fs.writeFileSync(filePath, contents, "utf8");
|
|
fs.utimesSync(filePath, previous.atime, previous.mtime);
|
|
}
|
|
|
|
function createManifestlessClaudeBundleIndex(params: {
|
|
rootDir: string;
|
|
env: NodeJS.ProcessEnv;
|
|
}): InstalledPluginIndex {
|
|
return loadInstalledPluginIndex({
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [params.rootDir] },
|
|
},
|
|
},
|
|
env: params.env,
|
|
});
|
|
}
|
|
|
|
describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
|
it("recovers managed npm plugins missing from a stale persisted registry", () => {
|
|
const tempRoot = makeTempDir();
|
|
const stateDir = path.join(tempRoot, "state");
|
|
const env = {
|
|
...createHermeticEnv(tempRoot),
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
};
|
|
const config = {};
|
|
const whatsappDir = writeManagedNpmPlugin({
|
|
stateDir,
|
|
packageName: "@openclaw/whatsapp",
|
|
pluginId: "whatsapp",
|
|
version: "2026.5.2",
|
|
});
|
|
const staleIndex = loadInstalledPluginIndex({
|
|
config,
|
|
env,
|
|
stateDir,
|
|
installRecords: {},
|
|
});
|
|
expect(staleIndex.plugins.some((plugin) => plugin.pluginId === "whatsapp")).toBe(false);
|
|
writePersistedInstalledPluginIndexSync(staleIndex, { stateDir });
|
|
|
|
const result = loadPluginRegistrySnapshotWithMetadata({
|
|
config,
|
|
env,
|
|
stateDir,
|
|
});
|
|
|
|
expect(result.source).toBe("derived");
|
|
expect(result.diagnostics).toContainEqual(
|
|
expect.objectContaining({ code: "persisted-registry-stale-source" }),
|
|
);
|
|
expect(result.snapshot.installRecords).toMatchObject({
|
|
whatsapp: {
|
|
source: "npm",
|
|
spec: "@openclaw/whatsapp@2026.5.2",
|
|
installPath: whatsappDir,
|
|
version: "2026.5.2",
|
|
resolvedName: "@openclaw/whatsapp",
|
|
resolvedVersion: "2026.5.2",
|
|
resolvedSpec: "@openclaw/whatsapp@2026.5.2",
|
|
},
|
|
});
|
|
expect(result.snapshot.plugins).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
pluginId: "whatsapp",
|
|
origin: "global",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("keeps persisted manifestless Claude bundles on the fast path", () => {
|
|
const tempRoot = makeTempDir();
|
|
const rootDir = path.join(tempRoot, "workspace");
|
|
const stateDir = path.join(tempRoot, "state");
|
|
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
|
const config = {
|
|
plugins: {
|
|
load: { paths: [rootDir] },
|
|
},
|
|
};
|
|
writeManifestlessClaudeBundle(rootDir);
|
|
const index = createManifestlessClaudeBundleIndex({ rootDir, env });
|
|
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
|
|
|
const result = loadPluginRegistrySnapshotWithMetadata({
|
|
config,
|
|
env,
|
|
stateDir,
|
|
});
|
|
|
|
expect(result.source).toBe("persisted");
|
|
expect(result.diagnostics).toEqual([]);
|
|
});
|
|
|
|
it("keeps persisted package plugins when file hashes match", () => {
|
|
const tempRoot = makeTempDir();
|
|
const rootDir = path.join(tempRoot, "workspace");
|
|
const stateDir = path.join(tempRoot, "state");
|
|
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
|
const config = {
|
|
plugins: {
|
|
load: { paths: [rootDir] },
|
|
},
|
|
};
|
|
writePackagePlugin(rootDir);
|
|
const index = loadInstalledPluginIndex({ config, env });
|
|
const [record] = index.plugins;
|
|
expect(record?.manifestFile).toBeDefined();
|
|
expect(record?.packageJson?.fileSignature).toBeDefined();
|
|
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
|
|
|
const result = loadPluginRegistrySnapshotWithMetadata({
|
|
config,
|
|
env,
|
|
stateDir,
|
|
});
|
|
|
|
expect(result.source).toBe("persisted");
|
|
expect(result.diagnostics).toEqual([]);
|
|
});
|
|
|
|
it("detects same-size same-mtime manifest replacements", () => {
|
|
const tempRoot = makeTempDir();
|
|
const rootDir = path.join(tempRoot, "workspace");
|
|
const stateDir = path.join(tempRoot, "state");
|
|
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
|
const config = {
|
|
plugins: {
|
|
load: { paths: [rootDir] },
|
|
},
|
|
};
|
|
writePackagePlugin(rootDir);
|
|
const index = loadInstalledPluginIndex({ config, env });
|
|
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
|
|
|
replaceFilePreservingSizeAndMtime(
|
|
path.join(rootDir, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: "demo",
|
|
name: "Demo",
|
|
description: "two",
|
|
configSchema: { type: "object" },
|
|
}),
|
|
);
|
|
|
|
const result = loadPluginRegistrySnapshotWithMetadata({
|
|
config,
|
|
env,
|
|
stateDir,
|
|
});
|
|
|
|
expect(result.source).toBe("derived");
|
|
expect(result.diagnostics).toContainEqual(
|
|
expect.objectContaining({ code: "persisted-registry-stale-source" }),
|
|
);
|
|
});
|
|
|
|
it("detects same-size same-mtime package.json replacements", () => {
|
|
const tempRoot = makeTempDir();
|
|
const rootDir = path.join(tempRoot, "workspace");
|
|
const stateDir = path.join(tempRoot, "state");
|
|
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
|
const config = {
|
|
plugins: {
|
|
load: { paths: [rootDir] },
|
|
},
|
|
};
|
|
writePackagePlugin(rootDir);
|
|
const index = loadInstalledPluginIndex({ config, env });
|
|
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
|
|
|
replaceFilePreservingSizeAndMtime(
|
|
path.join(rootDir, "package.json"),
|
|
JSON.stringify({ name: "demo", version: "1.0.1" }),
|
|
);
|
|
|
|
const result = loadPluginRegistrySnapshotWithMetadata({
|
|
config,
|
|
env,
|
|
stateDir,
|
|
});
|
|
|
|
expect(result.source).toBe("derived");
|
|
expect(result.diagnostics).toContainEqual(
|
|
expect.objectContaining({ code: "persisted-registry-stale-source" }),
|
|
);
|
|
});
|
|
|
|
it("detects package.json replacements even when stored stat fields still match", () => {
|
|
const tempRoot = makeTempDir();
|
|
const rootDir = path.join(tempRoot, "workspace");
|
|
const stateDir = path.join(tempRoot, "state");
|
|
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
|
const config = {
|
|
plugins: {
|
|
load: { paths: [rootDir] },
|
|
},
|
|
};
|
|
writePackagePlugin(rootDir);
|
|
const index = loadInstalledPluginIndex({ config, env });
|
|
|
|
replaceFilePreservingSizeAndMtime(
|
|
path.join(rootDir, "package.json"),
|
|
JSON.stringify({ name: "demo", version: "1.0.1" }),
|
|
);
|
|
const stat = fs.statSync(path.join(rootDir, "package.json"));
|
|
const [plugin] = index.plugins;
|
|
if (!plugin?.packageJson) {
|
|
throw new Error("expected test plugin package metadata");
|
|
}
|
|
const stalePlugin = {
|
|
...plugin,
|
|
packageJson: {
|
|
...plugin.packageJson,
|
|
fileSignature: {
|
|
size: stat.size,
|
|
mtimeMs: stat.mtimeMs,
|
|
ctimeMs: stat.ctimeMs,
|
|
},
|
|
},
|
|
};
|
|
const staleIndex: InstalledPluginIndex = {
|
|
...index,
|
|
plugins: [stalePlugin, ...index.plugins.slice(1)],
|
|
};
|
|
writePersistedInstalledPluginIndexSync(staleIndex, { stateDir });
|
|
|
|
const result = loadPluginRegistrySnapshotWithMetadata({
|
|
config,
|
|
env,
|
|
stateDir,
|
|
});
|
|
|
|
expect(result.source).toBe("derived");
|
|
expect(result.diagnostics).toContainEqual(
|
|
expect.objectContaining({ code: "persisted-registry-stale-source" }),
|
|
);
|
|
});
|
|
});
|