Files
openclaw/test/setup-openclaw-runtime.ts

295 lines
9.5 KiB
TypeScript

import { afterAll, afterEach, beforeAll } from "vitest";
import type {
ChannelId,
ChannelOutboundAdapter,
ChannelPlugin,
} from "../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../src/config/config.js";
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../src/plugins/runtime.js";
import type { PluginRegistry } from "../src/plugins/registry.js";
import { installSharedTestSetup } from "./setup.shared.js";
const testEnv = installSharedTestSetup();
const [
{ resetContextWindowCacheForTest },
{ resetModelsJsonReadyCacheForTest },
{ drainSessionWriteLockStateForTest, resetSessionWriteLockStateForTest },
{ createTopLevelChannelReplyToModeResolver },
{ createTestRegistry },
{ cleanupSessionStateForTest },
] = await Promise.all([
import("../src/agents/context.js"),
import("../src/agents/models-config.js"),
import("../src/agents/session-write-lock.js"),
import("../src/channels/plugins/threading-helpers.js"),
import("../src/test-utils/channel-plugins.js"),
import("../src/test-utils/session-state-cleanup.js"),
]);
const WORKER_RUNTIME_STATE = Symbol.for("openclaw.testSetupRuntimeState");
type WorkerRuntimeState = {
defaultPluginRegistry: PluginRegistry | null;
materializedDefaultPluginRegistry: PluginRegistry | null;
};
const workerRuntimeState = (() => {
const globalState = globalThis as typeof globalThis & {
[WORKER_RUNTIME_STATE]?: WorkerRuntimeState;
};
if (!globalState[WORKER_RUNTIME_STATE]) {
globalState[WORKER_RUNTIME_STATE] = {
defaultPluginRegistry: null,
materializedDefaultPluginRegistry: null,
};
}
return globalState[WORKER_RUNTIME_STATE];
})();
const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
return deps?.[id] as ((...args: unknown[]) => Promise<unknown>) | undefined;
};
function resolveSlackStubReplyToMode(params: {
cfg: OpenClawConfig;
chatType?: string | null;
}): "off" | "first" | "all" {
const entry = (
params.cfg.channels as
| Record<
string,
{
replyToMode?: "off" | "first" | "all";
replyToModeByChatType?: Partial<
Record<"direct" | "group" | "channel", "off" | "first" | "all">
>;
dm?: { replyToMode?: "off" | "first" | "all" };
}
>
| undefined
)?.slack;
const normalizedChatType = params.chatType?.trim().toLowerCase();
if (
normalizedChatType === "direct" ||
normalizedChatType === "group" ||
normalizedChatType === "channel"
) {
const byChatType = entry?.replyToModeByChatType?.[normalizedChatType];
if (byChatType) {
return byChatType;
}
if (normalizedChatType === "direct" && entry?.dm?.replyToMode) {
return entry.dm.replyToMode;
}
}
return entry?.replyToMode ?? "off";
}
const createStubOutbound = (
id: ChannelId,
deliveryMode: ChannelOutboundAdapter["deliveryMode"] = "direct",
): ChannelOutboundAdapter => ({
deliveryMode,
sendText: async ({ deps, to, text }) => {
const send = pickSendFn(id, deps);
if (send) {
// oxlint-disable-next-line typescript/no-explicit-any
const result = (await send(to, text, { verbose: false } as any)) as {
messageId: string;
};
return { channel: id, ...result };
}
return { channel: id, messageId: "test" };
},
sendMedia: async ({ deps, to, text, mediaUrl }) => {
const send = pickSendFn(id, deps);
if (send) {
// oxlint-disable-next-line typescript/no-explicit-any
const result = (await send(to, text, { verbose: false, mediaUrl } as any)) as {
messageId: string;
};
return { channel: id, ...result };
}
return { channel: id, messageId: "test" };
},
});
const createStubPlugin = (params: {
id: ChannelId;
label?: string;
aliases?: string[];
deliveryMode?: ChannelOutboundAdapter["deliveryMode"];
preferSessionLookupForAnnounceTarget?: boolean;
resolveReplyToMode?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
chatType?: string | null;
}) => "off" | "first" | "all";
}): ChannelPlugin => ({
id: params.id,
meta: {
id: params.id,
label: params.label ?? String(params.id),
selectionLabel: params.label ?? String(params.id),
docsPath: `/channels/${params.id}`,
blurb: "test stub.",
aliases: params.aliases,
preferSessionLookupForAnnounceTarget: params.preferSessionLookupForAnnounceTarget,
},
capabilities: { chatTypes: ["direct", "group"] },
threading: params.resolveReplyToMode
? {
resolveReplyToMode: params.resolveReplyToMode,
}
: undefined,
config: {
listAccountIds: (cfg: OpenClawConfig) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[params.id];
if (!entry || typeof entry !== "object") {
return [];
}
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
const ids = accounts ? Object.keys(accounts).filter(Boolean) : [];
return ids.length > 0 ? ids : ["default"];
},
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[params.id];
if (!entry || typeof entry !== "object") {
return {};
}
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
const match = accountId ? accounts?.[accountId] : undefined;
return (match && typeof match === "object") || typeof match === "string" ? match : entry;
},
isConfigured: async (_account, cfg: OpenClawConfig) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
return Boolean(channels?.[params.id]);
},
},
outbound: createStubOutbound(params.id, params.deliveryMode),
});
const createDefaultRegistry = () =>
createTestRegistry([
{
pluginId: "discord",
plugin: createStubPlugin({
id: "discord",
label: "Discord",
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("discord"),
}),
source: "test",
},
{
pluginId: "slack",
plugin: createStubPlugin({
id: "slack",
label: "Slack",
resolveReplyToMode: ({ cfg, chatType }) => resolveSlackStubReplyToMode({ cfg, chatType }),
}),
source: "test",
},
{
pluginId: "telegram",
plugin: {
...createStubPlugin({
id: "telegram",
label: "Telegram",
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("telegram"),
}),
status: {
buildChannelSummary: async () => ({
configured: false,
tokenSource: process.env.TELEGRAM_BOT_TOKEN ? "env" : "none",
}),
},
},
source: "test",
},
{
pluginId: "whatsapp",
plugin: createStubPlugin({
id: "whatsapp",
label: "WhatsApp",
deliveryMode: "gateway",
preferSessionLookupForAnnounceTarget: true,
}),
source: "test",
},
{
pluginId: "signal",
plugin: createStubPlugin({ id: "signal", label: "Signal" }),
source: "test",
},
{
pluginId: "imessage",
plugin: createStubPlugin({ id: "imessage", label: "iMessage", aliases: ["imsg"] }),
source: "test",
},
]);
function getDefaultPluginRegistry(): PluginRegistry {
workerRuntimeState.materializedDefaultPluginRegistry ??= createDefaultRegistry();
return workerRuntimeState.materializedDefaultPluginRegistry;
}
function resolveDefaultPluginRegistryProxy(): PluginRegistry {
workerRuntimeState.defaultPluginRegistry ??= new Proxy({} as PluginRegistry, {
defineProperty(_target, property, attributes) {
return Reflect.defineProperty(getDefaultPluginRegistry() as object, property, attributes);
},
deleteProperty(_target, property) {
return Reflect.deleteProperty(getDefaultPluginRegistry() as object, property);
},
get(_target, property, receiver) {
return Reflect.get(getDefaultPluginRegistry() as object, property, receiver);
},
getOwnPropertyDescriptor(_target, property) {
return Reflect.getOwnPropertyDescriptor(getDefaultPluginRegistry() as object, property);
},
has(_target, property) {
return Reflect.has(getDefaultPluginRegistry() as object, property);
},
ownKeys() {
return Reflect.ownKeys(getDefaultPluginRegistry() as object);
},
set(_target, property, value, receiver) {
return Reflect.set(getDefaultPluginRegistry() as object, property, value, receiver);
},
});
return workerRuntimeState.defaultPluginRegistry;
}
function installDefaultPluginRegistry(): void {
workerRuntimeState.materializedDefaultPluginRegistry = null;
resetPluginRuntimeStateForTest();
setActivePluginRegistry(resolveDefaultPluginRegistryProxy());
}
// Some suites import channel/plugin consumers at module top level, before
// Vitest runs hooks. Seed the lazy registry during setup module evaluation so
// import-time lookups still see the default test registry.
installDefaultPluginRegistry();
beforeAll(() => {
installDefaultPluginRegistry();
});
afterEach(async () => {
await cleanupSessionStateForTest();
resetContextWindowCacheForTest();
resetModelsJsonReadyCacheForTest();
resetSessionWriteLockStateForTest();
installDefaultPluginRegistry();
});
afterAll(async () => {
await cleanupSessionStateForTest();
await drainSessionWriteLockStateForTest();
testEnv.cleanup();
});