Files
openclaw/src/plugins/installed-plugin-index.test.ts

627 lines
18 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginCandidate } from "./discovery.js";
import {
diffInstalledPluginIndexInvalidationReasons,
getInstalledPluginRecord,
isInstalledPluginEnabled,
listEnabledInstalledPluginRecords,
listInstalledPluginContributionIds,
listInstalledPluginRecords,
loadInstalledPluginIndex,
refreshInstalledPluginIndex,
resolveInstalledPluginContributionOwners,
resolveInstalledPluginContributions,
} from "./installed-plugin-index.js";
import { recordPluginInstall } from "./installs.js";
import type { OpenClawPackageManifest } from "./manifest.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
vi.unmock("../version.js");
const tempDirs: string[] = [];
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
function makeTempDir() {
return makeTrackedTempDir("openclaw-installed-plugin-index", tempDirs);
}
function writePluginManifest(rootDir: string, manifest: Record<string, unknown>) {
fs.writeFileSync(path.join(rootDir, "openclaw.plugin.json"), JSON.stringify(manifest), "utf-8");
}
function writePackageJson(rootDir: string, packageJson: Record<string, unknown>) {
fs.writeFileSync(path.join(rootDir, "package.json"), JSON.stringify(packageJson), "utf-8");
}
function writeRuntimeEntry(rootDir: string) {
fs.writeFileSync(
path.join(rootDir, "index.ts"),
"throw new Error('runtime entry should not load while building installed plugin index');\n",
"utf-8",
);
}
function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return {
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
OPENCLAW_VERSION: "2026.4.25",
VITEST: "true",
...overrides,
};
}
function createPluginCandidate(params: {
rootDir: string;
idHint?: string;
origin?: PluginCandidate["origin"];
packageName?: string;
packageVersion?: string;
packageManifest?: OpenClawPackageManifest;
}): PluginCandidate {
return {
idHint: params.idHint ?? "demo",
source: path.join(params.rootDir, "index.ts"),
rootDir: params.rootDir,
origin: params.origin ?? "global",
packageName: params.packageName,
packageVersion: params.packageVersion,
packageDir: params.rootDir,
packageManifest: params.packageManifest,
};
}
function createRichPluginFixture(params: { packageVersion?: string } = {}) {
const rootDir = makeTempDir();
writeRuntimeEntry(rootDir);
writePackageJson(rootDir, {
name: "@vendor/demo-plugin",
version: params.packageVersion ?? "1.2.3",
});
writePluginManifest(rootDir, {
id: "demo",
name: "Demo",
configSchema: { type: "object" },
providers: ["demo"],
channels: ["demo-chat"],
cliBackends: ["demo-cli"],
channelConfigs: {
"demo-chat": {
schema: { type: "object" },
},
},
modelCatalog: {
providers: {
demo: {
models: [{ id: "demo-model" }],
},
},
discovery: {
demo: "static",
},
},
setup: {
providers: [{ id: "demo", envVars: ["DEMO_API_KEY"] }],
cliBackends: ["setup-cli"],
},
commandAliases: [{ name: "demo-command" }],
contracts: {
tools: ["demo-tool"],
},
providerAuthEnvVars: {
demo: ["DEMO_API_KEY"],
},
channelEnvVars: {
"demo-chat": ["DEMO_CHAT_TOKEN"],
},
activation: {
onProviders: ["demo"],
onChannels: ["demo-chat"],
},
});
return {
rootDir,
candidate: createPluginCandidate({
rootDir,
packageName: "@vendor/demo-plugin",
packageVersion: params.packageVersion ?? "1.2.3",
packageManifest: {
install: {
npmSpec: "@vendor/demo-plugin@1.2.3",
expectedIntegrity: "sha512-demo",
defaultChoice: "npm",
},
},
}),
};
}
describe("installed plugin index", () => {
it("builds a runtime-free installed plugin snapshot from manifest and package metadata", () => {
const fixture = createRichPluginFixture();
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
env: hermeticEnv(),
now: () => new Date("2026-04-25T12:00:00.000Z"),
});
expect(index).toMatchObject({
version: 1,
generatedAtMs: 1777118400000,
plugins: [
{
pluginId: "demo",
packageName: "@vendor/demo-plugin",
packageVersion: "1.2.3",
origin: "global",
rootDir: fixture.rootDir,
enabled: true,
packageInstall: {
defaultChoice: "npm",
npm: {
spec: "@vendor/demo-plugin@1.2.3",
packageName: "@vendor/demo-plugin",
selector: "1.2.3",
selectorKind: "exact-version",
exactVersion: true,
expectedIntegrity: "sha512-demo",
pinState: "exact-with-integrity",
},
warnings: [],
},
contributions: {
providers: ["demo"],
channels: ["demo-chat"],
channelConfigs: ["demo-chat"],
setupProviders: ["demo"],
cliBackends: ["demo-cli", "setup-cli"],
modelCatalogProviders: ["demo"],
commandAliases: ["demo-command"],
contracts: ["tools"],
},
compat: [
"activation-channel-hint",
"activation-provider-hint",
"channel-env-vars",
"provider-auth-env-vars",
],
},
],
});
expect(index.plugins[0]?.manifestHash).toMatch(/^[a-f0-9]{64}$/u);
expect(index.plugins[0]?.packageJson).toMatchObject({
path: "package.json",
hash: expect.stringMatching(/^[a-f0-9]{64}$/u),
});
expect(index.plugins[0]?.installRecord).toBeUndefined();
expect(index.plugins[0]?.installRecordHash).toBeUndefined();
const contributions = resolveInstalledPluginContributions(index);
expect(contributions.providers.get("demo")).toEqual(["demo"]);
expect(contributions.channels.get("demo-chat")).toEqual(["demo"]);
expect(contributions.contracts.get("tools")).toEqual(["demo"]);
});
it("exposes cold registry records and owners for existing plugins without install ledgers", () => {
const fixture = createRichPluginFixture();
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
env: hermeticEnv(),
});
expect(listInstalledPluginRecords(index).map((plugin) => plugin.pluginId)).toEqual(["demo"]);
expect(listEnabledInstalledPluginRecords(index).map((plugin) => plugin.pluginId)).toEqual([
"demo",
]);
const record = getInstalledPluginRecord(index, "demo");
expect(record).toMatchObject({
pluginId: "demo",
enabled: true,
});
expect(record?.installRecord).toBeUndefined();
expect(isInstalledPluginEnabled(index, "demo")).toBe(true);
expect(listInstalledPluginContributionIds(index, "providers")).toEqual(["demo"]);
expect(resolveInstalledPluginContributionOwners(index, "providers", "demo")).toEqual(["demo"]);
expect(resolveInstalledPluginContributionOwners(index, "channels", "demo-chat")).toEqual([
"demo",
]);
});
it("keeps disabled plugins in inventory while excluding them from cold owner resolution", () => {
const fixture = createRichPluginFixture();
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
entries: {
demo: {
enabled: false,
},
},
},
},
env: hermeticEnv(),
});
expect(listInstalledPluginRecords(index).map((plugin) => plugin.pluginId)).toEqual(["demo"]);
const config = {
plugins: {
entries: {
demo: {
enabled: false,
},
},
},
};
expect(listEnabledInstalledPluginRecords(index, config)).toEqual([]);
expect(getInstalledPluginRecord(index, "demo")).toMatchObject({
pluginId: "demo",
enabled: false,
});
expect(isInstalledPluginEnabled(index, "demo", config)).toBe(false);
expect(listInstalledPluginContributionIds(index, "providers", { config })).toEqual([]);
expect(
listInstalledPluginContributionIds(index, "providers", { includeDisabled: true }),
).toEqual(["demo"]);
expect(
resolveInstalledPluginContributionOwners(index, "providers", "demo", { config }),
).toEqual([]);
expect(
resolveInstalledPluginContributionOwners(index, "providers", "demo", {
includeDisabled: true,
}),
).toEqual(["demo"]);
});
it("records the config install ledger separately from package install intent", () => {
const fixture = createRichPluginFixture();
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
installs: {
demo: {
source: "npm",
spec: "@vendor/demo-plugin@latest",
installPath: "plugins/demo",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-installed",
shasum: "abc123",
resolvedAt: "2026-04-25T11:00:00.000Z",
installedAt: "2026-04-25T11:01:00.000Z",
},
},
},
},
env: hermeticEnv(),
});
expect(index.plugins[0]).toMatchObject({
installRecord: {
source: "npm",
spec: "@vendor/demo-plugin@latest",
installPath: "plugins/demo",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-installed",
shasum: "abc123",
resolvedAt: "2026-04-25T11:00:00.000Z",
installedAt: "2026-04-25T11:01:00.000Z",
},
packageInstall: {
npm: {
spec: "@vendor/demo-plugin@1.2.3",
expectedIntegrity: "sha512-demo",
pinState: "exact-with-integrity",
},
},
});
expect(index.plugins[0]?.installRecordHash).toMatch(/^[a-f0-9]{64}$/u);
});
it("indexes npm install ledger records written before a process reload", () => {
const fixture = createRichPluginFixture();
const cfg = recordPluginInstall(
{},
{
pluginId: "demo",
source: "npm",
spec: "@vendor/demo-plugin@latest",
installPath: fixture.rootDir,
version: "1.2.3",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-installed",
shasum: "abc123",
resolvedAt: "2026-04-25T11:00:00.000Z",
installedAt: "2026-04-25T11:01:00.000Z",
},
);
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: cfg,
env: hermeticEnv(),
});
expect(index.plugins[0]).toMatchObject({
pluginId: "demo",
installRecord: {
source: "npm",
spec: "@vendor/demo-plugin@latest",
installPath: fixture.rootDir,
version: "1.2.3",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-installed",
shasum: "abc123",
resolvedAt: "2026-04-25T11:00:00.000Z",
installedAt: "2026-04-25T11:01:00.000Z",
},
});
});
it("indexes local fallback install ledger records written before a process reload", () => {
const fixture = createRichPluginFixture();
const cfg = recordPluginInstall(
{},
{
pluginId: "demo",
source: "path",
sourcePath: "./plugins/demo",
spec: "@vendor/demo-plugin@1.2.3",
installedAt: "2026-04-25T11:01:00.000Z",
},
);
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: cfg,
env: hermeticEnv(),
});
expect(index.plugins[0]).toMatchObject({
pluginId: "demo",
installRecord: {
source: "path",
sourcePath: "./plugins/demo",
spec: "@vendor/demo-plugin@1.2.3",
installedAt: "2026-04-25T11:01:00.000Z",
},
});
});
it("does not treat package install intent as source invalidation", () => {
const fixture = createRichPluginFixture();
const previous = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-installed",
},
},
},
},
env: hermeticEnv(),
});
const current = {
...previous,
plugins: previous.plugins.map((plugin) => ({
...plugin,
packageInstall: {
...plugin.packageInstall,
warnings: ["npm-spec-missing-integrity" as const],
},
})),
};
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([]);
});
it("treats install ledger changes as source invalidation", () => {
const fixture = createRichPluginFixture();
const previous = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-old",
},
},
},
},
env: hermeticEnv(),
});
const current = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-new",
},
},
},
},
env: hermeticEnv(),
});
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([
"source-changed",
]);
});
it("treats enablement changes as policy invalidation", () => {
const fixture = createRichPluginFixture();
const previous = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
entries: {
demo: {
enabled: true,
},
},
},
},
env: hermeticEnv(),
});
const current = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
entries: {
demo: {
enabled: false,
},
},
},
},
env: hermeticEnv(),
});
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([
"policy-changed",
]);
});
it("marks disabled plugins without dropping their cold contributions", () => {
const fixture = createRichPluginFixture();
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
entries: {
demo: {
enabled: false,
},
},
},
},
env: hermeticEnv(),
});
expect(
isInstalledPluginEnabled(index, "demo", {
plugins: {
entries: {
demo: {
enabled: false,
},
},
},
}),
).toBe(false);
expect(index.plugins[0]?.enabled).toBe(false);
expect(index.plugins[0]?.contributions.providers).toEqual(["demo"]);
});
it("tracks refresh reason without using the manifest cache", () => {
const fixture = createRichPluginFixture();
const index = refreshInstalledPluginIndex({
reason: "manual",
candidates: [fixture.candidate],
env: hermeticEnv(),
});
expect(index.refreshReason).toBe("manual");
});
it("diffs invalidation reasons for manifest, package, source, host, and compat changes", () => {
const fixture = createRichPluginFixture();
const previous = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedVersion: "1.2.3",
},
},
},
},
env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.25" }),
});
writePackageJson(fixture.rootDir, {
name: "@vendor/demo-plugin",
version: "1.2.4",
});
writePluginManifest(fixture.rootDir, {
id: "demo",
configSchema: { type: "object" },
providers: ["demo", "demo-next"],
});
const current = {
...loadInstalledPluginIndex({
candidates: [
{
...fixture.candidate,
packageVersion: "1.2.4",
},
],
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedVersion: "1.2.4",
},
},
},
},
env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.26" }),
}),
compatRegistryVersion: "different-compat-registry",
};
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([
"compat-registry-changed",
"host-contract-changed",
"source-changed",
"stale-manifest",
"stale-package",
]);
const moved = {
...current,
plugins: current.plugins.map((plugin) => ({
...plugin,
rootDir: path.join(plugin.rootDir, "moved"),
})),
};
expect(diffInstalledPluginIndexInvalidationReasons(current, moved)).toContain("source-changed");
});
});