mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 01:51:39 -06:00
8616c0c374
* test: finish shared helper migrations * test: fix helper migration CI * style: fix test import ordering * test(acpx): restore deferred void types * test: fix helper migrations after rebase
391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
// Covers loader precedence between a plugin's flat project dir and newer
|
|
// `__openclaw-generation__` dirs when reconciling persisted install records.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
|
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
|
import {
|
|
resolvePluginNpmGenerationProjectDir,
|
|
resolvePluginNpmProjectDir,
|
|
} from "./install-paths.js";
|
|
import {
|
|
clearLoadInstalledPluginIndexInstallRecordsCache,
|
|
loadInstalledPluginIndexInstallRecords,
|
|
loadInstalledPluginIndexInstallRecordsSync,
|
|
writePersistedInstalledPluginIndexInstallRecords,
|
|
} from "./installed-plugin-index-records.js";
|
|
import {
|
|
markRetainedManagedNpmInstall,
|
|
resolveRetainedManagedNpmInstallPackageInfo,
|
|
} from "./managed-npm-retention.js";
|
|
import { writeManagedNpmPlugin } from "./test-helpers/managed-npm-plugin.js";
|
|
|
|
const PACKAGE_NAME = "@openclaw/discord";
|
|
const PLUGIN_ID = "discord";
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
|
|
function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
|
|
if (!record || typeof record !== "object") {
|
|
throw new Error("Expected record");
|
|
}
|
|
const actual = record as Record<string, unknown>;
|
|
for (const [key, value] of Object.entries(expected)) {
|
|
expect(actual[key]).toEqual(value);
|
|
}
|
|
return actual;
|
|
}
|
|
|
|
/** Writes a managed plugin version into an `__openclaw-generation__` dir. */
|
|
function writeManagedGeneration(params: {
|
|
stateDir: string;
|
|
version: string;
|
|
generationKey: string;
|
|
}): string {
|
|
const npmDir = path.join(params.stateDir, "npm");
|
|
writeManagedNpmPlugin({
|
|
stateDir: params.stateDir,
|
|
packageName: PACKAGE_NAME,
|
|
pluginId: PLUGIN_ID,
|
|
version: params.version,
|
|
});
|
|
const flatProjectRoot = resolvePluginNpmProjectDir({ npmDir, packageName: PACKAGE_NAME });
|
|
const generationProjectRoot = resolvePluginNpmGenerationProjectDir({
|
|
npmDir,
|
|
packageName: PACKAGE_NAME,
|
|
generationKey: params.generationKey,
|
|
});
|
|
fs.renameSync(flatProjectRoot, generationProjectRoot);
|
|
return path.join(generationProjectRoot, "node_modules", ...PACKAGE_NAME.split("/"));
|
|
}
|
|
|
|
/** Writes a managed plugin version into the flat project dir and leaves it there. */
|
|
function writeManagedFlat(stateDir: string, version: string): string {
|
|
const npmDir = path.join(stateDir, "npm");
|
|
writeManagedNpmPlugin({ stateDir, packageName: PACKAGE_NAME, pluginId: PLUGIN_ID, version });
|
|
const flatProjectRoot = resolvePluginNpmProjectDir({ npmDir, packageName: PACKAGE_NAME });
|
|
return path.join(flatProjectRoot, "node_modules", ...PACKAGE_NAME.split("/"));
|
|
}
|
|
|
|
function writeManagedLegacy(stateDir: string, version: string): string {
|
|
return writeManagedNpmPlugin({
|
|
stateDir,
|
|
packageName: PACKAGE_NAME,
|
|
pluginId: PLUGIN_ID,
|
|
version,
|
|
layout: "legacy",
|
|
});
|
|
}
|
|
|
|
function setInstallTimestamp(packageDir: string, timestampMs: number): void {
|
|
const packageInfo = resolveRetainedManagedNpmInstallPackageInfo(packageDir);
|
|
if (!packageInfo) {
|
|
throw new Error(`Expected managed npm package dir: ${packageDir}`);
|
|
}
|
|
const timestamp = new Date(timestampMs);
|
|
fs.utimesSync(path.join(packageDir, "package.json"), timestamp, timestamp);
|
|
fs.utimesSync(packageDir, timestamp, timestamp);
|
|
fs.utimesSync(path.join(packageInfo.projectRoot, "package.json"), timestamp, timestamp);
|
|
fs.utimesSync(packageInfo.projectRoot, timestamp, timestamp);
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
closeOpenClawStateDatabaseForTest();
|
|
clearLoadInstalledPluginIndexInstallRecordsCache();
|
|
});
|
|
|
|
describe("managed npm generation-dir loader precedence", () => {
|
|
it("loads the authoritative generation after an upgrade leaves the old flat install", async () => {
|
|
const stateDir = tempDirs.make("openclaw-plugin-generation-precedence-");
|
|
const staleVersion = "2026.6.11";
|
|
const activeVersion = "2026.7.1";
|
|
|
|
const activePackageDir = writeManagedGeneration({
|
|
stateDir,
|
|
version: activeVersion,
|
|
generationKey: `discord-${activeVersion}`,
|
|
});
|
|
// Recreate the prior version at the flat project dir so it is still present
|
|
// on disk (the case `isUnavailableManagedNpmInstallRecord` does not cover).
|
|
const stalePackageDir = writeManagedFlat(stateDir, staleVersion);
|
|
|
|
await writePersistedInstalledPluginIndexInstallRecords(
|
|
{
|
|
discord: {
|
|
source: "npm",
|
|
spec: `${PACKAGE_NAME}@latest`,
|
|
installPath: activePackageDir,
|
|
version: activeVersion,
|
|
resolvedName: PACKAGE_NAME,
|
|
resolvedVersion: activeVersion,
|
|
resolvedSpec: `${PACKAGE_NAME}@${activeVersion}`,
|
|
integrity: "sha512-active",
|
|
},
|
|
},
|
|
{ stateDir, candidates: [] },
|
|
);
|
|
|
|
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
|
expectRecordFields(loaded.discord, {
|
|
source: "npm",
|
|
spec: `${PACKAGE_NAME}@latest`,
|
|
installPath: activePackageDir,
|
|
version: activeVersion,
|
|
resolvedName: PACKAGE_NAME,
|
|
resolvedVersion: activeVersion,
|
|
resolvedSpec: `${PACKAGE_NAME}@${activeVersion}`,
|
|
integrity: "sha512-active",
|
|
});
|
|
expect(fs.existsSync(stalePackageDir)).toBe(true);
|
|
|
|
clearLoadInstalledPluginIndexInstallRecordsCache();
|
|
expectRecordFields(loadInstalledPluginIndexInstallRecordsSync({ stateDir }).discord, {
|
|
installPath: activePackageDir,
|
|
resolvedVersion: activeVersion,
|
|
});
|
|
});
|
|
|
|
it("preserves an intentional downgrade when a newer generation lingers", async () => {
|
|
const stateDir = tempDirs.make("openclaw-plugin-generation-precedence-");
|
|
const newerPackageDir = writeManagedGeneration({
|
|
stateDir,
|
|
version: "3.0.0",
|
|
generationKey: "discord-three",
|
|
});
|
|
const downgradedPackageDir = writeManagedFlat(stateDir, "1.0.0");
|
|
|
|
await writePersistedInstalledPluginIndexInstallRecords(
|
|
{
|
|
discord: {
|
|
source: "npm",
|
|
spec: `${PACKAGE_NAME}@1.0.0`,
|
|
installPath: downgradedPackageDir,
|
|
version: "1.0.0",
|
|
resolvedName: PACKAGE_NAME,
|
|
resolvedVersion: "1.0.0",
|
|
resolvedSpec: `${PACKAGE_NAME}@1.0.0`,
|
|
},
|
|
},
|
|
{ stateDir, candidates: [] },
|
|
);
|
|
|
|
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
|
expectRecordFields(loaded.discord, {
|
|
installPath: downgradedPackageDir,
|
|
resolvedVersion: "1.0.0",
|
|
});
|
|
expect(fs.existsSync(newerPackageDir)).toBe(true);
|
|
});
|
|
|
|
it("matches the authoritative generation case-insensitively on Windows", async () => {
|
|
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
|
const stateDir = tempDirs.make("openclaw-plugin-generation-precedence-");
|
|
const newerPackageDir = writeManagedGeneration({
|
|
stateDir,
|
|
version: "3.0.0",
|
|
generationKey: "discord-newer-on-windows",
|
|
});
|
|
const downgradedPackageDir = writeManagedFlat(stateDir, "1.0.0");
|
|
setInstallTimestamp(downgradedPackageDir, Date.UTC(2026, 0, 1));
|
|
setInstallTimestamp(newerPackageDir, Date.UTC(2026, 0, 2));
|
|
const differentlyCasedActivePath = downgradedPackageDir.replace(
|
|
stateDir,
|
|
stateDir.toUpperCase(),
|
|
);
|
|
|
|
await writePersistedInstalledPluginIndexInstallRecords(
|
|
{
|
|
discord: {
|
|
source: "npm",
|
|
spec: `${PACKAGE_NAME}@1.0.0`,
|
|
installPath: differentlyCasedActivePath,
|
|
version: "1.0.0",
|
|
resolvedName: PACKAGE_NAME,
|
|
resolvedVersion: "1.0.0",
|
|
resolvedSpec: `${PACKAGE_NAME}@1.0.0`,
|
|
},
|
|
},
|
|
{ stateDir, candidates: [] },
|
|
);
|
|
const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
|
|
|
|
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
|
const record = expectRecordFields(loaded.discord, { resolvedVersion: "1.0.0" });
|
|
expect(String(record.installPath).toLowerCase()).toBe(downgradedPackageDir.toLowerCase());
|
|
expect(emitWarning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses install recency with a structured warning when no authority exists", async () => {
|
|
const stateDir = tempDirs.make("openclaw-plugin-generation-precedence-");
|
|
const recentPackageDir = writeManagedGeneration({
|
|
stateDir,
|
|
version: "1.0.0",
|
|
generationKey: "discord-recent",
|
|
});
|
|
const olderPackageDir = writeManagedFlat(stateDir, "9.0.0");
|
|
setInstallTimestamp(olderPackageDir, Date.UTC(2026, 0, 1));
|
|
setInstallTimestamp(recentPackageDir, Date.UTC(2026, 0, 2));
|
|
const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
|
|
|
|
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
|
expectRecordFields(loaded.discord, {
|
|
installPath: recentPackageDir,
|
|
resolvedVersion: "1.0.0",
|
|
});
|
|
expect(emitWarning).toHaveBeenCalledWith(
|
|
expect.stringContaining("without an authoritative active path"),
|
|
expect.objectContaining({
|
|
code: "OPENCLAW_PLUGIN_INSTALL_RECOVERY_FALLBACK",
|
|
type: "OpenClawPluginRecoveryWarning",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("warns when install recency replaces a dangling managed authority", async () => {
|
|
const stateDir = tempDirs.make("openclaw-plugin-generation-precedence-");
|
|
const npmDir = path.join(stateDir, "npm");
|
|
const recentPackageDir = writeManagedGeneration({
|
|
stateDir,
|
|
version: "1.0.0",
|
|
generationKey: "discord-recent-after-dangling",
|
|
});
|
|
const olderPackageDir = writeManagedFlat(stateDir, "9.0.0");
|
|
setInstallTimestamp(olderPackageDir, Date.UTC(2026, 0, 1));
|
|
setInstallTimestamp(recentPackageDir, Date.UTC(2026, 0, 2));
|
|
const missingPackageDir = path.join(
|
|
resolvePluginNpmGenerationProjectDir({
|
|
npmDir,
|
|
packageName: PACKAGE_NAME,
|
|
generationKey: "discord-missing",
|
|
}),
|
|
"node_modules",
|
|
...PACKAGE_NAME.split("/"),
|
|
);
|
|
await writePersistedInstalledPluginIndexInstallRecords(
|
|
{
|
|
discord: {
|
|
source: "npm",
|
|
spec: `${PACKAGE_NAME}@latest`,
|
|
installPath: missingPackageDir,
|
|
resolvedName: PACKAGE_NAME,
|
|
resolvedVersion: "2.0.0",
|
|
},
|
|
},
|
|
{ stateDir, candidates: [] },
|
|
);
|
|
const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
|
|
|
|
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
|
expectRecordFields(loaded.discord, {
|
|
installPath: recentPackageDir,
|
|
resolvedVersion: "1.0.0",
|
|
});
|
|
expect(emitWarning).toHaveBeenCalledWith(
|
|
expect.stringContaining("without an authoritative active path"),
|
|
expect.objectContaining({ code: "OPENCLAW_PLUGIN_INSTALL_RECOVERY_FALLBACK" }),
|
|
);
|
|
});
|
|
|
|
it("does not treat unrelated legacy-root metadata changes as plugin recency", async () => {
|
|
const stateDir = tempDirs.make("openclaw-plugin-generation-precedence-");
|
|
const recentPackageDir = writeManagedGeneration({
|
|
stateDir,
|
|
version: "1.0.0",
|
|
generationKey: "discord-recent-from-legacy",
|
|
});
|
|
const olderPackageDir = writeManagedLegacy(stateDir, "9.0.0");
|
|
setInstallTimestamp(olderPackageDir, Date.UTC(2026, 0, 1));
|
|
setInstallTimestamp(recentPackageDir, Date.UTC(2026, 0, 2));
|
|
writeManagedNpmPlugin({
|
|
stateDir,
|
|
packageName: "@openclaw/unrelated",
|
|
pluginId: "unrelated",
|
|
version: "1.0.0",
|
|
layout: "legacy",
|
|
});
|
|
vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
|
|
|
|
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
|
expectRecordFields(loaded.discord, {
|
|
installPath: recentPackageDir,
|
|
resolvedVersion: "1.0.0",
|
|
});
|
|
});
|
|
|
|
it("excludes doctor-retired generations when recovering without authority", async () => {
|
|
const stateDir = tempDirs.make("openclaw-plugin-generation-precedence-");
|
|
const retiredPackageDir = writeManagedGeneration({
|
|
stateDir,
|
|
version: "3.0.0",
|
|
generationKey: "discord-retired",
|
|
});
|
|
const recoveredPackageDir = writeManagedFlat(stateDir, "1.0.0");
|
|
await markRetainedManagedNpmInstall({
|
|
packageDir: retiredPackageDir,
|
|
pluginId: PLUGIN_ID,
|
|
reason: "test-retired-generation",
|
|
});
|
|
|
|
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
|
expectRecordFields(loaded.discord, {
|
|
installPath: recoveredPackageDir,
|
|
resolvedVersion: "1.0.0",
|
|
});
|
|
});
|
|
|
|
it("excludes a doctor-retired legacy-root package when recovering without authority", async () => {
|
|
const stateDir = tempDirs.make("openclaw-plugin-generation-precedence-");
|
|
const retiredPackageDir = writeManagedLegacy(stateDir, "3.0.0");
|
|
const recoveredPackageDir = writeManagedGeneration({
|
|
stateDir,
|
|
version: "1.0.0",
|
|
generationKey: "discord-after-retired-legacy",
|
|
});
|
|
await markRetainedManagedNpmInstall({
|
|
packageDir: retiredPackageDir,
|
|
pluginId: PLUGIN_ID,
|
|
reason: "test-retired-legacy-root-package",
|
|
});
|
|
|
|
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
|
expectRecordFields(loaded.discord, {
|
|
installPath: recoveredPackageDir,
|
|
resolvedVersion: "1.0.0",
|
|
});
|
|
});
|
|
|
|
it("does not repoint an intentional custom npm install outside the managed root", async () => {
|
|
const stateDir = tempDirs.make("openclaw-plugin-generation-precedence-");
|
|
// A managed generation with a higher version exists on disk...
|
|
writeManagedGeneration({ stateDir, version: "2.0.0", generationKey: "discord-managed" });
|
|
// ...but the persisted record points at a custom install outside the npm root.
|
|
const customInstallPath = path.join(stateDir, "custom", "node_modules", "@openclaw", "discord");
|
|
|
|
await writePersistedInstalledPluginIndexInstallRecords(
|
|
{
|
|
discord: {
|
|
source: "npm",
|
|
spec: `${PACKAGE_NAME}@beta`,
|
|
installPath: customInstallPath,
|
|
version: "1.0.0",
|
|
resolvedName: PACKAGE_NAME,
|
|
resolvedVersion: "1.0.0",
|
|
resolvedSpec: `${PACKAGE_NAME}@1.0.0`,
|
|
integrity: "sha512-custom",
|
|
},
|
|
},
|
|
{ stateDir, candidates: [] },
|
|
);
|
|
|
|
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
|
expectRecordFields(loaded.discord, {
|
|
source: "npm",
|
|
spec: `${PACKAGE_NAME}@beta`,
|
|
installPath: customInstallPath,
|
|
resolvedVersion: "1.0.0",
|
|
integrity: "sha512-custom",
|
|
});
|
|
});
|
|
});
|