From e9d76ae2a7675d5be181f0dff77efa0ed8a94338 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Wed, 15 Jul 2026 11:45:57 +0800
Subject: [PATCH] fix(qa-lab): prevent dashboard API hangs (#107701)
---
extensions/qa-lab/web/src/app.ts | 29 +---------
extensions/qa-lab/web/src/http.test.ts | 79 ++++++++++++++++++++++++++
extensions/qa-lab/web/src/http.ts | 38 +++++++++++++
scripts/check-no-raw-channel-fetch.mjs | 7 ++-
4 files changed, 122 insertions(+), 31 deletions(-)
create mode 100644 extensions/qa-lab/web/src/http.test.ts
create mode 100644 extensions/qa-lab/web/src/http.ts
diff --git a/extensions/qa-lab/web/src/app.ts b/extensions/qa-lab/web/src/app.ts
index 5b4b1c3eec27..73f8267b0e7e 100644
--- a/extensions/qa-lab/web/src/app.ts
+++ b/extensions/qa-lab/web/src/app.ts
@@ -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(path: string): Promise {
- const response = await fetch(path);
- if (!response.ok) {
- throw new Error(`${response.status} ${response.statusText}`);
- }
- return (await response.json()) as T;
-}
-
-async function getJsonNoStore(path: string): Promise {
- 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(path: string, body: unknown): Promise {
- 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"],
diff --git a/extensions/qa-lab/web/src/http.test.ts b/extensions/qa-lab/web/src/http.test.ts
new file mode 100644
index 000000000000..90a9f4e3fc74
--- /dev/null
+++ b/extensions/qa-lab/web/src/http.test.ts
@@ -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(
+ 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(async (_input, init) => {
+ const signal = init?.signal;
+ if (!signal) {
+ throw new Error("missing request signal");
+ }
+ return await new Promise((_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" });
+ });
+});
diff --git a/extensions/qa-lab/web/src/http.ts b/extensions/qa-lab/web/src/http.ts
new file mode 100644
index 000000000000..7914fbfc9a73
--- /dev/null
+++ b/extensions/qa-lab/web/src/http.ts
@@ -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(path: string): Promise {
+ 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(path: string): Promise {
+ 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(path: string, body: unknown): Promise {
+ 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;
+}
diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs
index c4e2549e9500..96a9568f54a6 100644
--- a/scripts/check-no-raw-channel-fetch.mjs
+++ b/scripts/check-no-raw-channel-fetch.mjs
@@ -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),