fix(test): stabilize e2e runtime imports

This commit is contained in:
Vincent Koc
2026-05-25 17:15:37 +02:00
parent 633e4b8a7c
commit 99997e4441
9 changed files with 588 additions and 93 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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;

View 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;

View File

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