diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index b3e8575475c..00e3702a737 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -28,6 +28,10 @@ import { type DispatchReplyParams, } from "./test-support/monitor-test-support.js"; +const { TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS } = vi.hoisted(() => ({ + TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS: 3, +})); + // Mock dependencies vi.mock("./send.js", () => ({ resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), @@ -58,6 +62,17 @@ vi.mock("./history.js", () => ({ fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }), })); +vi.mock("./runtime-api.js", async () => { + const actual = await vi.importActual("./runtime-api.js"); + return { + ...actual, + WEBHOOK_RATE_LIMIT_DEFAULTS: { + ...actual.WEBHOOK_RATE_LIMIT_DEFAULTS, + maxRequests: TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS, + }, + }; +}); + // Mock runtime const mockEnqueueSystemEvent = vi.fn(); const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE"); @@ -414,8 +429,7 @@ describe("BlueBubbles webhook monitor", () => { }); let saw429 = false; - // Default webhook fixed-window budget is 120 requests/minute, so loop past it. - for (let i = 0; i < 130; i += 1) { + for (let i = 0; i < TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS + 4; i += 1) { const candidate = String(i).padStart(8, "0"); const { res } = await dispatchWebhookPayloadForTest( createPasswordQueryRequestParamsForTest({ @@ -453,7 +467,7 @@ describe("BlueBubbles webhook monitor", () => { }); let saw429 = false; - for (let i = 0; i < 130; i += 1) { + for (let i = 0; i < TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS + 4; i += 1) { const candidate = String(i).padStart(8, "0"); const { res } = await dispatchWebhookPayloadForTest( createPasswordQueryRequestParamsForTest({ @@ -515,7 +529,7 @@ describe("BlueBubbles webhook monitor", () => { }); let saw429 = false; - for (let i = 0; i < 130; i += 1) { + for (let i = 0; i < TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS + 4; i += 1) { const candidate = String(i).padStart(8, "0"); const { res } = await dispatchWebhookPayloadForTest( createPasswordQueryRequestParamsForTest({ diff --git a/extensions/discord/src/audit.test.ts b/extensions/discord/src/audit.test.ts index 0446cca2ece..65afcceab3e 100644 --- a/extensions/discord/src/audit.test.ts +++ b/extensions/discord/src/audit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const sendModule = await import("./send.js"); const fetchChannelPermissionsDiscordMock = vi.fn(); @@ -6,11 +6,20 @@ vi.spyOn(sendModule, "fetchChannelPermissionsDiscord").mockImplementation( fetchChannelPermissionsDiscordMock, ); -describe("discord audit", () => { - it("collects numeric channel ids and counts unresolved keys", async () => { - const { collectDiscordAuditChannelIds, auditDiscordChannelPermissions } = - await import("./audit.js"); +let auditDiscordChannelPermissions: typeof import("./audit.js").auditDiscordChannelPermissions; +let collectDiscordAuditChannelIds: typeof import("./audit.js").collectDiscordAuditChannelIds; +describe("discord audit", () => { + beforeAll(async () => { + ({ collectDiscordAuditChannelIds, auditDiscordChannelPermissions } = + await import("./audit.js")); + }); + + beforeEach(() => { + fetchChannelPermissionsDiscordMock.mockReset(); + }); + + it("collects numeric channel ids and counts unresolved keys", async () => { const cfg = { channels: { discord: { @@ -55,9 +64,7 @@ describe("discord audit", () => { expect(audit.channels[0]?.missing).toContain("SendMessages"); }); - it("does not count '*' wildcard key as unresolved channel", async () => { - const { collectDiscordAuditChannelIds } = await import("./audit.js"); - + it("does not count '*' wildcard key as unresolved channel", () => { const cfg = { channels: { discord: { @@ -81,9 +88,7 @@ describe("discord audit", () => { expect(collected.unresolvedChannels).toBe(0); }); - it("handles guild with only '*' wildcard and no numeric channel ids", async () => { - const { collectDiscordAuditChannelIds } = await import("./audit.js"); - + it("handles guild with only '*' wildcard and no numeric channel ids", () => { const cfg = { channels: { discord: { @@ -106,9 +111,7 @@ describe("discord audit", () => { expect(collected.unresolvedChannels).toBe(0); }); - it("collects audit channel ids without resolving SecretRef-backed Discord tokens", async () => { - const { collectDiscordAuditChannelIds } = await import("./audit.js"); - + it("collects audit channel ids without resolving SecretRef-backed Discord tokens", () => { const cfg = { channels: { discord: { diff --git a/extensions/discord/src/gateway-logging.test.ts b/extensions/discord/src/gateway-logging.test.ts index 49959e84fff..f88449fed16 100644 --- a/extensions/discord/src/gateway-logging.test.ts +++ b/extensions/discord/src/gateway-logging.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "node:events"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ logVerbose: vi.fn(), @@ -15,12 +15,15 @@ const makeRuntime = () => ({ }); describe("attachDiscordGatewayLogging", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ logVerbose } = await import("openclaw/plugin-sdk/runtime-env")); ({ attachDiscordGatewayLogging } = await import("./gateway-logging.js")); }); + beforeEach(() => { + vi.clearAllMocks(); + }); + afterEach(() => { vi.clearAllMocks(); }); diff --git a/extensions/discord/src/monitor.test.ts b/extensions/discord/src/monitor.test.ts index 18c57c32c37..ab0c53d0f77 100644 --- a/extensions/discord/src/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -99,6 +99,11 @@ describe("DiscordMessageListener", () => { }; } + async function flushAsyncWork() { + await Promise.resolve(); + await Promise.resolve(); + } + it("returns immediately while handler continues in background", async () => { let handlerResolved = false; const deferred = createDeferred(); @@ -115,9 +120,8 @@ describe("DiscordMessageListener", () => { // handle() returns immediately while the background queue starts on the next tick. await expect(handlePromise).resolves.toBeUndefined(); - await vi.waitFor(() => { - expect(handler).toHaveBeenCalledOnce(); - }); + await flushAsyncWork(); + expect(handler).toHaveBeenCalledOnce(); expect(handlerResolved).toBe(false); // Release and let background handler finish. @@ -154,9 +158,8 @@ describe("DiscordMessageListener", () => { ).resolves.toBeUndefined(); // Both handlers are dispatched concurrently (fire-and-forget). - await vi.waitFor(() => { - expect(handler).toHaveBeenCalledTimes(2); - }); + await flushAsyncWork(); + expect(handler).toHaveBeenCalledTimes(2); first.resolve(); second.resolve(); @@ -179,9 +182,8 @@ describe("DiscordMessageListener", () => { {} as unknown as import("./monitor/listeners.js").DiscordMessageEvent, {} as unknown as import("@buape/carbon").Client, ); - await vi.waitFor(() => { - expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("discord handler failed")); - }); + await flushAsyncWork(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("discord handler failed")); }); it("does not apply its own slow-listener logging (owned by inbound worker)", async () => { @@ -202,9 +204,8 @@ describe("DiscordMessageListener", () => { await expect(handlePromise).resolves.toBeUndefined(); deferred.resolve(); - await vi.waitFor(() => { - expect(handler).toHaveBeenCalledOnce(); - }); + await flushAsyncWork(); + expect(handler).toHaveBeenCalledOnce(); // The listener no longer wraps handlers with slow-listener logging; // that responsibility moved to the inbound worker. expect(logger.warn).not.toHaveBeenCalled(); diff --git a/extensions/discord/src/monitor/exec-approvals.test.ts b/extensions/discord/src/monitor/exec-approvals.test.ts index 11d014acfed..16359131187 100644 --- a/extensions/discord/src/monitor/exec-approvals.test.ts +++ b/extensions/discord/src/monitor/exec-approvals.test.ts @@ -1,18 +1,18 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import type { ButtonInteraction, ComponentData } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js"; import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js"; -const STORE_PATH = path.join(os.tmpdir(), "openclaw-exec-approvals-test.json"); +const { STORE_PATH, mockSessionStoreEntries } = vi.hoisted(() => ({ + STORE_PATH: "/tmp/openclaw-exec-approvals-test.json", + mockSessionStoreEntries: { + value: {} as Record, + }, +})); const writeStore = (store: Record) => { - fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8"); - // CI runners can have coarse mtime resolution; avoid returning stale cached stores. - clearSessionStoreCacheForTest(); + mockSessionStoreEntries.value = JSON.parse(JSON.stringify(store)) as Record; }; beforeEach(() => { @@ -69,68 +69,65 @@ vi.mock("../send.shared.js", async (importOriginal) => { }; }); -vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => { - const actual = await importOriginal(); - type CreateOperatorApprovalsGatewayClientParams = Parameters< - typeof actual.createOperatorApprovalsGatewayClient - >[0]; - class MockGatewayClient { - private params: Record; - constructor(params: Record) { - this.params = params; - gatewayClientParams.push(params); - mockGatewayClientCtor(params); - } - start() { - gatewayClientStarts(); - } - stop() { - gatewayClientStops(); - } - async request(...args: unknown[]) { - return gatewayClientRequests(...args); - } - } +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - GatewayClient: MockGatewayClient, - createOperatorApprovalsGatewayClient: async ( - params: CreateOperatorApprovalsGatewayClientParams, - ) => { - mockCreateOperatorApprovalsGatewayClient(params); - const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim(); - const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789"; - const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined; - const auth = await mockResolveGatewayConnectionAuth({ - config: params.config, - env: process.env, - ...(urlOverrideSource - ? { - urlOverride: gatewayUrl, - urlOverrideSource, - } - : {}), - }); - return new MockGatewayClient({ - url: gatewayUrl, - token: auth?.token, - password: auth?.password, - clientName: "gateway-client", - clientDisplayName: params.clientDisplayName, - mode: "backend", - scopes: ["operator.approvals"], - onEvent: params.onEvent, - onHelloOk: params.onHelloOk, - onConnectError: params.onConnectError, - onClose: params.onClose, - }); - }, + loadSessionStore: () => mockSessionStoreEntries.value, + resolveStorePath: () => STORE_PATH, }; }); -vi.mock("../../../../src/gateway/operator-approvals-client.js", async () => { - class MockGatewayClient { - private params: Record; +vi.mock("../../../../src/gateway/operator-approvals-client.js", () => ({ + createOperatorApprovalsGatewayClient: async (params: { + config?: unknown; + gatewayUrl?: string; + clientDisplayName?: string; + onEvent?: unknown; + onHelloOk?: unknown; + onConnectError?: unknown; + onClose?: unknown; + }) => { + mockCreateOperatorApprovalsGatewayClient(params); + const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim(); + const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789"; + const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined; + const auth = await mockResolveGatewayConnectionAuth({ + config: params.config, + env: process.env, + ...(urlOverrideSource + ? { + urlOverride: gatewayUrl, + urlOverrideSource, + } + : {}), + }); + const clientParams = { + url: gatewayUrl, + token: auth?.token, + password: auth?.password, + clientName: "gateway-client", + clientDisplayName: params.clientDisplayName, + mode: "backend", + scopes: ["operator.approvals"], + onEvent: params.onEvent, + onHelloOk: params.onHelloOk, + onConnectError: params.onConnectError, + onClose: params.onClose, + }; + gatewayClientParams.push(clientParams); + mockGatewayClientCtor(clientParams); + return { + start: gatewayClientStarts, + stop: gatewayClientStops, + request: gatewayClientRequests, + }; + }, +})); + +vi.mock("../../../../src/gateway/client.js", () => ({ + GatewayClient: class { + params: Record; constructor(params: Record) { this.params = params; gatewayClientParams.push(params); @@ -145,55 +142,8 @@ vi.mock("../../../../src/gateway/operator-approvals-client.js", async () => { async request(...args: unknown[]) { return gatewayClientRequests(...args); } - } - - return { - createOperatorApprovalsGatewayClient: async (params: { - config?: { - gateway?: { - auth?: { - token?: string; - password?: string; - }; - }; - }; - gatewayUrl?: string; - clientDisplayName: string; - onEvent?: unknown; - onHelloOk?: () => void; - onConnectError?: (err: Error) => void; - onClose?: (code: number, reason: string) => void; - }) => { - mockCreateOperatorApprovalsGatewayClient(params); - const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim(); - const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789"; - const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined; - const auth = await mockResolveGatewayConnectionAuth({ - config: params.config, - env: process.env, - ...(urlOverrideSource - ? { - urlOverride: gatewayUrl, - urlOverrideSource, - } - : {}), - }); - return new MockGatewayClient({ - url: gatewayUrl, - token: auth?.token, - password: auth?.password, - clientName: "gateway-client", - clientDisplayName: params.clientDisplayName, - mode: "backend", - scopes: ["operator.approvals"], - onEvent: params.onEvent, - onHelloOk: params.onHelloOk, - onConnectError: params.onConnectError, - onClose: params.onClose, - }); - }, - }; -}); + }, +})); vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => { const actual = await importOriginal(); diff --git a/extensions/discord/src/monitor/listeners.test.ts b/extensions/discord/src/monitor/listeners.test.ts index 75ad573471a..92894e66937 100644 --- a/extensions/discord/src/monitor/listeners.test.ts +++ b/extensions/discord/src/monitor/listeners.test.ts @@ -25,6 +25,11 @@ function createDeferred() { return { promise, resolve }; } +async function flushAsyncWork() { + await Promise.resolve(); + await Promise.resolve(); +} + describe("DiscordMessageListener", () => { it("returns immediately without awaiting handler completion", async () => { let resolveHandler: (() => void) | undefined; @@ -40,9 +45,8 @@ describe("DiscordMessageListener", () => { await expect(listener.handle(fakeEvent("ch-1"), {} as never)).resolves.toBeUndefined(); // Handler was dispatched but may not have been called yet (fire-and-forget). // Wait for the microtask to flush so the handler starts. - await vi.waitFor(() => { - expect(handler).toHaveBeenCalledTimes(1); - }); + await flushAsyncWork(); + expect(handler).toHaveBeenCalledTimes(1); expect(logger.error).not.toHaveBeenCalled(); resolveHandler?.(); @@ -71,24 +75,21 @@ describe("DiscordMessageListener", () => { await listener.handle(fakeEvent("ch-1"), {} as never); await listener.handle(fakeEvent("ch-1"), {} as never); - await vi.waitFor(() => { - expect(handler).toHaveBeenCalledTimes(2); - }); + await flushAsyncWork(); + expect(handler).toHaveBeenCalledTimes(2); // Both handlers started without waiting for the first to finish. expect(order).toContain("start:1"); expect(order).toContain("start:2"); deferredB.resolve?.(); - await vi.waitFor(() => { - expect(order).toContain("end:2"); - }); + await flushAsyncWork(); + expect(order).toContain("end:2"); // First handler is still running — no serialization. expect(order).not.toContain("end:1"); deferredA.resolve?.(); - await vi.waitFor(() => { - expect(order).toContain("end:1"); - }); + await flushAsyncWork(); + expect(order).toContain("end:1"); }); it("runs handlers for different channels in parallel", async () => { @@ -109,22 +110,19 @@ describe("DiscordMessageListener", () => { await listener.handle(fakeEvent("ch-a"), {} as never); await listener.handle(fakeEvent("ch-b"), {} as never); - await vi.waitFor(() => { - expect(handler).toHaveBeenCalledTimes(2); - }); + await flushAsyncWork(); + expect(handler).toHaveBeenCalledTimes(2); expect(order).toContain("start:ch-a"); expect(order).toContain("start:ch-b"); deferredB.resolve?.(); - await vi.waitFor(() => { - expect(order).toContain("end:ch-b"); - }); + await flushAsyncWork(); + expect(order).toContain("end:ch-b"); expect(order).not.toContain("end:ch-a"); deferredA.resolve?.(); - await vi.waitFor(() => { - expect(order).toContain("end:ch-a"); - }); + await flushAsyncWork(); + expect(order).toContain("end:ch-a"); }); it("logs async handler failures", async () => { @@ -135,11 +133,10 @@ describe("DiscordMessageListener", () => { const listener = new DiscordMessageListener(handler as never, logger as never); await expect(listener.handle(fakeEvent("ch-1"), {} as never)).resolves.toBeUndefined(); - await vi.waitFor(() => { - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("discord handler failed: Error: boom"), - ); - }); + await flushAsyncWork(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("discord handler failed: Error: boom"), + ); }); it("calls onEvent callback for each message", async () => { diff --git a/extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts b/extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts index 6538ec1915a..befc34608cb 100644 --- a/extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts +++ b/extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts @@ -10,6 +10,11 @@ import { createDiscordPreflightContext, } from "./message-handler.test-helpers.js"; +async function flushAsyncWork() { + await Promise.resolve(); + await Promise.resolve(); +} + function createMessageData(authorId: string, channelId = "ch-1") { return { author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID }, @@ -56,9 +61,8 @@ describe("createDiscordMessageHandler bot-self filter", () => { handler(createMessageData("user-456") as never, {} as never), ).resolves.toBeUndefined(); - await vi.waitFor(() => { - expect(preflightDiscordMessageMock).toHaveBeenCalledTimes(1); - expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); - }); + await flushAsyncWork(); + expect(preflightDiscordMessageMock).toHaveBeenCalledTimes(1); + expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 474b740fda2..88665fac845 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -464,9 +464,7 @@ describe("processDiscordMessage ack reactions", () => { // oxlint-disable-next-line typescript/no-explicit-any await processDiscordMessage(ctx as any); - await vi.waitFor(() => { - expect(sendMocks.removeReactionDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} }); - }); + expect(sendMocks.removeReactionDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} }); }); }); diff --git a/extensions/discord/src/monitor/message-utils.test.ts b/extensions/discord/src/monitor/message-utils.test.ts index 6b19d03af08..4ed4142a616 100644 --- a/extensions/discord/src/monitor/message-utils.test.ts +++ b/extensions/discord/src/monitor/message-utils.test.ts @@ -26,7 +26,6 @@ let resolveForwardedMediaList: typeof import("./message-utils.js").resolveForwar let resolveMediaList: typeof import("./message-utils.js").resolveMediaList; beforeAll(async () => { - vi.resetModules(); ({ __resetDiscordChannelInfoCacheForTest, resolveDiscordChannelInfo, diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 2679f3e7d2c..f72a8a1de69 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -10,7 +10,7 @@ import type { GatewayPresenceUpdate } from "discord-api-types/v10"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import { buildPluginBindingApprovalCustomId } from "openclaw/plugin-sdk/conversation-runtime"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { type DiscordComponentEntry, type DiscordModalEntry } from "../components.js"; import { buildPluginBindingResolvedTextMock, @@ -308,8 +308,7 @@ describe("discord component interactions", () => { expect(dispatchReplyMock).not.toHaveBeenCalled(); } - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ createDiscordComponentButton, createDiscordComponentStringSelect, @@ -322,6 +321,9 @@ describe("discord component interactions", () => { resolveDiscordModalEntry, } = await import("../components-registry.js")); sendComponents = await import("../send.components.js"); + }); + + beforeEach(() => { editDiscordComponentMessageMock = vi .spyOn(sendComponents, "editDiscordComponentMessage") .mockResolvedValue({ diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts index bb5287a7c47..67f80fe51e3 100644 --- a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -8,7 +8,7 @@ import { } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtime"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => @@ -55,9 +55,11 @@ const SESSION_KEY = "agent:main:main"; let resolveDiscordNativeChoiceContext: typeof import("./native-command-ui.js").resolveDiscordNativeChoiceContext; describe("discord native /think autocomplete", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ resolveDiscordNativeChoiceContext } = await import("./native-command-ui.js")); + }); + + beforeEach(() => { clearSessionStoreCacheForTest(); ensureConfiguredBindingRouteReadyMock.mockReset(); ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true }); diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index 978d71d6cf7..00780e51847 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import type { GatewayPlugin } from "@buape/carbon/gateway"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import type { WaitForDiscordGatewayStopParams } from "../monitor.gateway.js"; import type { MutableDiscordGateway } from "./gateway-handle.js"; import type { DiscordGatewayEvent } from "./gateway-supervisor.js"; @@ -61,8 +61,13 @@ vi.mock("./gateway-registry.js", () => ({ })); describe("runDiscordGatewayLifecycle", () => { + let runDiscordGatewayLifecycle: typeof import("./provider.lifecycle.js").runDiscordGatewayLifecycle; + + beforeAll(async () => { + ({ runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js")); + }); + beforeEach(() => { - vi.resetModules(); attachDiscordGatewayLoggingMock.mockClear(); getDiscordGatewayEmitterMock.mockClear(); waitForDiscordGatewayStopMock.mockClear(); @@ -217,7 +222,6 @@ describe("runDiscordGatewayLifecycle", () => { } it("cleans up thread bindings when exec approvals startup fails", async () => { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({ start: async () => { throw new Error("startup failed"); @@ -236,7 +240,6 @@ describe("runDiscordGatewayLifecycle", () => { }); it("cleans up when gateway wait fails after startup", async () => { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed")); const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness(); @@ -255,7 +258,6 @@ describe("runDiscordGatewayLifecycle", () => { }); it("cleans up after successful gateway wait", async () => { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness(); @@ -271,7 +273,6 @@ describe("runDiscordGatewayLifecycle", () => { }); it("pushes connected status when gateway is already connected at lifecycle start", async () => { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness(); gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); @@ -296,7 +297,6 @@ describe("runDiscordGatewayLifecycle", () => { it("forces a fresh reconnect when startup never reaches READY, then recovers", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness(); getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); gateway.connect.mockImplementation((_resume?: boolean) => { @@ -324,7 +324,6 @@ describe("runDiscordGatewayLifecycle", () => { it("clears resume state and suppresses socket-driven auto-resume during forced startup reconnects", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const pendingGatewayEvents: DiscordGatewayEvent[] = []; const socket = new EventEmitter(); const { emitter, gateway } = createGatewayHarness({ @@ -388,7 +387,6 @@ describe("runDiscordGatewayLifecycle", () => { it("waits for forced terminate to close the old socket before reconnecting", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const socket = Object.assign(new EventEmitter(), { terminate: vi.fn(() => { setTimeout(() => { @@ -430,7 +428,6 @@ describe("runDiscordGatewayLifecycle", () => { it("fails closed when forced terminate still does not close the old socket", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const socket = Object.assign(new EventEmitter(), { terminate: vi.fn(() => { setTimeout(() => { @@ -476,7 +473,6 @@ describe("runDiscordGatewayLifecycle", () => { it("does not reconnect after lifecycle shutdown begins during socket drain", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const socket = new EventEmitter(); const { emitter, gateway } = createGatewayHarness({ ws: socket }); getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); @@ -505,7 +501,6 @@ describe("runDiscordGatewayLifecycle", () => { it("treats drain timeout as a graceful stop after lifecycle abort", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const socket = new EventEmitter(); const { emitter, gateway } = createGatewayHarness({ ws: socket }); getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); @@ -540,7 +535,6 @@ describe("runDiscordGatewayLifecycle", () => { it("fails fast when startup never reaches READY after a forced reconnect", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness(); getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = @@ -569,7 +563,6 @@ describe("runDiscordGatewayLifecycle", () => { }); it("handles queued disallowed intents errors without waiting for gateway events", async () => { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } = createLifecycleHarness({ pendingGatewayEvents: [ @@ -593,7 +586,6 @@ describe("runDiscordGatewayLifecycle", () => { }); it("logs queued non-fatal startup gateway errors and continues", async () => { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } = createLifecycleHarness({ pendingGatewayEvents: [createGatewayEvent("other", "transient startup error")], @@ -614,7 +606,6 @@ describe("runDiscordGatewayLifecycle", () => { }); it("throws queued non-disallowed fatal gateway errors", async () => { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({ pendingGatewayEvents: [createGatewayEvent("fatal", "Fatal Gateway error: 4000")], }); @@ -635,7 +626,6 @@ describe("runDiscordGatewayLifecycle", () => { it("surfaces fatal startup gateway errors while waiting for READY", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const pendingGatewayEvents: DiscordGatewayEvent[] = []; const { emitter, gateway } = createGatewayHarness(); getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); @@ -674,7 +664,6 @@ describe("runDiscordGatewayLifecycle", () => { it("retries stalled HELLO with resume before forcing fresh identify", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness({ state: { sessionId: "session-1", @@ -709,7 +698,6 @@ describe("runDiscordGatewayLifecycle", () => { it("resets HELLO stall counter after a successful reconnect that drops quickly", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness({ state: { sessionId: "session-2", @@ -755,7 +743,6 @@ describe("runDiscordGatewayLifecycle", () => { it("force-stops when reconnect stalls after a close event", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness(); gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); @@ -781,7 +768,6 @@ describe("runDiscordGatewayLifecycle", () => { it("does not force-stop when reconnect resumes before watchdog timeout", async () => { vi.useFakeTimers(); try { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { emitter, gateway } = createGatewayHarness(); gateway.isConnected = true; getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); @@ -814,7 +800,6 @@ describe("runDiscordGatewayLifecycle", () => { }); it("suppresses reconnect-exhausted already queued before shutdown", async () => { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const pendingGatewayEvents: DiscordGatewayEvent[] = []; const abortController = new AbortController(); @@ -856,7 +841,6 @@ describe("runDiscordGatewayLifecycle", () => { }); it("rejects reconnect-exhausted queued before startup when shutdown has not begun", async () => { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const pendingGatewayEvents: DiscordGatewayEvent[] = []; const emitter = new EventEmitter(); @@ -887,7 +871,6 @@ describe("runDiscordGatewayLifecycle", () => { }); it("does not push connected: true when abortSignal is already aborted", async () => { - const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const emitter = new EventEmitter(); const gateway: MockGateway = { isConnected: true, diff --git a/extensions/discord/src/monitor/provider.rest-proxy.test.ts b/extensions/discord/src/monitor/provider.rest-proxy.test.ts index 2a779c5d6f0..2cb75e80db5 100644 --- a/extensions/discord/src/monitor/provider.rest-proxy.test.ts +++ b/extensions/discord/src/monitor/provider.rest-proxy.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { undiciFetchMock, proxyAgentSpy } = vi.hoisted(() => ({ undiciFetchMock: vi.fn(), @@ -25,11 +25,15 @@ vi.mock("undici", () => { let resolveDiscordRestFetch: typeof import("./rest-fetch.js").resolveDiscordRestFetch; describe("resolveDiscordRestFetch", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ resolveDiscordRestFetch } = await import("./rest-fetch.js")); }); + beforeEach(() => { + undiciFetchMock.mockReset(); + proxyAgentSpy.mockReset(); + }); + it("uses undici proxy fetch when a proxy URL is configured", async () => { const runtime = { log: vi.fn(), diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 52dd2c98cf7..7a23eca1a6f 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -645,7 +645,6 @@ describe("monitorDiscordProvider", () => { }); it("continues startup when Discord daily slash-command create quota is exhausted", async () => { - const { RateLimitError } = await import("@buape/carbon"); const runtime = baseRuntime(); const request = new Request("https://discord.com/api/v10/applications/commands", { method: "PUT", diff --git a/extensions/discord/src/monitor/reply-delivery.test.ts b/extensions/discord/src/monitor/reply-delivery.test.ts index 10d3be0f807..004abc31095 100644 --- a/extensions/discord/src/monitor/reply-delivery.test.ts +++ b/extensions/discord/src/monitor/reply-delivery.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { __testing as threadBindingTesting, createThreadBindingManager, @@ -112,9 +112,11 @@ describe("deliverDiscordReply", () => { return threadBindings; }; - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ deliverDiscordReply } = await import("./reply-delivery.js")); + }); + + beforeEach(() => { sendMessageDiscordMock.mockClear().mockResolvedValue({ messageId: "msg-1", channelId: "channel-1", diff --git a/extensions/discord/src/monitor/thread-session-close.test.ts b/extensions/discord/src/monitor/thread-session-close.test.ts index dc91c72f7d0..713bbd1690f 100644 --- a/extensions/discord/src/monitor/thread-session-close.test.ts +++ b/extensions/discord/src/monitor/thread-session-close.test.ts @@ -30,9 +30,11 @@ const MATCHED_KEY = `agent:main:discord:channel:${THREAD_ID}`; const UNMATCHED_KEY = `agent:main:discord:channel:${OTHER_ID}`; describe("closeDiscordThreadSessions", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ closeDiscordThreadSessions } = await import("./thread-session-close.js")); + }); + + beforeEach(() => { hoisted.updateSessionStore.mockClear(); hoisted.resolveStorePath.mockClear(); hoisted.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions.json"); diff --git a/extensions/discord/src/monitor/threading.auto-thread.test.ts b/extensions/discord/src/monitor/threading.auto-thread.test.ts index 3ebd27d9254..bf9186c8eb0 100644 --- a/extensions/discord/src/monitor/threading.auto-thread.test.ts +++ b/extensions/discord/src/monitor/threading.auto-thread.test.ts @@ -1,6 +1,6 @@ import { ChannelType } from "@buape/carbon"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; type MaybeCreateDiscordAutoThreadFn = typeof import("./threading.js").maybeCreateDiscordAutoThread; const { generateThreadTitleMock } = vi.hoisted(() => ({ @@ -46,13 +46,15 @@ async function flushAsyncWork() { await Promise.resolve(); } -beforeEach(async () => { - vi.resetModules(); +beforeAll(async () => { + ({ maybeCreateDiscordAutoThread } = await import("./threading.js")); +}); + +beforeEach(() => { postMock.mockReset(); getMock.mockReset(); patchMock.mockReset(); generateThreadTitleMock.mockReset(); - ({ maybeCreateDiscordAutoThread } = await import("./threading.js")); }); describe("maybeCreateDiscordAutoThread", () => { diff --git a/extensions/discord/src/send.components.test.ts b/extensions/discord/src/send.components.test.ts index 2b5a7ad5cbf..6d2d4217810 100644 --- a/extensions/discord/src/send.components.test.ts +++ b/extensions/discord/src/send.components.test.ts @@ -1,5 +1,5 @@ import { ChannelType } from "discord-api-types/v10"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { makeDiscordRest } from "./send.test-harness.js"; const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } }))); @@ -26,14 +26,16 @@ let sendDiscordComponentMessage: typeof import("./send.components.js").sendDisco describe("sendDiscordComponentMessage", () => { let registerMock: ReturnType>; - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ registerDiscordComponentEntries } = await import("./components-registry.js")); ({ editDiscordComponentMessage, registerBuiltDiscordComponentMessage, sendDiscordComponentMessage, } = await import("./send.components.js")); + }); + + beforeEach(() => { registerMock = vi.mocked(registerDiscordComponentEntries); vi.clearAllMocks(); }); diff --git a/extensions/discord/src/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts index 30c6a4ed8d5..f2dddb20926 100644 --- a/extensions/discord/src/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -42,7 +42,6 @@ function createCompatRateLimitError( } beforeAll(async () => { - vi.resetModules(); ({ addRoleDiscord, banMemberDiscord, diff --git a/extensions/discord/src/send.permissions.authz.test.ts b/extensions/discord/src/send.permissions.authz.test.ts index 1b8332a88ee..29926244f4f 100644 --- a/extensions/discord/src/send.permissions.authz.test.ts +++ b/extensions/discord/src/send.permissions.authz.test.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { PermissionFlagsBits, Routes } from "discord-api-types/v10"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const mockRest = vi.hoisted(() => ({ get: vi.fn(), @@ -43,13 +43,15 @@ function mockGuildMemberRoutes(params: RouteMockParams): void { } describe("discord guild permission authorization", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ fetchMemberGuildPermissionsDiscord, hasAllGuildPermissionsDiscord, hasAnyGuildPermissionDiscord, } = await import("./send.permissions.js")); + }); + + beforeEach(() => { mockRest.get.mockReset(); }); diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index 153f69e8330..74e0a17cc7f 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -1,11 +1,8 @@ import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { makeDiscordRest } from "./send.test-harness.js"; +import { discordWebMediaMockFactory, makeDiscordRest } from "./send.test-harness.js"; -vi.mock("openclaw/plugin-sdk/web-media", async () => { - const { discordWebMediaMockFactory } = await import("./send.test-harness.js"); - return discordWebMediaMockFactory(); -}); +vi.mock("openclaw/plugin-sdk/web-media", () => discordWebMediaMockFactory()); let deleteMessageDiscord: typeof import("./send.js").deleteMessageDiscord; let editMessageDiscord: typeof import("./send.js").editMessageDiscord; @@ -24,7 +21,6 @@ let __resetDiscordDirectoryCacheForTest: typeof import("./directory-cache.js")._ let rememberDiscordDirectoryUser: typeof import("./directory-cache.js").rememberDiscordDirectoryUser; beforeAll(async () => { - vi.resetModules(); ({ deleteMessageDiscord, editMessageDiscord, diff --git a/extensions/discord/src/send.typing.test.ts b/extensions/discord/src/send.typing.test.ts index fb2dc144dc9..99ba91f123c 100644 --- a/extensions/discord/src/send.typing.test.ts +++ b/extensions/discord/src/send.typing.test.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const resolveDiscordRestMock = vi.hoisted(() => vi.fn()); @@ -10,11 +10,14 @@ vi.mock("./client.js", () => ({ let sendTypingDiscord: typeof import("./send.typing.js").sendTypingDiscord; -beforeEach(async () => { - vi.resetModules(); +beforeAll(async () => { ({ sendTypingDiscord } = await import("./send.typing.js")); }); +beforeEach(() => { + resolveDiscordRestMock.mockReset(); +}); + describe("sendTypingDiscord", () => { it("sends a typing event to the resolved Discord channel route", async () => { const post = vi.fn(async () => undefined); diff --git a/extensions/discord/src/send.webhook-activity.test.ts b/extensions/discord/src/send.webhook-activity.test.ts index 2082793c44a..882e6ae6710 100644 --- a/extensions/discord/src/send.webhook-activity.test.ts +++ b/extensions/discord/src/send.webhook-activity.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const recordChannelActivityMock = vi.hoisted(() => vi.fn()); const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ channels: { discord: {} } }))); @@ -22,9 +22,11 @@ vi.mock("../../../src/infra/channel-activity.js", async (importOriginal) => { let sendWebhookMessageDiscord: typeof import("./send.js").sendWebhookMessageDiscord; describe("sendWebhookMessageDiscord activity", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ sendWebhookMessageDiscord } = await import("./send.js")); + }); + + beforeEach(() => { recordChannelActivityMock.mockClear(); loadConfigMock.mockClear(); vi.stubGlobal( diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 070d608ac87..c5f7cac19f4 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -1,5 +1,5 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getRequiredHookHandler, registerHookHandlersForTest, @@ -166,9 +166,11 @@ async function expectSubagentSpawningError(params?: { } describe("discord subagent hook handlers", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ registerDiscordSubagentHooks } = await import("./subagent-hooks.js")); + }); + + beforeEach(() => { hookMocks.resolveDiscordAccount.mockClear(); hookMocks.resolveDiscordAccount.mockImplementation((params?: { accountId?: string }) => ({ accountId: params?.accountId?.trim() || "default", diff --git a/extensions/discord/src/voice-message.test.ts b/extensions/discord/src/voice-message.test.ts index 07b57a33e51..7145987b222 100644 --- a/extensions/discord/src/voice-message.test.ts +++ b/extensions/discord/src/voice-message.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const runFfprobeMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise>()); const runFfmpegMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise>()); @@ -28,11 +28,13 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { let ensureOggOpus: typeof import("./voice-message.js").ensureOggOpus; describe("ensureOggOpus", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { + ({ ensureOggOpus } = await import("./voice-message.js")); + }); + + beforeEach(() => { runFfprobeMock.mockReset(); runFfmpegMock.mockReset(); - ({ ensureOggOpus } = await import("./voice-message.js")); }); afterEach(() => { diff --git a/extensions/duckduckgo/src/ddg-search-provider.test.ts b/extensions/duckduckgo/src/ddg-search-provider.test.ts index 76a2c1308ef..d0691b66d3b 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.test.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.test.ts @@ -15,7 +15,6 @@ describe("duckduckgo web search provider", () => { let plugin: typeof import("../index.js").default; beforeAll(async () => { - vi.resetModules(); ({ createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js")); ({ __testing: ddgClientTesting } = await vi.importActual("./ddg-client.js")); diff --git a/extensions/googlechat/src/actions.test.ts b/extensions/googlechat/src/actions.test.ts index 0670c0b9a20..a12566928a0 100644 --- a/extensions/googlechat/src/actions.test.ts +++ b/extensions/googlechat/src/actions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn()); const resolveGoogleChatAccount = vi.hoisted(() => vi.fn()); @@ -31,10 +31,18 @@ vi.mock("./targets.js", () => ({ resolveGoogleChatOutboundSpace, })); -describe("googlechat message actions", () => { - it("describes send and reaction actions only when enabled accounts exist", async () => { - const { googlechatMessageActions } = await import("./actions.js"); +let googlechatMessageActions: typeof import("./actions.js").googlechatMessageActions; +describe("googlechat message actions", () => { + beforeAll(async () => { + ({ googlechatMessageActions } = await import("./actions.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("describes send and reaction actions only when enabled accounts exist", async () => { listEnabledGoogleChatAccounts.mockReturnValueOnce([]); expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull(); @@ -52,8 +60,6 @@ describe("googlechat message actions", () => { }); it("sends messages with uploaded media through the resolved space", async () => { - const { googlechatMessageActions } = await import("./actions.js"); - resolveGoogleChatAccount.mockReturnValue({ credentialSource: "service-account", config: { mediaMaxMb: 5 }, @@ -119,8 +125,6 @@ describe("googlechat message actions", () => { }); it("routes upload-file through the same attachment upload path with filename override", async () => { - const { googlechatMessageActions } = await import("./actions.js"); - resolveGoogleChatAccount.mockReturnValue({ credentialSource: "service-account", config: { mediaMaxMb: 5 }, @@ -190,8 +194,6 @@ describe("googlechat message actions", () => { }); it("removes only matching app reactions on react remove", async () => { - const { googlechatMessageActions } = await import("./actions.js"); - resolveGoogleChatAccount.mockReturnValue({ credentialSource: "service-account", config: { botUser: "users/app-bot" }, diff --git a/extensions/googlechat/src/monitor-access.test.ts b/extensions/googlechat/src/monitor-access.test.ts index 32df781a546..bed16bfc2f0 100644 --- a/extensions/googlechat/src/monitor-access.test.ts +++ b/extensions/googlechat/src/monitor-access.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; const createChannelPairingController = vi.hoisted(() => vi.fn()); const evaluateGroupRouteAccessForPolicy = vi.hoisted(() => vi.fn()); @@ -69,6 +69,8 @@ const defaultSender = { senderEmail: "alice@example.com", } as const; +let applyGoogleChatInboundAccessPolicy: typeof import("./monitor-access.js").applyGoogleChatInboundAccessPolicy; + function allowInboundGroupTraffic(options?: { effectiveGroupAllowFrom?: string[]; effectiveWasMentioned?: boolean; @@ -89,13 +91,8 @@ function allowInboundGroupTraffic(options?: { } async function applyInboundAccessPolicy( - overrides: Partial< - Parameters< - Awaited["applyGoogleChatInboundAccessPolicy"] - >[0] - >, + overrides: Partial[0]>, ) { - const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js"); return applyGoogleChatInboundAccessPolicy({ account: { accountId: "default", @@ -114,6 +111,10 @@ async function applyInboundAccessPolicy( } describe("googlechat inbound access policy", () => { + beforeAll(async () => { + ({ applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js")); + }); + it("issues a pairing challenge for unauthorized DMs in pairing mode", async () => { primeCommonDefaults(); const issueChallenge = vi.fn(async ({ onCreated, sendPairingReply }) => { @@ -132,7 +133,6 @@ describe("googlechat inbound access policy", () => { }); sendGoogleChatMessage.mockResolvedValue({ ok: true }); - const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js"); const statusSink = vi.fn(); const logVerbose = vi.fn(); diff --git a/extensions/googlechat/src/monitor-webhook.test.ts b/extensions/googlechat/src/monitor-webhook.test.ts index 9e94a1ef7db..e070802e17b 100644 --- a/extensions/googlechat/src/monitor-webhook.test.ts +++ b/extensions/googlechat/src/monitor-webhook.test.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { WebhookTarget } from "./monitor-types.js"; import type { GoogleChatEvent } from "./types.js"; @@ -19,6 +19,7 @@ vi.mock("./auth.js", () => ({ })); type ProcessEventFn = (event: GoogleChatEvent, target: WebhookTarget) => Promise; +let createGoogleChatWebhookRequestHandler: typeof import("./monitor-webhook.js").createGoogleChatWebhookRequestHandler; function createRequest(authorization?: string): IncomingMessage { return { @@ -76,7 +77,6 @@ async function runWebhookHandler(options?: { }) { const processEvent: ProcessEventFn = options?.processEvent ?? (vi.fn(async () => {}) as ProcessEventFn); - const { createGoogleChatWebhookRequestHandler } = await import("./monitor-webhook.js"); const handler = createGoogleChatWebhookRequestHandler({ webhookTargets: new Map(), webhookInFlightLimiter: {} as never, @@ -89,6 +89,14 @@ async function runWebhookHandler(options?: { } describe("googlechat monitor webhook", () => { + beforeAll(async () => { + ({ createGoogleChatWebhookRequestHandler } = await import("./monitor-webhook.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + it("accepts add-on payloads that carry systemIdToken in the body", async () => { installSimplePipeline([ { diff --git a/extensions/line/src/monitor.lifecycle.test.ts b/extensions/line/src/monitor.lifecycle.test.ts index 37a6a25dadb..e9d9536af3f 100644 --- a/extensions/line/src/monitor.lifecycle.test.ts +++ b/extensions/line/src/monitor.lifecycle.test.ts @@ -103,7 +103,7 @@ describe("monitorLineProvider lifecycle", () => { return monitor; }); - await vi.waitFor(() => expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1)); + expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1); expect(registerPluginHttpRouteMock).toHaveBeenCalledWith( expect.objectContaining({ auth: "plugin" }), ); diff --git a/extensions/line/src/setup-surface.test.ts b/extensions/line/src/setup-surface.test.ts index f94530b6612..23ed45bd693 100644 --- a/extensions/line/src/setup-surface.test.ts +++ b/extensions/line/src/setup-surface.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import ts from "typescript"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { bundledPluginRoot } from "../../../test/helpers/bundled-plugin-paths.js"; import { loadRuntimeApiExportTypesViaJiti } from "../../../test/helpers/plugins/jiti-runtime-api.ts"; import { @@ -177,14 +177,16 @@ describe("line setup wizard", () => { }); describe("probeLineBot", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { + ({ probeLineBot } = await import("./probe.js")); + }); + + beforeEach(() => { getBotInfoMock.mockReset(); MessagingApiClientMock.mockReset(); MessagingApiClientMock.mockImplementation(function () { return { getBotInfo: getBotInfoMock }; }); - ({ probeLineBot } = await import("./probe.js")); }); afterEach(() => { @@ -233,10 +235,6 @@ describe("linePlugin status.probeAccount", () => { pictureUrl: "https://example.com/bot.png", }); - const { linePlugin: freshLinePlugin } = await import("./channel.js"); - const { clearLineRuntime: clearFreshLineRuntime } = await import("./runtime.js"); - const { probeLineBot: directProbeLineBot } = await import("./probe.js"); - clearFreshLineRuntime(); const params = { cfg: {} as OpenClawConfig, account: { @@ -249,8 +247,10 @@ describe("linePlugin status.probeAccount", () => { timeoutMs: 50, }; - await expect(freshLinePlugin.status!.probeAccount!(params)).resolves.toEqual( - await directProbeLineBot("token", 50), + clearLineRuntime(); + + await expect(linePlugin.status!.probeAccount!(params)).resolves.toEqual( + await probeLineBot("token", 50), ); }); }); diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 4b09d5740f0..471add87704 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { z } from "openclaw/plugin-sdk/zod"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { loadRuntimeApiExportTypesViaJiti } from "../../../../../test/helpers/plugins/jiti-runtime-api.ts"; const hoisted = vi.hoisted(() => { @@ -273,9 +273,13 @@ vi.mock("./startup-verification.js", () => ({ ensureMatrixStartupVerification: vi.fn(), })); -const { monitorMatrixProvider } = await import("./index.js"); +let monitorMatrixProvider: typeof import("./index.js").monitorMatrixProvider; describe("monitorMatrixProvider", () => { + beforeAll(async () => { + ({ monitorMatrixProvider } = await import("./index.js")); + }); + async function startMonitorAndAbortAfterStartup(): Promise { const abortController = new AbortController(); const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal }); @@ -285,7 +289,6 @@ describe("monitorMatrixProvider", () => { abortController.abort(); await monitorPromise; } - beforeEach(() => { hoisted.callOrder.length = 0; hoisted.state.startClientError = null; @@ -437,6 +440,12 @@ describe("monitorMatrixProvider", () => { }); describe("matrix plugin registration", () => { + let matrixPlugin: typeof import("../../../index.js").default; + + beforeAll(async () => { + ({ default: matrixPlugin } = await import("../../../index.js")); + }); + beforeEach(() => { vi.clearAllMocks(); }); @@ -488,7 +497,6 @@ describe("matrix plugin registration", () => { }, 240_000); it("registers the channel without bootstrapping crypto runtime", async () => { - const { default: matrixPlugin } = await import("../../../index.js"); const runtime = {} as never; const registerChannel = vi.fn(); matrixPlugin.register({ diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 4ce6e7951b5..9ebb32459a1 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -1,6 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "../../runtime-api.js"; -import { setMatrixRuntime } from "../runtime.js"; const loadWebMediaMock = vi.fn().mockResolvedValue({ buffer: Buffer.from("media"), @@ -46,10 +45,22 @@ const runtimeStub = { }, } as unknown as PluginRuntime; -setMatrixRuntime(runtimeStub); -const { sendMessageMatrix, sendSingleTextMessageMatrix, sendTypingMatrix } = - await import("./send.js"); -const { voteMatrixPoll } = await import("./actions/polls.js"); +let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; +let sendSingleTextMessageMatrix: typeof import("./send.js").sendSingleTextMessageMatrix; +let sendTypingMatrix: typeof import("./send.js").sendTypingMatrix; +let voteMatrixPoll: typeof import("./actions/polls.js").voteMatrixPoll; +let setMatrixRuntime: typeof import("../runtime.js").setMatrixRuntime; + +async function primeMatrixSendModules() { + ({ setMatrixRuntime } = await import("../runtime.js")); + ({ sendMessageMatrix, sendSingleTextMessageMatrix, sendTypingMatrix } = + await import("./send.js")); + ({ voteMatrixPoll } = await import("./actions/polls.js")); +} + +function applyMatrixSendRuntimeStub() { + setMatrixRuntime(runtimeStub); +} function createEncryptedMediaPayload() { return { @@ -113,10 +124,15 @@ function resetMatrixSendRuntimeMocks() { resolveTextChunkLimitMock.mockReset().mockReturnValue(4000); resolveMarkdownTableModeMock.mockReset().mockReturnValue("code"); convertMarkdownTablesMock.mockReset().mockImplementation((text: string) => text); + applyMatrixSendRuntimeStub(); } describe("sendMessageMatrix media", () => { - beforeEach(() => { + beforeAll(async () => { + await primeMatrixSendModules(); + }); + + beforeEach(async () => { resetMatrixSendRuntimeMocks(); }); @@ -416,7 +432,11 @@ describe("sendSingleTextMessageMatrix", () => { }); describe("voteMatrixPoll", () => { - beforeEach(() => { + beforeAll(async () => { + await primeMatrixSendModules(); + }); + + beforeEach(async () => { vi.clearAllMocks(); resetMatrixSendRuntimeMocks(); }); @@ -554,7 +574,11 @@ describe("voteMatrixPoll", () => { }); describe("sendTypingMatrix", () => { - beforeEach(() => { + beforeAll(async () => { + await primeMatrixSendModules(); + }); + + beforeEach(async () => { vi.clearAllMocks(); resetMatrixSendRuntimeMocks(); }); diff --git a/extensions/openai/openai-codex-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index 8eaf0a589cb..05f7090dcbc 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn()); @@ -9,12 +9,14 @@ vi.mock("./openai-codex-provider.runtime.js", () => ({ let buildOpenAICodexProviderPlugin: typeof import("./openai-codex-provider.js").buildOpenAICodexProviderPlugin; describe("openai codex provider", () => { - beforeEach(async () => { - vi.resetModules(); - refreshOpenAICodexTokenMock.mockReset(); + beforeAll(async () => { ({ buildOpenAICodexProviderPlugin } = await import("./openai-codex-provider.js")); }); + beforeEach(() => { + refreshOpenAICodexTokenMock.mockReset(); + }); + it("falls back to the cached credential when accountId extraction fails", async () => { const provider = buildOpenAICodexProviderPlugin(); const credential = { diff --git a/extensions/signal/src/client.test.ts b/extensions/signal/src/client.test.ts index 831bb24c768..b6f6f2c5a0d 100644 --- a/extensions/signal/src/client.test.ts +++ b/extensions/signal/src/client.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const fetchWithTimeoutMock = vi.fn(); const resolveFetchMock = vi.fn(); @@ -29,11 +29,13 @@ function rpcResponse(body: unknown, status = 200): Response { } describe("signalRpcRequest", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { + ({ signalRpcRequest } = await import("./client.js")); + }); + + beforeEach(() => { vi.clearAllMocks(); resolveFetchMock.mockReturnValue(vi.fn()); - ({ signalRpcRequest } = await import("./client.js")); }); it("returns parsed RPC result", async () => { diff --git a/extensions/signal/src/monitor.tool-result.autostart.test.ts b/extensions/signal/src/monitor.tool-result.autostart.test.ts index 0b0e57093ee..297138cf3b0 100644 --- a/extensions/signal/src/monitor.tool-result.autostart.test.ts +++ b/extensions/signal/src/monitor.tool-result.autostart.test.ts @@ -12,7 +12,6 @@ import { installSignalToolResultTestHooks(); -vi.resetModules(); const { monitorSignalProvider } = await import("./monitor.js"); const { waitForTransportReadyMock, spawnSignalDaemonMock, streamMock } = diff --git a/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts b/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts index 1546085a147..fcd9033990d 100644 --- a/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts +++ b/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { config, flush, @@ -8,7 +8,7 @@ import { } from "./monitor.tool-result.test-harness.js"; installSignalToolResultTestHooks(); -let monitorSignalProvider: typeof import("./monitor.js").monitorSignalProvider; +const { monitorSignalProvider } = await import("./monitor.js"); const { replyMock, sendMock, streamMock, upsertPairingRequestMock } = getSignalToolResultTestMocks(); @@ -19,11 +19,6 @@ async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) { return monitorSignalProvider(opts); } describe("monitorSignalProvider tool results", () => { - beforeEach(async () => { - vi.resetModules(); - ({ monitorSignalProvider } = await import("./monitor.js")); - }); - it("pairs uuid-only senders with a uuid allowlist entry", async () => { const baseChannels = (config.channels ?? {}) as Record; const baseSignal = (baseChannels.signal ?? {}) as Record; diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index a1d78779cd5..8c75bd91891 100644 --- a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -15,7 +15,6 @@ import { installSignalToolResultTestHooks(); // Import after the harness registers `vi.mock(...)` for Signal internals. -vi.resetModules(); const { monitorSignalProvider } = await import("./monitor.js"); const { diff --git a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts index 6687eaccc82..4b3918190ea 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts @@ -51,7 +51,6 @@ let createSignalEventHandler: typeof import("./event-handler.js").createSignalEv describe("signal createSignalEventHandler inbound context", () => { beforeAll(async () => { vi.useRealTimers(); - vi.resetModules(); ({ expectChannelInboundContextContract: expectInboundContextContract } = await import("openclaw/plugin-sdk/testing")); ({ createBaseSignalEventHandlerDeps, createSignalReceiveEvent } = diff --git a/extensions/signal/src/monitor/event-handler.mention-gating.test.ts b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts index 06e61add80e..b98d7f1adbc 100644 --- a/extensions/signal/src/monitor/event-handler.mention-gating.test.ts +++ b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts @@ -101,7 +101,6 @@ async function expectSkippedGroupHistory(opts: GroupEventOpts, expectedBody: str describe("signal mention gating", () => { beforeAll(async () => { - vi.resetModules(); ({ createBaseSignalEventHandlerDeps, createSignalReceiveEvent } = await import("./event-handler.test-harness.js")); ({ createSignalEventHandler } = await import("./event-handler.js")); diff --git a/extensions/signal/src/send-reactions.test.ts b/extensions/signal/src/send-reactions.test.ts index 698d836df0e..3daf7760d90 100644 --- a/extensions/signal/src/send-reactions.test.ts +++ b/extensions/signal/src/send-reactions.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const rpcMock = vi.fn(); @@ -28,12 +28,14 @@ let sendReactionSignal: typeof import("./send-reactions.js").sendReactionSignal; let removeReactionSignal: typeof import("./send-reactions.js").removeReactionSignal; describe("sendReactionSignal", () => { - beforeEach(async () => { - vi.resetModules(); - rpcMock.mockClear().mockResolvedValue({ timestamp: 123 }); + beforeAll(async () => { ({ sendReactionSignal, removeReactionSignal } = await import("./send-reactions.js")); }); + beforeEach(() => { + rpcMock.mockClear().mockResolvedValue({ timestamp: 123 }); + }); + it("uses recipients array and targetAuthor for uuid dms", async () => { await sendReactionSignal("uuid:123e4567-e89b-12d3-a456-426614174000", 123, "🔥"); diff --git a/extensions/slack/src/monitor/events/interactions.test.ts b/extensions/slack/src/monitor/events/interactions.test.ts index 928c44cc53b..5cb4f43ce2a 100644 --- a/extensions/slack/src/monitor/events/interactions.test.ts +++ b/extensions/slack/src/monitor/events/interactions.test.ts @@ -166,7 +166,6 @@ function createContext(overrides?: { describe("registerSlackInteractionEvents", () => { beforeAll(async () => { - vi.resetModules(); const channelRuntime = await import("openclaw/plugin-sdk/channel-runtime"); const pluginRuntime = await import("openclaw/plugin-sdk/plugin-runtime"); const conversationBinding = await import("../../../../../src/plugins/conversation-binding.js"); 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 a69f4fc742c..ad6400912eb 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -11,7 +11,9 @@ import type { TelegramBotDeps } from "./bot-deps.js"; type AnyMock = ReturnType; type AnyAsyncMock = ReturnType; type LoadConfigFn = typeof import("openclaw/plugin-sdk/config-runtime").loadConfig; +type LoadSessionStoreFn = typeof import("openclaw/plugin-sdk/config-runtime").loadSessionStore; type ResolveStorePathFn = typeof import("openclaw/plugin-sdk/config-runtime").resolveStorePath; +type SessionStore = ReturnType; type TelegramBotRuntimeForTest = NonNullable< Parameters[0] >; @@ -47,26 +49,42 @@ vi.mock("openclaw/plugin-sdk/web-media.js", () => ({ loadWebMedia, })); -const { loadConfig, resolveStorePathMock } = vi.hoisted( +const { loadConfig, loadSessionStoreMock, resolveStorePathMock, sessionStoreEntries } = vi.hoisted( (): { loadConfig: MockFn; + loadSessionStoreMock: MockFn; resolveStorePathMock: MockFn; + sessionStoreEntries: { value: SessionStore }; } => ({ loadConfig: vi.fn(() => ({})), + loadSessionStoreMock: vi.fn( + (_storePath, _opts) => sessionStoreEntries.value, + ), resolveStorePathMock: vi.fn( (storePath?: string) => storePath ?? sessionStorePath, ), + sessionStoreEntries: { value: {} as SessionStore }, }), ); export function getLoadConfigMock(): AnyMock { return loadConfig; } + +export function getLoadSessionStoreMock(): AnyMock { + return loadSessionStoreMock; +} + +export function setSessionStoreEntriesForTest(entries: SessionStore) { + sessionStoreEntries.value = JSON.parse(JSON.stringify(entries)) as SessionStore; +} + vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadConfig, + loadSessionStore: loadSessionStoreMock, resolveStorePath: resolveStorePathMock, }; }); @@ -407,6 +425,7 @@ export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = { }; export const telegramBotDepsForTest: TelegramBotDeps = { loadConfig, + loadSessionStore: loadSessionStoreMock as TelegramBotDeps["loadSessionStore"], resolveStorePath: resolveStorePathMock, readChannelAllowFromStore: readChannelAllowFromStore as TelegramBotDeps["readChannelAllowFromStore"], @@ -499,6 +518,9 @@ beforeEach(() => { resetInboundDedupe(); loadConfig.mockReset(); loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG); + sessionStoreEntries.value = {}; + loadSessionStoreMock.mockReset(); + loadSessionStoreMock.mockImplementation(() => sessionStoreEntries.value); resolveStorePathMock.mockReset(); resolveStorePathMock.mockImplementation((storePath?: string) => storePath ?? sessionStorePath); loadWebMedia.mockReset(); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 543cdc08793..425bafdf3ce 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -1,11 +1,7 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import type { GetReplyOptions, MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { withEnvAsync } from "../../../test/helpers/plugins/env.js"; -import { useFrozenTime, useRealTime } from "../../../test/helpers/plugins/frozen-time.js"; const harness = await import("./bot.create-telegram-bot.test-harness.js"); const { answerCallbackQuerySpy, @@ -15,6 +11,7 @@ const { getLoadWebMediaMock, getChatSpy, getLoadConfigMock, + getLoadSessionStoreMock, getOnHandler, getReadChannelAllowFromStoreMock, getUpsertChannelPairingRequestMock, @@ -27,6 +24,7 @@ const { sendMessageSpy, sendPhotoSpy, sequentializeSpy, + setSessionStoreEntriesForTest, setMessageReactionSpy, setMyCommandsSpy, telegramBotDepsForTest, @@ -45,6 +43,7 @@ let createTelegramBot: ( ) => ReturnType; const loadConfig = getLoadConfigMock(); +const loadSessionStore = getLoadSessionStoreMock(); const loadWebMedia = getLoadWebMediaMock(); const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); const upsertChannelPairingRequest = getUpsertChannelPairingRequestMock(); @@ -55,30 +54,6 @@ const TELEGRAM_TEST_TIMINGS = { textFragmentGapMs: 30, } as const; -async function withIsolatedStateDirAsync(fn: () => Promise): Promise { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-state-")); - return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { - try { - return await fn(); - } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); - } - }); -} - -async function withConfigPathAsync(cfg: unknown, fn: () => Promise): Promise { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-cfg-")); - const configPath = path.join(dir, "openclaw.json"); - fs.writeFileSync(configPath, JSON.stringify(cfg), "utf-8"); - return await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath }, async () => { - try { - return await fn(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); -} - describe("createTelegramBot", () => { beforeAll(() => { process.env.TZ = "UTC"; @@ -488,174 +463,216 @@ describe("createTelegramBot", () => { }, ] as const; - await withIsolatedStateDirAsync(async () => { - for (const [index, testCase] of cases.entries()) { - onSpy.mockClear(); - sendMessageSpy.mockClear(); - replySpy.mockClear(); - loadConfig.mockReturnValue({ - channels: { telegram: { dmPolicy: "pairing" } }, - }); - readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest.mockClear(); - let pairingUpsertCall = 0; - upsertChannelPairingRequest.mockImplementation(async () => { - const result = - testCase.pairingUpsertResults[ - Math.min(pairingUpsertCall, testCase.pairingUpsertResults.length - 1) - ]; - pairingUpsertCall += 1; - return result; - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - const senderId = Number(`${Date.now()}${index}`.slice(-9)); - for (const text of testCase.messages) { - await handler({ - message: { - chat: { id: 1234, type: "private" }, - text, - date: 1736380800, - from: { id: senderId, username: "random" }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - } - - expect(replySpy, testCase.name).not.toHaveBeenCalled(); - expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount); - expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234); - const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]); - expect(pairingText, testCase.name).toContain(`Your Telegram user id: ${senderId}`); - expect(pairingText, testCase.name).toContain("Pairing code:"); - expect(pairingText, testCase.name).toContain("openclaw pairing approve telegram"); - expect(sendMessageSpy.mock.calls[0]?.[2], testCase.name).toEqual( - expect.objectContaining({ parse_mode: "HTML" }), - ); - } - }); - }); - - it("ignores private self-authored message updates instead of issuing a pairing challenge", async () => { - await withIsolatedStateDirAsync(async () => { + for (const [index, testCase] of cases.entries()) { + onSpy.mockClear(); + sendMessageSpy.mockClear(); + replySpy.mockClear(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "pairing" } }, }); readChannelAllowFromStore.mockResolvedValue([]); upsertChannelPairingRequest.mockClear(); - sendMessageSpy.mockClear(); - replySpy.mockClear(); + let pairingUpsertCall = 0; + upsertChannelPairingRequest.mockImplementation(async () => { + const result = + testCase.pairingUpsertResults[ + Math.min(pairingUpsertCall, testCase.pairingUpsertResults.length - 1) + ]; + pairingUpsertCall += 1; + return result; + }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 1234, type: "private", first_name: "Harold" }, - message_id: 1884, - date: 1736380800, - from: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" }, - pinned_message: { - message_id: 1883, - date: 1736380799, - chat: { id: 1234, type: "private", first_name: "Harold" }, - from: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" }, - text: "Binding: Review pull request 54118 (openclaw)", - }, - }, - me: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(upsertChannelPairingRequest).not.toHaveBeenCalled(); - expect(sendMessageSpy).not.toHaveBeenCalled(); - expect(replySpy).not.toHaveBeenCalled(); - }); - }); - - it("ignores group self-authored message updates instead of re-processing bot output", async () => { - await withIsolatedStateDirAsync(async () => { - loadConfig.mockReturnValue({ - channels: { telegram: { dmPolicy: "pairing" } }, - }); - readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest.mockClear(); - sendMessageSpy.mockClear(); - replySpy.mockClear(); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -1001234, type: "supergroup", title: "OpenClaw Ops" }, - message_id: 1884, - date: 1736380800, - from: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" }, - text: "approval card update", - }, - me: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(upsertChannelPairingRequest).not.toHaveBeenCalled(); - expect(sendMessageSpy).not.toHaveBeenCalled(); - expect(replySpy).not.toHaveBeenCalled(); - }); - }); - - it("blocks unauthorized DM media before download and sends pairing reply", async () => { - await withIsolatedStateDirAsync(async () => { - loadConfig.mockReturnValue({ - channels: { telegram: { dmPolicy: "pairing" } }, - }); - readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); - sendMessageSpy.mockClear(); - replySpy.mockClear(); - const senderId = Number(`${Date.now()}01`.slice(-9)); - - const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( - async () => - new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { - status: 200, - headers: { "content-type": "image/jpeg" }, - }), - ); - const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); - - try { - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - + const senderId = Number(`${Date.now()}${index}`.slice(-9)); + for (const text of testCase.messages) { await handler({ message: { chat: { id: 1234, type: "private" }, - message_id: 410, + text, date: 1736380800, - photo: [{ file_id: "p1" }], from: { id: senderId, username: "random" }, }, me: { username: "openclaw_bot" }, - getFile: getFileSpy, + getFile: async () => ({ download: async () => new Uint8Array() }), }); - - expect(getFileSpy).not.toHaveBeenCalled(); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]); - expect(pairingText).toContain("Pairing code:"); - expect(pairingText).toContain("
");
-        expect(sendMessageSpy.mock.calls[0]?.[2]).toEqual(
-          expect.objectContaining({ parse_mode: "HTML" }),
-        );
-        expect(replySpy).not.toHaveBeenCalled();
-      } finally {
-        fetchSpy.mockRestore();
       }
