mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 04:22:50 +00:00
498 lines
16 KiB
TypeScript
498 lines
16 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
repairMissingConfiguredPluginInstalls: vi.fn(),
|
|
relinkOpenClawPeerDependenciesInManagedNpmRoot: vi.fn(),
|
|
runPluginPayloadSmokeCheck: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../commands/doctor/shared/missing-configured-plugin-install.js", () => ({
|
|
repairMissingConfiguredPluginInstalls: mocks.repairMissingConfiguredPluginInstalls,
|
|
}));
|
|
vi.mock("../../plugins/plugin-peer-link.js", () => ({
|
|
relinkOpenClawPeerDependenciesInManagedNpmRoot:
|
|
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot,
|
|
}));
|
|
vi.mock("./plugin-payload-validation.js", () => ({
|
|
runPluginPayloadSmokeCheck: mocks.runPluginPayloadSmokeCheck,
|
|
}));
|
|
|
|
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
|
import { VERSION } from "../../version.js";
|
|
import {
|
|
convergenceWarningsToOutcomes,
|
|
filterRecordsToActive,
|
|
runPostCorePluginConvergence,
|
|
} from "./post-core-plugin-convergence.js";
|
|
|
|
describe("runPostCorePluginConvergence", () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
|
changes: [],
|
|
warnings: [],
|
|
records: {},
|
|
});
|
|
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot.mockResolvedValue({
|
|
checked: 0,
|
|
attempted: 0,
|
|
repaired: 0,
|
|
skipped: 0,
|
|
});
|
|
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({ checked: [], failures: [] });
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function makeTempDir(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-post-core-convergence-"));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function writeBundledPlugin(rootDir: string, pluginId: string): string {
|
|
const pluginDir = path.join(rootDir, pluginId);
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pluginDir, "index.js"), "export default {};\n", "utf8");
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: pluginId,
|
|
name: pluginId,
|
|
version: "2026.5.20-beta.1",
|
|
configSchema: { type: "object" },
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify({
|
|
name: `@openclaw/${pluginId}`,
|
|
version: "2026.5.20-beta.1",
|
|
}),
|
|
"utf8",
|
|
);
|
|
return pluginDir;
|
|
}
|
|
|
|
it("calls repair with OPENCLAW_UPDATE_POST_CORE_CONVERGENCE=1 set", async () => {
|
|
const cfg = { plugins: { entries: {} } } as unknown as OpenClawConfig;
|
|
await runPostCorePluginConvergence({
|
|
cfg,
|
|
env: { OPENCLAW_UPDATE_IN_PROGRESS: "1" },
|
|
});
|
|
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledTimes(1);
|
|
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({
|
|
cfg,
|
|
env: {
|
|
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
|
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
|
|
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("uses the candidate runtime version over a stale inherited host version", async () => {
|
|
const cfg = { plugins: { entries: {} } } as unknown as OpenClawConfig;
|
|
await runPostCorePluginConvergence({
|
|
cfg,
|
|
env: { OPENCLAW_COMPATIBILITY_HOST_VERSION: "2026.5.12" },
|
|
});
|
|
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({
|
|
cfg,
|
|
env: {
|
|
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
|
|
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("returns ok when no warnings/failures and includes repair changes", async () => {
|
|
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
|
changes: ['Repaired missing configured plugin "discord".'],
|
|
warnings: [],
|
|
records: { discord: { source: "npm", installPath: "/p/discord" } },
|
|
});
|
|
const result = await runPostCorePluginConvergence({
|
|
cfg: {
|
|
plugins: { entries: { discord: { enabled: true } } },
|
|
} as unknown as OpenClawConfig,
|
|
env: {},
|
|
});
|
|
expect(result.errored).toBe(false);
|
|
expect(result.changes).toEqual(['Repaired missing configured plugin "discord".']);
|
|
expect(result.warnings).toEqual([]);
|
|
});
|
|
|
|
it("returns the post-repair install records so callers can re-seed pluginConfig", async () => {
|
|
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
|
changes: ["Repaired"],
|
|
warnings: [],
|
|
records: { discord: { source: "npm", installPath: "/p/discord" } },
|
|
});
|
|
const result = await runPostCorePluginConvergence({
|
|
cfg: { plugins: { entries: { discord: { enabled: true } } } } as unknown as OpenClawConfig,
|
|
env: {},
|
|
});
|
|
expect(result.installRecords).toEqual({
|
|
discord: { source: "npm", installPath: "/p/discord" },
|
|
});
|
|
});
|
|
|
|
it("repairs managed npm openclaw peer links before payload smoke checks", async () => {
|
|
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
|
changes: [],
|
|
warnings: [],
|
|
records: { codex: { source: "npm", installPath: "/p/codex" } },
|
|
});
|
|
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot.mockResolvedValue({
|
|
checked: 1,
|
|
attempted: 1,
|
|
repaired: 1,
|
|
skipped: 0,
|
|
});
|
|
|
|
const result = await runPostCorePluginConvergence({
|
|
cfg: { plugins: { entries: { codex: { enabled: true } } } } as unknown as OpenClawConfig,
|
|
env: { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" },
|
|
});
|
|
|
|
expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenCalledWith({
|
|
npmRoot: "/tmp/openclaw-state/npm",
|
|
logger: {},
|
|
});
|
|
expect(result.changes).toEqual([
|
|
"Repaired OpenClaw host peer link(s) for 1 managed npm plugin package(s).",
|
|
]);
|
|
expect(
|
|
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot.mock.invocationCallOrder[0],
|
|
).toBeLessThan(mocks.runPluginPayloadSmokeCheck.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it("forwards baselineInstallRecords to repair so sync/npm in-memory mutations are preserved", async () => {
|
|
const baseline = { matrix: { source: "npm" as const, installPath: "/p/matrix" } };
|
|
const cfg = {
|
|
plugins: { entries: { matrix: { enabled: true } } },
|
|
} as unknown as OpenClawConfig;
|
|
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
|
changes: [],
|
|
warnings: [],
|
|
records: baseline,
|
|
});
|
|
await runPostCorePluginConvergence({
|
|
cfg,
|
|
env: {},
|
|
baselineInstallRecords: baseline,
|
|
});
|
|
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledTimes(1);
|
|
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({
|
|
cfg,
|
|
env: {
|
|
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
|
|
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
|
|
},
|
|
baselineRecords: baseline,
|
|
});
|
|
});
|
|
|
|
it("prunes stale local bundled plugin shadows from baseline records before repair", async () => {
|
|
const bundledRoot = makeTempDir();
|
|
writeBundledPlugin(bundledRoot, "discord");
|
|
const baseline = {
|
|
discord: {
|
|
source: "path" as const,
|
|
installPath: path.join(makeTempDir(), "dist", "extensions", "discord"),
|
|
version: "2026.5.4-beta.3",
|
|
},
|
|
brave: { source: "npm" as const, installPath: "/p/brave" },
|
|
};
|
|
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
|
changes: [],
|
|
warnings: [],
|
|
records: { brave: baseline.brave },
|
|
});
|
|
const cfg = {
|
|
plugins: { entries: { discord: { enabled: true }, brave: { enabled: true } } },
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = await runPostCorePluginConvergence({
|
|
cfg,
|
|
env: {
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
|
|
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
|
|
VITEST: "true",
|
|
},
|
|
baselineInstallRecords: baseline,
|
|
});
|
|
|
|
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({
|
|
cfg,
|
|
env: {
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
|
|
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
|
|
VITEST: "true",
|
|
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
|
|
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
|
|
},
|
|
baselineRecords: {
|
|
brave: baseline.brave,
|
|
},
|
|
});
|
|
expect(result.changes).toEqual([
|
|
'Removed stale local bundled plugin install record "discord".',
|
|
]);
|
|
expect(result.installRecords).toEqual({ brave: baseline.brave });
|
|
});
|
|
|
|
it("flags errored=true and surfaces actionable guidance when repair warns", async () => {
|
|
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
|
changes: [],
|
|
warnings: [
|
|
'Failed to install missing configured plugin "discord" from @openclaw/discord: ENETUNREACH.',
|
|
],
|
|
records: {},
|
|
});
|
|
const result = await runPostCorePluginConvergence({
|
|
cfg: {
|
|
plugins: { entries: { discord: { enabled: true } } },
|
|
} as unknown as OpenClawConfig,
|
|
env: {},
|
|
});
|
|
expect(result.errored).toBe(true);
|
|
expect(result.warnings).toStrictEqual([
|
|
{
|
|
reason:
|
|
'Failed to install missing configured plugin "discord" from @openclaw/discord: ENETUNREACH.',
|
|
message:
|
|
'Failed to install missing configured plugin "discord" from @openclaw/discord: ENETUNREACH.',
|
|
guidance: ["Run `openclaw doctor --fix` to retry plugin repair."],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("flags errored=true when smoke check finds a missing main entry", async () => {
|
|
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
|
changes: [],
|
|
warnings: [],
|
|
records: { brave: { source: "npm", installPath: "/p/brave" } },
|
|
});
|
|
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({
|
|
checked: ["brave"],
|
|
failures: [
|
|
{
|
|
pluginId: "brave",
|
|
installPath: "/p/brave",
|
|
reason: "missing-main-entry",
|
|
detail: 'Plugin main entry "dist/index.js" not found at /p/brave/dist/index.js',
|
|
},
|
|
],
|
|
});
|
|
const result = await runPostCorePluginConvergence({
|
|
cfg: {
|
|
plugins: { entries: { brave: { enabled: true } } },
|
|
} as unknown as OpenClawConfig,
|
|
env: {},
|
|
});
|
|
expect(result.errored).toBe(true);
|
|
expect(result.warnings).toStrictEqual([
|
|
{
|
|
pluginId: "brave",
|
|
reason:
|
|
'missing-main-entry: Plugin main entry "dist/index.js" not found at /p/brave/dist/index.js',
|
|
message:
|
|
'Plugin "brave" failed post-core payload smoke check (missing-main-entry): Plugin main entry "dist/index.js" not found at /p/brave/dist/index.js',
|
|
guidance: [
|
|
"Run `openclaw doctor --fix` to retry plugin repair.",
|
|
"Run `openclaw plugins inspect brave --runtime --json` for details.",
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("flags errored=true when smoke check finds a missing install path", async () => {
|
|
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
|
changes: [],
|
|
warnings: [],
|
|
records: { brave: { source: "npm" } },
|
|
});
|
|
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({
|
|
checked: ["brave"],
|
|
failures: [
|
|
{
|
|
pluginId: "brave",
|
|
reason: "missing-install-path",
|
|
detail: "Install path is missing from the plugin install record.",
|
|
},
|
|
],
|
|
});
|
|
const result = await runPostCorePluginConvergence({
|
|
cfg: {
|
|
plugins: { entries: { brave: { enabled: true } } },
|
|
} as unknown as OpenClawConfig,
|
|
env: {},
|
|
});
|
|
expect(result.errored).toBe(true);
|
|
expect(result.warnings).toStrictEqual([
|
|
{
|
|
pluginId: "brave",
|
|
reason: "missing-install-path: Install path is missing from the plugin install record.",
|
|
message:
|
|
'Plugin "brave" failed post-core payload smoke check (missing-install-path): Install path is missing from the plugin install record.',
|
|
guidance: [
|
|
"Run `openclaw doctor --fix` to retry plugin repair.",
|
|
"Run `openclaw plugins inspect brave --runtime --json` for details.",
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("hands repair's post-mutation records straight to the smoke check (no second disk read)", async () => {
|
|
const records = { brave: { source: "npm" as const, installPath: "/p/brave" } };
|
|
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
|
changes: ["Repaired"],
|
|
warnings: [],
|
|
records,
|
|
});
|
|
await runPostCorePluginConvergence({
|
|
cfg: {
|
|
plugins: { entries: { brave: { enabled: true } } },
|
|
} as unknown as OpenClawConfig,
|
|
env: {},
|
|
});
|
|
expect(mocks.runPluginPayloadSmokeCheck).toHaveBeenCalledTimes(1);
|
|
expect(mocks.runPluginPayloadSmokeCheck).toHaveBeenCalledWith({
|
|
records,
|
|
env: {
|
|
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
|
|
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("convergenceWarningsToOutcomes", () => {
|
|
it("emits per-plugin error outcomes for warnings that name a pluginId", () => {
|
|
const folded = convergenceWarningsToOutcomes({
|
|
changes: [],
|
|
warnings: [
|
|
{
|
|
pluginId: "brave",
|
|
reason: "missing-main-entry: …",
|
|
message: 'Plugin "brave" failed payload smoke check.',
|
|
guidance: ["Run `openclaw doctor --fix`."],
|
|
},
|
|
{
|
|
reason: "Failed install",
|
|
message: "Failed install for some plugin.",
|
|
guidance: ["Run `openclaw doctor --fix`."],
|
|
},
|
|
],
|
|
errored: true,
|
|
smokeFailures: [],
|
|
installRecords: {},
|
|
});
|
|
expect(folded.errored).toBe(true);
|
|
expect(folded.outcomes).toEqual([
|
|
{ pluginId: "brave", status: "error", message: 'Plugin "brave" failed payload smoke check.' },
|
|
]);
|
|
expect(folded.warnings).toHaveLength(2);
|
|
});
|
|
|
|
it("returns errored=false and no outcomes for a clean convergence", () => {
|
|
const folded = convergenceWarningsToOutcomes({
|
|
changes: ["Repaired."],
|
|
warnings: [],
|
|
errored: false,
|
|
smokeFailures: [],
|
|
installRecords: {},
|
|
});
|
|
expect(folded).toEqual({ warnings: [], outcomes: [], errored: false });
|
|
});
|
|
});
|
|
|
|
describe("filterRecordsToActive", () => {
|
|
it("retains records for plugins whose entry is enabled", () => {
|
|
const records = {
|
|
enabled: { source: "npm" as const, installPath: "/p/enabled" },
|
|
};
|
|
const filtered = filterRecordsToActive({
|
|
cfg: {
|
|
plugins: { enabled: true, entries: { enabled: { enabled: true } } },
|
|
} as unknown as OpenClawConfig,
|
|
records,
|
|
});
|
|
expect(filtered).toEqual(records);
|
|
});
|
|
|
|
it("drops records for plugins whose entry is explicitly disabled", () => {
|
|
const records = {
|
|
"stale-disabled": { source: "npm" as const, installPath: "/p/stale" },
|
|
"active-plugin": { source: "npm" as const, installPath: "/p/active" },
|
|
};
|
|
const filtered = filterRecordsToActive({
|
|
cfg: {
|
|
plugins: {
|
|
enabled: true,
|
|
entries: {
|
|
"stale-disabled": { enabled: false },
|
|
"active-plugin": { enabled: true },
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig,
|
|
records,
|
|
});
|
|
expect(filtered).toEqual({
|
|
"active-plugin": { source: "npm", installPath: "/p/active" },
|
|
});
|
|
});
|
|
|
|
it("drops records for plugins listed in plugins.deny", () => {
|
|
const records = {
|
|
denied: { source: "npm" as const, installPath: "/p/denied" },
|
|
};
|
|
const filtered = filterRecordsToActive({
|
|
cfg: {
|
|
plugins: {
|
|
enabled: true,
|
|
deny: ["denied"],
|
|
},
|
|
} as unknown as OpenClawConfig,
|
|
records,
|
|
});
|
|
expect(filtered).toEqual({});
|
|
});
|
|
|
|
it("retains a disabled trusted-source-linked official npm install (mirroring syncOfficialPluginInstalls policy)", () => {
|
|
// The Codex install record carries the trusted-source marker. The
|
|
// existing post-update sync path treats it as authoritative regardless
|
|
// of the entry's enable flag, so the convergence smoke check must too.
|
|
const records = {
|
|
codex: {
|
|
source: "npm" as const,
|
|
spec: "@openclaw/codex",
|
|
installPath: "/p/codex",
|
|
trustedSourceLinkedOfficial: true,
|
|
},
|
|
};
|
|
const filtered = filterRecordsToActive({
|
|
cfg: {
|
|
plugins: {
|
|
enabled: true,
|
|
entries: { codex: { enabled: false } },
|
|
},
|
|
} as unknown as OpenClawConfig,
|
|
records,
|
|
});
|
|
expect(filtered).toEqual(records);
|
|
});
|
|
});
|