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.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
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..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');
+ });
+ });
+});
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"