refactor: dedupe test helpers and harnesses

This commit is contained in:
Peter Steinberger
2026-03-24 21:40:53 +00:00
parent 9f4f997472
commit 23a4ae4759
32 changed files with 791 additions and 1125 deletions

View File

@@ -57,6 +57,38 @@ function createHandlerWithDefaultPreflight(overrides?: {
return createDiscordMessageHandler(createDiscordHandlerParams(overrides));
}
function installDefaultDiscordPreflight() {
preflightDiscordMessageMock.mockImplementation(async (params: { data: { channel_id: string } }) =>
createPreflightContext(params.data.channel_id),
);
}
async function runSingleMessageTimeout(params: {
processImpl: Parameters<typeof processDiscordMessageMock.mockImplementationOnce>[0];
workerRunTimeoutMs?: number;
}) {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
deliverDiscordReplyMock.mockClear();
processDiscordMessageMock.mockImplementationOnce(params.processImpl);
installDefaultDiscordPreflight();
const handlerParams = createDiscordHandlerParams({
workerRunTimeoutMs: params.workerRunTimeoutMs ?? 50,
});
const handler = createDiscordMessageHandler(handlerParams);
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
await vi.advanceTimersByTimeAsync(60);
await Promise.resolve();
expect(handlerParams.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("discord inbound worker timed out after"),
);
return handlerParams;
}
async function createLifecycleStopScenario(params: {
createHandler: (status: SetStatusFn) => {
handler: (data: never, opts: never) => Promise<void>;
@@ -312,12 +344,8 @@ describe("createDiscordMessageHandler queue behavior", () => {
it("does not send the timeout fallback when a final reply already went out", async () => {
vi.useFakeTimers();
try {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
deliverDiscordReplyMock.mockClear();
processDiscordMessageMock.mockImplementationOnce(
async (
await runSingleMessageTimeout({
processImpl: async (
ctx: { abortSignal?: AbortSignal },
observer?: { onFinalReplyStart?: () => void; onFinalReplyDelivered?: () => void },
) => {
@@ -331,25 +359,8 @@ describe("createDiscordMessageHandler queue behavior", () => {
ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
},
);
preflightDiscordMessageMock.mockImplementation(
async (params: { data: { channel_id: string } }) =>
createPreflightContext(params.data.channel_id),
);
});
const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 });
const handler = createDiscordMessageHandler(params);
await expect(
handler(createMessageData("m-1") as never, {} as never),
).resolves.toBeUndefined();
await vi.advanceTimersByTimeAsync(60);
await Promise.resolve();
expect(params.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("discord inbound worker timed out after"),
);
expect(deliverDiscordReplyMock).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
@@ -359,12 +370,8 @@ describe("createDiscordMessageHandler queue behavior", () => {
it("routes the timeout fallback to the created auto-thread target", async () => {
vi.useFakeTimers();
try {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
deliverDiscordReplyMock.mockClear();
processDiscordMessageMock.mockImplementationOnce(
async (
await runSingleMessageTimeout({
processImpl: async (
ctx: { abortSignal?: AbortSignal },
observer?: {
onReplyPlanResolved?: (params: {
@@ -385,25 +392,8 @@ describe("createDiscordMessageHandler queue behavior", () => {
ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
},
);
preflightDiscordMessageMock.mockImplementation(
async (params: { data: { channel_id: string } }) =>
createPreflightContext(params.data.channel_id),
);
});
const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 });
const handler = createDiscordMessageHandler(params);
await expect(
handler(createMessageData("m-1") as never, {} as never),
).resolves.toBeUndefined();
await vi.advanceTimersByTimeAsync(60);
await Promise.resolve();
expect(params.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("discord inbound worker timed out after"),
);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -0,0 +1,18 @@
export type AsyncLock = <T>(fn: () => Promise<T>) => Promise<T>;
export function createAsyncLock(): AsyncLock {
let lock: Promise<void> = Promise.resolve();
return async function withLock<T>(fn: () => Promise<T>): Promise<T> {
const previous = lock;
let release: (() => void) | undefined;
lock = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await fn();
} finally {
release?.();
}
};
}

View File

@@ -10,6 +10,7 @@ import {
import { MemoryStore } from "matrix-js-sdk/lib/store/memory.js";
import { SyncAccumulator } from "matrix-js-sdk/lib/sync-accumulator.js";
import { writeJsonFileAtomically } from "../../runtime-api.js";
import { createAsyncLock } from "../async-lock.js";
import { LogService } from "../sdk/logger.js";
const STORE_VERSION = 1;
@@ -22,23 +23,6 @@ type PersistedMatrixSyncStore = {
cleanShutdown?: boolean;
};
function createAsyncLock() {
let lock: Promise<void> = Promise.resolve();
return async function withLock<T>(fn: () => Promise<T>): Promise<T> {
const previous = lock;
let release: (() => void) | undefined;
lock = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await fn();
} finally {
release?.();
}
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@@ -48,6 +48,30 @@ function createMockClient(name: string) {
return client;
}
function primeAccountClientMocks(params?: {
mainAuth?: MatrixAuth;
opsAuth?: MatrixAuth;
mainClient?: ReturnType<typeof createMockClient>;
opsClient?: ReturnType<typeof createMockClient>;
}) {
const mainAuth = params?.mainAuth ?? authFor("main");
const opsAuth = params?.opsAuth ?? authFor("ops");
const mainClient = params?.mainClient ?? createMockClient("main");
const opsClient = params?.opsClient ?? createMockClient("ops");
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
accountId === "ops" ? opsAuth : mainAuth,
);
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
if (accountId === "ops") {
return opsClient;
}
return mainClient;
});
return { mainAuth, opsAuth, mainClient, opsClient };
}
describe("resolveSharedMatrixClient", () => {
beforeEach(() => {
resolveMatrixAuthMock.mockReset();
@@ -69,48 +93,22 @@ describe("resolveSharedMatrixClient", () => {
});
it("keeps account clients isolated when resolves are interleaved", async () => {
const mainAuth = authFor("main");
const poeAuth = authFor("ops");
const mainClient = createMockClient("main");
const poeClient = createMockClient("ops");
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
accountId === "ops" ? poeAuth : mainAuth,
);
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
if (accountId === "ops") {
return poeClient;
}
return mainClient;
});
const { mainClient, opsClient } = primeAccountClientMocks();
const firstMain = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
const firstPoe = await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
const secondMain = await resolveSharedMatrixClient({ accountId: "main" });
expect(firstMain).toBe(mainClient);
expect(firstPoe).toBe(poeClient);
expect(firstPoe).toBe(opsClient);
expect(secondMain).toBe(mainClient);
expect(createMatrixClientMock).toHaveBeenCalledTimes(2);
expect(mainClient.start).toHaveBeenCalledTimes(1);
expect(poeClient.start).toHaveBeenCalledTimes(0);
expect(opsClient.start).toHaveBeenCalledTimes(0);
});
it("stops only the targeted account client", async () => {
const mainAuth = authFor("main");
const poeAuth = authFor("ops");
const mainClient = createMockClient("main");
const poeClient = createMockClient("ops");
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
accountId === "ops" ? poeAuth : mainAuth,
);
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
if (accountId === "ops") {
return poeClient;
}
return mainClient;
});
const { mainAuth, mainClient, opsClient } = primeAccountClientMocks();
await resolveSharedMatrixClient({ accountId: "main", startClient: false });
await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
@@ -118,11 +116,11 @@ describe("resolveSharedMatrixClient", () => {
stopSharedClientForAccount(mainAuth);
expect(mainClient.stop).toHaveBeenCalledTimes(1);
expect(poeClient.stop).toHaveBeenCalledTimes(0);
expect(opsClient.stop).toHaveBeenCalledTimes(0);
stopSharedClient();
expect(poeClient.stop).toHaveBeenCalledTimes(1);
expect(opsClient.stop).toHaveBeenCalledTimes(1);
});
it("drops stopped shared clients by instance so the next resolve recreates them", async () => {

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "../../runtime-api.js";
import { createAsyncLock } from "../async-lock.js";
import { resolveMatrixStoragePaths } from "../client/storage.js";
import type { MatrixAuth } from "../client/types.js";
import { LogService } from "../sdk/logger.js";
@@ -28,23 +29,6 @@ export type MatrixInboundEventDeduper = {
stop: () => Promise<void>;
};
function createAsyncLock() {
let lock: Promise<void> = Promise.resolve();
return async function withLock<T>(fn: () => Promise<T>): Promise<T> {
const previous = lock;
let release: (() => void) | undefined;
lock = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await fn();
} finally {
release?.();
}
};
}
function normalizeEventPart(value: string): string {
return value.trim();
}

View File

@@ -55,6 +55,25 @@ async function loadMatrixSendModules() {
({ sendTypingMatrix } = await import("./send.js"));
({ voteMatrixPoll } = await import("./actions/polls.js"));
}
function createEncryptedMediaPayload() {
return {
buffer: Buffer.from("encrypted"),
file: {
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
},
};
}
const makeClient = () => {
const sendMessage = vi.fn().mockResolvedValue("evt1");
const sendEvent = vi.fn().mockResolvedValue("evt-poll-vote");
@@ -74,6 +93,22 @@ const makeClient = () => {
return { client, sendMessage, sendEvent, getEvent, uploadContent };
};
function makeEncryptedMediaClient() {
const result = makeClient();
(result.client as { crypto?: object }).crypto = {
isRoomEncrypted: vi.fn().mockResolvedValue(true),
encryptMedia: vi.fn().mockResolvedValue(createEncryptedMediaPayload()),
};
return result;
}
async function resetMatrixSendRuntimeMocks() {
loadConfigMock.mockReset().mockReturnValue({});
mediaKindFromMimeMock.mockReset().mockReturnValue("image");
isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false);
await loadMatrixSendModules();
}
describe("sendMessageMatrix media", () => {
beforeAll(async () => {
await loadMatrixSendModules();
@@ -119,25 +154,7 @@ describe("sendMessageMatrix media", () => {
});
it("uploads encrypted media with file payloads", async () => {
const { client, sendMessage, uploadContent } = makeClient();
(client as { crypto?: object }).crypto = {
isRoomEncrypted: vi.fn().mockResolvedValue(true),
encryptMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("encrypted"),
file: {
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
},
}),
};
const { client, sendMessage, uploadContent } = makeEncryptedMediaClient();
await sendMessageMatrix("room:!room:example", "caption", {
client,
@@ -156,25 +173,7 @@ describe("sendMessageMatrix media", () => {
});
it("does not upload plaintext thumbnails for encrypted image sends", async () => {
const { client, uploadContent } = makeClient();
(client as { crypto?: object }).crypto = {
isRoomEncrypted: vi.fn().mockResolvedValue(true),
encryptMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("encrypted"),
file: {
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
},
}),
};
const { client, uploadContent } = makeEncryptedMediaClient();
getImageMetadataMock
.mockResolvedValueOnce({ width: 1600, height: 1200 })
.mockResolvedValueOnce({ width: 800, height: 600 });
@@ -332,10 +331,7 @@ describe("sendMessageMatrix media", () => {
describe("sendMessageMatrix threads", () => {
beforeEach(async () => {
vi.clearAllMocks();
loadConfigMock.mockReset().mockReturnValue({});
mediaKindFromMimeMock.mockReset().mockReturnValue("image");
isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false);
await loadMatrixSendModules();
await resetMatrixSendRuntimeMocks();
});
it("includes thread relation metadata when threadId is set", async () => {
@@ -380,10 +376,7 @@ describe("voteMatrixPoll", () => {
beforeEach(async () => {
vi.clearAllMocks();
loadConfigMock.mockReset().mockReturnValue({});
mediaKindFromMimeMock.mockReset().mockReturnValue("image");
isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false);
await loadMatrixSendModules();
await resetMatrixSendRuntimeMocks();
});
it("maps 1-based option indexes to Matrix poll answer ids", async () => {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { SignalDaemonExitEvent } from "./daemon.js";
import {
createSignalToolResultConfig,
createMockSignalDaemonHandle,
config,
getSignalToolResultTestMocks,
@@ -30,27 +31,8 @@ function createMonitorRuntime() {
};
}
function createSignalConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const base = config as OpenClawConfig;
const channels = (base.channels ?? {}) as Record<string, unknown>;
const signal = (channels.signal ?? {}) as Record<string, unknown>;
return {
...base,
channels: {
...channels,
signal: {
...signal,
autoStart: true,
dmPolicy: "open",
allowFrom: ["*"],
...overrides,
},
},
};
}
function setSignalAutoStartConfig(overrides: Record<string, unknown> = {}) {
setSignalToolResultTestConfig(createSignalConfig(overrides));
setSignalToolResultTestConfig(createSignalToolResultConfig(overrides));
}
function createAutoAbortController() {

View File

@@ -3,6 +3,7 @@ import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
createSignalToolResultConfig,
config,
flush,
getSignalToolResultTestMocks,
@@ -29,25 +30,6 @@ const {
const SIGNAL_BASE_URL = "http://127.0.0.1:8080";
type MonitorSignalProviderOptions = Parameters<typeof monitorSignalProvider>[0];
function createSignalConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const base = config as OpenClawConfig;
const channels = (base.channels ?? {}) as Record<string, unknown>;
const signal = (channels.signal ?? {}) as Record<string, unknown>;
return {
...base,
channels: {
...channels,
signal: {
...signal,
autoStart: true,
dmPolicy: "open",
allowFrom: ["*"],
...overrides,
},
},
};
}
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
return monitorSignalProvider({
config: config as OpenClawConfig,
@@ -127,7 +109,7 @@ function expectNoReplyDeliveryOrRouteUpdate() {
function setReactionNotificationConfig(mode: "all" | "own", extra: Record<string, unknown> = {}) {
setSignalToolResultTestConfig(
createSignalConfig({
createSignalToolResultConfig({
autoStart: false,
dmPolicy: "open",
allowFrom: ["*"],
@@ -164,7 +146,7 @@ describe("monitorSignalProvider tool results", () => {
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
setSignalToolResultTestConfig(
createSignalConfig({ autoStart: false, dmPolicy: "pairing", allowFrom: [] }),
createSignalToolResultConfig({ autoStart: false, dmPolicy: "pairing", allowFrom: [] }),
);
await receiveSignalPayloads({
payloads: [
@@ -318,7 +300,7 @@ describe("monitorSignalProvider tool results", () => {
it("does not resend pairing code when a request is already pending", async () => {
setSignalToolResultTestConfig(
createSignalConfig({ autoStart: false, dmPolicy: "pairing", allowFrom: [] }),
createSignalToolResultConfig({ autoStart: false, dmPolicy: "pairing", allowFrom: [] }),
);
upsertPairingRequestMock
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })

View File

@@ -50,6 +50,27 @@ export function setSignalToolResultTestConfig(next: Record<string, unknown>) {
config = next;
}
export function createSignalToolResultConfig(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
const base = config as { channels?: Record<string, unknown> };
const channels = base.channels ?? {};
const signal = (channels.signal ?? {}) as Record<string, unknown>;
return {
...base,
channels: {
...channels,
signal: {
...signal,
autoStart: true,
dmPolicy: "open",
allowFrom: ["*"],
...overrides,
},
},
};
}
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
export function createMockSignalDaemonHandle(

View File

@@ -1,14 +1,9 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// Mock recordInboundSession to capture updateLastRoute parameter
const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined);
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
import {
getRecordedUpdateLastRoute,
loadTelegramMessageContextRouteHarness,
recordInboundSessionMock,
} from "./bot-message-context.route-test-support.js";
let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
let clearRuntimeConfigSnapshot: typeof import("../../../src/config/config.js").clearRuntimeConfigSnapshot;
@@ -26,21 +21,14 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889
});
}
function getUpdateLastRoute(): unknown {
const callArgs = recordInboundSessionMock.mock.calls[0]?.[0] as { updateLastRoute?: unknown };
return callArgs?.updateLastRoute;
}
afterEach(() => {
clearRuntimeConfigSnapshot();
recordInboundSessionMock.mockClear();
});
beforeAll(async () => {
vi.resetModules();
({ clearRuntimeConfigSnapshot } = await import("../../../src/config/config.js"));
({ buildTelegramMessageContextForTest } =
await import("./bot-message-context.test-harness.js"));
({ clearRuntimeConfigSnapshot, buildTelegramMessageContextForTest } =
await loadTelegramMessageContextRouteHarness());
});
beforeEach(() => {
@@ -59,7 +47,9 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889
expect(recordInboundSessionMock).toHaveBeenCalled();
// Check that updateLastRoute includes threadId
const updateLastRoute = getUpdateLastRoute() as { threadId?: string; to?: string } | undefined;
const updateLastRoute = getRecordedUpdateLastRoute(0) as
| { threadId?: string; to?: string }
| undefined;
expect(updateLastRoute).toBeDefined();
expect(updateLastRoute?.to).toBe("telegram:1234");
expect(updateLastRoute?.threadId).toBe("42");
@@ -76,7 +66,9 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889
expect(recordInboundSessionMock).toHaveBeenCalled();
// Check that updateLastRoute does NOT include threadId
const updateLastRoute = getUpdateLastRoute() as { threadId?: string; to?: string } | undefined;
const updateLastRoute = getRecordedUpdateLastRoute(0) as
| { threadId?: string; to?: string }
| undefined;
expect(updateLastRoute).toBeDefined();
expect(updateLastRoute?.to).toBe("telegram:1234");
expect(updateLastRoute?.threadId).toBeUndefined();
@@ -97,6 +89,6 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889
expect(recordInboundSessionMock).toHaveBeenCalled();
// Check that updateLastRoute is undefined for groups
expect(getUpdateLastRoute()).toBeUndefined();
expect(getRecordedUpdateLastRoute(0)).toBeUndefined();
});
});

View File

@@ -1,13 +1,9 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined);
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
import {
getRecordedUpdateLastRoute,
loadTelegramMessageContextRouteHarness,
recordInboundSessionMock,
} from "./bot-message-context.route-test-support.js";
let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
let clearRuntimeConfigSnapshot: typeof import("../../../src/config/config.js").clearRuntimeConfigSnapshot;
@@ -26,11 +22,8 @@ describe("buildTelegramMessageContext named-account DM fallback", () => {
});
beforeAll(async () => {
vi.resetModules();
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
await import("../../../src/config/config.js"));
({ buildTelegramMessageContextForTest } =
await import("./bot-message-context.test-harness.js"));
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot, buildTelegramMessageContextForTest } =
await loadTelegramMessageContextRouteHarness());
});
beforeEach(() => {
@@ -38,10 +31,7 @@ describe("buildTelegramMessageContext named-account DM fallback", () => {
});
function getLastUpdateLastRoute(): { sessionKey?: string } | undefined {
const callArgs = recordInboundSessionMock.mock.calls.at(-1)?.[0] as {
updateLastRoute?: { sessionKey?: string };
};
return callArgs?.updateLastRoute;
return getRecordedUpdateLastRoute() as { sessionKey?: string } | undefined;
}
function buildNamedAccountDmMessage(messageId = 1) {

View File

@@ -0,0 +1,43 @@
import { vi } from "vitest";
const hoisted = vi.hoisted(() => ({
recordInboundSessionMock: vi.fn().mockResolvedValue(undefined),
}));
export const recordInboundSessionMock = hoisted.recordInboundSessionMock;
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
export async function loadTelegramMessageContextRouteHarness() {
vi.resetModules();
const [
{ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot },
{ buildTelegramMessageContextForTest },
] = await Promise.all([
import("../../../src/config/config.js"),
import("./bot-message-context.test-harness.js"),
]);
return {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
buildTelegramMessageContextForTest,
};
}
export function getRecordedUpdateLastRoute(callIndex = -1): unknown {
const callArgs =
callIndex === -1
? (recordInboundSessionMock.mock.calls.at(-1)?.[0] as
| { updateLastRoute?: unknown }
| undefined)
: (recordInboundSessionMock.mock.calls[callIndex]?.[0] as
| { updateLastRoute?: unknown }
| undefined);
return callArgs?.updateLastRoute;
}

View File

@@ -46,6 +46,10 @@ vi.mock("./bot/delivery.js", () => ({
deliverReplies,
}));
vi.mock("./bot/delivery.replies.js", () => ({
deliverReplies,
}));
export async function waitForRegisteredCommands(
setMyCommands: ReturnType<typeof vi.fn>,
): Promise<RegisteredCommand[]> {
@@ -89,7 +93,7 @@ export function createNativeCommandTestParams(
counts: { block: 0, final: 0, tool: 0 },
};
const telegramDeps: TelegramBotDeps = {
loadConfig: vi.fn(() => ({}) as OpenClawConfig) as TelegramBotDeps["loadConfig"],
loadConfig: vi.fn(() => cfg) as TelegramBotDeps["loadConfig"],
resolveStorePath: vi.fn(
(storePath?: string) => storePath ?? "/tmp/sessions.json",
) as TelegramBotDeps["resolveStorePath"],

View File

@@ -8,83 +8,17 @@ import {
pluginCommandMocks,
resetPluginCommandMocks,
} from "../../../test/helpers/extensions/telegram-plugin-command.js";
import type { TelegramBotDeps } from "./bot-deps.js";
const skillCommandMocks = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn(() => []),
}));
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn(async () => ({ delivered: true })),
}));
vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/command-auth")>();
return {
...actual,
listSkillCommandsForAgents: skillCommandMocks.listSkillCommandsForAgents,
};
});
vi.mock("./bot/delivery.js", () => ({
deliverReplies: deliveryMocks.deliverReplies,
}));
vi.mock("./bot/delivery.replies.js", () => ({
deliverReplies: deliveryMocks.deliverReplies,
}));
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
import {
createNativeCommandTestParams as createNativeCommandTestParamsBase,
createNativeCommandTestParams,
createPrivateCommandContext,
deliverReplies as registeredDeliverReplies,
deliverReplies,
listSkillCommandsForAgents,
resetNativeCommandMenuMocks,
waitForRegisteredCommands,
} from "./bot-native-commands.menu-test-support.js";
function createNativeCommandTestParams(
cfg: OpenClawConfig,
params: Partial<Parameters<typeof registerTelegramNativeCommands>[0]> = {},
) {
const dispatchResult: Awaited<
ReturnType<TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"]>
> = {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
};
const telegramDeps: TelegramBotDeps = {
loadConfig: vi.fn(() => cfg) as TelegramBotDeps["loadConfig"],
resolveStorePath: vi.fn(
(storePath?: string) => storePath ?? "/tmp/sessions.json",
) as TelegramBotDeps["resolveStorePath"],
readChannelAllowFromStore: vi.fn(
async () => [],
) as TelegramBotDeps["readChannelAllowFromStore"],
upsertChannelPairingRequest: vi.fn(async () => ({
code: "PAIRCODE",
created: true,
})) as TelegramBotDeps["upsertChannelPairingRequest"],
enqueueSystemEvent: vi.fn() as TelegramBotDeps["enqueueSystemEvent"],
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async () => dispatchResult,
) as TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"],
buildModelsProviderData: vi.fn(async () => ({
byProvider: new Map<string, Set<string>>(),
providers: [],
resolvedDefault: { provider: "openai", model: "gpt-4.1" },
})) as TelegramBotDeps["buildModelsProviderData"],
listSkillCommandsForAgents: skillCommandMocks.listSkillCommandsForAgents,
wasSentByBot: vi.fn(() => false) as TelegramBotDeps["wasSentByBot"],
};
return createNativeCommandTestParamsBase(cfg, {
telegramDeps,
...params,
});
}
function resolveDeliverRepliesCalls() {
return deliveryMocks.deliverReplies.mock.calls.length > 0
? deliveryMocks.deliverReplies.mock.calls
: registeredDeliverReplies.mock.calls;
}
describe("registerTelegramNativeCommands", () => {
beforeAll(async () => {
vi.resetModules();
@@ -92,12 +26,7 @@ describe("registerTelegramNativeCommands", () => {
});
beforeEach(() => {
skillCommandMocks.listSkillCommandsForAgents.mockClear();
skillCommandMocks.listSkillCommandsForAgents.mockReturnValue([]);
deliveryMocks.deliverReplies.mockClear();
deliveryMocks.deliverReplies.mockResolvedValue({ delivered: true });
registeredDeliverReplies.mockClear();
registeredDeliverReplies.mockResolvedValue({ delivered: true });
resetNativeCommandMenuMocks();
resetPluginCommandMocks();
});
@@ -116,7 +45,7 @@ describe("registerTelegramNativeCommands", () => {
registerTelegramNativeCommands(createNativeCommandTestParams(cfg, { accountId: "bot-a" }));
expect(skillCommandMocks.listSkillCommandsForAgents).toHaveBeenCalledWith({
expect(listSkillCommandsForAgents).toHaveBeenCalledWith({
cfg,
agentIds: ["butler"],
});
@@ -131,7 +60,7 @@ describe("registerTelegramNativeCommands", () => {
registerTelegramNativeCommands(createNativeCommandTestParams(cfg, { accountId: "bot-a" }));
expect(skillCommandMocks.listSkillCommandsForAgents).toHaveBeenCalledWith({
expect(listSkillCommandsForAgents).toHaveBeenCalledWith({
cfg,
agentIds: ["main"],
});
@@ -278,7 +207,7 @@ describe("registerTelegramNativeCommands", () => {
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext());
const firstDeliverRepliesCall = resolveDeliverRepliesCalls().at(0) as [unknown] | undefined;
const firstDeliverRepliesCall = deliverReplies.mock.calls.at(0) as [unknown] | undefined;
expect(firstDeliverRepliesCall?.[0]).toEqual(
expect.objectContaining({
mediaLocalRoots: expect.arrayContaining([
@@ -333,7 +262,7 @@ describe("registerTelegramNativeCommands", () => {
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext());
const firstDeliverRepliesCall = resolveDeliverRepliesCalls().at(0) as [unknown] | undefined;
const firstDeliverRepliesCall = deliverReplies.mock.calls.at(0) as [unknown] | undefined;
expect(firstDeliverRepliesCall?.[0]).toEqual(
expect.objectContaining({
silent: true,

View File

@@ -59,6 +59,82 @@ function resolveFlushTimer(setTimeoutSpy: ReturnType<typeof vi.spyOn>) {
return flushTimer;
}
function createImageFetchSpy(params?: { body?: Uint8Array; contentType?: string }) {
return vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(Buffer.from(params?.body ?? [0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": params?.contentType ?? "image/png" },
}),
);
}
function createChannelPostContext(params: {
messageId: number;
date: number;
title?: string;
caption?: string;
text?: string;
mediaGroupId?: string;
photoFileId?: string;
getFileResult?: Record<string, unknown>;
}) {
const photoFileId = params.photoFileId;
return {
channelPost: {
chat: { id: -100777111222, type: "channel", title: params.title ?? "Wake Channel" },
message_id: params.messageId,
date: params.date,
...(params.caption ? { caption: params.caption } : {}),
...(params.text ? { text: params.text } : {}),
...(params.mediaGroupId ? { media_group_id: params.mediaGroupId } : {}),
...(photoFileId ? { photo: [{ file_id: photoFileId }] } : {}),
},
me: { username: "openclaw_bot" },
getFile: async () =>
params.getFileResult ?? (photoFileId ? { file_path: `photos/${photoFileId}.jpg` } : {}),
};
}
async function flushChannelPostMediaGroup(setTimeoutSpy: ReturnType<typeof vi.spyOn>) {
const flushTimer = resolveFlushTimer(setTimeoutSpy);
expect(flushTimer).toBeTypeOf("function");
await flushTimer?.();
}
async function queueChannelPostAlbum(
handler: ReturnType<typeof getChannelPostHandler>,
params: {
caption: string;
mediaGroupId: string;
firstMessageId: number;
secondMessageId: number;
firstPhotoFileId?: string;
secondPhotoFileId?: string;
secondGetFileResult?: Record<string, unknown>;
},
) {
const first = handler(
createChannelPostContext({
messageId: params.firstMessageId,
caption: params.caption,
date: 1736380800,
mediaGroupId: params.mediaGroupId,
photoFileId: params.firstPhotoFileId ?? "p1",
}),
);
const second = handler(
createChannelPostContext({
messageId: params.secondMessageId,
date: 1736380801,
mediaGroupId: params.mediaGroupId,
photoFileId: params.secondPhotoFileId ?? "p2",
getFileResult: params.secondGetFileResult,
}),
);
await Promise.all([first, second]);
}
describe("createTelegramBot channel_post media", () => {
beforeAll(async () => {
vi.resetModules();
@@ -84,49 +160,19 @@ describe("createTelegramBot channel_post media", () => {
it("buffers channel_post media groups and processes them together", async () => {
setOpenChannelPostConfig();
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const fetchSpy = createImageFetchSpy();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const handler = getChannelPostHandler();
const first = handler({
channelPost: {
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
message_id: 201,
caption: "album caption",
date: 1736380800,
media_group_id: "channel-album-1",
photo: [{ file_id: "p1" }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "photos/p1.jpg" }),
await queueChannelPostAlbum(handler, {
caption: "album caption",
mediaGroupId: "channel-album-1",
firstMessageId: 201,
secondMessageId: 202,
});
const second = handler({
channelPost: {
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
message_id: 202,
date: 1736380801,
media_group_id: "channel-album-1",
photo: [{ file_id: "p2" }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "photos/p2.jpg" }),
});
await Promise.all([first, second]);
expect(replySpy).not.toHaveBeenCalled();
const flushTimer = resolveFlushTimer(setTimeoutSpy);
expect(flushTimer).toBeTypeOf("function");
await flushTimer?.();
await flushChannelPostMediaGroup(setTimeoutSpy);
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0]?.[0] as { Body?: string };
@@ -184,27 +230,21 @@ describe("createTelegramBot channel_post media", () => {
it("drops oversized channel_post media instead of dispatching a placeholder message", async () => {
setOpenChannelPostConfig();
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const fetchSpy = createImageFetchSpy({
body: new Uint8Array([0xff, 0xd8, 0xff, 0x00]),
contentType: "image/jpeg",
});
createTelegramBot({ token: "tok", mediaMaxMb: 0 });
const handler = getOnHandler("channel_post") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
channelPost: {
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
message_id: 401,
await handler(
createChannelPostContext({
messageId: 401,
date: 1736380800,
photo: [{ file_id: "oversized" }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "photos/oversized.jpg" }),
});
photoFileId: "oversized",
}),
);
expect(replySpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
@@ -275,38 +315,14 @@ describe("createTelegramBot channel_post media", () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const handler = getChannelPostHandler();
const first = handler({
channelPost: {
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
message_id: 401,
caption: "partial album",
date: 1736380800,
media_group_id: "partial-album-1",
photo: [{ file_id: "p1" }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "photos/p1.jpg" }),
await queueChannelPostAlbum(handler, {
caption: "partial album",
mediaGroupId: "partial-album-1",
firstMessageId: 401,
secondMessageId: 402,
});
const second = handler({
channelPost: {
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
message_id: 402,
date: 1736380801,
media_group_id: "partial-album-1",
photo: [{ file_id: "p2" }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "photos/p2.jpg" }),
});
await Promise.all([first, second]);
expect(replySpy).not.toHaveBeenCalled();
const flushTimer = resolveFlushTimer(setTimeoutSpy);
expect(flushTimer).toBeTypeOf("function");
await flushTimer?.();
await flushChannelPostMediaGroup(setTimeoutSpy);
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0]?.[0] as { Body?: string };
@@ -321,49 +337,20 @@ describe("createTelegramBot channel_post media", () => {
replySpy.mockReset();
setOpenChannelPostConfig();
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const fetchSpy = createImageFetchSpy();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const handler = getChannelPostHandler();
const first = handler({
channelPost: {
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
message_id: 501,
caption: "fatal album",
date: 1736380800,
media_group_id: "fatal-album-1",
photo: [{ file_id: "p1" }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "photos/p1.jpg" }),
await queueChannelPostAlbum(handler, {
caption: "fatal album",
mediaGroupId: "fatal-album-1",
firstMessageId: 501,
secondMessageId: 502,
secondGetFileResult: {},
});
const second = handler({
channelPost: {
chat: { id: -100777111222, type: "channel", title: "Wake Channel" },
message_id: 502,
date: 1736380801,
media_group_id: "fatal-album-1",
photo: [{ file_id: "p2" }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
});
await Promise.all([first, second]);
expect(replySpy).not.toHaveBeenCalled();
const flushTimer = resolveFlushTimer(setTimeoutSpy);
expect(flushTimer).toBeTypeOf("function");
await flushTimer?.();
await flushChannelPostMediaGroup(setTimeoutSpy);
expect(replySpy).not.toHaveBeenCalled();
} finally {

View File

@@ -47,6 +47,35 @@ function requireEvent<T>(event: T | undefined, message: string): T {
return event;
}
type TwilioApiRequest = (
endpoint: string,
params: Record<string, string | string[]>,
options?: { allowNotFound?: boolean },
) => Promise<unknown>;
function createApiRequestMock() {
return vi.fn<TwilioApiRequest>(async () => ({}));
}
function configureTelephonyTwiMlFallback(params: { providerCallId: string; streamSid?: string }) {
const provider = createProvider();
const apiRequest = createApiRequestMock();
(
provider as unknown as {
apiRequest: TwilioApiRequest;
}
).apiRequest = apiRequest;
(
provider as unknown as {
callWebhookUrls: Map<string, string>;
}
).callWebhookUrls.set(params.providerCallId, "https://example.ngrok.app/voice/twilio");
if (params.streamSid) {
provider.registerCallStream(params.providerCallId, params.streamSid);
}
return { provider, apiRequest };
}
describe("TwilioProvider", () => {
it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
const provider = createProvider();
@@ -216,29 +245,10 @@ describe("TwilioProvider", () => {
});
it("fails when an active stream exists but telephony TTS is unavailable", async () => {
const provider = createProvider();
const apiRequest = vi.fn<
(
endpoint: string,
params: Record<string, string | string[]>,
options?: { allowNotFound?: boolean },
) => Promise<unknown>
>(async () => ({}));
(
provider as unknown as {
apiRequest: (
endpoint: string,
params: Record<string, string | string[]>,
options?: { allowNotFound?: boolean },
) => Promise<unknown>;
}
).apiRequest = apiRequest;
(
provider as unknown as {
callWebhookUrls: Map<string, string>;
}
).callWebhookUrls.set("CA-stream", "https://example.ngrok.app/voice/twilio");
provider.registerCallStream("CA-stream", "MZ-stream");
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-stream",
streamSid: "MZ-stream",
});
await expect(
provider.playTts({
@@ -251,28 +261,9 @@ describe("TwilioProvider", () => {
});
it("falls back to TwiML when no active stream exists and telephony TTS is unavailable", async () => {
const provider = createProvider();
const apiRequest = vi.fn<
(
endpoint: string,
params: Record<string, string | string[]>,
options?: { allowNotFound?: boolean },
) => Promise<unknown>
>(async () => ({}));
(
provider as unknown as {
apiRequest: (
endpoint: string,
params: Record<string, string | string[]>,
options?: { allowNotFound?: boolean },
) => Promise<unknown>;
}
).apiRequest = apiRequest;
(
provider as unknown as {
callWebhookUrls: Map<string, string>;
}
).callWebhookUrls.set("CA-nostream", "https://example.ngrok.app/voice/twilio");
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-nostream",
});
await expect(
provider.playTts({

View File

@@ -50,6 +50,40 @@ async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string,
});
}
async function runDuplicateInboundReplayLifecycleTest(provider: FakeProvider) {
const config = createConfig();
const manager = new CallManager(config, createTestStorePath());
await manager.initialize(provider, "https://example.com/voice/webhook");
const server = new VoiceCallWebhookServer(config, manager, provider);
try {
const baseUrl = await server.start();
const first = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222");
const second = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222");
return { first, second, manager };
} finally {
await server.stop();
}
}
function expectSingleRejectedReplayHangup(params: {
first: Response;
second: Response;
provider: FakeProvider;
manager: CallManager;
}) {
expect(params.first.status).toBe(200);
expect(params.second.status).toBe(200);
expect(params.provider.hangupCalls).toHaveLength(1);
expect(params.provider.hangupCalls[0]).toEqual(
expect.objectContaining({
providerCallId: "provider-inbound-1",
reason: "hangup-bot",
}),
);
expect(params.manager.getCallByProviderCallId("provider-inbound-1")).toBeUndefined();
}
class RejectInboundReplayProvider extends FakeProvider {
override verifyWebhook() {
return { ok: true, verifiedRequestKey: "verified:req:reject-once" };
@@ -89,55 +123,13 @@ describe("Voice-call webhook hangup-once lifecycle", () => {
it("hangs up a rejected inbound replay only once across duplicate webhook delivery", async () => {
const provider = new RejectInboundReplayProvider("plivo");
const config = createConfig();
const manager = new CallManager(config, createTestStorePath());
await manager.initialize(provider, "https://example.com/voice/webhook");
const server = new VoiceCallWebhookServer(config, manager, provider);
try {
const baseUrl = await server.start();
const first = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222");
const second = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222");
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(provider.hangupCalls).toHaveLength(1);
expect(provider.hangupCalls[0]).toEqual(
expect.objectContaining({
providerCallId: "provider-inbound-1",
reason: "hangup-bot",
}),
);
expect(manager.getCallByProviderCallId("provider-inbound-1")).toBeUndefined();
} finally {
await server.stop();
}
const { first, second, manager } = await runDuplicateInboundReplayLifecycleTest(provider);
expectSingleRejectedReplayHangup({ first, second, provider, manager });
});
it("does not attempt a second hangup when replay arrives after the first hangup fails", async () => {
const provider = new RejectInboundReplayWithHangupFailureProvider("plivo");
const config = createConfig();
const manager = new CallManager(config, createTestStorePath());
await manager.initialize(provider, "https://example.com/voice/webhook");
const server = new VoiceCallWebhookServer(config, manager, provider);
try {
const baseUrl = await server.start();
const first = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222");
const second = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222");
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(provider.hangupCalls).toHaveLength(1);
expect(provider.hangupCalls[0]).toEqual(
expect.objectContaining({
providerCallId: "provider-inbound-1",
reason: "hangup-bot",
}),
);
expect(manager.getCallByProviderCallId("provider-inbound-1")).toBeUndefined();
} finally {
await server.stop();
}
const { first, second, manager } = await runDuplicateInboundReplayLifecycleTest(provider);
expectSingleRejectedReplayHangup({ first, second, provider, manager });
});
});

View File

@@ -82,6 +82,48 @@ export const sessionMessages: unknown[] = [
export const sessionAbortCompactionMock: Mock<(reason?: unknown) => void> = vi.fn();
export const createOpenClawCodingToolsMock = vi.fn(() => []);
export function resetCompactSessionStateMocks(): void {
sanitizeSessionHistoryMock.mockReset();
sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => {
return params.messages;
});
getMemorySearchManagerMock.mockReset();
getMemorySearchManagerMock.mockResolvedValue({
manager: {
sync: vi.fn(async () => {}),
},
});
resolveMemorySearchConfigMock.mockReset();
resolveMemorySearchConfigMock.mockReturnValue({
sources: ["sessions"],
sync: {
sessions: {
postCompactionForce: true,
},
},
});
resolveSessionAgentIdMock.mockReset();
resolveSessionAgentIdMock.mockReturnValue("main");
estimateTokensMock.mockReset();
estimateTokensMock.mockReturnValue(10);
sessionMessages.splice(
0,
sessionMessages.length,
{ role: "user", content: "hello", timestamp: 1 },
{ role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 },
{
role: "toolResult",
toolCallId: "t1",
toolName: "exec",
content: [{ type: "text", text: "output" }],
isError: false,
timestamp: 3,
},
);
sessionAbortCompactionMock.mockReset();
}
export function resetCompactHooksHarnessMocks(): void {
hookRunner.hasHooks.mockReset();
hookRunner.hasHooks.mockReturnValue(false);
@@ -122,45 +164,7 @@ export function resetCompactHooksHarnessMocks(): void {
});
triggerInternalHook.mockReset();
sanitizeSessionHistoryMock.mockReset();
sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => {
return params.messages;
});
getMemorySearchManagerMock.mockReset();
getMemorySearchManagerMock.mockResolvedValue({
manager: {
sync: vi.fn(async () => {}),
},
});
resolveMemorySearchConfigMock.mockReset();
resolveMemorySearchConfigMock.mockReturnValue({
sources: ["sessions"],
sync: {
sessions: {
postCompactionForce: true,
},
},
});
resolveSessionAgentIdMock.mockReset();
resolveSessionAgentIdMock.mockReturnValue("main");
estimateTokensMock.mockReset();
estimateTokensMock.mockReturnValue(10);
sessionMessages.splice(
0,
sessionMessages.length,
{ role: "user", content: "hello", timestamp: 1 },
{ role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 },
{
role: "toolResult",
toolCallId: "t1",
toolName: "exec",
content: [{ type: "text", text: "output" }],
isError: false,
timestamp: 3,
},
);
sessionAbortCompactionMock.mockReset();
resetCompactSessionStateMocks();
createOpenClawCodingToolsMock.mockReset();
createOpenClawCodingToolsMock.mockReturnValue([]);
}

View File

@@ -14,7 +14,7 @@ import {
resolveModelMock,
resolveSessionAgentIdMock,
resetCompactHooksHarnessMocks,
sanitizeSessionHistoryMock,
resetCompactSessionStateMocks,
sessionAbortCompactionMock,
sessionMessages,
sessionCompactImpl,
@@ -122,44 +122,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
tokensBefore: 120,
details: { ok: true },
});
sanitizeSessionHistoryMock.mockReset();
sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => {
return params.messages;
});
getMemorySearchManagerMock.mockReset();
getMemorySearchManagerMock.mockResolvedValue({
manager: {
sync: vi.fn(async () => {}),
},
});
resolveMemorySearchConfigMock.mockReset();
resolveMemorySearchConfigMock.mockReturnValue({
sources: ["sessions"],
sync: {
sessions: {
postCompactionForce: true,
},
},
});
resolveSessionAgentIdMock.mockReset();
resolveSessionAgentIdMock.mockReturnValue("main");
estimateTokensMock.mockReset();
estimateTokensMock.mockReturnValue(10);
sessionAbortCompactionMock.mockReset();
sessionMessages.splice(
0,
sessionMessages.length,
{ role: "user", content: "hello", timestamp: 1 },
{ role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 },
{
role: "toolResult",
toolCallId: "t1",
toolName: "exec",
content: [{ type: "text", text: "output" }],
isError: false,
timestamp: 3,
},
);
resetCompactSessionStateMocks();
unregisterApiProviders(getCustomApiRegistrySourceId("ollama"));
});

View File

@@ -1,17 +1,11 @@
import type { Model } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { createPiAiStreamSimpleMock } from "./extra-params.pi-ai-mock.js";
import { runExtraParamsCase } from "./extra-params.test-support.js";
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
return {
...original,
streamSimple: vi.fn(() => ({
push: vi.fn(),
result: vi.fn(),
})),
};
});
vi.mock("@mariozechner/pi-ai", async (importOriginal) =>
createPiAiStreamSimpleMock(() => importOriginal<typeof import("@mariozechner/pi-ai")>()),
);
describe("extra-params: Google thinking payload compatibility", () => {
it("strips negative thinking budgets and fills Gemini 3.1 thinkingLevel", () => {

View File

@@ -0,0 +1,16 @@
import { vi } from "vitest";
type PiAiMockModule = Record<string, unknown>;
export async function createPiAiStreamSimpleMock(
importOriginal: () => Promise<PiAiMockModule>,
): Promise<PiAiMockModule> {
const original = await importOriginal();
return {
...original,
streamSimple: vi.fn(() => ({
push: vi.fn(),
result: vi.fn(),
})),
};
}

View File

@@ -1,17 +1,11 @@
import type { Model } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { createPiAiStreamSimpleMock } from "./extra-params.pi-ai-mock.js";
import { runExtraParamsCase } from "./extra-params.test-support.js";
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
return {
...original,
streamSimple: vi.fn(() => ({
push: vi.fn(),
result: vi.fn(),
})),
};
});
vi.mock("@mariozechner/pi-ai", async (importOriginal) =>
createPiAiStreamSimpleMock(() => importOriginal<typeof import("@mariozechner/pi-ai")>()),
);
describe("extra-params: xAI tool payload compatibility", () => {
it("strips function.strict for xai providers", () => {

View File

@@ -1,19 +1,12 @@
import type { Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { createPiAiStreamSimpleMock } from "./extra-params.pi-ai-mock.js";
import { runExtraParamsCase } from "./extra-params.test-support.js";
// Mock streamSimple for testing
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
return {
...original,
streamSimple: vi.fn(() => ({
push: vi.fn(),
result: vi.fn(),
})),
};
});
vi.mock("@mariozechner/pi-ai", async (importOriginal) =>
createPiAiStreamSimpleMock(() => importOriginal<typeof import("@mariozechner/pi-ai")>()),
);
type ToolStreamCase = {
applyProvider: string;

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelProviderConfig } from "../../config/config.js";
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
vi.mock("../pi-model-discovery.js", () => ({
@@ -40,6 +41,37 @@ function resolveModelForTest(
});
}
function createAnthropicTemplateModel() {
return {
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 200000,
maxTokens: 64000,
};
}
function resolveAnthropicModelWithProviderOverrides(overrides: Partial<ModelProviderConfig>) {
mockDiscoveredModel({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
templateModel: createAnthropicTemplateModel(),
});
return resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent", {
models: {
providers: {
anthropic: overrides,
},
},
} as unknown as OpenClawConfig);
}
describe("resolveModel forward-compat errors and overrides", () => {
it("resolves supported antigravity thinking model ids", () => {
expectResolvedForwardCompatFallbackResult({
@@ -234,67 +266,17 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
it("applies provider baseUrl override to registry-found models", () => {
mockDiscoveredModel({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
templateModel: {
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 200000,
maxTokens: 64000,
},
const result = resolveAnthropicModelWithProviderOverrides({
baseUrl: "https://my-proxy.example.com",
});
const cfg = {
models: {
providers: {
anthropic: {
baseUrl: "https://my-proxy.example.com",
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model?.baseUrl).toBe("https://my-proxy.example.com");
});
it("applies provider headers override to registry-found models", () => {
mockDiscoveredModel({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
templateModel: {
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 200000,
maxTokens: 64000,
},
const result = resolveAnthropicModelWithProviderOverrides({
headers: { "X-Custom-Auth": "token-123" },
});
const cfg = {
models: {
providers: {
anthropic: {
headers: { "X-Custom-Auth": "token-123" },
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
"X-Custom-Auth": "token-123",

View File

@@ -4,7 +4,7 @@ export function buildForwardCompatTemplate(params: {
id: string;
name: string;
provider: string;
api: "anthropic-messages" | "openai-completions" | "openai-responses";
api: "anthropic-messages" | "google-gemini-cli" | "openai-completions" | "openai-responses";
baseUrl: string;
reasoning?: boolean;
input?: readonly ["text"] | readonly ["text", "image"];

View File

@@ -22,6 +22,7 @@ vi.mock("./openrouter-model-capabilities.js", () => ({
}));
import type { OpenClawConfig } from "../../config/config.js";
import { buildForwardCompatTemplate } from "./model.forward-compat.test-support.js";
import { buildInlineProviderModels, resolveModel, resolveModelAsync } from "./model.js";
import {
buildOpenAICodexForwardCompatExpectation,
@@ -87,32 +88,6 @@ function resolveModelAsyncForTest(
});
}
function buildForwardCompatTemplate(params: {
id: string;
name: string;
provider: string;
api: "anthropic-messages" | "google-gemini-cli" | "openai-completions" | "openai-responses";
baseUrl: string;
reasoning?: boolean;
input?: readonly ["text"] | readonly ["text", "image"];
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow?: number;
maxTokens?: number;
}) {
return {
id: params.id,
name: params.name,
provider: params.provider,
api: params.api,
baseUrl: params.baseUrl,
reasoning: params.reasoning ?? true,
input: params.input ?? (["text", "image"] as const),
cost: params.cost ?? { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: params.contextWindow ?? 200000,
maxTokens: params.maxTokens ?? 64000,
};
}
describe("buildInlineProviderModels", () => {
it("attaches provider ids to inline models", () => {
const providers: Parameters<typeof buildInlineProviderModels>[0] = {

View File

@@ -10,6 +10,13 @@ type SpawnCall = {
args: string[];
};
type MockDockerChild = EventEmitter & {
stdout: Readable;
stderr: Readable;
stdin: { end: (input?: string | Buffer) => void };
kill: (signal?: NodeJS.Signals) => void;
};
const spawnState = vi.hoisted(() => ({
calls: [] as SpawnCall[],
inspectRunning: true,
@@ -26,62 +33,70 @@ vi.mock("./registry.js", () => ({
updateRegistry: registryMocks.updateRegistry,
}));
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
function createMockDockerChild(): MockDockerChild {
const child = new EventEmitter() as MockDockerChild;
child.stdout = new Readable({ read() {} });
child.stderr = new Readable({ read() {} });
child.stdin = { end: () => undefined };
child.kill = () => undefined;
return child;
}
function spawnDockerProcess(command: string, args: string[]) {
spawnState.calls.push({ command, args });
const child = createMockDockerChild();
let code = 0;
let stdout = "";
let stderr = "";
if (command !== "docker") {
code = 1;
stderr = `unexpected command: ${command}`;
} else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") {
stdout = spawnState.inspectRunning ? "true\n" : "false\n";
} else if (
args[0] === "inspect" &&
args[1] === "-f" &&
args[2]?.includes('index .Config.Labels "openclaw.configHash"')
) {
stdout = `${spawnState.labelHash}\n`;
} else if (
(args[0] === "rm" && args[1] === "-f") ||
(args[0] === "image" && args[1] === "inspect") ||
args[0] === "create" ||
args[0] === "start"
) {
code = 0;
} else {
code = 1;
stderr = `unexpected docker args: ${args.join(" ")}`;
}
queueMicrotask(() => {
if (stdout) {
child.stdout.emit("data", Buffer.from(stdout));
}
if (stderr) {
child.stderr.emit("data", Buffer.from(stderr));
}
child.emit("close", code);
});
return child;
}
async function createChildProcessMock(
importOriginal: () => Promise<typeof import("node:child_process")>,
) {
const actual = await importOriginal();
return {
...actual,
spawn: (command: string, args: string[]) => {
spawnState.calls.push({ command, args });
const child = new EventEmitter() as EventEmitter & {
stdout: Readable;
stderr: Readable;
stdin: { end: (input?: string | Buffer) => void };
kill: (signal?: NodeJS.Signals) => void;
};
child.stdout = new Readable({ read() {} });
child.stderr = new Readable({ read() {} });
child.stdin = { end: () => undefined };
child.kill = () => undefined;
let code = 0;
let stdout = "";
let stderr = "";
if (command !== "docker") {
code = 1;
stderr = `unexpected command: ${command}`;
} else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") {
stdout = spawnState.inspectRunning ? "true\n" : "false\n";
} else if (
args[0] === "inspect" &&
args[1] === "-f" &&
args[2]?.includes('index .Config.Labels "openclaw.configHash"')
) {
stdout = `${spawnState.labelHash}\n`;
} else if (
(args[0] === "rm" && args[1] === "-f") ||
(args[0] === "image" && args[1] === "inspect") ||
args[0] === "create" ||
args[0] === "start"
) {
code = 0;
} else {
code = 1;
stderr = `unexpected docker args: ${args.join(" ")}`;
}
queueMicrotask(() => {
if (stdout) {
child.stdout.emit("data", Buffer.from(stdout));
}
if (stderr) {
child.stderr.emit("data", Buffer.from(stderr));
}
child.emit("close", code);
});
return child;
},
spawn: spawnDockerProcess,
};
});
}
vi.mock("node:child_process", async (importOriginal) =>
createChildProcessMock(() => importOriginal<typeof import("node:child_process")>()),
);
let ensureSandboxContainer: typeof import("./docker.js").ensureSandboxContainer;
@@ -91,62 +106,9 @@ async function loadFreshDockerModuleForTest() {
readRegistry: registryMocks.readRegistry,
updateRegistry: registryMocks.updateRegistry,
}));
vi.doMock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
spawn: (command: string, args: string[]) => {
spawnState.calls.push({ command, args });
const child = new EventEmitter() as EventEmitter & {
stdout: Readable;
stderr: Readable;
stdin: { end: (input?: string | Buffer) => void };
kill: (signal?: NodeJS.Signals) => void;
};
child.stdout = new Readable({ read() {} });
child.stderr = new Readable({ read() {} });
child.stdin = { end: () => undefined };
child.kill = () => undefined;
let code = 0;
let stdout = "";
let stderr = "";
if (command !== "docker") {
code = 1;
stderr = `unexpected command: ${command}`;
} else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") {
stdout = spawnState.inspectRunning ? "true\n" : "false\n";
} else if (
args[0] === "inspect" &&
args[1] === "-f" &&
args[2]?.includes('index .Config.Labels "openclaw.configHash"')
) {
stdout = `${spawnState.labelHash}\n`;
} else if (
(args[0] === "rm" && args[1] === "-f") ||
(args[0] === "image" && args[1] === "inspect") ||
args[0] === "create" ||
args[0] === "start"
) {
code = 0;
} else {
code = 1;
stderr = `unexpected docker args: ${args.join(" ")}`;
}
queueMicrotask(() => {
if (stdout) {
child.stdout.emit("data", Buffer.from(stdout));
}
if (stderr) {
child.stderr.emit("data", Buffer.from(stderr));
}
child.emit("close", code);
});
return child;
},
};
});
vi.doMock("node:child_process", async (importOriginal) =>
createChildProcessMock(() => importOriginal<typeof import("node:child_process")>()),
);
({ ensureSandboxContainer } = await import("./docker.js"));
}

View File

@@ -2,6 +2,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { SandboxConfig } from "./types.js";
const sshMocks = vi.hoisted(() => ({
createSshSandboxSessionFromSettings: vi.fn(),
@@ -72,6 +73,69 @@ function createSession() {
};
}
function createBackendSandboxConfig(params?: { binds?: string[]; target?: string }): SandboxConfig {
return {
mode: "all",
backend: "ssh",
scope: "session",
workspaceAccess: "rw" as const,
workspaceRoot: "~/.openclaw/sandboxes",
docker: {
image: "img",
containerPrefix: "prefix-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
capDrop: ["ALL"],
env: {},
...(params?.binds ? { binds: params.binds } : {}),
},
ssh: {
...(params?.target ? { target: params.target } : {}),
command: "ssh",
workspaceRoot: "/remote/openclaw",
strictHostKeyChecking: true,
updateHostKeys: true,
},
browser: {
enabled: false,
image: "img",
containerPrefix: "prefix-",
network: "bridge",
cdpPort: 1,
vncPort: 2,
noVncPort: 3,
headless: true,
enableNoVnc: false,
allowHostControl: false,
autoStart: false,
autoStartTimeoutMs: 1,
},
tools: { allow: [], deny: [] },
prune: { idleHours: 24, maxAgeDays: 7 },
};
}
async function expectBackendCreationToReject(params: {
binds?: string[];
target?: string;
error: string;
}) {
await expect(
createSshSandboxBackend({
sessionKey: "s",
scopeKey: "s",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: createBackendSandboxConfig({
binds: params.binds,
target: params.target,
}),
}),
).rejects.toThrow(params.error);
}
describe("ssh sandbox backend", () => {
beforeEach(async () => {
vi.clearAllMocks();
@@ -255,102 +319,14 @@ describe("ssh sandbox backend", () => {
});
it("rejects docker binds and missing ssh target", async () => {
await expect(
createSshSandboxBackend({
sessionKey: "s",
scopeKey: "s",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: {
mode: "all",
backend: "ssh",
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "~/.openclaw/sandboxes",
docker: {
image: "img",
containerPrefix: "prefix-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
capDrop: ["ALL"],
env: {},
binds: ["/tmp:/tmp:rw"],
},
ssh: {
target: "peter@example.com:22",
command: "ssh",
workspaceRoot: "/remote/openclaw",
strictHostKeyChecking: true,
updateHostKeys: true,
},
browser: {
enabled: false,
image: "img",
containerPrefix: "prefix-",
network: "bridge",
cdpPort: 1,
vncPort: 2,
noVncPort: 3,
headless: true,
enableNoVnc: false,
allowHostControl: false,
autoStart: false,
autoStartTimeoutMs: 1,
},
tools: { allow: [], deny: [] },
prune: { idleHours: 24, maxAgeDays: 7 },
},
}),
).rejects.toThrow("does not support sandbox.docker.binds");
await expectBackendCreationToReject({
binds: ["/tmp:/tmp:rw"],
target: "peter@example.com:22",
error: "does not support sandbox.docker.binds",
});
await expect(
createSshSandboxBackend({
sessionKey: "s",
scopeKey: "s",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: {
mode: "all",
backend: "ssh",
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "~/.openclaw/sandboxes",
docker: {
image: "img",
containerPrefix: "prefix-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
capDrop: ["ALL"],
env: {},
},
ssh: {
command: "ssh",
workspaceRoot: "/remote/openclaw",
strictHostKeyChecking: true,
updateHostKeys: true,
},
browser: {
enabled: false,
image: "img",
containerPrefix: "prefix-",
network: "bridge",
cdpPort: 1,
vncPort: 2,
noVncPort: 3,
headless: true,
enableNoVnc: false,
allowHostControl: false,
autoStart: false,
autoStartTimeoutMs: 1,
},
tools: { allow: [], deny: [] },
prune: { idleHours: 24, maxAgeDays: 7 },
},
}),
).rejects.toThrow("requires agents.defaults.sandbox.ssh.target");
await expectBackendCreationToReject({
error: "requires agents.defaults.sandbox.ssh.target",
});
});
});

View File

@@ -8,6 +8,50 @@ vi.mock("../chrome-mcp.js", () => ({
let registerBrowserBasicRoutes: typeof import("./basic.js").registerBrowserBasicRoutes;
let BrowserProfileUnavailableError: typeof import("../errors.js").BrowserProfileUnavailableError;
function createExistingSessionProfileState(params?: { isHttpReachable?: () => Promise<boolean> }) {
return {
resolved: {
enabled: true,
headless: false,
noSandbox: false,
executablePath: undefined,
},
profiles: new Map(),
forProfile: () =>
({
profile: {
name: "chrome-live",
driver: "existing-session",
cdpPort: 0,
cdpUrl: "",
userDataDir: "/tmp/brave-profile",
color: "#00AA00",
attachOnly: true,
},
isHttpReachable: params?.isHttpReachable ?? (async () => true),
isReachable: async () => true,
}) as never,
};
}
async function callBasicRouteWithState(params: {
query?: Record<string, string>;
state: ReturnType<typeof createExistingSessionProfileState>;
}) {
const { app, getHandlers } = createBrowserRouteApp();
registerBrowserBasicRoutes(app, {
state: () => params.state,
forProfile: params.state.forProfile,
} as never);
const handler = getHandlers.get("/");
expect(handler).toBeTypeOf("function");
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: params.query ?? { profile: "chrome-live" } }, response.res);
return response;
}
beforeEach(async () => {
vi.resetModules();
({ BrowserProfileUnavailableError } = await import("../errors.js"));
@@ -16,78 +60,22 @@ beforeEach(async () => {
describe("basic browser routes", () => {
it("maps existing-session status failures to JSON browser errors", async () => {
const { app, getHandlers } = createBrowserRouteApp();
registerBrowserBasicRoutes(app, {
state: () => ({
resolved: {
enabled: true,
headless: false,
noSandbox: false,
executablePath: undefined,
const response = await callBasicRouteWithState({
state: createExistingSessionProfileState({
isHttpReachable: async () => {
throw new BrowserProfileUnavailableError("attach failed");
},
profiles: new Map(),
}),
forProfile: () =>
({
profile: {
name: "chrome-live",
driver: "existing-session",
cdpPort: 0,
cdpUrl: "",
userDataDir: "/tmp/brave-profile",
color: "#00AA00",
attachOnly: true,
},
isHttpReachable: async () => {
throw new BrowserProfileUnavailableError("attach failed");
},
isReachable: async () => true,
}) as never,
} as never);
const handler = getHandlers.get("/");
expect(handler).toBeTypeOf("function");
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { profile: "chrome-live" } }, response.res);
});
expect(response.statusCode).toBe(409);
expect(response.body).toMatchObject({ error: "attach failed" });
});
it("reports Chrome MCP transport without fake CDP fields", async () => {
const { app, getHandlers } = createBrowserRouteApp();
registerBrowserBasicRoutes(app, {
state: () => ({
resolved: {
enabled: true,
headless: false,
noSandbox: false,
executablePath: undefined,
},
profiles: new Map(),
}),
forProfile: () =>
({
profile: {
name: "chrome-live",
driver: "existing-session",
cdpPort: 0,
cdpUrl: "",
userDataDir: "/tmp/brave-profile",
color: "#00AA00",
attachOnly: true,
},
isHttpReachable: async () => true,
isReachable: async () => true,
}) as never,
} as never);
const handler = getHandlers.get("/");
expect(handler).toBeTypeOf("function");
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { profile: "chrome-live" } }, response.res);
const response = await callBasicRouteWithState({
state: createExistingSessionProfileState(),
});
expect(response.statusCode).toBe(200);
expect(response.body).toMatchObject({

View File

@@ -7,7 +7,7 @@ import type {
} from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
type TestChannelRegistration = {
export type TestChannelRegistration = {
pluginId: string;
plugin: unknown;
source: string;

View File

@@ -176,6 +176,59 @@ function expectFirstOnboardingInstallPlanCallOmitsToken() {
expect(firstArg && "token" in firstArg).toBe(false);
}
type AdvancedFinalizeArgs = {
nextConfig?: OpenClawConfig;
prompter?: ReturnType<typeof buildWizardPrompter>;
runtime?: RuntimeEnv;
installDaemon?: boolean;
};
function createLaterPrompter() {
return buildWizardPrompter({
select: vi.fn(async () => "later") as never,
confirm: vi.fn(async () => false),
});
}
function createEnabledFirecrawlSearchConfig(): OpenClawConfig {
return {
tools: {
web: {
search: {
provider: "firecrawl",
enabled: true,
},
},
},
};
}
function createAdvancedFinalizeArgs(params: AdvancedFinalizeArgs = {}) {
return {
flow: "advanced" as const,
opts: {
acceptRisk: true,
authChoice: "skip" as const,
installDaemon: params.installDaemon ?? false,
skipHealth: true,
skipUi: true,
},
baseConfig: {},
nextConfig: params.nextConfig ?? {},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback" as const,
authMode: "token" as const,
gatewayToken: undefined,
tailscaleMode: "off" as const,
tailscaleResetOnExit: false,
},
prompter: params.prompter ?? createLaterPrompter(),
runtime: params.runtime ?? createRuntime(),
};
}
describe("finalizeSetupWizard", () => {
beforeEach(() => {
runTui.mockClear();
@@ -381,43 +434,14 @@ describe("finalizeSetupWizard", () => {
});
it("reports selected providers blocked by plugin policy as unavailable", async () => {
const prompter = buildWizardPrompter({
select: vi.fn(async () => "later") as never,
confirm: vi.fn(async () => false),
});
const prompter = createLaterPrompter();
await finalizeSetupWizard({
flow: "advanced",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: false,
skipHealth: true,
skipUi: true,
},
baseConfig: {},
nextConfig: {
tools: {
web: {
search: {
provider: "firecrawl",
enabled: true,
},
},
},
},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback",
authMode: "token",
gatewayToken: undefined,
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime: createRuntime(),
});
await finalizeSetupWizard(
createAdvancedFinalizeArgs({
nextConfig: createEnabledFirecrawlSearchConfig(),
prompter,
}),
);
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("selected but unavailable under the current plugin policy"),
@@ -441,34 +465,9 @@ describe("finalizeSetupWizard", () => {
]);
hasExistingKey.mockImplementation((_config, provider) => provider === "perplexity");
const prompter = buildWizardPrompter({
select: vi.fn(async () => "later") as never,
confirm: vi.fn(async () => false),
});
const prompter = createLaterPrompter();
await finalizeSetupWizard({
flow: "advanced",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: false,
skipHealth: true,
skipUi: true,
},
baseConfig: {},
nextConfig: {},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback",
authMode: "token",
gatewayToken: undefined,
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime: createRuntime(),
});
await finalizeSetupWizard(createAdvancedFinalizeArgs({ prompter }));
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("Web search is available via Perplexity Search (auto-detected)."),
@@ -490,43 +489,14 @@ describe("finalizeSetupWizard", () => {
]);
hasExistingKey.mockImplementation((_config, provider) => provider === "firecrawl");
const prompter = buildWizardPrompter({
select: vi.fn(async () => "later") as never,
confirm: vi.fn(async () => false),
});
const prompter = createLaterPrompter();
await finalizeSetupWizard({
flow: "advanced",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: false,
skipHealth: true,
skipUi: true,
},
baseConfig: {},
nextConfig: {
tools: {
web: {
search: {
provider: "firecrawl",
enabled: true,
},
},
},
},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback",
authMode: "token",
gatewayToken: undefined,
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime: createRuntime(),
});
await finalizeSetupWizard(
createAdvancedFinalizeArgs({
nextConfig: createEnabledFirecrawlSearchConfig(),
prompter,
}),
);
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining(

View File

@@ -59,6 +59,7 @@ import type { OpenClawConfig } from "../src/config/config.js";
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
import { installProcessWarningFilter } from "../src/infra/warning-filter.js";
import type { PluginRegistry } from "../src/plugins/registry.js";
import { createTestRegistry } from "../src/test-utils/channel-plugins.js";
import { cleanupSessionStateForTest } from "../src/test-utils/session-state-cleanup.js";
import { withIsolatedTestHome } from "./test-env.js";
@@ -77,12 +78,6 @@ type RegistryState = {
version: number;
};
type TestChannelRegistration = {
pluginId: string;
plugin: unknown;
source: string;
};
const globalRegistryState = (() => {
const globalState = globalThis as typeof globalThis & {
[REGISTRY_STATE]?: RegistryState;
@@ -224,32 +219,6 @@ const createStubPlugin = (params: {
outbound: createStubOutbound(params.id, params.deliveryMode),
});
const createTestRegistry = (channels: TestChannelRegistration[] = []): PluginRegistry => ({
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: channels as unknown as PluginRegistry["channels"],
channelSetups: channels.map((entry) => ({
pluginId: entry.pluginId,
plugin: entry.plugin as PluginRegistry["channelSetups"][number]["plugin"],
source: entry.source,
enabled: true,
})),
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
});
const createDefaultRegistry = () =>
createTestRegistry([
{