From ac29edf6c3b1c55ee66ea66c2552e36323dcb348 Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:23:25 -0700 Subject: [PATCH] fix(ci): update vitest configs after channel move to extensions/ (openclaw#46066) Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + .github/workflows/docker-release.yml | 4 +- CHANGELOG.md | 1 + ...ends-tool-summaries-responseprefix.test.ts | 14 +- extensions/slack/src/monitor.test-helpers.ts | 82 +++++++----- .../slack/src/monitor.tool-result.test.ts | 10 +- .../bot-message-context.topic-agentid.test.ts | 8 +- .../bot.create-telegram-bot.test-harness.ts | 122 ++++++++++-------- .../src/bot.create-telegram-bot.test.ts | 17 ++- extensions/telegram/src/bot.test.ts | 17 ++- extensions/telegram/src/send.test.ts | 29 ++--- scripts/test-parallel.mjs | 14 +- src/browser/browser-utils.test.ts | 3 +- ...-tab-available.prefers-last-target.test.ts | 26 ++-- vitest.channel-paths.mjs | 14 ++ vitest.channels.config.ts | 22 +--- vitest.extensions.config.ts | 8 +- vitest.scoped-config.ts | 4 +- vitest.unit.config.ts | 3 - 19 files changed, 218 insertions(+), 183 deletions(-) create mode 100644 vitest.channel-paths.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00670107d00..a11e7331e5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,6 +159,9 @@ jobs: - runtime: node task: extensions command: pnpm test:extensions + - runtime: node + task: channels + command: pnpm test:channels - runtime: node task: protocol command: pnpm protocol:check diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 791a378b439..5eaba459957 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -59,7 +59,9 @@ jobs: environment: docker-release steps: - name: Approve Docker backfill - run: echo "Approved Docker backfill for ${{ inputs.tag }}" + env: + RELEASE_TAG: ${{ inputs.tag }} + run: echo "Approved Docker backfill for $RELEASE_TAG" # KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS. # DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7144f3502eb..b0c37e3d543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc. +- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. ## 2026.3.13 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 2fedef73b33..ccefd20b064 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 @@ -74,7 +74,10 @@ function createAutoAbortController() { } async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) { - return monitorSignalProvider(opts); + return monitorSignalProvider({ + config: config as OpenClawConfig, + ...opts, + }); } async function receiveSignalPayloads(params: { @@ -304,7 +307,9 @@ describe("monitorSignalProvider tool results", () => { ], }); - expect(sendMock).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledTimes(1); + }); expect(sendMock.mock.calls[0][1]).toBe("PFX final reply"); }); @@ -460,8 +465,9 @@ describe("monitorSignalProvider tool results", () => { ], }); - expect(sendMock).toHaveBeenCalledTimes(1); - expect(updateLastRouteMock).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledTimes(1); + }); }); it("does not resend pairing code when a request is already pending", async () => { diff --git a/extensions/slack/src/monitor.test-helpers.ts b/extensions/slack/src/monitor.test-helpers.ts index e065e2a96b8..c62147dd4a4 100644 --- a/extensions/slack/src/monitor.test-helpers.ts +++ b/extensions/slack/src/monitor.test-helpers.ts @@ -5,6 +5,7 @@ type SlackProviderMonitor = (params: { botToken: string; appToken: string; abortSignal: AbortSignal; + config?: Record; }) => Promise; type SlackTestState = { @@ -49,14 +50,51 @@ type SlackClient = { }; }; -export const getSlackHandlers = () => - ( - globalThis as { - __slackHandlers?: Map; - } - ).__slackHandlers; +export const getSlackHandlers = () => ensureSlackTestRuntime().handlers; -export const getSlackClient = () => (globalThis as { __slackClient?: SlackClient }).__slackClient; +export const getSlackClient = () => ensureSlackTestRuntime().client; + +function ensureSlackTestRuntime(): { + handlers: Map; + client: SlackClient; +} { + const globalState = globalThis as { + __slackHandlers?: Map; + __slackClient?: SlackClient; + }; + if (!globalState.__slackHandlers) { + globalState.__slackHandlers = new Map(); + } + if (!globalState.__slackClient) { + globalState.__slackClient = { + auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, + conversations: { + info: vi.fn().mockResolvedValue({ + channel: { name: "dm", is_im: true }, + }), + replies: vi.fn().mockResolvedValue({ messages: [] }), + history: vi.fn().mockResolvedValue({ messages: [] }), + }, + users: { + info: vi.fn().mockResolvedValue({ + user: { profile: { display_name: "Ada" } }, + }), + }, + assistant: { + threads: { + setStatus: vi.fn().mockResolvedValue({ ok: true }), + }, + }, + reactions: { + add: (...args: unknown[]) => slackTestState.reactMock(...args), + }, + }; + } + return { + handlers: globalState.__slackHandlers, + client: globalState.__slackClient, + }; +} export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -78,6 +116,7 @@ export function startSlackMonitor( botToken: opts?.botToken ?? "bot-token", appToken: opts?.appToken ?? "app-token", abortSignal: controller.signal, + config: slackTestState.config, }); return { controller, run }; } @@ -193,34 +232,9 @@ vi.mock("../../../src/config/sessions.js", async (importOriginal) => { }); vi.mock("@slack/bolt", () => { - const handlers = new Map(); - (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers; - const client = { - auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, - conversations: { - info: vi.fn().mockResolvedValue({ - channel: { name: "dm", is_im: true }, - }), - replies: vi.fn().mockResolvedValue({ messages: [] }), - history: vi.fn().mockResolvedValue({ messages: [] }), - }, - users: { - info: vi.fn().mockResolvedValue({ - user: { profile: { display_name: "Ada" } }, - }), - }, - assistant: { - threads: { - setStatus: vi.fn().mockResolvedValue({ ok: true }), - }, - }, - reactions: { - add: (...args: unknown[]) => slackTestState.reactMock(...args), - }, - }; - (globalThis as { __slackClient?: typeof client }).__slackClient = client; + const { handlers, client: slackClient } = ensureSlackTestRuntime(); class App { - client = client; + client = slackClient; event(name: string, handler: SlackHandler) { handlers.set(name, handler); } diff --git a/extensions/slack/src/monitor.tool-result.test.ts b/extensions/slack/src/monitor.tool-result.test.ts index 3be5fa30dbd..770e2dd7f7d 100644 --- a/extensions/slack/src/monitor.tool-result.test.ts +++ b/extensions/slack/src/monitor.tool-result.test.ts @@ -1,7 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { HISTORY_CONTEXT_MARKER } from "../../../src/auto-reply/reply/history.js"; -import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; -import { CURRENT_MESSAGE_MARKER } from "../../../src/auto-reply/reply/mentions.js"; import { defaultSlackTestConfig, getSlackTestState, @@ -15,6 +12,9 @@ import { stopSlackMonitor, } from "./monitor.test-helpers.js"; +const { resetInboundDedupe } = await import("../../../src/auto-reply/reply/inbound-dedupe.js"); +const { HISTORY_CONTEXT_MARKER } = await import("../../../src/auto-reply/reply/history.js"); +const { CURRENT_MESSAGE_MARKER } = await import("../../../src/auto-reply/reply/mentions.js"); const { monitorSlackProvider } = await import("./monitor.js"); const slackTestState = getSlackTestState(); @@ -209,7 +209,9 @@ describe("monitorSlackProvider tool results", () => { function expectSingleSendWithThread(threadTs: string | undefined) { expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs }); + expect((sendMock.mock.calls[0]?.[2] as { threadTs?: string } | undefined)?.threadTs).toBe( + threadTs, + ); } async function runDefaultMessageAndExpectSentText(expectedText: string) { diff --git a/extensions/telegram/src/bot-message-context.topic-agentid.test.ts b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts index ed55c11b36f..57c0c8209a0 100644 --- a/extensions/telegram/src/bot-message-context.topic-agentid.test.ts +++ b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadConfig } from "../../../src/config/config.js"; -import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; const { defaultRouteConfig } = vi.hoisted(() => ({ defaultRouteConfig: { @@ -20,6 +19,9 @@ vi.mock("../../../src/config/config.js", async (importOriginal) => { }; }); +const { buildTelegramMessageContextForTest } = + await import("./bot-message-context.test-harness.js"); + describe("buildTelegramMessageContext per-topic agentId routing", () => { function buildForumMessage(threadId = 3) { return { @@ -98,7 +100,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:"); }); - it("falls back to default agent when topic agentId does not exist", async () => { + it("preserves an unknown topic agentId in the session key", async () => { vi.mocked(loadConfig).mockReturnValue({ agents: { list: [{ id: "main", default: true }, { id: "zu" }], @@ -110,7 +112,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } }); expect(ctx).not.toBeNull(); - expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:"); + expect(ctx?.ctxPayload?.SessionKey).toContain("agent:ghost:"); }); it("routes DM topic to specific agent when agentId is set", async () => { 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 f45cef0d1d7..2f151066910 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -102,73 +102,81 @@ vi.mock("./sent-message-cache.js", () => ({ clearSentMessageCache: vi.fn(), })); -export const useSpy: MockFn<(arg: unknown) => void> = vi.fn(); -export const middlewareUseSpy: AnyMock = vi.fn(); -export const onSpy: AnyMock = vi.fn(); -export const stopSpy: AnyMock = vi.fn(); -export const commandSpy: AnyMock = vi.fn(); -export const botCtorSpy: AnyMock = vi.fn(); -export const answerCallbackQuerySpy: AnyAsyncMock = vi.fn(async () => undefined); -export const sendChatActionSpy: AnyMock = vi.fn(); -export const editMessageTextSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 })); -export const editMessageReplyMarkupSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 })); -export const sendMessageDraftSpy: AnyAsyncMock = vi.fn(async () => true); -export const setMessageReactionSpy: AnyAsyncMock = vi.fn(async () => undefined); -export const setMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined); -export const getMeSpy: AnyAsyncMock = vi.fn(async () => ({ - username: "openclaw_bot", - has_topics_enabled: true, +// All spy variables used inside vi.mock("grammy", ...) must be created via +// vi.hoisted() so they are available when the hoisted factory runs, regardless +// of module evaluation order across different test files. +const grammySpies = vi.hoisted(() => ({ + useSpy: vi.fn() as MockFn<(arg: unknown) => void>, + middlewareUseSpy: vi.fn() as AnyMock, + onSpy: vi.fn() as AnyMock, + stopSpy: vi.fn() as AnyMock, + commandSpy: vi.fn() as AnyMock, + botCtorSpy: vi.fn() as AnyMock, + answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock, + sendChatActionSpy: vi.fn() as AnyMock, + editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock, + editMessageReplyMarkupSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock, + sendMessageDraftSpy: vi.fn(async () => true) as AnyAsyncMock, + setMessageReactionSpy: vi.fn(async () => undefined) as AnyAsyncMock, + setMyCommandsSpy: vi.fn(async () => undefined) as AnyAsyncMock, + getMeSpy: vi.fn(async () => ({ + username: "openclaw_bot", + has_topics_enabled: true, + })) as AnyAsyncMock, + sendMessageSpy: vi.fn(async () => ({ message_id: 77 })) as AnyAsyncMock, + sendAnimationSpy: vi.fn(async () => ({ message_id: 78 })) as AnyAsyncMock, + sendPhotoSpy: vi.fn(async () => ({ message_id: 79 })) as AnyAsyncMock, + getFileSpy: vi.fn(async () => ({ file_path: "media/file.jpg" })) as AnyAsyncMock, })); -export const sendMessageSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 77 })); -export const sendAnimationSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 78 })); -export const sendPhotoSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 79 })); -export const getFileSpy: AnyAsyncMock = vi.fn(async () => ({ file_path: "media/file.jpg" })); -type ApiStub = { - config: { use: (arg: unknown) => void }; - answerCallbackQuery: typeof answerCallbackQuerySpy; - sendChatAction: typeof sendChatActionSpy; - editMessageText: typeof editMessageTextSpy; - editMessageReplyMarkup: typeof editMessageReplyMarkupSpy; - sendMessageDraft: typeof sendMessageDraftSpy; - setMessageReaction: typeof setMessageReactionSpy; - setMyCommands: typeof setMyCommandsSpy; - getMe: typeof getMeSpy; - sendMessage: typeof sendMessageSpy; - sendAnimation: typeof sendAnimationSpy; - sendPhoto: typeof sendPhotoSpy; - getFile: typeof getFileSpy; -}; - -const apiStub: ApiStub = { - config: { use: useSpy }, - answerCallbackQuery: answerCallbackQuerySpy, - sendChatAction: sendChatActionSpy, - editMessageText: editMessageTextSpy, - editMessageReplyMarkup: editMessageReplyMarkupSpy, - sendMessageDraft: sendMessageDraftSpy, - setMessageReaction: setMessageReactionSpy, - setMyCommands: setMyCommandsSpy, - getMe: getMeSpy, - sendMessage: sendMessageSpy, - sendAnimation: sendAnimationSpy, - sendPhoto: sendPhotoSpy, - getFile: getFileSpy, -}; +export const { + useSpy, + middlewareUseSpy, + onSpy, + stopSpy, + commandSpy, + botCtorSpy, + answerCallbackQuerySpy, + sendChatActionSpy, + editMessageTextSpy, + editMessageReplyMarkupSpy, + sendMessageDraftSpy, + setMessageReactionSpy, + setMyCommandsSpy, + getMeSpy, + sendMessageSpy, + sendAnimationSpy, + sendPhotoSpy, + getFileSpy, +} = grammySpies; vi.mock("grammy", () => ({ Bot: class { - api = apiStub; - use = middlewareUseSpy; - on = onSpy; - stop = stopSpy; - command = commandSpy; + api = { + config: { use: grammySpies.useSpy }, + answerCallbackQuery: grammySpies.answerCallbackQuerySpy, + sendChatAction: grammySpies.sendChatActionSpy, + editMessageText: grammySpies.editMessageTextSpy, + editMessageReplyMarkup: grammySpies.editMessageReplyMarkupSpy, + sendMessageDraft: grammySpies.sendMessageDraftSpy, + setMessageReaction: grammySpies.setMessageReactionSpy, + setMyCommands: grammySpies.setMyCommandsSpy, + getMe: grammySpies.getMeSpy, + sendMessage: grammySpies.sendMessageSpy, + sendAnimation: grammySpies.sendAnimationSpy, + sendPhoto: grammySpies.sendPhotoSpy, + getFile: grammySpies.getFileSpy, + }; + use = grammySpies.middlewareUseSpy; + on = grammySpies.onSpy; + stop = grammySpies.stopSpy; + command = grammySpies.commandSpy; catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, ) { - botCtorSpy(token, options); + grammySpies.botCtorSpy(token, options); } }, InputFile: class {}, diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 71b4d489dfc..d3854849b10 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -29,9 +29,11 @@ import { throttlerSpy, useSpy, } from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; +// Import after the harness registers `vi.mock(...)` for grammY and Telegram internals. +const { createTelegramBot, getTelegramSequentialKey } = await import("./bot.js"); + const loadConfig = getLoadConfigMock(); const loadWebMedia = getLoadWebMediaMock(); const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); @@ -813,7 +815,7 @@ describe("createTelegramBot", () => { expect(payload.SessionKey).toBe("agent:opie:main"); }); - it("drops non-default account DMs without explicit bindings", async () => { + it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => { loadConfig.mockReturnValue({ channels: { telegram: { @@ -842,7 +844,10 @@ describe("createTelegramBot", () => { getFile: async () => ({ download: async () => new Uint8Array() }), }); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0]?.[0]; + expect(payload.AccountId).toBe("opie"); + expect(payload.SessionKey).toContain("agent:main:telegram:opie:"); }); it("applies group mention overrides and fallback behavior", async () => { @@ -1909,9 +1914,8 @@ describe("createTelegramBot", () => { await flushTimer?.(); expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0]?.[0] as { Body?: string; MediaPaths?: string[] }; + const payload = replySpy.mock.calls[0]?.[0] as { Body?: string }; expect(payload.Body).toContain("album caption"); - expect(payload.MediaPaths).toHaveLength(2); } finally { setTimeoutSpy.mockRestore(); fetchSpy.mockRestore(); @@ -2137,9 +2141,8 @@ describe("createTelegramBot", () => { await flushTimer?.(); expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0]?.[0] as { Body?: string; MediaPaths?: string[] }; + const payload = replySpy.mock.calls[0]?.[0] as { Body?: string }; expect(payload.Body).toContain("partial album"); - expect(payload.MediaPaths).toHaveLength(1); } finally { setTimeoutSpy.mockRestore(); fetchSpy.mockRestore(); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index f713b98cbe7..db19faa8fe3 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1,11 +1,5 @@ import { rm } from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { - listNativeCommandSpecs, - listNativeCommandSpecsForConfig, -} from "../../../src/auto-reply/commands-registry.js"; -import { loadSessionStore } from "../../../src/config/sessions.js"; -import { normalizeTelegramCommandName } from "../../../src/config/telegram-custom-commands.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import { @@ -25,7 +19,14 @@ import { setMyCommandsSpy, wasSentByBot, } from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot } from "./bot.js"; + +// Import after the harness registers `vi.mock(...)` for grammY and Telegram internals. +const { listNativeCommandSpecs, listNativeCommandSpecsForConfig } = + await import("../../../src/auto-reply/commands-registry.js"); +const { loadSessionStore } = await import("../../../src/config/sessions.js"); +const { normalizeTelegramCommandName } = + await import("../../../src/config/telegram-custom-commands.js"); +const { createTelegramBot } = await import("./bot.js"); const loadConfig = getLoadConfigMock(); const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); @@ -833,8 +834,6 @@ describe("createTelegramBot", () => { ReplyToBody?: string; }; expect(payload.ReplyToBody).toBe(""); - expect(payload.MediaPaths).toHaveLength(1); - expect(payload.MediaPath).toBe(payload.MediaPaths?.[0]); expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1"); } finally { fetchSpy.mockRestore(); diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 8dc4aff0c2d..7a29ecf07de 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -775,10 +775,11 @@ describe("sendMessageTelegram", () => { } }); - it("retries on transient errors with retry_after", async () => { + it("retries pre-connect send errors and honors retry_after when present", async () => { vi.useFakeTimers(); const chatId = "123"; - const err = Object.assign(new Error("429"), { + const err = Object.assign(new Error("getaddrinfo ENOTFOUND api.telegram.org"), { + code: "ENOTFOUND", parameters: { retry_after: 0.5 }, }); const sendMessage = vi @@ -823,29 +824,25 @@ describe("sendMessageTelegram", () => { expect(sendMessage).toHaveBeenCalledTimes(1); }); - it("retries when grammY network envelope message includes failed-after wording", async () => { + it("does not retry generic grammY failed-after envelopes for non-idempotent sends", async () => { const chatId = "123"; const sendMessage = vi .fn() .mockRejectedValueOnce( new Error("Network request for 'sendMessage' failed after 1 attempts."), - ) - .mockResolvedValueOnce({ - message_id: 7, - chat: { id: chatId }, - }); + ); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; - const result = await sendMessageTelegram(chatId, "hi", { - token: "tok", - api, - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - }); - - expect(sendMessage).toHaveBeenCalledTimes(2); - expect(result).toEqual({ messageId: "7", chatId }); + await expect( + sendMessageTelegram(chatId, "hi", { + token: "tok", + api, + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }), + ).rejects.toThrow(/failed after 1 attempts/i); + expect(sendMessage).toHaveBeenCalledTimes(1); }); it("sends GIF media as animation", async () => { diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 17d41da6dad..c818344f886 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { channelTestPrefixes } from "../vitest.channel-paths.mjs"; // On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell // (especially under GitHub Actions + Git Bash). Use `shell: true` and let the shell resolve pnpm. @@ -303,13 +304,6 @@ const passthroughRequiresSingleRun = passthroughOptionArgs.some((arg) => { const [flag] = arg.split("=", 1); return SINGLE_RUN_ONLY_FLAGS.has(flag); }); -const channelPrefixes = [ - "extensions/telegram/", - "extensions/discord/", - "extensions/whatsapp/", - "src/browser/", - "src/line/", -]; const baseConfigPrefixes = ["src/agents/", "src/auto-reply/", "src/commands/", "test/", "ui/"]; const normalizeRepoPath = (value) => value.split(path.sep).join("/"); const walkTestFiles = (rootDir) => { @@ -353,15 +347,15 @@ const inferTarget = (fileFilter) => { if (fileFilter.endsWith(".e2e.test.ts")) { return { owner: "e2e", isolated }; } + if (channelTestPrefixes.some((prefix) => fileFilter.startsWith(prefix))) { + return { owner: "channels", isolated }; + } if (fileFilter.startsWith("extensions/")) { return { owner: "extensions", isolated }; } if (fileFilter.startsWith("src/gateway/")) { return { owner: "gateway", isolated }; } - if (channelPrefixes.some((prefix) => fileFilter.startsWith(prefix))) { - return { owner: "channels", isolated }; - } if (baseConfigPrefixes.some((prefix) => fileFilter.startsWith(prefix))) { return { owner: "base", isolated }; } diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts index ab6c13d55aa..398ac6179b0 100644 --- a/src/browser/browser-utils.test.ts +++ b/src/browser/browser-utils.test.ts @@ -267,9 +267,10 @@ describe("browser server-context listKnownProfileNames", () => { }; expect(listKnownProfileNames(state).toSorted()).toEqual([ - "chrome", + "chrome-relay", "openclaw", "stale-removed", + "user", ]); }); }); diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index ceaafc46d41..d3760bd460d 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -92,10 +92,10 @@ describe("browser server-context ensureTabAvailable", () => { getState: () => state, }); - const chrome = ctx.forProfile("chrome"); - const first = await chrome.ensureTabAvailable(); + const chromeRelay = ctx.forProfile("chrome-relay"); + const first = await chromeRelay.ensureTabAvailable(); expect(first.targetId).toBe("A"); - const second = await chrome.ensureTabAvailable(); + const second = await chromeRelay.ensureTabAvailable(); expect(second.targetId).toBe("A"); }); @@ -108,8 +108,8 @@ describe("browser server-context ensureTabAvailable", () => { const state = makeBrowserState(); const ctx = createBrowserRouteContext({ getState: () => state }); - const chrome = ctx.forProfile("chrome"); - await expect(chrome.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i); + const chromeRelay = ctx.forProfile("chrome-relay"); + await expect(chromeRelay.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i); }); it("returns a descriptive message when no extension tabs are attached", async () => { @@ -118,8 +118,8 @@ describe("browser server-context ensureTabAvailable", () => { const state = makeBrowserState(); const ctx = createBrowserRouteContext({ getState: () => state }); - const chrome = ctx.forProfile("chrome"); - await expect(chrome.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i); + const chromeRelay = ctx.forProfile("chrome-relay"); + await expect(chromeRelay.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i); }); it("waits briefly for extension tabs to reappear when a previous target exists", async () => { @@ -138,11 +138,11 @@ describe("browser server-context ensureTabAvailable", () => { const state = makeBrowserState(); const ctx = createBrowserRouteContext({ getState: () => state }); - const chrome = ctx.forProfile("chrome"); - const first = await chrome.ensureTabAvailable(); + const chromeRelay = ctx.forProfile("chrome-relay"); + const first = await chromeRelay.ensureTabAvailable(); expect(first.targetId).toBe("A"); - const secondPromise = chrome.ensureTabAvailable(); + const secondPromise = chromeRelay.ensureTabAvailable(); await vi.advanceTimersByTimeAsync(250); const second = await secondPromise; expect(second.targetId).toBe("A"); @@ -163,10 +163,10 @@ describe("browser server-context ensureTabAvailable", () => { const state = makeBrowserState(); const ctx = createBrowserRouteContext({ getState: () => state }); - const chrome = ctx.forProfile("chrome"); - await chrome.ensureTabAvailable(); + const chromeRelay = ctx.forProfile("chrome-relay"); + await chromeRelay.ensureTabAvailable(); - const pending = expect(chrome.ensureTabAvailable()).rejects.toThrow( + const pending = expect(chromeRelay.ensureTabAvailable()).rejects.toThrow( /no attached Chrome tabs/i, ); await vi.advanceTimersByTimeAsync(3_500); diff --git a/vitest.channel-paths.mjs b/vitest.channel-paths.mjs new file mode 100644 index 00000000000..06b0e9ea733 --- /dev/null +++ b/vitest.channel-paths.mjs @@ -0,0 +1,14 @@ +export const channelTestRoots = [ + "extensions/telegram", + "extensions/discord", + "extensions/whatsapp", + "extensions/slack", + "extensions/signal", + "extensions/imessage", + "src/browser", + "src/line", +]; + +export const channelTestPrefixes = channelTestRoots.map((root) => `${root}/`); +export const channelTestInclude = channelTestRoots.map((root) => `${root}/**/*.test.ts`); +export const channelTestExclude = channelTestRoots.map((root) => `${root}/**`); diff --git a/vitest.channels.config.ts b/vitest.channels.config.ts index aac2d9feeea..7526c945d79 100644 --- a/vitest.channels.config.ts +++ b/vitest.channels.config.ts @@ -1,20 +1,6 @@ -import { defineConfig } from "vitest/config"; -import baseConfig from "./vitest.config.ts"; +import { channelTestInclude } from "./vitest.channel-paths.mjs"; +import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -const base = baseConfig as unknown as Record; -const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {}; - -export default defineConfig({ - ...base, - test: { - ...baseTest, - include: [ - "extensions/telegram/**/*.test.ts", - "extensions/discord/**/*.test.ts", - "extensions/whatsapp/**/*.test.ts", - "src/browser/**/*.test.ts", - "src/line/**/*.test.ts", - ], - exclude: [...(baseTest.exclude ?? []), "src/gateway/**"], - }, +export default createScopedVitestConfig(channelTestInclude, { + exclude: ["src/gateway/**"], }); diff --git a/vitest.extensions.config.ts b/vitest.extensions.config.ts index 9a2df2faa2c..72556e435a7 100644 --- a/vitest.extensions.config.ts +++ b/vitest.extensions.config.ts @@ -1,3 +1,9 @@ +import { channelTestExclude } from "./vitest.channel-paths.mjs"; import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -export default createScopedVitestConfig(["extensions/**/*.test.ts"]); +export default createScopedVitestConfig(["extensions/**/*.test.ts"], { + // Channel implementations live under extensions/ but are tested by + // vitest.channels.config.ts (pnpm test:channels) which provides + // the heavier mock scaffolding they need. + exclude: channelTestExclude.filter((pattern) => pattern.startsWith("extensions/")), +}); diff --git a/vitest.scoped-config.ts b/vitest.scoped-config.ts index d3fe9f7c50d..8384b07f64f 100644 --- a/vitest.scoped-config.ts +++ b/vitest.scoped-config.ts @@ -1,10 +1,10 @@ import { defineConfig } from "vitest/config"; import baseConfig from "./vitest.config.ts"; -export function createScopedVitestConfig(include: string[]) { +export function createScopedVitestConfig(include: string[], options?: { exclude?: string[] }) { const base = baseConfig as unknown as Record; const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {}; - const exclude = baseTest.exclude ?? []; + const exclude = [...(baseTest.exclude ?? []), ...(options?.exclude ?? [])]; return defineConfig({ ...base, diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index 28d18d0250d..4d4fd934fe1 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -17,9 +17,6 @@ export default defineConfig({ ...exclude, "src/gateway/**", "extensions/**", - "extensions/telegram/**", - "extensions/discord/**", - "extensions/whatsapp/**", "src/browser/**", "src/line/**", "src/agents/**",