From 23f618d62d6e390204eca7e24e322e91af11617f Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 09:47:58 +0530 Subject: [PATCH] test(telegram): rewire bot harnesses to runtime seams --- .../bot.create-telegram-bot.test-harness.ts | 131 +++++++++++++----- .../src/bot.create-telegram-bot.test.ts | 19 ++- extensions/telegram/src/bot.test.ts | 18 ++- 3 files changed, 126 insertions(+), 42 deletions(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index f8573fecadd..d8a742a0340 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -7,6 +7,21 @@ import { beforeEach, vi } from "vitest"; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; +type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< + ReturnType +>; +type DispatchReplyHarnessParams = { + ctx: MsgContext; + replyOptions?: GetReplyOptions; + dispatcherOptions?: { + typingCallbacks?: { + start?: () => void | Promise; + }; + deliver?: (payload: ReplyPayload, info: { kind: "final" }) => void | Promise; + }; +}; const { sessionStorePath } = vi.hoisted(() => ({ sessionStorePath: `/tmp/openclaw-telegram-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}.json`, @@ -20,18 +35,21 @@ export function getLoadWebMediaMock(): AnyMock { return loadWebMedia; } -vi.mock("openclaw/plugin-sdk/web-media", () => ({ +vi.doMock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia, })); const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({ loadConfig: vi.fn(() => ({})), })); +const { resolveStorePathMock } = vi.hoisted((): { resolveStorePathMock: AnyMock } => ({ + resolveStorePathMock: vi.fn((storePath?: string) => storePath ?? sessionStorePath), +})); export function getLoadConfigMock(): AnyMock { return loadConfig; } -vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { +vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, @@ -39,11 +57,11 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { }; }); -vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { +vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + resolveStorePath: resolveStorePathMock, }; }); @@ -68,7 +86,7 @@ export function getUpsertChannelPairingRequestMock(): AnyAsyncMock { return upsertChannelPairingRequest; } -vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { +vi.doMock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, @@ -89,23 +107,36 @@ const skillCommandsHoisted = vi.hoisted(() => ({ configOverride?: OpenClawConfig, ) => Promise >, + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async (params: DispatchReplyHarnessParams) => { + const result: DispatchReplyWithBufferedBlockDispatcherResult = { + queuedFinal: false, + counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], + }; + await params.dispatcherOptions?.typingCallbacks?.start?.(); + const reply = await skillCommandsHoisted.replySpy(params.ctx, params.replyOptions); + const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; + for (const payload of payloads) { + await params.dispatcherOptions?.deliver?.(payload, { kind: "final" }); + } + return result; + }, + ), })); export const listSkillCommandsForAgents = skillCommandsHoisted.listSkillCommandsForAgents; export const replySpy = skillCommandsHoisted.replySpy; +export const dispatchReplyWithBufferedBlockDispatcher = + skillCommandsHoisted.dispatchReplyWithBufferedBlockDispatcher; -vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { +vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents: skillCommandsHoisted.listSkillCommandsForAgents, getReplyFromConfig: skillCommandsHoisted.replySpy, __replySpy: skillCommandsHoisted.replySpy, - dispatchReplyWithBufferedBlockDispatcher: vi.fn( - async ({ ctx, replyOptions }: { ctx: MsgContext; replyOptions?: GetReplyOptions }) => { - await skillCommandsHoisted.replySpy(ctx, replyOptions); - return { queuedFinal: false }; - }, - ), + dispatchReplyWithBufferedBlockDispatcher: + skillCommandsHoisted.dispatchReplyWithBufferedBlockDispatcher, }; }); @@ -114,7 +145,7 @@ const systemEventsHoisted = vi.hoisted(() => ({ })); export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy; -vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { +vi.doMock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, @@ -127,7 +158,7 @@ const sentMessageCacheHoisted = vi.hoisted(() => ({ })); export const wasSentByBot = sentMessageCacheHoisted.wasSentByBot; -vi.mock("./sent-message-cache.js", () => ({ +vi.doMock("./sent-message-cache.js", () => ({ wasSentByBot: sentMessageCacheHoisted.wasSentByBot, recordSentMessage: vi.fn(), clearSentMessageCache: vi.fn(), @@ -181,7 +212,19 @@ export const { getFileSpy, } = grammySpies; -vi.mock("grammy", () => ({ +const runnerHoisted = vi.hoisted(() => ({ + sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise) => { + if (typeof next === "function") { + await next(); + } + }), + sequentializeSpy: vi.fn(() => runnerHoisted.sequentializeMiddleware), + throttlerSpy: vi.fn(() => "throttler"), +})); +export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy; +export let sequentializeKey: ((ctx: unknown) => string) | undefined; +export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy; +export const telegramBotRuntimeForTest = { Bot: class { api = { config: { use: grammySpies.useSpy }, @@ -210,32 +253,30 @@ vi.mock("grammy", () => ({ grammySpies.botCtorSpy(token, options); } }, - InputFile: class {}, -})); - -const runnerHoisted = vi.hoisted(() => ({ - sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise) => { - if (typeof next === "function") { - await next(); - } - }), - sequentializeSpy: vi.fn(() => runnerHoisted.sequentializeMiddleware), - throttlerSpy: vi.fn(() => "throttler"), -})); -export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy; -export let sequentializeKey: ((ctx: unknown) => string) | undefined; -vi.mock("@grammyjs/runner", () => ({ sequentialize: (keyFn: (ctx: unknown) => string) => { sequentializeKey = keyFn; return runnerHoisted.sequentializeSpy(); }, -})); - -export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy; - -vi.mock("@grammyjs/transformer-throttler", () => ({ apiThrottler: () => runnerHoisted.throttlerSpy(), -})); + loadConfig, +}; +export const telegramBotMessageDispatchRuntimeForTest = { + dispatchReplyWithBufferedBlockDispatcher, +}; +export const telegramBotNativeCommandsRuntimeForTest = { + dispatchReplyWithBufferedBlockDispatcher, + listSkillCommandsForAgents, +}; +export const telegramBotHandlersRuntimeForTest = { + loadConfig, + resolveStorePath: resolveStorePathMock, + readChannelAllowFromStore, + enqueueSystemEvent: enqueueSystemEventSpy, + listSkillCommandsForAgents, + wasSentByBot, +}; + +vi.doMock("./bot.runtime.js", () => telegramBotRuntimeForTest); export const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -310,6 +351,8 @@ beforeEach(() => { resetInboundDedupe(); loadConfig.mockReset(); loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG); + resolveStorePathMock.mockReset(); + resolveStorePathMock.mockImplementation((storePath?: string) => storePath ?? sessionStorePath); loadWebMedia.mockReset(); readChannelAllowFromStore.mockReset(); readChannelAllowFromStore.mockResolvedValue([]); @@ -324,6 +367,22 @@ beforeEach(() => { await opts?.onReplyStart?.(); return undefined; }); + dispatchReplyWithBufferedBlockDispatcher.mockReset(); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async (params: DispatchReplyHarnessParams) => { + const result: DispatchReplyWithBufferedBlockDispatcherResult = { + queuedFinal: false, + counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], + }; + await params.dispatcherOptions?.typingCallbacks?.start?.(); + const reply = await replySpy(params.ctx, params.replyOptions); + const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; + for (const payload of payloads) { + await params.dispatcherOptions?.deliver?.(payload, { kind: "final" }); + } + return result; + }, + ); sendAnimationSpy.mockReset(); sendAnimationSpy.mockResolvedValue({ message_id: 78 }); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 1cb0fd98512..c791282b9f9 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; import { useFrozenTime, useRealTime } from "../../../test/helpers/extensions/frozen-time.js"; -import { +const { answerCallbackQuerySpy, botCtorSpy, commandSpy, @@ -26,13 +26,26 @@ import { sequentializeSpy, setMessageReactionSpy, setMyCommandsSpy, + telegramBotHandlersRuntimeForTest, + telegramBotMessageDispatchRuntimeForTest, + telegramBotNativeCommandsRuntimeForTest, + telegramBotRuntimeForTest, throttlerSpy, useSpy, -} from "./bot.create-telegram-bot.test-harness.js"; +} = await import("./bot.create-telegram-bot.test-harness.js"); import { resolveTelegramFetch } from "./fetch.js"; // Import after the harness registers `vi.mock(...)` for grammY and Telegram internals. -const { createTelegramBot, getTelegramSequentialKey } = await import("./bot.js"); +const { createTelegramBot, getTelegramSequentialKey, setTelegramBotRuntimeForTest } = + await import("./bot.js"); +const { setBotHandlersRuntimeForTest } = await import("./bot-handlers.runtime.js"); +const { setBotMessageDispatchRuntimeForTest } = await import("./bot-message-dispatch.js"); +const { setBotNativeCommandsRuntimeForTest } = await import("./bot-native-commands.js"); + +setTelegramBotRuntimeForTest(telegramBotRuntimeForTest); +setBotHandlersRuntimeForTest(telegramBotHandlersRuntimeForTest); +setBotMessageDispatchRuntimeForTest(telegramBotMessageDispatchRuntimeForTest); +setBotNativeCommandsRuntimeForTest(telegramBotNativeCommandsRuntimeForTest); const loadConfig = getLoadConfigMock(); const loadWebMedia = getLoadWebMediaMock(); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 3266c080254..36a42a74830 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -7,7 +7,7 @@ import { registerPluginInteractiveHandler, } from "../../../src/plugins/interactive.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; -import { +const { answerCallbackQuerySpy, commandSpy, editMessageReplyMarkupSpy, @@ -22,8 +22,12 @@ import { replySpy, sendMessageSpy, setMyCommandsSpy, + telegramBotHandlersRuntimeForTest, + telegramBotMessageDispatchRuntimeForTest, + telegramBotNativeCommandsRuntimeForTest, + telegramBotRuntimeForTest, wasSentByBot, -} from "./bot.create-telegram-bot.test-harness.js"; +} = await import("./bot.create-telegram-bot.test-harness.js"); // Import after the harness registers `vi.mock(...)` for grammY and Telegram internals. const { listNativeCommandSpecs, listNativeCommandSpecsForConfig } = @@ -31,7 +35,15 @@ const { listNativeCommandSpecs, listNativeCommandSpecsForConfig } = const { loadSessionStore } = await import("../../../src/config/sessions.js"); const { normalizeTelegramCommandName } = await import("../../../src/config/telegram-custom-commands.js"); -const { createTelegramBot } = await import("./bot.js"); +const { createTelegramBot, setTelegramBotRuntimeForTest } = await import("./bot.js"); +const { setBotHandlersRuntimeForTest } = await import("./bot-handlers.runtime.js"); +const { setBotMessageDispatchRuntimeForTest } = await import("./bot-message-dispatch.js"); +const { setBotNativeCommandsRuntimeForTest } = await import("./bot-native-commands.js"); + +setTelegramBotRuntimeForTest(telegramBotRuntimeForTest); +setBotHandlersRuntimeForTest(telegramBotHandlersRuntimeForTest); +setBotMessageDispatchRuntimeForTest(telegramBotMessageDispatchRuntimeForTest); +setBotNativeCommandsRuntimeForTest(telegramBotNativeCommandsRuntimeForTest); const loadConfig = getLoadConfigMock(); const readChannelAllowFromStore = getReadChannelAllowFromStoreMock();