Files
openclaw/src/plugins/plugin-registry-snapshot.test.ts
Josh Lehman b22c8998ca fix(doctor): discover load-path plugin contracts (#77477)
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
2026-05-06 11:50:47 -07:00

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" }),
);
});
});