* Plugins: harden global contract coverage * Channels: tighten global contract coverage * Channels: centralize inbound contract coverage * Channels: move inbound contract helpers into core * Tests: rename local inbound context checks * Tests: stabilize contract runner profile * Tests: split scoped contract lanes * Channels: move inbound dispatch testkit into contracts * Plugins: share provider contract registry helpers * Plugins: reuse provider contract registry helpers
811 lines
25 KiB
TypeScript
811 lines
25 KiB
TypeScript
import { expect, it, type Mock } from "vitest";
|
|
import type { MsgContext } from "../../../auto-reply/templating.js";
|
|
import type { OpenClawConfig } from "../../../config/config.js";
|
|
import type {
|
|
ResolveProviderRuntimeGroupPolicyParams,
|
|
RuntimeGroupPolicyResolution,
|
|
} from "../../../config/runtime-group-policy.js";
|
|
import type {
|
|
SessionBindingCapabilities,
|
|
SessionBindingRecord,
|
|
} from "../../../infra/outbound/session-binding-service.js";
|
|
import { createNonExitingRuntime } from "../../../runtime.js";
|
|
import { normalizeChatType } from "../../chat-type.js";
|
|
import { resolveConversationLabel } from "../../conversation-label.js";
|
|
import { validateSenderIdentity } from "../../sender-identity.js";
|
|
import type {
|
|
ChannelAccountSnapshot,
|
|
ChannelAccountState,
|
|
ChannelDirectoryEntry,
|
|
ChannelFocusedBindingContext,
|
|
ChannelReplyTransport,
|
|
ChannelSetupInput,
|
|
ChannelThreadingToolContext,
|
|
} from "../types.core.js";
|
|
import type {
|
|
ChannelMessageActionName,
|
|
ChannelMessageCapability,
|
|
ChannelPlugin,
|
|
} from "../types.js";
|
|
|
|
function sortStrings(values: readonly string[]) {
|
|
return [...values].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
const contractRuntime = createNonExitingRuntime();
|
|
function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) {
|
|
expect(["user", "group", "channel"]).toContain(entry.kind);
|
|
expect(typeof entry.id).toBe("string");
|
|
expect(entry.id.trim()).not.toBe("");
|
|
if (entry.name !== undefined) {
|
|
expect(typeof entry.name).toBe("string");
|
|
}
|
|
if (entry.handle !== undefined) {
|
|
expect(typeof entry.handle).toBe("string");
|
|
}
|
|
if (entry.avatarUrl !== undefined) {
|
|
expect(typeof entry.avatarUrl).toBe("string");
|
|
}
|
|
if (entry.rank !== undefined) {
|
|
expect(typeof entry.rank).toBe("number");
|
|
}
|
|
}
|
|
|
|
function expectThreadingToolContextShape(context: ChannelThreadingToolContext) {
|
|
if (context.currentChannelId !== undefined) {
|
|
expect(typeof context.currentChannelId).toBe("string");
|
|
}
|
|
if (context.currentChannelProvider !== undefined) {
|
|
expect(typeof context.currentChannelProvider).toBe("string");
|
|
}
|
|
if (context.currentThreadTs !== undefined) {
|
|
expect(typeof context.currentThreadTs).toBe("string");
|
|
}
|
|
if (context.currentMessageId !== undefined) {
|
|
expect(["string", "number"]).toContain(typeof context.currentMessageId);
|
|
}
|
|
if (context.replyToMode !== undefined) {
|
|
expect(["off", "first", "all"]).toContain(context.replyToMode);
|
|
}
|
|
if (context.hasRepliedRef !== undefined) {
|
|
expect(typeof context.hasRepliedRef).toBe("object");
|
|
}
|
|
if (context.skipCrossContextDecoration !== undefined) {
|
|
expect(typeof context.skipCrossContextDecoration).toBe("boolean");
|
|
}
|
|
}
|
|
|
|
function expectReplyTransportShape(transport: ChannelReplyTransport) {
|
|
if (transport.replyToId !== undefined && transport.replyToId !== null) {
|
|
expect(typeof transport.replyToId).toBe("string");
|
|
}
|
|
if (transport.threadId !== undefined && transport.threadId !== null) {
|
|
expect(["string", "number"]).toContain(typeof transport.threadId);
|
|
}
|
|
}
|
|
|
|
function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) {
|
|
expect(typeof binding.conversationId).toBe("string");
|
|
expect(binding.conversationId.trim()).not.toBe("");
|
|
if (binding.parentConversationId !== undefined) {
|
|
expect(typeof binding.parentConversationId).toBe("string");
|
|
}
|
|
expect(["current", "child"]).toContain(binding.placement);
|
|
expect(typeof binding.labelNoun).toBe("string");
|
|
expect(binding.labelNoun.trim()).not.toBe("");
|
|
}
|
|
|
|
export function installChannelPluginContractSuite(params: {
|
|
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
|
|
}) {
|
|
it("satisfies the base channel plugin contract", () => {
|
|
const { plugin } = params;
|
|
|
|
expect(typeof plugin.id).toBe("string");
|
|
expect(plugin.id.trim()).not.toBe("");
|
|
|
|
expect(plugin.meta.id).toBe(plugin.id);
|
|
expect(plugin.meta.label.trim()).not.toBe("");
|
|
expect(plugin.meta.selectionLabel.trim()).not.toBe("");
|
|
expect(plugin.meta.docsPath).toMatch(/^\/channels\//);
|
|
expect(plugin.meta.blurb.trim()).not.toBe("");
|
|
|
|
expect(plugin.capabilities.chatTypes.length).toBeGreaterThan(0);
|
|
|
|
expect(typeof plugin.config.listAccountIds).toBe("function");
|
|
expect(typeof plugin.config.resolveAccount).toBe("function");
|
|
});
|
|
}
|
|
|
|
type ChannelActionsContractCase = {
|
|
name: string;
|
|
cfg: OpenClawConfig;
|
|
expectedActions: readonly ChannelMessageActionName[];
|
|
expectedCapabilities?: readonly ChannelMessageCapability[];
|
|
beforeTest?: () => void;
|
|
};
|
|
|
|
export function installChannelActionsContractSuite(params: {
|
|
plugin: Pick<ChannelPlugin, "id" | "actions">;
|
|
cases: readonly ChannelActionsContractCase[];
|
|
unsupportedAction?: ChannelMessageActionName;
|
|
}) {
|
|
it("exposes the base message actions contract", () => {
|
|
expect(params.plugin.actions).toBeDefined();
|
|
expect(typeof params.plugin.actions?.listActions).toBe("function");
|
|
});
|
|
|
|
for (const testCase of params.cases) {
|
|
it(`actions contract: ${testCase.name}`, () => {
|
|
testCase.beforeTest?.();
|
|
|
|
const actions = params.plugin.actions?.listActions?.({ cfg: testCase.cfg }) ?? [];
|
|
const capabilities = params.plugin.actions?.getCapabilities?.({ cfg: testCase.cfg }) ?? [];
|
|
|
|
expect(actions).toEqual([...new Set(actions)]);
|
|
expect(capabilities).toEqual([...new Set(capabilities)]);
|
|
expect(sortStrings(actions)).toEqual(sortStrings(testCase.expectedActions));
|
|
expect(sortStrings(capabilities)).toEqual(sortStrings(testCase.expectedCapabilities ?? []));
|
|
|
|
if (params.plugin.actions?.supportsAction) {
|
|
for (const action of testCase.expectedActions) {
|
|
expect(params.plugin.actions.supportsAction({ action })).toBe(true);
|
|
}
|
|
if (
|
|
params.unsupportedAction &&
|
|
!testCase.expectedActions.includes(params.unsupportedAction)
|
|
) {
|
|
expect(params.plugin.actions.supportsAction({ action: params.unsupportedAction })).toBe(
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export function installChannelSurfaceContractSuite(params: {
|
|
plugin: Pick<
|
|
ChannelPlugin,
|
|
| "id"
|
|
| "actions"
|
|
| "setup"
|
|
| "status"
|
|
| "outbound"
|
|
| "messaging"
|
|
| "threading"
|
|
| "directory"
|
|
| "gateway"
|
|
>;
|
|
surface:
|
|
| "actions"
|
|
| "setup"
|
|
| "status"
|
|
| "outbound"
|
|
| "messaging"
|
|
| "threading"
|
|
| "directory"
|
|
| "gateway";
|
|
}) {
|
|
const { plugin, surface } = params;
|
|
|
|
it(`exposes the ${surface} surface contract`, () => {
|
|
if (surface === "actions") {
|
|
expect(plugin.actions).toBeDefined();
|
|
expect(typeof plugin.actions?.listActions).toBe("function");
|
|
return;
|
|
}
|
|
|
|
if (surface === "setup") {
|
|
expect(plugin.setup).toBeDefined();
|
|
expect(typeof plugin.setup?.applyAccountConfig).toBe("function");
|
|
return;
|
|
}
|
|
|
|
if (surface === "status") {
|
|
expect(plugin.status).toBeDefined();
|
|
expect(typeof plugin.status?.buildAccountSnapshot).toBe("function");
|
|
return;
|
|
}
|
|
|
|
if (surface === "outbound") {
|
|
const outbound = plugin.outbound;
|
|
expect(outbound).toBeDefined();
|
|
expect(["direct", "gateway", "hybrid"]).toContain(outbound?.deliveryMode);
|
|
expect(
|
|
[
|
|
outbound?.sendPayload,
|
|
outbound?.sendFormattedText,
|
|
outbound?.sendFormattedMedia,
|
|
outbound?.sendText,
|
|
outbound?.sendMedia,
|
|
outbound?.sendPoll,
|
|
].some((value) => typeof value === "function"),
|
|
).toBe(true);
|
|
return;
|
|
}
|
|
|
|
if (surface === "messaging") {
|
|
const messaging = plugin.messaging;
|
|
expect(messaging).toBeDefined();
|
|
expect(
|
|
[
|
|
messaging?.normalizeTarget,
|
|
messaging?.parseExplicitTarget,
|
|
messaging?.inferTargetChatType,
|
|
messaging?.buildCrossContextComponents,
|
|
messaging?.enableInteractiveReplies,
|
|
messaging?.hasStructuredReplyPayload,
|
|
messaging?.formatTargetDisplay,
|
|
messaging?.resolveOutboundSessionRoute,
|
|
].some((value) => typeof value === "function"),
|
|
).toBe(true);
|
|
if (messaging?.targetResolver) {
|
|
if (messaging.targetResolver.looksLikeId) {
|
|
expect(typeof messaging.targetResolver.looksLikeId).toBe("function");
|
|
}
|
|
if (messaging.targetResolver.hint !== undefined) {
|
|
expect(typeof messaging.targetResolver.hint).toBe("string");
|
|
expect(messaging.targetResolver.hint.trim()).not.toBe("");
|
|
}
|
|
if (messaging.targetResolver.resolveTarget) {
|
|
expect(typeof messaging.targetResolver.resolveTarget).toBe("function");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (surface === "threading") {
|
|
const threading = plugin.threading;
|
|
expect(threading).toBeDefined();
|
|
expect(
|
|
[
|
|
threading?.resolveReplyToMode,
|
|
threading?.buildToolContext,
|
|
threading?.resolveAutoThreadId,
|
|
threading?.resolveReplyTransport,
|
|
threading?.resolveFocusedBinding,
|
|
].some((value) => typeof value === "function"),
|
|
).toBe(true);
|
|
return;
|
|
}
|
|
|
|
if (surface === "directory") {
|
|
const directory = plugin.directory;
|
|
expect(directory).toBeDefined();
|
|
expect(
|
|
[
|
|
directory?.self,
|
|
directory?.listPeers,
|
|
directory?.listPeersLive,
|
|
directory?.listGroups,
|
|
directory?.listGroupsLive,
|
|
directory?.listGroupMembers,
|
|
].some((value) => typeof value === "function"),
|
|
).toBe(true);
|
|
return;
|
|
}
|
|
|
|
const gateway = plugin.gateway;
|
|
expect(gateway).toBeDefined();
|
|
expect(
|
|
[
|
|
gateway?.startAccount,
|
|
gateway?.stopAccount,
|
|
gateway?.loginWithQrStart,
|
|
gateway?.loginWithQrWait,
|
|
gateway?.logoutAccount,
|
|
].some((value) => typeof value === "function"),
|
|
).toBe(true);
|
|
});
|
|
}
|
|
|
|
export function installChannelThreadingContractSuite(params: {
|
|
plugin: Pick<ChannelPlugin, "id" | "threading">;
|
|
}) {
|
|
it("exposes the base threading contract", () => {
|
|
expect(params.plugin.threading).toBeDefined();
|
|
});
|
|
|
|
it("keeps threading return values normalized", () => {
|
|
const threading = params.plugin.threading;
|
|
expect(threading).toBeDefined();
|
|
|
|
if (threading?.resolveReplyToMode) {
|
|
expect(
|
|
["off", "first", "all"].includes(
|
|
threading.resolveReplyToMode({
|
|
cfg: {} as OpenClawConfig,
|
|
accountId: "default",
|
|
chatType: "group",
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
}
|
|
|
|
const repliedRef = { value: false };
|
|
const toolContext = threading?.buildToolContext?.({
|
|
cfg: {} as OpenClawConfig,
|
|
accountId: "default",
|
|
context: {
|
|
Channel: "group:test",
|
|
From: "user:test",
|
|
To: "group:test",
|
|
ChatType: "group",
|
|
CurrentMessageId: "msg-1",
|
|
ReplyToId: "msg-0",
|
|
ReplyToIdFull: "thread-0",
|
|
MessageThreadId: "thread-0",
|
|
NativeChannelId: "native:test",
|
|
},
|
|
hasRepliedRef: repliedRef,
|
|
});
|
|
|
|
if (toolContext) {
|
|
expectThreadingToolContextShape(toolContext);
|
|
if (toolContext.hasRepliedRef) {
|
|
expect(toolContext.hasRepliedRef).toBe(repliedRef);
|
|
}
|
|
}
|
|
|
|
const autoThreadId = threading?.resolveAutoThreadId?.({
|
|
cfg: {} as OpenClawConfig,
|
|
accountId: "default",
|
|
to: "group:test",
|
|
toolContext,
|
|
replyToId: null,
|
|
});
|
|
if (autoThreadId !== undefined) {
|
|
expect(typeof autoThreadId).toBe("string");
|
|
expect(autoThreadId.trim()).not.toBe("");
|
|
}
|
|
|
|
const replyTransport = threading?.resolveReplyTransport?.({
|
|
cfg: {} as OpenClawConfig,
|
|
accountId: "default",
|
|
threadId: "thread-0",
|
|
replyToId: "msg-0",
|
|
});
|
|
if (replyTransport) {
|
|
expectReplyTransportShape(replyTransport);
|
|
}
|
|
|
|
const focusedBinding = threading?.resolveFocusedBinding?.({
|
|
cfg: {} as OpenClawConfig,
|
|
accountId: "default",
|
|
context: {
|
|
Channel: "group:test",
|
|
From: "user:test",
|
|
To: "group:test",
|
|
ChatType: "group",
|
|
CurrentMessageId: "msg-1",
|
|
ReplyToId: "msg-0",
|
|
ReplyToIdFull: "thread-0",
|
|
MessageThreadId: "thread-0",
|
|
NativeChannelId: "native:test",
|
|
},
|
|
});
|
|
if (focusedBinding) {
|
|
expectFocusedBindingShape(focusedBinding);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function installChannelDirectoryContractSuite(params: {
|
|
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
|
coverage?: "lookups" | "presence";
|
|
cfg?: OpenClawConfig;
|
|
accountId?: string;
|
|
}) {
|
|
it("exposes the base directory contract", async () => {
|
|
const directory = params.plugin.directory;
|
|
expect(directory).toBeDefined();
|
|
|
|
if (params.coverage === "presence") {
|
|
return;
|
|
}
|
|
const self = await directory?.self?.({
|
|
cfg: params.cfg ?? ({} as OpenClawConfig),
|
|
accountId: params.accountId ?? "default",
|
|
runtime: contractRuntime,
|
|
});
|
|
if (self) {
|
|
expectDirectoryEntryShape(self);
|
|
}
|
|
|
|
const peers =
|
|
(await directory?.listPeers?.({
|
|
cfg: params.cfg ?? ({} as OpenClawConfig),
|
|
accountId: params.accountId ?? "default",
|
|
query: "",
|
|
limit: 5,
|
|
runtime: contractRuntime,
|
|
})) ?? [];
|
|
expect(Array.isArray(peers)).toBe(true);
|
|
for (const peer of peers) {
|
|
expectDirectoryEntryShape(peer);
|
|
}
|
|
|
|
const groups =
|
|
(await directory?.listGroups?.({
|
|
cfg: params.cfg ?? ({} as OpenClawConfig),
|
|
accountId: params.accountId ?? "default",
|
|
query: "",
|
|
limit: 5,
|
|
runtime: contractRuntime,
|
|
})) ?? [];
|
|
expect(Array.isArray(groups)).toBe(true);
|
|
for (const group of groups) {
|
|
expectDirectoryEntryShape(group);
|
|
}
|
|
|
|
if (directory?.listGroupMembers && groups[0]?.id) {
|
|
const members = await directory.listGroupMembers({
|
|
cfg: params.cfg ?? ({} as OpenClawConfig),
|
|
accountId: params.accountId ?? "default",
|
|
groupId: groups[0].id,
|
|
limit: 5,
|
|
runtime: contractRuntime,
|
|
});
|
|
expect(Array.isArray(members)).toBe(true);
|
|
for (const member of members) {
|
|
expectDirectoryEntryShape(member);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export function installSessionBindingContractSuite(params: {
|
|
getCapabilities: () => SessionBindingCapabilities;
|
|
bindAndResolve: () => Promise<SessionBindingRecord>;
|
|
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
|
cleanup: () => Promise<void> | void;
|
|
expectedCapabilities: SessionBindingCapabilities;
|
|
}) {
|
|
it("registers the expected session binding capabilities", () => {
|
|
expect(params.getCapabilities()).toEqual(params.expectedCapabilities);
|
|
});
|
|
|
|
it("binds and resolves a session binding through the shared service", async () => {
|
|
const binding = await params.bindAndResolve();
|
|
expect(typeof binding.bindingId).toBe("string");
|
|
expect(binding.bindingId.trim()).not.toBe("");
|
|
expect(typeof binding.targetSessionKey).toBe("string");
|
|
expect(binding.targetSessionKey.trim()).not.toBe("");
|
|
expect(["session", "subagent"]).toContain(binding.targetKind);
|
|
expect(typeof binding.conversation.channel).toBe("string");
|
|
expect(typeof binding.conversation.accountId).toBe("string");
|
|
expect(typeof binding.conversation.conversationId).toBe("string");
|
|
expect(["active", "ending", "ended"]).toContain(binding.status);
|
|
expect(typeof binding.boundAt).toBe("number");
|
|
});
|
|
|
|
it("unbinds a registered binding through the shared service", async () => {
|
|
const binding = await params.bindAndResolve();
|
|
await params.unbindAndVerify(binding);
|
|
});
|
|
|
|
it("cleans up registered bindings", async () => {
|
|
await params.cleanup();
|
|
});
|
|
}
|
|
|
|
type ChannelSetupContractCase<ResolvedAccount> = {
|
|
name: string;
|
|
cfg: OpenClawConfig;
|
|
accountId?: string;
|
|
input: ChannelSetupInput;
|
|
expectedAccountId?: string;
|
|
expectedValidation?: string | null;
|
|
beforeTest?: () => void;
|
|
assertPatchedConfig?: (cfg: OpenClawConfig) => void;
|
|
assertResolvedAccount?: (account: ResolvedAccount, cfg: OpenClawConfig) => void;
|
|
};
|
|
|
|
export function installChannelSetupContractSuite<ResolvedAccount>(params: {
|
|
plugin: Pick<ChannelPlugin<ResolvedAccount>, "id" | "config" | "setup">;
|
|
cases: readonly ChannelSetupContractCase<ResolvedAccount>[];
|
|
}) {
|
|
it("exposes the base setup contract", () => {
|
|
expect(params.plugin.setup).toBeDefined();
|
|
expect(typeof params.plugin.setup?.applyAccountConfig).toBe("function");
|
|
});
|
|
|
|
for (const testCase of params.cases) {
|
|
it(`setup contract: ${testCase.name}`, () => {
|
|
testCase.beforeTest?.();
|
|
|
|
const resolvedAccountId =
|
|
params.plugin.setup?.resolveAccountId?.({
|
|
cfg: testCase.cfg,
|
|
accountId: testCase.accountId,
|
|
input: testCase.input,
|
|
}) ??
|
|
testCase.accountId ??
|
|
"default";
|
|
|
|
expect(resolvedAccountId).toBe(testCase.expectedAccountId ?? resolvedAccountId);
|
|
|
|
const validation =
|
|
params.plugin.setup?.validateInput?.({
|
|
cfg: testCase.cfg,
|
|
accountId: resolvedAccountId,
|
|
input: testCase.input,
|
|
}) ?? null;
|
|
expect(validation).toBe(testCase.expectedValidation ?? null);
|
|
|
|
const nextCfg = params.plugin.setup?.applyAccountConfig({
|
|
cfg: testCase.cfg,
|
|
accountId: resolvedAccountId,
|
|
input: testCase.input,
|
|
});
|
|
expect(nextCfg).toBeDefined();
|
|
|
|
const account = params.plugin.config.resolveAccount(nextCfg!, resolvedAccountId);
|
|
testCase.assertPatchedConfig?.(nextCfg!);
|
|
testCase.assertResolvedAccount?.(account, nextCfg!);
|
|
});
|
|
}
|
|
}
|
|
|
|
type ChannelStatusContractCase<Probe> = {
|
|
name: string;
|
|
cfg: OpenClawConfig;
|
|
accountId?: string;
|
|
runtime?: ChannelAccountSnapshot;
|
|
probe?: Probe;
|
|
beforeTest?: () => void;
|
|
expectedState?: ChannelAccountState;
|
|
resolveStateInput?: {
|
|
configured: boolean;
|
|
enabled: boolean;
|
|
};
|
|
assertSnapshot?: (snapshot: ChannelAccountSnapshot) => void;
|
|
assertSummary?: (summary: Record<string, unknown>) => void;
|
|
};
|
|
|
|
export function installChannelStatusContractSuite<ResolvedAccount, Probe = unknown>(params: {
|
|
plugin: Pick<ChannelPlugin<ResolvedAccount, Probe>, "id" | "config" | "status">;
|
|
cases: readonly ChannelStatusContractCase<Probe>[];
|
|
}) {
|
|
it("exposes the base status contract", () => {
|
|
expect(params.plugin.status).toBeDefined();
|
|
expect(typeof params.plugin.status?.buildAccountSnapshot).toBe("function");
|
|
});
|
|
|
|
if (params.plugin.status?.defaultRuntime) {
|
|
it("status contract: default runtime is shaped like an account snapshot", () => {
|
|
expect(typeof params.plugin.status?.defaultRuntime?.accountId).toBe("string");
|
|
});
|
|
}
|
|
|
|
for (const testCase of params.cases) {
|
|
it(`status contract: ${testCase.name}`, async () => {
|
|
testCase.beforeTest?.();
|
|
|
|
const account = params.plugin.config.resolveAccount(testCase.cfg, testCase.accountId);
|
|
const snapshot = await params.plugin.status!.buildAccountSnapshot!({
|
|
account,
|
|
cfg: testCase.cfg,
|
|
runtime: testCase.runtime,
|
|
probe: testCase.probe,
|
|
});
|
|
|
|
expect(typeof snapshot.accountId).toBe("string");
|
|
expect(snapshot.accountId.trim()).not.toBe("");
|
|
testCase.assertSnapshot?.(snapshot);
|
|
|
|
if (params.plugin.status?.buildChannelSummary) {
|
|
const defaultAccountId =
|
|
params.plugin.config.defaultAccountId?.(testCase.cfg) ?? testCase.accountId ?? "default";
|
|
const summary = await params.plugin.status.buildChannelSummary({
|
|
account,
|
|
cfg: testCase.cfg,
|
|
defaultAccountId,
|
|
snapshot,
|
|
});
|
|
expect(summary).toEqual(expect.any(Object));
|
|
testCase.assertSummary?.(summary);
|
|
}
|
|
|
|
if (testCase.expectedState && params.plugin.status?.resolveAccountState) {
|
|
const state = params.plugin.status.resolveAccountState({
|
|
account,
|
|
cfg: testCase.cfg,
|
|
configured: testCase.resolveStateInput?.configured ?? true,
|
|
enabled: testCase.resolveStateInput?.enabled ?? true,
|
|
});
|
|
expect(state).toBe(testCase.expectedState);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
type PayloadLike = {
|
|
mediaUrl?: string;
|
|
mediaUrls?: string[];
|
|
text?: string;
|
|
};
|
|
|
|
type SendResultLike = {
|
|
messageId: string;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type ChunkingMode =
|
|
| {
|
|
longTextLength: number;
|
|
maxChunkLength: number;
|
|
mode: "split";
|
|
}
|
|
| {
|
|
longTextLength: number;
|
|
mode: "passthrough";
|
|
};
|
|
|
|
export function installChannelOutboundPayloadContractSuite(params: {
|
|
channel: string;
|
|
chunking: ChunkingMode;
|
|
createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) => {
|
|
run: () => Promise<Record<string, unknown>>;
|
|
sendMock: Mock;
|
|
to: string;
|
|
};
|
|
}) {
|
|
it("text-only delegates to sendText", async () => {
|
|
const { run, sendMock, to } = params.createHarness({
|
|
payload: { text: "hello" },
|
|
});
|
|
const result = await run();
|
|
|
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
|
expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object));
|
|
expect(result).toMatchObject({ channel: params.channel });
|
|
});
|
|
|
|
it("single media delegates to sendMedia", async () => {
|
|
const { run, sendMock, to } = params.createHarness({
|
|
payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" },
|
|
});
|
|
const result = await run();
|
|
|
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
|
expect(sendMock).toHaveBeenCalledWith(
|
|
to,
|
|
"cap",
|
|
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
|
|
);
|
|
expect(result).toMatchObject({ channel: params.channel });
|
|
});
|
|
|
|
it("multi-media iterates URLs with caption on first", async () => {
|
|
const { run, sendMock, to } = params.createHarness({
|
|
payload: {
|
|
text: "caption",
|
|
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
|
},
|
|
sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }],
|
|
});
|
|
const result = await run();
|
|
|
|
expect(sendMock).toHaveBeenCalledTimes(2);
|
|
expect(sendMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
to,
|
|
"caption",
|
|
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
|
|
);
|
|
expect(sendMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
to,
|
|
"",
|
|
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
|
|
);
|
|
expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" });
|
|
});
|
|
|
|
it("empty payload returns no-op", async () => {
|
|
const { run, sendMock } = params.createHarness({ payload: {} });
|
|
const result = await run();
|
|
|
|
expect(sendMock).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ channel: params.channel, messageId: "" });
|
|
});
|
|
|
|
if (params.chunking.mode === "passthrough") {
|
|
it("text exceeding chunk limit is sent as-is when chunker is null", async () => {
|
|
const text = "a".repeat(params.chunking.longTextLength);
|
|
const { run, sendMock, to } = params.createHarness({ payload: { text } });
|
|
const result = await run();
|
|
|
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
|
expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object));
|
|
expect(result).toMatchObject({ channel: params.channel });
|
|
});
|
|
return;
|
|
}
|
|
|
|
const chunking = params.chunking;
|
|
|
|
it("chunking splits long text", async () => {
|
|
const text = "a".repeat(chunking.longTextLength);
|
|
const { run, sendMock } = params.createHarness({
|
|
payload: { text },
|
|
sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }],
|
|
});
|
|
const result = await run();
|
|
|
|
expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
for (const call of sendMock.mock.calls) {
|
|
expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength);
|
|
}
|
|
expect(result).toMatchObject({ channel: params.channel });
|
|
});
|
|
}
|
|
|
|
export function primeChannelOutboundSendMock(
|
|
sendMock: Mock,
|
|
fallbackResult: Record<string, unknown>,
|
|
sendResults: SendResultLike[] = [],
|
|
) {
|
|
sendMock.mockReset();
|
|
if (sendResults.length === 0) {
|
|
sendMock.mockResolvedValue(fallbackResult);
|
|
return;
|
|
}
|
|
for (const result of sendResults) {
|
|
sendMock.mockResolvedValueOnce(result);
|
|
}
|
|
}
|
|
|
|
type RuntimeGroupPolicyResolver = (
|
|
params: ResolveProviderRuntimeGroupPolicyParams,
|
|
) => RuntimeGroupPolicyResolution;
|
|
|
|
export function installChannelRuntimeGroupPolicyFallbackSuite(params: {
|
|
configuredLabel: string;
|
|
defaultGroupPolicyUnderTest: "allowlist" | "disabled" | "open";
|
|
missingConfigLabel: string;
|
|
missingDefaultLabel: string;
|
|
resolve: RuntimeGroupPolicyResolver;
|
|
}) {
|
|
it(params.missingConfigLabel, () => {
|
|
const resolved = params.resolve({
|
|
providerConfigPresent: false,
|
|
});
|
|
expect(resolved.groupPolicy).toBe("allowlist");
|
|
expect(resolved.providerMissingFallbackApplied).toBe(true);
|
|
});
|
|
|
|
it(params.configuredLabel, () => {
|
|
const resolved = params.resolve({
|
|
providerConfigPresent: true,
|
|
});
|
|
expect(resolved.groupPolicy).toBe("open");
|
|
expect(resolved.providerMissingFallbackApplied).toBe(false);
|
|
});
|
|
|
|
it(params.missingDefaultLabel, () => {
|
|
const resolved = params.resolve({
|
|
providerConfigPresent: false,
|
|
defaultGroupPolicy: params.defaultGroupPolicyUnderTest,
|
|
});
|
|
expect(resolved.groupPolicy).toBe("allowlist");
|
|
expect(resolved.providerMissingFallbackApplied).toBe(true);
|
|
});
|
|
}
|
|
|
|
export function expectChannelInboundContextContract(ctx: MsgContext) {
|
|
expect(validateSenderIdentity(ctx)).toEqual([]);
|
|
|
|
expect(ctx.Body).toBeTypeOf("string");
|
|
expect(ctx.BodyForAgent).toBeTypeOf("string");
|
|
expect(ctx.BodyForCommands).toBeTypeOf("string");
|
|
|
|
const chatType = normalizeChatType(ctx.ChatType);
|
|
if (chatType && chatType !== "direct") {
|
|
const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx);
|
|
expect(label).toBeTruthy();
|
|
}
|
|
}
|