fix(post card): clarify full error copying

This commit is contained in:
Tommaso Casaburi
2026-04-22 13:44:55 +07:00
parent fb19ee47c8
commit be13311834
5 changed files with 313 additions and 81 deletions
+52 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatErrorForDisplay } from '../error-utils';
import { formatErrorForDisplay, serializeErrorForClipboard } from '../error-utils';
describe('error utils', () => {
it('returns plain string errors unchanged', () => {
@@ -29,4 +29,55 @@ describe('error utils', () => {
}),
).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('{}');
});
});
+137 -4
View File
@@ -4,27 +4,160 @@ type ErrorLike = {
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) {
return undefined;
}
if (typeof value === 'string') {
return value.trim() || undefined;
}
if (typeof value === 'number' || typeof value === 'boolean') {
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
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)) {
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;
}
if (typeof value === 'object') {
if (seen.has(value)) {
return CIRCULAR_REFERENCE_LABEL;
}
seen.add(value);
const entries = Object.entries(value as Record<string, unknown>)
.map(([key, entryValue]) => {
const normalizedValue = normalizeUnknownErrorPart(entryValue);
const normalizedValue = normalizeUnknownErrorPart(entryValue, seen);
return normalizedValue ? `${key}: ${normalizedValue}` : undefined;
})
.filter(Boolean);
seen.delete(value);
if (entries.length) {
return entries.join('; ');