mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 20:11:14 +00:00
* ci: enforce max-lines suppression ratchet * chore: prune stale max-lines suppression * fix: close max-lines ratchet enforcement gaps * fix: harden max-lines ratchet checks * fix(ci): satisfy max-lines ratchet checks * style: format max-lines declarations * fix(ci): match oxlint suppression grammar * test: isolate max-lines git fixtures * chore: prune resolved max-lines debt * test: skip newline path fixture on Windows * fix: harden max-lines suppression ratchet * chore: refresh max-lines baseline * fix: close max-lines ratchet bypasses * fix: derive ratchet base from PR merge tree * fix: support older Git in staged ratchet * fix: align max-lines declarations and baseline * chore: refresh max-lines baseline for current main * fix: exclude generated wizard locales from max-lines * chore: prune resolved max-lines debt
1006 lines
30 KiB
TypeScript
1006 lines
30 KiB
TypeScript
/** Broad plugin loader coverage for manifest discovery, runtime registration, and diagnostics. */
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { expect } from "vitest";
|
|
import { listRegisteredAgentHarnesses } from "../agents/harness/registry.js";
|
|
import { clearRuntimeConfigSnapshot } from "../config/runtime-snapshot.js";
|
|
import { resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js";
|
|
import type { DetachedTaskLifecycleRuntime } from "../tasks/detached-task-runtime-contract.js";
|
|
import { withEnv } from "../test-utils/env.js";
|
|
import {
|
|
getRegisteredEmbeddingProvider,
|
|
listRegisteredEmbeddingProviders,
|
|
} from "./embedding-providers.js";
|
|
import { getGlobalHookRunner } from "./hook-runner-global.js";
|
|
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
|
|
import {
|
|
cleanupPluginLoaderFixturesForTest,
|
|
EMPTY_PLUGIN_SCHEMA,
|
|
makeTempDir,
|
|
mkdirSafe,
|
|
type PluginRegistry,
|
|
resetPluginLoaderTestStateForTest,
|
|
type TempPlugin,
|
|
useNoBundledPlugins,
|
|
writePlugin,
|
|
} from "./loader.test-fixtures.js";
|
|
import { testing as runtimeRegistryLoaderTesting } from "./runtime/runtime-registry-loader.js";
|
|
|
|
export const getEmbeddingProvider = (id: string) => getRegisteredEmbeddingProvider(id)?.adapter;
|
|
|
|
export const listEmbeddingProviders = () =>
|
|
listRegisteredEmbeddingProviders().map((entry) => entry.adapter);
|
|
|
|
export let cachedBundledTelegramDir = "";
|
|
|
|
export let cachedBundledMemoryDir = "";
|
|
|
|
export type GlobalHookRunner = NonNullable<ReturnType<typeof getGlobalHookRunner>>;
|
|
|
|
export type PluginStartupTraceDetail = {
|
|
name: string;
|
|
metrics: ReadonlyArray<readonly [string, number | string]>;
|
|
};
|
|
|
|
export function listRegisteredAgentHarnessIdsForTest(): string[] {
|
|
return listRegisteredAgentHarnesses().map((entry) => entry.harness.id);
|
|
}
|
|
|
|
export function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
|
|
let count = 0;
|
|
for (const item of items) {
|
|
if (predicate(item)) {
|
|
count += 1;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
export function expectGlobalHookRunner(
|
|
runner: ReturnType<typeof getGlobalHookRunner>,
|
|
): GlobalHookRunner {
|
|
if (runner === null) {
|
|
throw new Error("Expected global hook runner");
|
|
}
|
|
expect(typeof runner.hasHooks).toBe("function");
|
|
return runner;
|
|
}
|
|
|
|
export function createDetachedTaskRuntimeStub(id: string): DetachedTaskLifecycleRuntime {
|
|
const fail = (name: string): never => {
|
|
throw new Error(`detached runtime ${id} should not execute ${name} in this test`);
|
|
};
|
|
return {
|
|
createQueuedTaskRun: () => fail("createQueuedTaskRun"),
|
|
createRunningTaskRun: () => fail("createRunningTaskRun"),
|
|
startTaskRunByRunId: () => fail("startTaskRunByRunId"),
|
|
recordTaskRunProgressByRunId: () => fail("recordTaskRunProgressByRunId"),
|
|
finalizeTaskRunByRunId: () => fail("finalizeTaskRunByRunId"),
|
|
completeTaskRunByRunId: () => fail("completeTaskRunByRunId"),
|
|
failTaskRunByRunId: () => fail("failTaskRunByRunId"),
|
|
setDetachedTaskDeliveryStatusByRunId: () => fail("setDetachedTaskDeliveryStatusByRunId"),
|
|
cancelDetachedTaskRunById: async () => ({
|
|
found: true,
|
|
cancelled: true,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export const BUNDLED_TELEGRAM_PLUGIN_BODY = `module.exports = {
|
|
id: "telegram",
|
|
register(api) {
|
|
api.registerChannel({
|
|
plugin: {
|
|
id: "telegram",
|
|
meta: {
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
selectionLabel: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "telegram channel",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => [],
|
|
resolveAccount: () => ({ accountId: "default" }),
|
|
},
|
|
outbound: { deliveryMode: "direct" },
|
|
},
|
|
});
|
|
},
|
|
};`;
|
|
|
|
export function simplePluginBody(id: string) {
|
|
return `module.exports = { id: ${JSON.stringify(id)}, register() {} };`;
|
|
}
|
|
|
|
export function updatePluginManifest(
|
|
plugin: Pick<TempPlugin, "dir">,
|
|
patch: Record<string, unknown>,
|
|
) {
|
|
const manifestPath = path.join(plugin.dir, "openclaw.plugin.json");
|
|
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as Record<string, unknown>;
|
|
fs.writeFileSync(manifestPath, JSON.stringify({ ...raw, ...patch }, null, 2), "utf-8");
|
|
}
|
|
|
|
export function memoryPluginBody(id: string) {
|
|
return `module.exports = { id: ${JSON.stringify(id)}, kind: "memory", register() {} };`;
|
|
}
|
|
|
|
export function setupBundledDreamingMemoryPlugins(params?: {
|
|
selectedId?: string;
|
|
selectedKind?: unknown;
|
|
coreBody?: string;
|
|
}) {
|
|
const selectedId = params?.selectedId ?? "memory-lancedb";
|
|
const bundledDir = makeTempDir();
|
|
const memoryCoreDir = path.join(bundledDir, "memory-core");
|
|
const selectedMemoryDir = path.join(bundledDir, selectedId);
|
|
mkdirSafe(memoryCoreDir);
|
|
mkdirSafe(selectedMemoryDir);
|
|
writePlugin({
|
|
id: "memory-core",
|
|
dir: memoryCoreDir,
|
|
filename: "index.cjs",
|
|
body: params?.coreBody ?? memoryPluginBody("memory-core"),
|
|
});
|
|
writePlugin({
|
|
id: selectedId,
|
|
dir: selectedMemoryDir,
|
|
filename: "index.cjs",
|
|
body:
|
|
params?.selectedKind === "utility"
|
|
? `module.exports = { id: ${JSON.stringify(selectedId)}, kind: "utility", register() {} };`
|
|
: memoryPluginBody(selectedId),
|
|
});
|
|
const openSchema = { type: "object", additionalProperties: true };
|
|
fs.writeFileSync(
|
|
path.join(memoryCoreDir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{ id: "memory-core", kind: "memory", configSchema: EMPTY_PLUGIN_SCHEMA },
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(selectedMemoryDir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: selectedId,
|
|
kind: params?.selectedKind ?? "memory",
|
|
configSchema: openSchema,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
return { bundledDir, selectedId };
|
|
}
|
|
|
|
export const RESERVED_ADMIN_PLUGIN_METHOD = "config.plugin.inspect";
|
|
|
|
export const RESERVED_ADMIN_SCOPE_WARNING =
|
|
"gateway method scope coerced to operator.admin for reserved core namespace";
|
|
|
|
export function writeBundledPlugin(params: {
|
|
id: string;
|
|
body?: string;
|
|
filename?: string;
|
|
bundledDir?: string;
|
|
}) {
|
|
const bundledDir = params.bundledDir ?? makeTempDir();
|
|
const plugin = writePlugin({
|
|
id: params.id,
|
|
dir: bundledDir,
|
|
filename: params.filename ?? "index.cjs",
|
|
body: params.body ?? simplePluginBody(params.id),
|
|
});
|
|
delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
return { bundledDir, plugin };
|
|
}
|
|
|
|
export function makeOpenClawDevSourceRoot() {
|
|
const root = makeTempDir();
|
|
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }), "utf-8");
|
|
mkdirSafe(path.join(root, "src"));
|
|
mkdirSafe(path.join(root, "extensions"));
|
|
return root;
|
|
}
|
|
|
|
export function writeWorkspacePlugin(params: {
|
|
id: string;
|
|
body?: string;
|
|
filename?: string;
|
|
workspaceDir?: string;
|
|
}) {
|
|
const workspaceDir = params.workspaceDir ?? makeTempDir();
|
|
const workspacePluginDir = path.join(workspaceDir, ".openclaw", "extensions", params.id);
|
|
mkdirSafe(workspacePluginDir);
|
|
const plugin = writePlugin({
|
|
id: params.id,
|
|
dir: workspacePluginDir,
|
|
filename: params.filename ?? "index.cjs",
|
|
body: params.body ?? simplePluginBody(params.id),
|
|
});
|
|
return { workspaceDir, workspacePluginDir, plugin };
|
|
}
|
|
|
|
export function withStateDir<T>(run: (stateDir: string) => T) {
|
|
const stateDir = makeTempDir();
|
|
return withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => run(stateDir));
|
|
}
|
|
|
|
export function loadBundledMemoryPluginRegistry(options?: {
|
|
packageMeta?: { name: string; version: string; description?: string };
|
|
pluginBody?: string;
|
|
pluginFilename?: string;
|
|
}) {
|
|
if (!options && cachedBundledMemoryDir) {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = cachedBundledMemoryDir;
|
|
return loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: cachedBundledMemoryDir,
|
|
config: {
|
|
plugins: {
|
|
slots: {
|
|
memory: "memory-core",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const bundledDir = makeTempDir();
|
|
let pluginDir = bundledDir;
|
|
let pluginFilename = options?.pluginFilename ?? "memory-core.cjs";
|
|
|
|
if (options?.packageMeta) {
|
|
pluginDir = path.join(bundledDir, "memory-core");
|
|
pluginFilename = options.pluginFilename ?? "index.js";
|
|
mkdirSafe(pluginDir);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: options.packageMeta.name,
|
|
version: options.packageMeta.version,
|
|
description: options.packageMeta.description,
|
|
openclaw: { extensions: [`./${pluginFilename}`] },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
writePlugin({
|
|
id: "memory-core",
|
|
body:
|
|
options?.pluginBody ??
|
|
`module.exports = { id: "memory-core", kind: "memory", register() {} };`,
|
|
dir: pluginDir,
|
|
filename: pluginFilename,
|
|
});
|
|
if (!options) {
|
|
cachedBundledMemoryDir = bundledDir;
|
|
}
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
|
|
return loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: bundledDir,
|
|
config: {
|
|
plugins: {
|
|
slots: {
|
|
memory: "memory-core",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export function setupBundledTelegramPlugin() {
|
|
if (!cachedBundledTelegramDir) {
|
|
cachedBundledTelegramDir = makeTempDir();
|
|
writePlugin({
|
|
id: "telegram",
|
|
body: BUNDLED_TELEGRAM_PLUGIN_BODY,
|
|
dir: cachedBundledTelegramDir,
|
|
filename: "telegram.cjs",
|
|
});
|
|
}
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = cachedBundledTelegramDir;
|
|
}
|
|
|
|
export function expectTelegramLoaded(registry: ReturnType<typeof loadOpenClawPlugins>) {
|
|
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
|
|
expect(telegram?.status).toBe("loaded");
|
|
expect(registry.channels.map((entry) => entry.plugin.id)).toContain("telegram");
|
|
}
|
|
|
|
export function loadRegistryFromSinglePlugin(params: {
|
|
plugin: TempPlugin;
|
|
pluginConfig?: Record<string, unknown>;
|
|
includeWorkspaceDir?: boolean;
|
|
options?: Omit<Parameters<typeof loadOpenClawPlugins>[0], "cache" | "workspaceDir" | "config">;
|
|
}) {
|
|
const pluginConfig = params.pluginConfig ?? {};
|
|
return loadOpenClawPlugins({
|
|
cache: false,
|
|
...(params.includeWorkspaceDir === false ? {} : { workspaceDir: params.plugin.dir }),
|
|
...params.options,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [params.plugin.file] },
|
|
...pluginConfig,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export function loadRegistryFromAllowedPlugins(
|
|
plugins: TempPlugin[],
|
|
options?: Omit<Parameters<typeof loadOpenClawPlugins>[0], "cache" | "config">,
|
|
) {
|
|
return loadOpenClawPlugins({
|
|
cache: false,
|
|
...options,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: plugins.map((plugin) => plugin.file) },
|
|
allow: plugins.map((plugin) => plugin.id),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export function runRegistryScenarios<
|
|
T extends { assert: (registry: PluginRegistry, scenario: T) => void },
|
|
>(scenarios: readonly T[], loadRegistry: (scenario: T) => PluginRegistry) {
|
|
for (const scenario of scenarios) {
|
|
scenario.assert(loadRegistry(scenario), scenario);
|
|
}
|
|
}
|
|
|
|
export function runScenarioCases<T>(scenarios: readonly T[], run: (scenario: T) => void) {
|
|
for (const scenario of scenarios) {
|
|
run(scenario);
|
|
}
|
|
}
|
|
|
|
export function runSinglePluginRegistryScenarios<
|
|
T extends {
|
|
pluginId: string;
|
|
body: string;
|
|
assert: (registry: PluginRegistry, scenario: T) => void;
|
|
},
|
|
>(scenarios: readonly T[], resolvePluginConfig?: (scenario: T) => Record<string, unknown>) {
|
|
runRegistryScenarios(scenarios, (scenario) => {
|
|
const plugin = writePlugin({
|
|
id: scenario.pluginId,
|
|
filename: `${scenario.pluginId}.cjs`,
|
|
body: scenario.body,
|
|
});
|
|
return loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: resolvePluginConfig?.(scenario) ?? { allow: [scenario.pluginId] },
|
|
});
|
|
});
|
|
}
|
|
|
|
export function loadRegistryFromScenarioPlugins(plugins: readonly TempPlugin[]) {
|
|
return plugins.length === 1
|
|
? loadRegistryFromSinglePlugin({
|
|
plugin: expectDefined(plugins[0], "plugins[0] test invariant"),
|
|
pluginConfig: {
|
|
allow: [expectDefined(plugins[0], "plugins[0] test invariant").id],
|
|
},
|
|
})
|
|
: loadRegistryFromAllowedPlugins([...plugins]);
|
|
}
|
|
|
|
export function expectOpenAllowWarnings(params: {
|
|
warnings: string[];
|
|
pluginId: string;
|
|
expectedWarnings: number;
|
|
label: string;
|
|
}) {
|
|
const openAllowWarnings = params.warnings.filter((msg) => msg.includes("plugins.allow is empty"));
|
|
expect(openAllowWarnings, params.label).toHaveLength(params.expectedWarnings);
|
|
if (params.expectedWarnings > 0) {
|
|
expect(
|
|
openAllowWarnings.some((msg) => msg.includes(params.pluginId)),
|
|
params.label,
|
|
).toBe(true);
|
|
}
|
|
}
|
|
|
|
export function expectLoadedPluginProvenance(params: {
|
|
scenario: { label: string };
|
|
registry: PluginRegistry;
|
|
warnings: string[];
|
|
pluginId: string;
|
|
expectWarning: boolean;
|
|
expectedSource?: string;
|
|
}) {
|
|
const plugin = params.registry.plugins.find((entry) => entry.id === params.pluginId);
|
|
expect(plugin?.status, params.scenario.label).toBe("loaded");
|
|
if (params.expectedSource) {
|
|
expect(plugin?.source, params.scenario.label).toBe(params.expectedSource);
|
|
}
|
|
expect(
|
|
params.warnings.some(
|
|
(msg) =>
|
|
msg.includes(params.pluginId) &&
|
|
msg.includes("loaded without install/load-path provenance"),
|
|
),
|
|
params.scenario.label,
|
|
).toBe(params.expectWarning);
|
|
}
|
|
|
|
export function expectRegisteredHttpRoute(
|
|
registry: PluginRegistry,
|
|
scenario: {
|
|
pluginId: string;
|
|
expectedPath: string;
|
|
expectedAuth: string;
|
|
expectedMatch: string;
|
|
label: string;
|
|
},
|
|
) {
|
|
const route = registry.httpRoutes.find((entry) => entry.pluginId === scenario.pluginId);
|
|
if (!route) {
|
|
throw new Error(`expected http route for ${scenario.label}`);
|
|
}
|
|
expect(route.path, scenario.label).toBe(scenario.expectedPath);
|
|
expect(route.auth, scenario.label).toBe(scenario.expectedAuth);
|
|
expect(route.match, scenario.label).toBe(scenario.expectedMatch);
|
|
const httpPlugin = registry.plugins.find((entry) => entry.id === scenario.pluginId);
|
|
expect(httpPlugin?.httpRoutes, scenario.label).toBe(1);
|
|
}
|
|
|
|
export function expectDuplicateRegistrationResult(
|
|
registry: PluginRegistry,
|
|
scenario: {
|
|
selectCount: (registry: PluginRegistry) => number;
|
|
ownerB: string;
|
|
duplicateMessage: string;
|
|
label: string;
|
|
assertPrimaryOwner?: (registry: PluginRegistry) => void;
|
|
},
|
|
) {
|
|
expect(scenario.selectCount(registry), scenario.label).toBe(1);
|
|
scenario.assertPrimaryOwner?.(registry);
|
|
expect(
|
|
registry.diagnostics.some(
|
|
(diag) =>
|
|
diag.level === "error" &&
|
|
diag.pluginId === scenario.ownerB &&
|
|
diag.message === scenario.duplicateMessage,
|
|
),
|
|
scenario.label,
|
|
).toBe(true);
|
|
}
|
|
|
|
export function expectPluginSourcePrecedence(
|
|
registry: PluginRegistry,
|
|
scenario: {
|
|
pluginId: string;
|
|
expectedLoadedOrigin: string;
|
|
expectedDisabledOrigin: string;
|
|
label: string;
|
|
expectedDisabledError?: string;
|
|
expectDuplicateWarning?: boolean;
|
|
},
|
|
) {
|
|
const entries = registry.plugins.filter((entry) => entry.id === scenario.pluginId);
|
|
expect(entries, scenario.label).toHaveLength(1);
|
|
const loaded = entries[0];
|
|
expect(loaded?.origin, scenario.label).toBe(scenario.expectedLoadedOrigin);
|
|
expect(loaded?.status, scenario.label).toBe("loaded");
|
|
const expectedWarning =
|
|
scenario.expectedDisabledError ??
|
|
`${scenario.expectedDisabledOrigin} plugin will be overridden by ${scenario.expectedLoadedOrigin} plugin`;
|
|
const hasDuplicateWarning = registry.diagnostics.some(
|
|
(diag) =>
|
|
diag.level === "warn" &&
|
|
diag.pluginId === scenario.pluginId &&
|
|
diag.message.includes(expectedWarning),
|
|
);
|
|
expect(hasDuplicateWarning, scenario.label).toBe(scenario.expectDuplicateWarning ?? true);
|
|
}
|
|
|
|
export function expectPluginOriginAndStatus(params: {
|
|
registry: PluginRegistry;
|
|
pluginId: string;
|
|
origin: string;
|
|
status: string;
|
|
label: string;
|
|
errorIncludes?: string;
|
|
}) {
|
|
const plugin = params.registry.plugins.find((entry) => entry.id === params.pluginId);
|
|
expect(plugin?.origin, params.label).toBe(params.origin);
|
|
expect(plugin?.status, params.label).toBe(params.status);
|
|
if (params.errorIncludes) {
|
|
expect(plugin?.error, params.label).toContain(params.errorIncludes);
|
|
}
|
|
}
|
|
|
|
export function expectRegistryErrorDiagnostic(params: {
|
|
registry: PluginRegistry;
|
|
pluginId: string;
|
|
message: string;
|
|
}) {
|
|
const diagnostic = params.registry.diagnostics.find(
|
|
(entry) =>
|
|
entry.level === "error" &&
|
|
entry.pluginId === params.pluginId &&
|
|
entry.message === params.message,
|
|
);
|
|
if (!diagnostic) {
|
|
throw new Error(`Expected registry error diagnostic: ${params.message}`);
|
|
}
|
|
}
|
|
|
|
export function expectDiagnosticContaining(params: {
|
|
registry: PluginRegistry;
|
|
message: string;
|
|
level?: string;
|
|
pluginId?: string;
|
|
}) {
|
|
const diagnostic = params.registry.diagnostics.find(
|
|
(entry) =>
|
|
(!params.level || entry.level === params.level) &&
|
|
(!params.pluginId || entry.pluginId === params.pluginId) &&
|
|
entry.message.includes(params.message),
|
|
);
|
|
if (!diagnostic) {
|
|
throw new Error(`Expected diagnostic containing: ${params.message}`);
|
|
}
|
|
}
|
|
|
|
export function expectNoDiagnosticContaining(params: {
|
|
registry: PluginRegistry;
|
|
message: string;
|
|
level?: string;
|
|
pluginId?: string;
|
|
}) {
|
|
const diagnostic = params.registry.diagnostics.find(
|
|
(entry) =>
|
|
(!params.level || entry.level === params.level) &&
|
|
(!params.pluginId || entry.pluginId === params.pluginId) &&
|
|
entry.message.includes(params.message),
|
|
);
|
|
expect(diagnostic, params.message).toBeUndefined();
|
|
}
|
|
|
|
export function createWarningLogger(warnings: string[]) {
|
|
return {
|
|
info: () => {},
|
|
warn: (msg: string) => warnings.push(msg),
|
|
error: () => {},
|
|
};
|
|
}
|
|
|
|
export function createErrorLogger(errors: string[]) {
|
|
return {
|
|
info: () => {},
|
|
warn: () => {},
|
|
error: (msg: string) => errors.push(msg),
|
|
debug: () => {},
|
|
};
|
|
}
|
|
|
|
export function createEscapingEntryFixture(params: { id: string; sourceBody: string }) {
|
|
const pluginDir = makeTempDir();
|
|
const outsideDir = makeTempDir();
|
|
const outsideEntry = path.join(outsideDir, "outside.cjs");
|
|
const linkedEntry = path.join(pluginDir, "entry.cjs");
|
|
fs.writeFileSync(outsideEntry, params.sourceBody, "utf-8");
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: params.id,
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
return { pluginDir, outsideEntry, linkedEntry };
|
|
}
|
|
|
|
export function resolveLoadedPluginSource(
|
|
registry: ReturnType<typeof loadOpenClawPlugins>,
|
|
pluginId: string,
|
|
) {
|
|
return fs.realpathSync(registry.plugins.find((entry) => entry.id === pluginId)?.source ?? "");
|
|
}
|
|
|
|
export function expectCachePartitionByPluginSource(params: {
|
|
pluginId: string;
|
|
loadFirst: () => ReturnType<typeof loadOpenClawPlugins>;
|
|
loadSecond: () => ReturnType<typeof loadOpenClawPlugins>;
|
|
expectedFirstSource: string;
|
|
expectedSecondSource: string;
|
|
}) {
|
|
const first = params.loadFirst();
|
|
const second = params.loadSecond();
|
|
|
|
expect(second).not.toBe(first);
|
|
expect(resolveLoadedPluginSource(first, params.pluginId)).toBe(
|
|
fs.realpathSync(params.expectedFirstSource),
|
|
);
|
|
expect(resolveLoadedPluginSource(second, params.pluginId)).toBe(
|
|
fs.realpathSync(params.expectedSecondSource),
|
|
);
|
|
}
|
|
|
|
export function expectCacheMissThenHit(params: {
|
|
loadFirst: () => ReturnType<typeof loadOpenClawPlugins>;
|
|
loadVariant: () => ReturnType<typeof loadOpenClawPlugins>;
|
|
}) {
|
|
const first = params.loadFirst();
|
|
const second = params.loadVariant();
|
|
const third = params.loadVariant();
|
|
|
|
expect(second).not.toBe(first);
|
|
expect(third).toBe(second);
|
|
}
|
|
|
|
export function createSetupEntryChannelPluginFixture(params: {
|
|
id: string;
|
|
label: string;
|
|
packageName: string;
|
|
fullBlurb: string;
|
|
setupBlurb: string;
|
|
configured: boolean;
|
|
startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean;
|
|
useBundledFullEntryContract?: boolean;
|
|
bundledFullEntryId?: string;
|
|
useBundledSetupEntryContract?: boolean;
|
|
bundledSetupEntryId?: string;
|
|
splitBundledSetupSecrets?: boolean;
|
|
bundledSetupRuntimeMarker?: string;
|
|
bundledSetupRuntimeRoutePath?: string;
|
|
bundledSetupRuntimeRegisterError?: string;
|
|
bundledSetupRuntimeLateRoutePath?: string;
|
|
bundledSetupRuntimeError?: string;
|
|
bundledFullRuntimeMarker?: string;
|
|
requireBundledFullRuntimeBeforeLoad?: boolean;
|
|
}) {
|
|
useNoBundledPlugins();
|
|
const pluginDir = makeTempDir();
|
|
const fullMarker = path.join(pluginDir, "full-loaded.txt");
|
|
const setupMarker = path.join(pluginDir, "setup-loaded.txt");
|
|
const listAccountIds = params.configured ? '["default"]' : "[]";
|
|
const resolveAccount = params.configured
|
|
? '({ accountId: "default", token: "configured" })'
|
|
: '({ accountId: "default" })';
|
|
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: params.packageName,
|
|
openclaw: {
|
|
extensions: ["./index.cjs"],
|
|
setupEntry: "./setup-entry.cjs",
|
|
...(params.startupDeferConfiguredChannelFullLoadUntilAfterListen
|
|
? {
|
|
startup: {
|
|
deferConfiguredChannelFullLoadUntilAfterListen: true,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: params.id,
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
channels: [params.id],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "index.cjs"),
|
|
params.useBundledFullEntryContract
|
|
? `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
|
|
module.exports = {
|
|
kind: "bundled-channel-entry",
|
|
id: ${JSON.stringify(params.bundledFullEntryId ?? params.id)},
|
|
name: ${JSON.stringify(params.label)},
|
|
description: ${JSON.stringify(params.fullBlurb)},
|
|
loadChannelPlugin: () => {
|
|
${
|
|
params.requireBundledFullRuntimeBeforeLoad && params.bundledFullRuntimeMarker
|
|
? `if (!require("node:fs").existsSync(${JSON.stringify(params.bundledFullRuntimeMarker)})) {
|
|
throw new Error("bundled runtime not initialized");
|
|
}`
|
|
: ""
|
|
}
|
|
return {
|
|
id: ${JSON.stringify(params.bundledFullEntryId ?? params.id)},
|
|
meta: {
|
|
id: ${JSON.stringify(params.bundledFullEntryId ?? params.id)},
|
|
label: ${JSON.stringify(params.label)},
|
|
selectionLabel: ${JSON.stringify(params.label)},
|
|
docsPath: ${JSON.stringify(`/channels/${params.bundledFullEntryId ?? params.id}`)},
|
|
blurb: ${JSON.stringify(params.fullBlurb)},
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ${listAccountIds},
|
|
resolveAccount: () => ${resolveAccount},
|
|
},
|
|
outbound: { deliveryMode: "direct" },
|
|
};
|
|
},
|
|
${
|
|
params.bundledFullRuntimeMarker
|
|
? `setChannelRuntime: () => {
|
|
require("node:fs").writeFileSync(${JSON.stringify(params.bundledFullRuntimeMarker)}, "loaded", "utf-8");
|
|
},`
|
|
: ""
|
|
}
|
|
register() {},
|
|
};`
|
|
: `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
|
|
module.exports = {
|
|
id: ${JSON.stringify(params.id)},
|
|
register(api) {
|
|
api.registerChannel({
|
|
plugin: {
|
|
id: ${JSON.stringify(params.id)},
|
|
meta: {
|
|
id: ${JSON.stringify(params.id)},
|
|
label: ${JSON.stringify(params.label)},
|
|
selectionLabel: ${JSON.stringify(params.label)},
|
|
docsPath: ${JSON.stringify(`/channels/${params.id}`)},
|
|
blurb: ${JSON.stringify(params.fullBlurb)},
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ${listAccountIds},
|
|
resolveAccount: () => ${resolveAccount},
|
|
},
|
|
outbound: { deliveryMode: "direct" },
|
|
},
|
|
});
|
|
},
|
|
};`,
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "setup-entry.cjs"),
|
|
params.useBundledSetupEntryContract
|
|
? `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8");
|
|
module.exports = {
|
|
kind: "bundled-channel-setup-entry",
|
|
loadSetupPlugin: () => ({
|
|
id: ${JSON.stringify(params.bundledSetupEntryId ?? params.id)},
|
|
meta: {
|
|
id: ${JSON.stringify(params.bundledSetupEntryId ?? params.id)},
|
|
label: ${JSON.stringify(params.label)},
|
|
selectionLabel: ${JSON.stringify(params.label)},
|
|
docsPath: ${JSON.stringify(`/channels/${params.bundledSetupEntryId ?? params.id}`)},
|
|
blurb: ${JSON.stringify(params.setupBlurb)},
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ${listAccountIds},
|
|
resolveAccount: () => ${resolveAccount},
|
|
},
|
|
outbound: { deliveryMode: "direct" },
|
|
}),
|
|
${
|
|
params.splitBundledSetupSecrets
|
|
? `loadSetupSecrets: () => ({
|
|
secretTargetRegistryEntries: [
|
|
{
|
|
id: ${JSON.stringify(`channels.${params.id}.setup-token`)},
|
|
targetType: "channel",
|
|
},
|
|
],
|
|
}),`
|
|
: ""
|
|
}
|
|
${
|
|
params.bundledSetupRuntimeError
|
|
? `setChannelRuntime: () => {
|
|
throw new Error(${JSON.stringify(params.bundledSetupRuntimeError)});
|
|
},`
|
|
: params.bundledSetupRuntimeMarker
|
|
? `setChannelRuntime: () => {
|
|
require("node:fs").writeFileSync(${JSON.stringify(params.bundledSetupRuntimeMarker)}, "loaded", "utf-8");
|
|
},`
|
|
: ""
|
|
}
|
|
${
|
|
params.bundledSetupRuntimeRoutePath
|
|
? `registerSetupRuntime: (api) => {
|
|
api.registerHttpRoute({
|
|
path: ${JSON.stringify(params.bundledSetupRuntimeRoutePath)},
|
|
auth: "plugin",
|
|
handler: async () => true,
|
|
});
|
|
${
|
|
params.bundledSetupRuntimeRegisterError
|
|
? `throw new Error(${JSON.stringify(params.bundledSetupRuntimeRegisterError)});`
|
|
: ""
|
|
}
|
|
${
|
|
params.bundledSetupRuntimeLateRoutePath
|
|
? `queueMicrotask(() => {
|
|
api.registerHttpRoute({
|
|
path: ${JSON.stringify(params.bundledSetupRuntimeLateRoutePath)},
|
|
auth: "plugin",
|
|
handler: async () => true,
|
|
});
|
|
});`
|
|
: ""
|
|
}
|
|
},`
|
|
: ""
|
|
}
|
|
};`
|
|
: `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8");
|
|
module.exports = {
|
|
plugin: {
|
|
id: ${JSON.stringify(params.id)},
|
|
meta: {
|
|
id: ${JSON.stringify(params.id)},
|
|
label: ${JSON.stringify(params.label)},
|
|
selectionLabel: ${JSON.stringify(params.label)},
|
|
docsPath: ${JSON.stringify(`/channels/${params.id}`)},
|
|
blurb: ${JSON.stringify(params.setupBlurb)},
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config: {
|
|
listAccountIds: () => ${listAccountIds},
|
|
resolveAccount: () => ${resolveAccount},
|
|
},
|
|
outbound: { deliveryMode: "direct" },
|
|
},
|
|
${
|
|
params.bundledSetupRuntimeMarker
|
|
? `setChannelRuntime: () => {
|
|
require("node:fs").writeFileSync(${JSON.stringify(params.bundledSetupRuntimeMarker)}, "loaded", "utf-8");
|
|
},`
|
|
: ""
|
|
}
|
|
};`,
|
|
"utf-8",
|
|
);
|
|
|
|
return { pluginDir, fullMarker, setupMarker };
|
|
}
|
|
|
|
export function createEnvResolvedPluginFixture(pluginId: string) {
|
|
useNoBundledPlugins();
|
|
const openclawHome = makeTempDir();
|
|
const ignoredHome = makeTempDir();
|
|
const stateDir = makeTempDir();
|
|
const pluginDir = path.join(openclawHome, "plugins", pluginId);
|
|
mkdirSafe(pluginDir);
|
|
const plugin = writePlugin({
|
|
id: pluginId,
|
|
dir: pluginDir,
|
|
filename: "index.cjs",
|
|
body: `module.exports = { id: ${JSON.stringify(pluginId)}, register() {} };`,
|
|
});
|
|
const env = {
|
|
...process.env,
|
|
OPENCLAW_HOME: openclawHome,
|
|
HOME: ignoredHome,
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
|
};
|
|
return { plugin, env };
|
|
}
|
|
|
|
export function expectEscapingEntryRejected(params: {
|
|
id: string;
|
|
linkKind: "symlink" | "hardlink";
|
|
sourceBody: string;
|
|
}) {
|
|
useNoBundledPlugins();
|
|
const { outsideEntry, linkedEntry } = createEscapingEntryFixture({
|
|
id: params.id,
|
|
sourceBody: params.sourceBody,
|
|
});
|
|
try {
|
|
if (params.linkKind === "symlink") {
|
|
fs.symlinkSync(outsideEntry, linkedEntry);
|
|
} else {
|
|
fs.linkSync(outsideEntry, linkedEntry);
|
|
}
|
|
} catch (err) {
|
|
if (params.linkKind === "hardlink" && (err as NodeJS.ErrnoException).code === "EXDEV") {
|
|
return undefined;
|
|
}
|
|
if (params.linkKind === "symlink") {
|
|
return undefined;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [linkedEntry] },
|
|
allow: [params.id],
|
|
},
|
|
},
|
|
});
|
|
|
|
const record = registry.plugins.find((entry) => entry.id === params.id);
|
|
expect(record?.status).not.toBe("loaded");
|
|
expectDiagnosticContaining({ registry, message: "escapes" });
|
|
return registry;
|
|
}
|
|
|
|
export function createStartupTraceRecorder(): {
|
|
details: PluginStartupTraceDetail[];
|
|
startupTrace: NonNullable<PluginLoadOptions["startupTrace"]>;
|
|
} {
|
|
const details: PluginStartupTraceDetail[] = [];
|
|
return {
|
|
details,
|
|
startupTrace: {
|
|
detail: (name, metrics) => {
|
|
details.push({ name, metrics });
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function collectStartupTraceMetrics(
|
|
details: readonly PluginStartupTraceDetail[],
|
|
name: string,
|
|
): Record<string, number | string> {
|
|
const matched = details.filter((entry) => entry.name === name);
|
|
expect(matched.length).toBeGreaterThan(0);
|
|
const metrics: Record<string, number | string> = {};
|
|
for (const entry of matched) {
|
|
for (const [key, value] of entry.metrics) {
|
|
metrics[key] = value;
|
|
}
|
|
}
|
|
return metrics;
|
|
}
|
|
|
|
export const globalAfterEach0 = () => {
|
|
resetDiagnosticEventsForTest();
|
|
clearRuntimeConfigSnapshot();
|
|
runtimeRegistryLoaderTesting.resetPluginRegistryLoadedForTests();
|
|
resetPluginLoaderTestStateForTest();
|
|
};
|
|
|
|
export const globalAfterAll1 = () => {
|
|
cleanupPluginLoaderFixturesForTest();
|
|
cachedBundledTelegramDir = "";
|
|
cachedBundledMemoryDir = "";
|
|
};
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|