From 96b7d9e6d8ea072f15d0ea0456efeb537d5c33bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 7 May 2026 05:52:35 +0100 Subject: [PATCH] fix: preserve mantis recordings on record errors (#78768) --- CHANGELOG.md | 1 + .../src/mantis/visual-task.runtime.test.ts | 87 +++++++++++++++++++ .../qa-lab/src/mantis/visual-task.runtime.ts | 38 +++++--- 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c08f57bfaa9..b03c201b4c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,7 @@ Docs: https://docs.openclaw.ai - Gateway/performance: avoid importing `jiti` on native-loadable plugin startup paths, so compiled bundled plugin surfaces do not pay source-transform loader cost unless fallback loading is actually needed. - Plugins/loader: preserve real compiled plugin module evaluation errors on the native fast path instead of treating every thrown `.js` module as a source-transform fallback miss. Thanks @vincentkoc. - Plugin SDK/fs-safe: expose reusable atomic replacement, sibling-temp writes, and cross-device move fallback helpers through `plugin-sdk/security-runtime`, and move OpenClaw's duplicated safe filesystem write paths onto the shared `@openclaw/fs-safe` package. +- Plugin SDK/fs-safe: route browser, media, channel, and QA external output producers through staged fs-safe writes before final publication. (#78768) - Plugin SDK/fs-safe: rename the public temp workspace helpers to `tempWorkspace`, `withTempWorkspace`, `tempWorkspaceSync`, and `withTempWorkspaceSync`, matching the cleaner `@openclaw/fs-safe` API before the package is published. - Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc. - Providers/OpenRouter: expand app-attribution categories so OpenClaw advertises coding, programming, writing, chat, and personal-agent usage on verified OpenRouter routes. Thanks @vincentkoc. diff --git a/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts b/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts index 37d385e8089..feceed1e25d 100644 --- a/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts +++ b/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts @@ -199,6 +199,93 @@ describe("mantis visual task runtime", () => { }); }); + it("preserves the video artifact when recording fails after writing output", async () => { + const commands: { args: readonly string[]; command: string }[] = []; + let stagedVideoPath = ""; + const runner = vi.fn(async (command: string, args: readonly string[]) => { + commands.push({ command, args }); + if (command === "/tmp/crabbox" && args[0] === "warmup") { + return { stdout: "ready lease cbx_abc123\n", stderr: "" }; + } + if (command === "/tmp/crabbox" && args[0] === "inspect") { + return { + stdout: `${JSON.stringify({ + id: "cbx_abc123", + provider: "hetzner", + slug: "brisk-mantis", + state: "active", + })}\n`, + stderr: "", + }; + } + if (command === "/tmp/crabbox" && args[0] === "record") { + const outputPath = args[args.indexOf("--output") + 1]; + const outputDir = args[args.indexOf("--output-dir") + 1]; + stagedVideoPath = outputPath; + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, "mp4"); + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(path.join(outputDir, "visual-task.png"), "png"); + await fs.writeFile( + path.join(outputDir, "mantis-visual-task-driver-result.json"), + `${JSON.stringify({ + browserUrl: "https://example.net", + finishedAt: "2026-05-04T12:00:05.000Z", + matched: true, + outputDir, + screenshotPath: path.join(outputDir, "visual-task.png"), + startedAt: "2026-05-04T12:00:01.000Z", + status: "pass", + vision: { + mode: "metadata", + timeoutMs: 120000, + }, + })}\n`, + ); + throw new Error("crabbox record failed after writing video"); + } + return { stdout: "", stderr: "" }; + }); + + const result = await runMantisVisualTask({ + commandRunner: runner, + crabboxBin: "/tmp/crabbox", + env: { PATH: process.env.PATH }, + now: () => new Date("2026-05-04T12:00:00.000Z"), + outputDir: ".artifacts/qa-e2e/mantis/visual-task-recording-preserved", + repoRoot, + settleMs: 0, + visionMode: "metadata", + }); + + expect(result.status).toBe("fail"); + expect(result.videoPath).toBe( + path.join( + repoRoot, + ".artifacts/qa-e2e/mantis/visual-task-recording-preserved/visual-task.mp4", + ), + ); + await expect(fs.readFile(result.videoPath ?? "", "utf8")).resolves.toBe("mp4"); + await expect(fs.stat(stagedVideoPath)).rejects.toThrow(); + const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as { + artifacts?: { videoPath?: string }; + error?: string; + recording?: { error?: string; required: boolean }; + status: string; + }; + expect(summary).toMatchObject({ + artifacts: { + videoPath: result.videoPath, + }, + error: "crabbox record failed after writing video", + recording: { + error: "crabbox record failed after writing video", + required: true, + }, + status: "fail", + }); + }); + it("drives a lease, screenshots it, and verifies image-describe text", async () => { const commands: { args: readonly string[]; command: string }[] = []; const runner = vi.fn(async (command: string, args: readonly string[]) => { diff --git a/extensions/qa-lab/src/mantis/visual-task.runtime.ts b/extensions/qa-lab/src/mantis/visual-task.runtime.ts index f81d655508d..64345ddb568 100644 --- a/extensions/qa-lab/src/mantis/visual-task.runtime.ts +++ b/extensions/qa-lab/src/mantis/visual-task.runtime.ts @@ -291,22 +291,36 @@ async function runCommandWithExternalOutput(params: { command: string; cwd: string; env: NodeJS.ProcessEnv; + preserveOutputOnError?: (params: { error: unknown; tempPath: string }) => Promise; runner: CommandRunner; stdio?: "inherit" | "pipe"; -}) { - return await writeExternalFileWithinRoot({ +}): Promise { + let deferredError: unknown; + await writeExternalFileWithinRoot({ rootDir: path.dirname(params.outputPath), path: path.basename(params.outputPath), - write: async (tempPath) => - await runCommand({ - command: params.command, - args: params.buildArgs(tempPath), - cwd: params.cwd, - env: params.env, - runner: params.runner, - stdio: params.stdio, - }), + write: async (tempPath) => { + try { + await runCommand({ + command: params.command, + args: params.buildArgs(tempPath), + cwd: params.cwd, + env: params.env, + runner: params.runner, + stdio: params.stdio, + }); + } catch (error) { + if (await params.preserveOutputOnError?.({ error, tempPath })) { + deferredError = error; + return; + } + throw error; + } + }, }); + if (deferredError) { + throw deferredError; + } } async function warmupCrabbox(params: { @@ -840,6 +854,8 @@ export async function runMantisVisualTask( ], cwd: repoRoot, env, + preserveOutputOnError: async ({ tempPath }) => + (await pathExists(driverResultPath)) && (await nonEmptyFileExists(tempPath)), runner, stdio: "inherit", });