From 28eb7b3cf05e234be896e3242ca9da4b25e8a724 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Mon, 9 Mar 2026 19:00:32 +0300 Subject: [PATCH 01/13] fix: preserve content when promise is rejected with plain object --- .../javascript/src/addons/consoleCatcher.ts | 21 ++++++++++- packages/javascript/src/catcher.ts | 18 ++-------- packages/javascript/src/utils/event.ts | 36 +++++++++++++++++++ 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index 29519eaa..17a926a7 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -189,6 +189,25 @@ export class ConsoleCatcher { this.consoleOutput.push(logEvent); } + /** + * Converts a promise rejection reason to a string message. + * + * String(obj) gives "[object Object]" and JSON.stringify("str") + * adds unwanted quotes. + * + * @param reason - The rejection reason from PromiseRejectionEvent + */ + private stringifyReason(reason: unknown): string { + if (reason instanceof Error) { + return reason.message; + } + if (typeof reason === 'string') { + return reason; + } + + return JSON.stringify(Sanitizer.sanitize(reason)); + } + /** * Creates a console log event from an error or promise rejection * @@ -212,7 +231,7 @@ export class ConsoleCatcher { method: 'error', timestamp: new Date(), type: 'UnhandledRejection', - message: event.reason?.message || String(event.reason), + message: this.stringifyReason(event.reason), stack: event.reason?.stack || '', fileLine: '', }; diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 822d974c..e7de0295 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -14,7 +14,7 @@ import type { } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from './types/integrations'; import { EventRejectedError } from './errors'; -import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; +import { isErrorProcessed, markErrorAsProcessed, getErrorFromEvent } from './utils/event'; import { BrowserRandomGenerator } from './utils/random'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; @@ -331,21 +331,7 @@ export default class Catcher { this.consoleCatcher!.addErrorEvent(event); } - /** - * Promise rejection reason is recommended to be an Error, but it can be a string: - * - Promise.reject(new Error('Reason message')) ——— recommended - * - Promise.reject('Reason message') - */ - let error = (event as ErrorEvent).error || (event as PromiseRejectionEvent).reason; - - /** - * Case when error triggered in external script - * We can't access event error object because of CORS - * Event message will be 'Script error.' - */ - if (event instanceof ErrorEvent && error === undefined) { - error = (event as ErrorEvent).message; - } + const error = getErrorFromEvent(event); void this.formatAndSend(error); } diff --git a/packages/javascript/src/utils/event.ts b/packages/javascript/src/utils/event.ts index 63741533..a44bec6d 100644 --- a/packages/javascript/src/utils/event.ts +++ b/packages/javascript/src/utils/event.ts @@ -1,4 +1,5 @@ import { log } from '@hawk.so/core'; +import Sanitizer from '../modules/sanitizer'; /** * Symbol to mark error as processed by Hawk @@ -44,3 +45,38 @@ export function markErrorAsProcessed(error: unknown): void { log('Failed to mark error as processed', 'error', e); } } + +/** + * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent + * + * @param event - The error or promise rejection event + */ +export function getErrorFromEvent(event: ErrorEvent | PromiseRejectionEvent): Error | string { + /** + * Promise rejection reason is recommended to be an Error, but it can be a string: + * - Promise.reject(new Error('Reason message')) ——— recommended + * - Promise.reject('Reason message') + */ + let error = (event as ErrorEvent).error || (event as PromiseRejectionEvent).reason; + + /** + * Case when error triggered in external script + * We can't access event error object because of CORS + * Event message will be 'Script error.' + */ + if (event instanceof ErrorEvent && error === undefined) { + error = (event as ErrorEvent).message; + } + + /** + * Case when error rejected with an object + * Using a string instead of wrapping in Error is more natural, + * it doesn't fake the backtrace, also prefix added for dashboard readability + */ + if (error instanceof Object && !(error instanceof Error) && event instanceof PromiseRejectionEvent) { + // Extra sanitize is needed to handle objects with circular references before JSON.stringify + error = `Promise rejected with ${JSON.stringify(Sanitizer.sanitize(error))}`; + } + + return Sanitizer.sanitize(error); +} From 033bdfb6a5fc760fe07a7ee261f7a8af31aa6ac9 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 11 Mar 2026 20:03:48 +0300 Subject: [PATCH 02/13] fix: move stringify reason method to utils --- .../javascript/src/addons/consoleCatcher.ts | 22 ++----------------- packages/javascript/src/utils/event.ts | 19 ++++++++++++++++ 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index 17a926a7..7d7750d7 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -3,6 +3,7 @@ */ import type { ConsoleLogEvent } from '@hawk.so/types'; import Sanitizer from '../modules/sanitizer'; +import { stringifyRejectionReason } from 'src/utils/event'; /** * Maximum number of console logs to store @@ -189,25 +190,6 @@ export class ConsoleCatcher { this.consoleOutput.push(logEvent); } - /** - * Converts a promise rejection reason to a string message. - * - * String(obj) gives "[object Object]" and JSON.stringify("str") - * adds unwanted quotes. - * - * @param reason - The rejection reason from PromiseRejectionEvent - */ - private stringifyReason(reason: unknown): string { - if (reason instanceof Error) { - return reason.message; - } - if (typeof reason === 'string') { - return reason; - } - - return JSON.stringify(Sanitizer.sanitize(reason)); - } - /** * Creates a console log event from an error or promise rejection * @@ -231,7 +213,7 @@ export class ConsoleCatcher { method: 'error', timestamp: new Date(), type: 'UnhandledRejection', - message: this.stringifyReason(event.reason), + message: stringifyRejectionReason(event.reason), stack: event.reason?.stack || '', fileLine: '', }; diff --git a/packages/javascript/src/utils/event.ts b/packages/javascript/src/utils/event.ts index a44bec6d..058f2ec4 100644 --- a/packages/javascript/src/utils/event.ts +++ b/packages/javascript/src/utils/event.ts @@ -80,3 +80,22 @@ export function getErrorFromEvent(event: ErrorEvent | PromiseRejectionEvent): Er return Sanitizer.sanitize(error); } + +/** + * Converts a promise rejection reason to a string message. + * + * String(obj) gives "[object Object]" and JSON.stringify("str") + * adds unwanted quotes. + * + * @param reason - The rejection reason from PromiseRejectionEvent + */ +export function stringifyRejectionReason(reason: unknown): string { + if (reason instanceof Error) { + return reason.message; + } + if (typeof reason === 'string') { + return reason; + } + + return JSON.stringify(Sanitizer.sanitize(reason)); +} From e2f8a61ab31911cb946a79e142f9bdee8ed97ac5 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 11 Mar 2026 17:37:09 +0300 Subject: [PATCH 03/13] fix: fixed import --- packages/javascript/src/addons/consoleCatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index 7d7750d7..efe7ef3b 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -3,7 +3,7 @@ */ import type { ConsoleLogEvent } from '@hawk.so/types'; import Sanitizer from '../modules/sanitizer'; -import { stringifyRejectionReason } from 'src/utils/event'; +import { stringifyRejectionReason } from '../utils/event'; /** * Maximum number of console logs to store From 47c696728bae733ada390f7a6373c4ad9e88bae4 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 11 Mar 2026 17:38:33 +0300 Subject: [PATCH 04/13] test: tests added for getErrorFromEvent --- packages/javascript/tests/utils/event.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/javascript/tests/utils/event.test.ts diff --git a/packages/javascript/tests/utils/event.test.ts b/packages/javascript/tests/utils/event.test.ts new file mode 100644 index 00000000..55ec8a15 --- /dev/null +++ b/packages/javascript/tests/utils/event.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getErrorFromEvent } from '../../src/utils/event'; + +vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); + +vi.mock('../../src/modules/sanitizer', () => ({ + default: { + sanitize: vi.fn((data) => data), + }, +})); + +import Sanitizer from '../../src/modules/sanitizer'; + +describe('getErrorFromEvent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('ErrorEvent', () => { + it('should return the Error when event.error is an Error instance', () => { + const error = new Error('Test error'); + const event = new ErrorEvent('error', { error }); + + const result = getErrorFromEvent(event); + + expect(result).toBe(error); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(error); + }); + + it('should return the DOMException when event.error is a DOMException', () => { + const error = new DOMException('Network error', 'NetworkError'); + const event = new ErrorEvent('error', { error }); + + const result = getErrorFromEvent(event); + + expect(result).toBe(error); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(error); + }); + + it('should return the message when event.error is not provided and message is a string', () => { + const event = new ErrorEvent('error', { message: 'Script error.' }); + + const result = getErrorFromEvent(event); + + expect(result).toBe('Script error.'); + expect(Sanitizer.sanitize).toHaveBeenCalledWith('Script error.'); + }); + + it('should return empty string when event.error is not provided and message is empty', () => { + const event = new ErrorEvent('error', { message: '' }); + + const result = getErrorFromEvent(event); + + expect(result).toBe(''); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(''); + }); + }); + + describe('PromiseRejectionEvent', () => { + it('should return the Error when event.reason is an Error instance', () => { + const reason = new Error('Promise rejected'); + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); + + const result = getErrorFromEvent(event); + + expect(result).toBe(reason); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(reason); + }); + + it('should return the string when event.reason is a string', () => { + const reason = 'Promise rejected with string'; + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); + + const result = getErrorFromEvent(event); + + expect(result).toBe(reason); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(reason); + }); + + it('should return stringified object when event.reason is a plain object', () => { + const reason = { code: 'ERR_001', details: 'Something went wrong' }; + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); + + const result = getErrorFromEvent(event); + + expect(result).toBe('Promise rejected with {"code":"ERR_001","details":"Something went wrong"}'); + }); + + it('should return undefined when event.reason is not provided', () => { + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: undefined }); + + const result = getErrorFromEvent(event); + + expect(result).toBeUndefined(); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(undefined); + }); + + it('should return null when event.reason is null', () => { + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: null }); + + const result = getErrorFromEvent(event); + + expect(result).toBeNull(); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(null); + }); + + it('should handle circular references in object reason', () => { + vi.mocked(Sanitizer.sanitize).mockImplementation((data) => { + if (data !== null && typeof data === 'object') { + const seen = new WeakSet(); + const sanitize = (obj: unknown): unknown => { + if (obj !== null && typeof obj === 'object') { + if (seen.has(obj as object)) { + return ''; + } + seen.add(obj as object); + if (Array.isArray(obj)) { + return obj.map(sanitize); + } + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = sanitize(value); + } + return result; + } + return obj; + }; + return sanitize(data); + } + return data; + }); + + const circularObj: Record = { name: 'test' }; + circularObj.self = circularObj; + + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: circularObj }); + + const result = getErrorFromEvent(event); + + expect(result).toContain('Promise rejected with'); + expect(result).toContain(''); + }); + }); +}); From ad0313a2f1f6dd3419b3b4942e3d5032f09f1102 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 11 Mar 2026 17:55:25 +0300 Subject: [PATCH 05/13] test: remove sanitizer mocking --- packages/javascript/tests/utils/event.test.ts | 41 +------------------ 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/packages/javascript/tests/utils/event.test.ts b/packages/javascript/tests/utils/event.test.ts index 55ec8a15..a221a6b1 100644 --- a/packages/javascript/tests/utils/event.test.ts +++ b/packages/javascript/tests/utils/event.test.ts @@ -3,12 +3,6 @@ import { getErrorFromEvent } from '../../src/utils/event'; vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); -vi.mock('../../src/modules/sanitizer', () => ({ - default: { - sanitize: vi.fn((data) => data), - }, -})); - import Sanitizer from '../../src/modules/sanitizer'; describe('getErrorFromEvent', () => { @@ -24,7 +18,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBe(error); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(error); }); it('should return the DOMException when event.error is a DOMException', () => { @@ -33,8 +26,7 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); - expect(result).toBe(error); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(error); + expect(result).toBe(''); }); it('should return the message when event.error is not provided and message is a string', () => { @@ -43,7 +35,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBe('Script error.'); - expect(Sanitizer.sanitize).toHaveBeenCalledWith('Script error.'); }); it('should return empty string when event.error is not provided and message is empty', () => { @@ -52,7 +43,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBe(''); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(''); }); }); @@ -64,7 +54,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBe(reason); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(reason); }); it('should return the string when event.reason is a string', () => { @@ -74,7 +63,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBe(reason); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(reason); }); it('should return stringified object when event.reason is a plain object', () => { @@ -92,7 +80,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBeUndefined(); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(undefined); }); it('should return null when event.reason is null', () => { @@ -101,35 +88,9 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBeNull(); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(null); }); it('should handle circular references in object reason', () => { - vi.mocked(Sanitizer.sanitize).mockImplementation((data) => { - if (data !== null && typeof data === 'object') { - const seen = new WeakSet(); - const sanitize = (obj: unknown): unknown => { - if (obj !== null && typeof obj === 'object') { - if (seen.has(obj as object)) { - return ''; - } - seen.add(obj as object); - if (Array.isArray(obj)) { - return obj.map(sanitize); - } - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = sanitize(value); - } - return result; - } - return obj; - }; - return sanitize(data); - } - return data; - }); - const circularObj: Record = { name: 'test' }; circularObj.self = circularObj; From c33558690dc1d308b975d6376c8884b20b0609ea Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 11 Mar 2026 18:28:44 +0300 Subject: [PATCH 06/13] fix: extract error utilities --- packages/javascript/src/catcher.ts | 5 +- packages/javascript/src/utils/error.ts | 55 +++++++++++++++++++ packages/javascript/src/utils/event.ts | 55 ------------------- .../utils/{event.test.ts => error.test.ts} | 24 ++++---- 4 files changed, 70 insertions(+), 69 deletions(-) create mode 100644 packages/javascript/src/utils/error.ts rename packages/javascript/tests/utils/{event.test.ts => error.test.ts} (83%) diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index e7de0295..16ec3650 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -14,7 +14,8 @@ import type { } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from './types/integrations'; import { EventRejectedError } from './errors'; -import { isErrorProcessed, markErrorAsProcessed, getErrorFromEvent } from './utils/event'; +import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; +import { getErrorFromErrorEvent } from './utils/error'; import { BrowserRandomGenerator } from './utils/random'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; @@ -331,7 +332,7 @@ export default class Catcher { this.consoleCatcher!.addErrorEvent(event); } - const error = getErrorFromEvent(event); + const error = getErrorFromErrorEvent(event); void this.formatAndSend(error); } diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts new file mode 100644 index 00000000..9182ecef --- /dev/null +++ b/packages/javascript/src/utils/error.ts @@ -0,0 +1,55 @@ +import Sanitizer from '../modules/sanitizer'; + +/** + * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent + * + * @param event - The error or promise rejection event + */ +export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): Error | string { + /** + * Promise rejection reason is recommended to be an Error, but it can be a string: + * - Promise.reject(new Error('Reason message')) ——— recommended + * - Promise.reject('Reason message') + */ + let error = (event as ErrorEvent).error || (event as PromiseRejectionEvent).reason; + + /** + * Case when error triggered in external script + * We can't access event error object because of CORS + * Event message will be 'Script error.' + */ + if (event instanceof ErrorEvent && error === undefined) { + error = (event as ErrorEvent).message; + } + + /** + * Case when error rejected with an object + * Using a string instead of wrapping in Error is more natural, + * it doesn't fake the backtrace, also prefix added for dashboard readability + */ + if (error instanceof Object && !(error instanceof Error) && event instanceof PromiseRejectionEvent) { + // Extra sanitize is needed to handle objects with circular references before JSON.stringify + error = `Promise rejected with ${JSON.stringify(Sanitizer.sanitize(error))}`; + } + + return Sanitizer.sanitize(error); +} + +/** + * Converts a promise rejection reason to a string message. + * + * String(obj) gives "[object Object]" and JSON.stringify("str") + * adds unwanted quotes. + * + * @param reason - The rejection reason from PromiseRejectionEvent + */ +export function stringifyRejectionReason(reason: unknown): string { + if (reason instanceof Error) { + return reason.message; + } + if (typeof reason === 'string') { + return reason; + } + + return JSON.stringify(Sanitizer.sanitize(reason)); +} diff --git a/packages/javascript/src/utils/event.ts b/packages/javascript/src/utils/event.ts index 058f2ec4..63741533 100644 --- a/packages/javascript/src/utils/event.ts +++ b/packages/javascript/src/utils/event.ts @@ -1,5 +1,4 @@ import { log } from '@hawk.so/core'; -import Sanitizer from '../modules/sanitizer'; /** * Symbol to mark error as processed by Hawk @@ -45,57 +44,3 @@ export function markErrorAsProcessed(error: unknown): void { log('Failed to mark error as processed', 'error', e); } } - -/** - * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent - * - * @param event - The error or promise rejection event - */ -export function getErrorFromEvent(event: ErrorEvent | PromiseRejectionEvent): Error | string { - /** - * Promise rejection reason is recommended to be an Error, but it can be a string: - * - Promise.reject(new Error('Reason message')) ——— recommended - * - Promise.reject('Reason message') - */ - let error = (event as ErrorEvent).error || (event as PromiseRejectionEvent).reason; - - /** - * Case when error triggered in external script - * We can't access event error object because of CORS - * Event message will be 'Script error.' - */ - if (event instanceof ErrorEvent && error === undefined) { - error = (event as ErrorEvent).message; - } - - /** - * Case when error rejected with an object - * Using a string instead of wrapping in Error is more natural, - * it doesn't fake the backtrace, also prefix added for dashboard readability - */ - if (error instanceof Object && !(error instanceof Error) && event instanceof PromiseRejectionEvent) { - // Extra sanitize is needed to handle objects with circular references before JSON.stringify - error = `Promise rejected with ${JSON.stringify(Sanitizer.sanitize(error))}`; - } - - return Sanitizer.sanitize(error); -} - -/** - * Converts a promise rejection reason to a string message. - * - * String(obj) gives "[object Object]" and JSON.stringify("str") - * adds unwanted quotes. - * - * @param reason - The rejection reason from PromiseRejectionEvent - */ -export function stringifyRejectionReason(reason: unknown): string { - if (reason instanceof Error) { - return reason.message; - } - if (typeof reason === 'string') { - return reason; - } - - return JSON.stringify(Sanitizer.sanitize(reason)); -} diff --git a/packages/javascript/tests/utils/event.test.ts b/packages/javascript/tests/utils/error.test.ts similarity index 83% rename from packages/javascript/tests/utils/event.test.ts rename to packages/javascript/tests/utils/error.test.ts index a221a6b1..64e38fce 100644 --- a/packages/javascript/tests/utils/event.test.ts +++ b/packages/javascript/tests/utils/error.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getErrorFromEvent } from '../../src/utils/event'; +import { getErrorFromErrorEvent } from '../../src/utils/error'; vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); import Sanitizer from '../../src/modules/sanitizer'; -describe('getErrorFromEvent', () => { +describe('getErrorFromErrorEvent', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -15,7 +15,7 @@ describe('getErrorFromEvent', () => { const error = new Error('Test error'); const event = new ErrorEvent('error', { error }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe(error); }); @@ -24,7 +24,7 @@ describe('getErrorFromEvent', () => { const error = new DOMException('Network error', 'NetworkError'); const event = new ErrorEvent('error', { error }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe(''); }); @@ -32,7 +32,7 @@ describe('getErrorFromEvent', () => { it('should return the message when event.error is not provided and message is a string', () => { const event = new ErrorEvent('error', { message: 'Script error.' }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe('Script error.'); }); @@ -40,7 +40,7 @@ describe('getErrorFromEvent', () => { it('should return empty string when event.error is not provided and message is empty', () => { const event = new ErrorEvent('error', { message: '' }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe(''); }); @@ -51,7 +51,7 @@ describe('getErrorFromEvent', () => { const reason = new Error('Promise rejected'); const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe(reason); }); @@ -60,7 +60,7 @@ describe('getErrorFromEvent', () => { const reason = 'Promise rejected with string'; const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe(reason); }); @@ -69,7 +69,7 @@ describe('getErrorFromEvent', () => { const reason = { code: 'ERR_001', details: 'Something went wrong' }; const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe('Promise rejected with {"code":"ERR_001","details":"Something went wrong"}'); }); @@ -77,7 +77,7 @@ describe('getErrorFromEvent', () => { it('should return undefined when event.reason is not provided', () => { const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: undefined }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBeUndefined(); }); @@ -85,7 +85,7 @@ describe('getErrorFromEvent', () => { it('should return null when event.reason is null', () => { const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: null }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBeNull(); }); @@ -96,7 +96,7 @@ describe('getErrorFromEvent', () => { const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: circularObj }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toContain('Promise rejected with'); expect(result).toContain(''); From b2c89814d1ac6ad2da6509da1a2530355a31d903 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Fri, 10 Apr 2026 14:22:46 +0300 Subject: [PATCH 07/13] refactor(javascript): normalize browser error event extraction --- .../javascript/src/addons/consoleCatcher.ts | 14 +- packages/javascript/src/catcher.ts | 81 ++++-------- packages/javascript/src/utils/error.ts | 122 ++++++++++++------ .../tests/catcher.global-handlers.test.ts | 2 +- packages/javascript/tests/utils/error.test.ts | 116 +++++++++++------ 5 files changed, 195 insertions(+), 140 deletions(-) diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index efe7ef3b..f131bec4 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -3,7 +3,7 @@ */ import type { ConsoleLogEvent } from '@hawk.so/types'; import Sanitizer from '../modules/sanitizer'; -import { stringifyRejectionReason } from '../utils/event'; +import { getErrorFromErrorEvent } from '../utils/error'; /** * Maximum number of console logs to store @@ -196,13 +196,15 @@ export class ConsoleCatcher { * @param event - The error event or promise rejection event to convert */ private createConsoleEventFromError(event: ErrorEvent | PromiseRejectionEvent): ConsoleLogEvent { + const capturedError = getErrorFromErrorEvent(event); + if (event instanceof ErrorEvent) { return { method: 'error', timestamp: new Date(), - type: event.error?.name || 'Error', - message: event.error?.message || event.message, - stack: event.error?.stack || '', + type: capturedError.type || 'Error', + message: capturedError.title, + stack: (capturedError.rawError as Error)?.stack || '', fileLine: event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : '', @@ -213,8 +215,8 @@ export class ConsoleCatcher { method: 'error', timestamp: new Date(), type: 'UnhandledRejection', - message: stringifyRejectionReason(event.reason), - stack: event.reason?.stack || '', + message: capturedError.title, + stack: (capturedError.rawError as Error)?.stack || '', fileLine: '', }; } diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 16ec3650..c0e6dd67 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -10,12 +10,13 @@ import type { EventContext, JavaScriptAddons, Json, - VueIntegrationAddons + VueIntegrationAddons, } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from './types/integrations'; import { EventRejectedError } from './errors'; import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; -import { getErrorFromErrorEvent } from './utils/error'; +import type { CapturedError } from './utils/error'; +import { fillCapturedError, getErrorFromErrorEvent } from './utils/error'; import { BrowserRandomGenerator } from './utils/random'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; @@ -222,7 +223,7 @@ export default class Catcher { * @param [context] - any additional data to send */ public send(message: Error | string, context?: EventContext): void { - void this.formatAndSend(message, undefined, context); + void this.formatAndSend(fillCapturedError(message), undefined, context); } /** @@ -234,7 +235,7 @@ export default class Catcher { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any public captureError(error: Error | string, addons?: JavaScriptCatcherIntegrations): void { - void this.formatAndSend(error, addons); + void this.formatAndSend(fillCapturedError(error), addons); } /** @@ -247,7 +248,7 @@ export default class Catcher { this.vue = new VueIntegration( vue, (error: Error, addons: VueIntegrationAddons) => { - void this.formatAndSend(error, { + void this.formatAndSend(fillCapturedError(error), { vue: addons, }); }, @@ -345,13 +346,13 @@ export default class Catcher { * @param context - any additional data passed by user */ private async formatAndSend( - error: Error | string, + error: CapturedError, // eslint-disable-next-line @typescript-eslint/no-explicit-any integrationAddons?: JavaScriptCatcherIntegrations, context?: EventContext ): Promise { try { - const isAlreadySentError = isErrorProcessed(error); + const isAlreadySentError = isErrorProcessed(error.rawError); if (isAlreadySentError) { /** @@ -359,7 +360,7 @@ export default class Catcher { */ return; } else { - markErrorAsProcessed(error); + markErrorAsProcessed(error.rawError); } const errorFormatted = await this.prepareErrorFormatted(error, context); @@ -402,16 +403,17 @@ export default class Catcher { * @param error - error to format * @param context - any additional data passed by user */ - private async prepareErrorFormatted(error: Error | string, context?: EventContext): Promise { + private async prepareErrorFormatted(error: CapturedError, context?: EventContext): Promise { + const { title, type, rawError } = error; let payload: HawkJavaScriptEvent = { - title: this.getTitle(error), - type: this.getType(error), + title, + type, release: this.getRelease(), breadcrumbs: this.getBreadcrumbsForEvent(), context: this.getContext(context), user: this.getUser(), - addons: this.getAddons(error), - backtrace: await this.getBacktrace(error), + addons: this.getAddons(rawError), + backtrace: await this.getBacktrace(rawError), catcherVersion: this.version, }; @@ -463,44 +465,6 @@ export default class Catcher { }; } - /** - * Return event title - * - * @param error - event from which to get the title - */ - private getTitle(error: Error | string): string { - const notAnError = !(error instanceof Error); - - /** - * Case when error is 'reason' of PromiseRejectionEvent - * and reject() provided with text reason instead of Error() - */ - if (notAnError) { - return error.toString() as string; - } - - return (error as Error).message; - } - - /** - * Return event type: TypeError, ReferenceError etc - * - * @param error - caught error - */ - private getType(error: Error | string): HawkJavaScriptEvent['type'] { - const notAnError = !(error instanceof Error); - - /** - * Case when error is 'reason' of PromiseRejectionEvent - * and reject() provided with text reason instead of Error() - */ - if (notAnError) { - return null; - } - - return (error as Error).name; - } - /** * Release version */ @@ -590,7 +554,7 @@ export default class Catcher { * * @param error - event from which to get backtrace */ - private async getBacktrace(error: Error | string): Promise { + private async getBacktrace(error: unknown): Promise { const notAnError = !(error instanceof Error); /** @@ -613,9 +577,9 @@ export default class Catcher { /** * Return some details * - * @param {Error|string} error — caught error + * @param {Error} error — caught error */ - private getAddons(error: Error | string): HawkJavaScriptEvent['addons'] { + private getAddons(error: unknown): HawkJavaScriptEvent['addons'] { const { innerWidth, innerHeight } = window; const userAgent = window.navigator.userAgent; const location = window.location.href; @@ -649,9 +613,9 @@ export default class Catcher { /** * Compose raw data object * - * @param {Error|string} error — caught error + * @param {Error} error — caught error */ - private getRawData(error: Error | string): Json | undefined { + private getRawData(error: unknown): Json | undefined { if (!(error instanceof Error)) { return; } @@ -672,7 +636,10 @@ export default class Catcher { * @param errorFormatted - Hawk event prepared for sending * @param integrationAddons - extra addons */ - private appendIntegrationAddons(errorFormatted: CatcherMessage, integrationAddons: JavaScriptCatcherIntegrations): void { + private appendIntegrationAddons( + errorFormatted: CatcherMessage, + integrationAddons: JavaScriptCatcherIntegrations + ): void { Object.assign(errorFormatted.payload.addons, integrationAddons); } } diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts index 9182ecef..a65f3e98 100644 --- a/packages/javascript/src/utils/error.ts +++ b/packages/javascript/src/utils/error.ts @@ -1,55 +1,105 @@ import Sanitizer from '../modules/sanitizer'; /** - * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent + * Represents a captured error in a normalized form. * - * @param event - The error or promise rejection event + * Motivation: + * - `Error | string` is unclear and hard to work with. + * - Fields can be filled from an event or from the error itself. + */ +export type CapturedError = { + /** Human-readable error message used as a title in the dashboard */ + title: string; + /** Error type (e.g. 'TypeError', 'NetworkError'), or null if unknown */ + type: string | null; + /** The original (unsanitized) value — use for instanceof checks and backtrace parsing only */ + rawError: unknown; +}; + +/** + * Extracts a human-readable title from an unknown sanitized error. + * Prefers `.message` on objects, falls back to the value itself for strings, + * and serializes everything else. + * + * @param safeError - Sanitized error value (any shape) + * @returns A non-empty string title, or undefined if the value is nullish or empty */ -export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): Error | string { - /** - * Promise rejection reason is recommended to be an Error, but it can be a string: - * - Promise.reject(new Error('Reason message')) ——— recommended - * - Promise.reject('Reason message') - */ - let error = (event as ErrorEvent).error || (event as PromiseRejectionEvent).reason; - - /** - * Case when error triggered in external script - * We can't access event error object because of CORS - * Event message will be 'Script error.' - */ - if (event instanceof ErrorEvent && error === undefined) { - error = (event as ErrorEvent).message; +function getTitleFromError(safeError: unknown): string | undefined { + if (safeError == null) { + return undefined; } - /** - * Case when error rejected with an object - * Using a string instead of wrapping in Error is more natural, - * it doesn't fake the backtrace, also prefix added for dashboard readability - */ - if (error instanceof Object && !(error instanceof Error) && event instanceof PromiseRejectionEvent) { - // Extra sanitize is needed to handle objects with circular references before JSON.stringify - error = `Promise rejected with ${JSON.stringify(Sanitizer.sanitize(error))}`; + const message = + typeof safeError === 'object' && 'message' in safeError ? (safeError as Error).message : safeError; + + if (typeof message === 'string') { + return message || undefined; } - return Sanitizer.sanitize(error); + try { + return JSON.stringify(message); + } catch { + // If no JSON global is available, fall back to string conversion + return String(message); + } +} + +/** + * Extracts an error type name from an unknown sanitized error. + * Returns `.name` only when it is a non-empty string (e.g. 'TypeError'). + * + * @param safeError - Sanitized error value (any shape) + * @returns The error name string, or undefined if absent or empty + */ +function getTypeFromError(safeError: unknown): string | undefined { + const name = (safeError as Error)?.name; + + return name || undefined; } /** - * Converts a promise rejection reason to a string message. + * Constructs a CapturedError from an unknown error value and optional fallbacks. * - * String(obj) gives "[object Object]" and JSON.stringify("str") - * adds unwanted quotes. + * @param error - Any value thrown or rejected + * @param fallbackValues - Fallback values from event if they can't be extracted from the error + * @returns A normalized `CapturedError` object + */ +export function fillCapturedError( + error: unknown, + fallbackValues: { title?: string; type?: string } = {} +): CapturedError { + const sanitizedError = Sanitizer.sanitize(error); + + return { + title: getTitleFromError(sanitizedError) || fallbackValues.title || '', + type: getTypeFromError(sanitizedError) || fallbackValues.type || null, + rawError: error, + }; +} + +/** + * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent. + * Handles CORS-restricted errors (where event.error is undefined) by falling back to event.message. * - * @param reason - The rejection reason from PromiseRejectionEvent + * @param event - The error or promise rejection event + * @returns A normalized CapturedError object */ -export function stringifyRejectionReason(reason: unknown): string { - if (reason instanceof Error) { - return reason.message; +export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): CapturedError { + if (event.type === 'error') { + event = event as ErrorEvent; + + return fillCapturedError(event.error, { + title: event.message && `'${event.message}' at ${event.filename || ''}:${event.lineno}:${event.colno}`, + }); } - if (typeof reason === 'string') { - return reason; + + if (event.type === 'unhandledrejection') { + event = event as PromiseRejectionEvent; + + return fillCapturedError(event.reason, { + type: 'UnhandledRejection', + }); } - return JSON.stringify(Sanitizer.sanitize(reason)); + return fillCapturedError(undefined); } diff --git a/packages/javascript/tests/catcher.global-handlers.test.ts b/packages/javascript/tests/catcher.global-handlers.test.ts index 34c18108..660fffcf 100644 --- a/packages/javascript/tests/catcher.global-handlers.test.ts +++ b/packages/javascript/tests/catcher.global-handlers.test.ts @@ -80,7 +80,7 @@ describe('Catcher', () => { await wait(); expect(sendSpy).toHaveBeenCalledOnce(); - expect(getLastPayload(sendSpy).title).toBe('Script error.'); + expect(getLastPayload(sendSpy).title).toBe("'Script error.' at :0:0"); }); it('should capture unhandled promise rejections', async () => { diff --git a/packages/javascript/tests/utils/error.test.ts b/packages/javascript/tests/utils/error.test.ts index 64e38fce..0d960516 100644 --- a/packages/javascript/tests/utils/error.test.ts +++ b/packages/javascript/tests/utils/error.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getErrorFromErrorEvent } from '../../src/utils/error'; -vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); - -import Sanitizer from '../../src/modules/sanitizer'; +vi.mock('@hawk.so/core', () => ({ + log: vi.fn(), + isLoggerSet: vi.fn(() => true), + setLogger: vi.fn(), +})); describe('getErrorFromErrorEvent', () => { beforeEach(() => { @@ -11,95 +13,129 @@ describe('getErrorFromErrorEvent', () => { }); describe('ErrorEvent', () => { - it('should return the Error when event.error is an Error instance', () => { + it('should capture Error instance with correct fields', () => { const error = new Error('Test error'); const event = new ErrorEvent('error', { error }); - const result = getErrorFromErrorEvent(event); - expect(result).toBe(error); + expect(result.rawError).toBe(error); + expect(result.title).toBe('Test error'); + expect(result.type).toBe('Error'); }); - it('should return the DOMException when event.error is a DOMException', () => { + it('should capture DOMException with correct fields', () => { const error = new DOMException('Network error', 'NetworkError'); const event = new ErrorEvent('error', { error }); - const result = getErrorFromErrorEvent(event); - expect(result).toBe(''); + expect(result.rawError).toBe(error); + expect(result.title).toBe(''); }); - it('should return the message when event.error is not provided and message is a string', () => { - const event = new ErrorEvent('error', { message: 'Script error.' }); - + it('should fall back to event.message when event.error is not provided', () => { + const event = new ErrorEvent('error', { + message: 'Script error.', + filename: 'app.js', + lineno: 10, + colno: 5, + }); const result = getErrorFromErrorEvent(event); - expect(result).toBe('Script error.'); + expect(result.rawError).toBeNull(); + expect(result.title).toContain('Script error.'); + expect(result.title).toContain('app.js:10:5'); }); - it('should return empty string when event.error is not provided and message is empty', () => { + it('should return unknown error title when event.error and message are both absent', () => { const event = new ErrorEvent('error', { message: '' }); - const result = getErrorFromErrorEvent(event); - expect(result).toBe(''); + expect(result.title).toBe(''); }); }); describe('PromiseRejectionEvent', () => { - it('should return the Error when event.reason is an Error instance', () => { + it('should capture Error reason with correct fields', () => { const reason = new Error('Promise rejected'); - const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason, + }); const result = getErrorFromErrorEvent(event); - expect(result).toBe(reason); + expect(result.rawError).toBe(reason); + expect(result.title).toBe('Promise rejected'); + expect(result.type).toBe('Error'); }); - it('should return the string when event.reason is a string', () => { - const reason = 'Promise rejected with string'; - const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - + it('should capture string reason', () => { + const reason = 'Something went wrong'; + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason, + }); const result = getErrorFromErrorEvent(event); - expect(result).toBe(reason); + expect(result.rawError).toBe(reason); + expect(result.title).toBe('Something went wrong'); + expect(result.type).toBe('UnhandledRejection'); }); - it('should return stringified object when event.reason is a plain object', () => { + it('should capture plain object reason', () => { const reason = { code: 'ERR_001', details: 'Something went wrong' }; const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - const result = getErrorFromErrorEvent(event); - expect(result).toBe('Promise rejected with {"code":"ERR_001","details":"Something went wrong"}'); + expect(result.rawError).toBe(reason); + expect(result.title).toBe('{"code":"ERR_001","details":"Something went wrong"}'); + expect(result.type).toBe('UnhandledRejection'); }); - it('should return undefined when event.reason is not provided', () => { - const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: undefined }); - + it('should handle undefined reason', () => { + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason: undefined, + }); const result = getErrorFromErrorEvent(event); - expect(result).toBeUndefined(); + expect(result.rawError).toBeUndefined(); + expect(result.title).toBe(''); }); - it('should return null when event.reason is null', () => { - const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: null }); - + it('should handle null reason', () => { + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason: null, + }); const result = getErrorFromErrorEvent(event); - expect(result).toBeNull(); + expect(result.rawError).toBeNull(); + expect(result.title).toBe(''); }); it('should handle circular references in object reason', () => { const circularObj: Record = { name: 'test' }; circularObj.self = circularObj; + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason: circularObj, + }); + const result = getErrorFromErrorEvent(event); - const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: circularObj }); + expect(result.rawError).toBe(circularObj); + expect(result.title).toContain(''); + }); + }); - const result = getErrorFromErrorEvent(event); + describe('deduplication identity', () => { + it('rawError should preserve reference to the original object for deduplication', () => { + const error = new Error('Test'); + const event = new ErrorEvent('error', { error }); + const result1 = getErrorFromErrorEvent(event); + const result2 = getErrorFromErrorEvent(event); - expect(result).toContain('Promise rejected with'); - expect(result).toContain(''); + expect(result1.rawError).toBe(result2.rawError); + expect(result1.rawError).toBe(error); }); }); }); From 93c21d33add2076812a832ec5e8510e0606e5248 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Sat, 11 Apr 2026 13:58:14 +0300 Subject: [PATCH 08/13] fix: tests and lint fixed --- packages/javascript/src/catcher.ts | 2 +- packages/javascript/src/utils/error.ts | 2 +- packages/javascript/tests/utils/error.test.ts | 15 ++++++++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index c03f8677..dd5be3c0 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -31,7 +31,7 @@ import { import { HawkLocalStorage } from './storages/hawk-local-storage'; import { createBrowserLogger } from './logger/logger'; import { BrowserRandomGenerator } from './utils/random'; -import { CapturedError, fillCapturedError, getErrorFromErrorEvent } from './utils/error'; +import { type CapturedError, fillCapturedError, getErrorFromErrorEvent } from './utils/error'; /** * Allow to use global VERSION, that will be overwritten by Webpack diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts index ce918c2e..b9755c9b 100644 --- a/packages/javascript/src/utils/error.ts +++ b/packages/javascript/src/utils/error.ts @@ -1,4 +1,4 @@ -import { HawkJavaScriptEvent } from '@/types'; +import type { HawkJavaScriptEvent } from '@/types'; import { Sanitizer } from '@hawk.so/core'; /** diff --git a/packages/javascript/tests/utils/error.test.ts b/packages/javascript/tests/utils/error.test.ts index 0d960516..9de57285 100644 --- a/packages/javascript/tests/utils/error.test.ts +++ b/packages/javascript/tests/utils/error.test.ts @@ -1,11 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getErrorFromErrorEvent } from '../../src/utils/error'; -vi.mock('@hawk.so/core', () => ({ - log: vi.fn(), - isLoggerSet: vi.fn(() => true), - setLogger: vi.fn(), -})); +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + log: vi.fn(), + isLoggerSet: vi.fn(() => true), + setLogger: vi.fn(), + }; +}); describe('getErrorFromErrorEvent', () => { beforeEach(() => { From 46b2fd191568a47b0019f6956dd5e7e1b149d263 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Sat, 11 Apr 2026 20:24:17 +0300 Subject: [PATCH 09/13] fix(javascript): simplify global error titles and rename error composer --- packages/javascript/src/catcher.ts | 8 ++--- packages/javascript/src/utils/error.ts | 31 ++++++++++--------- .../tests/catcher.global-handlers.test.ts | 2 +- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index dd5be3c0..b02488bb 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -31,7 +31,7 @@ import { import { HawkLocalStorage } from './storages/hawk-local-storage'; import { createBrowserLogger } from './logger/logger'; import { BrowserRandomGenerator } from './utils/random'; -import { type CapturedError, fillCapturedError, getErrorFromErrorEvent } from './utils/error'; +import { type CapturedError, composeCapturedError, getErrorFromErrorEvent } from './utils/error'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -231,7 +231,7 @@ export default class Catcher { * @param [context] - any additional data to send */ public send(message: Error | string, context?: EventContext): void { - void this.formatAndSend(fillCapturedError(message), undefined, context); + void this.formatAndSend(composeCapturedError(message), undefined, context); } /** @@ -243,7 +243,7 @@ export default class Catcher { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any public captureError(error: Error | string, addons?: JavaScriptCatcherIntegrations): void { - void this.formatAndSend(fillCapturedError(error), addons); + void this.formatAndSend(composeCapturedError(error), addons); } /** @@ -256,7 +256,7 @@ export default class Catcher { this.vue = new VueIntegration( vue, (error: Error, addons: VueIntegrationAddons) => { - void this.formatAndSend(fillCapturedError(error), { + void this.formatAndSend(composeCapturedError(error), { vue: addons, }); }, diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts index b9755c9b..21fe8037 100644 --- a/packages/javascript/src/utils/error.ts +++ b/packages/javascript/src/utils/error.ts @@ -22,16 +22,16 @@ export type CapturedError = { * Prefers `.message` on objects, falls back to the value itself for strings, * and serializes everything else. * - * @param safeError - Sanitized error value (any shape) + * @param sanitizedError - Value returned by `Sanitizer.sanitize(error)` * @returns A non-empty string title, or undefined if the value is nullish or empty */ -function getTitleFromError(safeError: unknown): string | undefined { - if (safeError == null) { +function getTitleFromError(sanitizedError: unknown): string | undefined { + if (sanitizedError == null) { return undefined; } const message = - typeof safeError === 'object' && 'message' in safeError ? (safeError as Error).message : safeError; + typeof sanitizedError === 'object' && 'message' in sanitizedError ? (sanitizedError as Error).message : sanitizedError; if (typeof message === 'string') { return message || undefined; @@ -49,23 +49,22 @@ function getTitleFromError(safeError: unknown): string | undefined { * Extracts an error type name from an unknown sanitized error. * Returns `.name` only when it is a non-empty string (e.g. 'TypeError'). * - * @param safeError - Sanitized error value (any shape) + * @param sanitizedError - Value returned by `Sanitizer.sanitize(error)` * @returns The error name string, or undefined if absent or empty */ -function getTypeFromError(safeError: unknown): string | undefined { - const name = (safeError as Error)?.name; - - return name || undefined; +function getTypeFromError(sanitizedError: unknown): string | undefined { + return (sanitizedError as Error)?.name; } /** * Constructs a CapturedError from an unknown error value and optional fallbacks. + * The thrown value is first passed through `Sanitizer.sanitize(error)`. * * @param error - Any value thrown or rejected * @param fallbackValues - Fallback values from event if they can't be extracted from the error * @returns A normalized `CapturedError` object */ -export function fillCapturedError( +export function composeCapturedError( error: unknown, fallbackValues: { title?: string; type?: string } = {} ): CapturedError { @@ -89,18 +88,22 @@ export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent if (event.type === 'error') { event = event as ErrorEvent; - return fillCapturedError(event.error, { - title: event.message && `'${event.message}' at ${event.filename || ''}:${event.lineno}:${event.colno}`, + return composeCapturedError(event.error, { + title: event.message && (event.filename ? `'${event.message}' at ${event.filename}:${event.lineno}:${event.colno}` : event.message), }); } if (event.type === 'unhandledrejection') { event = event as PromiseRejectionEvent; - return fillCapturedError(event.reason, { + return composeCapturedError(event.reason, { type: 'UnhandledRejection', }); } - return fillCapturedError(undefined); + /* + Fallback case: ensures function always returns CapturedError. + composeCapturedError(undefined) yields object with undefined fields. + */ + return composeCapturedError(undefined); } diff --git a/packages/javascript/tests/catcher.global-handlers.test.ts b/packages/javascript/tests/catcher.global-handlers.test.ts index 660fffcf..9f8b1240 100644 --- a/packages/javascript/tests/catcher.global-handlers.test.ts +++ b/packages/javascript/tests/catcher.global-handlers.test.ts @@ -80,7 +80,7 @@ describe('Catcher', () => { await wait(); expect(sendSpy).toHaveBeenCalledOnce(); - expect(getLastPayload(sendSpy).title).toBe("'Script error.' at :0:0"); + expect(getLastPayload(sendSpy).title).toBe("Script error."); }); it('should capture unhandled promise rejections', async () => { From 88506937977ec86335a288d73d8fae4af244d385 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Tue, 14 Apr 2026 00:13:41 +0300 Subject: [PATCH 10/13] test(error): cover unsupported event fallback in error utils --- packages/javascript/src/utils/error.ts | 5 ++++- packages/javascript/tests/utils/error.test.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts index 21fe8037..baefe4e8 100644 --- a/packages/javascript/src/utils/error.ts +++ b/packages/javascript/src/utils/error.ts @@ -53,7 +53,7 @@ function getTitleFromError(sanitizedError: unknown): string | undefined { * @returns The error name string, or undefined if absent or empty */ function getTypeFromError(sanitizedError: unknown): string | undefined { - return (sanitizedError as Error)?.name; + return (sanitizedError as {name: string})?.name; } /** @@ -68,6 +68,9 @@ export function composeCapturedError( error: unknown, fallbackValues: { title?: string; type?: string } = {} ): CapturedError { + /** + * @todo we should consider moving Sanitizer to utils + */ const sanitizedError = Sanitizer.sanitize(error); return { diff --git a/packages/javascript/tests/utils/error.test.ts b/packages/javascript/tests/utils/error.test.ts index 9de57285..451ff418 100644 --- a/packages/javascript/tests/utils/error.test.ts +++ b/packages/javascript/tests/utils/error.test.ts @@ -132,6 +132,17 @@ describe('getErrorFromErrorEvent', () => { }); }); + describe('fallback branch', () => { + it('should return a normalized unknown error for unsupported event types', () => { + const event = { type: 'custom' } as ErrorEvent | PromiseRejectionEvent; + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBeUndefined(); + expect(result.title).toBe(''); + expect(result.type).toBeUndefined(); + }); + }); + describe('deduplication identity', () => { it('rawError should preserve reference to the original object for deduplication', () => { const error = new Error('Test'); From 1aadaf48d8e5ef1720acc1fd36bb7ca1d16a8da4 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Tue, 14 Apr 2026 00:18:31 +0300 Subject: [PATCH 11/13] fix: lint fixed --- packages/javascript/tests/catcher.global-handlers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/javascript/tests/catcher.global-handlers.test.ts b/packages/javascript/tests/catcher.global-handlers.test.ts index 9f8b1240..34c18108 100644 --- a/packages/javascript/tests/catcher.global-handlers.test.ts +++ b/packages/javascript/tests/catcher.global-handlers.test.ts @@ -80,7 +80,7 @@ describe('Catcher', () => { await wait(); expect(sendSpy).toHaveBeenCalledOnce(); - expect(getLastPayload(sendSpy).title).toBe("Script error."); + expect(getLastPayload(sendSpy).title).toBe('Script error.'); }); it('should capture unhandled promise rejections', async () => { From ae85a1322353eb9bd33f260058ee46758f5653be Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Tue, 14 Apr 2026 00:35:00 +0300 Subject: [PATCH 12/13] fix: small improvements --- packages/javascript/src/utils/error.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts index baefe4e8..e88ccd2b 100644 --- a/packages/javascript/src/utils/error.ts +++ b/packages/javascript/src/utils/error.ts @@ -9,7 +9,7 @@ import { Sanitizer } from '@hawk.so/core'; * - Fields can be filled from an event or from the error itself. */ export type CapturedError = { - /** Human-readable error message used as a title in the dashboard */ + /** Human-readable non-empty error message used as a title in the dashboard */ title: string; /** Error type (e.g. 'TypeError', 'NetworkError'), or undefined if unknown */ type: HawkJavaScriptEvent['type']; @@ -23,15 +23,17 @@ export type CapturedError = { * and serializes everything else. * * @param sanitizedError - Value returned by `Sanitizer.sanitize(error)` - * @returns A non-empty string title, or undefined if the value is nullish or empty + * @returns The error title string, or undefined if absent or empty */ function getTitleFromError(sanitizedError: unknown): string | undefined { if (sanitizedError == null) { return undefined; } - const message = - typeof sanitizedError === 'object' && 'message' in sanitizedError ? (sanitizedError as Error).message : sanitizedError; + let message: unknown = sanitizedError; + if (typeof sanitizedError === 'object' && 'message' in sanitizedError) { + message = (sanitizedError as {message: unknown}).message; + } if (typeof message === 'string') { return message || undefined; @@ -40,20 +42,22 @@ function getTitleFromError(sanitizedError: unknown): string | undefined { try { return JSON.stringify(message); } catch { - // If no JSON global is available, fall back to string conversion + /** + * If no JSON global is available or serialization fails, + * fall back to string conversion + */ return String(message); } } /** * Extracts an error type name from an unknown sanitized error. - * Returns `.name` only when it is a non-empty string (e.g. 'TypeError'). * * @param sanitizedError - Value returned by `Sanitizer.sanitize(error)` * @returns The error name string, or undefined if absent or empty */ function getTypeFromError(sanitizedError: unknown): string | undefined { - return (sanitizedError as {name: string})?.name; + return (sanitizedError as {name: string})?.name || undefined; } /** From 759e649b1269b67f47142f285074a53d9b4344d1 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Tue, 14 Apr 2026 14:05:21 +0300 Subject: [PATCH 13/13] refactor: move error sanitization out of error utils --- .../javascript/src/addons/consoleCatcher.ts | 17 +-- packages/javascript/src/catcher.ts | 43 ++++--- packages/javascript/src/utils/error.ts | 96 ++++++--------- packages/javascript/tests/utils/error.test.ts | 113 ++++++++++++------ 4 files changed, 146 insertions(+), 123 deletions(-) diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index 2843ca78..564f60c4 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -2,7 +2,7 @@ * @file Module for intercepting console logs with stack trace capture */ import type { ConsoleLogEvent } from '@hawk.so/types'; -import { getErrorFromErrorEvent } from '../utils/error'; +import { getErrorFromErrorEvent, getTitleFromError, getTypeFromError } from '../utils/error'; import { Sanitizer } from '@hawk.so/core'; /** @@ -196,15 +196,18 @@ export class ConsoleCatcher { * @param event - The error event or promise rejection event to convert */ private createConsoleEventFromError(event: ErrorEvent | PromiseRejectionEvent): ConsoleLogEvent { - const capturedError = getErrorFromErrorEvent(event); + const errorSource = getErrorFromErrorEvent(event); + const sanitizedError = Sanitizer.sanitize(errorSource.rawError); + const message = getTitleFromError(sanitizedError) ?? errorSource.fallbackTitle ?? ''; + const type = getTypeFromError(sanitizedError) ?? errorSource.fallbackType ?? 'Error'; if (event instanceof ErrorEvent) { return { method: 'error', timestamp: new Date(), - type: capturedError.type || 'Error', - message: capturedError.title, - stack: (capturedError.rawError as Error)?.stack || '', + type, + message, + stack: (errorSource.rawError as Error)?.stack || '', fileLine: event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : '', @@ -215,8 +218,8 @@ export class ConsoleCatcher { method: 'error', timestamp: new Date(), type: 'UnhandledRejection', - message: capturedError.title, - stack: (capturedError.rawError as Error)?.stack || '', + message, + stack: (errorSource.rawError as Error)?.stack || '', fileLine: '', }; } diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index b02488bb..2e3b582c 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -31,7 +31,7 @@ import { import { HawkLocalStorage } from './storages/hawk-local-storage'; import { createBrowserLogger } from './logger/logger'; import { BrowserRandomGenerator } from './utils/random'; -import { type CapturedError, composeCapturedError, getErrorFromErrorEvent } from './utils/error'; +import { type ErrorSource, getErrorFromErrorEvent, getTitleFromError, getTypeFromError } from './utils/error'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -231,7 +231,7 @@ export default class Catcher { * @param [context] - any additional data to send */ public send(message: Error | string, context?: EventContext): void { - void this.formatAndSend(composeCapturedError(message), undefined, context); + void this.formatAndSend({ rawError: message }, undefined, context); } /** @@ -243,7 +243,7 @@ export default class Catcher { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any public captureError(error: Error | string, addons?: JavaScriptCatcherIntegrations): void { - void this.formatAndSend(composeCapturedError(error), addons); + void this.formatAndSend({ rawError: error }, addons); } /** @@ -256,7 +256,7 @@ export default class Catcher { this.vue = new VueIntegration( vue, (error: Error, addons: VueIntegrationAddons) => { - void this.formatAndSend(composeCapturedError(error), { + void this.formatAndSend({ rawError: error }, { vue: addons, }); }, @@ -354,7 +354,7 @@ export default class Catcher { * @param context - any additional data passed by user */ private async formatAndSend( - error: CapturedError, + error: ErrorSource, // eslint-disable-next-line @typescript-eslint/no-explicit-any integrationAddons?: JavaScriptCatcherIntegrations, context?: EventContext @@ -411,8 +411,13 @@ export default class Catcher { * @param error - error to format * @param context - any additional data passed by user */ - private async prepareErrorFormatted(error: CapturedError, context?: EventContext): Promise> { - const { title, type, rawError } = error; + private async prepareErrorFormatted(error: ErrorSource, context?: EventContext): Promise> { + const { rawError, fallbackTitle, fallbackType } = error; + const sanitizedError = Sanitizer.sanitize(rawError); + const throwableError = rawError instanceof Error ? rawError : undefined; + const title = getTitleFromError(sanitizedError) ?? fallbackTitle ?? ''; + const type = getTypeFromError(sanitizedError) ?? fallbackType; + let payload: HawkJavaScriptEvent = { title, type, @@ -420,8 +425,8 @@ export default class Catcher { breadcrumbs: this.getBreadcrumbsForEvent(), context: this.getContext(context), user: this.getUser(), - addons: this.getAddons(rawError), - backtrace: await this.getBacktrace(rawError), + addons: this.getAddons(throwableError), + backtrace: await this.getBacktrace(throwableError), catcherVersion: this.version, }; @@ -560,21 +565,15 @@ export default class Catcher { /** * Return parsed backtrace information * - * @param error - event from which to get backtrace + * @param {Error} error - event from which to get backtrace */ - private async getBacktrace(error: unknown): Promise { - const notAnError = !(error instanceof Error); - - /** - * Case when error is 'reason' of PromiseRejectionEvent - * and reject() provided with text reason instead of Error() - */ - if (notAnError) { + private async getBacktrace(error?: Error): Promise { + if (!error) { return undefined; } try { - return await this.stackParser.parse(error as Error); + return await this.stackParser.parse(error); } catch (e) { log('Can not parse stack:', 'warn', e); @@ -587,7 +586,7 @@ export default class Catcher { * * @param {Error} error — caught error */ - private getAddons(error: unknown): HawkJavaScriptEvent['addons'] { + private getAddons(error?: Error): HawkJavaScriptEvent['addons'] { const { innerWidth, innerHeight } = window; const userAgent = window.navigator.userAgent; const location = window.location.href; @@ -623,8 +622,8 @@ export default class Catcher { * * @param {Error} error — caught error */ - private getRawData(error: unknown): Json | undefined { - if (!(error instanceof Error)) { + private getRawData(error?: Error): Json | undefined { + if (!error) { return; } diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts index e88ccd2b..0d079932 100644 --- a/packages/javascript/src/utils/error.ts +++ b/packages/javascript/src/utils/error.ts @@ -1,38 +1,34 @@ import type { HawkJavaScriptEvent } from '@/types'; -import { Sanitizer } from '@hawk.so/core'; /** - * Represents a captured error in a normalized form. - * - * Motivation: - * - `Error | string` is unclear and hard to work with. - * - Fields can be filled from an event or from the error itself. + * Represents a raw error source before title/type normalization. + * Fallback values are provided by the event itself when raw error data is missing. */ -export type CapturedError = { - /** Human-readable non-empty error message used as a title in the dashboard */ - title: string; - /** Error type (e.g. 'TypeError', 'NetworkError'), or undefined if unknown */ - type: HawkJavaScriptEvent['type']; - /** The original (unsanitized) value — use for instanceof checks and backtrace parsing only */ +export type ErrorSource = { + /** The original unsanitized value — use for instanceof checks and backtrace parsing only */ rawError: unknown; + /** Fallback human-readable title used when rawError does not provide one */ + fallbackTitle?: string; + /** Fallback error type provided by the caller */ + fallbackType?: HawkJavaScriptEvent['type']; }; /** - * Extracts a human-readable title from an unknown sanitized error. + * Extracts a human-readable title from an unknown value. * Prefers `.message` on objects, falls back to the value itself for strings, * and serializes everything else. * - * @param sanitizedError - Value returned by `Sanitizer.sanitize(error)` + * @param value - Any already-safe value prepared by the caller * @returns The error title string, or undefined if absent or empty */ -function getTitleFromError(sanitizedError: unknown): string | undefined { - if (sanitizedError == null) { +export function getTitleFromError(value: unknown): string | undefined { + if (value == null) { return undefined; } - let message: unknown = sanitizedError; - if (typeof sanitizedError === 'object' && 'message' in sanitizedError) { - message = (sanitizedError as {message: unknown}).message; + let message: unknown = value; + if (typeof value === 'object' && 'message' in value) { + message = (value as {message?: unknown}).message; } if (typeof message === 'string') { @@ -51,66 +47,48 @@ function getTitleFromError(sanitizedError: unknown): string | undefined { } /** - * Extracts an error type name from an unknown sanitized error. + * Extracts an error type name from an unknown value. * - * @param sanitizedError - Value returned by `Sanitizer.sanitize(error)` + * @param value - Any already-safe value prepared by the caller * @returns The error name string, or undefined if absent or empty */ -function getTypeFromError(sanitizedError: unknown): string | undefined { - return (sanitizedError as {name: string})?.name || undefined; -} +export function getTypeFromError(value: unknown): HawkJavaScriptEvent['type'] | undefined { + if (typeof value !== 'object' || value === null || !('name' in value)) { + return undefined; + } -/** - * Constructs a CapturedError from an unknown error value and optional fallbacks. - * The thrown value is first passed through `Sanitizer.sanitize(error)`. - * - * @param error - Any value thrown or rejected - * @param fallbackValues - Fallback values from event if they can't be extracted from the error - * @returns A normalized `CapturedError` object - */ -export function composeCapturedError( - error: unknown, - fallbackValues: { title?: string; type?: string } = {} -): CapturedError { - /** - * @todo we should consider moving Sanitizer to utils - */ - const sanitizedError = Sanitizer.sanitize(error); + const name = (value as {name?: unknown}).name; - return { - title: getTitleFromError(sanitizedError) || fallbackValues.title || '', - type: getTypeFromError(sanitizedError) || fallbackValues.type, - rawError: error, - }; + return typeof name === 'string' && name ? name : undefined; } /** - * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent. + * Extracts raw error data and event-level fallbacks from ErrorEvent or PromiseRejectionEvent. * Handles CORS-restricted errors (where event.error is undefined) by falling back to event.message. * * @param event - The error or promise rejection event - * @returns A normalized CapturedError object + * @returns Raw error source with optional event-level fallback values */ -export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): CapturedError { +export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): ErrorSource { if (event.type === 'error') { event = event as ErrorEvent; - return composeCapturedError(event.error, { - title: event.message && (event.filename ? `'${event.message}' at ${event.filename}:${event.lineno}:${event.colno}` : event.message), - }); + return { + rawError: event.error, + fallbackTitle: event.message + ? (event.filename ? `'${event.message}' at ${event.filename}:${event.lineno}:${event.colno}` : event.message) + : undefined, + }; } if (event.type === 'unhandledrejection') { event = event as PromiseRejectionEvent; - return composeCapturedError(event.reason, { - type: 'UnhandledRejection', - }); + return { + rawError: event.reason, + fallbackType: 'UnhandledRejection', + }; } - /* - Fallback case: ensures function always returns CapturedError. - composeCapturedError(undefined) yields object with undefined fields. - */ - return composeCapturedError(undefined); + return { rawError: undefined }; } diff --git a/packages/javascript/tests/utils/error.test.ts b/packages/javascript/tests/utils/error.test.ts index 451ff418..2aa647af 100644 --- a/packages/javascript/tests/utils/error.test.ts +++ b/packages/javascript/tests/utils/error.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getErrorFromErrorEvent } from '../../src/utils/error'; +import { Sanitizer } from '@hawk.so/core'; +import { getErrorFromErrorEvent, getTitleFromError, getTypeFromError } from '../../src/utils/error'; vi.mock('@hawk.so/core', async (importOriginal) => { const actual = await importOriginal(); @@ -18,23 +19,24 @@ describe('getErrorFromErrorEvent', () => { }); describe('ErrorEvent', () => { - it('should capture Error instance with correct fields', () => { + it('should capture Error instance raw error without fallbacks', () => { const error = new Error('Test error'); const event = new ErrorEvent('error', { error }); const result = getErrorFromErrorEvent(event); expect(result.rawError).toBe(error); - expect(result.title).toBe('Test error'); - expect(result.type).toBe('Error'); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBeUndefined(); }); - it('should capture DOMException with correct fields', () => { + it('should preserve DOMException instance for downstream normalization', () => { const error = new DOMException('Network error', 'NetworkError'); const event = new ErrorEvent('error', { error }); const result = getErrorFromErrorEvent(event); expect(result.rawError).toBe(error); - expect(result.title).toBe(''); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBeUndefined(); }); it('should fall back to event.message when event.error is not provided', () => { @@ -47,20 +49,23 @@ describe('getErrorFromErrorEvent', () => { const result = getErrorFromErrorEvent(event); expect(result.rawError).toBeNull(); - expect(result.title).toContain('Script error.'); - expect(result.title).toContain('app.js:10:5'); + expect(result.fallbackTitle).toContain('Script error.'); + expect(result.fallbackTitle).toContain('app.js:10:5'); + expect(result.fallbackType).toBeUndefined(); }); - it('should return unknown error title when event.error and message are both absent', () => { + it('should omit fallback title when event.error and message are both absent', () => { const event = new ErrorEvent('error', { message: '' }); const result = getErrorFromErrorEvent(event); - expect(result.title).toBe(''); + expect(result.rawError).toBeNull(); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBeUndefined(); }); }); describe('PromiseRejectionEvent', () => { - it('should capture Error reason with correct fields', () => { + it('should capture Error reason and rejection fallback type', () => { const reason = new Error('Promise rejected'); const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), @@ -69,8 +74,8 @@ describe('getErrorFromErrorEvent', () => { const result = getErrorFromErrorEvent(event); expect(result.rawError).toBe(reason); - expect(result.title).toBe('Promise rejected'); - expect(result.type).toBe('Error'); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBe('UnhandledRejection'); }); it('should capture string reason', () => { @@ -82,8 +87,8 @@ describe('getErrorFromErrorEvent', () => { const result = getErrorFromErrorEvent(event); expect(result.rawError).toBe(reason); - expect(result.title).toBe('Something went wrong'); - expect(result.type).toBe('UnhandledRejection'); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBe('UnhandledRejection'); }); it('should capture plain object reason', () => { @@ -92,8 +97,8 @@ describe('getErrorFromErrorEvent', () => { const result = getErrorFromErrorEvent(event); expect(result.rawError).toBe(reason); - expect(result.title).toBe('{"code":"ERR_001","details":"Something went wrong"}'); - expect(result.type).toBe('UnhandledRejection'); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBe('UnhandledRejection'); }); it('should handle undefined reason', () => { @@ -104,7 +109,8 @@ describe('getErrorFromErrorEvent', () => { const result = getErrorFromErrorEvent(event); expect(result.rawError).toBeUndefined(); - expect(result.title).toBe(''); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBe('UnhandledRejection'); }); it('should handle null reason', () => { @@ -115,31 +121,19 @@ describe('getErrorFromErrorEvent', () => { const result = getErrorFromErrorEvent(event); expect(result.rawError).toBeNull(); - expect(result.title).toBe(''); - }); - - it('should handle circular references in object reason', () => { - const circularObj: Record = { name: 'test' }; - circularObj.self = circularObj; - const event = new PromiseRejectionEvent('unhandledrejection', { - promise: Promise.resolve(), - reason: circularObj, - }); - const result = getErrorFromErrorEvent(event); - - expect(result.rawError).toBe(circularObj); - expect(result.title).toContain(''); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBe('UnhandledRejection'); }); }); describe('fallback branch', () => { - it('should return a normalized unknown error for unsupported event types', () => { + it('should return an empty error source for unsupported event types', () => { const event = { type: 'custom' } as ErrorEvent | PromiseRejectionEvent; const result = getErrorFromErrorEvent(event); expect(result.rawError).toBeUndefined(); - expect(result.title).toBe(''); - expect(result.type).toBeUndefined(); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBeUndefined(); }); }); @@ -155,3 +149,52 @@ describe('getErrorFromErrorEvent', () => { }); }); }); + +describe('getTitleFromError', () => { + it('should return Error message from sanitized Error', () => { + const sanitizedError = Sanitizer.sanitize(new Error('Test error')); + + expect(getTitleFromError(sanitizedError)).toBe('Test error'); + }); + + it('should return class instance placeholder for sanitized DOMException', () => { + const sanitizedError = Sanitizer.sanitize(new DOMException('Network error', 'NetworkError')); + + expect(getTitleFromError(sanitizedError)).toBe(''); + }); + + it('should serialize sanitized plain objects', () => { + const sanitizedError = Sanitizer.sanitize({ code: 'ERR_001', details: 'Something went wrong' }); + + expect(getTitleFromError(sanitizedError)).toBe('{"code":"ERR_001","details":"Something went wrong"}'); + }); + + it('should handle circular references after sanitization', () => { + const circularObj: Record = { name: 'test' }; + circularObj.self = circularObj; + const sanitizedError = Sanitizer.sanitize(circularObj); + + expect(getTitleFromError(sanitizedError)).toContain(''); + }); + + it('should return undefined for nullish values', () => { + expect(getTitleFromError(undefined)).toBeUndefined(); + expect(getTitleFromError(null)).toBeUndefined(); + }); +}); + +describe('getTypeFromError', () => { + it('should return Error name from sanitized Error', () => { + const sanitizedError = Sanitizer.sanitize(new Error('Test error')); + + expect(getTypeFromError(sanitizedError)).toBe('Error'); + }); + + it('should return undefined for sanitized strings', () => { + expect(getTypeFromError(Sanitizer.sanitize('Something went wrong'))).toBeUndefined(); + }); + + it('should return undefined when name is absent', () => { + expect(getTypeFromError(Sanitizer.sanitize({ code: 'ERR_001' }))).toBeUndefined(); + }); +});