fix: stabilize tests and reduce plugin memory churn

This commit is contained in:
Peter Steinberger
2026-05-25 23:57:17 +01:00
parent 1d21224de3
commit c1a026a976
18 changed files with 155 additions and 63 deletions

View File

@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
import path from "node:path";
import { promisify } from "node:util";
import { splitCommandParts } from "./command-line.js";
import { resolveAcpxPluginRoot } from "./config.js";
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
const execFileAsync = promisify(execFile);
@@ -24,8 +25,7 @@ const OWNED_ACP_PACKAGE_NAMES = [
"acpx",
];
const ACP_PACKAGE_MARKERS = [
"/@zed-industries/codex-acp/",
"/@agentclientprotocol/claude-agent-acp/",
...OWNED_ACP_PACKAGE_NAMES.map((packageName) => `/node_modules/${packageName}/`),
"/acpx/dist/",
];
@@ -65,8 +65,29 @@ function resolvePackageRoot(packageName: string): string | undefined {
}
}
const OWNED_ACP_PACKAGE_ROOTS = OWNED_ACP_PACKAGE_NAMES.map(resolvePackageRoot).filter(
(root): root is string => Boolean(root),
function resolveOpenClawInstallRoot(pluginRoot: string): string {
if (
path.basename(pluginRoot) === "acpx" &&
path.basename(path.dirname(pluginRoot)) === "extensions"
) {
const parent = path.dirname(path.dirname(pluginRoot));
return path.basename(parent) === "dist" ? path.dirname(parent) : parent;
}
return path.resolve(pluginRoot, "..");
}
function resolveOwnedAcpPackageRootCandidates(packageName: string): string[] {
const pluginRoot = resolveAcpxPluginRoot(import.meta.url);
const openClawRoot = resolveOpenClawInstallRoot(pluginRoot);
return [
resolvePackageRoot(packageName),
path.join(pluginRoot, "node_modules", packageName),
path.join(openClawRoot, "node_modules", packageName),
].flatMap((root) => (root ? [normalizePathLike(root)] : []));
}
const OWNED_ACP_PACKAGE_ROOTS = Array.from(
new Set(OWNED_ACP_PACKAGE_NAMES.flatMap(resolveOwnedAcpPackageRootCandidates)),
);
function commandBelongsToResolvedAcpPackage(command: string): boolean {

View File

@@ -13,14 +13,18 @@ const { buildSessionEntryMock } = vi.hoisted(() => ({
buildSessionEntryMock: vi.fn(),
}));
vi.mock("undici", () => ({
Agent: vi.fn(),
EnvHttpProxyAgent: vi.fn(),
ProxyAgent: vi.fn(),
fetch: vi.fn(),
getGlobalDispatcher: vi.fn(),
setGlobalDispatcher: vi.fn(),
}));
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
return {
...actual,
Agent: vi.fn(),
EnvHttpProxyAgent: vi.fn(),
ProxyAgent: vi.fn(),
fetch: vi.fn(),
getGlobalDispatcher: vi.fn(),
setGlobalDispatcher: vi.fn(),
};
});
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", () => {
const basename = (filePath: string) => filePath.split(/[\\/]/).pop() ?? filePath;

View File

@@ -9,11 +9,15 @@ const closeGlobalDispatcher = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./runners/contract/runtime.js", () => ({
runMatrixQaLive,
}));
vi.mock("undici", () => ({
getGlobalDispatcher: () => ({
close: closeGlobalDispatcher,
}),
}));
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
return {
...actual,
getGlobalDispatcher: () => ({
close: closeGlobalDispatcher,
}),
};
});
import { runQaMatrixCommand } from "./cli.runtime.js";

View File

