mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-22 18:30:09 +00:00
fix(error-handling): prevent [object Object] from appearing in error messages
Improve error text extraction utilities to never return "[object Object]" when displaying error messages to users. The fix adds more robust fallback mechanisms including: - Check for message, name, statusText properties before calling toString() - Detect "[object Object]" result and fallback to JSON.stringify() - Provide meaningful fallback messages when all extraction methods fail Fixes #5790
This commit is contained in:
parent
3a5cddd8e8
commit
e571d6e3bc
5 changed files with 499 additions and 39 deletions
|
|
@ -56,20 +56,48 @@ function _handleError(
|
|||
}
|
||||
}
|
||||
|
||||
const OBJECT_OBJECT_STR = '[object Object]';
|
||||
|
||||
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
||||
function _getErrorStr(e: unknown): string {
|
||||
if (typeof e === 'string') {
|
||||
return e;
|
||||
}
|
||||
|
||||
if (e == null) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
// Check for message property first (standard Error and custom errors)
|
||||
if (typeof (e as any).message === 'string' && (e as any).message) {
|
||||
return (e as any).message;
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
return e.toString();
|
||||
}
|
||||
if (typeof e === 'object' && e !== null) {
|
||||
|
||||
// Check for name property
|
||||
if (typeof (e as any).name === 'string' && (e as any).name) {
|
||||
return (e as any).name;
|
||||
}
|
||||
|
||||
if (typeof e === 'object') {
|
||||
try {
|
||||
return JSON.stringify(e);
|
||||
} catch (err) {
|
||||
return String(e);
|
||||
const jsonStr = JSON.stringify(e);
|
||||
if (jsonStr && jsonStr !== '{}') {
|
||||
return jsonStr;
|
||||
}
|
||||
} catch {
|
||||
// Circular reference - fall through
|
||||
}
|
||||
|
||||
// Try toString but check for [object Object]
|
||||
const str = String(e);
|
||||
if (str && str !== OBJECT_OBJECT_STR) {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
return String(e);
|
||||
|
||||
return 'Unknown error (unable to extract message)';
|
||||
}
|
||||
|
|
|
|||
150
src/app/imex/sync/get-sync-error-str.spec.ts
Normal file
150
src/app/imex/sync/get-sync-error-str.spec.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { getSyncErrorStr } from './get-sync-error-str';
|
||||
import { HANDLED_ERROR_PROP_STR } from '../../app.constants';
|
||||
|
||||
describe('getSyncErrorStr', () => {
|
||||
it('should return string errors directly', () => {
|
||||
expect(getSyncErrorStr('sync failed')).toBe('sync failed');
|
||||
});
|
||||
|
||||
it('should handle null', () => {
|
||||
expect(getSyncErrorStr(null)).toBe('Unknown sync error');
|
||||
});
|
||||
|
||||
it('should handle undefined', () => {
|
||||
expect(getSyncErrorStr(undefined)).toBe('Unknown sync error');
|
||||
});
|
||||
|
||||
it('should extract message from Error instances', () => {
|
||||
const err = new Error('sync error message');
|
||||
expect(getSyncErrorStr(err)).toBe('sync error message');
|
||||
});
|
||||
|
||||
it('should handle HANDLED_ERROR_PROP_STR', () => {
|
||||
const err = { [HANDLED_ERROR_PROP_STR]: 'handled sync error' };
|
||||
expect(getSyncErrorStr(err)).toBe('handled sync error');
|
||||
});
|
||||
|
||||
it('should extract response.data string (Axios pattern)', () => {
|
||||
const err = { response: { data: 'server error response' } };
|
||||
expect(getSyncErrorStr(err)).toBe('server error response');
|
||||
});
|
||||
|
||||
it('should extract response.data.message (nested API error)', () => {
|
||||
const err = { response: { data: { message: 'API error message' } } };
|
||||
expect(getSyncErrorStr(err)).toBe('API error message');
|
||||
});
|
||||
|
||||
it('should extract name property', () => {
|
||||
const err = { name: 'SyncError' };
|
||||
expect(getSyncErrorStr(err)).toBe('SyncError');
|
||||
});
|
||||
|
||||
it('should extract statusText for HTTP errors', () => {
|
||||
const err = { statusText: 'Service Unavailable' };
|
||||
expect(getSyncErrorStr(err)).toBe('Service Unavailable');
|
||||
});
|
||||
|
||||
it('should never return [object Object]', () => {
|
||||
const plainObject = { foo: 'bar' };
|
||||
const result = getSyncErrorStr(plainObject);
|
||||
expect(result).not.toBe('[object Object]');
|
||||
});
|
||||
|
||||
it('should JSON.stringify objects without standard error properties', () => {
|
||||
const err = { code: 'NETWORK_ERROR', retry: true };
|
||||
const result = getSyncErrorStr(err);
|
||||
expect(result).toContain('code');
|
||||
expect(result).toContain('NETWORK_ERROR');
|
||||
});
|
||||
|
||||
it('should handle empty objects', () => {
|
||||
const result = getSyncErrorStr({});
|
||||
expect(result).toBe('Unknown sync error (unable to extract message)');
|
||||
});
|
||||
|
||||
it('should prioritize HANDLED_ERROR_PROP_STR over message', () => {
|
||||
const err = {
|
||||
[HANDLED_ERROR_PROP_STR]: 'handled',
|
||||
message: 'regular message',
|
||||
};
|
||||
expect(getSyncErrorStr(err)).toBe('handled');
|
||||
});
|
||||
|
||||
it('should truncate long error messages', () => {
|
||||
const longMessage = 'x'.repeat(500);
|
||||
const err = { message: longMessage };
|
||||
const result = getSyncErrorStr(err);
|
||||
expect(result.length).toBeLessThanOrEqual(403); // 400 + '...'
|
||||
});
|
||||
|
||||
it('should handle objects with custom toString', () => {
|
||||
const err = {
|
||||
toString: () => 'custom sync error',
|
||||
};
|
||||
expect(getSyncErrorStr(err)).toBe('custom sync error');
|
||||
});
|
||||
|
||||
it('should prefer message over response.data', () => {
|
||||
const err = {
|
||||
message: 'direct message',
|
||||
response: { data: 'response data' },
|
||||
};
|
||||
expect(getSyncErrorStr(err)).toBe('direct message');
|
||||
});
|
||||
|
||||
it('should handle circular reference objects gracefully', () => {
|
||||
const err: any = { message: null, data: {} };
|
||||
err.data.self = err; // circular reference
|
||||
const result = getSyncErrorStr(err);
|
||||
expect(result).not.toBe('[object Object]');
|
||||
expect(result).toBe('Unknown sync error (unable to extract message)');
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const result = getSyncErrorStr(['sync error 1', 'sync error 2']);
|
||||
expect(result).not.toBe('[object Object]');
|
||||
expect(result).toContain('sync error');
|
||||
});
|
||||
|
||||
it('should handle numbers', () => {
|
||||
expect(getSyncErrorStr(503)).toBe('503');
|
||||
});
|
||||
|
||||
it('should handle objects where toString throws', () => {
|
||||
const err = {
|
||||
toString: () => {
|
||||
throw new Error('toString failed');
|
||||
},
|
||||
};
|
||||
const result = getSyncErrorStr(err);
|
||||
expect(result).not.toBe('[object Object]');
|
||||
});
|
||||
|
||||
it('should handle empty string message', () => {
|
||||
const err = { message: '' };
|
||||
const result = getSyncErrorStr(err);
|
||||
// Empty message falls through to JSON.stringify
|
||||
expect(result).not.toBe('[object Object]');
|
||||
expect(result).toContain('message');
|
||||
});
|
||||
|
||||
it('should handle WebDAV-style errors', () => {
|
||||
const err = {
|
||||
status: 423,
|
||||
statusText: 'Locked',
|
||||
response: { data: 'Resource is locked by another process' },
|
||||
};
|
||||
expect(getSyncErrorStr(err)).toBe('Resource is locked by another process');
|
||||
});
|
||||
|
||||
it('should handle Dropbox-style API errors', () => {
|
||||
const err = {
|
||||
error: {
|
||||
error_summary: 'path/not_found/...',
|
||||
error: { tag: 'path', path: { tag: 'not_found' } },
|
||||
},
|
||||
};
|
||||
const result = getSyncErrorStr(err);
|
||||
expect(result).not.toBe('[object Object]');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +1,76 @@
|
|||
import { truncate } from '../../util/truncate';
|
||||
import { HANDLED_ERROR_PROP_STR } from '../../app.constants';
|
||||
import { isObject } from '../../util/is-object';
|
||||
|
||||
// ugly little helper to make sure we get the most information out of it for the user
|
||||
const OBJECT_OBJECT_STR = '[object Object]';
|
||||
|
||||
// Helper to extract error string from various error shapes
|
||||
export const getSyncErrorStr = (err: unknown): string => {
|
||||
let errorAsString: string =
|
||||
err && (err as any)?.toString ? (err as any).toString() : '???';
|
||||
|
||||
// Check if error has a message property (most Error objects do)
|
||||
if (err && typeof (err as any)?.message === 'string') {
|
||||
errorAsString = (err as any).message as string;
|
||||
// Handle string errors directly
|
||||
if (typeof err === 'string') {
|
||||
return truncate(err, 400);
|
||||
}
|
||||
|
||||
if (err && typeof (err as any)?.response?.data === 'string') {
|
||||
errorAsString = (err as any)?.response?.data as string;
|
||||
// Handle null/undefined
|
||||
if (err == null) {
|
||||
return 'Unknown sync error';
|
||||
}
|
||||
|
||||
if (
|
||||
errorAsString === '[object Object]' &&
|
||||
err &&
|
||||
(err as any)[HANDLED_ERROR_PROP_STR]
|
||||
) {
|
||||
errorAsString = (err as any)[HANDLED_ERROR_PROP_STR] as string;
|
||||
const errAny = err as any;
|
||||
|
||||
// Check for handled error marker first (highest priority)
|
||||
if (errAny[HANDLED_ERROR_PROP_STR]) {
|
||||
return truncate(String(errAny[HANDLED_ERROR_PROP_STR]), 400);
|
||||
}
|
||||
|
||||
// Increased from 150 to 400 to show more context, especially for HTTP errors
|
||||
return truncate(errorAsString.toString(), 400);
|
||||
// Check message property (standard Error objects)
|
||||
if (typeof errAny.message === 'string' && errAny.message) {
|
||||
return truncate(errAny.message, 400);
|
||||
}
|
||||
|
||||
// Check response.data (Axios-style errors)
|
||||
if (typeof errAny.response?.data === 'string' && errAny.response.data) {
|
||||
return truncate(errAny.response.data, 400);
|
||||
}
|
||||
|
||||
// Check response.data.message (nested API error)
|
||||
if (typeof errAny.response?.data?.message === 'string') {
|
||||
return truncate(errAny.response.data.message, 400);
|
||||
}
|
||||
|
||||
// Check for name property
|
||||
if (typeof errAny.name === 'string' && errAny.name) {
|
||||
return truncate(errAny.name, 400);
|
||||
}
|
||||
|
||||
// Check for statusText (HTTP errors)
|
||||
if (typeof errAny.statusText === 'string' && errAny.statusText) {
|
||||
return truncate(errAny.statusText, 400);
|
||||
}
|
||||
|
||||
// Try toString() but check for [object Object]
|
||||
if (typeof errAny.toString === 'function') {
|
||||
try {
|
||||
const str = errAny.toString();
|
||||
if (str && str !== OBJECT_OBJECT_STR) {
|
||||
return truncate(str, 400);
|
||||
}
|
||||
} catch {
|
||||
// toString() threw - fall through to JSON.stringify
|
||||
}
|
||||
}
|
||||
|
||||
// Try JSON.stringify as last resort
|
||||
if (isObject(err)) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(err);
|
||||
if (jsonStr && jsonStr !== '{}') {
|
||||
return truncate(jsonStr, 400);
|
||||
}
|
||||
} catch {
|
||||
// Circular reference or other JSON error - fall through
|
||||
}
|
||||
}
|
||||
|
||||
return 'Unknown sync error (unable to extract message)';
|
||||
};
|
||||
|
|
|
|||
178
src/app/util/get-error-text.spec.ts
Normal file
178
src/app/util/get-error-text.spec.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { getErrorTxt } from './get-error-text';
|
||||
import { HANDLED_ERROR_PROP_STR } from '../app.constants';
|
||||
|
||||
describe('getErrorTxt', () => {
|
||||
it('should return string errors directly', () => {
|
||||
expect(getErrorTxt('simple error')).toBe('simple error');
|
||||
});
|
||||
|
||||
it('should handle null', () => {
|
||||
expect(getErrorTxt(null)).toBe('Unknown error (null/undefined)');
|
||||
});
|
||||
|
||||
it('should handle undefined', () => {
|
||||
expect(getErrorTxt(undefined)).toBe('Unknown error (null/undefined)');
|
||||
});
|
||||
|
||||
it('should extract message from Error instances', () => {
|
||||
const err = new Error('test error message');
|
||||
expect(getErrorTxt(err)).toBe('test error message');
|
||||
});
|
||||
|
||||
it('should extract message from plain objects with message property', () => {
|
||||
expect(getErrorTxt({ message: 'object error' })).toBe('object error');
|
||||
});
|
||||
|
||||
it('should handle HANDLED_ERROR_PROP_STR', () => {
|
||||
const err = { [HANDLED_ERROR_PROP_STR]: 'handled error message' };
|
||||
expect(getErrorTxt(err)).toBe('handled error message');
|
||||
});
|
||||
|
||||
it('should extract nested error.message (HttpErrorResponse pattern)', () => {
|
||||
const err = { error: { message: 'nested error message' } };
|
||||
expect(getErrorTxt(err)).toBe('nested error message');
|
||||
});
|
||||
|
||||
it('should extract error.name when message is not available', () => {
|
||||
const err = { error: { name: 'ValidationError' } };
|
||||
expect(getErrorTxt(err)).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('should extract deeply nested error.error.message', () => {
|
||||
const err = { error: { error: { message: 'deep nested message' } } };
|
||||
expect(getErrorTxt(err)).toBe('deep nested message');
|
||||
});
|
||||
|
||||
it('should extract name property when message is not available', () => {
|
||||
const err = { name: 'CustomError' };
|
||||
expect(getErrorTxt(err)).toBe('CustomError');
|
||||
});
|
||||
|
||||
it('should extract statusText for HTTP errors', () => {
|
||||
const err = { statusText: 'Not Found' };
|
||||
expect(getErrorTxt(err)).toBe('Not Found');
|
||||
});
|
||||
|
||||
it('should never return [object Object]', () => {
|
||||
const plainObject = { foo: 'bar' };
|
||||
const result = getErrorTxt(plainObject);
|
||||
expect(result).not.toBe('[object Object]');
|
||||
expect(result).toContain('foo');
|
||||
});
|
||||
|
||||
it('should JSON.stringify objects without standard error properties', () => {
|
||||
const err = { code: 500, details: 'server error' };
|
||||
const result = getErrorTxt(err);
|
||||
expect(result).toContain('code');
|
||||
expect(result).toContain('500');
|
||||
});
|
||||
|
||||
it('should handle empty objects', () => {
|
||||
const result = getErrorTxt({});
|
||||
expect(result).toBe('Unknown error (unable to extract message)');
|
||||
});
|
||||
|
||||
it('should prioritize HANDLED_ERROR_PROP_STR over message', () => {
|
||||
const err = {
|
||||
[HANDLED_ERROR_PROP_STR]: 'handled',
|
||||
message: 'regular message',
|
||||
};
|
||||
expect(getErrorTxt(err)).toBe('handled');
|
||||
});
|
||||
|
||||
it('should handle TypeError instances', () => {
|
||||
const err = new TypeError('Cannot read property of undefined');
|
||||
expect(getErrorTxt(err)).toBe('Cannot read property of undefined');
|
||||
});
|
||||
|
||||
it('should handle objects with custom toString that does not return [object Object]', () => {
|
||||
const err = {
|
||||
toString: () => 'custom error string',
|
||||
};
|
||||
expect(getErrorTxt(err)).toBe('custom error string');
|
||||
});
|
||||
|
||||
it('should truncate long JSON strings', () => {
|
||||
const longValue = 'x'.repeat(300);
|
||||
const err = { data: longValue };
|
||||
const result = getErrorTxt(err);
|
||||
expect(result.length).toBeLessThanOrEqual(203); // 200 + '...'
|
||||
});
|
||||
|
||||
it('should handle circular reference objects gracefully', () => {
|
||||
const err: any = { message: null, data: {} };
|
||||
err.data.self = err; // circular reference
|
||||
const result = getErrorTxt(err);
|
||||
// Should not throw and should not return [object Object]
|
||||
expect(result).not.toBe('[object Object]');
|
||||
expect(result).toBe('Unknown error (unable to extract message)');
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const result = getErrorTxt(['error1', 'error2']);
|
||||
expect(result).not.toBe('[object Object]');
|
||||
expect(result).toContain('error1');
|
||||
});
|
||||
|
||||
it('should handle numbers', () => {
|
||||
expect(getErrorTxt(404)).toBe('404');
|
||||
});
|
||||
|
||||
it('should handle booleans', () => {
|
||||
expect(getErrorTxt(false)).toBe('false');
|
||||
});
|
||||
|
||||
it('should handle objects where toString throws', () => {
|
||||
const err = {
|
||||
toString: () => {
|
||||
throw new Error('toString failed');
|
||||
},
|
||||
};
|
||||
const result = getErrorTxt(err);
|
||||
expect(result).not.toBe('[object Object]');
|
||||
});
|
||||
|
||||
it('should handle empty string message', () => {
|
||||
const err = { message: '' };
|
||||
const result = getErrorTxt(err);
|
||||
// Empty message falls through to JSON.stringify
|
||||
expect(result).not.toBe('[object Object]');
|
||||
expect(result).toContain('message');
|
||||
});
|
||||
|
||||
it('should handle RangeError instances', () => {
|
||||
const err = new RangeError('Maximum call stack size exceeded');
|
||||
expect(getErrorTxt(err)).toBe('Maximum call stack size exceeded');
|
||||
});
|
||||
|
||||
it('should handle SyntaxError instances', () => {
|
||||
const err = new SyntaxError('Unexpected token');
|
||||
expect(getErrorTxt(err)).toBe('Unexpected token');
|
||||
});
|
||||
|
||||
it('should handle DOMException-like objects', () => {
|
||||
const err = { name: 'NotFoundError', message: 'Node was not found' };
|
||||
expect(getErrorTxt(err)).toBe('Node was not found');
|
||||
});
|
||||
|
||||
it('should handle HTTP response error objects', () => {
|
||||
const err = {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
error: { message: 'Database connection failed' },
|
||||
};
|
||||
expect(getErrorTxt(err)).toBe('Database connection failed');
|
||||
});
|
||||
|
||||
it('should handle Axios-style error with response.data.error', () => {
|
||||
const err = {
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: 'Unauthorized access' },
|
||||
},
|
||||
};
|
||||
const result = getErrorTxt(err);
|
||||
// Should JSON.stringify since no direct message property
|
||||
expect(result).toContain('Unauthorized');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,24 +1,80 @@
|
|||
import { isObject } from './is-object';
|
||||
import { HANDLED_ERROR_PROP_STR } from '../app.constants';
|
||||
|
||||
const OBJECT_OBJECT_STR = '[object Object]';
|
||||
|
||||
export const getErrorTxt = (err: unknown): string => {
|
||||
if (err && isObject((err as any).error)) {
|
||||
return (
|
||||
(err as any).error.message ||
|
||||
(err as any).error.name ||
|
||||
// for ngx translate...
|
||||
(isObject((err as any).error.error)
|
||||
? (err as any).error.error.toString()
|
||||
: (err as any).error) ||
|
||||
(err as any).error
|
||||
);
|
||||
} else if (err && (err as any)[HANDLED_ERROR_PROP_STR]) {
|
||||
return (err as any)[HANDLED_ERROR_PROP_STR];
|
||||
} else if (err && (err as any).toString) {
|
||||
return (err as any).toString();
|
||||
} else if (typeof err === 'string') {
|
||||
// Handle string errors directly
|
||||
if (typeof err === 'string') {
|
||||
return err;
|
||||
} else {
|
||||
return 'Unknown getErrorTxt error';
|
||||
}
|
||||
|
||||
// Handle null/undefined
|
||||
if (err == null) {
|
||||
return 'Unknown error (null/undefined)';
|
||||
}
|
||||
|
||||
const errAny = err as any;
|
||||
|
||||
// Check for handled error marker first
|
||||
if (errAny[HANDLED_ERROR_PROP_STR]) {
|
||||
return errAny[HANDLED_ERROR_PROP_STR];
|
||||
}
|
||||
|
||||
// Check direct message property (standard Error objects)
|
||||
if (typeof errAny.message === 'string' && errAny.message) {
|
||||
return errAny.message;
|
||||
}
|
||||
|
||||
// Check nested error.message (HttpErrorResponse pattern)
|
||||
if (isObject(errAny.error)) {
|
||||
if (typeof errAny.error.message === 'string' && errAny.error.message) {
|
||||
return errAny.error.message;
|
||||
}
|
||||
if (typeof errAny.error.name === 'string' && errAny.error.name) {
|
||||
return errAny.error.name;
|
||||
}
|
||||
// Handle deeper nesting (ngx-translate pattern)
|
||||
if (isObject(errAny.error.error)) {
|
||||
if (typeof errAny.error.error.message === 'string') {
|
||||
return errAny.error.error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for name property (some Error subclasses)
|
||||
if (typeof errAny.name === 'string' && errAny.name) {
|
||||
return errAny.name;
|
||||
}
|
||||
|
||||
// Check for statusText (HTTP errors)
|
||||
if (typeof errAny.statusText === 'string' && errAny.statusText) {
|
||||
return errAny.statusText;
|
||||
}
|
||||
|
||||
// Try toString() but check for [object Object]
|
||||
if (typeof errAny.toString === 'function') {
|
||||
try {
|
||||
const str = errAny.toString();
|
||||
if (str && str !== OBJECT_OBJECT_STR) {
|
||||
return str;
|
||||
}
|
||||
} catch {
|
||||
// toString() threw - fall through to JSON.stringify
|
||||
}
|
||||
}
|
||||
|
||||
// Try JSON.stringify as last resort for objects
|
||||
if (isObject(err)) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(err);
|
||||
if (jsonStr && jsonStr !== '{}') {
|
||||
return jsonStr.length > 200 ? jsonStr.substring(0, 200) + '...' : jsonStr;
|
||||
}
|
||||
} catch {
|
||||
// Circular reference or other JSON error - fall through
|
||||
}
|
||||
}
|
||||
|
||||
return 'Unknown error (unable to extract message)';
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue