diff --git a/frontend/editor/src/core/services/httpErrorHandler.test.ts b/frontend/editor/src/core/services/httpErrorHandler.test.ts new file mode 100644 index 0000000000..8228cba831 --- /dev/null +++ b/frontend/editor/src/core/services/httpErrorHandler.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { handleHttpError } from "@app/services/httpErrorHandler"; + +const alertMock = vi.hoisted(() => vi.fn()); +vi.mock("@app/components/toast", () => ({ + alert: (options: unknown) => alertMock(options), +})); + +// Reproduces a tool POST: responseType "blob" makes error.response.data a Blob. +// jsdom's Blob has no .text(), so mirror the browser API the decode path uses. +function blobAxiosError(status: number, body: string, contentType: string) { + return { + isAxiosError: true, + config: { url: "/api/v1/general/merge-pdfs" }, + response: { + status, + statusText: "", + data: { type: contentType, text: () => Promise.resolve(body) }, + }, + }; +} + +describe("handleHttpError — blob error bodies", () => { + beforeEach(() => { + alertMock.mockClear(); + }); + + test("shows the decoded server message from a JSON blob body", async () => { + await handleHttpError( + blobAxiosError( + 500, + JSON.stringify({ message: "JPDFium merge failed" }), + "application/json", + ), + ); + + expect(alertMock).toHaveBeenCalledTimes(1); + expect(alertMock.mock.calls[0][0]).toMatchObject({ + title: "Server error", + body: "JPDFium merge failed", + }); + }); + + test("shows the decoded server message from a plain-text blob body", async () => { + await handleHttpError( + blobAxiosError(500, "JPDFium merge failed", "text/plain"), + ); + + expect(alertMock).toHaveBeenCalledTimes(1); + expect(alertMock.mock.calls[0][0]).toMatchObject({ + body: "JPDFium merge failed", + }); + }); +}); diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index 55b0a425f9..81bdd1cbd6 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -147,10 +147,9 @@ export async function handleHttpError(error: any): Promise { if (handleSaaSError(error)) return true; - // Compute title/body (friendly) from the error object - const { title, body } = extractAxiosErrorMessage(error); - - // Normalize response data ONCE, reuse for both ID extraction and special-toast matching + // Decode response data ONCE. Tool POSTs use responseType "blob", so + // error.response.data is an unread Blob here. The decoded body drives the + // toast message, file-ID extraction, and special-toast matching alike. const raw = error?.response?.data as any; let normalized: unknown = raw; try { @@ -159,6 +158,9 @@ export async function handleHttpError(error: any): Promise { console.debug("normalizeAxiosErrorData", e); } + // Compute title/body (friendly) from the decoded body, not the raw Blob. + const { title, body } = extractAxiosErrorMessage(error, normalized); + // 1) If server sends structured file IDs for failures, also mark them errored in UI try { const ids = extractErrorFileIds(normalized); diff --git a/frontend/editor/src/core/services/httpErrorUtils.ts b/frontend/editor/src/core/services/httpErrorUtils.ts index b3b54e2664..60ec75bb8f 100644 --- a/frontend/editor/src/core/services/httpErrorUtils.ts +++ b/frontend/editor/src/core/services/httpErrorUtils.ts @@ -25,7 +25,10 @@ function titleForStatus(status?: number): string { return "Request failed"; } -export function extractAxiosErrorMessage(error: any): { +export function extractAxiosErrorMessage( + error: any, + decodedData?: unknown, +): { title: string; body: string; } { @@ -33,7 +36,10 @@ export function extractAxiosErrorMessage(error: any): { const status = error.response?.status; const _statusText = error.response?.statusText || ""; let parsed: any = undefined; - const raw = error.response?.data; + // Tool POSTs use responseType "blob", so error.response.data is an unread + // Blob. Prefer the decoded body when the caller supplies it, otherwise the + // message falls back to the generic text and the server reason is lost. + const raw = decodedData !== undefined ? decodedData : error.response?.data; if (typeof raw === "string") { try { parsed = JSON.parse(raw);