mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(ui): prevent browser screenshot fetch hangs (#107703)
* fix(ui): prevent browser screenshot fetch hangs * test(ui): cover stalled screenshot body * test(ui): verify screenshot timer cleanup * test(ui): cover successful timer cleanup --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
f638cbd0f9
commit
f1778ad36b
@@ -0,0 +1,129 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchBrowserScreenshotDataUrl } from "./browser-client.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchBrowserScreenshotDataUrl", () => {
|
||||
it("returns the fetched screenshot as a data URL", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const screenshot = new Blob(["image-bytes"], { type: "image/png" });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn<typeof fetch>(async () => ({ ok: true, blob: async () => screenshot }) as Response),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchBrowserScreenshotDataUrl({
|
||||
basePath: "/openclaw/",
|
||||
authToken: null,
|
||||
path: "/tmp/browser shot.png",
|
||||
}),
|
||||
).resolves.toBe("data:image/png;base64,aW1hZ2UtYnl0ZXM=");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects unsuccessful screenshot responses", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn<typeof fetch>(async () => new Response(null, { status: 404 })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchBrowserScreenshotDataUrl({
|
||||
basePath: "/openclaw",
|
||||
authToken: null,
|
||||
path: "/tmp/missing.png",
|
||||
}),
|
||||
).rejects.toThrow("screenshot fetch failed (404)");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("aborts a stalled screenshot fetch after the request deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
const signal = init?.signal;
|
||||
if (!signal) {
|
||||
throw new Error("missing screenshot fetch signal");
|
||||
}
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
// Fetch rejects with the exact abort reason. DOM types define it as an Error,
|
||||
// although jsdom does not preserve that prototype relationship at runtime.
|
||||
reject(signal.reason as Error);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = fetchBrowserScreenshotDataUrl({
|
||||
basePath: "/openclaw",
|
||||
authToken: null,
|
||||
path: "/tmp/browser shot.png",
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(url).toBe("/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fbrowser+shot.png");
|
||||
expect(init?.signal?.aborted).toBe(false);
|
||||
|
||||
const outcome = expect(request).rejects.toMatchObject({ name: "TimeoutError" });
|
||||
await vi.advanceTimersByTimeAsync(29_999);
|
||||
expect(init?.signal?.aborted).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await outcome;
|
||||
expect(init?.signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts a stalled screenshot body after the request deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
let bodyReadStarted = false;
|
||||
const fetchMock = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
const signal = init?.signal;
|
||||
if (!signal) {
|
||||
throw new Error("missing screenshot fetch signal");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
blob: async () => {
|
||||
bodyReadStarted = true;
|
||||
return await new Promise<Blob>((_resolve, reject) => {
|
||||
const rejectWithAbortReason = () => reject(signal.reason as Error);
|
||||
if (signal.aborted) {
|
||||
rejectWithAbortReason();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", rejectWithAbortReason, { once: true });
|
||||
});
|
||||
},
|
||||
} as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = fetchBrowserScreenshotDataUrl({
|
||||
basePath: "/openclaw",
|
||||
authToken: null,
|
||||
path: "/tmp/browser shot.png",
|
||||
});
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(bodyReadStarted).toBe(true);
|
||||
expect(init?.signal?.aborted).toBe(false);
|
||||
|
||||
const outcome = expect(request).rejects.toMatchObject({ name: "TimeoutError" });
|
||||
await vi.advanceTimersByTimeAsync(29_999);
|
||||
expect(init?.signal?.aborted).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await outcome;
|
||||
expect(init?.signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/recor
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
|
||||
const BROWSER_REQUEST_METHOD = "browser.request";
|
||||
const BROWSER_SCREENSHOT_FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type BrowserPanelTab = {
|
||||
/**
|
||||
@@ -320,15 +321,26 @@ export async function fetchBrowserScreenshotDataUrl(params: {
|
||||
if (params.authToken) {
|
||||
headers.set("Authorization", `Bearer ${params.authToken}`);
|
||||
}
|
||||
const res = await fetch(`${basePath}/__openclaw__/assistant-media?${search.toString()}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`screenshot fetch failed (${res.status})`);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(new DOMException("screenshot fetch timed out", "TimeoutError")),
|
||||
BROWSER_SCREENSHOT_FETCH_TIMEOUT_MS,
|
||||
);
|
||||
let blob: Blob;
|
||||
try {
|
||||
const res = await fetch(`${basePath}/__openclaw__/assistant-media?${search.toString()}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
credentials: "same-origin",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`screenshot fetch failed (${res.status})`);
|
||||
}
|
||||
blob = await res.blob();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
|
||||
Reference in New Issue
Block a user