mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(test): stabilize e2e runtime imports
This commit is contained in:
@@ -44,6 +44,53 @@ function downloadRequest(
|
||||
return request as { filePathHint?: string; url?: string };
|
||||
}
|
||||
|
||||
type ScheduledTimer = {
|
||||
callback: () => unknown;
|
||||
handle: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
function resolveActiveScheduledTimersForDelay(
|
||||
setTimeoutSpy: ReturnType<typeof vi.spyOn>,
|
||||
clearTimeoutSpy: ReturnType<typeof vi.spyOn>,
|
||||
delayMs: number,
|
||||
): ScheduledTimer[] {
|
||||
const clearedHandles = new Set(
|
||||
(clearTimeoutSpy.mock.calls as Array<Parameters<typeof clearTimeout>>).map(
|
||||
([handle]) => handle,
|
||||
),
|
||||
);
|
||||
return (setTimeoutSpy.mock.calls as Array<Parameters<typeof setTimeout>>).flatMap(
|
||||
(call, index) => {
|
||||
if (call[1] !== delayMs) {
|
||||
return [];
|
||||
}
|
||||
const handle = setTimeoutSpy.mock.results[index]?.value as ReturnType<typeof setTimeout>;
|
||||
if (clearedHandles.has(handle) || typeof call[0] !== "function") {
|
||||
return [];
|
||||
}
|
||||
return [{ callback: call[0] as () => unknown, handle }];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function flushActiveScheduledTimersForDelay(params: {
|
||||
setTimeoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
clearTimeoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
delayMs: number;
|
||||
expectedCount: number;
|
||||
}) {
|
||||
const timers = resolveActiveScheduledTimersForDelay(
|
||||
params.setTimeoutSpy,
|
||||
params.clearTimeoutSpy,
|
||||
params.delayMs,
|
||||
);
|
||||
expect(timers).toHaveLength(params.expectedCount);
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer.handle);
|
||||
await timer.callback();
|
||||
}
|
||||
}
|
||||
|
||||
describe("telegram inbound media", () => {
|
||||
// Parallel vitest shards can make this suite slower than the standalone run.
|
||||
const INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 90_000;
|
||||
@@ -346,6 +393,13 @@ describe("telegram media groups", () => {
|
||||
const runtimeError = vi.fn();
|
||||
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
|
||||
const fetchSpy = mockTelegramPngDownload();
|
||||
let nextTimerHandle = 1;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation(() => {
|
||||
const handle = nextTimerHandle;
|
||||
nextTimerHandle += 1;
|
||||
return handle as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
|
||||
try {
|
||||
for (const scenario of [
|
||||
@@ -354,7 +408,7 @@ describe("telegram media groups", () => {
|
||||
{
|
||||
chat: { id: 42, type: "private" as const },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 1,
|
||||
message_id: 101,
|
||||
caption: "Here are my photos",
|
||||
date: 1736380800,
|
||||
media_group_id: "album123",
|
||||
@@ -364,7 +418,7 @@ describe("telegram media groups", () => {
|
||||
{
|
||||
chat: { id: 42, type: "private" as const },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 2,
|
||||
message_id: 102,
|
||||
date: 1736380801,
|
||||
media_group_id: "album123",
|
||||
photo: [{ file_id: "photo2" }],
|
||||
@@ -383,7 +437,7 @@ describe("telegram media groups", () => {
|
||||
{
|
||||
chat: { id: 42, type: "private" as const },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 11,
|
||||
message_id: 111,
|
||||
caption: "Album A",
|
||||
date: 1736380800,
|
||||
media_group_id: "albumA",
|
||||
@@ -393,7 +447,7 @@ describe("telegram media groups", () => {
|
||||
{
|
||||
chat: { id: 42, type: "private" as const },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 12,
|
||||
message_id: 112,
|
||||
caption: "Album B",
|
||||
date: 1736380801,
|
||||
media_group_id: "albumB",
|
||||
@@ -407,6 +461,8 @@ describe("telegram media groups", () => {
|
||||
]) {
|
||||
replySpy.mockClear();
|
||||
runtimeError.mockClear();
|
||||
setTimeoutSpy.mockClear();
|
||||
clearTimeoutSpy.mockClear();
|
||||
|
||||
await Promise.all(
|
||||
scenario.messages.map((message) =>
|
||||
@@ -419,17 +475,20 @@ describe("telegram media groups", () => {
|
||||
);
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(scenario.expectedReplyCount);
|
||||
},
|
||||
{ timeout: MEDIA_GROUP_WAIT_TIMEOUT_MS, interval: 2 },
|
||||
);
|
||||
await flushActiveScheduledTimersForDelay({
|
||||
setTimeoutSpy,
|
||||
clearTimeoutSpy,
|
||||
delayMs: TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs,
|
||||
expectedCount: scenario.expectedReplyCount,
|
||||
});
|
||||
expect(replySpy).toHaveBeenCalledTimes(scenario.expectedReplyCount);
|
||||
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
scenario.assert(replySpy);
|
||||
}
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
@@ -437,7 +496,7 @@ describe("telegram media groups", () => {
|
||||
);
|
||||
|
||||
it(
|
||||
"flushes same-id forum topic media groups in parallel",
|
||||
"buffers same-id forum topic media groups independently",
|
||||
async () => {
|
||||
const originalLoadConfig = telegramBotDepsForTest.getRuntimeConfig;
|
||||
telegramBotDepsForTest.getRuntimeConfig = (() => ({
|
||||
@@ -445,8 +504,11 @@ describe("telegram media groups", () => {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupAllowFrom: ["777"],
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
groups: {
|
||||
"-10042": { allowFrom: ["777"], groupPolicy: "open", requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
})) as typeof telegramBotDepsForTest.getRuntimeConfig;
|
||||
@@ -454,17 +516,13 @@ describe("telegram media groups", () => {
|
||||
const runtimeError = vi.fn();
|
||||
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
|
||||
const fetchSpy = mockTelegramPngDownload();
|
||||
let releaseFirstReply: (() => void) | undefined;
|
||||
const firstReplyStarted = new Promise<void>((resolve) => {
|
||||
replySpy.mockImplementationOnce(async (_ctx, opts?: { onReplyStart?: () => unknown }) => {
|
||||
await opts?.onReplyStart?.();
|
||||
resolve();
|
||||
await new Promise<void>((release) => {
|
||||
releaseFirstReply = release;
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
let nextTimerHandle = 1;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation(() => {
|
||||
const handle = nextTimerHandle;
|
||||
nextTimerHandle += 1;
|
||||
return handle as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
@@ -472,9 +530,10 @@ describe("telegram media groups", () => {
|
||||
message: {
|
||||
chat: { id: -10042, type: "supergroup" as const, is_forum: true },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 31,
|
||||
message_id: 131,
|
||||
message_thread_id: 101,
|
||||
caption: "Topic one album",
|
||||
is_topic_message: true,
|
||||
caption: "@openclaw_bot Topic one album",
|
||||
date: 1736380800,
|
||||
media_group_id: "album-shared-by-telegram",
|
||||
photo: [{ file_id: "topic1photo" }],
|
||||
@@ -486,9 +545,10 @@ describe("telegram media groups", () => {
|
||||
message: {
|
||||
chat: { id: -10042, type: "supergroup" as const, is_forum: true },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 32,
|
||||
message_id: 132,
|
||||
message_thread_id: 202,
|
||||
caption: "Topic two album",
|
||||
is_topic_message: true,
|
||||
caption: "@openclaw_bot Topic two album",
|
||||
date: 1736380801,
|
||||
media_group_id: "album-shared-by-telegram",
|
||||
photo: [{ file_id: "topic2photo" }],
|
||||
@@ -498,15 +558,17 @@ describe("telegram media groups", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await firstReplyStarted;
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
{ timeout: MEDIA_GROUP_WAIT_TIMEOUT_MS, interval: 2 },
|
||||
const timers = resolveActiveScheduledTimersForDelay(
|
||||
setTimeoutSpy,
|
||||
clearTimeoutSpy,
|
||||
TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs,
|
||||
);
|
||||
|
||||
expect(timers).toHaveLength(2);
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer.handle);
|
||||
await timer.callback();
|
||||
}
|
||||
expect(replySpy).toHaveBeenCalledTimes(2);
|
||||
const firstPayload = replyPayload(replySpy, 0);
|
||||
const secondPayload = replyPayload(replySpy, 1);
|
||||
expect([firstPayload.Body, secondPayload.Body]).toEqual(
|
||||
@@ -519,7 +581,15 @@ describe("telegram media groups", () => {
|
||||
expect(secondPayload.MediaPaths).toHaveLength(1);
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseFirstReply?.();
|
||||
for (const timer of resolveActiveScheduledTimersForDelay(
|
||||
setTimeoutSpy,
|
||||
clearTimeoutSpy,
|
||||
TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs,
|
||||
)) {
|
||||
clearTimeout(timer.handle);
|
||||
}
|
||||
setTimeoutSpy.mockRestore();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
fetchSpy.mockRestore();
|
||||
telegramBotDepsForTest.getRuntimeConfig = originalLoadConfig;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { GetReplyOptions, MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeEach, vi, type Mock } from "vitest";
|
||||
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||
import {
|
||||
resetTopicNameCacheForTest,
|
||||
setTelegramTopicNameStoreFactoryForTest,
|
||||
} from "./topic-name-cache.js";
|
||||
|
||||
type TelegramBotRuntimeForTest = NonNullable<
|
||||
Parameters<typeof import("./bot.js").setTelegramBotRuntimeForTest>[0]
|
||||
@@ -108,6 +112,32 @@ const apiStub: ApiStub = {
|
||||
|
||||
const throttlerSpy = vi.fn(() => "throttler");
|
||||
|
||||
type TopicNameStoreFactory = NonNullable<
|
||||
Parameters<typeof setTelegramTopicNameStoreFactoryForTest>[0]
|
||||
>;
|
||||
type TopicNamePersistentStore = ReturnType<TopicNameStoreFactory>;
|
||||
type TopicNameEntry = Awaited<ReturnType<TopicNamePersistentStore["entries"]>>[number]["value"];
|
||||
|
||||
const topicNameStoresForTest = new Map<string, Map<string, TopicNameEntry>>();
|
||||
|
||||
setTelegramTopicNameStoreFactoryForTest((namespace) => {
|
||||
let store = topicNameStoresForTest.get(namespace);
|
||||
if (!store) {
|
||||
store = new Map();
|
||||
topicNameStoresForTest.set(namespace, store);
|
||||
}
|
||||
return {
|
||||
register: async (key, value) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
entries: async () => [...store.entries()].map(([key, value]) => ({ key, value })),
|
||||
delete: async (key) => store.delete(key),
|
||||
clear: async () => {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = {
|
||||
Bot: class {
|
||||
api = apiStub;
|
||||
@@ -172,6 +202,8 @@ export const telegramBotDepsForTest: TelegramBotDeps = {
|
||||
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
topicNameStoresForTest.clear();
|
||||
resetTopicNameCacheForTest();
|
||||
resetSaveMediaBufferMock();
|
||||
resetUndiciFetchMock();
|
||||
resetReadRemoteMediaBufferMock();
|
||||
@@ -181,22 +213,26 @@ vi.doMock("./bot.runtime.js", () => ({
|
||||
...telegramBotRuntimeForTest,
|
||||
}));
|
||||
|
||||
vi.mock("undici", () => ({
|
||||
Agent: vi.fn(function MockAgent(this: { options?: unknown }, options?: unknown) {
|
||||
this.options = options;
|
||||
}),
|
||||
EnvHttpProxyAgent: vi.fn(function MockEnvHttpProxyAgent(
|
||||
this: { options?: unknown },
|
||||
options?: unknown,
|
||||
) {
|
||||
this.options = options;
|
||||
}),
|
||||
ProxyAgent: vi.fn(function MockProxyAgent(this: { options?: unknown }, options?: unknown) {
|
||||
this.options = options;
|
||||
}),
|
||||
fetch: (...args: Parameters<typeof undiciFetchSpy>) => undiciFetchSpy(...args),
|
||||
setGlobalDispatcher: vi.fn(),
|
||||
}));
|
||||
vi.mock("undici", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("undici")>();
|
||||
return {
|
||||
...actual,
|
||||
Agent: vi.fn(function MockAgent(this: { options?: unknown }, options?: unknown) {
|
||||
this.options = options;
|
||||
}),
|
||||
EnvHttpProxyAgent: vi.fn(function MockEnvHttpProxyAgent(
|
||||
this: { options?: unknown },
|
||||
options?: unknown,
|
||||
) {
|
||||
this.options = options;
|
||||
}),
|
||||
ProxyAgent: vi.fn(function MockProxyAgent(this: { options?: unknown }, options?: unknown) {
|
||||
this.options = options;
|
||||
}),
|
||||
fetch: (...args: Parameters<typeof undiciFetchSpy>) => undiciFetchSpy(...args),
|
||||
setGlobalDispatcher: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./telegram-media.runtime.js", () => ({
|
||||
readRemoteMediaBuffer: (...args: Parameters<typeof readRemoteMediaBufferSpy>) =>
|
||||
|
||||
@@ -13,14 +13,25 @@ import type { TelegramTransport } from "./fetch.js";
|
||||
|
||||
function resolveScheduledTimerForDelay(
|
||||
setTimeoutSpy: ReturnType<typeof vi.spyOn>,
|
||||
clearTimeoutSpy: ReturnType<typeof vi.spyOn>,
|
||||
delayMs: number,
|
||||
) {
|
||||
const timerCallIndex = setTimeoutSpy.mock.calls.findLastIndex(
|
||||
(call: Parameters<typeof setTimeout>) => call[1] === delayMs,
|
||||
const clearedHandles = new Set(
|
||||
(clearTimeoutSpy.mock.calls as Array<Parameters<typeof clearTimeout>>).map(
|
||||
([handle]) => handle,
|
||||
),
|
||||
);
|
||||
const timerCalls = setTimeoutSpy.mock.calls as Array<Parameters<typeof setTimeout>>;
|
||||
const timerCallIndex = timerCalls.findLastIndex(
|
||||
(call, index) =>
|
||||
call[1] === delayMs &&
|
||||
!clearedHandles.has(
|
||||
setTimeoutSpy.mock.results[index]?.value as ReturnType<typeof setTimeout>,
|
||||
),
|
||||
);
|
||||
const flushTimer =
|
||||
timerCallIndex >= 0
|
||||
? (setTimeoutSpy.mock.calls[timerCallIndex]?.[0] as (() => unknown) | undefined)
|
||||
? (timerCalls[timerCallIndex]?.[0] as (() => unknown) | undefined)
|
||||
: undefined;
|
||||
if (timerCallIndex >= 0) {
|
||||
clearTimeout(
|
||||
@@ -32,13 +43,43 @@ function resolveScheduledTimerForDelay(
|
||||
|
||||
async function flushScheduledTimerForDelay(
|
||||
setTimeoutSpy: ReturnType<typeof vi.spyOn>,
|
||||
clearTimeoutSpy: ReturnType<typeof vi.spyOn>,
|
||||
delayMs: number,
|
||||
) {
|
||||
const flushTimer = resolveScheduledTimerForDelay(setTimeoutSpy, delayMs);
|
||||
const flushTimer = resolveScheduledTimerForDelay(setTimeoutSpy, clearTimeoutSpy, delayMs);
|
||||
expect(flushTimer).toBeTypeOf("function");
|
||||
await flushTimer?.();
|
||||
}
|
||||
|
||||
type ScheduledTimer = {
|
||||
callback: () => unknown;
|
||||
handle: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
function resolveActiveScheduledTimersForDelay(
|
||||
setTimeoutSpy: ReturnType<typeof vi.spyOn>,
|
||||
clearTimeoutSpy: ReturnType<typeof vi.spyOn>,
|
||||
delayMs: number,
|
||||
): ScheduledTimer[] {
|
||||
const clearedHandles = new Set(
|
||||
(clearTimeoutSpy.mock.calls as Array<Parameters<typeof clearTimeout>>).map(
|
||||
([handle]) => handle,
|
||||
),
|
||||
);
|
||||
return (setTimeoutSpy.mock.calls as Array<Parameters<typeof setTimeout>>).flatMap(
|
||||
(call, index) => {
|
||||
if (call[1] !== delayMs) {
|
||||
return [];
|
||||
}
|
||||
const handle = setTimeoutSpy.mock.results[index]?.value as ReturnType<typeof setTimeout>;
|
||||
if (clearedHandles.has(handle) || typeof call[0] !== "function") {
|
||||
return [];
|
||||
}
|
||||
return [{ callback: call[0] as () => unknown, handle }];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("telegram stickers", () => {
|
||||
// Parallel Testbox shards can make these media-path e2e tests slower than standalone local runs.
|
||||
const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 90_000;
|
||||
@@ -203,10 +244,6 @@ describe("telegram text fragments", () => {
|
||||
});
|
||||
|
||||
const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
|
||||
const TEXT_FRAGMENT_PARALLEL_WAIT_TIMEOUT_MS = Math.max(
|
||||
2_000,
|
||||
TELEGRAM_TEST_TIMINGS.textFragmentGapMs * 10,
|
||||
);
|
||||
|
||||
it(
|
||||
"buffers near-limit text and processes sequential parts as one message",
|
||||
@@ -215,6 +252,7 @@ describe("telegram text fragments", () => {
|
||||
const part1 = "A".repeat(4050);
|
||||
const part2 = "B".repeat(50);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
|
||||
try {
|
||||
await handler({
|
||||
@@ -242,7 +280,11 @@ describe("telegram text fragments", () => {
|
||||
});
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
await flushScheduledTimerForDelay(setTimeoutSpy, TELEGRAM_TEST_TIMINGS.textFragmentGapMs);
|
||||
await flushScheduledTimerForDelay(
|
||||
setTimeoutSpy,
|
||||
clearTimeoutSpy,
|
||||
TELEGRAM_TEST_TIMINGS.textFragmentGapMs,
|
||||
);
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls.at(0)?.[0] as { RawBody?: string };
|
||||
@@ -250,6 +292,7 @@ describe("telegram text fragments", () => {
|
||||
expect(payload.RawBody).toContain(part2.slice(0, 32));
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
TEXT_FRAGMENT_TEST_TIMEOUT_MS,
|
||||
@@ -278,7 +321,13 @@ describe("telegram text fragments", () => {
|
||||
|
||||
const runtimeError = vi.fn();
|
||||
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
let nextTimerHandle = 1;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation(() => {
|
||||
const handle = nextTimerHandle;
|
||||
nextTimerHandle += 1;
|
||||
return handle as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
const part1 = "A".repeat(4050);
|
||||
const part2 = "B".repeat(50);
|
||||
|
||||
@@ -307,7 +356,11 @@ describe("telegram text fragments", () => {
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
|
||||
await flushScheduledTimerForDelay(setTimeoutSpy, TELEGRAM_TEST_TIMINGS.textFragmentGapMs);
|
||||
await flushScheduledTimerForDelay(
|
||||
setTimeoutSpy,
|
||||
clearTimeoutSpy,
|
||||
TELEGRAM_TEST_TIMINGS.textFragmentGapMs,
|
||||
);
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledWith("telegram", process.env, "default");
|
||||
expect(upsertPairingRequest).not.toHaveBeenCalled();
|
||||
@@ -315,6 +368,7 @@ describe("telegram text fragments", () => {
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
telegramBotDepsForTest.getRuntimeConfig = originalLoadConfig;
|
||||
readAllowFromStore.mockReset();
|
||||
readAllowFromStore.mockResolvedValue([]);
|
||||
@@ -324,7 +378,7 @@ describe("telegram text fragments", () => {
|
||||
);
|
||||
|
||||
it(
|
||||
"flushes different forum topic fragments in parallel",
|
||||
"buffers different forum topic fragments independently",
|
||||
async () => {
|
||||
const originalLoadConfig = telegramBotDepsForTest.getRuntimeConfig;
|
||||
telegramBotDepsForTest.getRuntimeConfig = (() => ({
|
||||
@@ -332,35 +386,35 @@ describe("telegram text fragments", () => {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupAllowFrom: ["777"],
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
groups: {
|
||||
"-10042": { allowFrom: ["777"], groupPolicy: "open", requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
})) as typeof telegramBotDepsForTest.getRuntimeConfig;
|
||||
|
||||
const runtimeError = vi.fn();
|
||||
const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError });
|
||||
let releaseFirstReply: (() => void) | undefined;
|
||||
const firstReplyStarted = new Promise<void>((resolve) => {
|
||||
replySpy.mockImplementationOnce(async (_ctx, opts?: { onReplyStart?: () => unknown }) => {
|
||||
await opts?.onReplyStart?.();
|
||||
resolve();
|
||||
await new Promise<void>((release) => {
|
||||
releaseFirstReply = release;
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
let nextTimerHandle = 1;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation(() => {
|
||||
const handle = nextTimerHandle;
|
||||
nextTimerHandle += 1;
|
||||
return handle as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
|
||||
try {
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: -10042, type: "supergroup", is_forum: true },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 20,
|
||||
message_id: 120,
|
||||
message_thread_id: 101,
|
||||
is_topic_message: true,
|
||||
date: 1736380800,
|
||||
text: `topic-one ${"A".repeat(4050)}`,
|
||||
text: `@openclaw_bot topic-one ${"A".repeat(4050)}`,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({}),
|
||||
@@ -370,27 +424,31 @@ describe("telegram text fragments", () => {
|
||||
message: {
|
||||
chat: { id: -10042, type: "supergroup", is_forum: true },
|
||||
from: { id: 777, is_bot: false, first_name: "Ada" },
|
||||
message_id: 21,
|
||||
message_id: 121,
|
||||
message_thread_id: 202,
|
||||
is_topic_message: true,
|
||||
date: 1736380801,
|
||||
text: `topic-two ${"B".repeat(4050)}`,
|
||||
text: `@openclaw_bot topic-two ${"B".repeat(4050)}`,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
|
||||
await firstReplyStarted;
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
{ timeout: TEXT_FRAGMENT_PARALLEL_WAIT_TIMEOUT_MS, interval: 2 },
|
||||
const timers = resolveActiveScheduledTimersForDelay(
|
||||
setTimeoutSpy,
|
||||
clearTimeoutSpy,
|
||||
TELEGRAM_TEST_TIMINGS.textFragmentGapMs,
|
||||
);
|
||||
|
||||
const firstPayload = replySpy.mock.calls.at(0)?.[0] as { RawBody?: string };
|
||||
const secondPayload = replySpy.mock.calls.at(1)?.[0] as { RawBody?: string };
|
||||
expect([firstPayload.RawBody, secondPayload.RawBody]).toEqual(
|
||||
expect(timers).toHaveLength(2);
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer.handle);
|
||||
await timer.callback();
|
||||
}
|
||||
expect(replySpy).toHaveBeenCalledTimes(2);
|
||||
const rawBodies = replySpy.mock.calls.map(
|
||||
(call) => (call[0] as { RawBody?: string }).RawBody,
|
||||
);
|
||||
expect(rawBodies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("topic-one"),
|
||||
expect.stringContaining("topic-two"),
|
||||
@@ -398,7 +456,15 @@ describe("telegram text fragments", () => {
|
||||
);
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseFirstReply?.();
|
||||
for (const timer of resolveActiveScheduledTimersForDelay(
|
||||
setTimeoutSpy,
|
||||
clearTimeoutSpy,
|
||||
TELEGRAM_TEST_TIMINGS.textFragmentGapMs,
|
||||
)) {
|
||||
clearTimeout(timer.handle);
|
||||
}
|
||||
setTimeoutSpy.mockRestore();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
telegramBotDepsForTest.getRuntimeConfig = originalLoadConfig;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,9 +3,14 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { spawn as spawnPty, type PtyExitEvent, type PtyHandle } from "@lydell/node-pty";
|
||||
import * as nodePty from "@lydell/node-pty";
|
||||
import type { PtyExitEvent, PtyHandle } from "@lydell/node-pty";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
type NodePtyRuntimeModule = typeof nodePty & {
|
||||
default?: Partial<typeof nodePty>;
|
||||
};
|
||||
|
||||
type KillablePtyHandle = PtyHandle & {
|
||||
kill?: (signal?: string) => void;
|
||||
};
|
||||
@@ -30,6 +35,19 @@ const EXIT_TIMEOUT_MS = 4_000;
|
||||
const TEST_TIMEOUT_MS = 5_000;
|
||||
const STARTUP_TEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
function resolveSpawnPty() {
|
||||
const runtime = nodePty as NodePtyRuntimeModule;
|
||||
if (typeof runtime.spawn === "function") {
|
||||
return runtime.spawn;
|
||||
}
|
||||
if (typeof runtime.default?.spawn === "function") {
|
||||
return runtime.default.spawn;
|
||||
}
|
||||
throw new TypeError("@lydell/node-pty spawn export is unavailable");
|
||||
}
|
||||
|
||||
const spawnPty = resolveSpawnPty();
|
||||
|
||||
function waitFor<T>(params: {
|
||||
timeoutMs: number;
|
||||
read: () => T | null;
|
||||
@@ -462,7 +480,8 @@ describe.sequential("TUI PTY harness", () => {
|
||||
for (const run of activeRuns.splice(0)) {
|
||||
run.dispose();
|
||||
}
|
||||
await fixture.cleanup();
|
||||
const startedFixture = fixture as Awaited<ReturnType<typeof startTuiFixture>> | undefined;
|
||||
await startedFixture?.cleanup();
|
||||
});
|
||||
|
||||
it("renders local ready on startup", () => {
|
||||
|
||||
@@ -3,10 +3,15 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn as spawnPty, type PtyExitEvent, type PtyHandle } from "@lydell/node-pty";
|
||||
import * as nodePty from "@lydell/node-pty";
|
||||
import type { PtyExitEvent, PtyHandle } from "@lydell/node-pty";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
type NodePtyRuntimeModule = typeof nodePty & {
|
||||
default?: Partial<typeof nodePty>;
|
||||
};
|
||||
|
||||
type KillablePtyHandle = PtyHandle & {
|
||||
kill?: (signal?: string) => void;
|
||||
};
|
||||
@@ -31,6 +36,19 @@ const LOCAL_OUTPUT_TIMEOUT_MS = 35_000;
|
||||
const LOCAL_EXIT_TIMEOUT_MS = 4_000;
|
||||
const LOCAL_TEST_TIMEOUT_MS = 60_000;
|
||||
|
||||
function resolveSpawnPty() {
|
||||
const runtime = nodePty as NodePtyRuntimeModule;
|
||||
if (typeof runtime.spawn === "function") {
|
||||
return runtime.spawn;
|
||||
}
|
||||
if (typeof runtime.default?.spawn === "function") {
|
||||
return runtime.default.spawn;
|
||||
}
|
||||
throw new TypeError("@lydell/node-pty spawn export is unavailable");
|
||||
}
|
||||
|
||||
const spawnPty = resolveSpawnPty();
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
17
test/vitest/discord-api-types-gateway-v10-runtime.ts
Normal file
17
test/vitest/discord-api-types-gateway-v10-runtime.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type * as DiscordGatewayApiTypes from "discord-api-types/gateway/v10";
|
||||
|
||||
const requireDiscordGatewayApiTypes = createRequire(import.meta.url);
|
||||
const discordGatewayApiTypes = requireDiscordGatewayApiTypes(
|
||||
"discord-api-types/gateway/v10",
|
||||
) as typeof DiscordGatewayApiTypes;
|
||||
|
||||
export default discordGatewayApiTypes;
|
||||
export const {
|
||||
GatewayCloseCodes,
|
||||
GatewayDispatchEvents,
|
||||
GatewayIntentBits,
|
||||
GatewayOpcodes,
|
||||
GatewayVersion,
|
||||
VoiceChannelEffectSendAnimationType,
|
||||
} = discordGatewayApiTypes;
|
||||
109
test/vitest/discord-api-types-payloads-v10-runtime.ts
Normal file
109
test/vitest/discord-api-types-payloads-v10-runtime.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type * as DiscordPayloadApiTypes from "discord-api-types/payloads/v10";
|
||||
|
||||
const requireDiscordPayloadApiTypes = createRequire(import.meta.url);
|
||||
const discordPayloadApiTypes = requireDiscordPayloadApiTypes(
|
||||
"discord-api-types/payloads/v10",
|
||||
) as typeof DiscordPayloadApiTypes;
|
||||
|
||||
export default discordPayloadApiTypes;
|
||||
export const {
|
||||
APIApplicationCommandPermissionsConstant,
|
||||
ActivityFlags,
|
||||
ActivityLocationKind,
|
||||
ActivityPlatform,
|
||||
ActivityType,
|
||||
AllowedMentionsTypes,
|
||||
ApplicationCommandOptionType,
|
||||
ApplicationCommandPermissionType,
|
||||
ApplicationCommandType,
|
||||
ApplicationFlags,
|
||||
ApplicationIntegrationType,
|
||||
ApplicationRoleConnectionMetadataType,
|
||||
ApplicationWebhookEventStatus,
|
||||
ApplicationWebhookEventType,
|
||||
ApplicationWebhookType,
|
||||
AttachmentFlags,
|
||||
AuditLogEvent,
|
||||
AuditLogOptionsType,
|
||||
AutoModerationActionType,
|
||||
AutoModerationRuleEventType,
|
||||
AutoModerationRuleKeywordPresetType,
|
||||
AutoModerationRuleTriggerType,
|
||||
BaseThemeType,
|
||||
ButtonStyle,
|
||||
ChannelFlags,
|
||||
ChannelType,
|
||||
ComponentType,
|
||||
ConnectionService,
|
||||
ConnectionVisibility,
|
||||
EmbedFlags,
|
||||
EmbedMediaFlags,
|
||||
EmbedType,
|
||||
EntitlementType,
|
||||
EntryPointCommandHandlerType,
|
||||
ForumLayoutType,
|
||||
GuildDefaultMessageNotifications,
|
||||
GuildExplicitContentFilter,
|
||||
GuildFeature,
|
||||
GuildHubType,
|
||||
GuildMFALevel,
|
||||
GuildMemberFlags,
|
||||
GuildNSFWLevel,
|
||||
GuildOnboardingMode,
|
||||
GuildOnboardingPromptType,
|
||||
GuildPremiumTier,
|
||||
GuildScheduledEventEntityType,
|
||||
GuildScheduledEventPrivacyLevel,
|
||||
GuildScheduledEventRecurrenceRuleFrequency,
|
||||
GuildScheduledEventRecurrenceRuleMonth,
|
||||
GuildScheduledEventRecurrenceRuleWeekday,
|
||||
GuildScheduledEventStatus,
|
||||
GuildSystemChannelFlags,
|
||||
GuildVerificationLevel,
|
||||
GuildWidgetStyle,
|
||||
IntegrationExpireBehavior,
|
||||
InteractionContextType,
|
||||
InteractionResponseType,
|
||||
InteractionType,
|
||||
InviteFlags,
|
||||
InviteTargetType,
|
||||
InviteType,
|
||||
MembershipScreeningFieldType,
|
||||
MessageActivityType,
|
||||
MessageFlags,
|
||||
MessageReferenceType,
|
||||
MessageSearchAuthorType,
|
||||
MessageSearchEmbedType,
|
||||
MessageSearchHasType,
|
||||
MessageSearchSortMode,
|
||||
MessageType,
|
||||
NameplatePalette,
|
||||
OAuth2Scopes,
|
||||
OverwriteType,
|
||||
PermissionFlagsBits,
|
||||
PollLayoutType,
|
||||
PresenceUpdateStatus,
|
||||
RoleFlags,
|
||||
SKUFlags,
|
||||
SKUType,
|
||||
SelectMenuDefaultValueType,
|
||||
SeparatorSpacingSize,
|
||||
SortOrderType,
|
||||
StageInstancePrivacyLevel,
|
||||
StatusDisplayType,
|
||||
StickerFormatType,
|
||||
StickerType,
|
||||
SubscriptionStatus,
|
||||
TeamMemberMembershipState,
|
||||
TeamMemberRole,
|
||||
TextInputStyle,
|
||||
ThreadAutoArchiveDuration,
|
||||
ThreadMemberFlags,
|
||||
UnfurledMediaItemFlags,
|
||||
UnfurledMediaItemLoadingState,
|
||||
UserFlags,
|
||||
UserPremiumType,
|
||||
VideoQualityMode,
|
||||
WebhookType,
|
||||
} = discordPayloadApiTypes;
|
||||
138
test/vitest/discord-api-types-v10-runtime.ts
Normal file
138
test/vitest/discord-api-types-v10-runtime.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type * as DiscordApiTypes from "discord-api-types/v10";
|
||||
|
||||
const requireDiscordApiTypes = createRequire(import.meta.url);
|
||||
const discordApiTypes = requireDiscordApiTypes("discord-api-types/v10") as typeof DiscordApiTypes;
|
||||
|
||||
export default discordApiTypes;
|
||||
export const {
|
||||
APIApplicationCommandPermissionsConstant,
|
||||
APIVersion,
|
||||
ActivityFlags,
|
||||
ActivityLocationKind,
|
||||
ActivityPlatform,
|
||||
ActivityType,
|
||||
AllowedMentionsTypes,
|
||||
ApplicationCommandOptionType,
|
||||
ApplicationCommandPermissionType,
|
||||
ApplicationCommandType,
|
||||
ApplicationFlags,
|
||||
ApplicationIntegrationType,
|
||||
ApplicationRoleConnectionMetadataType,
|
||||
ApplicationWebhookEventStatus,
|
||||
ApplicationWebhookEventType,
|
||||
ApplicationWebhookType,
|
||||
AttachmentFlags,
|
||||
AuditLogEvent,
|
||||
AuditLogOptionsType,
|
||||
AutoModerationActionType,
|
||||
AutoModerationRuleEventType,
|
||||
AutoModerationRuleKeywordPresetType,
|
||||
AutoModerationRuleTriggerType,
|
||||
BaseThemeType,
|
||||
ButtonStyle,
|
||||
CDNRoutes,
|
||||
CannotSendMessagesToThisUserErrorCodes,
|
||||
ChannelFlags,
|
||||
ChannelType,
|
||||
ComponentType,
|
||||
ConnectionService,
|
||||
ConnectionVisibility,
|
||||
EmbedFlags,
|
||||
EmbedMediaFlags,
|
||||
EmbedType,
|
||||
EntitlementOwnerType,
|
||||
EntitlementType,
|
||||
EntryPointCommandHandlerType,
|
||||
FormattingPatterns,
|
||||
ForumLayoutType,
|
||||
GatewayCloseCodes,
|
||||
GatewayDispatchEvents,
|
||||
GatewayIntentBits,
|
||||
GatewayOpcodes,
|
||||
GatewayVersion,
|
||||
GuildDefaultMessageNotifications,
|
||||
GuildExplicitContentFilter,
|
||||
GuildFeature,
|
||||
GuildHubType,
|
||||
GuildMFALevel,
|
||||
GuildMemberFlags,
|
||||
GuildNSFWLevel,
|
||||
GuildOnboardingMode,
|
||||
GuildOnboardingPromptType,
|
||||
GuildPremiumTier,
|
||||
GuildScheduledEventEntityType,
|
||||
GuildScheduledEventPrivacyLevel,
|
||||
GuildScheduledEventRecurrenceRuleFrequency,
|
||||
GuildScheduledEventRecurrenceRuleMonth,
|
||||
GuildScheduledEventRecurrenceRuleWeekday,
|
||||
GuildScheduledEventStatus,
|
||||
GuildSystemChannelFlags,
|
||||
GuildVerificationLevel,
|
||||
GuildWidgetStyle,
|
||||
ImageFormat,
|
||||
IntegrationExpireBehavior,
|
||||
InteractionContextType,
|
||||
InteractionResponseType,
|
||||
InteractionType,
|
||||
InviteFlags,
|
||||
InviteTargetType,
|
||||
InviteType,
|
||||
Locale,
|
||||
MembershipScreeningFieldType,
|
||||
MessageActivityType,
|
||||
MessageFlags,
|
||||
MessageReferenceType,
|
||||
MessageSearchAuthorType,
|
||||
MessageSearchEmbedType,
|
||||
MessageSearchHasType,
|
||||
MessageSearchSortMode,
|
||||
MessageType,
|
||||
NameplatePalette,
|
||||
OAuth2Routes,
|
||||
OAuth2Scopes,
|
||||
OverwriteType,
|
||||
PermissionFlagsBits,
|
||||
PollLayoutType,
|
||||
PresenceUpdateStatus,
|
||||
RESTJSONErrorCodes,
|
||||
RPCCloseEventCodes,
|
||||
RPCCommands,
|
||||
RPCDeviceType,
|
||||
RPCErrorCodes,
|
||||
RPCEvents,
|
||||
RPCVersion,
|
||||
RPCVoiceSettingsModeType,
|
||||
RPCVoiceShortcutKeyComboKeyType,
|
||||
ReactionType,
|
||||
RelationshipType,
|
||||
RoleFlags,
|
||||
RouteBases,
|
||||
Routes,
|
||||
SKUFlags,
|
||||
SKUType,
|
||||
SelectMenuDefaultValueType,
|
||||
SeparatorSpacingSize,
|
||||
SortOrderType,
|
||||
StageInstancePrivacyLevel,
|
||||
StatusDisplayType,
|
||||
StickerFormatType,
|
||||
StickerPackApplicationId,
|
||||
StickerType,
|
||||
SubscriptionStatus,
|
||||
TeamMemberMembershipState,
|
||||
TeamMemberRole,
|
||||
TextInputStyle,
|
||||
ThreadAutoArchiveDuration,
|
||||
ThreadMemberFlags,
|
||||
UnfurledMediaItemFlags,
|
||||
UnfurledMediaItemLoadingState,
|
||||
UserFlags,
|
||||
UserPremiumType,
|
||||
Utils,
|
||||
VideoQualityMode,
|
||||
VoiceChannelEffectSendAnimationType,
|
||||
VoiceConnectionStates,
|
||||
WebhookType,
|
||||
urlSafeCharacters,
|
||||
} = discordApiTypes;
|
||||
@@ -131,6 +131,28 @@ export const sharedVitestConfig = {
|
||||
envFile: false,
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "discord-api-types/v10",
|
||||
replacement: path.join(repoRoot, "test", "vitest", "discord-api-types-v10-runtime.ts"),
|
||||
},
|
||||
{
|
||||
find: "discord-api-types/gateway/v10",
|
||||
replacement: path.join(
|
||||
repoRoot,
|
||||
"test",
|
||||
"vitest",
|
||||
"discord-api-types-gateway-v10-runtime.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
find: "discord-api-types/payloads/v10",
|
||||
replacement: path.join(
|
||||
repoRoot,
|
||||
"test",
|
||||
"vitest",
|
||||
"discord-api-types-payloads-v10-runtime.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
find: "openclaw/extension-api",
|
||||
replacement: path.join(repoRoot, "src", "extensionAPI.ts"),
|
||||
|
||||
Reference in New Issue
Block a user