From b48901f7ce730ab6e594840eebc029626e01646c Mon Sep 17 00:00:00 2001 From: Eli Holmes Date: Thu, 27 Aug 2026 22:22:59 +0000 Subject: [PATCH 1/2] fix(lib): name the codec when a dataset cannot be decoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dataset whose chunks use a codec gridlook has no decoder for fails in a way that is indistinguishable from a slow or unreachable host: the store opens, every variable is listed, and only the first chunk read fails — under a "Could not fetch data" heading that sends the reader to the network, which is not where the problem is. zarrita names the codec precisely at that point, but nothing carried the name through to the user. src/lib/data/codecErrors.ts recognises three cases and rewrites each into a message naming the culprit: - an unregistered codec -> "Unsupported codec: numcodecs.quantize" - a codec that rejected a chunk -> the codec, plus the reason it threw - a data type the browser lacks -> the type, plus the browsers that have it Behaviour is unchanged and no decoder is added: an unsupported dataset still fails, it just says why — and says it precisely enough to report. Errors are matched both structurally and by message text, because the grid data worker flattens them to a plain string before posting back, which loses the error class and its fields. That flattening also dropped the reason a codec threw, which zarrita puts on `cause`, so gridData.worker.ts now appends it. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/data/codecErrors.ts | 143 ++++++++++++++++++++ src/lib/grids/gridData.worker.ts | 3 +- src/ui/common/useLog.ts | 15 ++- tests/unit/lib/data/codecErrors.test.ts | 170 ++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 src/lib/data/codecErrors.ts create mode 100644 tests/unit/lib/data/codecErrors.test.ts diff --git a/src/lib/data/codecErrors.ts b/src/lib/data/codecErrors.ts new file mode 100644 index 00000000..03405a37 --- /dev/null +++ b/src/lib/data/codecErrors.ts @@ -0,0 +1,143 @@ +import * as zarr from "zarrita"; + +/** + * A dataset whose chunks use a codec gridlook has no decoder for behaves + * exactly like a dataset that is merely slow or unreachable: the repository + * opens, every variable is listed, and the failure only arrives on the first + * chunk read. zarrita raises a precise error at that point — it names the + * codec — but the message reaches the reader under a "Could not fetch data" + * heading that points at the network, which is the wrong place to look. + * + * This module classifies those errors and rewrites them into something that + * says which codec is missing. It changes no behaviour and adds no data path: + * an unsupported dataset still fails, it just says why. Naming the codec is + * also what makes the gap reportable — an unsupported codec is invisible until + * someone runs into a dataset that uses it. + * + * Errors are matched both structurally and by message text, because the grid + * data worker flattens errors to a plain string before posting them back to + * the main thread (see `src/lib/grids/gridData.worker.ts`), so by the time one + * reaches the UI its class and fields are gone. + */ + +export type TDataErrorExplanation = { + heading: string; + detail: string; +}; + +const UNKNOWN_CODEC = /^Unknown codec:\s*(\S+)/; +const CODEC_PIPELINE = /^Failed to (?:de|en)code chunk via codec "([^"]+)"/; +const UNSUPPORTED_DATA_TYPE = /^Unknown or unsupported dataType:\s*(\S+)/; + +/** The `: reason` the worker appends when flattening a wrapped error. */ +const FLATTENED_CAUSE = /(?:^|\s)—\s(.+)$/; + +function messageOf(error: unknown) { + if (error instanceof Error) { + return error.message; + } + return typeof error === "string" ? error : ""; +} + +function causeMessageOf(error: unknown) { + if (error instanceof Error && error.cause instanceof Error) { + return error.cause.message; + } + // Across the worker boundary the cause survives only as flattened text. + return messageOf(error).match(FLATTENED_CAUSE)?.[1]; +} + +function unknownCodecName(error: unknown) { + if (zarr.isZarritaError(error, "UnknownCodecError")) { + return error.codec; + } + return messageOf(error).match(UNKNOWN_CODEC)?.[1]; +} + +function failingCodecName(error: unknown) { + if (zarr.isZarritaError(error, "CodecPipelineError")) { + return error.codec; + } + return messageOf(error).match(CODEC_PIPELINE)?.[1]; +} + +function unsupportedDataType(error: unknown) { + return messageOf(error).match(UNSUPPORTED_DATA_TYPE)?.[1]; +} + +function explainUnknownCodec(codec: string): TDataErrorExplanation { + return { + heading: `Unsupported codec: ${codec}`, + detail: + `This dataset's chunks are compressed with "${codec}", which gridlook ` + + `has no decoder for. The data and the connection are fine — only the ` + + `codec is missing. Quote that codec name when reporting it.`, + }; +} + +function explainCodecFailure( + codec: string, + cause: string | undefined +): TDataErrorExplanation { + return { + heading: `Codec failed: ${codec}`, + detail: cause + ? `Decoding a chunk with "${codec}" failed: ${cause}` + : `Decoding a chunk with "${codec}" failed. The codec is available but ` + + `rejected this dataset's chunks.`, + }; +} + +function explainUnsupportedDataType(dataType: string): TDataErrorExplanation { + const isFloat16 = dataType.startsWith("float16"); + return { + heading: `Unsupported data type: ${dataType}`, + detail: isFloat16 + ? `This variable is stored as ${dataType}, which this browser cannot ` + + `represent. Chrome 135, Firefox 129 or Safari 26 and newer support it.` + : `This variable is stored as ${dataType}, which gridlook cannot read.`, + }; +} + +/** + * Recognise a codec or data-type failure and describe it in the reader's + * terms. Returns `undefined` for everything else, so callers fall back to + * whatever they showed before. + */ +export function explainDataError( + error: unknown +): TDataErrorExplanation | undefined { + const missingCodec = unknownCodecName(error); + if (missingCodec) { + return explainUnknownCodec(missingCodec); + } + + const brokenCodec = failingCodecName(error); + if (brokenCodec) { + return explainCodecFailure(brokenCodec, causeMessageOf(error)); + } + + const dataType = unsupportedDataType(error); + if (dataType) { + return explainUnsupportedDataType(dataType); + } + + 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 af94fb7d..c48018dd 100644 --- a/src/lib/grids/gridData.worker.ts +++ b/src/lib/grids/gridData.worker.ts @@ -1,5 +1,6 @@ /// +import { flattenErrorMessage } from "@/lib/data/codecErrors.ts"; import { ZarrDataManager } from "@/lib/data/ZarrDataManager.ts"; import { GridDataWorkerMessageType, @@ -35,7 +36,7 @@ workerScope.onmessage = async (event: MessageEvent) => { const response: TGridDataWorkerResponse = { requestId, type: GridDataWorkerMessageType.ERROR, - message: error instanceof Error ? error.message : String(error), + message: flattenErrorMessage(error), }; workerScope.postMessage(response); } diff --git a/src/ui/common/useLog.ts b/src/ui/common/useLog.ts index db616910..a2d9cab4 100644 --- a/src/ui/common/useLog.ts +++ b/src/ui/common/useLog.ts @@ -1,17 +1,26 @@ import { ToastType, useToast } from "./useToast.ts"; +import { explainDataError } 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 +// stays up longer than a transient error. +const EXPLAINED_ERROR_DURATION = 12000; + export function useLog() { const { addToast } = useToast(); function logError(maybeError: unknown, context?: string) { const error = toNormalizedError(maybeError); console.error(context, error, error?.stack); - const prefix = context ?? "Error"; + // 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 prefix = explanation?.heading ?? context ?? "Error"; addToast(prefix, { - detail: `${getErrorMessage(error)}`, - duration: 4000, + detail: explanation?.detail ?? `${getErrorMessage(error)}`, + duration: explanation ? EXPLAINED_ERROR_DURATION : 4000, type: ToastType.DANGER, }); } diff --git a/tests/unit/lib/data/codecErrors.test.ts b/tests/unit/lib/data/codecErrors.test.ts new file mode 100644 index 00000000..b15a8b91 --- /dev/null +++ b/tests/unit/lib/data/codecErrors.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { get, open } from "zarrita"; + +import "@/lib/data/codecs.ts"; +import { + explainDataError, + flattenErrorMessage, +} from "@/lib/data/codecErrors.ts"; + +/** + * Every error fed to `explainDataError` 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 + * through `flattenErrorMessage` plus `new Error(...)` that the grid data + * worker performs, which strips the error's class and its fields. + */ + +const V2MetadataKey = { + FILL_VALUE: "fill_value", + ZARR_FORMAT: "zarr_format", +} as const; + +function base64Bytes(encoded: string) { + return Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); +} + +/** What `gridData.worker.ts` posts back and `gridDataWorkerClient.ts` rebuilds. */ +function throughWorker(error: unknown) { + return new Error(flattenErrorMessage(error)); +} + +function v2Store(dtype: string, compressor: unknown, chunk: Uint8Array) { + const metadata = new TextEncoder().encode( + JSON.stringify({ + chunks: [4], + compressor, + dtype, + filters: [], + order: "C", + shape: [4], + [V2MetadataKey.FILL_VALUE]: null, + [V2MetadataKey.ZARR_FORMAT]: 2, + }) + ); + return new Map([ + ["/.zarray", metadata], + ["/0", chunk], + ]); +} + +async function readV2(compressor: unknown, chunk: Uint8Array) { + const array = await open.v2(v2Store(") { + return promise.then( + () => undefined, + (error: unknown) => error + ); +} + +describe("an unregistered codec", () => { + async function unknownCodecError() { + return await caught(readV2({ id: "quantize" }, new Uint8Array(16))); + } + + it("names the codec in the heading", async () => { + const explanation = explainDataError(await unknownCodecError()); + expect(explanation?.heading).toBe("Unsupported codec: numcodecs.quantize"); + }); + + it("says the network is not the problem", async () => { + const explanation = explainDataError(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( + throughWorker(await unknownCodecError()) + ); + expect(explanation?.heading).toBe("Unsupported codec: numcodecs.quantize"); + }); +}); + +describe("a registered codec that rejects the chunk", () => { + // A fletcher32 chunk whose trailing checksum does not match its payload. + const CORRUPT_FLETCHER32 = "AACAPwAAAEAAAEBAAACAQAAAAAA="; + + async function codecPipelineError() { + return await caught( + readV2({ id: "fletcher32" }, base64Bytes(CORRUPT_FLETCHER32)) + ); + } + + it("names the codec that failed", async () => { + const explanation = explainDataError(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()); + 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( + throughWorker(await codecPipelineError()) + ); + expect(explanation?.heading).toBe("Codec failed: numcodecs.fletcher32"); + expect(explanation?.detail).toContain("checksum mismatch"); + }); +}); + +describe("a data type this browser cannot represent", () => { + async function unsupportedDataTypeError() { + // zarrita maps float16 onto `globalThis.Float16Array`, which older + // browsers do not have. + const original = Reflect.get(globalThis, "Float16Array"); + Reflect.deleteProperty(globalThis, "Float16Array"); + try { + const store = v2Store(" { + const explanation = explainDataError(await unsupportedDataTypeError()); + expect(explanation?.heading).toBe("Unsupported data type: float16"); + expect(explanation?.detail).toContain("Safari 26"); + }); +}); + +describe("errors that are not codec problems", () => { + const UNRELATED = [ + new Error("Failed to fetch chunk bytes from example.invalid"), + new TypeError("Failed to fetch"), + new Error("Cannot convert a BigInt value to a number"), + new Error("Not found: /some/path"), + "a bare string", + undefined, + ]; + + 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"); + }); +}); From be2dfb0e8db3617289e137b18bec23110c9eec6e Mon Sep 17 00:00:00 2001 From: Eli Holmes Date: Sun, 30 Aug 2026 01:24:54 +0000 Subject: [PATCH 2/2] =?UTF-8?q?refactor(lib):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20narrow=20the=20names,=20move=20the=20generic=20help?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two naming points from review: - `explainDataError` claimed more than it does, so it is now `explainCodecError` (and `TDataErrorExplanation` follows it to `TCodecErrorExplanation`). - `flattenErrorMessage` is not codec-specific at all — it moves to `src/utils/errorHandling.ts` next to `getErrorMessage` and `toNormalizedError`, with its unit tests alongside it in `tests/unit/utils/errorHandling.test.ts`. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0162gtw32ckX3EFQFPqs74dN --- src/lib/data/codecErrors.ts | 29 ++++-------------- src/lib/grids/gridData.worker.ts | 2 +- src/ui/common/useLog.ts | 4 +-- src/utils/errorHandling.ts | 17 +++++++++++ tests/unit/lib/data/codecErrors.test.ts | 39 +++++++------------------ tests/unit/utils/errorHandling.test.ts | 18 ++++++++++++ 6 files changed, 55 insertions(+), 54 deletions(-) create mode 100644 tests/unit/utils/errorHandling.test.ts 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"); + }); +});