From c8d19d51235f501eff37fa78dd2922905585c59b Mon Sep 17 00:00:00 2001 From: Remi DEBETTE Date: Wed, 27 May 2026 18:30:17 +0200 Subject: [PATCH 1/4] fix(k8s): prevent circular-JSON crash in catch-block debug logs Six catch blocks in the k8s hooks built debug strings with `JSON.stringify(err)` inside a template literal: } catch (err) { core.debug(`execPodStep failed: ${JSON.stringify(err)}`) const message = (err as any)?.response?.body?.message || err throw new Error(`failed to run script step: ${message}`) } When err is a @kubernetes/client-node HTTP error, its response embeds a TLSSocket <-> HTTPParser cycle. JSON.stringify throws synchronously before core.debug runs, propagates out of the catch block, and shadows the original failure with: TypeError: Converting circular structure to JSON --> starting at object with constructor 'TLSSocket' Add a defensive formatError() helper that prefers response.body.message (the diagnostic K8s API surface), then Error.stack/message, then a String() / safe-JSON fallback for plain objects, never throwing. Use it at the six unsafe sites. Closes #329. Complements #341, which fixes a related but distinct pattern (`throw new Error(... JSON.stringify(error) ...)` producing `{}` because Error.message is non-enumerable). The two PRs touch disjoint lines. --- packages/k8s/src/hooks/prepare-job.ts | 7 +- packages/k8s/src/hooks/run-container-step.ts | 5 +- packages/k8s/src/hooks/run-script-step.ts | 11 ++- packages/k8s/src/k8s/utils.ts | 43 +++++++++ packages/k8s/tests/format-error-test.ts | 94 ++++++++++++++++++++ 5 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 packages/k8s/tests/format-error-test.ts diff --git a/packages/k8s/src/hooks/prepare-job.ts b/packages/k8s/src/hooks/prepare-job.ts index 28453c17..2dc8c0f1 100644 --- a/packages/k8s/src/hooks/prepare-job.ts +++ b/packages/k8s/src/hooks/prepare-job.ts @@ -21,6 +21,7 @@ import { CONTAINER_VOLUMES, DEFAULT_CONTAINER_ENTRY_POINT, DEFAULT_CONTAINER_ENTRY_POINT_ARGS, + formatError, generateContainerName, mergeContainerWithOptions, readExtensionFromFile, @@ -84,7 +85,7 @@ export async function prepareJob( ) } catch (err) { await prunePods() - core.debug(`createPod failed: ${JSON.stringify(err)}`) + core.debug(`createPod failed: ${formatError(err)}`) const message = (err as any)?.response?.body?.message || err throw new Error(`failed to create job pod: ${message}`) } @@ -146,9 +147,7 @@ export async function prepareJob( JOB_CONTAINER_NAME ) } catch (err) { - core.debug( - `Failed to determine if the pod is alpine: ${JSON.stringify(err)}` - ) + core.debug(`Failed to determine if the pod is alpine: ${formatError(err)}`) const message = (err as any)?.response?.body?.message || err throw new Error(`failed to determine if the pod is alpine: ${message}`) } diff --git a/packages/k8s/src/hooks/run-container-step.ts b/packages/k8s/src/hooks/run-container-step.ts index 1786a38a..037ca71d 100644 --- a/packages/k8s/src/hooks/run-container-step.ts +++ b/packages/k8s/src/hooks/run-container-step.ts @@ -14,6 +14,7 @@ import { } from '../k8s' import { CONTAINER_VOLUMES, + formatError, mergeContainerWithOptions, PodPhase, readExtensionFromFile, @@ -54,7 +55,7 @@ export async function runContainerStep( try { pod = await createContainerStepPod(getStepPodName(), container, extension) } catch (err) { - core.debug(`createJob failed: ${JSON.stringify(err)}`) + core.debug(`createJob failed: ${formatError(err)}`) const message = (err as any)?.response?.body?.message || err throw new Error(`failed to run script step: ${message}`) } @@ -109,7 +110,7 @@ export async function runContainerStep( JOB_CONTAINER_NAME ) } catch (err) { - core.debug(`execPodStep failed: ${JSON.stringify(err)}`) + core.debug(`execPodStep failed: ${formatError(err)}`) const message = (err as any)?.response?.body?.message || err throw new Error(`failed to run script step: ${message}`) } finally { diff --git a/packages/k8s/src/hooks/run-script-step.ts b/packages/k8s/src/hooks/run-script-step.ts index 1db2bb2e..4d7246a6 100644 --- a/packages/k8s/src/hooks/run-script-step.ts +++ b/packages/k8s/src/hooks/run-script-step.ts @@ -3,7 +3,12 @@ import * as fs from 'fs' import * as core from '@actions/core' import { RunScriptStepArgs } from 'hooklib' import { execCpFromPod, execCpToPod, execPodStep } from '../k8s' -import { writeRunScript, sleep, listDirAllCommand } from '../k8s/utils' +import { + formatError, + writeRunScript, + sleep, + listDirAllCommand +} from '../k8s/utils' import { JOB_CONTAINER_NAME } from './constants' import { dirname } from 'path' import * as shlex from 'shlex' @@ -66,7 +71,7 @@ export async function runScriptStep( JOB_CONTAINER_NAME ) } catch (err) { - core.debug(`Failed to merge temp directories: ${JSON.stringify(err)}`) + core.debug(`Failed to merge temp directories: ${formatError(err)}`) const message = (err as any)?.response?.body?.message || err throw new Error(`failed to merge temp dirs: ${message}`) } @@ -81,7 +86,7 @@ export async function runScriptStep( JOB_CONTAINER_NAME ) } catch (err) { - core.debug(`execPodStep failed: ${JSON.stringify(err)}`) + core.debug(`execPodStep failed: ${formatError(err)}`) const message = (err as any)?.response?.body?.message || err throw new Error(`failed to run script step: ${message}`) } finally { diff --git a/packages/k8s/src/k8s/utils.ts b/packages/k8s/src/k8s/utils.ts index 9e744004..6c4d2607 100644 --- a/packages/k8s/src/k8s/utils.ts +++ b/packages/k8s/src/k8s/utils.ts @@ -303,3 +303,46 @@ export async function sleep(ms: number): Promise { export function listDirAllCommand(dir: string): string { return `cd ${shlex.quote(dir)} && find . -type f -not -path '*/_runner_hook_responses*' -exec stat -c '%s %n' {} \\;` } + +// Safely turn an unknown thrown value into a diagnostic string without +// throwing. The previous `JSON.stringify(err)` pattern crashed with +// `TypeError: Converting circular structure to JSON` when err was a +// @kubernetes/client-node HTTP error (its response embeds a +// TLSSocket <-> HTTPParser cycle). The thrown TypeError shadowed the +// original failure in every catch block that used it (issue #329). +export function formatError(err: unknown): string { + if (err === null || err === undefined) { + return String(err) + } + + // @kubernetes/client-node API errors expose the actual server message + // under response.body — prefer that when available. + const body = + (err as { response?: { body?: unknown } })?.response?.body ?? + (err as { body?: unknown })?.body + if (body && typeof body === 'object') { + const msg = (body as { message?: unknown }).message + const reason = (body as { reason?: unknown }).reason + if (typeof msg === 'string') { + return typeof reason === 'string' && reason.length > 0 + ? `${msg} (reason: ${reason})` + : msg + } + } + + if (err instanceof Error) { + return err.stack ?? err.message + } + + // Primitives serialise more readably via String() than JSON.stringify + // (which would quote strings and refuse to handle symbols). + if (typeof err !== 'object') { + return String(err) + } + + try { + return JSON.stringify(err) + } catch { + return String(err) + } +} diff --git a/packages/k8s/tests/format-error-test.ts b/packages/k8s/tests/format-error-test.ts new file mode 100644 index 00000000..434fdf67 --- /dev/null +++ b/packages/k8s/tests/format-error-test.ts @@ -0,0 +1,94 @@ +import { formatError } from '../src/k8s/utils' + +describe('formatError', () => { + it('returns the message of a standard Error (or stack when available)', () => { + const err = new Error('connection refused') + const out = formatError(err) + // err.stack starts with 'Error: connection refused' in V8; either form is fine. + expect(out).toContain('connection refused') + }) + + it('extracts response.body.message from @kubernetes/client-node errors', () => { + const k8sErr = { + message: 'HTTP request failed', + response: { + body: { + message: "pods 'foo' is forbidden: User cannot create pods", + reason: 'Forbidden', + code: 403 + } + } + } + expect(formatError(k8sErr)).toBe( + "pods 'foo' is forbidden: User cannot create pods (reason: Forbidden)" + ) + }) + + it('returns body.message without reason when reason is missing', () => { + const k8sErr = { + response: { body: { message: 'something broke' } } + } + expect(formatError(k8sErr)).toBe('something broke') + }) + + it('falls back to top-level body when response is absent', () => { + const k8sErr = { body: { message: 'top-level body message' } } + expect(formatError(k8sErr)).toBe('top-level body message') + }) + + it('does NOT throw on objects with circular references (the #329 regression)', () => { + // Reproduce the @kubernetes/client-node error shape that crashed + // JSON.stringify with: "Converting circular structure to JSON, + // starting at object with constructor 'TLSSocket'". + const socket: any = { constructor: { name: 'TLSSocket' } } + const parser: any = { constructor: { name: 'HTTPParser' }, socket } + socket.parser = parser + const circularErr: any = { + message: 'k8s exec failed', + response: { req: { socket } } + // no response.body — exercises the Error/JSON.stringify fallbacks + } + + expect(() => formatError(circularErr)).not.toThrow() + expect(typeof formatError(circularErr)).toBe('string') + }) + + it('returns the message field when present on a plain object', () => { + // Many K8s client errors carry a top-level message even without body. + const err = new Error('top-level msg only') + expect(formatError(err)).toContain('top-level msg only') + }) + + it('handles string throwables', () => { + expect(formatError('raw string error')).toBe('raw string error') + }) + + it('handles number throwables', () => { + expect(formatError(42)).toBe('42') + }) + + it('handles null', () => { + expect(formatError(null)).toBe('null') + }) + + it('handles undefined', () => { + expect(formatError(undefined)).toBe('undefined') + }) + + it('handles plain objects without message via JSON.stringify', () => { + expect(formatError({ code: 'ENOENT', path: '/tmp/x' })).toBe( + '{"code":"ENOENT","path":"/tmp/x"}' + ) + }) + + it('falls back to String() when even JSON.stringify throws', () => { + // Construct an object whose toJSON throws — exercises the inner try/catch. + const evil = { + toJSON() { + throw new Error('toJSON exploded') + } + } + expect(() => formatError(evil)).not.toThrow() + expect(formatError(evil)).toBe('[object Object]') + }) +}) From bced6534b9ebb7a5a7bdb1f23bb3224528233afb Mon Sep 17 00:00:00 2001 From: Remi DEBETTE Date: Thu, 4 Jun 2026 13:35:19 +0200 Subject: [PATCH 2/4] refactor(k8s): consolidate the 4 #341 throw sites via formatError() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #341 (merged 2026-06-03) replaced `JSON.stringify(error)` with `error instanceof Error ? error.message : String(error)` at four throw sites in k8s/index.ts. Now that `formatError()` ships in k8s/utils.ts (introduced in the same PR as this commit), use it instead — a strict superset that additionally: - extracts `response.body.message` (and `reason`) from @kubernetes/client-node API errors at `waitForPodPhases` and `waitForJobToComplete`, which currently surface a generic "HTTP request failed" instead of the actual K8s server message; - never throws on circular references (the original #329 bug). Also drops the `.stack ?? .message` branch in formatError() and returns just `.message` for Error instances. This keeps the format predictable (no stack trace dumped into user-visible CI errors) and lets #341's `error-serialization-test.ts` continue to pass unchanged. --- packages/k8s/src/k8s/index.ts | 9 +++++---- packages/k8s/src/k8s/utils.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/k8s/src/k8s/index.ts b/packages/k8s/src/k8s/index.ts index 098c1157..beaee808 100644 --- a/packages/k8s/src/k8s/index.ts +++ b/packages/k8s/src/k8s/index.ts @@ -14,6 +14,7 @@ import { } from '../hooks/constants' import { PodPhase, + formatError, mergePodSpecWithOptions, mergeObjectMeta, fixArgs, @@ -517,7 +518,7 @@ export async function execCpToPod( attempt++ if (attempt >= 30) { throw new Error( - `cpToPod failed after ${attempt} attempts: ${error instanceof Error ? error.message : String(error)}` + `cpToPod failed after ${attempt} attempts: ${formatError(error)}` ) } await sleep(1000) @@ -614,7 +615,7 @@ export async function execCpFromPod( attempt++ if (attempt >= 30) { throw new Error( - `execCpFromPod failed after ${attempt} attempts: ${error instanceof Error ? error.message : String(error)}` + `execCpFromPod failed after ${attempt} attempts: ${formatError(error)}` ) } await sleep(1000) @@ -661,7 +662,7 @@ export async function waitForJobToComplete(jobName: string): Promise { return } } catch (error) { - throw new Error(`job ${jobName} has failed: ${error instanceof Error ? error.message : String(error)}`) + throw new Error(`job ${jobName} has failed: ${formatError(error)}`) } await backOffManager.backOff() } @@ -784,7 +785,7 @@ export async function waitForPodPhases( } } catch (error) { throw new Error( - `Pod ${podName} is unhealthy with phase status ${phase}: ${error instanceof Error ? error.message : String(error)}` + `Pod ${podName} is unhealthy with phase status ${phase}: ${formatError(error)}` ) } } diff --git a/packages/k8s/src/k8s/utils.ts b/packages/k8s/src/k8s/utils.ts index 6c4d2607..42b21c16 100644 --- a/packages/k8s/src/k8s/utils.ts +++ b/packages/k8s/src/k8s/utils.ts @@ -331,7 +331,7 @@ export function formatError(err: unknown): string { } if (err instanceof Error) { - return err.stack ?? err.message + return err.message } // Primitives serialise more readably via String() than JSON.stringify From 631fef44a310f61ef4106559b5532605fcdb06b4 Mon Sep 17 00:00:00 2001 From: Remi DEBETTE Date: Thu, 4 Jun 2026 13:35:27 +0200 Subject: [PATCH 3/4] chore(test): fix pre-existing lint + format errors in error-serialization-test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three `no-extraneous-class` lint errors (empty mock classes) and a prettier deviation slipped into main with #341 because upstream CI runs build.yaml only on `pull_request`, never on push to main — there was no gate to catch them after merge. The mock classes are deliberate (used as the `name` discriminator in the @kubernetes/client-node factory mock), so they get a single `eslint-disable-next-line` rather than a code change; the prettier deviations are auto-fixed. Unblocks CI for the consolidation commit on this same branch. --- .../k8s/tests/error-serialization-test.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/k8s/tests/error-serialization-test.ts b/packages/k8s/tests/error-serialization-test.ts index 875d9386..47cd4492 100644 --- a/packages/k8s/tests/error-serialization-test.ts +++ b/packages/k8s/tests/error-serialization-test.ts @@ -16,13 +16,14 @@ jest.mock('@kubernetes/client-node', () => { } return { readNamespacedPod: mockReadNamespacedPod } }), - getContexts: jest - .fn() - .mockReturnValue([{ namespace: 'test-namespace' }]) + getContexts: jest.fn().mockReturnValue([{ namespace: 'test-namespace' }]) })), Exec: jest.fn().mockImplementation(() => ({ exec: mockExec })), + // eslint-disable-next-line @typescript-eslint/no-extraneous-class CoreV1Api: class CoreV1Api {}, + // eslint-disable-next-line @typescript-eslint/no-extraneous-class BatchV1Api: class BatchV1Api {}, + // eslint-disable-next-line @typescript-eslint/no-extraneous-class AuthorizationV1Api: class AuthorizationV1Api {}, Log: jest.fn() } @@ -125,9 +126,7 @@ describe('error serialization', () => { }) it('should include Error.message when API call throws', async () => { - mockReadNamespacedJob.mockRejectedValue( - new Error('403 Forbidden') - ) + mockReadNamespacedJob.mockRejectedValue(new Error('403 Forbidden')) await expect(waitForJobToComplete('my-job')).rejects.toThrow( 'job my-job has failed: 403 Forbidden' @@ -155,15 +154,11 @@ describe('error serialization', () => { new Set([PodPhase.RUNNING]), new Set([PodPhase.PENDING]) ) - ).rejects.toThrow( - /Pod test-pod is unhealthy with phase status Failed/ - ) + ).rejects.toThrow(/Pod test-pod is unhealthy with phase status Failed/) }) it('should include Error.message when API call throws', async () => { - mockReadNamespacedPod.mockRejectedValue( - new Error('network timeout') - ) + mockReadNamespacedPod.mockRejectedValue(new Error('network timeout')) await expect( waitForPodPhases( @@ -177,9 +172,7 @@ describe('error serialization', () => { }) it('should not produce empty braces from Error objects', async () => { - mockReadNamespacedPod.mockRejectedValue( - new Error('socket hang up') - ) + mockReadNamespacedPod.mockRejectedValue(new Error('socket hang up')) try { await waitForPodPhases( From ea72fa8ffdb5d42e06a012640db4a9140545db00 Mon Sep 17 00:00:00 2001 From: Remi DEBETTE Date: Thu, 4 Jun 2026 18:20:02 +0200 Subject: [PATCH 4/4] fix(k8s): use formatError in hook catch-block throws (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three themes flagged by Copilot's review on the previous push: 1. Catch-block throws (×7 sites in run-script-step.ts, run-container-step.ts, prepare-job.ts) previously built the thrown Error message via `const message = (err as any)?.response?.body?.message || err`. When the body path is absent and `err` is a plain object, this falls back to `${err}` (a `.toString()` that yields `[object Object]`); for a plain `Error`, it leaves a stale `Error: ` prefix. Replace with `formatError(err)` for the throw too — same call already used on the debug line above. Also covers the `${err}` site at the `waitForPodPhases` catch (same anti-pattern, not flagged but adjacent). 2. `formatError()` gains a top-level-message branch for non-Error objects, so axios-style / hand-rolled error-likes no longer collapse to JSON.stringify or to `[object Object]` after the circular-ref fallback fires. The original #329 test now asserts the actual recovered message instead of just type-checking it. 3. Test cleanup: the stale `(or stack when available)` comment is gone (formatError returns just `.message` for Error after the previous commit), and the "plain object" test now uses an actual plain object instead of `new Error(...)`. Net effect: every error path in the k8s hooks now produces the same diagnostic-quality output, the helper is consistent end-to-end, and the tests assert behaviour that matches their names. --- packages/k8s/src/hooks/prepare-job.ts | 10 ++++---- packages/k8s/src/hooks/run-container-step.ts | 8 +++--- packages/k8s/src/hooks/run-script-step.ts | 8 +++--- packages/k8s/src/k8s/utils.ts | 26 +++++++++++++------- packages/k8s/tests/format-error-test.ts | 20 +++++++-------- 5 files changed, 40 insertions(+), 32 deletions(-) diff --git a/packages/k8s/src/hooks/prepare-job.ts b/packages/k8s/src/hooks/prepare-job.ts index 2dc8c0f1..c77ded40 100644 --- a/packages/k8s/src/hooks/prepare-job.ts +++ b/packages/k8s/src/hooks/prepare-job.ts @@ -85,8 +85,8 @@ export async function prepareJob( ) } catch (err) { await prunePods() - core.debug(`createPod failed: ${formatError(err)}`) - const message = (err as any)?.response?.body?.message || err + const message = formatError(err) + core.debug(`createPod failed: ${message}`) throw new Error(`failed to create job pod: ${message}`) } @@ -113,7 +113,7 @@ export async function prepareJob( ) } catch (err) { await prunePods() - throw new Error(`pod failed to come online with error: ${err}`) + throw new Error(`pod failed to come online with error: ${formatError(err)}`) } await execCpToPod(createdPod.metadata.name, runnerWorkspace, '/__w') @@ -147,8 +147,8 @@ export async function prepareJob( JOB_CONTAINER_NAME ) } catch (err) { - core.debug(`Failed to determine if the pod is alpine: ${formatError(err)}`) - const message = (err as any)?.response?.body?.message || err + const message = formatError(err) + core.debug(`Failed to determine if the pod is alpine: ${message}`) throw new Error(`failed to determine if the pod is alpine: ${message}`) } core.debug(`Setting isAlpine to ${isAlpine}`) diff --git a/packages/k8s/src/hooks/run-container-step.ts b/packages/k8s/src/hooks/run-container-step.ts index 037ca71d..9ced42d6 100644 --- a/packages/k8s/src/hooks/run-container-step.ts +++ b/packages/k8s/src/hooks/run-container-step.ts @@ -55,8 +55,8 @@ export async function runContainerStep( try { pod = await createContainerStepPod(getStepPodName(), container, extension) } catch (err) { - core.debug(`createJob failed: ${formatError(err)}`) - const message = (err as any)?.response?.body?.message || err + const message = formatError(err) + core.debug(`createJob failed: ${message}`) throw new Error(`failed to run script step: ${message}`) } @@ -110,8 +110,8 @@ export async function runContainerStep( JOB_CONTAINER_NAME ) } catch (err) { - core.debug(`execPodStep failed: ${formatError(err)}`) - const message = (err as any)?.response?.body?.message || err + const message = formatError(err) + core.debug(`execPodStep failed: ${message}`) throw new Error(`failed to run script step: ${message}`) } finally { fs.rmSync(runnerPath, { force: true }) diff --git a/packages/k8s/src/hooks/run-script-step.ts b/packages/k8s/src/hooks/run-script-step.ts index 4d7246a6..11ea9c54 100644 --- a/packages/k8s/src/hooks/run-script-step.ts +++ b/packages/k8s/src/hooks/run-script-step.ts @@ -71,8 +71,8 @@ export async function runScriptStep( JOB_CONTAINER_NAME ) } catch (err) { - core.debug(`Failed to merge temp directories: ${formatError(err)}`) - const message = (err as any)?.response?.body?.message || err + const message = formatError(err) + core.debug(`Failed to merge temp directories: ${message}`) throw new Error(`failed to merge temp dirs: ${message}`) } @@ -86,8 +86,8 @@ export async function runScriptStep( JOB_CONTAINER_NAME ) } catch (err) { - core.debug(`execPodStep failed: ${formatError(err)}`) - const message = (err as any)?.response?.body?.message || err + const message = formatError(err) + core.debug(`execPodStep failed: ${message}`) throw new Error(`failed to run script step: ${message}`) } finally { try { diff --git a/packages/k8s/src/k8s/utils.ts b/packages/k8s/src/k8s/utils.ts index 42b21c16..20da57a3 100644 --- a/packages/k8s/src/k8s/utils.ts +++ b/packages/k8s/src/k8s/utils.ts @@ -334,15 +334,23 @@ export function formatError(err: unknown): string { return err.message } - // Primitives serialise more readably via String() than JSON.stringify - // (which would quote strings and refuse to handle symbols). - if (typeof err !== 'object') { - return String(err) + if (typeof err === 'object') { + // Non-Error objects sometimes carry a top-level message (axios-style + // errors, hand-rolled error-likes). Extract before the JSON.stringify + // branch so a circular ref doesn't reduce the diagnostic to + // "[object Object]". + const msg = (err as { message?: unknown }).message + if (typeof msg === 'string') { + return msg + } + try { + return JSON.stringify(err) + } catch { + return String(err) + } } - try { - return JSON.stringify(err) - } catch { - return String(err) - } + // Primitives serialise more readably via String() than JSON.stringify + // (which would quote strings and refuse to handle symbols). + return String(err) } diff --git a/packages/k8s/tests/format-error-test.ts b/packages/k8s/tests/format-error-test.ts index 434fdf67..9fe78936 100644 --- a/packages/k8s/tests/format-error-test.ts +++ b/packages/k8s/tests/format-error-test.ts @@ -1,11 +1,9 @@ import { formatError } from '../src/k8s/utils' describe('formatError', () => { - it('returns the message of a standard Error (or stack when available)', () => { + it('returns the message of a standard Error', () => { const err = new Error('connection refused') - const out = formatError(err) - // err.stack starts with 'Error: connection refused' in V8; either form is fine. - expect(out).toContain('connection refused') + expect(formatError(err)).toBe('connection refused') }) it('extracts response.body.message from @kubernetes/client-node errors', () => { @@ -46,17 +44,19 @@ describe('formatError', () => { const circularErr: any = { message: 'k8s exec failed', response: { req: { socket } } - // no response.body — exercises the Error/JSON.stringify fallbacks + // no response.body — exercises the top-level-message branch. } expect(() => formatError(circularErr)).not.toThrow() - expect(typeof formatError(circularErr)).toBe('string') + expect(formatError(circularErr)).toBe('k8s exec failed') }) - it('returns the message field when present on a plain object', () => { - // Many K8s client errors carry a top-level message even without body. - const err = new Error('top-level msg only') - expect(formatError(err)).toContain('top-level msg only') + it('returns the message field when present on a plain (non-Error) object', () => { + // Some throwables expose a top-level .message without being an Error + // (axios-style errors, hand-rolled error-likes from third parties). + expect(formatError({ message: 'top-level msg only' })).toBe( + 'top-level msg only' + ) }) it('handles string throwables', () => {