test(release): harden qa live canaries

This commit is contained in:
Peter Steinberger
2026-04-28 22:25:56 +01:00
parent dcc8190933
commit 46eaa5171d
4 changed files with 109 additions and 8 deletions

View File

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

View File

@@ -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<ReturnType<typeof startQaGatewayChild>>,
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<TelegramObservedMessage, "messageId" | "replyToMessageId" | "text">
@@ -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,
};

View File

@@ -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: {

View File

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