Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/javascript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}\""
},
Expand All @@ -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",
Expand Down
95 changes: 95 additions & 0 deletions packages/javascript/tests/catcher.addons.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<App />', props: {}, lifecycle: 'mounted' },
});
await wait();

expect(getLastPayload(sendSpy).addons).toMatchObject({
vue: { component: '<App />', 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: '<Foo />', props: {}, lifecycle: 'mounted' },
});
await wait();

const addons = getLastPayload(sendSpy).addons;

expect(addons.userAgent).toBeDefined();
expect(addons.url).toBeDefined();
});
});
});
63 changes: 63 additions & 0 deletions packages/javascript/tests/catcher.breadcrumbs.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
87 changes: 87 additions & 0 deletions packages/javascript/tests/catcher.context.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
});
108 changes: 108 additions & 0 deletions packages/javascript/tests/catcher.global-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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<never>,
reason: new Error('rejected'),
})
);
await wait();

expect(sendSpy).toHaveBeenCalledOnce();
expect(getLastPayload(sendSpy).title).toBe('rejected');
});
});
});
Loading