fix(qa-lab): prevent dashboard API hangs (#107701)

This commit is contained in:
Alix-007
2026-07-15 11:45:57 +08:00
committed by GitHub
parent 1878c57a46
commit e9d76ae2a7
4 changed files with 122 additions and 31 deletions

View File

@@ -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"],

View File

@@ -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" });
});
});

View File

@@ -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;
}

View File

@@ -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),