diff --git a/src/lib/data/codecErrors.ts b/src/lib/data/codecErrors.ts index 03405a37..928bdb9f 100644 --- a/src/lib/data/codecErrors.ts +++ b/src/lib/data/codecErrors.ts @@ -20,7 +20,7 @@ import * as zarr from "zarrita"; * reaches the UI its class and fields are gone. */ -export type TDataErrorExplanation = { +export type TCodecErrorExplanation = { heading: string; detail: string; }; @@ -65,7 +65,7 @@ function unsupportedDataType(error: unknown) { return messageOf(error).match(UNSUPPORTED_DATA_TYPE)?.[1]; } -function explainUnknownCodec(codec: string): TDataErrorExplanation { +function explainUnknownCodec(codec: string): TCodecErrorExplanation { return { heading: `Unsupported codec: ${codec}`, detail: @@ -78,7 +78,7 @@ function explainUnknownCodec(codec: string): TDataErrorExplanation { function explainCodecFailure( codec: string, cause: string | undefined -): TDataErrorExplanation { +): TCodecErrorExplanation { return { heading: `Codec failed: ${codec}`, detail: cause @@ -88,7 +88,7 @@ function explainCodecFailure( }; } -function explainUnsupportedDataType(dataType: string): TDataErrorExplanation { +function explainUnsupportedDataType(dataType: string): TCodecErrorExplanation { const isFloat16 = dataType.startsWith("float16"); return { heading: `Unsupported data type: ${dataType}`, @@ -104,9 +104,9 @@ function explainUnsupportedDataType(dataType: string): TDataErrorExplanation { * terms. Returns `undefined` for everything else, so callers fall back to * whatever they showed before. */ -export function explainDataError( +export function explainCodecError( error: unknown -): TDataErrorExplanation | undefined { +): TCodecErrorExplanation | undefined { const missingCodec = unknownCodecName(error); if (missingCodec) { return explainUnknownCodec(missingCodec); @@ -124,20 +124,3 @@ export function explainDataError( return undefined; } - -/** - * Flatten an error to a string that survives `postMessage`. - * - * zarrita reports a codec that threw as a `CodecPipelineError` naming the - * codec, with the reason it threw — a checksum mismatch, a truncated chunk — - * on `cause`. Structured-cloning an Error keeps neither the subclass nor the - * cause, so a worker that posts back only `error.message` drops the reason - * before anyone can read it. Appending the cause keeps both halves. - */ -export function flattenErrorMessage(error: unknown): string { - if (!(error instanceof Error)) { - return String(error); - } - const cause = error.cause instanceof Error ? error.cause.message : undefined; - return cause ? `${error.message} — ${cause}` : error.message; -} diff --git a/src/lib/grids/gridData.worker.ts b/src/lib/grids/gridData.worker.ts index c48018dd..964dff9b 100644 --- a/src/lib/grids/gridData.worker.ts +++ b/src/lib/grids/gridData.worker.ts @@ -1,12 +1,12 @@ /// -import { flattenErrorMessage } from "@/lib/data/codecErrors.ts"; import { ZarrDataManager } from "@/lib/data/ZarrDataManager.ts"; import { GridDataWorkerMessageType, type TGridDataWorkerRequest, type TGridDataWorkerResponse, } from "@/lib/grids/gridDataWorkerProtocol.ts"; +import { flattenErrorMessage } from "@/utils/errorHandling.ts"; const workerScope = self as unknown as DedicatedWorkerGlobalScope; diff --git a/src/ui/common/useLog.ts b/src/ui/common/useLog.ts index a2d9cab4..4e37544d 100644 --- a/src/ui/common/useLog.ts +++ b/src/ui/common/useLog.ts @@ -1,6 +1,6 @@ import { ToastType, useToast } from "./useToast.ts"; -import { explainDataError } from "@/lib/data/codecErrors.ts"; +import { explainCodecError } from "@/lib/data/codecErrors.ts"; import { getErrorMessage, toNormalizedError } from "@/utils/errorHandling.ts"; // A codec or data-type failure is worth reading and worth writing down, so it @@ -16,7 +16,7 @@ export function useLog() { // A recognised codec problem describes itself better than the call site // can: "Could not fetch data" sends the reader to the network, which is // not where the problem is. - const explanation = explainDataError(maybeError); + const explanation = explainCodecError(maybeError); const prefix = explanation?.heading ?? context ?? "Error"; addToast(prefix, { detail: explanation?.detail ?? `${getErrorMessage(error)}`, diff --git a/src/utils/errorHandling.ts b/src/utils/errorHandling.ts index 852b4ff6..ac7afac1 100644 --- a/src/utils/errorHandling.ts +++ b/src/utils/errorHandling.ts @@ -37,3 +37,20 @@ export function getErrorMessage(error: unknown) { // strip quotation marks added by JSON.stringify return errorMessage.replace(/^"(.*)"$/, "$1"); } + +/** + * Flatten an error to a string that survives `postMessage`. + * + * zarrita reports a codec that threw as a `CodecPipelineError` naming the + * codec, with the reason it threw — a checksum mismatch, a truncated chunk — + * on `cause`. Structured-cloning an Error keeps neither the subclass nor the + * cause, so a worker that posts back only `error.message` drops the reason + * before anyone can read it. Appending the cause keeps both halves. + */ +export function flattenErrorMessage(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + const cause = error.cause instanceof Error ? error.cause.message : undefined; + return cause ? `${error.message} — ${cause}` : error.message; +} diff --git a/tests/unit/lib/data/codecErrors.test.ts b/tests/unit/lib/data/codecErrors.test.ts index b15a8b91..7fa055f0 100644 --- a/tests/unit/lib/data/codecErrors.test.ts +++ b/tests/unit/lib/data/codecErrors.test.ts @@ -2,13 +2,11 @@ import { describe, expect, it } from "vitest"; import { get, open } from "zarrita"; import "@/lib/data/codecs.ts"; -import { - explainDataError, - flattenErrorMessage, -} from "@/lib/data/codecErrors.ts"; +import { explainCodecError } from "@/lib/data/codecErrors.ts"; +import { flattenErrorMessage } from "@/utils/errorHandling.ts"; /** - * Every error fed to `explainDataError` here is one zarrita actually threw, + * Every error fed to `explainCodecError` here is one zarrita actually threw, * not a hand-written stand-in, so the patterns stay tied to the real messages. * * Each case is checked twice: once as thrown, and once after the round trip @@ -69,18 +67,18 @@ describe("an unregistered codec", () => { } it("names the codec in the heading", async () => { - const explanation = explainDataError(await unknownCodecError()); + const explanation = explainCodecError(await unknownCodecError()); expect(explanation?.heading).toBe("Unsupported codec: numcodecs.quantize"); }); it("says the network is not the problem", async () => { - const explanation = explainDataError(await unknownCodecError()); + const explanation = explainCodecError(await unknownCodecError()); expect(explanation?.detail).toContain("numcodecs.quantize"); expect(explanation?.detail).toContain("connection are fine"); }); it("is still recognised after the worker flattens it", async () => { - const explanation = explainDataError( + const explanation = explainCodecError( throughWorker(await unknownCodecError()) ); expect(explanation?.heading).toBe("Unsupported codec: numcodecs.quantize"); @@ -98,19 +96,19 @@ describe("a registered codec that rejects the chunk", () => { } it("names the codec that failed", async () => { - const explanation = explainDataError(await codecPipelineError()); + const explanation = explainCodecError(await codecPipelineError()); expect(explanation?.heading).toBe("Codec failed: numcodecs.fletcher32"); }); it("keeps the reason zarrita put on the cause", async () => { - const explanation = explainDataError(await codecPipelineError()); + const explanation = explainCodecError(await codecPipelineError()); expect(explanation?.detail).toContain("checksum mismatch"); }); it("keeps the reason across the worker boundary", async () => { // Without `flattenErrorMessage` the cause is dropped by structured clone // and the reader is told only that some codec failed. - const explanation = explainDataError( + const explanation = explainCodecError( throughWorker(await codecPipelineError()) ); expect(explanation?.heading).toBe("Codec failed: numcodecs.fletcher32"); @@ -133,7 +131,7 @@ describe("a data type this browser cannot represent", () => { } it("names the data type and the browsers that support it", async () => { - const explanation = explainDataError(await unsupportedDataTypeError()); + const explanation = explainCodecError(await unsupportedDataTypeError()); expect(explanation?.heading).toBe("Unsupported data type: float16"); expect(explanation?.detail).toContain("Safari 26"); }); @@ -150,21 +148,6 @@ describe("errors that are not codec problems", () => { ]; it.each(UNRELATED)("passes through %s", (error) => { - expect(explainDataError(error)).toBeUndefined(); - }); -}); - -describe("flattenErrorMessage", () => { - it("returns the message when there is no cause", () => { - expect(flattenErrorMessage(new Error("plain"))).toBe("plain"); - }); - - it("appends the cause when there is one", () => { - const error = new Error("outer", { cause: new Error("inner") }); - expect(flattenErrorMessage(error)).toBe("outer — inner"); - }); - - it("stringifies a non-error", () => { - expect(flattenErrorMessage("oops")).toBe("oops"); + expect(explainCodecError(error)).toBeUndefined(); }); }); diff --git a/tests/unit/utils/errorHandling.test.ts b/tests/unit/utils/errorHandling.test.ts new file mode 100644 index 00000000..fa349740 --- /dev/null +++ b/tests/unit/utils/errorHandling.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { flattenErrorMessage } from "@/utils/errorHandling.ts"; + +describe("flattenErrorMessage", () => { + it("returns the message when there is no cause", () => { + expect(flattenErrorMessage(new Error("plain"))).toBe("plain"); + }); + + it("appends the cause when there is one", () => { + const error = new Error("outer", { cause: new Error("inner") }); + expect(flattenErrorMessage(error)).toBe("outer — inner"); + }); + + it("stringifies a non-error", () => { + expect(flattenErrorMessage("oops")).toBe("oops"); + }); +});