mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(post card): clarify full error copying
This commit is contained in:
@@ -52,7 +52,7 @@ describe('ErrorDisplay', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('waits before rendering, then copies structured errors and shows feedback', async () => {
|
it('waits before rendering, then shows a copy action for structured errors', async () => {
|
||||||
testState.copyToClipboardMock.mockResolvedValue(undefined);
|
testState.copyToClipboardMock.mockResolvedValue(undefined);
|
||||||
const error = {
|
const error = {
|
||||||
details: { code: 500 },
|
details: { code: 500 },
|
||||||
@@ -67,20 +67,22 @@ describe('ErrorDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const button = container.querySelector('button');
|
const button = container.querySelector('button');
|
||||||
expect(button?.textContent).toContain('error: network down');
|
expect(container.textContent).toContain('error: network down: code: 500');
|
||||||
|
expect(button?.textContent).toBe('copy full error');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(testState.copyToClipboardMock).toHaveBeenCalledWith(JSON.stringify(error, null, 2));
|
expect(testState.copyToClipboardMock).toHaveBeenCalledWith(JSON.stringify(error, null, 2));
|
||||||
expect(container.textContent).toContain('full error copied to the clipboard');
|
expect(container.textContent).toContain('copied');
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(1500);
|
vi.advanceTimersByTime(1500);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.textContent).toContain('error: network down');
|
expect(container.textContent).toContain('error: network down: code: 500');
|
||||||
|
expect(button?.textContent).toBe('copy full error');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows copy failure feedback and logs the clipboard error', async () => {
|
it('shows copy failure feedback and logs the clipboard error', async () => {
|
||||||
@@ -93,6 +95,7 @@ describe('ErrorDisplay', () => {
|
|||||||
|
|
||||||
const button = container.querySelector('button');
|
const button = container.querySelector('button');
|
||||||
expect(button).toBeTruthy();
|
expect(button).toBeTruthy();
|
||||||
|
expect(button?.textContent).toBe('copy full error');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
@@ -115,7 +118,8 @@ describe('ErrorDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const button = container.querySelector('button');
|
const button = container.querySelector('button');
|
||||||
expect(button?.textContent).toContain('error: native failure');
|
expect(container.textContent).toContain('error: native failure: status: 504');
|
||||||
|
expect(button?.textContent).toBe('copy full error');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
@@ -135,6 +139,68 @@ describe('ErrorDisplay', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('copies nested cyclic errors as readable valid JSON', async () => {
|
||||||
|
testState.copyToClipboardMock.mockResolvedValue(undefined);
|
||||||
|
const cause = new Error('provider timeout');
|
||||||
|
cause.stack = 'Error: provider timeout\n at provider';
|
||||||
|
const error = Object.assign(new Error('publish failed'), {
|
||||||
|
attempts: [
|
||||||
|
{
|
||||||
|
elapsedMs: BigInt(5000),
|
||||||
|
provider: 'pubsub',
|
||||||
|
reason: cause,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
Object.assign(error, { self: error });
|
||||||
|
error.stack = 'Error: publish failed\n at publish';
|
||||||
|
|
||||||
|
await renderDisplay(error);
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector('button');
|
||||||
|
await act(async () => {
|
||||||
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const copied = testState.copyToClipboardMock.mock.calls[0]?.[0];
|
||||||
|
expect(copied).toBeTypeOf('string');
|
||||||
|
const parsed = JSON.parse(copied as string);
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
attempts: [
|
||||||
|
{
|
||||||
|
elapsedMs: '5000',
|
||||||
|
provider: 'pubsub',
|
||||||
|
reason: {
|
||||||
|
message: 'provider timeout',
|
||||||
|
name: 'Error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: 'publish failed',
|
||||||
|
name: 'Error',
|
||||||
|
self: '[Circular]',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies empty structured errors as valid JSON', async () => {
|
||||||
|
testState.copyToClipboardMock.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await renderDisplay({});
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector('button');
|
||||||
|
await act(async () => {
|
||||||
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('{}');
|
||||||
|
});
|
||||||
|
|
||||||
it('supports compact custom labels that copy plain string errors immediately', async () => {
|
it('supports compact custom labels that copy plain string errors immediately', async () => {
|
||||||
testState.copyToClipboardMock.mockResolvedValue(undefined);
|
testState.copyToClipboardMock.mockResolvedValue(undefined);
|
||||||
|
|
||||||
@@ -146,14 +212,15 @@ describe('ErrorDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const button = container.querySelector('button');
|
const button = container.querySelector('button');
|
||||||
expect(button?.textContent).toBe('failed');
|
expect(container.textContent).toContain('failed');
|
||||||
|
expect(button?.textContent).toBe('copy full error');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('All pubsub providers throw an error and unable to publish or subscribe');
|
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('All pubsub providers throw an error and unable to publish or subscribe');
|
||||||
expect(container.textContent).toContain('full error copied to the clipboard');
|
expect(container.textContent).toContain('copied');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders plain string errors after the delay and hides again when the error clears', async () => {
|
it('renders plain string errors after the delay and hides again when the error clears', async () => {
|
||||||
|
|||||||
@@ -1,29 +1,50 @@
|
|||||||
.error {
|
.error {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inlineError {
|
.inlineError {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorMessage {
|
.errorMessage {
|
||||||
color: red;
|
color: red;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickableErrorMessage {
|
.copyErrorButtonWrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1px;
|
||||||
|
color: var(--button-desktop-text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyErrorButton {
|
||||||
all: unset;
|
all: unset;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: red;
|
color: var(--button-desktop-text-color);
|
||||||
|
text-decoration: var(--button-text-decoration);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickableErrorMessage:hover {
|
.copyErrorButton:hover {
|
||||||
text-decoration: underline;
|
color: var(--button-desktop-text-color-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feedbackSuccessMessage {
|
.feedbackSuccessMessage {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feedbackFailedMessage {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,43 +1,21 @@
|
|||||||
import { useReducer, useEffect } from 'react';
|
import { useReducer, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { copyToClipboard } from '../../lib/utils/clipboard-utils';
|
import { copyToClipboard } from '../../lib/utils/clipboard-utils';
|
||||||
|
import { formatErrorForDisplay, serializeErrorForClipboard } from '../../lib/utils/error-utils';
|
||||||
import styles from './error-display.module.css';
|
import styles from './error-display.module.css';
|
||||||
|
|
||||||
type State = { showAfterDelay: boolean; feedbackMessageKey: string | null };
|
type FeedbackMessageKey = 'copied' | 'failed' | null;
|
||||||
|
type State = { showAfterDelay: boolean; feedbackMessageKey: FeedbackMessageKey };
|
||||||
|
|
||||||
function reducer(state: State, action: { type: 'RESET_DELAY' } | { type: 'SHOW' } | { type: 'FEEDBACK'; payload: string | null }): State {
|
function reducer(state: State, action: { type: 'RESET_DELAY' } | { type: 'SHOW' } | { type: 'FEEDBACK'; payload: FeedbackMessageKey }): State {
|
||||||
if (action.type === 'RESET_DELAY') return { ...state, showAfterDelay: false };
|
if (action.type === 'RESET_DELAY') return { ...state, showAfterDelay: false };
|
||||||
if (action.type === 'SHOW') return { ...state, showAfterDelay: true };
|
if (action.type === 'SHOW') return { ...state, showAfterDelay: true };
|
||||||
if (action.type === 'FEEDBACK') return { ...state, feedbackMessageKey: action.payload };
|
if (action.type === 'FEEDBACK') return { ...state, feedbackMessageKey: action.payload };
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializeErrorForClipboard = (error: unknown): string => {
|
|
||||||
if (typeof error === 'string') {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializableError =
|
|
||||||
error instanceof Error
|
|
||||||
? {
|
|
||||||
name: error.name,
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
...Object.fromEntries(Object.entries(error)),
|
|
||||||
...('cause' in error && error.cause ? { cause: error.cause } : {}),
|
|
||||||
}
|
|
||||||
: error;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const serializedError = JSON.stringify(serializableError, null, 2);
|
|
||||||
return serializedError && serializedError !== '{}' ? serializedError : String(error);
|
|
||||||
} catch {
|
|
||||||
return String(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type ErrorDisplayProps = {
|
type ErrorDisplayProps = {
|
||||||
error: any;
|
error: unknown;
|
||||||
displayMessage?: string;
|
displayMessage?: string;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
showImmediately?: boolean;
|
showImmediately?: boolean;
|
||||||
@@ -47,7 +25,7 @@ const ErrorDisplay = ({ error, displayMessage, inline = false, showImmediately =
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, dispatch] = useReducer(reducer, { showAfterDelay: showImmediately, feedbackMessageKey: null });
|
const [state, dispatch] = useReducer(reducer, { showAfterDelay: showImmediately, feedbackMessageKey: null });
|
||||||
|
|
||||||
const hasError = !!(error?.message || error?.stack || error?.details || error);
|
const hasError = error !== null && error !== undefined && error !== '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasError) {
|
if (!hasError) {
|
||||||
@@ -66,10 +44,11 @@ const ErrorDisplay = ({ error, displayMessage, inline = false, showImmediately =
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalDisplayMessage = displayMessage || (error?.message ? `${t('error')}: ${error.message}` : typeof error === 'string' ? error : error ? t('error') : null);
|
const formattedError = formatErrorForDisplay(error);
|
||||||
const canCopyError = !!error && !!originalDisplayMessage;
|
const originalDisplayMessage = displayMessage ?? (formattedError ? (typeof error === 'string' ? formattedError : `${t('error')}: ${formattedError}`) : t('error'));
|
||||||
|
const canCopyError = hasError && !!originalDisplayMessage;
|
||||||
|
|
||||||
const handleMessageClick = async () => {
|
const handleCopyError = async () => {
|
||||||
if (!canCopyError || state.feedbackMessageKey) return;
|
if (!canCopyError || state.feedbackMessageKey) return;
|
||||||
|
|
||||||
const errorString = serializeErrorForClipboard(error);
|
const errorString = serializeErrorForClipboard(error);
|
||||||
@@ -84,46 +63,27 @@ const ErrorDisplay = ({ error, displayMessage, inline = false, showImmediately =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentDisplayMessage = '';
|
const copyButtonLabel =
|
||||||
const classNames: string[] = [];
|
state.feedbackMessageKey === 'copied' ? t('copied') : state.feedbackMessageKey === 'failed' ? t('copyFailed', 'copy failed') : t('copyFullError', 'copy full error');
|
||||||
let isClickable = false;
|
const copyButtonClassNames = [styles.copyErrorButton];
|
||||||
|
|
||||||
if (state.feedbackMessageKey === 'copied') {
|
if (state.feedbackMessageKey === 'copied') {
|
||||||
currentDisplayMessage = t('fullErrorCopiedToClipboard', 'full error copied to the clipboard');
|
copyButtonClassNames.push(styles.feedbackSuccessMessage);
|
||||||
classNames.push(styles.feedbackSuccessMessage);
|
|
||||||
} else if (state.feedbackMessageKey === 'failed') {
|
} else if (state.feedbackMessageKey === 'failed') {
|
||||||
currentDisplayMessage = t('copyFailed', 'copy failed');
|
copyButtonClassNames.push(styles.feedbackFailedMessage);
|
||||||
classNames.push(styles.errorMessage);
|
|
||||||
} else if (originalDisplayMessage) {
|
|
||||||
currentDisplayMessage = originalDisplayMessage;
|
|
||||||
classNames.push(styles.errorMessage);
|
|
||||||
isClickable = canCopyError;
|
|
||||||
if (isClickable) {
|
|
||||||
classNames.push(styles.clickableErrorMessage);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={inline ? styles.inlineError : styles.error}>
|
<div className={inline ? styles.inlineError : styles.error}>
|
||||||
{currentDisplayMessage &&
|
{originalDisplayMessage && <span className={styles.errorMessage}>{originalDisplayMessage}</span>}
|
||||||
(isClickable ? (
|
{canCopyError && (
|
||||||
<button
|
<span className={styles.copyErrorButtonWrapper}>
|
||||||
type='button'
|
[
|
||||||
className={classNames.join(' ')}
|
<button type='button' className={copyButtonClassNames.join(' ')} onClick={handleCopyError} title={t('copyFullError', 'copy full error')}>
|
||||||
onClick={handleMessageClick}
|
{copyButtonLabel}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleMessageClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={t('clickToCopyFullError', 'Click to copy full error')}
|
|
||||||
>
|
|
||||||
{currentDisplayMessage}
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
]
|
||||||
<span className={classNames.join(' ')}>{currentDisplayMessage}</span>
|
</span>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatErrorForDisplay } from '../error-utils';
|
import { formatErrorForDisplay, serializeErrorForClipboard } from '../error-utils';
|
||||||
|
|
||||||
describe('error utils', () => {
|
describe('error utils', () => {
|
||||||
it('returns plain string errors unchanged', () => {
|
it('returns plain string errors unchanged', () => {
|
||||||
@@ -29,4 +29,55 @@ describe('error utils', () => {
|
|||||||
}),
|
}),
|
||||||
).toBe('publish failed: provider: plebpubsub; reason: timeout');
|
).toBe('publish failed: provider: plebpubsub; reason: timeout');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats nested Error details without dropping the nested message', () => {
|
||||||
|
expect(
|
||||||
|
formatErrorForDisplay({
|
||||||
|
details: {
|
||||||
|
reason: new Error('provider timeout'),
|
||||||
|
},
|
||||||
|
message: 'publish failed',
|
||||||
|
}),
|
||||||
|
).toBe('publish failed: reason: provider timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats cyclic errors without recursing forever', () => {
|
||||||
|
const error = Object.assign(new Error('publish failed'), {
|
||||||
|
details: {
|
||||||
|
elapsedMs: BigInt(5000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.assign(error.details, { self: error.details });
|
||||||
|
|
||||||
|
expect(formatErrorForDisplay(error)).toBe('publish failed: elapsedMs: 5000; self: [Circular]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes cyclic errors as valid JSON for copying', () => {
|
||||||
|
const cause = new Error('provider timeout');
|
||||||
|
const error = Object.assign(new Error('publish failed'), {
|
||||||
|
attempts: [{ elapsedMs: BigInt(5000), provider: 'pubsub', reason: cause }],
|
||||||
|
});
|
||||||
|
Object.assign(error, { self: error });
|
||||||
|
|
||||||
|
expect(JSON.parse(serializeErrorForClipboard(error))).toMatchObject({
|
||||||
|
attempts: [
|
||||||
|
{
|
||||||
|
elapsedMs: '5000',
|
||||||
|
provider: 'pubsub',
|
||||||
|
reason: {
|
||||||
|
message: 'provider timeout',
|
||||||
|
name: 'Error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: 'publish failed',
|
||||||
|
name: 'Error',
|
||||||
|
self: '[Circular]',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps string errors unchanged and empty objects as JSON', () => {
|
||||||
|
expect(serializeErrorForClipboard('plain failure')).toBe('plain failure');
|
||||||
|
expect(serializeErrorForClipboard({})).toBe('{}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,27 +4,160 @@ type ErrorLike = {
|
|||||||
message?: unknown;
|
message?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeUnknownErrorPart = (value: unknown): string | undefined => {
|
const CIRCULAR_REFERENCE_LABEL = '[Circular]';
|
||||||
|
const UNDEFINED_LABEL = '[undefined]';
|
||||||
|
|
||||||
|
const normalizeErrorForClipboard = (value: unknown, seen = new WeakSet<object>()): unknown => {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value === undefined) {
|
||||||
|
return UNDEFINED_LABEL;
|
||||||
|
}
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'symbol' || typeof value === 'function') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return Number.isNaN(value.getTime()) ? String(value) : value.toISOString();
|
||||||
|
}
|
||||||
|
if (value instanceof Error) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return CIRCULAR_REFERENCE_LABEL;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const normalized: Record<string, unknown> = {
|
||||||
|
name: value.name,
|
||||||
|
message: value.message,
|
||||||
|
};
|
||||||
|
if (value.stack) {
|
||||||
|
normalized.stack = value.stack;
|
||||||
|
}
|
||||||
|
for (const [key, entryValue] of Object.entries(value)) {
|
||||||
|
normalized[key] = normalizeErrorForClipboard(entryValue, seen);
|
||||||
|
}
|
||||||
|
if ('cause' in value && value.cause !== undefined) {
|
||||||
|
normalized.cause = normalizeErrorForClipboard(value.cause, seen);
|
||||||
|
}
|
||||||
|
seen.delete(value);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return CIRCULAR_REFERENCE_LABEL;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const normalized = value.map((entry) => normalizeErrorForClipboard(entry, seen));
|
||||||
|
seen.delete(value);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
if (value instanceof Map) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return CIRCULAR_REFERENCE_LABEL;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const normalized = Object.fromEntries([...value.entries()].map(([key, entryValue]) => [String(key), normalizeErrorForClipboard(entryValue, seen)]));
|
||||||
|
seen.delete(value);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
if (value instanceof Set) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return CIRCULAR_REFERENCE_LABEL;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const normalized = [...value].map((entry) => normalizeErrorForClipboard(entry, seen));
|
||||||
|
seen.delete(value);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return CIRCULAR_REFERENCE_LABEL;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const normalized: Record<string, unknown> = {};
|
||||||
|
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
normalized[key] = normalizeErrorForClipboard(entryValue, seen);
|
||||||
|
}
|
||||||
|
seen.delete(value);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeErrorForClipboard = (error: unknown): string => {
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializableError = normalizeErrorForClipboard(error);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(serializableError, null, 2) ?? String(error);
|
||||||
|
} catch {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUnknownErrorPart = (value: unknown, seen = new WeakSet<object>()): string | undefined => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
return value.trim() || undefined;
|
return value.trim() || undefined;
|
||||||
}
|
}
|
||||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
if (typeof value === 'symbol' || typeof value === 'function') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (value instanceof Error) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return CIRCULAR_REFERENCE_LABEL;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const parts = [normalizeUnknownErrorPart(value.message, seen)];
|
||||||
|
for (const [key, entryValue] of Object.entries(value)) {
|
||||||
|
const normalizedValue = normalizeUnknownErrorPart(entryValue, seen);
|
||||||
|
if (normalizedValue) {
|
||||||
|
parts.push(`${key}: ${normalizedValue}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('cause' in value) {
|
||||||
|
const normalizedCause = normalizeUnknownErrorPart(value.cause, seen);
|
||||||
|
if (normalizedCause) {
|
||||||
|
parts.push(`cause: ${normalizedCause}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen.delete(value);
|
||||||
|
return parts.filter(Boolean).join('; ') || value.name;
|
||||||
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const parts = value.map(normalizeUnknownErrorPart).filter(Boolean);
|
if (seen.has(value)) {
|
||||||
|
return CIRCULAR_REFERENCE_LABEL;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const parts = value.map((entryValue) => normalizeUnknownErrorPart(entryValue, seen)).filter(Boolean);
|
||||||
|
seen.delete(value);
|
||||||
return parts.length ? parts.join('; ') : undefined;
|
return parts.length ? parts.join('; ') : undefined;
|
||||||
}
|
}
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return CIRCULAR_REFERENCE_LABEL;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
const entries = Object.entries(value as Record<string, unknown>)
|
const entries = Object.entries(value as Record<string, unknown>)
|
||||||
.map(([key, entryValue]) => {
|
.map(([key, entryValue]) => {
|
||||||
const normalizedValue = normalizeUnknownErrorPart(entryValue);
|
const normalizedValue = normalizeUnknownErrorPart(entryValue, seen);
|
||||||
return normalizedValue ? `${key}: ${normalizedValue}` : undefined;
|
return normalizedValue ? `${key}: ${normalizedValue}` : undefined;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
seen.delete(value);
|
||||||
|
|
||||||
if (entries.length) {
|
if (entries.length) {
|
||||||
return entries.join('; ');
|
return entries.join('; ');
|
||||||
|
|||||||
Reference in New Issue
Block a user