diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts index 911248a4fb8..261c4af6405 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts @@ -28,6 +28,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { describe("telegram live qa runtime", () => { afterEach(() => { + vi.useRealTimers(); fetchWithSsrFGuardMock.mockClear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -100,6 +101,46 @@ describe("telegram live qa runtime", () => { ).toBe(true); }); + it("normalizes the Telegram canary timeout env", () => { + expect( + __testing.resolveTelegramQaCanaryTimeoutMs({ + OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS: "12345", + }), + ).toBe(12345); + expect( + __testing.resolveTelegramQaCanaryTimeoutMs({ + OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS: "nope", + }), + ).toBe(60_000); + }); + + it("waits for Telegram polling connectivity before treating the account as ready", async () => { + vi.useFakeTimers(); + const gateway = { + call: vi + .fn() + .mockResolvedValueOnce({ + channelAccounts: { + telegram: [{ accountId: "sut", connected: false, running: true }], + }, + }) + .mockResolvedValueOnce({ + channelAccounts: { + telegram: [{ accountId: "sut", connected: true, running: true }], + }, + }), + }; + + const ready = __testing.waitForTelegramChannelRunning(gateway as never, "sut", { + pollIntervalMs: 10, + timeoutMs: 1_000, + }); + await vi.advanceTimersByTimeAsync(10); + + await expect(ready).resolves.toBeUndefined(); + expect(gateway.call).toHaveBeenCalledTimes(2); + }); + it("sanitizes and truncates Telegram live progress details", () => { expect(__testing.sanitizeTelegramQaProgressValue("scenario\nid\tvalue")).toBe( "scenario id value", diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts index d84a13a72b0..919277f6106 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts @@ -302,6 +302,7 @@ const TELEGRAM_QA_ENV_KEYS = [ "OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN", "OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN", ] as const; +const DEFAULT_TELEGRAM_QA_CANARY_TIMEOUT_MS = 60_000; const TELEGRAM_QA_CAPTURE_CONTENT_ENV = "OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT"; const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA"; const QA_SUITE_PROGRESS_ENV = "OPENCLAW_QA_SUITE_PROGRESS"; @@ -342,6 +343,26 @@ function parseTelegramQaProgressBooleanEnv(value: string | undefined): boolean | return undefined; } +function parsePositiveTelegramQaEnvMs(env: NodeJS.ProcessEnv, name: string, fallback: number) { + const raw = env[name]; + if (raw === undefined) { + return fallback; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 1) { + return fallback; + } + return Math.floor(parsed); +} + +function resolveTelegramQaCanaryTimeoutMs(env: NodeJS.ProcessEnv = process.env) { + return parsePositiveTelegramQaEnvMs( + env, + "OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS", + DEFAULT_TELEGRAM_QA_CANARY_TIMEOUT_MS, + ); +} + function shouldLogTelegramQaLiveProgress(env: NodeJS.ProcessEnv = process.env) { const override = parseTelegramQaProgressBooleanEnv(env[QA_SUITE_PROGRESS_ENV]); if (override !== undefined) { @@ -633,9 +654,15 @@ async function waitForObservedMessage(params: { async function waitForTelegramChannelRunning( gateway: Awaited>, accountId: string, + opts: { + pollIntervalMs?: number; + timeoutMs?: number; + } = {}, ) { const startedAt = Date.now(); - while (Date.now() - startedAt < 45_000) { + const timeoutMs = opts.timeoutMs ?? 90_000; + const pollIntervalMs = opts.pollIntervalMs ?? 500; + while (Date.now() - startedAt < timeoutMs) { try { const payload = (await gateway.call( "channels.status", @@ -644,20 +671,25 @@ async function waitForTelegramChannelRunning( )) as { channelAccounts?: Record< string, - Array<{ accountId?: string; running?: boolean; restartPending?: boolean }> + Array<{ + accountId?: string; + connected?: boolean; + running?: boolean; + restartPending?: boolean; + }> >; }; const accounts = payload.channelAccounts?.telegram ?? []; const match = accounts.find((entry) => entry.accountId === accountId); - if (match?.running && match.restartPending !== true) { + if (match?.running && match.connected === true && match.restartPending !== true) { return; } } catch { // retry } - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } - throw new Error(`telegram account "${accountId}" did not become ready`); + throw new Error(`telegram account "${accountId}" did not become ready and connected`); } function renderTelegramQaMarkdown(params: { @@ -831,6 +863,7 @@ async function runCanary(params: { params.groupId, `/help@${params.sutUsername}`, ); + const canaryTimeoutMs = resolveTelegramQaCanaryTimeoutMs(); const requestStartedAt = new Date(requestStartedAtMs).toISOString(); let firstUnthreadedReply: | Pick @@ -840,7 +873,7 @@ async function runCanary(params: { sutObserved = await waitForObservedMessage({ token: params.driverToken, initialOffset: offset, - timeoutMs: 30_000, + timeoutMs: canaryTimeoutMs, observedMessages: params.observedMessages, observationScenarioId: "telegram-canary", observationScenarioTitle: "Telegram canary", @@ -881,7 +914,7 @@ async function runCanary(params: { } throw new TelegramQaCanaryError( "sut_reply_timeout", - "SUT bot did not send any group reply after the canary command within 30s.", + `SUT bot did not send any group reply after the canary command within ${Math.round(canaryTimeoutMs / 1000)}s.`, { groupId: params.groupId, sutBotId: params.sutBotId, @@ -1439,10 +1472,12 @@ export const __testing = { matchesTelegramScenarioReply, normalizeTelegramObservedMessage, parseTelegramQaProgressBooleanEnv, + resolveTelegramQaCanaryTimeoutMs, parseTelegramQaCredentialPayload, resolveTelegramQaRuntimeEnv, sanitizeTelegramQaProgressValue, shouldLogTelegramQaLiveProgress, + waitForTelegramChannelRunning, formatTelegramQaProgressDetails, renderTelegramQaMarkdown, }; diff --git a/extensions/qa-matrix/src/runners/contract/runtime.test.ts b/extensions/qa-matrix/src/runners/contract/runtime.test.ts index 0764582ea51..5bde68bb371 100644 --- a/extensions/qa-matrix/src/runners/contract/runtime.test.ts +++ b/extensions/qa-matrix/src/runners/contract/runtime.test.ts @@ -106,6 +106,22 @@ describe("matrix live qa runtime", () => { } }); + it("normalizes the Matrix QA canary timeout env", () => { + const previous = process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS; + try { + process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "12345"; + expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(12345); + process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "nope"; + expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(90_000); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS; + } else { + process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = previous; + } + } + }); + it("injects a temporary Matrix account into the QA gateway config", () => { const baseCfg: OpenClawConfig = { plugins: { diff --git a/extensions/qa-matrix/src/runners/contract/runtime.ts b/extensions/qa-matrix/src/runners/contract/runtime.ts index f042d9cfe5e..36af67df6f0 100644 --- a/extensions/qa-matrix/src/runners/contract/runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/runtime.ts @@ -50,6 +50,7 @@ type MatrixQaGatewayChild = { }; const DEFAULT_MATRIX_QA_RUN_TIMEOUT_MS = 30 * 60_000; +const DEFAULT_MATRIX_QA_CANARY_TIMEOUT_MS = 90_000; const DEFAULT_MATRIX_QA_CLEANUP_TIMEOUT_MS = 90_000; type MatrixQaLiveLaneGatewayHarness = { @@ -192,6 +193,13 @@ function createMatrixQaRunDeadline() { }; } +function resolveMatrixQaCanaryTimeoutMs() { + return parsePositiveMatrixQaEnvMs( + "OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS", + DEFAULT_MATRIX_QA_CANARY_TIMEOUT_MS, + ); +} + function remainingMatrixQaRunMs(deadline: { deadlineMs: number }) { return Math.max(1, deadline.deadlineMs - Date.now()); } @@ -720,7 +728,7 @@ export async function runMatrixQaLive(params: { syncState, syncStreams, sutUserId: provisioning.sut.userId, - timeoutMs: 45_000, + timeoutMs: resolveMatrixQaCanaryTimeoutMs(), }), ), ); @@ -1128,6 +1136,7 @@ export const __testing = { findMatrixQaScenarios, isMatrixAccountReady, patchMatrixQaGatewayConfig, + resolveMatrixQaCanaryTimeoutMs, resolveMatrixQaModels, shouldWriteMatrixQaProgress, summarizeMatrixQaConfigSnapshot,