+
+      expect(replySpy, testCase.name).not.toHaveBeenCalled();
+      expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount);
+      expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234);
+      const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
+      expect(pairingText, testCase.name).toContain(`Your Telegram user id: ${senderId}`);
+      expect(pairingText, testCase.name).toContain("Pairing code:");
+      expect(pairingText, testCase.name).toContain("openclaw pairing approve telegram");
+      expect(sendMessageSpy.mock.calls[0]?.[2], testCase.name).toEqual(
+        expect.objectContaining({ parse_mode: "HTML" }),
+      );
+    }
+  });
+
+  it("ignores private self-authored message updates instead of issuing a pairing challenge", async () => {
+    loadConfig.mockReturnValue({
+      channels: { telegram: { dmPolicy: "pairing" } },
     });
+    readChannelAllowFromStore.mockResolvedValue([]);
+    upsertChannelPairingRequest.mockClear();
+    sendMessageSpy.mockClear();
+    replySpy.mockClear();
+
+    createTelegramBot({ token: "tok" });
+    const handler = getOnHandler("message") as (ctx: Record) => Promise;
+
+    await handler({
+      message: {
+        chat: { id: 1234, type: "private", first_name: "Harold" },
+        message_id: 1884,
+        date: 1736380800,
+        from: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" },
+        pinned_message: {
+          message_id: 1883,
+          date: 1736380799,
+          chat: { id: 1234, type: "private", first_name: "Harold" },
+          from: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" },
+          text: "Binding: Review pull request 54118 (openclaw)",
+        },
+      },
+      me: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" },
+      getFile: async () => ({ download: async () => new Uint8Array() }),
+    });
+
+    expect(upsertChannelPairingRequest).not.toHaveBeenCalled();
+    expect(sendMessageSpy).not.toHaveBeenCalled();
+    expect(replySpy).not.toHaveBeenCalled();
+  });
+
+  it("blocks unauthorized DM media before download and sends pairing reply", async () => {
+    loadConfig.mockReturnValue({
+      channels: { telegram: { dmPolicy: "pairing" } },
+    });
+    readChannelAllowFromStore.mockResolvedValue([]);
+    upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
+    sendMessageSpy.mockClear();
+    replySpy.mockClear();
+    const senderId = Number(`${Date.now()}01`.slice(-9));
+
+    const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
+      async () =>
+        new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
+          status: 200,
+          headers: { "content-type": "image/jpeg" },
+        }),
+    );
+    const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
+
+    try {
+      createTelegramBot({ token: "tok" });
+      const handler = getOnHandler("message") as (ctx: Record) => Promise;
+
+      await handler({
+        message: {
+          chat: { id: 1234, type: "private" },
+          message_id: 410,
+          date: 1736380800,
+          photo: [{ file_id: "p1" }],
+          from: { id: senderId, username: "random" },
+        },
+        me: { username: "openclaw_bot" },
+        getFile: getFileSpy,
+      });
+
+      expect(getFileSpy).not.toHaveBeenCalled();
+      expect(fetchSpy).not.toHaveBeenCalled();
+      expect(sendMessageSpy).toHaveBeenCalledTimes(1);
+      const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
+      expect(pairingText).toContain("Pairing code:");
+      expect(pairingText).toContain("
");
+      expect(sendMessageSpy.mock.calls[0]?.[2]).toEqual(
+        expect.objectContaining({ parse_mode: "HTML" }),
+      );
+      expect(replySpy).not.toHaveBeenCalled();
+    } finally {
+      fetchSpy.mockRestore();
+    }
+  });
+
+  it("ignores group self-authored message updates instead of re-processing bot output", async () => {
+    loadConfig.mockReturnValue({
+      channels: { telegram: { dmPolicy: "pairing" } },
+    });
+    readChannelAllowFromStore.mockResolvedValue([]);
+    upsertChannelPairingRequest.mockClear();
+    sendMessageSpy.mockClear();
+    replySpy.mockClear();
+
+    createTelegramBot({ token: "tok" });
+    const handler = getOnHandler("message") as (ctx: Record) => Promise;
+
+    await handler({
+      message: {
+        chat: { id: -1001234, type: "supergroup", title: "OpenClaw Ops" },
+        message_id: 1884,
+        date: 1736380800,
+        from: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" },
+        text: "approval card update",
+      },
+      me: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" },
+      getFile: async () => ({ download: async () => new Uint8Array() }),
+    });
+
+    expect(upsertChannelPairingRequest).not.toHaveBeenCalled();
+    expect(sendMessageSpy).not.toHaveBeenCalled();
+    expect(replySpy).not.toHaveBeenCalled();
+  });
+
+  it("blocks unauthorized DM media before download and sends pairing reply", async () => {
+    loadConfig.mockReturnValue({
+      channels: { telegram: { dmPolicy: "pairing" } },
+    });
+    readChannelAllowFromStore.mockResolvedValue([]);
+    upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
+    sendMessageSpy.mockClear();
+    replySpy.mockClear();
+    const senderId = Number(`${Date.now()}01`.slice(-9));
+
+    const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
+      async () =>
+        new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
+          status: 200,
+          headers: { "content-type": "image/jpeg" },
+        }),
+    );
+    const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
+
+    try {
+      createTelegramBot({ token: "tok" });
+      const handler = getOnHandler("message") as (ctx: Record) => Promise;
+
+      await handler({
+        message: {
+          chat: { id: 1234, type: "private" },
+          message_id: 411,
+          date: 1736380800,
+          photo: [{ file_id: "p1" }],
+          from: { id: senderId, username: "random" },
+        },
+        me: { username: "openclaw_bot" },
+        getFile: getFileSpy,
+      });
+
+      expect(getFileSpy).not.toHaveBeenCalled();
+      expect(fetchSpy).not.toHaveBeenCalled();
+      expect(sendMessageSpy).toHaveBeenCalledTimes(1);
+      const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
+      expect(pairingText).toContain("Pairing code:");
+      expect(pairingText).toContain("
");
+      expect(sendMessageSpy.mock.calls[0]?.[2]).toEqual(
+        expect.objectContaining({ parse_mode: "HTML" }),
+      );
+      expect(replySpy).not.toHaveBeenCalled();
+    } finally {
+      fetchSpy.mockRestore();
+    }
   });
   it("blocks DM media downloads completely when dmPolicy is disabled", async () => {
     loadConfig.mockReturnValue({
@@ -698,56 +715,54 @@ describe("createTelegramBot", () => {
     }
   });
   it("blocks unauthorized DM media groups before any photo download", async () => {
-    await withIsolatedStateDirAsync(async () => {
-      loadConfig.mockReturnValue({
-        channels: { telegram: { dmPolicy: "pairing" } },
-      });
-      readChannelAllowFromStore.mockResolvedValue([]);
-      upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
-      sendMessageSpy.mockClear();
-      replySpy.mockClear();
-      const senderId = Number(`${Date.now()}02`.slice(-9));
-
-      const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
-        async () =>
-          new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
-            status: 200,
-            headers: { "content-type": "image/jpeg" },
-          }),
-      );
-      const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
-
-      try {
-        createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
-        const handler = getOnHandler("message") as (ctx: Record) => Promise;
-
-        await handler({
-          message: {
-            chat: { id: 1234, type: "private" },
-            message_id: 412,
-            media_group_id: "dm-album-1",
-            date: 1736380800,
-            photo: [{ file_id: "p1" }],
-            from: { id: senderId, username: "random" },
-          },
-          me: { username: "openclaw_bot" },
-          getFile: getFileSpy,
-        });
-
-        expect(getFileSpy).not.toHaveBeenCalled();
-        expect(fetchSpy).not.toHaveBeenCalled();
-        expect(sendMessageSpy).toHaveBeenCalledTimes(1);
-        const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
-        expect(pairingText).toContain("Pairing code:");
-        expect(pairingText).toContain("
");
-        expect(sendMessageSpy.mock.calls[0]?.[2]).toEqual(
-          expect.objectContaining({ parse_mode: "HTML" }),
-        );
-        expect(replySpy).not.toHaveBeenCalled();
-      } finally {
-        fetchSpy.mockRestore();
-      }
+    loadConfig.mockReturnValue({
+      channels: { telegram: { dmPolicy: "pairing" } },
     });
+    readChannelAllowFromStore.mockResolvedValue([]);
+    upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
+    sendMessageSpy.mockClear();
+    replySpy.mockClear();
+    const senderId = Number(`${Date.now()}02`.slice(-9));
+
+    const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
+      async () =>
+        new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
+          status: 200,
+          headers: { "content-type": "image/jpeg" },
+        }),
+    );
+    const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
+
+    try {
+      createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
+      const handler = getOnHandler("message") as (ctx: Record) => Promise;
+
+      await handler({
+        message: {
+          chat: { id: 1234, type: "private" },
+          message_id: 412,
+          media_group_id: "dm-album-1",
+          date: 1736380800,
+          photo: [{ file_id: "p1" }],
+          from: { id: senderId, username: "random" },
+        },
+        me: { username: "openclaw_bot" },
+        getFile: getFileSpy,
+      });
+
+      expect(getFileSpy).not.toHaveBeenCalled();
+      expect(fetchSpy).not.toHaveBeenCalled();
+      expect(sendMessageSpy).toHaveBeenCalledTimes(1);
+      const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
+      expect(pairingText).toContain("Pairing code:");
+      expect(pairingText).toContain("
");
+      expect(sendMessageSpy.mock.calls[0]?.[2]).toEqual(
+        expect.objectContaining({ parse_mode: "HTML" }),
+      );
+      expect(replySpy).not.toHaveBeenCalled();
+    } finally {
+      fetchSpy.mockRestore();
+    }
   });
   it("triggers typing cue via onReplyStart", async () => {
     dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
@@ -1186,27 +1201,25 @@ describe("createTelegramBot", () => {
     };
     loadConfig.mockReturnValue(config);
 
-    await withConfigPathAsync(config, async () => {
-      createTelegramBot({ token: "tok", accountId: "opie" });
-      const handler = getOnHandler("message") as (ctx: Record) => Promise;
+    createTelegramBot({ token: "tok", accountId: "opie" });
+    const handler = getOnHandler("message") as (ctx: Record) => Promise;
 
-      await handler({
-        message: {
-          chat: { id: 123, type: "private" },
-          from: { id: 999, username: "testuser" },
-          text: "hello",
-          date: 1736380800,
-          message_id: 42,
-        },
-        me: { username: "openclaw_bot" },
-        getFile: async () => ({ download: async () => new Uint8Array() }),
-      });
-
-      expect(replySpy).toHaveBeenCalledTimes(1);
-      const payload = replySpy.mock.calls[0][0];
-      expect(payload.AccountId).toBe("opie");
-      expect(payload.SessionKey).toBe("agent:opie:main");
+    await handler({
+      message: {
+        chat: { id: 123, type: "private" },
+        from: { id: 999, username: "testuser" },
+        text: "hello",
+        date: 1736380800,
+        message_id: 42,
+      },
+      me: { username: "openclaw_bot" },
+      getFile: async () => ({ download: async () => new Uint8Array() }),
     });
+
+    expect(replySpy).toHaveBeenCalledTimes(1);
+    const payload = replySpy.mock.calls[0][0];
+    expect(payload.AccountId).toBe("opie");
+    expect(payload.SessionKey).toBe("agent:opie:main");
   });
 
   it("reloads DM routing bindings between messages without recreating the bot", async () => {
@@ -1510,28 +1523,26 @@ describe("createTelegramBot", () => {
     ];
 
     for (const testCase of cases) {
-      await withConfigPathAsync(testCase.config, async () => {
-        resetHarnessSpies();
-        loadConfig.mockReturnValue(testCase.config);
-        await dispatchMessage({
-          message: {
-            chat: {
-              id: -1001234567890,
-              type: "supergroup",
-              title: "Forum Group",
-              is_forum: true,
-            },
-            from: { id: 999, username: "testuser" },
-            text: testCase.text,
-            date: 1736380800,
-            message_id: 42,
-            message_thread_id: 99,
+      resetHarnessSpies();
+      loadConfig.mockReturnValue(testCase.config);
+      await dispatchMessage({
+        message: {
+          chat: {
+            id: -1001234567890,
+            type: "supergroup",
+            title: "Forum Group",
+            is_forum: true,
           },
-        });
-        expect(replySpy).toHaveBeenCalledTimes(1);
-        const payload = replySpy.mock.calls[0][0];
-        expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
+          from: { id: 999, username: "testuser" },
+          text: testCase.text,
+          date: 1736380800,
+          message_id: 42,
+          message_thread_id: 99,
+        },
       });
+      expect(replySpy).toHaveBeenCalledTimes(1);
+      const payload = replySpy.mock.calls[0][0];
+      expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
     }
   });
 
@@ -2273,15 +2284,19 @@ describe("createTelegramBot", () => {
     }
   });
   it("honors routed group activation from session store", async () => {
-    const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-"));
-    const storePath = path.join(storeDir, "sessions.json");
-    fs.writeFileSync(
-      storePath,
-      JSON.stringify({
-        "agent:ops:telegram:group:123": { groupActivation: "always" },
-      }),
-      "utf-8",
-    );
+    const storePath = "/tmp/openclaw-telegram-group-activation.json";
+    const routedGroupEntry = {
+      sessionId: "agent:ops:telegram:group:123",
+      updatedAt: 0,
+      groupActivation: "always",
+      chatType: "group",
+    } as const;
+    setSessionStoreEntriesForTest({
+      "agent:ops:telegram:group:123": routedGroupEntry,
+    });
+    loadSessionStore.mockImplementation(() => ({
+      "agent:ops:telegram:group:123": routedGroupEntry,
+    }));
     const config = {
       channels: {
         telegram: {
@@ -2302,23 +2317,21 @@ describe("createTelegramBot", () => {
     };
     loadConfig.mockReturnValue(config);
 
-    await withConfigPathAsync(config, async () => {
-      createTelegramBot({ token: "tok" });
-      const handler = getOnHandler("message") as (ctx: Record) => Promise;
+    createTelegramBot({ token: "tok" });
+    const handler = getOnHandler("message") as (ctx: Record) => Promise;
 
-      await handler({
-        message: {
-          chat: { id: 123, type: "group", title: "Routing" },
-          from: { id: 999, username: "ops" },
-          text: "hello",
-          date: 1736380800,
-        },
-        me: { username: "openclaw_bot" },
-        getFile: async () => ({ download: async () => new Uint8Array() }),
-      });
-
-      expect(replySpy).toHaveBeenCalledTimes(1);
+    await handler({
+      message: {
+        chat: { id: 123, type: "group", title: "Routing" },
+        from: { id: 999, username: "ops" },
+        text: "hello",
+        date: 1736380800,
+      },
+      me: { username: "openclaw_bot" },
+      getFile: async () => ({ download: async () => new Uint8Array() }),
     });
+
+    expect(replySpy).toHaveBeenCalledTimes(1);
   });
 
   it("applies topic skill filters and system prompts", async () => {
diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts
index 65ef7fce980..73c53eb64bf 100644
--- a/extensions/telegram/src/bot.test.ts
+++ b/extensions/telegram/src/bot.test.ts
@@ -42,6 +42,37 @@ let createTelegramBot: (
 const loadConfig = getLoadConfigMock();
 const readChannelAllowFromStore = getReadChannelAllowFromStoreMock();
 
+function createSignal() {
+  let resolve!: () => void;
+  const promise = new Promise((res) => {
+    resolve = res;
+  });
+  return { promise, resolve };
+}
+
+function waitForNextSetMyCommands() {
+  const synced = createSignal();
+  setMyCommandsSpy.mockImplementationOnce(async () => {
+    synced.resolve();
+    return undefined;
+  });
+  return synced.promise;
+}
+
+function waitForReplyCalls(count: number) {
+  const done = createSignal();
+  let seen = 0;
+  replySpy.mockImplementation(async (_ctx, opts) => {
+    await opts?.onReplyStart?.();
+    seen += 1;
+    if (seen >= count) {
+      done.resolve();
+    }
+    return undefined;
+  });
+  return done.promise;
+}
+
 function resolveSkillCommands(config: Parameters[0]) {
   void config;
   return listSkillCommandsForAgents() as NonNullable<
@@ -101,6 +132,7 @@ describe("createTelegramBot", () => {
       },
     };
     loadConfig.mockReturnValue(config);
+    const commandsSynced = waitForNextSetMyCommands();
 
     createTelegramBot({
       token: "tok",
@@ -119,9 +151,7 @@ describe("createTelegramBot", () => {
       },
     });
 
-    await vi.waitFor(() => {
-      expect(setMyCommandsSpy).toHaveBeenCalled();
-    });
+    await commandsSynced;
 
     const registered = setMyCommandsSpy.mock.calls.at(-1)?.[0] as Array<{
       command: string;
@@ -148,6 +178,7 @@ describe("createTelegramBot", () => {
       },
     };
     loadConfig.mockReturnValue(config);
+    const commandsSynced = waitForNextSetMyCommands();
 
     createTelegramBot({
       token: "tok",
@@ -160,9 +191,7 @@ describe("createTelegramBot", () => {
       },
     });
 
-    await vi.waitFor(() => {
-      expect(setMyCommandsSpy).toHaveBeenCalled();
-    });
+    await commandsSynced;
 
     const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
       command: string;
@@ -194,12 +223,11 @@ describe("createTelegramBot", () => {
       },
     };
     loadConfig.mockReturnValue(config);
+    const commandsSynced = waitForNextSetMyCommands();
 
     createTelegramBot({ token: "tok" });
 
-    await vi.waitFor(() => {
-      expect(setMyCommandsSpy).toHaveBeenCalled();
-    });
+    await commandsSynced;
 
     const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
       command: string;
@@ -1316,6 +1344,7 @@ describe("createTelegramBot", () => {
     );
     const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
     try {
+      const replyDelivered = waitForReplyCalls(1);
       createTelegramBot({ token: "tok" });
       const handler = getOnHandler("message") as (ctx: Record) => Promise;
 
@@ -1369,9 +1398,7 @@ describe("createTelegramBot", () => {
       }
       expect(flushTimer).toBeTypeOf("function");
       await flushTimer?.();
-      await vi.waitFor(() => {
-        expect(replySpy).toHaveBeenCalledTimes(1);
-      });
+      await replyDelivered;
 
       expect(getFileSpy).toHaveBeenCalledTimes(1);
       expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1");
@@ -1406,6 +1433,7 @@ describe("createTelegramBot", () => {
 
     const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
     try {
+      const repliesDelivered = waitForReplyCalls(2);
       createTelegramBot({ token: "tok" });
       const handler = getOnHandler("message") as (ctx: Record) => Promise;
 
@@ -1450,9 +1478,7 @@ describe("createTelegramBot", () => {
         await flushTimer?.();
       }
 
-      await vi.waitFor(() => {
-        expect(replySpy).toHaveBeenCalledTimes(2);
-      });
+      await repliesDelivered;
       const threadIds = replySpy.mock.calls
         .map(
           (call: [unknown, ...unknown[]]) =>
diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts
index 32866c03ba1..2dd47d58556 100644
--- a/extensions/telegram/src/bot.ts
+++ b/extensions/telegram/src/bot.ts
@@ -439,7 +439,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
       `agent:${agentId}:telegram:group:${buildTelegramGroupPeerId(params.chatId, params.messageThreadId)}`;
     const storePath = resolveStorePath(cfg.session?.store, { agentId });
     try {
-      const store = loadSessionStore(storePath);
+      const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath);
       const entry = store[sessionKey];
       if (entry?.groupActivation === "always") {
         return false;
diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts
index 2d66a5f43b6..88d2cf96ed9 100644
--- a/extensions/telegram/src/monitor.test.ts
+++ b/extensions/telegram/src/monitor.test.ts
@@ -133,8 +133,21 @@ const createAbortTask = (
 const makeAbortRunner = (abort: AbortController, beforeAbort?: () => void): RunnerStub =>
   makeRunnerStub({ task: createAbortTask(abort, beforeAbort) });
 
+function createSignal() {
+  let resolve!: () => void;
+  const promise = new Promise((res) => {
+    resolve = res;
+  });
+  return { promise, resolve };
+}
+
 function mockRunOnceAndAbort(abort: AbortController) {
-  runSpy.mockImplementationOnce(() => makeAbortRunner(abort));
+  const started = createSignal();
+  runSpy.mockImplementationOnce(() => {
+    started.resolve();
+    return makeAbortRunner(abort);
+  });
+  return { waitForRunStart: () => started.promise };
 }
 
 async function expectOffsetConfirmationSkipped(offset: number | null) {
@@ -179,11 +192,16 @@ async function runMonitorAndCaptureStartupOrder(params?: { persistedOffset?: num
 
 function mockRunOnceWithStalledPollingRunner(): {
   stop: ReturnType void | Promise>>;
+  waitForRunStart: () => Promise;
   waitForTaskStart: () => Promise;
 } {
   let running = true;
   let releaseTask: (() => void) | undefined;
   let releaseBeforeTaskStart = false;
+  let signalRunStarted: (() => void) | undefined;
+  const runStarted = new Promise((resolve) => {
+    signalRunStarted = resolve;
+  });
   let signalTaskStarted: (() => void) | undefined;
   const taskStarted = new Promise((resolve) => {
     signalTaskStarted = resolve;
@@ -196,8 +214,9 @@ function mockRunOnceWithStalledPollingRunner(): {
     }
     releaseBeforeTaskStart = true;
   });
-  runSpy.mockImplementationOnce(() =>
-    makeRunnerStub({
+  runSpy.mockImplementationOnce(() => {
+    signalRunStarted?.();
+    return makeRunnerStub({
       task: () =>
         new Promise((resolve) => {
           signalTaskStarted?.();
@@ -208,10 +227,11 @@ function mockRunOnceWithStalledPollingRunner(): {
         }),
       stop,
       isRunning: () => running,
-    }),
-  );
+    });
+  });
   return {
     stop,
+    waitForRunStart: () => runStarted,
     waitForTaskStart: () => taskStarted,
   };
 }
@@ -539,16 +559,16 @@ describe("monitorTelegramProvider (grammY)", () => {
   it("force-restarts polling when unhandled network rejection stalls runner", async () => {
     const abort = new AbortController();
     const firstCycle = mockRunOnceWithStalledPollingRunner();
-    mockRunOnceWithStalledPollingRunner();
+    const secondCycle = mockRunOnceWithStalledPollingRunner();
 
     const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
-    await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1));
+    await firstCycle.waitForRunStart();
 
     expect(emitUnhandledRejection(await makeTaggedPollingFetchError())).toBe(true);
     expect(firstCycle.stop).toHaveBeenCalledTimes(1);
     // Unhandled polling rejections restart via TelegramPollingSession backoff,
     // so the second runner cycle is not immediate.
-    await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(2), { timeout: 4_000 });
+    await secondCycle.waitForRunStart();
     abort.abort();
     await monitor;
     expectRecoverableRetryState(2);
@@ -570,13 +590,14 @@ describe("monitorTelegramProvider (grammY)", () => {
         .mockReturnValueOnce(rebuiltTransport);
 
       const abort = new AbortController();
-      mockRunOnceWithStalledPollingRunner();
-      mockRunOnceAndAbort(abort);
+      const firstCycle = mockRunOnceWithStalledPollingRunner();
+      const secondCycle = mockRunOnceAndAbort(abort);
 
       const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
-      await vi.waitFor(() => expect(createTelegramBotCalls.length).toBeGreaterThanOrEqual(1));
+      await firstCycle.waitForRunStart();
 
       vi.advanceTimersByTime(120_000);
+      await secondCycle.waitForRunStart();
       await monitor;
 
       expect(resolveTelegramTransportSpy).toHaveBeenCalledTimes(2);
@@ -602,13 +623,14 @@ describe("monitorTelegramProvider (grammY)", () => {
     resolveTelegramTransportSpy
       .mockReturnValueOnce(telegramTransport)
       .mockReturnValueOnce(rebuiltTransport);
-    mockRunOnceAndAbort(abort);
+    const secondCycle = mockRunOnceAndAbort(abort);
 
     const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
-    await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1));
+    await firstCycle.waitForRunStart();
 
     expect(emitUnhandledRejection(await makeTaggedPollingFetchError())).toBe(true);
     expect(firstCycle.stop).toHaveBeenCalledTimes(1);
+    await secondCycle.waitForRunStart();
     await monitor;
 
     expect(resolveTelegramTransportSpy).toHaveBeenCalledTimes(2);
@@ -620,16 +642,16 @@ describe("monitorTelegramProvider (grammY)", () => {
   it("aborts the active Telegram fetch when unhandled network rejection forces restart", async () => {
     const abort = new AbortController();
     const { stop, waitForTaskStart } = mockRunOnceWithStalledPollingRunner();
-    mockRunOnceAndAbort(abort);
+    const secondCycle = mockRunOnceAndAbort(abort);
 
     const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
-    await vi.waitFor(() => expect(createTelegramBotCalls.length).toBeGreaterThanOrEqual(1));
     await waitForTaskStart();
     const firstSignal = createTelegramBotCalls[0]?.fetchAbortSignal;
     expect(firstSignal).toBeInstanceOf(AbortSignal);
     expect((firstSignal as AbortSignal).aborted).toBe(false);
 
     emitUnhandledRejection(await makeTaggedPollingFetchError());
+    await secondCycle.waitForRunStart();
     await monitor;
 
     expect((firstSignal as AbortSignal).aborted).toBe(true);
@@ -638,10 +660,11 @@ describe("monitorTelegramProvider (grammY)", () => {
 
   it("ignores unrelated process-level network errors while telegram polling is active", async () => {
     const abort = new AbortController();
-    const { stop } = mockRunOnceWithStalledPollingRunner();
+    const firstCycle = mockRunOnceWithStalledPollingRunner();
+    const { stop } = firstCycle;
 
     const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
-    await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1));
+    await firstCycle.waitForRunStart();
 
     const slackDnsError = Object.assign(
       new Error("A request error occurred: getaddrinfo ENOTFOUND slack.com"),
@@ -707,14 +730,16 @@ describe("monitorTelegramProvider (grammY)", () => {
   it("force-restarts polling when getUpdates stalls (watchdog)", async () => {
     vi.useFakeTimers({ shouldAdvanceTime: true });
     const abort = new AbortController();
-    const { stop } = mockRunOnceWithStalledPollingRunner();
-    mockRunOnceAndAbort(abort);
+    const firstCycle = mockRunOnceWithStalledPollingRunner();
+    const { stop } = firstCycle;
+    const secondCycle = mockRunOnceAndAbort(abort);
 
     const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
-    await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1));
+    await firstCycle.waitForRunStart();
 
     // Advance time past the stall threshold (90s) + watchdog interval (30s)
     vi.advanceTimersByTime(120_000);
+    await secondCycle.waitForRunStart();
     await monitor;
 
     expect(stop.mock.calls.length).toBeGreaterThanOrEqual(1);
diff --git a/extensions/telegram/src/sendchataction-401-backoff.test.ts b/extensions/telegram/src/sendchataction-401-backoff.test.ts
index 2a8ccb76c16..641479fc00a 100644
--- a/extensions/telegram/src/sendchataction-401-backoff.test.ts
+++ b/extensions/telegram/src/sendchataction-401-backoff.test.ts
@@ -17,7 +17,6 @@ let createTelegramSendChatActionHandler: typeof import("./sendchataction-401-bac
 
 describe("createTelegramSendChatActionHandler", () => {
   beforeAll(async () => {
-    vi.resetModules();
     ({ createTelegramSendChatActionHandler } = await import("./sendchataction-401-backoff.js"));
   });
 
diff --git a/extensions/telegram/src/webhook.test.ts b/extensions/telegram/src/webhook.test.ts
index 73feb502501..abd7bcbcabf 100644
--- a/extensions/telegram/src/webhook.test.ts
+++ b/extensions/telegram/src/webhook.test.ts
@@ -915,7 +915,7 @@ describe("startTelegramWebhook", () => {
     });
 
     abort.abort();
-    await vi.waitFor(() => expect(deleteWebhookSpy).toHaveBeenCalledTimes(1));
+    expect(deleteWebhookSpy).toHaveBeenCalledTimes(1);
     expect(deleteWebhookSpy).toHaveBeenCalledWith({ drop_pending_updates: false });
   });
 });
diff --git a/extensions/voice-call/src/manager.notify.test.ts b/extensions/voice-call/src/manager.notify.test.ts
index f7839e3a0a9..cee2ab5128c 100644
--- a/extensions/voice-call/src/manager.notify.test.ts
+++ b/extensions/voice-call/src/manager.notify.test.ts
@@ -15,11 +15,17 @@ class FailFirstPlayTtsProvider extends FakeProvider {
 
 class DelayedPlayTtsProvider extends FakeProvider {
   private releasePlayTts: (() => void) | null = null;
+  private resolvePlayTtsStarted: (() => void) | null = null;
   readonly playTtsStarted = vi.fn();
+  readonly playTtsStartedPromise = new Promise((resolve) => {
+    this.resolvePlayTtsStarted = resolve;
+  });
 
   override async playTts(input: Parameters[0]): Promise {
     this.playTtsCalls.push(input);
     this.playTtsStarted();
+    this.resolvePlayTtsStarted?.();
+    this.resolvePlayTtsStarted = null;
     await new Promise((resolve) => {
       this.releasePlayTts = resolve;
     });
@@ -301,9 +307,8 @@ describe("CallManager notify and mapping", () => {
     expect(provider.playTtsCalls).toHaveLength(0);
 
     const first = manager.speakInitialMessage("call-uuid");
-    await vi.waitFor(() => {
-      expect(provider.playTtsStarted).toHaveBeenCalledTimes(1);
-    });
+    await provider.playTtsStartedPromise;
+    expect(provider.playTtsStarted).toHaveBeenCalledTimes(1);
 
     const second = manager.speakInitialMessage("call-uuid");
     await new Promise((resolve) => setTimeout(resolve, 0));
diff --git a/extensions/whatsapp/src/inbound.test.ts b/extensions/whatsapp/src/inbound.test.ts
index 0964573e36e..0833c5b53c4 100644
--- a/extensions/whatsapp/src/inbound.test.ts
+++ b/extensions/whatsapp/src/inbound.test.ts
@@ -1,14 +1,7 @@
-import { beforeAll, describe, expect, it, vi } from "vitest";
-
-let extractLocationData: typeof import("./inbound.js").extractLocationData;
-let extractMediaPlaceholder: typeof import("./inbound.js").extractMediaPlaceholder;
-let extractText: typeof import("./inbound.js").extractText;
+import { describe, expect, it, vi } from "vitest";
+import { extractLocationData, extractMediaPlaceholder, extractText } from "./inbound.js";
 
 describe("web inbound helpers", () => {
-  beforeAll(async () => {
-    ({ extractLocationData, extractMediaPlaceholder, extractText } = await import("./inbound.js"));
-  });
-
   it("prefers the main conversation body", () => {
     const body = extractText({
       conversation: " hello ",
diff --git a/extensions/whatsapp/src/media.test.ts b/extensions/whatsapp/src/media.test.ts
index 6ff15c4416c..70f23553399 100644
--- a/extensions/whatsapp/src/media.test.ts
+++ b/extensions/whatsapp/src/media.test.ts
@@ -8,12 +8,13 @@ import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/testing";
 import sharp from "sharp";
 import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
 import { captureEnv } from "../../../test/helpers/plugins/env.js";
-
-let LocalMediaAccessError: typeof import("./media.js").LocalMediaAccessError;
-let loadWebMedia: typeof import("./media.js").loadWebMedia;
-let loadWebMediaRaw: typeof import("./media.js").loadWebMediaRaw;
-let optimizeImageToJpeg: typeof import("./media.js").optimizeImageToJpeg;
-let sendVoiceMessageDiscord: typeof import("../../discord/src/send.js").sendVoiceMessageDiscord;
+import { sendVoiceMessageDiscord } from "../../discord/src/send.js";
+import {
+  LocalMediaAccessError,
+  loadWebMedia,
+  loadWebMediaRaw,
+  optimizeImageToJpeg,
+} from "./media.js";
 
 let fixtureRoot = "";
 let fixtureFileCount = 0;
@@ -54,9 +55,6 @@ function cloneStatWithDev(stat: T, dev: numb
 }
 
 beforeAll(async () => {
-  ({ LocalMediaAccessError, loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg } =
-    await import("./media.js"));
-  ({ sendVoiceMessageDiscord } = await import("../../discord/src/send.js"));
   fixtureRoot = await fs.mkdtemp(
     path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-test-"),
   );
diff --git a/extensions/zalo/src/monitor.lifecycle.test.ts b/extensions/zalo/src/monitor.lifecycle.test.ts
index 31e80abf675..4a3a74396c5 100644
--- a/extensions/zalo/src/monitor.lifecycle.test.ts
+++ b/extensions/zalo/src/monitor.lifecycle.test.ts
@@ -29,10 +29,6 @@ vi.mock("./runtime.js", () => ({
   }),
 }));
 
-async function waitForPollingLoopStart(): Promise {
-  await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
-}
-
 const TEST_ACCOUNT = {
   accountId: "default",
   config: {},
@@ -74,7 +70,7 @@ describe("monitorZaloProvider lifecycle", () => {
       settled = true;
     });
 
-    await waitForPollingLoopStart();
+    await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
 
     expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
     expect(deleteWebhookMock).not.toHaveBeenCalled();
@@ -98,7 +94,7 @@ describe("monitorZaloProvider lifecycle", () => {
 
     const { abort, runtime, run } = await startLifecycleMonitor();
 
-    await waitForPollingLoopStart();
+    await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
 
     expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
     expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
@@ -116,7 +112,7 @@ describe("monitorZaloProvider lifecycle", () => {
 
     const { abort, runtime, run } = await startLifecycleMonitor();
 
-    await waitForPollingLoopStart();
+    await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
 
     expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
     expect(deleteWebhookMock).not.toHaveBeenCalled();