From 7c822b09891948bcd417485a1196132bd059d870 Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 25 Feb 2026 20:03:49 +0300 Subject: [PATCH] test(catcher): Catcher, Sanitizer and Validation utils covered with tests --- packages/javascript/package.json | 2 + packages/javascript/tests/before-send.test.ts | 181 ------------------ .../javascript/tests/catcher.addons.test.ts | 95 +++++++++ .../tests/catcher.before-send.test.ts | 150 +++++++++++++++ .../tests/catcher.breadcrumbs.test.ts | 63 ++++++ .../javascript/tests/catcher.context.test.ts | 68 +++++++ .../tests/catcher.global-handlers.test.ts | 108 +++++++++++ packages/javascript/tests/catcher.helpers.ts | 31 +++ .../javascript/tests/catcher.release.test.ts | 22 +++ packages/javascript/tests/catcher.test.ts | 158 +++++++++++++++ .../tests/catcher.transport.test.ts | 32 ++++ .../javascript/tests/catcher.user.test.ts | 74 +++++++ .../tests/modules/sanitizer.test.ts | 126 ++++++++++++ .../javascript/tests/utils/validation.test.ts | 101 ++++++++++ yarn.lock | 109 ++++++++++- 15 files changed, 1137 insertions(+), 183 deletions(-) delete mode 100644 packages/javascript/tests/before-send.test.ts create mode 100644 packages/javascript/tests/catcher.addons.test.ts create mode 100644 packages/javascript/tests/catcher.before-send.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.release.test.ts create mode 100644 packages/javascript/tests/catcher.test.ts create mode 100644 packages/javascript/tests/catcher.transport.test.ts create mode 100644 packages/javascript/tests/catcher.user.test.ts create mode 100644 packages/javascript/tests/modules/sanitizer.test.ts create mode 100644 packages/javascript/tests/utils/validation.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/before-send.test.ts b/packages/javascript/tests/before-send.test.ts deleted file mode 100644 index 304cebdc..00000000 --- a/packages/javascript/tests/before-send.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { CatcherMessage } from '../src/types/catcher-message'; -import type { Transport } from '../src/types/transport'; -import type { HawkJavaScriptEvent } from '../src/types/event'; -import Catcher from '../src/catcher'; - -const TEST_TOKEN = 'eyJpbnRlZ3JhdGlvbklkIjoiOTU3MmQyOWQtNWJhZS00YmYyLTkwN2MtZDk5ZDg5MGIwOTVmIiwic2VjcmV0IjoiZTExODFiZWItMjdlMS00ZDViLWEwZmEtZmUwYTM1Mzg5OWMyIn0='; -/** - * Wait for fire-and-forget async calls inside hawk.send() to complete - */ -const wait = (): Promise => new Promise((r) => setTimeout(r, 0)); - -function createTransport() { - const sendSpy = vi.fn<(msg: CatcherMessage) => Promise>().mockResolvedValue(undefined); - const transport: Transport = { send: sendSpy }; - - return { sendSpy, transport }; -} - -function getSentPayload(spy: ReturnType): HawkJavaScriptEvent | null { - const calls = spy.mock.calls; - - return calls.length ? calls[calls.length - 1][0].payload : null; -} - -/** - * Shared Catcher config — no breadcrumbs, no global handlers, fake transport - */ -function createCatcher(transport: Transport, beforeSend: NonNullable[0] extends object ? ConstructorParameters[0]['beforeSend'] : never>) { - return new Catcher({ - token: TEST_TOKEN, - disableGlobalErrorsHandling: true, - breadcrumbs: false, - transport, - beforeSend, - }); -} - -describe('beforeSend', () => { - let warnSpy: ReturnType; - - beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - it('should send event as-is when beforeSend returns it unchanged', async () => { - // Arrange - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, (event) => event); - - // Act - hawk.send(new Error('hello')); - await wait(); - - // Assert - expect(sendSpy).toHaveBeenCalledOnce(); - expect(getSentPayload(sendSpy)!.title).toBe('hello'); - }); - - it('should send modified event when beforeSend mutates and returns it', async () => { - // Arrange - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, (event) => { - event.context = { sanitized: true }; - - return event; - }); - - // Act - hawk.send(new Error('modify')); - await wait(); - - // Assert - expect(sendSpy).toHaveBeenCalledOnce(); - expect(getSentPayload(sendSpy)!.context).toEqual({ sanitized: true }); - }); - - it('should not send event when beforeSend returns false', async () => { - // Arrange - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, () => false); - - // Act - hawk.send(new Error('drop')); - await wait(); - - // Assert - expect(sendSpy).not.toHaveBeenCalled(); - }); - - it.each([ - { label: 'undefined', value: undefined }, - { label: 'null', value: null }, - { label: 'number (42)', value: 42 }, - { label: 'string ("oops")', value: 'oops' }, - { label: 'true', value: true }, - ])('should send original event and warn when beforeSend returns $label', async ({ value }) => { - // Arrange - const { sendSpy, transport } = createTransport(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const hawk = createCatcher(transport, () => value as any); - - // Act - hawk.send(new Error('invalid')); - await wait(); - - // Assert - expect(sendSpy).toHaveBeenCalledOnce(); - expect(getSentPayload(sendSpy)!.title).toBe('invalid'); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid beforeSend value'), - expect.anything(), - expect.anything() - ); - }); - - it('should send original event and warn when beforeSend deletes required field (title)', async () => { - // Arrange - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, (event) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (event as any).title; - - return event; - }); - - // Act - hawk.send(new Error('required-field')); - await wait(); - - // Assert — fallback to original payload, title preserved - expect(sendSpy).toHaveBeenCalledOnce(); - expect(getSentPayload(sendSpy)!.title).toBe('required-field'); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid beforeSend value'), - expect.anything(), - expect.anything() - ); - }); - - it('should still send event when structuredClone throws (non-cloneable payload)', async () => { - // Arrange - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, (event) => event); - const cloneSpy = vi.spyOn(globalThis, 'structuredClone').mockImplementation(() => { - throw new DOMException('could not be cloned', 'DataCloneError'); - }); - - // Act - hawk.send(new Error('non-cloneable')); - await wait(); - - // Assert — event is still sent, reporting didn't crash - expect(sendSpy).toHaveBeenCalledOnce(); - expect(getSentPayload(sendSpy)!.title).toBe('non-cloneable'); - - cloneSpy.mockRestore(); - }); - - it('should send event without deleted optional fields', async () => { - // Arrange - const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, (event) => { - delete event.release; - - return event; - }); - - // Act - hawk.send(new Error('optional')); - await wait(); - - // Assert - expect(sendSpy).toHaveBeenCalledOnce(); - expect(getSentPayload(sendSpy)!.release).toBeUndefined(); - }); -}); 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.before-send.test.ts b/packages/javascript/tests/catcher.before-send.test.ts new file mode 100644 index 00000000..bf18f18d --- /dev/null +++ b/packages/javascript/tests/catcher.before-send.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createCatcher, createTransport, wait, getLastPayload } from './catcher.helpers'; + +describe('Catcher', () => { + it('should send event as-is when beforeSend returns it unchanged', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { beforeSend: (event) => event }); + + hawk.send(new Error('hello')); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('hello'); + }); + + it('should send modified event when beforeSend mutates and returns it', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { + beforeSend: (event) => { + event.context = { sanitized: true }; + + return event; + }, + }); + + hawk.send(new Error('modify')); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).context).toEqual({ sanitized: true }); + }); + + it('should not send event when beforeSend returns false', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { beforeSend: () => false }); + + hawk.send(new Error('drop')); + await wait(); + + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it.each([ + { label: 'undefined', value: undefined }, + { label: 'null', value: null }, + { label: 'number (42)', value: 42 }, + { label: 'string ("oops")', value: 'oops' }, + { label: 'true', value: true }, + ])('should send original event when beforeSend returns $label', async ({ value }) => { + const { sendSpy, transport } = createTransport(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hawk = createCatcher(transport, { beforeSend: () => value as any }); + + hawk.send(new Error('invalid')); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('invalid'); + }); + + it('should send original event when beforeSend deletes required field (title)', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { + beforeSend: (event) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (event as any).title; + + return event; + }, + }); + + hawk.send(new Error('required-field')); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('required-field'); + }); + + it('should still send event when structuredClone throws (non-cloneable payload)', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { beforeSend: (event) => event }); + const cloneSpy = vi.spyOn(globalThis, 'structuredClone').mockImplementation(() => { + throw new DOMException('could not be cloned', 'DataCloneError'); + }); + + hawk.send(new Error('non-cloneable')); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('non-cloneable'); + + cloneSpy.mockRestore(); + }); + + it('should send event without deleted optional fields', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { + beforeSend: (event) => { + delete event.release; + + return event; + }, + }); + + hawk.send(new Error('optional')); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).release).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(); + }); + + it('should send original event unchanged when beforeSend mutates clone and returns undefined', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { + beforeSend: (event) => { (event as any).title = 'mutated'; return undefined as any; }, + }); + + hawk.send(new Error('original')); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('original'); + }); + + it('should send new event object when beforeSend returns a brand-new object', async () => { + const { sendSpy, transport } = createTransport(); + const hawk = createCatcher(transport, { + beforeSend: (event) => ({ ...event, title: 'brand new title' }), + }); + + hawk.send(new Error('original')); + await wait(); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getLastPayload(sendSpy).title).toBe('brand new title'); + }); +}); 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..231b653c --- /dev/null +++ b/packages/javascript/tests/catcher.context.test.ts @@ -0,0 +1,68 @@ +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 arbitrary developer-supplied context data to every event. + describe('context enrichment', () => { + + 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.release.test.ts b/packages/javascript/tests/catcher.release.test.ts new file mode 100644 index 00000000..2694711e --- /dev/null +++ b/packages/javascript/tests/catcher.release.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { createCatcher, createTransport, getLastPayload, wait } from "./catcher.helpers"; + +describe('Catcher', () => { + 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(); + }); +}); diff --git a/packages/javascript/tests/catcher.test.ts b/packages/javascript/tests/catcher.test.ts new file mode 100644 index 00000000..25a363a7 --- /dev/null +++ b/packages/javascript/tests/catcher.test.ts @@ -0,0 +1,158 @@ +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, 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([])); +vi.mock('../src/modules/stackParser', () => ({ + default: class { + parse = mockParse; + }, +})); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('Catcher', () => { + beforeEach(() => { + localStorage.clear(); + mockParse.mockResolvedValue([]); + (BreadcrumbManager as any).instance = null; + }); + + // ── Constructor variants ────────────────────────────────────────────────── + // + // The Catcher can be initialized with either a full settings object or a + // bare string token as a shorthand. + describe('constructor', () => { + const listeners: Array<[string, EventListenerOrEventListenerObject]> = []; + + beforeEach(() => { + const orig = window.addEventListener.bind(window); + + vi.spyOn(window, 'addEventListener').mockImplementation( + (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => { + listeners.push([type, listener]); + return orig(type, listener, options as AddEventListenerOptions); + } + ); + }); + + 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(); + }); + + 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 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.'); + }); + }); + + // ── 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); + }); + }); + + // ── 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'); + }); + }); + + // ── Backtrace ───────────────────────────────────────────────────────────── + describe('backtrace', () => { + it('should omit backtrace when stack parsing throws', async () => { + mockParse.mockRejectedValueOnce(new Error('parse failed')); + + 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(); + }); + }); +}); + diff --git a/packages/javascript/tests/catcher.transport.test.ts b/packages/javascript/tests/catcher.transport.test.ts new file mode 100644 index 00000000..a59f4b1e --- /dev/null +++ b/packages/javascript/tests/catcher.transport.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import type { Transport } from '../src'; +import { wait, createCatcher, createTransport } 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; + }); + + 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(); + }); + }); +}); 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'); + }); + }); +}); diff --git a/packages/javascript/tests/modules/sanitizer.test.ts b/packages/javascript/tests/modules/sanitizer.test.ts new file mode 100644 index 00000000..7b79c6be --- /dev/null +++ b/packages/javascript/tests/modules/sanitizer.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; +import Sanitizer from '../../src/modules/sanitizer'; + +describe('Sanitizer', () => { + describe('isObject', () => { + it('should return true for a plain object', () => { + expect(Sanitizer.isObject({})).toBe(true); + }); + + it('should return false for an array', () => { + expect(Sanitizer.isObject([])).toBe(false); + }); + + it('should return false for a string', () => { + expect(Sanitizer.isObject('x')).toBe(false); + }); + + it('should return false for a boolean', () => { + expect(Sanitizer.isObject(true)).toBe(false); + }); + + it('should return false for null', () => { + expect(Sanitizer.isObject(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(Sanitizer.isObject(undefined)).toBe(false); + }); + }); + + describe('sanitize', () => { + it('should pass through strings within the length limit', () => { + expect(Sanitizer.sanitize('hello')).toBe('hello'); + }); + + it('should trim strings longer than maxStringLen', () => { + const long = 'a'.repeat(201); + const result = Sanitizer.sanitize(long); + + expect(result).toBe('a'.repeat(200) + '…'); + }); + + it('should pass through short arrays unchanged', () => { + expect(Sanitizer.sanitize([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('should truncate arrays over maxArrayLength items and append placeholder', () => { + const arr = Array.from({ length: 12 }, (_, i) => i); + const result = Sanitizer.sanitize(arr); + + expect(result).toHaveLength(11); + expect(result[10]).toBe('<2 more items...>'); + }); + + it('should sanitize nested objects recursively', () => { + const longStr = 'a'.repeat(201); + const longArr = Array.from({ length: 12 }, (_, i) => i); + const obj = { + foo: 'x', + bar: longStr, + baz: longArr + } + const result = Sanitizer.sanitize(obj); + + expect(result.foo).toBe('x'); + expect(result.bar).toBe('a'.repeat(200) + '…'); + expect(result.baz).toHaveLength(11); + expect(result.baz[10]).toBe('<2 more items...>'); + }); + + it('should replace objects with more than 20 keys with placeholder', () => { + const big: Record = {}; + + for (let i = 0; i < 21; i++) { + big[`k${i}`] = i; + } + + expect(Sanitizer.sanitize(big)).toBe(''); + }); + + it('should replace deeply nested objects with placeholder', () => { + const deep = { a: { b: { c: { d: { e: { f: 'bottom' } } } } } }; + const result = Sanitizer.sanitize(deep); + + expect(result.a.b.c.d.e).toBe(''); + }); + + it('should format HTML elements as a string starting with tag', () => { + const el = document.createElement('div'); + const result = Sanitizer.sanitize(el); + + expect(typeof result).toBe('string'); + expect(result).toMatch(/^
"', () => { + class Foo {} + + expect(Sanitizer.sanitize(Foo)).toBe(''); + }); + + it('should format a class instance as ""', () => { + class Foo {} + + expect(Sanitizer.sanitize(new Foo())).toBe(''); + }); + + it('should replace circular references with placeholder', () => { + const obj: any = { a: 1 }; + + obj.self = obj; + + const result = Sanitizer.sanitize(obj); + + expect(result.self).toBe(''); + }); + + it.each([ + { label: 'number', value: 42 }, + { label: 'boolean', value: true }, + { label: 'null', value: null }, + ])('should pass through $label primitives unchanged', ({ value }) => { + expect(Sanitizer.sanitize(value)).toBe(value); + }); + }); +}); diff --git a/packages/javascript/tests/utils/validation.test.ts b/packages/javascript/tests/utils/validation.test.ts new file mode 100644 index 00000000..f7cafd8f --- /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); + }); +}); 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"