mirror of https://github.com/openclaw/openclaw.git
330 lines
9.6 KiB
TypeScript
330 lines
9.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { type ChannelId, type ChannelPlugin } from "../channels/plugins/types.js";
|
|
import {
|
|
createSubsystemLogger,
|
|
type SubsystemLogger,
|
|
runtimeForLogger,
|
|
} from "../logging/subsystem.js";
|
|
import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js";
|
|
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
|
import type { PluginRuntime } from "../plugins/runtime/types.js";
|
|
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { createChannelManager } from "./server-channels.js";
|
|
|
|
const hoisted = vi.hoisted(() => {
|
|
const computeBackoff = vi.fn(() => 10);
|
|
const sleepWithAbort = vi.fn((ms: number, abortSignal?: AbortSignal) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => resolve(), ms);
|
|
abortSignal?.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearTimeout(timer);
|
|
reject(new Error("aborted"));
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
});
|
|
return { computeBackoff, sleepWithAbort };
|
|
});
|
|
|
|
vi.mock("../infra/backoff.js", () => ({
|
|
computeBackoff: hoisted.computeBackoff,
|
|
sleepWithAbort: hoisted.sleepWithAbort,
|
|
}));
|
|
|
|
type TestAccount = {
|
|
enabled?: boolean;
|
|
configured?: boolean;
|
|
};
|
|
|
|
function createTestPlugin(params?: {
|
|
account?: TestAccount;
|
|
startAccount?: NonNullable<ChannelPlugin<TestAccount>["gateway"]>["startAccount"];
|
|
includeDescribeAccount?: boolean;
|
|
resolveAccount?: ChannelPlugin<TestAccount>["config"]["resolveAccount"];
|
|
}): ChannelPlugin<TestAccount> {
|
|
const account = params?.account ?? { enabled: true, configured: true };
|
|
const includeDescribeAccount = params?.includeDescribeAccount !== false;
|
|
const config: ChannelPlugin<TestAccount>["config"] = {
|
|
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
resolveAccount: params?.resolveAccount ?? (() => account),
|
|
isEnabled: (resolved) => resolved.enabled !== false,
|
|
};
|
|
if (includeDescribeAccount) {
|
|
config.describeAccount = (resolved) => ({
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
enabled: resolved.enabled !== false,
|
|
configured: resolved.configured !== false,
|
|
});
|
|
}
|
|
const gateway: NonNullable<ChannelPlugin<TestAccount>["gateway"]> = {};
|
|
if (params?.startAccount) {
|
|
gateway.startAccount = params.startAccount;
|
|
}
|
|
return {
|
|
id: "discord",
|
|
meta: {
|
|
id: "discord",
|
|
label: "Discord",
|
|
selectionLabel: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "test stub",
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
config,
|
|
gateway,
|
|
};
|
|
}
|
|
|
|
function installTestRegistry(plugin: ChannelPlugin<TestAccount>) {
|
|
const registry = createEmptyPluginRegistry();
|
|
registry.channels.push({
|
|
pluginId: plugin.id,
|
|
source: "test",
|
|
plugin,
|
|
});
|
|
setActivePluginRegistry(registry);
|
|
}
|
|
|
|
function createManager(options?: {
|
|
channelRuntime?: PluginRuntime["channel"];
|
|
loadConfig?: () => Record<string, unknown>;
|
|
}) {
|
|
const log = createSubsystemLogger("gateway/server-channels-test");
|
|
const channelLogs = { discord: log } as Record<ChannelId, SubsystemLogger>;
|
|
const runtime = runtimeForLogger(log);
|
|
const channelRuntimeEnvs = { discord: runtime } as Record<ChannelId, RuntimeEnv>;
|
|
return createChannelManager({
|
|
loadConfig: () => options?.loadConfig?.() ?? {},
|
|
channelLogs,
|
|
channelRuntimeEnvs,
|
|
...(options?.channelRuntime ? { channelRuntime: options.channelRuntime } : {}),
|
|
});
|
|
}
|
|
|
|
describe("server-channels auto restart", () => {
|
|
let previousRegistry: PluginRegistry | null = null;
|
|
|
|
beforeEach(() => {
|
|
previousRegistry = getActivePluginRegistry();
|
|
vi.useFakeTimers();
|
|
hoisted.computeBackoff.mockClear();
|
|
hoisted.sleepWithAbort.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
setActivePluginRegistry(previousRegistry ?? createEmptyPluginRegistry());
|
|
});
|
|
|
|
it("caps crash-loop restarts after max attempts", async () => {
|
|
const startAccount = vi.fn(async () => {});
|
|
installTestRegistry(
|
|
createTestPlugin({
|
|
startAccount,
|
|
}),
|
|
);
|
|
const manager = createManager();
|
|
|
|
await manager.startChannels();
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
|
|
expect(startAccount).toHaveBeenCalledTimes(11);
|
|
const snapshot = manager.getRuntimeSnapshot();
|
|
const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
|
|
expect(account?.running).toBe(false);
|
|
expect(account?.reconnectAttempts).toBe(10);
|
|
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
expect(startAccount).toHaveBeenCalledTimes(11);
|
|
});
|
|
|
|
it("does not auto-restart after manual stop during backoff", async () => {
|
|
const startAccount = vi.fn(async () => {});
|
|
installTestRegistry(
|
|
createTestPlugin({
|
|
startAccount,
|
|
}),
|
|
);
|
|
const manager = createManager();
|
|
|
|
await manager.startChannels();
|
|
vi.runAllTicks();
|
|
await manager.stopChannel("discord", DEFAULT_ACCOUNT_ID);
|
|
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
expect(startAccount).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("marks enabled/configured when account descriptors omit them", () => {
|
|
installTestRegistry(
|
|
createTestPlugin({
|
|
includeDescribeAccount: false,
|
|
}),
|
|
);
|
|
const manager = createManager();
|
|
const snapshot = manager.getRuntimeSnapshot();
|
|
const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
|
|
expect(account?.enabled).toBe(true);
|
|
expect(account?.configured).toBe(true);
|
|
});
|
|
|
|
it("passes channelRuntime through channel gateway context when provided", async () => {
|
|
const channelRuntime = { marker: "channel-runtime" } as unknown as PluginRuntime["channel"];
|
|
const startAccount = vi.fn(async (ctx) => {
|
|
expect(ctx.channelRuntime).toBe(channelRuntime);
|
|
});
|
|
|
|
installTestRegistry(createTestPlugin({ startAccount }));
|
|
const manager = createManager({ channelRuntime });
|
|
|
|
await manager.startChannels();
|
|
expect(startAccount).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reuses plugin account resolution for health monitor overrides", () => {
|
|
installTestRegistry(
|
|
createTestPlugin({
|
|
resolveAccount: (cfg, accountId) => {
|
|
const accounts = (
|
|
cfg as {
|
|
channels?: {
|
|
discord?: {
|
|
accounts?: Record<
|
|
string,
|
|
TestAccount & { healthMonitor?: { enabled?: boolean } }
|
|
>;
|
|
};
|
|
};
|
|
}
|
|
).channels?.discord?.accounts;
|
|
if (!accounts) {
|
|
return { enabled: true, configured: true };
|
|
}
|
|
const direct = accounts[accountId ?? DEFAULT_ACCOUNT_ID];
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
const normalized = (accountId ?? DEFAULT_ACCOUNT_ID).toLowerCase().replaceAll(" ", "-");
|
|
const matchKey = Object.keys(accounts).find(
|
|
(key) => key.toLowerCase().replaceAll(" ", "-") === normalized,
|
|
);
|
|
return matchKey ? (accounts[matchKey] ?? { enabled: true, configured: true }) : {};
|
|
},
|
|
}),
|
|
);
|
|
|
|
const manager = createManager({
|
|
loadConfig: () => ({
|
|
channels: {
|
|
discord: {
|
|
accounts: {
|
|
"Router D": {
|
|
enabled: true,
|
|
configured: true,
|
|
healthMonitor: { enabled: false },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(manager.isHealthMonitorEnabled("discord", "router-d")).toBe(false);
|
|
});
|
|
|
|
it("falls back to channel-level health monitor overrides when account resolution omits them", () => {
|
|
installTestRegistry(
|
|
createTestPlugin({
|
|
resolveAccount: () => ({
|
|
enabled: true,
|
|
configured: true,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const manager = createManager({
|
|
loadConfig: () => ({
|
|
channels: {
|
|
discord: {
|
|
healthMonitor: { enabled: false },
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(manager.isHealthMonitorEnabled("discord", DEFAULT_ACCOUNT_ID)).toBe(false);
|
|
});
|
|
|
|
it("uses raw account config overrides when resolvers omit health monitor fields", () => {
|
|
installTestRegistry(
|
|
createTestPlugin({
|
|
resolveAccount: () => ({
|
|
enabled: true,
|
|
configured: true,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const manager = createManager({
|
|
loadConfig: () => ({
|
|
channels: {
|
|
discord: {
|
|
accounts: {
|
|
[DEFAULT_ACCOUNT_ID]: {
|
|
healthMonitor: { enabled: false },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(manager.isHealthMonitorEnabled("discord", DEFAULT_ACCOUNT_ID)).toBe(false);
|
|
});
|
|
|
|
it("fails closed when account resolution throws during health monitor gating", () => {
|
|
installTestRegistry(
|
|
createTestPlugin({
|
|
resolveAccount: () => {
|
|
throw new Error("unresolved SecretRef");
|
|
},
|
|
}),
|
|
);
|
|
|
|
const manager = createManager();
|
|
|
|
expect(manager.isHealthMonitorEnabled("discord", DEFAULT_ACCOUNT_ID)).toBe(false);
|
|
});
|
|
|
|
it("does not treat an empty account id as the default account when matching raw overrides", () => {
|
|
installTestRegistry(
|
|
createTestPlugin({
|
|
resolveAccount: () => ({
|
|
enabled: true,
|
|
configured: true,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const manager = createManager({
|
|
loadConfig: () => ({
|
|
channels: {
|
|
discord: {
|
|
accounts: {
|
|
default: {
|
|
healthMonitor: { enabled: false },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(manager.isHealthMonitorEnabled("discord", "")).toBe(true);
|
|
});
|
|
});
|