mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
refactor: dedupe test helpers and harnesses
This commit is contained in:
@@ -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({
|
||||
|
||||
18
extensions/matrix/src/matrix/async-lock.ts
Normal file
18
extensions/matrix/src/matrix/async-lock.ts
Normal 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?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
16
src/agents/pi-embedded-runner/extra-params.pi-ai-mock.ts
Normal file
16
src/agents/pi-embedded-runner/extra-params.pi-ai-mock.ts
Normal 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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user