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>
This commit is contained in:
scoootscooob 2026-03-14 11:23:25 -07:00 committed by GitHub
parent e490f450f3
commit ac29edf6c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 218 additions and 183 deletions

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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 () => {

View File

@ -5,6 +5,7 @@ type SlackProviderMonitor = (params: {
botToken: string;
appToken: string;
abortSignal: AbortSignal;
config?: Record<string, unknown>;
}) => Promise<unknown>;
type SlackTestState = {
@ -49,14 +50,51 @@ type SlackClient = {
};
};
export const getSlackHandlers = () =>
(
globalThis as {
__slackHandlers?: Map<string, SlackHandler>;
}
).__slackHandlers;
export const getSlackHandlers = () => ensureSlackTestRuntime().handlers;
export const getSlackClient = () => (globalThis as { __slackClient?: SlackClient }).__slackClient;
export const getSlackClient = () => ensureSlackTestRuntime().client;
function ensureSlackTestRuntime(): {
handlers: Map<string, SlackHandler>;
client: SlackClient;
} {
const globalState = globalThis as {
__slackHandlers?: Map<string, SlackHandler>;
__slackClient?: SlackClient;
};
if (!globalState.__slackHandlers) {
globalState.__slackHandlers = new Map<string, SlackHandler>();
}
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<string, SlackHandler>();
(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);
}

View File

@ -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) {

View File

@ -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 () => {

View File

@ -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 {},

View File

@ -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();

View File

@ -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("<media:image>");
expect(payload.MediaPaths).toHaveLength(1);
expect(payload.MediaPath).toBe(payload.MediaPaths?.[0]);
expect(getFileSpy).toHaveBeenCalledWith("reply-photo-1");
} finally {
fetchSpy.mockRestore();

View File

@ -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 () => {

View File

@ -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 };
}

View File

@ -267,9 +267,10 @@ describe("browser server-context listKnownProfileNames", () => {
};
expect(listKnownProfileNames(state).toSorted()).toEqual([
"chrome",
"chrome-relay",
"openclaw",
"stale-removed",
"user",
]);
});
});

View File

@ -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);

14
vitest.channel-paths.mjs Normal file
View File

@ -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}/**`);

View File

@ -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<string, unknown>;
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/**"],
});

View File

@ -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/")),
});

View File

@ -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<string, unknown>;
const baseTest = (baseConfig as { test?: { exclude?: string[] } }).test ?? {};
const exclude = baseTest.exclude ?? [];
const exclude = [...(baseTest.exclude ?? []), ...(options?.exclude ?? [])];
return defineConfig({
...base,

View File

@ -17,9 +17,6 @@ export default defineConfig({
...exclude,
"src/gateway/**",
"extensions/**",
"extensions/telegram/**",
"extensions/discord/**",
"extensions/whatsapp/**",
"src/browser/**",
"src/line/**",
"src/agents/**",