// That a handled UI failure reaches the crash reporter as a titled error is // covered end to end by e2e/cloud/frontend-error-reporting.test.ts, which reads // the report off the wire. What is left here is the input shapes a browser run // cannot produce on demand — a thrown string, a tagged error declared without a // `message` field, an already-normalized error — at the pure function that has // to handle each of them. import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Exit from "effect/Exit"; import { messageFromExit, messageFromUnknown, reportExitFailure, toReportableError, type FrontendErrorContext, } from "./error-reporting"; const context: FrontendErrorContext = { surface: "api_client", action: "decode_or_transport", }; /** * These tests exercise the adapter between Effect's failure model and a crash * reporter, so a built-in `Error` is the fixture the adapter exists to accept * and to produce. */ // oxlint-disable-next-line executor/no-error-constructor, executor/no-redundant-error-factory -- boundary: fixture for the raw-Error adapter under test const rawError = (message: string): Error => new Error(message); const captureReports = (): { readonly calls: Array<{ error: unknown; context: FrontendErrorContext }>; readonly report: (error: unknown, context: FrontendErrorContext) => void; } => { const calls: Array<{ error: unknown; context: FrontendErrorContext }> = []; return { calls, report: (error, ctx) => calls.push({ error, context: ctx }) }; }; describe("frontend error reporting", () => { it("extracts stable messages from structured failures", () => { expect(messageFromUnknown({ message: "Saved connection failed" }, "Fallback")).toBe( "Saved connection failed", ); expect(messageFromUnknown("Plain failure", "Fallback")).toBe("Plain failure"); expect(messageFromUnknown({ reason: "unknown" }, "Fallback")).toBe("Fallback"); }); it("extracts stable messages from Effect exits", () => { const exit = Exit.fail({ message: "Could not update integration" }); expect(messageFromExit(exit, "Fallback")).toBe("Could not update integration"); expect(messageFromExit(Exit.fail({ reason: "unknown" }), "Fallback")).toBe("Fallback"); }); it("reads a message the error exposes as a prototype getter", () => { // Schema-tagged API errors (e.g. OrgWriteDeniedError) declare no `message` // field; the sentence lives on a class getter, which a struct decode misses. class GetterError extends Data.TaggedError("GetterError")<{}> { override get message(): string { return "Requires a workspace admin."; } } const exit = Exit.fail(new GetterError()); expect(messageFromExit(exit, "Fallback")).toBe("Requires a workspace admin."); expect(messageFromUnknown(new GetterError(), "Fallback")).toBe("Requires a workspace admin."); }); it("reports failed exits with the provided context", () => { const exit = Exit.fail({ message: "Could not update integration" }); const { calls, report } = captureReports(); reportExitFailure(report, exit, { surface: "integrations", action: "update", message: "Could not update integration", }); expect(calls).toHaveLength(1); expect(calls[0]!.context.surface).toBe("integrations"); expect(calls[0]!.context.action).toBe("update"); // The originating cause travels with the report rather than being reported // as-is — see the normalization contract below. expect(Cause.isCause((calls[0]!.error as Error).cause)).toBe(true); }); }); class ConnectionRefused extends Data.TaggedError("ConnectionRefused")<{ readonly endpoint: string; }> {} describe("toReportableError", () => { it("returns a real Error untouched", () => { const original = rawError("original failure"); const reportable = toReportableError(original, context); expect(reportable).toBe(original); expect(reportable.message).toBe("original failure"); expect(reportable.stack).toBe(original.stack); }); it("keeps the message and stack of a defect inside a cause", () => { const defect = rawError("render crashed"); const reportable = toReportableError(Cause.die(defect), context); expect(reportable).toBeInstanceOf(Error); expect(reportable.message).toBe("render crashed"); expect(reportable.stack ?? "").toContain("render crashed"); expect(Cause.isCause(reportable.cause)).toBe(true); }); it("titles a thrown string", () => { const reportable = toReportableError("something went sideways", context); expect(reportable).toBeInstanceOf(Error); expect(reportable.message).toBe("something went sideways"); }); it("titles a cause carrying a thrown string", () => { const reportable = toReportableError(Cause.fail("upstream said no"), context); expect(reportable.message).toBe("upstream said no"); }); it("names an Effect tagged error by its tag and never leaves it message-less", () => { const reportable = toReportableError( Cause.fail(new ConnectionRefused({ endpoint: "https://api.example.test" })), { ...context, message: "Could not reach the API" }, ); // A `Data.TaggedError` declared without a `message` field renders with an // empty one — the exact shape that produced `No error message` in Sentry. expect(reportable.name).toBe("ConnectionRefused"); expect(reportable.message).toBe("Could not reach the API"); }); it("falls back to the reporting surface when nothing carries a message", () => { const reportable = toReportableError({ status: 418 }, context); expect(reportable.message).toBe("api_client/decode_or_transport"); }); it("is idempotent, so layering it is safe", () => { const once = toReportableError(Cause.die(rawError("render crashed")), context); expect(toReportableError(once, context)).toBe(once); }); });