From fdf58c199820565967bea5c4e5fd8501e1b53ae1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 27 May 2026 06:52:02 +0200 Subject: [PATCH] fix(e2e): backstop Parallels update jobs --- CHANGELOG.md | 1 + scripts/e2e/parallels/npm-update-smoke.ts | 24 +++-- scripts/e2e/parallels/update-job-timeout.ts | 44 ++++++++++ .../parallels-update-job-timeout.test.ts | 88 +++++++++++++++++++ 4 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 scripts/e2e/parallels/update-job-timeout.ts create mode 100644 test/scripts/parallels-update-job-timeout.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 19507453cee..8166aae3379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Agents/BTW: route fallback side-question streams through the embedded stream resolver so Anthropic-compatible MiniMax requests use the same capped transport as normal chat. (#86312) Thanks @neeravmakwana. - Telegram: treat `/command@TargetBot` bot-command entities as explicit mentions for the addressed bot so `requireMention` groups no longer drop targeted commands or captions. Fixes #84462. (#86553) Thanks @luoyanglang. - CI: bound Docker/Bash E2E tarball npm installs with `OPENCLAW_E2E_NPM_INSTALL_TIMEOUT` so package, onboarding, plugin, and upgrade lanes fail instead of hanging on a stuck npm install. +- CI: fail Parallels npm-update smoke jobs after the guest command timeout and cleanup backstop instead of only logging a timeout line. - CI: keep `OPENCLAW_TESTBOX=1 pnpm check:changed` delegating to Blacksmith Testbox through Crabbox without forwarding local Testbox or worker env into the remote command. - CI: send KILL after the TERM grace period for manual checkout fetch timeouts so stuck Testbox and workflow checkout retries cannot hang behind a wedged `git fetch`. - CI: send KILL after the TERM grace period for Bun global install smoke command timeouts so trapped `openclaw` child processes cannot wedge the scheduled install smoke. diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 273edf04d84..f776a733e5b 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -30,6 +30,7 @@ import { import { runWindowsBackgroundPowerShell } from "./guest-transports.ts"; import { linuxUpdateScript, macosUpdateScript, windowsUpdateScript } from "./npm-update-scripts.ts"; import { ensureVmRunning, resolveUbuntuVmName } from "./parallels-vm.ts"; +import { runTimedUpdateJob } from "./update-job-timeout.ts"; interface NpmUpdateOptions { betaValidation?: string; @@ -96,6 +97,7 @@ const macosVm = "macOS Tahoe"; const windowsVm = "Windows 11"; const linuxVmDefault = "Ubuntu 26.04"; const updateTimeoutSeconds = Number(process.env.OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S || 1200); +const updateCleanupBackstopMs = 60_000; function usage(): string { return `Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options] @@ -547,20 +549,14 @@ class NpmUpdateSmoke { log += text; this.noteJobOutput(job, text); }; - const timeout = setTimeout(() => { - append(`${label} update timed out after ${updateTimeoutSeconds}s\n`); - }, updateTimeoutSeconds * 1000); - try { - await fn({ append, logPath }); - await writeFile(logPath, log, "utf8"); - return 0; - } catch (error) { - append(`${error instanceof Error ? error.message : String(error)}\n`); - await writeFile(logPath, log, "utf8"); - return 1; - } finally { - clearTimeout(timeout); - } + return await runTimedUpdateJob({ + append, + label, + run: () => fn({ append, logPath }), + timeoutDescription: `${updateTimeoutSeconds}s plus cleanup backstop`, + timeoutMs: updateTimeoutSeconds * 1000 + updateCleanupBackstopMs, + writeLog: () => writeFile(logPath, log, "utf8"), + }); })().finally(() => { job.durationMs = Date.now() - job.startedAt; job.done = true; diff --git a/scripts/e2e/parallels/update-job-timeout.ts b/scripts/e2e/parallels/update-job-timeout.ts new file mode 100644 index 00000000000..a963adeacb0 --- /dev/null +++ b/scripts/e2e/parallels/update-job-timeout.ts @@ -0,0 +1,44 @@ +interface TimedUpdateJobOptions { + append(this: void, chunk: string): void; + label: string; + run(this: void): Promise | void; + timeoutDescription: string; + timeoutMs: number; + writeLog(this: void): Promise; +} + +export async function runTimedUpdateJob({ + append, + label, + run, + timeoutDescription, + timeoutMs, + writeLog, +}: TimedUpdateJobOptions): Promise { + let timedOut = false; + const timeoutMessage = `${label} update timed out after ${timeoutDescription}`; + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + timedOut = true; + append(`${timeoutMessage}\n`); + reject(new Error(timeoutMessage)); + }, timeoutMs); + }); + + try { + await Promise.race([Promise.resolve(run()), timeoutPromise]); + await writeLog(); + return 0; + } catch (error) { + if (!timedOut) { + append(`${error instanceof Error ? error.message : String(error)}\n`); + } + await writeLog(); + return 1; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} diff --git a/test/scripts/parallels-update-job-timeout.test.ts b/test/scripts/parallels-update-job-timeout.test.ts new file mode 100644 index 00000000000..3750ffc7893 --- /dev/null +++ b/test/scripts/parallels-update-job-timeout.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runTimedUpdateJob } from "../../scripts/e2e/parallels/update-job-timeout.ts"; + +describe("Parallels update job timeout", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("passes after the update body completes", async () => { + const chunks: string[] = []; + const writeLog = vi.fn(async () => undefined); + + await expect( + runTimedUpdateJob({ + append: (chunk) => chunks.push(chunk), + label: "macOS", + run: async () => undefined, + timeoutDescription: "1s", + timeoutMs: 1000, + writeLog, + }), + ).resolves.toBe(0); + + expect(chunks).toEqual([]); + expect(writeLog).toHaveBeenCalledTimes(1); + }); + + it("records update failures and writes the job log", async () => { + const chunks: string[] = []; + const writeLog = vi.fn(async () => undefined); + + await expect( + runTimedUpdateJob({ + append: (chunk) => chunks.push(chunk), + label: "Linux", + run: async () => { + throw new Error("package swap failed"); + }, + timeoutDescription: "1s", + timeoutMs: 1000, + writeLog, + }), + ).resolves.toBe(1); + + expect(chunks).toEqual(["package swap failed\n"]); + expect(writeLog).toHaveBeenCalledTimes(1); + }); + + it("lets the inner bounded operation settle before the backstop fires", async () => { + vi.useFakeTimers(); + const chunks: string[] = []; + const writeLog = vi.fn(async () => undefined); + + const result = runTimedUpdateJob({ + append: (chunk) => chunks.push(chunk), + label: "macOS", + run: () => new Promise((resolve) => setTimeout(resolve, 1000)), + timeoutDescription: "1s plus cleanup backstop", + timeoutMs: 1200, + writeLog, + }); + + await vi.advanceTimersByTimeAsync(1000); + await expect(result).resolves.toBe(0); + expect(chunks).toEqual([]); + expect(writeLog).toHaveBeenCalledTimes(1); + }); + + it("fails and writes the job log when the update body hangs", async () => { + vi.useFakeTimers(); + const chunks: string[] = []; + const writeLog = vi.fn(async () => undefined); + + const result = runTimedUpdateJob({ + append: (chunk) => chunks.push(chunk), + label: "Windows", + run: () => new Promise(() => undefined), + timeoutDescription: "1s", + timeoutMs: 1000, + writeLog, + }); + + await vi.advanceTimersByTimeAsync(1000); + await expect(result).resolves.toBe(1); + expect(chunks).toEqual(["Windows update timed out after 1s\n"]); + expect(writeLog).toHaveBeenCalledTimes(1); + }); +});