mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(qa-lab): prevent dashboard API hangs (#107701)
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { defaultQaModelForMode, isQaFastModeEnabled } from "../../model-selection.js";
|
||||
import { normalizeCaptureSavedView, normalizeCaptureSavedViews } from "./capture-saved-view.js";
|
||||
import { formatErrorMessage } from "./errors.js";
|
||||
import { getJson, getJsonNoStore, postJson } from "./http.js";
|
||||
import {
|
||||
type Bootstrap,
|
||||
type EvidenceEnvelope,
|
||||
@@ -19,34 +20,6 @@ import {
|
||||
type UiState,
|
||||
renderQaLabUi,
|
||||
} from "./ui-render.js";
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function getJsonNoStore<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(payload.error || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function countCaptureDimension(
|
||||
events: UiState["captureEvents"],
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getJson, getJsonNoStore, postJson } from "./http.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("QA Lab dashboard HTTP", () => {
|
||||
it("gives every API request a fresh 30 second deadline", async () => {
|
||||
const controllers = [new AbortController(), new AbortController(), new AbortController()];
|
||||
const timeout = vi.spyOn(AbortSignal, "timeout").mockImplementation((timeoutMs) => {
|
||||
expect(timeoutMs).toBe(30_000);
|
||||
const controller = controllers.shift();
|
||||
if (!controller) {
|
||||
throw new Error("unexpected timeout signal request");
|
||||
}
|
||||
return controller.signal;
|
||||
});
|
||||
const fetchMock = vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await getJson("/api/bootstrap");
|
||||
await getJsonNoStore("/api/snapshot");
|
||||
await postJson("/api/runner/start", { scenario: "baseline" });
|
||||
|
||||
expect(timeout).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/bootstrap",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/snapshot",
|
||||
expect.objectContaining({ cache: "no-store", signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"/api/runner/start",
|
||||
expect.objectContaining({ method: "POST", signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a stalled request when its deadline aborts", async () => {
|
||||
const timeoutController = new AbortController();
|
||||
vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn<typeof fetch>(async (_input, init) => {
|
||||
const signal = init?.signal;
|
||||
if (!signal) {
|
||||
throw new Error("missing request 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 },
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const request = getJson("/api/stalled");
|
||||
timeoutController.abort(new DOMException("request deadline exceeded", "TimeoutError"));
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: "TimeoutError" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
const QA_LAB_API_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
function createRequestSignal(): AbortSignal {
|
||||
return AbortSignal.timeout(QA_LAB_API_REQUEST_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path, { signal: createRequestSignal() });
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function getJsonNoStore<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
cache: "no-store",
|
||||
signal: createRequestSignal(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: createRequestSignal(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(payload.error || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -48,9 +48,10 @@ const allowedRawFetchCallsites = new Set([
|
||||
bundledPluginCallsite("qa-lab", "src/gateway-child.ts", 489),
|
||||
bundledPluginCallsite("qa-lab", "src/suite.ts", 330),
|
||||
bundledPluginCallsite("qa-lab", "src/suite.ts", 341),
|
||||
bundledPluginCallsite("qa-lab", "web/src/app.ts", 23),
|
||||
bundledPluginCallsite("qa-lab", "web/src/app.ts", 31),
|
||||
bundledPluginCallsite("qa-lab", "web/src/app.ts", 39),
|
||||
// The QA dashboard calls its same-origin local API from the browser, where server SSRF helpers do not run.
|
||||
bundledPluginCallsite("qa-lab", "web/src/http.ts", 8),
|
||||
bundledPluginCallsite("qa-lab", "web/src/http.ts", 16),
|
||||
bundledPluginCallsite("qa-lab", "web/src/http.ts", 27),
|
||||
bundledPluginCallsite("qqbot", "src/engine/api/api-client.ts", 124),
|
||||
bundledPluginCallsite("qqbot", "src/engine/api/media-chunked.ts", 554),
|
||||
bundledPluginCallsite("qqbot", "src/engine/api/token.ts", 211),
|
||||
|
||||
Reference in New Issue
Block a user