From 1883d9d9d37b46e94858a891783e49484d8511f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 22 Apr 2026 19:04:53 -0400 Subject: [PATCH] fix(errors): warn on raw stringify fallback --- src/main/errors/taxonomy.ts | 16 +++++++++++- tests/unit/error-taxonomy.test.ts | 42 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/unit/error-taxonomy.test.ts diff --git a/src/main/errors/taxonomy.ts b/src/main/errors/taxonomy.ts index 46bf3b6e..ca6f73b9 100644 --- a/src/main/errors/taxonomy.ts +++ b/src/main/errors/taxonomy.ts @@ -41,6 +41,8 @@ * and ideally the process. */ +import log from "electron-log/main.js"; + export type ErrorKind = "recoverable" | "user_input" | "upstream" | "fatal"; export interface AppErrorMeta { @@ -146,6 +148,8 @@ export class FatalError extends AppError { } } +let rawStringifyFallbackWarned = false; + /** * Wrap an unknown thrown value into an AppError. * @@ -199,14 +203,24 @@ function stringifyRaw(value: unknown): string { } try { return JSON.stringify(value); - } catch { + } catch (error) { // The raw value is a cyclic / un-serialisable object. `String(value)` // falls back to "[object Object]" which is still better than losing // the fact that something was thrown. + warnRawStringifyFallback(error); return String(value); } } +function warnRawStringifyFallback(error: unknown): void { + if (rawStringifyFallbackWarned) return; + rawStringifyFallbackWarned = true; + log.warn( + "[errors] stringifyRaw failed; using String(value) for raw thrown value.", + error instanceof Error ? error.message : String(error), + ); +} + /** * Convenience narrowing predicate. Intentional: avoids forcing every * consumer to re-import `AppError` just to write an `instanceof` guard. diff --git a/tests/unit/error-taxonomy.test.ts b/tests/unit/error-taxonomy.test.ts new file mode 100644 index 00000000..31ed151d --- /dev/null +++ b/tests/unit/error-taxonomy.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron-log/main.js", () => ({ + default: { + warn: vi.fn(), + }, +})); + +import log from "electron-log/main.js"; +import { classifyUnknownError } from "../../src/main/errors/taxonomy"; + +const warn = vi.mocked(log.warn); + +describe("error taxonomy", () => { + beforeEach(() => { + warn.mockClear(); + }); + + it("warns once when raw thrown values cannot be JSON stringified", () => { + const raw: { self?: unknown } = {}; + raw.self = raw; + + const first = classifyUnknownError(raw, { + kind: "recoverable", + code: "test.raw.cyclic", + message: "Cyclic raw value.", + }); + const second = classifyUnknownError(raw, { + kind: "recoverable", + code: "test.raw.cyclic", + message: "Cyclic raw value.", + }); + + expect(first.context?.raw).toBe("[object Object]"); + expect(second.context?.raw).toBe("[object Object]"); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "[errors] stringifyRaw failed; using String(value) for raw thrown value.", + expect.stringContaining("circular"), + ); + }); +});