@@ -7,17 +7,20 @@ const ssrfMocks = {
};
// Mock http and https modules before importing the client
vi.mock("node:https", () => {
vi.mock("node:https", async () => {
const actual = await vi.importActual<typeof import("node:https")>("node:https");
const httpsRequest = vi.fn();
const httpsGet = vi.fn();
const httpsModule = { request: httpsRequest, get: httpsGet };
return { default: httpsModule, request: httpsRequest, get: httpsGet };
const httpsModule = { ...actual, request: httpsRequest, get: httpsGet };
return { ...actual, default: httpsModule, request: httpsRequest, get: httpsGet };
});
vi.mock("node:http", () => {
vi.mock("node:http", async () => {
const actual = await vi.importActual<typeof import("node:http")>("node:http");
const httpRequest = vi.fn();
const httpGet = vi.fn();
return { default: { request: httpRequest, get: httpGet }, request: httpRequest, get: httpGet };
const httpModule = { ...actual, request: httpRequest, get: httpGet };
return { ...actual, default: httpModule, request: httpRequest, get: httpGet };
});
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
@@ -275,6 +278,10 @@ describe("resolveLegacyWebhookNameToChatUserId", () => {
const baseUrl2 =
"https://nas2.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test-2%22";
beforeAll(async () => {
({ resolveLegacyWebhookNameToChatUserId } = await import("./client.js"));
});
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();

View File

@@ -52,11 +52,15 @@ afterEach(() => {
vi.unstubAllEnvs();
});
vi.mock("undici", () => ({
ProxyAgent: proxyMocks.ProxyAgent,
fetch: proxyMocks.undiciFetch,
setGlobalDispatcher: proxyMocks.setGlobalDispatcher,
}));
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
return {
...actual,
ProxyAgent: proxyMocks.ProxyAgent,
fetch: proxyMocks.undiciFetch,
setGlobalDispatcher: proxyMocks.setGlobalDispatcher,
};
});
describe("fetchTelegramChatId", () => {
const cases = [

View File

@@ -65,13 +65,17 @@ vi.mock("node:net", async () => {
};
});
vi.mock("undici", () => ({
Agent: AgentCtor,
EnvHttpProxyAgent: EnvHttpProxyAgentCtor,
ProxyAgent: ProxyAgentCtor,
fetch: undiciFetch,
setGlobalDispatcher,
}));
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
return {
...actual,
Agent: AgentCtor,
EnvHttpProxyAgent: EnvHttpProxyAgentCtor,
ProxyAgent: ProxyAgentCtor,
fetch: undiciFetch,
setGlobalDispatcher,
};
});
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
createSubsystemLogger: () => ({

View File

@@ -139,13 +139,17 @@ vi.mock("grammy", () => ({
InputFile: function InputFile() {},
}));
vi.mock("undici", () => ({
Agent: undiciAgentCtor,
EnvHttpProxyAgent: undiciEnvHttpProxyAgentCtor,
ProxyAgent: undiciProxyAgentCtor,
fetch: undiciFetch,
setGlobalDispatcher: undiciSetGlobalDispatcher,
}));
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
return {
...actual,
Agent: undiciAgentCtor,
EnvHttpProxyAgent: undiciEnvHttpProxyAgentCtor,
ProxyAgent: undiciProxyAgentCtor,
fetch: undiciFetch,
setGlobalDispatcher: undiciSetGlobalDispatcher,
};
});
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-config-runtime")>(

View File

@@ -26,10 +26,14 @@ const { envHttpProxyAgentCtor, proxyAgentCtor } = vi.hoisted(() => ({
const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__";
vi.mock("undici", () => ({
EnvHttpProxyAgent: envHttpProxyAgentCtor,
ProxyAgent: proxyAgentCtor,
}));
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
return {
...actual,
EnvHttpProxyAgent: envHttpProxyAgentCtor,
ProxyAgent: proxyAgentCtor,
};
});
const useMultiFileAuthStateMock = vi.mocked(baileys.useMultiFileAuthState);

View File

@@ -20,6 +20,7 @@ const hoisted = await vi.hoisted(async () => {
mkdirMock: vi.fn(async (_filePath: string, _options?: { recursive?: boolean }) => undefined),
accessMock: vi.fn(async (_filePath: string) => undefined),
pathExistsMock: vi.fn(async (_filePath: string) => true),
migrateSessionEntriesMock: vi.fn((_entries: unknown[]) => undefined),
exportHtmlTemplateContents: new Map<string, string>(),
sessionTranscriptContent: "",
};
@@ -43,6 +44,10 @@ vi.mock("../../infra/fs-safe.js", () => ({
pathExists: hoisted.pathExistsMock,
}));
vi.mock("@earendil-works/pi-coding-agent", () => ({
migrateSessionEntries: hoisted.migrateSessionEntriesMock,
}));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
const mockedFs = {

View File

@@ -1,11 +1,10 @@
import fsp from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
migrateSessionEntries,
type FileEntry as PiSessionFileEntry,
type SessionEntry as PiSessionEntry,
type SessionHeader,
import type {
FileEntry as PiSessionFileEntry,
SessionEntry as PiSessionEntry,
SessionHeader,
} from "@earendil-works/pi-coding-agent";
import { pathExists } from "../../infra/fs-safe.js";
import { isRecord } from "../../shared/record-coerce.js";
@@ -44,6 +43,11 @@ async function loadTemplate(fileName: string): Promise<string> {
return await fsp.readFile(path.join(EXPORT_HTML_DIR, fileName), "utf-8");
}
async function migratePiSessionEntries(fileEntries: PiSessionFileEntry[]): Promise<void> {
const { migrateSessionEntries } = await import("@earendil-works/pi-coding-agent");
migrateSessionEntries(fileEntries);
}
function replaceHtmlPlaceholder(template: string, name: string, value: string): string {
let replaced = false;
const placeholder = new RegExp(
@@ -243,7 +247,7 @@ async function readSessionDataFromTranscript(sessionFile: string): Promise<{
}> {
const raw = await fsp.readFile(sessionFile, "utf-8");
const { entries: fileEntries, warnings } = parseSessionEntriesWithWarnings(raw);
migrateSessionEntries(fileEntries);
await migratePiSessionEntries(fileEntries);
const header =
fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null;
const entries = fileEntries.filter((entry): entry is PiSessionEntry => entry.type !== "session");

View File

@@ -16,9 +16,13 @@ const { spawnMock } = vi.hoisted(() => ({
})),
}));
vi.mock("node:child_process", () => ({
spawn: spawnMock,
}));
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{ spawn: spawnMock },
);
});
const tempDirs = new Set<string>();

View File

@@ -8,9 +8,13 @@ const mocks = vi.hoisted(() => ({
spawn: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: mocks.spawn,
}));
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{ spawn: mocks.spawn },
);
});
vi.mock("../agents/skills.js", () => ({
hasBinary: mocks.hasBinary,

View File

@@ -30,13 +30,18 @@ export function registryContainsRuntimePluginIds(
}
const present = new Set<string>();
const loaded = new Set<string>();
const pluginStatusById = new Map<string, string | undefined>();
for (const plugin of registry.plugins ?? []) {
present.add(plugin.id);
pluginStatusById.set(plugin.id, plugin.status);
if (plugin.status === undefined || plugin.status === "loaded") {
loaded.add(plugin.id);
}
}
for (const value of Object.values(registry)) {
for (const [key, value] of Object.entries(registry)) {
if (key === "diagnostics" || key === "channelSetups") {
continue;
}
if (!Array.isArray(value)) {
continue;
}
@@ -45,6 +50,10 @@ export function registryContainsRuntimePluginIds(
const pluginId = entry.pluginId;
if (typeof pluginId === "string" && pluginId.length > 0) {
present.add(pluginId);
const status = pluginStatusById.get(pluginId);
if (status === undefined || status === "loaded") {
loaded.add(pluginId);
}
}
}
}

View File

@@ -138,6 +138,7 @@ let resolveBundledCapabilityProviderIds: typeof import("./capability-provider-ru
let resolveManifestCapabilityProviderIds: typeof import("./capability-provider-runtime.js").resolveManifestCapabilityProviderIds;
let clearCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").clearCurrentPluginMetadataSnapshot;
let setCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").setCurrentPluginMetadataSnapshot;
let clearPluginMetadataLifecycleCaches: typeof import("./plugin-metadata-lifecycle.js").clearPluginMetadataLifecycleCaches;
function expectResolvedCapabilityProviderIds(providers: Array<{ id: string }>, expected: string[]) {
expect(providers.map((provider) => provider.id)).toEqual(expected);
@@ -307,10 +308,12 @@ describe("resolvePluginCapabilityProviders", () => {
} = await import("./capability-provider-runtime.js"));
({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } =
await import("./current-plugin-metadata-snapshot.js"));
({ clearPluginMetadataLifecycleCaches } = await import("./plugin-metadata-lifecycle.js"));
});
beforeEach(() => {
clearCurrentPluginMetadataSnapshot();
clearPluginMetadataLifecycleCaches();
mocks.resolveRuntimePluginRegistry.mockReset();
mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined);
mocks.resolvePluginRegistryLoadCacheKey.mockReset();
@@ -1167,7 +1170,7 @@ describe("resolvePluginCapabilityProviders", () => {
});
});
it("reads manifest-derived capability plugin ids for each config snapshot", () => {
it("reuses manifest metadata while applying compat for each config snapshot", () => {
const { cfg, enablementCompat } = createCompatChainConfig();
setBundledCapabilityFixture("mediaUnderstandingProviders");
mocks.withBundledPluginEnablementCompat.mockReturnValue(enablementCompat);
@@ -1180,7 +1183,7 @@ describe("resolvePluginCapabilityProviders", () => {
resolvePluginCapabilityProviders({ key: "mediaUnderstandingProviders", cfg }),
);
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(2);
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(1);
expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledTimes(2);
expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({
config: cfg,
@@ -1222,7 +1225,7 @@ describe("resolvePluginCapabilityProviders", () => {
expect(snapshotLoads).toHaveLength(1);
});
it("resolves manifest-derived capability plugin ids for equivalent config snapshots independently", () => {
it("reuses equivalent manifest metadata while applying compat per config object", () => {
const first = createCompatChainConfig();
const second = createCompatChainConfig();
setBundledCapabilityFixture("mediaUnderstandingProviders");
@@ -1242,7 +1245,7 @@ describe("resolvePluginCapabilityProviders", () => {
}),
);
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(2);
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(1);
expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledTimes(2);
expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenNthCalledWith(1, {
config: first.cfg,

View File

@@ -70,9 +70,12 @@ async function withIsolatedSpeechProviderEnvAsync<T>(
return await withEnvAsync(isolatedSpeechProviderEnv(overrides), fn);
}
vi.mock("@earendil-works/pi-ai", () => {
vi.mock("@earendil-works/pi-ai", async () => {
const actual =
await vi.importActual<typeof import("@earendil-works/pi-ai")>("@earendil-works/pi-ai");
const getApiProvider = vi.fn(() => undefined);
return {
...actual,
completeSimple: vi.fn(),
createAssistantMessageEventStream: vi.fn(),
getApiProvider,

View File

@@ -4,6 +4,7 @@ import {
clearPluginHostRuntimeState,
dispatchPluginAgentEventSubscriptions,
} from "./host-hook-runtime.js";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { markPluginRegistryActive, markPluginRegistryRetired } from "./registry-lifecycle.js";
import type { PluginRegistry } from "./registry-types.js";
@@ -378,4 +379,5 @@ export function resetPluginRuntimeStateForTest(): void {
// Otherwise per-test bleed-over of those globals can cause flaky behavior
// since this helper is widely used across plugin/agent tests.
clearPluginHostRuntimeState();
clearPluginMetadataLifecycleCaches();
}

View File

@@ -250,6 +250,7 @@ describe("install-cli.sh", () => {
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
`export PATH=${JSON.stringify(bin)}`,
"os_detect() { printf 'linux\\n'; }",
"arch_detect() { printf 'x64\\n'; }",
"is_musl_linux() { return 0; }",
@@ -361,6 +362,7 @@ describe("install-cli.sh", () => {
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
`export PATH=${JSON.stringify(`${nodePrefixBin}:${oldBin}:${bin}`)}`,
"os_detect() { printf 'linux\\n'; }",
"arch_detect() { printf 'x64\\n'; }",
"is_musl_linux() { return 0; }",
@@ -442,6 +444,7 @@ describe("install-cli.sh", () => {
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
`export PATH=${JSON.stringify(bin)}`,
"os_detect() { printf 'linux\\n'; }",
"arch_detect() { printf 'x64\\n'; }",
"is_musl_linux() { return 0; }",
@@ -514,6 +517,7 @@ describe("install-cli.sh", () => {
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
`export PATH=${JSON.stringify(bin)}`,
"os_detect() { printf 'linux\\n'; }",
"arch_detect() { printf 'x64\\n'; }",
"is_musl_linux() { return 0; }",

View File

@@ -29,12 +29,14 @@ exit 0
function runEnsureNode(root: string, requested: string, extraEnv: NodeJS.ProcessEnv = {}) {
const githubPath = join(root, "github-path");
const pathOverride = extraEnv.PATH;
const result = spawnSync(
"bash",
[
"-c",
[
"set -e",
...(pathOverride ? [`export PATH=${JSON.stringify(pathOverride)}`] : []),
`source "${ensureNodeScript}"`,
`openclaw_ensure_node "${requested}"`,
"command -v node",