From bab30fe41d1b09d050c06025ad1e3195972e3b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Valais?= Date: Thu, 23 Jul 2026 14:56:09 +0200 Subject: [PATCH 1/2] fix: surface the root cause of wrapped errors in CLI output errorMessage() only printed err.message, so a network failure like Node's fetch throwing "fetch failed" hid the actual reason (e.g. an AggregateError of ECONNREFUSED entries) in err.cause. Walk the cause chain, including AggregateError.errors, so users see e.g. "fetch failed: connect ECONNREFUSED 127.0.0.1:9090" instead of just "fetch failed". --- src/cli/context.ts | 24 ++++++++++++++++++++++-- test/error-message.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 test/error-message.test.ts diff --git a/src/cli/context.ts b/src/cli/context.ts index 8c9dac4..7e70832 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -42,8 +42,28 @@ function effectiveWorkspaceUrl(flag?: string): string | undefined { return flag?.trim() || process.env.SLACK_WORKSPACE_URL?.trim() || undefined; } -function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); +export function errorMessage(err: unknown): string { + if (!(err instanceof Error)) { + return String(err); + } + const { message: rootMessage, cause: rootCause } = err; + let message = rootMessage; + let cause: unknown = rootCause; + while (cause !== undefined && cause !== null) { + if (cause instanceof AggregateError && cause.errors.length > 0) { + message += `: ${cause.errors.map((e) => (e instanceof Error ? e.message : String(e))).join("; ")}`; + break; + } + if (cause instanceof Error) { + const { message: causeMessage, cause: nextCause } = cause; + message += `: ${causeMessage}`; + cause = nextCause; + continue; + } + message += `: ${String(cause)}`; + break; + } + return message; } function parseContentType(value: unknown): "any" | "text" | "image" | "snippet" | "file" { diff --git a/test/error-message.test.ts b/test/error-message.test.ts new file mode 100644 index 0000000..63f11d0 --- /dev/null +++ b/test/error-message.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { errorMessage } from "../src/cli/context.ts"; + +describe("errorMessage", () => { + test("returns the message for a plain error", () => { + expect(errorMessage(new Error("boom"))).toBe("boom"); + }); + + test("stringifies non-Error values", () => { + expect(errorMessage("boom")).toBe("boom"); + }); + + test("appends a single Error cause", () => { + const err = new Error("fetch failed", { cause: new Error("connect ECONNREFUSED") }); + expect(errorMessage(err)).toBe("fetch failed: connect ECONNREFUSED"); + }); + + test("appends AggregateError causes from a failed fetch through a dead proxy", () => { + const cause = new AggregateError( + [ + new Error("connect ECONNREFUSED 127.0.0.1:9090"), + new Error("connect ECONNREFUSED ::1:9090"), + ], + "ECONNREFUSED", + ); + const err = new Error("fetch failed", { cause }); + expect(errorMessage(err)).toBe( + "fetch failed: connect ECONNREFUSED 127.0.0.1:9090; connect ECONNREFUSED ::1:9090", + ); + }); + + test("walks multi-level cause chains", () => { + const root = new Error("connect ECONNREFUSED"); + const middle = new Error("request failed", { cause: root }); + const err = new Error("fetch failed", { cause: middle }); + expect(errorMessage(err)).toBe("fetch failed: request failed: connect ECONNREFUSED"); + }); +}); From 6af72042e88f52d04f40dff8595dc2b3aea63e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Valais?= Date: Thu, 23 Jul 2026 15:10:29 +0200 Subject: [PATCH 2/2] address Copilot review comment on #126 errorMessage() could loop forever on a cyclic cause chain (e.g. err.cause === err), hanging the process while rendering a CLI error. Track visited causes in a Set and stop once a repeat is seen. --- src/cli/context.ts | 5 +++++ test/error-message.test.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/cli/context.ts b/src/cli/context.ts index 7e70832..3b6f924 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -49,7 +49,12 @@ export function errorMessage(err: unknown): string { const { message: rootMessage, cause: rootCause } = err; let message = rootMessage; let cause: unknown = rootCause; + const seenCauses = new Set([err]); while (cause !== undefined && cause !== null) { + if (seenCauses.has(cause)) { + break; + } + seenCauses.add(cause); if (cause instanceof AggregateError && cause.errors.length > 0) { message += `: ${cause.errors.map((e) => (e instanceof Error ? e.message : String(e))).join("; ")}`; break; diff --git a/test/error-message.test.ts b/test/error-message.test.ts index 63f11d0..8509efe 100644 --- a/test/error-message.test.ts +++ b/test/error-message.test.ts @@ -35,4 +35,17 @@ describe("errorMessage", () => { const err = new Error("fetch failed", { cause: middle }); expect(errorMessage(err)).toBe("fetch failed: request failed: connect ECONNREFUSED"); }); + + test("terminates on a self-referential cause instead of looping forever", () => { + const err = new Error("boom"); + err.cause = err; + expect(errorMessage(err)).toBe("boom"); + }); + + test("terminates on a longer cause cycle", () => { + const a = new Error("a"); + const b = new Error("b", { cause: a }); + a.cause = b; + expect(errorMessage(a)).toBe("a: b"); + }); });