From ffff7d64264d526145f361d705133803404cccab Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 25 Feb 2026 20:03:49 +0300 Subject: [PATCH 1/4] test(catcher): Catcher covered with tests --- packages/javascript/package.json | 2 + packages/javascript/tests/catcher.test.ts | 642 ++++++++++++++++++++++ yarn.lock | 109 +++- 3 files changed, 751 insertions(+), 2 deletions(-) create mode 100644 packages/javascript/tests/catcher.test.ts diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 22e9827c..66543c23 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -21,6 +21,7 @@ "build": "vite build", "stats": "size-limit > stats.txt", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest", "lint": "eslint --fix \"src/**/*.{js,ts}\"" }, @@ -43,6 +44,7 @@ }, "devDependencies": { "@hawk.so/types": "0.5.8", + "@vitest/coverage-v8": "^4.0.18", "jsdom": "^28.0.0", "vite": "^7.3.1", "vite-plugin-dts": "^4.2.4", diff --git a/packages/javascript/tests/catcher.test.ts b/packages/javascript/tests/catcher.test.ts new file mode 100644 index 00000000..3f2b184b --- /dev/null +++ b/packages/javascript/tests/catcher.test.ts @@ -0,0 +1,642 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Catcher from '../src/catcher'; +import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import type { Transport } from '../src'; + +const TEST_TOKEN = 'eyJpbnRlZ3JhdGlvbklkIjoiOTU3MmQyOWQtNWJhZS00YmYyLTkwN2MtZDk5ZDg5MGIwOTVmIiwic2VjcmV0IjoiZTExODFiZWItMjdlMS00ZDViLWEwZmEtZmUwYTM1Mzg5OWMyIn0='; +const wait = (): Promise => new Promise((r) => setTimeout(r, 0)); + +// StackParser is mocked to prevent real network calls to source files in the jsdom environment. +const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); +vi.mock('../src/modules/stackParser', () => ({ + default: class { + parse = mockParse; + }, +})); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('Catcher', () => { + beforeEach(() => { + localStorage.clear(); + mockParse.mockResolvedValue([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (BreadcrumbManager as any).instance = null; + }); + + // ── Error delivery ──────────────────────────────────────────────────────── + // + // The Catcher's primary responsibility: capture errors and forward them to + // the configured transport with identifying metadata. + describe('error delivery', () => { + it('should deliver an Error instance via transport with matching title', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).send(new Error('something broke')); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(sendSpy.mock.calls[0][0].token).toBe(TEST_TOKEN); + expect(sendSpy.mock.calls[0][0].catcherType).toBe('errors/javascript'); + expect(getLastPayload(sendSpy).title).toBe('something broke'); + }); + + it('should deliver a string-based error via transport with matching title', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).send('unhandled rejection reason'); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(sendSpy.mock.calls[0][0].token).toBe(TEST_TOKEN); + expect(sendSpy.mock.calls[0][0].catcherType).toBe('errors/javascript'); + expect(getLastPayload(sendSpy).title).toBe('unhandled rejection reason'); + }); + + it('should not deliver the same Error instance twice', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + const error = new Error('duplicate'); + + hawk.send(error); + hawk.send(error); + await wait(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + + it('should deliver distinct Error instances independently', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.send(new Error('first')); + hawk.send(new Error('second')); + await wait(); + + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + + it('should deliver string errors without deduplication', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.send('reason'); + hawk.send('reason'); + await wait(); + + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + }); + + // ── User identity ───────────────────────────────────────────────────────── + // + // The Catcher tracks who caused the error. When no user is configured it + // falls back to a generated anonymous ID that persists across events. + describe('user identity', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('should generate a stable anonymous ID when no user is configured', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.send(new Error('first')); + await wait(); + const id1 = getLastPayload(sendSpy).user?.id; + + hawk.send(new Error('second')); + await wait(); + const id2 = getLastPayload(sendSpy).user?.id; + + expect(id1).toBeTruthy(); + expect(id1).toBe(id2); + }); + + it('should include the user configured via setUser()', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.setUser({ id: 'user-1', name: 'Alice' }); + hawk.send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).user).toMatchObject({ id: 'user-1', name: 'Alice' }); + }); + + it('should include the user configured via constructor', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport, { user: { id: 'user-2' } }).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).user).toMatchObject({ id: 'user-2' }); + }); + + it('should ignore setUser when called with null', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.setUser({ id: 'valid' }); + hawk.setUser(null as never); + hawk.send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).user?.id).toBe('valid'); + }); + + it('should ignore setUser when the user has no id', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.setUser({ id: 'valid' }); + hawk.setUser({} as never); + hawk.send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).user?.id).toBe('valid'); + }); + + it('should revert to an anonymous identity after clearUser()', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.setUser({ id: 'user-1' }); + hawk.clearUser(); + hawk.send(new Error('e')); + await wait(); + + const user = getLastPayload(sendSpy).user; + + expect(user?.id).toBeTruthy(); + expect(user?.id).not.toBe('user-1'); + }); + }); + + // ── Context enrichment ──────────────────────────────────────────────────── + // + // The Catcher attaches contextual information to every event: an optional + // release version and arbitrary developer-supplied context data. + describe('context enrichment', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('should include the release version when configured', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport, { release: '1.2.3' }).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).release).toBe('1.2.3'); + }); + + it('should omit the release when not configured', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).release).toBeFalsy(); + }); + + it('should include global context set via setContext()', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.setContext({ env: 'production' }); + hawk.send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).context).toMatchObject({ env: 'production' }); + }); + + it('should include per-send context passed to send()', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).send(new Error('e'), { requestId: 'abc123' }); + await wait(); + + expect(getLastPayload(sendSpy).context).toMatchObject({ requestId: 'abc123' }); + }); + + it('should ignore setContext when called with a non-object value', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.setContext({ original: true }); + hawk.setContext(42 as never); + hawk.send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).context).toMatchObject({ original: true }); + }); + + it('should merge global and per-send context, per-send wins on key collision', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { context: { key: 'global', shared: 1 } }); + + hawk.send(new Error('e'), { key: 'local', extra: 2 }); + await wait(); + + expect(getLastPayload(sendSpy).context).toMatchObject({ + key: 'local', + shared: 1, + extra: 2, + }); + }); + }); + + // ── Breadcrumbs trail ───────────────────────────────────────────────────── + // + // The Catcher maintains a chronological trail of events leading up to the + // error. The trail is included in delivered events only when non-empty. + describe('breadcrumbs trail', () => { + it('should include recorded breadcrumbs in delivered events', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { breadcrumbs: {} }); + + hawk.breadcrumbs.add({ message: 'button clicked', timestamp: Date.now() }); + hawk.send(new Error('e')); + await wait(); + + const breadcrumbs = getLastPayload(sendSpy).breadcrumbs; + + expect(Array.isArray(breadcrumbs)).toBe(true); + expect(breadcrumbs[0].message).toBe('button clicked'); + }); + + it('should omit breadcrumbs when none have been recorded', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport, { breadcrumbs: {} }).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).breadcrumbs).toBeFalsy(); + }); + + it('should return an empty array from breadcrumbs.get when breadcrumbs are disabled', () => { + const { transport } = createTransport(); + + expect(createCatcher(transport).breadcrumbs.get()).toEqual([]); + }); + + it('should omit breadcrumbs cleared before the event was sent', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { breadcrumbs: {} }); + + hawk.breadcrumbs.add({ message: 'click', timestamp: Date.now() }); + hawk.breadcrumbs.clear(); + hawk.send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).breadcrumbs).toBeFalsy(); + }); + }); + + // ── Integration addons ──────────────────────────────────────────────────── + // + // Framework integrations (Vue, Nuxt, etc.) attach extra addons when + // reporting errors via captureError(). These are merged into the payload + // alongside the standard browser addons. + describe('integration addons via captureError()', () => { + it('should merge integration-specific addons into the delivered event', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).captureError(new Error('e'), { + vue: { component: '', props: {}, lifecycle: 'mounted' }, + }); + await wait(); + + expect(getLastPayload(sendSpy).addons).toMatchObject({ + vue: { component: '', props: {}, lifecycle: 'mounted' }, + }); + }); + + it('should preserve standard browser addons when integration addons are merged', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).captureError(new Error('e'), { + vue: { component: '', props: {}, lifecycle: 'mounted' }, + }); + await wait(); + + const addons = getLastPayload(sendSpy).addons; + + expect(addons.userAgent).toBeDefined(); + expect(addons.url).toBeDefined(); + }); + }); + + // ── Constructor variants ────────────────────────────────────────────────── + // + // The Catcher can be initialised with either a full settings object or a + // bare string token as a shorthand. + describe('constructor', () => { + it('should accept a plain string as a shorthand for the token', async () => { + const sendSpy = vi.fn().mockResolvedValue(undefined); + const transport: Transport = { send: sendSpy }; + + const hawk = new Catcher({ + token: TEST_TOKEN, + disableGlobalErrorsHandling: true, + breadcrumbs: false, + consoleTracking: false, + transport, + }); + + hawk.send(new Error('shorthand')); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(sendSpy.mock.calls[0][0].token).toBe(TEST_TOKEN); + }); + + it('should warn and silently do nothing when no token is provided', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const hawk = new Catcher({ token: '' } as never); + + hawk.send(new Error('no-token')); + await wait(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Integration Token is missed'), + expect.anything(), + expect.anything() + ); + + warnSpy.mockRestore(); + }); + + it('should construct without error when given a bare token string', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const addELSpy = vi.spyOn(window, 'addEventListener').mockImplementation(() => undefined); + + expect(() => new Catcher(TEST_TOKEN)).not.toThrow(); + + addELSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + it('should throw when the integration token contains malformed JSON', () => { + // getIntegrationId() tries JSON.parse(atob(token)); malformed JSON triggers the catch path. + expect(() => new Catcher({ token: btoa('not-json') })).toThrow('Invalid integration token.'); + }); + + it('should throw when the integration token has no integrationId field', () => { + // Valid base64 JSON but missing the integrationId property — inner guard throws. + const tokenWithoutId = btoa(JSON.stringify({ secret: 'abc' })); + + expect(() => new Catcher({ token: tokenWithoutId })).toThrow('Invalid integration token.'); + }); + }); + + // ── test() convenience method ───────────────────────────────────────────── + describe('test()', () => { + it('should send a predefined test error event via transport', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).test(); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toContain('Hawk JavaScript Catcher test message'); + }); + }); + + // ── Global error handlers ───────────────────────────────────────────────── + // + // When disableGlobalErrorsHandling is not set, the Catcher listens to + // window 'error' and 'unhandledrejection' events. + describe('global error handlers', () => { + const addedListeners: Array<[string, EventListenerOrEventListenerObject]> = []; + + beforeEach(() => { + const origAddEL = window.addEventListener.bind(window); + + vi.spyOn(window, 'addEventListener').mockImplementation( + (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => { + if (type === 'error' || type === 'unhandledrejection') { + addedListeners.push([type, listener]); + } + return origAddEL(type, listener, options as AddEventListenerOptions); + } + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const [type, listener] of addedListeners) { + window.removeEventListener(type, listener as EventListener); + } + addedListeners.length = 0; + }); + + it('should capture errors from window error events', async () => { + const { sendSpy, transport } = createTransport(); + + new Catcher({ + token: TEST_TOKEN, + breadcrumbs: false, + consoleTracking: false, + transport, + // disableGlobalErrorsHandling not set → handlers registered + }); + + window.dispatchEvent( + new ErrorEvent('error', { error: new Error('global error'), message: 'global error' }) + ); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('global error'); + }); + + it('should capture CORS script errors where error object is unavailable', async () => { + const { sendSpy, transport } = createTransport(); + + new Catcher({ + token: TEST_TOKEN, + breadcrumbs: false, + consoleTracking: false, + transport, + }); + + // CORS case: error property is undefined, only message is available. + window.dispatchEvent( + new ErrorEvent('error', { error: undefined, message: 'Script error.' }) + ); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('Script error.'); + }); + + it('should capture unhandled promise rejections', async () => { + const { sendSpy, transport } = createTransport(); + + new Catcher({ + token: TEST_TOKEN, + breadcrumbs: false, + consoleTracking: false, + transport, + }); + + window.dispatchEvent( + new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve() as Promise, + reason: new Error('rejected'), + }) + ); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('rejected'); + }); + }); + + // ── Transport failure ───────────────────────────────────────────────────── + describe('transport failure', () => { + it('should log a warning when transport.send rejects', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const transport: Transport = { + send: vi.fn().mockRejectedValue(new Error('network error')), + }; + + createCatcher(transport).send(new Error('e')); + await wait(); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('WebSocket sending error'), + expect.anything(), + expect.anything(), + expect.anything() + ); + + errorSpy.mockRestore(); + }); + + it('should log a warning when beforeSend throws unexpectedly', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { transport } = createTransport(); + + createCatcher(transport, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + beforeSend: () => { throw new Error('beforeSend crashed'); }, + } as never).send(new Error('e')); + await wait(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Unable to send error'), + expect.anything(), + expect.anything(), + expect.anything() + ); + + warnSpy.mockRestore(); + }); + }); + + // ── Environment addons ──────────────────────────────────────────────────── + // + // Browser-specific data collected from window (URL, GET params, debug info). + describe('environment addons', () => { + it('should include GET parameters when the URL has a query string', async () => { + vi.stubGlobal('location', { + ...window.location, + search: '?foo=bar&baz=qux', + href: 'http://localhost/?foo=bar&baz=qux', + }); + + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).addons.get).toEqual({ foo: 'bar', baz: 'qux' }); + + vi.unstubAllGlobals(); + }); + + it('should include raw error data in debug mode', async () => { + const { sendSpy, transport } = createTransport(); + const error = new Error('debug error'); + + createCatcher(transport, { debug: true }).send(error); + await wait(); + + expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toMatchObject({ + name: 'Error', + message: 'debug error', + stack: expect.any(String), + }); + }); + + it('should not include raw error data for string errors even in debug mode', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport, { debug: true }).send('string reason'); + await wait(); + + expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toBeUndefined(); + }); + }); + + // ── Backtrace ───────────────────────────────────────────────────────────── + describe('backtrace', () => { + it('should omit backtrace when stack parsing throws', async () => { + mockParse.mockRejectedValueOnce(new Error('parse failed')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).send(new Error('stack parse failure')); + await wait(); + + expect(getLastPayload(sendSpy).title).toBe('stack parse failure'); + expect(sendSpy).toHaveBeenCalledOnce(); + + warnSpy.mockRestore(); + }); + }); +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function createTransport() { + const sendSpy = vi.fn().mockResolvedValue(undefined); + const transport: Transport = { send: sendSpy }; + + return { sendSpy, transport }; +} + +/** Returns the payload of the last call to transport.send. */ +function getLastPayload(spy: ReturnType) { + const calls = spy.mock.calls; + + return calls[calls.length - 1][0].payload; +} + +function createCatcher(transport: Transport, options: Record = {}) { + return new Catcher({ + token: TEST_TOKEN, + disableGlobalErrorsHandling: true, + breadcrumbs: false, + consoleTracking: false, + transport, + ...options, + }); +} diff --git a/yarn.lock b/yarn.lock index b4ec9e55..eacf26a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -80,7 +80,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.23.5, @babel/parser@npm:^7.28.5": +"@babel/parser@npm:^7.23.5, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.29.0": version: 7.29.0 resolution: "@babel/parser@npm:7.29.0" dependencies: @@ -101,6 +101,13 @@ __metadata: languageName: node linkType: hard +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 + languageName: node + linkType: hard + "@csstools/color-helpers@npm:^6.0.1": version: 6.0.1 resolution: "@csstools/color-helpers@npm:6.0.1" @@ -590,6 +597,7 @@ __metadata: resolution: "@hawk.so/javascript@workspace:packages/javascript" dependencies: "@hawk.so/types": "npm:0.5.8" + "@vitest/coverage-v8": "npm:^4.0.18" error-stack-parser: "npm:^2.1.4" jsdom: "npm:^28.0.0" vite: "npm:^7.3.1" @@ -708,7 +716,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.24": +"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.31": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -1385,6 +1393,30 @@ __metadata: languageName: node linkType: hard +"@vitest/coverage-v8@npm:^4.0.18": + version: 4.0.18 + resolution: "@vitest/coverage-v8@npm:4.0.18" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.2" + "@vitest/utils": "npm:4.0.18" + ast-v8-to-istanbul: "npm:^0.3.10" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.2.0" + magicast: "npm:^0.5.1" + obug: "npm:^2.1.1" + std-env: "npm:^3.10.0" + tinyrainbow: "npm:^3.0.3" + peerDependencies: + "@vitest/browser": 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/e23e0da86f0b2a020c51562bc40ebdc7fc7553c24f8071dfb39a6df0161badbd5eaf2eebbf8ceaef18933a18c1934ff52d1c0c4bde77bb87e0c1feb0c8cbee4d + languageName: node + linkType: hard + "@vitest/expect@npm:4.0.18": version: 4.0.18 resolution: "@vitest/expect@npm:4.0.18" @@ -1846,6 +1878,17 @@ __metadata: languageName: node linkType: hard +"ast-v8-to-istanbul@npm:^0.3.10": + version: 0.3.11 + resolution: "ast-v8-to-istanbul@npm:0.3.11" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.31" + estree-walker: "npm:^3.0.3" + js-tokens: "npm:^10.0.0" + checksum: 10c0/0667dcb5f42bd16f5d50b8687f3471f9b9d000ea7f8808c3cd0ddabc1ef7d5b1a61e19f498d5ca7b1285e6c185e11d0ae724c4f9291491b50b6340110ce63108 + languageName: node + linkType: hard + "astral-regex@npm:^2.0.0": version: 2.0.0 resolution: "astral-regex@npm:2.0.0" @@ -3602,6 +3645,13 @@ __metadata: languageName: node linkType: hard +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" @@ -3997,6 +4047,34 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.2.0": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc + languageName: node + linkType: hard + "jiti@npm:^2.4.2": version: 2.6.1 resolution: "jiti@npm:2.6.1" @@ -4013,6 +4091,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^10.0.0": + version: 10.0.0 + resolution: "js-tokens@npm:10.0.0" + checksum: 10c0/a93498747812ba3e0c8626f95f75ab29319f2a13613a0de9e610700405760931624433a0de59eb7c27ff8836e526768fb20783861b86ef89be96676f2c996b64 + languageName: node + linkType: hard + "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -4249,6 +4334,26 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.5.1": + version: 0.5.2 + resolution: "magicast@npm:0.5.2" + dependencies: + "@babel/parser": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + source-map-js: "npm:^1.2.1" + checksum: 10c0/924af677643c5a0a7d6cdb3247c0eb96fa7611b2ba6a5e720d35d81c503d3d9f5948eb5227f80f90f82ea3e7d38cffd10bb988f3fc09020db428e14f26e960d7 + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + "make-fetch-happen@npm:^15.0.0": version: 15.0.3 resolution: "make-fetch-happen@npm:15.0.3" From 90d2fb661561219f004be633d8f64fa67bcbe6cc Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 25 Feb 2026 20:03:59 +0300 Subject: [PATCH 2/4] test(catcher): Validation utils covered with tests --- .../javascript/tests/utils/validation.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/javascript/tests/utils/validation.test.ts diff --git a/packages/javascript/tests/utils/validation.test.ts b/packages/javascript/tests/utils/validation.test.ts new file mode 100644 index 00000000..e277815a --- /dev/null +++ b/packages/javascript/tests/utils/validation.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest'; +import { validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from '../../src/utils/validation'; + +// Suppress console output produced by log() calls inside validation failures. +vi.mock('../src/utils/log', () => ({ default: vi.fn() })); + +describe('validateUser', () => { + it('should return false when user is null', () => { + expect(validateUser(null as never)).toBe(false); + }); + + it('should return false when user is a primitive (not an object)', () => { + expect(validateUser('alice' as never)).toBe(false); + }); + + it('should return false when user has no id property', () => { + expect(validateUser({} as never)).toBe(false); + }); + + it('should return false when user.id is a whitespace-only string', () => { + expect(validateUser({ id: ' ' } as never)).toBe(false); + }); + + it('should return false when user.id is not a string', () => { + expect(validateUser({ id: 42 } as never)).toBe(false); + }); + + it('should return true for a valid user object with an id', () => { + expect(validateUser({ id: 'user-1' })).toBe(true); + }); +}); + +describe('validateContext', () => { + it('should return false when context is a non-object primitive', () => { + expect(validateContext(42 as never)).toBe(false); + }); + + it('should return false when context is an array', () => { + expect(validateContext([] as never)).toBe(false); + }); + + it('should return true when context is undefined', () => { + expect(validateContext(undefined)).toBe(true); + }); + + it('should return true when context is a plain object', () => { + expect(validateContext({ env: 'production' })).toBe(true); + }); +}); + +describe('isValidEventPayload', () => { + it('should return false when payload is not a plain object', () => { + expect(isValidEventPayload('string')).toBe(false); + }); + + it('should return false when title is missing', () => { + expect(isValidEventPayload({})).toBe(false); + }); + + it('should return false when title is an empty string', () => { + expect(isValidEventPayload({ title: ' ' })).toBe(false); + }); + + it('should return false when backtrace is present but not an array', () => { + expect(isValidEventPayload({ title: 'oops', backtrace: 'not-an-array' })).toBe(false); + }); + + it('should return true when backtrace is an array', () => { + expect(isValidEventPayload({ title: 'oops', backtrace: [] })).toBe(true); + }); + + it('should return true for a minimal valid payload', () => { + expect(isValidEventPayload({ title: 'oops' })).toBe(true); + }); +}); + +describe('isValidBreadcrumb', () => { + it('should return false when breadcrumb is not a plain object', () => { + expect(isValidBreadcrumb('not-an-object')).toBe(false); + }); + + it('should return false when message is missing', () => { + expect(isValidBreadcrumb({})).toBe(false); + }); + + it('should return false when message is a whitespace-only string', () => { + expect(isValidBreadcrumb({ message: ' ' })).toBe(false); + }); + + it('should return false when timestamp is present but not a number', () => { + expect(isValidBreadcrumb({ message: 'click', timestamp: 'noon' })).toBe(false); + }); + + it('should return true when timestamp is a valid number', () => { + expect(isValidBreadcrumb({ message: 'click', timestamp: Date.now() })).toBe(true); + }); + + it('should return true when timestamp is absent', () => { + expect(isValidBreadcrumb({ message: 'click' })).toBe(true); + }); +}); From 6aebddadc2c07fee9914568143b5cb430ed23eb4 Mon Sep 17 00:00:00 2001 From: Reversean Date: Tue, 3 Mar 2026 23:32:47 +0300 Subject: [PATCH 3/4] test(catcher): reduced duplicating tests --- packages/javascript/tests/catcher.test.ts | 174 +++++------------- .../javascript/tests/utils/validation.test.ts | 2 +- 2 files changed, 48 insertions(+), 128 deletions(-) diff --git a/packages/javascript/tests/catcher.test.ts b/packages/javascript/tests/catcher.test.ts index 3f2b184b..3902c91e 100644 --- a/packages/javascript/tests/catcher.test.ts +++ b/packages/javascript/tests/catcher.test.ts @@ -20,7 +20,6 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any (BreadcrumbManager as any).instance = null; }); @@ -29,7 +28,7 @@ describe('Catcher', () => { // The Catcher's primary responsibility: capture errors and forward them to // the configured transport with identifying metadata. describe('error delivery', () => { - it('should deliver an Error instance via transport with matching title', async () => { + it('should send payload composed from Error instance', async () => { const { sendSpy, transport } = createTransport(); createCatcher(transport).send(new Error('something broke')); @@ -41,7 +40,7 @@ describe('Catcher', () => { expect(getLastPayload(sendSpy).title).toBe('something broke'); }); - it('should deliver a string-based error via transport with matching title', async () => { + it('should send payload composed from string', async () => { const { sendSpy, transport } = createTransport(); createCatcher(transport).send('unhandled rejection reason'); @@ -53,7 +52,7 @@ describe('Catcher', () => { expect(getLastPayload(sendSpy).title).toBe('unhandled rejection reason'); }); - it('should not deliver the same Error instance twice', async () => { + it('should not send payload for same Error instance twice', async () => { const { sendSpy, transport } = createTransport(); const hawk = createCatcher(transport); const error = new Error('duplicate'); @@ -65,7 +64,7 @@ describe('Catcher', () => { expect(sendSpy).toHaveBeenCalledTimes(1); }); - it('should deliver distinct Error instances independently', async () => { + it('should send payload for distinct Error instances independently', async () => { const { sendSpy, transport } = createTransport(); const hawk = createCatcher(transport); @@ -76,7 +75,7 @@ describe('Catcher', () => { expect(sendSpy).toHaveBeenCalledTimes(2); }); - it('should deliver string errors without deduplication', async () => { + it('should send payload for same strings without deduplication', async () => { const { sendSpy, transport } = createTransport(); const hawk = createCatcher(transport); @@ -93,17 +92,8 @@ describe('Catcher', () => { // The Catcher tracks who caused the error. When no user is configured it // falls back to a generated anonymous ID that persists across events. describe('user identity', () => { - let warnSpy: ReturnType; - beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - it('should generate a stable anonymous ID when no user is configured', async () => { + it('should generate and persist anonymous ID when no user is configured', async () => { const { sendSpy, transport } = createTransport(); const hawk = createCatcher(transport); @@ -119,7 +109,7 @@ describe('Catcher', () => { expect(id1).toBe(id2); }); - it('should include the user configured via setUser()', async () => { + it('should include user configured via setUser()', async () => { const { sendSpy, transport } = createTransport(); const hawk = createCatcher(transport); @@ -130,7 +120,7 @@ describe('Catcher', () => { expect(getLastPayload(sendSpy).user).toMatchObject({ id: 'user-1', name: 'Alice' }); }); - it('should include the user configured via constructor', async () => { + it('should include user configured via constructor', async () => { const { sendSpy, transport } = createTransport(); createCatcher(transport, { user: { id: 'user-2' } }).send(new Error('e')); @@ -139,30 +129,6 @@ describe('Catcher', () => { expect(getLastPayload(sendSpy).user).toMatchObject({ id: 'user-2' }); }); - it('should ignore setUser when called with null', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport); - - hawk.setUser({ id: 'valid' }); - hawk.setUser(null as never); - hawk.send(new Error('e')); - await wait(); - - expect(getLastPayload(sendSpy).user?.id).toBe('valid'); - }); - - it('should ignore setUser when the user has no id', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport); - - hawk.setUser({ id: 'valid' }); - hawk.setUser({} as never); - hawk.send(new Error('e')); - await wait(); - - expect(getLastPayload(sendSpy).user?.id).toBe('valid'); - }); - it('should revert to an anonymous identity after clearUser()', async () => { const { sendSpy, transport } = createTransport(); const hawk = createCatcher(transport); @@ -184,17 +150,8 @@ describe('Catcher', () => { // The Catcher attaches contextual information to every event: an optional // release version and arbitrary developer-supplied context data. describe('context enrichment', () => { - let warnSpy: ReturnType; - - beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - it('should include the release version when configured', async () => { + it('should include release version when configured', async () => { const { sendSpy, transport } = createTransport(); createCatcher(transport, { release: '1.2.3' }).send(new Error('e')); @@ -203,7 +160,7 @@ describe('Catcher', () => { expect(getLastPayload(sendSpy).release).toBe('1.2.3'); }); - it('should omit the release when not configured', async () => { + it('should omit release when not configured', async () => { const { sendSpy, transport } = createTransport(); createCatcher(transport).send(new Error('e')); @@ -264,7 +221,7 @@ describe('Catcher', () => { // The Catcher maintains a chronological trail of events leading up to the // error. The trail is included in delivered events only when non-empty. describe('breadcrumbs trail', () => { - it('should include recorded breadcrumbs in delivered events', async () => { + it('should include recorded breadcrumbs', async () => { const { sendSpy, transport } = createTransport(); const hawk = createCatcher(transport, { breadcrumbs: {} }); @@ -293,7 +250,7 @@ describe('Catcher', () => { expect(createCatcher(transport).breadcrumbs.get()).toEqual([]); }); - it('should omit breadcrumbs cleared before the event was sent', async () => { + it('should omit breadcrumbs cleared before payload was sent', async () => { const { sendSpy, transport } = createTransport(); const hawk = createCatcher(transport, { breadcrumbs: {} }); @@ -312,7 +269,7 @@ describe('Catcher', () => { // reporting errors via captureError(). These are merged into the payload // alongside the standard browser addons. describe('integration addons via captureError()', () => { - it('should merge integration-specific addons into the delivered event', async () => { + it('should merge integration-specific addons', async () => { const { sendSpy, transport } = createTransport(); createCatcher(transport).captureError(new Error('e'), { @@ -342,61 +299,40 @@ describe('Catcher', () => { // ── Constructor variants ────────────────────────────────────────────────── // - // The Catcher can be initialised with either a full settings object or a + // The Catcher can be initialized with either a full settings object or a // bare string token as a shorthand. describe('constructor', () => { - it('should accept a plain string as a shorthand for the token', async () => { - const sendSpy = vi.fn().mockResolvedValue(undefined); - const transport: Transport = { send: sendSpy }; - - const hawk = new Catcher({ - token: TEST_TOKEN, - disableGlobalErrorsHandling: true, - breadcrumbs: false, - consoleTracking: false, - transport, - }); - - hawk.send(new Error('shorthand')); - await wait(); - - expect(sendSpy).toHaveBeenCalledOnce(); - expect(sendSpy.mock.calls[0][0].token).toBe(TEST_TOKEN); - }); + const listeners: Array<[string, EventListenerOrEventListenerObject]> = []; - it('should warn and silently do nothing when no token is provided', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const hawk = new Catcher({ token: '' } as never); - - hawk.send(new Error('no-token')); - await wait(); + beforeEach(() => { + const orig = window.addEventListener.bind(window); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Integration Token is missed'), - expect.anything(), - expect.anything() + vi.spyOn(window, 'addEventListener').mockImplementation( + (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => { + listeners.push([type, listener]); + return orig(type, listener, options as AddEventListenerOptions); + } ); - - warnSpy.mockRestore(); }); - it('should construct without error when given a bare token string', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const addELSpy = vi.spyOn(window, 'addEventListener').mockImplementation(() => undefined); + afterEach(() => { + vi.restoreAllMocks(); + for (const [type, listener] of listeners) { + window.removeEventListener(type, listener as EventListener); + } + listeners.length = 0; + }); + it('should not throw when token provided via plain string shorthand', () => { expect(() => new Catcher(TEST_TOKEN)).not.toThrow(); - - addELSpy.mockRestore(); - consoleSpy.mockRestore(); }); - it('should throw when the integration token contains malformed JSON', () => { + it('should throw when integration token contains malformed JSON', () => { // getIntegrationId() tries JSON.parse(atob(token)); malformed JSON triggers the catch path. expect(() => new Catcher({ token: btoa('not-json') })).toThrow('Invalid integration token.'); }); - it('should throw when the integration token has no integrationId field', () => { + it('should throw when integration token has no integrationId field', () => { // Valid base64 JSON but missing the integrationId property — inner guard throws. const tokenWithoutId = btoa(JSON.stringify({ secret: 'abc' })); @@ -406,7 +342,7 @@ describe('Catcher', () => { // ── test() convenience method ───────────────────────────────────────────── describe('test()', () => { - it('should send a predefined test error event via transport', async () => { + it('should send a predefined test error event', async () => { const { sendSpy, transport } = createTransport(); createCatcher(transport).test(); @@ -510,43 +446,30 @@ describe('Catcher', () => { // ── Transport failure ───────────────────────────────────────────────────── describe('transport failure', () => { - it('should log a warning when transport.send rejects', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + it('should not throw when transport.send rejects', async () => { const transport: Transport = { send: vi.fn().mockRejectedValue(new Error('network error')), }; - createCatcher(transport).send(new Error('e')); - await wait(); - - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('WebSocket sending error'), - expect.anything(), - expect.anything(), - expect.anything() - ); + const act = async () => { + createCatcher(transport).send(new Error('e')); + await wait(); + }; - errorSpy.mockRestore(); + await expect(act()).resolves.toBeUndefined(); }); - it('should log a warning when beforeSend throws unexpectedly', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('should not throw when beforeSend throws unexpectedly', async () => { const { transport } = createTransport(); - createCatcher(transport, { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - beforeSend: () => { throw new Error('beforeSend crashed'); }, - } as never).send(new Error('e')); - await wait(); - - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Unable to send error'), - expect.anything(), - expect.anything(), - expect.anything() - ); + const act = async () => { + createCatcher(transport, { + beforeSend: () => { throw new Error('beforeSend crashed'); }, + } as never).send(new Error('e')); + await wait(); + }; - warnSpy.mockRestore(); + await expect(act()).resolves.toBeUndefined(); }); }); @@ -600,7 +523,6 @@ describe('Catcher', () => { it('should omit backtrace when stack parsing throws', async () => { mockParse.mockRejectedValueOnce(new Error('parse failed')); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { sendSpy, transport } = createTransport(); createCatcher(transport).send(new Error('stack parse failure')); @@ -608,8 +530,6 @@ describe('Catcher', () => { expect(getLastPayload(sendSpy).title).toBe('stack parse failure'); expect(sendSpy).toHaveBeenCalledOnce(); - - warnSpy.mockRestore(); }); }); }); diff --git a/packages/javascript/tests/utils/validation.test.ts b/packages/javascript/tests/utils/validation.test.ts index e277815a..f7cafd8f 100644 --- a/packages/javascript/tests/utils/validation.test.ts +++ b/packages/javascript/tests/utils/validation.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from '../../src/utils/validation'; // Suppress console output produced by log() calls inside validation failures. -vi.mock('../src/utils/log', () => ({ default: vi.fn() })); +vi.mock('../../src/utils/log', () => ({ default: vi.fn() })); describe('validateUser', () => { it('should return false when user is null', () => { From fbd1089d118a1b6ca0b411608304a9927a7d7234 Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 4 Mar 2026 00:47:36 +0300 Subject: [PATCH 4/4] test(catcher): sections from catcher.test.ts separated on different test.ts files --- .../javascript/tests/catcher.addons.test.ts | 95 ++++ .../tests/catcher.breadcrumbs.test.ts | 63 +++ .../javascript/tests/catcher.context.test.ts | 87 ++++ .../tests/catcher.global-handlers.test.ts | 108 ++++ packages/javascript/tests/catcher.helpers.ts | 31 ++ packages/javascript/tests/catcher.test.ts | 480 ++---------------- .../tests/catcher.transport.test.ts | 46 ++ .../javascript/tests/catcher.user.test.ts | 74 +++ 8 files changed, 542 insertions(+), 442 deletions(-) create mode 100644 packages/javascript/tests/catcher.addons.test.ts create mode 100644 packages/javascript/tests/catcher.breadcrumbs.test.ts create mode 100644 packages/javascript/tests/catcher.context.test.ts create mode 100644 packages/javascript/tests/catcher.global-handlers.test.ts create mode 100644 packages/javascript/tests/catcher.helpers.ts create mode 100644 packages/javascript/tests/catcher.transport.test.ts create mode 100644 packages/javascript/tests/catcher.user.test.ts diff --git a/packages/javascript/tests/catcher.addons.test.ts b/packages/javascript/tests/catcher.addons.test.ts new file mode 100644 index 00000000..ff8c5689 --- /dev/null +++ b/packages/javascript/tests/catcher.addons.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; + +const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); +vi.mock('../src/modules/stackParser', () => ({ + default: class { parse = mockParse; }, +})); + +describe('Catcher', () => { + beforeEach(() => { + localStorage.clear(); + mockParse.mockResolvedValue([]); + (BreadcrumbManager as any).instance = null; + }); + + // ── Environment addons ──────────────────────────────────────────────────── + // + // Browser-specific data collected from window (URL, GET params, debug info). + describe('environment addons', () => { + it('should include GET parameters when the URL has a query string', async () => { + vi.stubGlobal('location', { + ...window.location, + search: '?foo=bar&baz=qux', + href: 'http://localhost/?foo=bar&baz=qux', + }); + + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).addons.get).toEqual({ foo: 'bar', baz: 'qux' }); + + vi.unstubAllGlobals(); + }); + + it('should include raw error data in debug mode', async () => { + const { sendSpy, transport } = createTransport(); + const error = new Error('debug error'); + + createCatcher(transport, { debug: true }).send(error); + await wait(); + + expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toMatchObject({ + name: 'Error', + message: 'debug error', + stack: expect.any(String), + }); + }); + + it('should not include raw error data for string errors even in debug mode', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport, { debug: true }).send('string reason'); + await wait(); + + expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toBeUndefined(); + }); + }); + + // ── Integration addons ──────────────────────────────────────────────────── + // + // Framework integrations (Vue, Nuxt, etc.) attach extra addons when + // reporting errors via captureError(). These are merged into the payload + // alongside the standard browser addons. + describe('integration addons via captureError()', () => { + it('should merge integration-specific addons', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).captureError(new Error('e'), { + vue: { component: '', props: {}, lifecycle: 'mounted' }, + }); + await wait(); + + expect(getLastPayload(sendSpy).addons).toMatchObject({ + vue: { component: '', props: {}, lifecycle: 'mounted' }, + }); + }); + + it('should preserve standard browser addons when integration addons are merged', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).captureError(new Error('e'), { + vue: { component: '', props: {}, lifecycle: 'mounted' }, + }); + await wait(); + + const addons = getLastPayload(sendSpy).addons; + + expect(addons.userAgent).toBeDefined(); + expect(addons.url).toBeDefined(); + }); + }); +}); diff --git a/packages/javascript/tests/catcher.breadcrumbs.test.ts b/packages/javascript/tests/catcher.breadcrumbs.test.ts new file mode 100644 index 00000000..45cd450a --- /dev/null +++ b/packages/javascript/tests/catcher.breadcrumbs.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; + +const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); +vi.mock('../src/modules/stackParser', () => ({ + default: class { parse = mockParse; }, +})); + +describe('Catcher', () => { + beforeEach(() => { + localStorage.clear(); + mockParse.mockResolvedValue([]); + (BreadcrumbManager as any).instance = null; + }); + + // ── Breadcrumbs trail ───────────────────────────────────────────────────── + // + // The Catcher maintains a chronological trail of events leading up to the + // error. The trail is included in delivered events only when non-empty. + describe('breadcrumbs trail', () => { + it('should include recorded breadcrumbs', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { breadcrumbs: {} }); + + hawk.breadcrumbs.add({ message: 'button clicked', timestamp: Date.now() }); + hawk.send(new Error('e')); + await wait(); + + const breadcrumbs = getLastPayload(sendSpy).breadcrumbs; + + expect(Array.isArray(breadcrumbs)).toBe(true); + expect(breadcrumbs[0].message).toBe('button clicked'); + }); + + it('should omit breadcrumbs when none have been recorded', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport, { breadcrumbs: {} }).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).breadcrumbs).toBeFalsy(); + }); + + it('should return an empty array from breadcrumbs.get when breadcrumbs are disabled', () => { + const { transport } = createTransport(); + + expect(createCatcher(transport).breadcrumbs.get()).toEqual([]); + }); + + it('should omit breadcrumbs cleared before payload was sent', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { breadcrumbs: {} }); + + hawk.breadcrumbs.add({ message: 'click', timestamp: Date.now() }); + hawk.breadcrumbs.clear(); + hawk.send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).breadcrumbs).toBeFalsy(); + }); + }); +}); diff --git a/packages/javascript/tests/catcher.context.test.ts b/packages/javascript/tests/catcher.context.test.ts new file mode 100644 index 00000000..79211f4d --- /dev/null +++ b/packages/javascript/tests/catcher.context.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; + +const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); +vi.mock('../src/modules/stackParser', () => ({ + default: class { parse = mockParse; }, +})); + +describe('Catcher', () => { + beforeEach(() => { + localStorage.clear(); + mockParse.mockResolvedValue([]); + (BreadcrumbManager as any).instance = null; + }); + + // ── Context enrichment ──────────────────────────────────────────────────── + // + // The Catcher attaches contextual information to every event: an optional + // release version and arbitrary developer-supplied context data. + describe('context enrichment', () => { + + it('should include release version when configured', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport, { release: '1.2.3' }).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).release).toBe('1.2.3'); + }); + + it('should omit release when not configured', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).release).toBeFalsy(); + }); + + it('should include global context set via setContext()', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.setContext({ env: 'production' }); + hawk.send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).context).toMatchObject({ env: 'production' }); + }); + + it('should include per-send context passed to send()', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).send(new Error('e'), { requestId: 'abc123' }); + await wait(); + + expect(getLastPayload(sendSpy).context).toMatchObject({ requestId: 'abc123' }); + }); + + it('should ignore setContext when called with a non-object value', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.setContext({ original: true }); + hawk.setContext(42 as never); + hawk.send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).context).toMatchObject({ original: true }); + }); + + it('should merge global and per-send context, per-send wins on key collision', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { context: { key: 'global', shared: 1 } }); + + hawk.send(new Error('e'), { key: 'local', extra: 2 }); + await wait(); + + expect(getLastPayload(sendSpy).context).toMatchObject({ + key: 'local', + shared: 1, + extra: 2, + }); + }); + }); +}); diff --git a/packages/javascript/tests/catcher.global-handlers.test.ts b/packages/javascript/tests/catcher.global-handlers.test.ts new file mode 100644 index 00000000..34c18108 --- /dev/null +++ b/packages/javascript/tests/catcher.global-handlers.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Catcher from '../src/catcher'; +import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { TEST_TOKEN, wait, createTransport, getLastPayload } from './catcher.helpers'; + +const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); +vi.mock('../src/modules/stackParser', () => ({ + default: class { parse = mockParse; }, +})); + +describe('Catcher', () => { + beforeEach(() => { + localStorage.clear(); + mockParse.mockResolvedValue([]); + (BreadcrumbManager as any).instance = null; + }); + + // ── Global error handlers ───────────────────────────────────────────────── + // + // When disableGlobalErrorsHandling is not set, the Catcher listens to + // window 'error' and 'unhandledrejection' events. + describe('global error handlers', () => { + const addedListeners: Array<[string, EventListenerOrEventListenerObject]> = []; + + beforeEach(() => { + const origAddEL = window.addEventListener.bind(window); + + vi.spyOn(window, 'addEventListener').mockImplementation( + (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => { + if (type === 'error' || type === 'unhandledrejection') { + addedListeners.push([type, listener]); + } + return origAddEL(type, listener, options as AddEventListenerOptions); + } + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const [type, listener] of addedListeners) { + window.removeEventListener(type, listener as EventListener); + } + addedListeners.length = 0; + }); + + it('should capture errors from window error events', async () => { + const { sendSpy, transport } = createTransport(); + + new Catcher({ + token: TEST_TOKEN, + breadcrumbs: false, + consoleTracking: false, + transport, + // disableGlobalErrorsHandling not set → handlers registered + }); + + window.dispatchEvent( + new ErrorEvent('error', { error: new Error('global error'), message: 'global error' }) + ); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('global error'); + }); + + it('should capture CORS script errors where error object is unavailable', async () => { + const { sendSpy, transport } = createTransport(); + + new Catcher({ + token: TEST_TOKEN, + breadcrumbs: false, + consoleTracking: false, + transport, + }); + + // CORS case: error property is undefined, only message is available. + window.dispatchEvent( + new ErrorEvent('error', { error: undefined, message: 'Script error.' }) + ); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('Script error.'); + }); + + it('should capture unhandled promise rejections', async () => { + const { sendSpy, transport } = createTransport(); + + new Catcher({ + token: TEST_TOKEN, + breadcrumbs: false, + consoleTracking: false, + transport, + }); + + window.dispatchEvent( + new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve() as Promise, + reason: new Error('rejected'), + }) + ); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('rejected'); + }); + }); +}); diff --git a/packages/javascript/tests/catcher.helpers.ts b/packages/javascript/tests/catcher.helpers.ts new file mode 100644 index 00000000..6a0ab21d --- /dev/null +++ b/packages/javascript/tests/catcher.helpers.ts @@ -0,0 +1,31 @@ +import { vi } from 'vitest'; +import Catcher from '../src/catcher'; +import type { Transport } from '../src'; + +export const TEST_TOKEN = 'eyJpbnRlZ3JhdGlvbklkIjoiOTU3MmQyOWQtNWJhZS00YmYyLTkwN2MtZDk5ZDg5MGIwOTVmIiwic2VjcmV0IjoiZTExODFiZWItMjdlMS00ZDViLWEwZmEtZmUwYTM1Mzg5OWMyIn0='; +export const wait = (): Promise => new Promise((r) => setTimeout(r, 0)); + +export function createTransport() { + const sendSpy = vi.fn().mockResolvedValue(undefined); + const transport: Transport = { send: sendSpy }; + + return { sendSpy, transport }; +} + +/** Returns the payload of the last call to transport.send. */ +export function getLastPayload(spy: ReturnType) { + const calls = spy.mock.calls; + + return calls[calls.length - 1][0].payload; +} + +export function createCatcher(transport: Transport, options: Record = {}) { + return new Catcher({ + token: TEST_TOKEN, + disableGlobalErrorsHandling: true, + breadcrumbs: false, + consoleTracking: false, + transport, + ...options, + }); +} diff --git a/packages/javascript/tests/catcher.test.ts b/packages/javascript/tests/catcher.test.ts index 3902c91e..25a363a7 100644 --- a/packages/javascript/tests/catcher.test.ts +++ b/packages/javascript/tests/catcher.test.ts @@ -1,10 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import Catcher from '../src/catcher'; import { BreadcrumbManager } from '../src/addons/breadcrumbs'; -import type { Transport } from '../src'; - -const TEST_TOKEN = 'eyJpbnRlZ3JhdGlvbklkIjoiOTU3MmQyOWQtNWJhZS00YmYyLTkwN2MtZDk5ZDg5MGIwOTVmIiwic2VjcmV0IjoiZTExODFiZWItMjdlMS00ZDViLWEwZmEtZmUwYTM1Mzg5OWMyIn0='; -const wait = (): Promise => new Promise((r) => setTimeout(r, 0)); +import { TEST_TOKEN, wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; // StackParser is mocked to prevent real network calls to source files in the jsdom environment. const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -23,280 +20,6 @@ describe('Catcher', () => { (BreadcrumbManager as any).instance = null; }); - // ── Error delivery ──────────────────────────────────────────────────────── - // - // The Catcher's primary responsibility: capture errors and forward them to - // the configured transport with identifying metadata. - describe('error delivery', () => { - it('should send payload composed from Error instance', async () => { - const { sendSpy, transport } = createTransport(); - - createCatcher(transport).send(new Error('something broke')); - await wait(); - - expect(sendSpy).toHaveBeenCalledOnce(); - expect(sendSpy.mock.calls[0][0].token).toBe(TEST_TOKEN); - expect(sendSpy.mock.calls[0][0].catcherType).toBe('errors/javascript'); - expect(getLastPayload(sendSpy).title).toBe('something broke'); - }); - - it('should send payload composed from string', async () => { - const { sendSpy, transport } = createTransport(); - - createCatcher(transport).send('unhandled rejection reason'); - await wait(); - - expect(sendSpy).toHaveBeenCalledOnce(); - expect(sendSpy.mock.calls[0][0].token).toBe(TEST_TOKEN); - expect(sendSpy.mock.calls[0][0].catcherType).toBe('errors/javascript'); - expect(getLastPayload(sendSpy).title).toBe('unhandled rejection reason'); - }); - - it('should not send payload for same Error instance twice', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport); - const error = new Error('duplicate'); - - hawk.send(error); - hawk.send(error); - await wait(); - - expect(sendSpy).toHaveBeenCalledTimes(1); - }); - - it('should send payload for distinct Error instances independently', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport); - - hawk.send(new Error('first')); - hawk.send(new Error('second')); - await wait(); - - expect(sendSpy).toHaveBeenCalledTimes(2); - }); - - it('should send payload for same strings without deduplication', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport); - - hawk.send('reason'); - hawk.send('reason'); - await wait(); - - expect(sendSpy).toHaveBeenCalledTimes(2); - }); - }); - - // ── User identity ───────────────────────────────────────────────────────── - // - // The Catcher tracks who caused the error. When no user is configured it - // falls back to a generated anonymous ID that persists across events. - describe('user identity', () => { - - it('should generate and persist anonymous ID when no user is configured', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport); - - hawk.send(new Error('first')); - await wait(); - const id1 = getLastPayload(sendSpy).user?.id; - - hawk.send(new Error('second')); - await wait(); - const id2 = getLastPayload(sendSpy).user?.id; - - expect(id1).toBeTruthy(); - expect(id1).toBe(id2); - }); - - it('should include user configured via setUser()', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport); - - hawk.setUser({ id: 'user-1', name: 'Alice' }); - hawk.send(new Error('e')); - await wait(); - - expect(getLastPayload(sendSpy).user).toMatchObject({ id: 'user-1', name: 'Alice' }); - }); - - it('should include user configured via constructor', async () => { - const { sendSpy, transport } = createTransport(); - - createCatcher(transport, { user: { id: 'user-2' } }).send(new Error('e')); - await wait(); - - expect(getLastPayload(sendSpy).user).toMatchObject({ id: 'user-2' }); - }); - - it('should revert to an anonymous identity after clearUser()', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport); - - hawk.setUser({ id: 'user-1' }); - hawk.clearUser(); - hawk.send(new Error('e')); - await wait(); - - const user = getLastPayload(sendSpy).user; - - expect(user?.id).toBeTruthy(); - expect(user?.id).not.toBe('user-1'); - }); - }); - - // ── Context enrichment ──────────────────────────────────────────────────── - // - // The Catcher attaches contextual information to every event: an optional - // release version and arbitrary developer-supplied context data. - describe('context enrichment', () => { - - it('should include release version when configured', async () => { - const { sendSpy, transport } = createTransport(); - - createCatcher(transport, { release: '1.2.3' }).send(new Error('e')); - await wait(); - - expect(getLastPayload(sendSpy).release).toBe('1.2.3'); - }); - - it('should omit release when not configured', async () => { - const { sendSpy, transport } = createTransport(); - - createCatcher(transport).send(new Error('e')); - await wait(); - - expect(getLastPayload(sendSpy).release).toBeFalsy(); - }); - - it('should include global context set via setContext()', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport); - - hawk.setContext({ env: 'production' }); - hawk.send(new Error('e')); - await wait(); - - expect(getLastPayload(sendSpy).context).toMatchObject({ env: 'production' }); - }); - - it('should include per-send context passed to send()', async () => { - const { sendSpy, transport } = createTransport(); - - createCatcher(transport).send(new Error('e'), { requestId: 'abc123' }); - await wait(); - - expect(getLastPayload(sendSpy).context).toMatchObject({ requestId: 'abc123' }); - }); - - it('should ignore setContext when called with a non-object value', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport); - - hawk.setContext({ original: true }); - hawk.setContext(42 as never); - hawk.send(new Error('e')); - await wait(); - - expect(getLastPayload(sendSpy).context).toMatchObject({ original: true }); - }); - - it('should merge global and per-send context, per-send wins on key collision', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, { context: { key: 'global', shared: 1 } }); - - hawk.send(new Error('e'), { key: 'local', extra: 2 }); - await wait(); - - expect(getLastPayload(sendSpy).context).toMatchObject({ - key: 'local', - shared: 1, - extra: 2, - }); - }); - }); - - // ── Breadcrumbs trail ───────────────────────────────────────────────────── - // - // The Catcher maintains a chronological trail of events leading up to the - // error. The trail is included in delivered events only when non-empty. - describe('breadcrumbs trail', () => { - it('should include recorded breadcrumbs', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, { breadcrumbs: {} }); - - hawk.breadcrumbs.add({ message: 'button clicked', timestamp: Date.now() }); - hawk.send(new Error('e')); - await wait(); - - const breadcrumbs = getLastPayload(sendSpy).breadcrumbs; - - expect(Array.isArray(breadcrumbs)).toBe(true); - expect(breadcrumbs[0].message).toBe('button clicked'); - }); - - it('should omit breadcrumbs when none have been recorded', async () => { - const { sendSpy, transport } = createTransport(); - - createCatcher(transport, { breadcrumbs: {} }).send(new Error('e')); - await wait(); - - expect(getLastPayload(sendSpy).breadcrumbs).toBeFalsy(); - }); - - it('should return an empty array from breadcrumbs.get when breadcrumbs are disabled', () => { - const { transport } = createTransport(); - - expect(createCatcher(transport).breadcrumbs.get()).toEqual([]); - }); - - it('should omit breadcrumbs cleared before payload was sent', async () => { - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, { breadcrumbs: {} }); - - hawk.breadcrumbs.add({ message: 'click', timestamp: Date.now() }); - hawk.breadcrumbs.clear(); - hawk.send(new Error('e')); - await wait(); - - expect(getLastPayload(sendSpy).breadcrumbs).toBeFalsy(); - }); - }); - - // ── Integration addons ──────────────────────────────────────────────────── - // - // Framework integrations (Vue, Nuxt, etc.) attach extra addons when - // reporting errors via captureError(). These are merged into the payload - // alongside the standard browser addons. - describe('integration addons via captureError()', () => { - it('should merge integration-specific addons', async () => { - const { sendSpy, transport } = createTransport(); - - createCatcher(transport).captureError(new Error('e'), { - vue: { component: '', props: {}, lifecycle: 'mounted' }, - }); - await wait(); - - expect(getLastPayload(sendSpy).addons).toMatchObject({ - vue: { component: '', props: {}, lifecycle: 'mounted' }, - }); - }); - - it('should preserve standard browser addons when integration addons are merged', async () => { - const { sendSpy, transport } = createTransport(); - - createCatcher(transport).captureError(new Error('e'), { - vue: { component: '', props: {}, lifecycle: 'mounted' }, - }); - await wait(); - - const addons = getLastPayload(sendSpy).addons; - - expect(addons.userAgent).toBeDefined(); - expect(addons.url).toBeDefined(); - }); - }); - // ── Constructor variants ────────────────────────────────────────────────── // // The Catcher can be initialized with either a full settings object or a @@ -340,181 +63,80 @@ describe('Catcher', () => { }); }); - // ── test() convenience method ───────────────────────────────────────────── - describe('test()', () => { - it('should send a predefined test error event', async () => { - const { sendSpy, transport } = createTransport(); - - createCatcher(transport).test(); - await wait(); - - expect(sendSpy).toHaveBeenCalledOnce(); - expect(getLastPayload(sendSpy).title).toContain('Hawk JavaScript Catcher test message'); - }); - }); - - // ── Global error handlers ───────────────────────────────────────────────── + // ── Error delivery ──────────────────────────────────────────────────────── // - // When disableGlobalErrorsHandling is not set, the Catcher listens to - // window 'error' and 'unhandledrejection' events. - describe('global error handlers', () => { - const addedListeners: Array<[string, EventListenerOrEventListenerObject]> = []; - - beforeEach(() => { - const origAddEL = window.addEventListener.bind(window); - - vi.spyOn(window, 'addEventListener').mockImplementation( - (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => { - if (type === 'error' || type === 'unhandledrejection') { - addedListeners.push([type, listener]); - } - return origAddEL(type, listener, options as AddEventListenerOptions); - } - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - for (const [type, listener] of addedListeners) { - window.removeEventListener(type, listener as EventListener); - } - addedListeners.length = 0; - }); - - it('should capture errors from window error events', async () => { + // The Catcher's primary responsibility: capture errors and forward them to + // the configured transport with identifying metadata. + describe('error delivery', () => { + it('should send payload composed from Error instance', async () => { const { sendSpy, transport } = createTransport(); - new Catcher({ - token: TEST_TOKEN, - breadcrumbs: false, - consoleTracking: false, - transport, - // disableGlobalErrorsHandling not set → handlers registered - }); - - window.dispatchEvent( - new ErrorEvent('error', { error: new Error('global error'), message: 'global error' }) - ); + createCatcher(transport).send(new Error('something broke')); await wait(); expect(sendSpy).toHaveBeenCalledOnce(); - expect(getLastPayload(sendSpy).title).toBe('global error'); + expect(sendSpy.mock.calls[0][0].token).toBe(TEST_TOKEN); + expect(sendSpy.mock.calls[0][0].catcherType).toBe('errors/javascript'); + expect(getLastPayload(sendSpy).title).toBe('something broke'); }); - it('should capture CORS script errors where error object is unavailable', async () => { + it('should send payload composed from string', async () => { const { sendSpy, transport } = createTransport(); - new Catcher({ - token: TEST_TOKEN, - breadcrumbs: false, - consoleTracking: false, - transport, - }); - - // CORS case: error property is undefined, only message is available. - window.dispatchEvent( - new ErrorEvent('error', { error: undefined, message: 'Script error.' }) - ); + createCatcher(transport).send('unhandled rejection reason'); await wait(); expect(sendSpy).toHaveBeenCalledOnce(); - expect(getLastPayload(sendSpy).title).toBe('Script error.'); + expect(sendSpy.mock.calls[0][0].token).toBe(TEST_TOKEN); + expect(sendSpy.mock.calls[0][0].catcherType).toBe('errors/javascript'); + expect(getLastPayload(sendSpy).title).toBe('unhandled rejection reason'); }); - it('should capture unhandled promise rejections', async () => { + it('should not send payload for same Error instance twice', async () => { const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + const error = new Error('duplicate'); - new Catcher({ - token: TEST_TOKEN, - breadcrumbs: false, - consoleTracking: false, - transport, - }); - - window.dispatchEvent( - new PromiseRejectionEvent('unhandledrejection', { - promise: Promise.resolve() as Promise, - reason: new Error('rejected'), - }) - ); + hawk.send(error); + hawk.send(error); await wait(); - expect(sendSpy).toHaveBeenCalledOnce(); - expect(getLastPayload(sendSpy).title).toBe('rejected'); - }); - }); - - // ── Transport failure ───────────────────────────────────────────────────── - describe('transport failure', () => { - it('should not throw when transport.send rejects', async () => { - const transport: Transport = { - send: vi.fn().mockRejectedValue(new Error('network error')), - }; - - const act = async () => { - createCatcher(transport).send(new Error('e')); - await wait(); - }; - - await expect(act()).resolves.toBeUndefined(); - }); - - it('should not throw when beforeSend throws unexpectedly', async () => { - const { transport } = createTransport(); - - const act = async () => { - createCatcher(transport, { - beforeSend: () => { throw new Error('beforeSend crashed'); }, - } as never).send(new Error('e')); - await wait(); - }; - - await expect(act()).resolves.toBeUndefined(); + expect(sendSpy).toHaveBeenCalledTimes(1); }); - }); - - // ── Environment addons ──────────────────────────────────────────────────── - // - // Browser-specific data collected from window (URL, GET params, debug info). - describe('environment addons', () => { - it('should include GET parameters when the URL has a query string', async () => { - vi.stubGlobal('location', { - ...window.location, - search: '?foo=bar&baz=qux', - href: 'http://localhost/?foo=bar&baz=qux', - }); + it('should send payload for distinct Error instances independently', async () => { const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); - createCatcher(transport).send(new Error('e')); + hawk.send(new Error('first')); + hawk.send(new Error('second')); await wait(); - expect(getLastPayload(sendSpy).addons.get).toEqual({ foo: 'bar', baz: 'qux' }); - - vi.unstubAllGlobals(); + expect(sendSpy).toHaveBeenCalledTimes(2); }); - it('should include raw error data in debug mode', async () => { + it('should send payload for same strings without deduplication', async () => { const { sendSpy, transport } = createTransport(); - const error = new Error('debug error'); + const hawk = createCatcher(transport); - createCatcher(transport, { debug: true }).send(error); + hawk.send('reason'); + hawk.send('reason'); await wait(); - expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toMatchObject({ - name: 'Error', - message: 'debug error', - stack: expect.any(String), - }); + expect(sendSpy).toHaveBeenCalledTimes(2); }); + }); - it('should not include raw error data for string errors even in debug mode', async () => { + // ── test() convenience method ───────────────────────────────────────────── + describe('test()', () => { + it('should send a predefined test error event', async () => { const { sendSpy, transport } = createTransport(); - createCatcher(transport, { debug: true }).send('string reason'); + createCatcher(transport).test(); await wait(); - expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toBeUndefined(); + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toContain('Hawk JavaScript Catcher test message'); }); }); @@ -534,29 +156,3 @@ describe('Catcher', () => { }); }); -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function createTransport() { - const sendSpy = vi.fn().mockResolvedValue(undefined); - const transport: Transport = { send: sendSpy }; - - return { sendSpy, transport }; -} - -/** Returns the payload of the last call to transport.send. */ -function getLastPayload(spy: ReturnType) { - const calls = spy.mock.calls; - - return calls[calls.length - 1][0].payload; -} - -function createCatcher(transport: Transport, options: Record = {}) { - return new Catcher({ - token: TEST_TOKEN, - disableGlobalErrorsHandling: true, - breadcrumbs: false, - consoleTracking: false, - transport, - ...options, - }); -} diff --git a/packages/javascript/tests/catcher.transport.test.ts b/packages/javascript/tests/catcher.transport.test.ts new file mode 100644 index 00000000..7a233704 --- /dev/null +++ b/packages/javascript/tests/catcher.transport.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import type { Transport } from '../src'; +import { wait, createTransport, createCatcher } from './catcher.helpers'; + +const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); +vi.mock('../src/modules/stackParser', () => ({ + default: class { parse = mockParse; }, +})); + +describe('Catcher', () => { + beforeEach(() => { + localStorage.clear(); + mockParse.mockResolvedValue([]); + (BreadcrumbManager as any).instance = null; + }); + + // ── Transport failure ───────────────────────────────────────────────────── + describe('transport failure', () => { + it('should not throw when transport.send rejects', async () => { + const transport: Transport = { + send: vi.fn().mockRejectedValue(new Error('network error')), + }; + + const act = async () => { + createCatcher(transport).send(new Error('e')); + await wait(); + }; + + await expect(act()).resolves.toBeUndefined(); + }); + + it('should not throw when beforeSend throws unexpectedly', async () => { + const { transport } = createTransport(); + + const act = async () => { + createCatcher(transport, { + beforeSend: () => { throw new Error('beforeSend crashed'); }, + } as never).send(new Error('e')); + await wait(); + }; + + await expect(act()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/javascript/tests/catcher.user.test.ts b/packages/javascript/tests/catcher.user.test.ts new file mode 100644 index 00000000..6f2d29a8 --- /dev/null +++ b/packages/javascript/tests/catcher.user.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; + +const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); +vi.mock('../src/modules/stackParser', () => ({ + default: class { parse = mockParse; }, +})); + +describe('Catcher', () => { + beforeEach(() => { + localStorage.clear(); + mockParse.mockResolvedValue([]); + (BreadcrumbManager as any).instance = null; + }); + + // ── User identity ───────────────────────────────────────────────────────── + // + // The Catcher tracks who caused the error. When no user is configured it + // falls back to a generated anonymous ID that persists across events. + describe('user identity', () => { + + it('should generate and persist anonymous ID when no user is configured', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.send(new Error('first')); + await wait(); + const id1 = getLastPayload(sendSpy).user?.id; + + hawk.send(new Error('second')); + await wait(); + const id2 = getLastPayload(sendSpy).user?.id; + + expect(id1).toBeTruthy(); + expect(id1).toBe(id2); + }); + + it('should include user configured via setUser()', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.setUser({ id: 'user-1', name: 'Alice' }); + hawk.send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).user).toMatchObject({ id: 'user-1', name: 'Alice' }); + }); + + it('should include user configured via constructor', async () => { + const { sendSpy, transport } = createTransport(); + + createCatcher(transport, { user: { id: 'user-2' } }).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).user).toMatchObject({ id: 'user-2' }); + }); + + it('should revert to an anonymous identity after clearUser()', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport); + + hawk.setUser({ id: 'user-1' }); + hawk.clearUser(); + hawk.send(new Error('e')); + await wait(); + + const user = getLastPayload(sendSpy).user; + + expect(user?.id).toBeTruthy(); + expect(user?.id).not.toBe('user-1'); + }); + }); +});