From 9bd6a903d54e20f3b59fc00d5b09dfe350993a11 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 28 Jan 2026 18:04:37 +0300 Subject: [PATCH 01/12] fix(catchers): validate beforeSend return value to avoid sending invalid payload --- packages/javascript/package.json | 2 +- packages/javascript/src/catcher.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 84eed2b..7f9c034 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@hawk.so/javascript", - "version": "3.2.13", + "version": "3.2.14", "description": "JavaScript errors tracking for Hawk.so", "files": [ "dist" diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 313ba93..7b78e1d 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -440,8 +440,10 @@ export default class Catcher { if (beforeSendResult === false) { throw new EventRejectedError('Event rejected by beforeSend method.'); - } else { + } else if (typeof beforeSendResult === 'object' && beforeSendResult !== null) { payload = beforeSendResult; + } else if (beforeSendResult !== undefined) { + log('beforeSend must return event object or false. Received: ' + typeof beforeSendResult, 'warn'); } } From f20a748fd78239577e03bccae4a284296c041d6d Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:26:07 +0300 Subject: [PATCH 02/12] chore: update version to 3.2.16 in package.json and enhance validation for beforeSend and beforeBreadcrumb return values --- packages/javascript/package.json | 2 +- packages/javascript/src/addons/breadcrumbs.ts | 36 +++++++++---- packages/javascript/src/catcher.ts | 48 +++++++++++++---- .../src/types/hawk-initial-settings.ts | 7 +-- packages/javascript/src/utils/validation.ts | 51 ++++++++++++++++++- 5 files changed, 119 insertions(+), 25 deletions(-) diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 7f9c034..c0c716e 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@hawk.so/javascript", - "version": "3.2.14", + "version": "3.2.16", "description": "JavaScript errors tracking for Hawk.so", "files": [ "dist" diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index cce9947..be56370 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -5,6 +5,7 @@ import type { Breadcrumb, BreadcrumbLevel, BreadcrumbType, Json, JsonNode } from import Sanitizer from '../modules/sanitizer'; import { buildElementSelector } from '../utils/selector'; import log from '../utils/log'; +import { isValidBreadcrumb } from '../utils/validation'; /** * Default maximum number of breadcrumbs to store @@ -48,11 +49,13 @@ export interface BreadcrumbsOptions { maxBreadcrumbs?: number; /** - * Hook called before each breadcrumb is stored - * Return null to discard the breadcrumb - * Return modified breadcrumb to store it + * Hook called before each breadcrumb is stored. + * - Return modified breadcrumb — it will be stored instead of the original. + * - Return `false` — the breadcrumb will be discarded. + * - Return nothing (`void` / `undefined` / `null`) — the original breadcrumb is stored as-is (a warning is logged). + * - If the hook returns an invalid value, a warning is logged and the original breadcrumb is stored. */ - beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | null; + beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | false | void; /** * Enable automatic fetch/XHR breadcrumbs @@ -91,7 +94,7 @@ interface InternalBreadcrumbsOptions { trackFetch: boolean; trackNavigation: boolean; trackClicks: boolean; - beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | null; + beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | false | void; } /** @@ -235,14 +238,27 @@ export class BreadcrumbManager { if (this.options.beforeBreadcrumb) { const result = this.options.beforeBreadcrumb(bc, hint); - if (result === null) { - /** - * Discard breadcrumb - */ + /** + * false means discard + */ + if (result === false) { return; } - Object.assign(bc, result); + /** + * void/undefined/null — warn and keep original breadcrumb + */ + if (result === undefined || result === null) { + log('[Hawk] beforeBreadcrumb returned nothing, storing original breadcrumb.', 'warn'); + } else if (isValidBreadcrumb(result)) { + Object.assign(bc, result); + } else { + log( + '[Hawk] beforeBreadcrumb produced invalid breadcrumb (must be an object with numeric timestamp), storing original. ' + + `Received: ${Object.prototype.toString.call(result)}`, + 'warn' + ); + } } /** diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 7b78e1d..a0be03d 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -18,7 +18,7 @@ import type { HawkJavaScriptEvent } from './types'; import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; -import { validateUser, validateContext } from './utils/validation'; +import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -73,10 +73,12 @@ export default class Catcher { private context: EventContext | undefined; /** - * This Method allows developer to filter any data you don't want sending to Hawk - * If method returns false, event will not be sent + * This Method allows developer to filter any data you don't want sending to Hawk. + * - Return modified event — it will be sent instead of the original. + * - Return `false` — the event will be dropped entirely. + * - Return nothing (`void` / `undefined` / `null`) — the original event is sent as-is (a warning is logged). */ - private readonly beforeSend: undefined | ((event: HawkJavaScriptEvent) => HawkJavaScriptEvent | false); + private readonly beforeSend: undefined | ((event: HawkJavaScriptEvent) => HawkJavaScriptEvent | false | void); /** * Transport for dialog between Catcher and Collector @@ -436,14 +438,40 @@ export default class Catcher { * Filter sensitive data */ if (typeof this.beforeSend === 'function') { - const beforeSendResult = this.beforeSend(payload); + const result = this.beforeSend(payload); - if (beforeSendResult === false) { + /** + * Allow user to intentionally drop event by returning false + */ + if (result === false) { throw new EventRejectedError('Event rejected by beforeSend method.'); - } else if (typeof beforeSendResult === 'object' && beforeSendResult !== null) { - payload = beforeSendResult; - } else if (beforeSendResult !== undefined) { - log('beforeSend must return event object or false. Received: ' + typeof beforeSendResult, 'warn'); + } + + /** + * If user returned nothing (void/undefined/null) — warn and keep original payload + */ + if (result === undefined || result === null) { + log(`[Hawk] Invalid beforeSend value: (${String(result)}). It should return event or false. Event is sent without changes.`, 'warn'); + } else if (isValidEventPayload(result)) { + payload = result; + } else { + let received: string; + + try { + received = JSON.stringify(result); + } catch { + try { + received = String(result); + } catch { + received = Object.prototype.toString.call(result); + } + } + + log( + '[Hawk] beforeSend produced invalid payload (missing required fields), sending original. ' + + `Received: ${received}`, + 'warn' + ); } } diff --git a/packages/javascript/src/types/hawk-initial-settings.ts b/packages/javascript/src/types/hawk-initial-settings.ts index 51e80f0..9a32b2c 100644 --- a/packages/javascript/src/types/hawk-initial-settings.ts +++ b/packages/javascript/src/types/hawk-initial-settings.ts @@ -65,10 +65,11 @@ export interface HawkInitialSettings { /** * This Method allows you to filter any data you don't want sending to Hawk. - * - * Return `false` to prevent the event from being sent to Hawk. + * - Return modified event — it will be sent instead of the original. + * - Return `false` — the event will be dropped entirely. + * - Return nothing (`void` / `undefined` / `null`) — the original event is sent as-is (a warning is logged). */ - beforeSend?(event: HawkJavaScriptEvent): HawkJavaScriptEvent | false; + beforeSend?(event: HawkJavaScriptEvent): HawkJavaScriptEvent | false | void; /** * Disable Vue.js error handler diff --git a/packages/javascript/src/utils/validation.ts b/packages/javascript/src/utils/validation.ts index 468177f..e1cda05 100644 --- a/packages/javascript/src/utils/validation.ts +++ b/packages/javascript/src/utils/validation.ts @@ -1,5 +1,5 @@ import log from './log'; -import type { AffectedUser, EventContext } from '@hawk.so/types'; +import type { AffectedUser, Breadcrumb, EventContext, EventData, JavaScriptAddons } from '@hawk.so/types'; import Sanitizer from '../modules/sanitizer'; /** @@ -38,3 +38,52 @@ export function validateContext(context: EventContext | undefined): boolean { return true; } + +/** + * Checks if value is a plain object (not array, Date, etc.) + * @param value - value to check + */ +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +/** + * Runtime check for required EventData fields. + * Per @hawk.so/types EventData, `title` is the only non-optional field. + * Additionally validates `backtrace` shape if present (must be an array). + * @param payload - value to validate + */ +export function isValidEventPayload(payload: unknown): payload is EventData { + if (!isPlainObject(payload)) { + return false; + } + + if (typeof payload.title !== 'string' || payload.title.trim() === '') { + return false; + } + + if (payload.backtrace !== undefined && !Array.isArray(payload.backtrace)) { + return false; + } + + return true; +} + +/** + * Runtime check that value is a valid Breadcrumb-like object. + * Must be a plain object with a numeric timestamp. + * @param breadcrumb - value to validate + */ +export function isValidBreadcrumb(breadcrumb: unknown): breadcrumb is Breadcrumb { + if (typeof breadcrumb !== 'object' || breadcrumb === null || Array.isArray(breadcrumb)) { + return false; + } + + const record = breadcrumb as Record; + + if (record.timestamp !== undefined && typeof record.timestamp !== 'number') { + return false; + } + + return true; +} From da99f8a5b7908a058f80091f90113914ba2a1148 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:00:54 +0300 Subject: [PATCH 03/12] chore: tests --- packages/javascript/README.md | 6 +- .../javascript/example/before-send-tests.js | 131 ++++++++++++++++++ packages/javascript/example/hooks-tests.html | 113 +++++++++++++++ packages/javascript/example/index.html | 2 +- 4 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 packages/javascript/example/before-send-tests.js create mode 100644 packages/javascript/example/hooks-tests.html diff --git a/packages/javascript/README.md b/packages/javascript/README.md index c541694..8cd86fc 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -89,7 +89,7 @@ Initialization settings: | `disableVueErrorHandler` | boolean | optional | Do not initialize Vue errors handling | | `consoleTracking` | boolean | optional | Initialize console logs tracking | | `breadcrumbs` | false or BreadcrumbsOptions object | optional | Configure breadcrumbs tracking (see below) | -| `beforeSend` | function(event) => event | optional | This Method allows you to filter any data you don't want sending to Hawk | +| `beforeSend` | function(event) => event \| false \| void | optional | Filter data before sending. Return modified event, `false` to drop the event. | Other available [initial settings](types/hawk-initial-settings.d.ts) are described at the type definition. @@ -187,7 +187,7 @@ const hawk = new HawkCatcher({ beforeBreadcrumb: (breadcrumb, hint) => { // Filter or modify breadcrumbs before storing if (breadcrumb.category === 'fetch' && breadcrumb.data?.url?.includes('/sensitive')) { - return null; // Discard this breadcrumb + return false; // Discard this breadcrumb } return breadcrumb; } @@ -203,7 +203,7 @@ const hawk = new HawkCatcher({ | `trackFetch` | `boolean` | `true` | Automatically track `fetch()` and `XMLHttpRequest` calls as breadcrumbs. Captures request URL, method, status code, and response time. | | `trackNavigation` | `boolean` | `true` | Automatically track navigation events (History API: `pushState`, `replaceState`, `popstate`). Captures route changes. | | `trackClicks` | `boolean` | `true` | Automatically track UI click events. Captures element selector, coordinates, and other click metadata. | -| `beforeBreadcrumb` | `function` | `undefined` | Hook called before each breadcrumb is stored. Receives `(breadcrumb, hint)` and can return modified breadcrumb, `null` to discard it, or the original breadcrumb. Useful for filtering sensitive data or PII. | +| `beforeBreadcrumb` | `function` | `undefined` | Hook called before each breadcrumb is stored. Receives `(breadcrumb, hint)`. Return modified breadcrumb to keep it, `false` to discard. | ### Manual Breadcrumbs diff --git a/packages/javascript/example/before-send-tests.js b/packages/javascript/example/before-send-tests.js new file mode 100644 index 0000000..33a7cfe --- /dev/null +++ b/packages/javascript/example/before-send-tests.js @@ -0,0 +1,131 @@ +/** + * beforeSend tests + * + * Three core scenarios: + * 1. Return modified event → event is sent with changes + * 2. Return false → event is dropped, nothing is sent + * 3. Return nothing (undefined) → original event is sent, warning in console + */ +const bsOutput = document.getElementById('before-send-output'); + +document.getElementById('btn-bs-modify').addEventListener('click', () => { + bsOutput.textContent = ''; + + const hawk = new window.HawkCatcher({ + token: window.HAWK_TOKEN, + disableGlobalErrorsHandling: true, + beforeSend(event) { + event.context = { sanitized: true }; + + return event; + }, + }); + + hawk.send(new Error('beforeSend: modify test')); + + bsOutput.textContent = + 'Expected: event sent with context.sanitized = true\n' + + 'Check: DevTools → Network tab, outgoing WebSocket message should contain "sanitized"'; +}); + +document.getElementById('btn-bs-drop').addEventListener('click', () => { + bsOutput.textContent = ''; + + const hawk = new window.HawkCatcher({ + token: window.HAWK_TOKEN, + disableGlobalErrorsHandling: true, + beforeSend() { + return false; + }, + }); + + hawk.send(new Error('beforeSend: drop test')); + + bsOutput.textContent = + 'Expected: event NOT sent (dropped by beforeSend)\n' + + 'Check: DevTools → Network tab, no new WebSocket message should appear'; +}); + +document.getElementById('btn-bs-void').addEventListener('click', () => { + bsOutput.textContent = ''; + + const hawk = new window.HawkCatcher({ + token: window.HAWK_TOKEN, + disableGlobalErrorsHandling: true, + beforeSend() { + /* no return */ + }, + }); + + hawk.send(new Error('beforeSend: void test')); + + bsOutput.textContent = + 'Expected: original event sent as-is, warning logged\n' + + 'Check: DevTools → Console should show "[Hawk] Invalid beforeSend value: (undefined)..."'; +}); + +/** + * beforeBreadcrumb test + * + * BreadcrumbManager is a singleton — only the first init() takes effect. + * We test all three scenarios in a single run with one beforeBreadcrumb + * that handles each case based on the breadcrumb message. + * + * Messages: + * - "modify me" → returns modified breadcrumb (message prefixed with "MODIFIED:") + * - "drop me" → returns false (breadcrumb discarded) + * - "void me" → returns undefined (original stored, warning in console) + */ +const bbcOutput = document.getElementById('before-bc-output'); + +document.getElementById('btn-bbc-run').addEventListener('click', () => { + bbcOutput.textContent = 'Running...'; + + const hawk = new window.HawkCatcher({ + token: window.HAWK_TOKEN, + disableGlobalErrorsHandling: true, + breadcrumbs: { + trackFetch: false, + trackNavigation: false, + trackClicks: false, + beforeBreadcrumb(bc) { + if (bc.message === 'modify me') { + bc.message = 'MODIFIED: ' + bc.message; + + return bc; + } + + if (bc.message === 'drop me') { + return false; + } + + /* "void me" — no return */ + }, + }, + }); + + hawk.breadcrumbs.clear(); + + hawk.breadcrumbs.add({ type: 'logic', message: 'modify me', level: 'info' }); + hawk.breadcrumbs.add({ type: 'logic', message: 'drop me', level: 'info' }); + hawk.breadcrumbs.add({ type: 'logic', message: 'void me', level: 'info' }); + + const crumbs = hawk.breadcrumbs.get(); + const messages = crumbs.map((c) => c.message); + + const modifyPass = messages.includes('MODIFIED: modify me'); + const dropPass = !messages.includes('drop me'); + const voidPass = messages.includes('void me'); + + const lines = [ + `1. Modify: ${modifyPass ? 'PASS' : 'FAIL'} — stored "${messages.find((m) => m.startsWith('MODIFIED:')) || '(not found)'}"`, + `2. Drop: ${dropPass ? 'PASS' : 'FAIL'} — "drop me" ${dropPass ? 'not in list' : 'still present'}`, + `3. Void: ${voidPass ? 'PASS' : 'FAIL'} — "void me" ${voidPass ? 'stored as-is' : 'missing'}`, + '', + 'Console should show: [Hawk] beforeBreadcrumb returned nothing...', + '', + `All stored messages: ${JSON.stringify(messages)}`, + ]; + + bbcOutput.textContent = lines.join('\n'); +}); diff --git a/packages/javascript/example/hooks-tests.html b/packages/javascript/example/hooks-tests.html new file mode 100644 index 0000000..3229b89 --- /dev/null +++ b/packages/javascript/example/hooks-tests.html @@ -0,0 +1,113 @@ + + + + + Hawk — beforeSend / beforeBreadcrumb tests + + + + +
+

beforeSend & beforeBreadcrumb hook tests

+

Each button creates a fresh HawkCatcher with the specific hook. Open DevTools → Console & Network.

+
+ +
+

beforeSend

+
+ + + +
+
+
+ +
+

beforeBreadcrumb

+ +
+
+ + + + + diff --git a/packages/javascript/example/index.html b/packages/javascript/example/index.html index 91f6185..c8a050b 100644 --- a/packages/javascript/example/index.html +++ b/packages/javascript/example/index.html @@ -312,7 +312,7 @@

Test Vue integration: <test-component>

// beforeBreadcrumb: (breadcrumb, hint) => { // // Filter or modify breadcrumbs before storing // if (breadcrumb.category === 'fetch' && breadcrumb.data?.url?.includes('/sensitive')) { - // return null; // Discard this breadcrumb + // return false; // Discard this breadcrumb // } // return breadcrumb; // } From 14cbeabc7d153da24e3ad4889b65621cd829d282 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:46:06 +0300 Subject: [PATCH 04/12] chore: add tests --- .github/workflows/main.yml | 16 +- .../javascript/example/before-send-tests.js | 131 ---- packages/javascript/example/hooks-tests.html | 113 ---- packages/javascript/package.json | 6 +- packages/javascript/tests/before-send.test.ts | 192 ++++++ packages/javascript/tests/breadcrumbs.test.ts | 253 ++++++++ packages/javascript/tsconfig.test.json | 12 + packages/javascript/vitest.config.ts | 18 + yarn.lock | 610 +++++++++++++++++- 9 files changed, 1094 insertions(+), 257 deletions(-) delete mode 100644 packages/javascript/example/before-send-tests.js delete mode 100644 packages/javascript/example/hooks-tests.html create mode 100644 packages/javascript/tests/before-send.test.ts create mode 100644 packages/javascript/tests/breadcrumbs.test.ts create mode 100644 packages/javascript/tsconfig.test.json create mode 100644 packages/javascript/vitest.config.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 09f91e6..fade885 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,10 +16,24 @@ jobs: - run: yarn install - run: yarn lint-test - build: + test: runs-on: ubuntu-latest env: CI_JOB_NUMBER: 2 + steps: + - uses: actions/checkout@v1 + - name: Use Node.js from .nvmrc + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + - run: corepack enable + - run: yarn install + - run: yarn workspace @hawk.so/javascript test + + build: + runs-on: ubuntu-latest + env: + CI_JOB_NUMBER: 3 steps: - uses: actions/checkout@v1 with: diff --git a/packages/javascript/example/before-send-tests.js b/packages/javascript/example/before-send-tests.js deleted file mode 100644 index 33a7cfe..0000000 --- a/packages/javascript/example/before-send-tests.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * beforeSend tests - * - * Three core scenarios: - * 1. Return modified event → event is sent with changes - * 2. Return false → event is dropped, nothing is sent - * 3. Return nothing (undefined) → original event is sent, warning in console - */ -const bsOutput = document.getElementById('before-send-output'); - -document.getElementById('btn-bs-modify').addEventListener('click', () => { - bsOutput.textContent = ''; - - const hawk = new window.HawkCatcher({ - token: window.HAWK_TOKEN, - disableGlobalErrorsHandling: true, - beforeSend(event) { - event.context = { sanitized: true }; - - return event; - }, - }); - - hawk.send(new Error('beforeSend: modify test')); - - bsOutput.textContent = - 'Expected: event sent with context.sanitized = true\n' - + 'Check: DevTools → Network tab, outgoing WebSocket message should contain "sanitized"'; -}); - -document.getElementById('btn-bs-drop').addEventListener('click', () => { - bsOutput.textContent = ''; - - const hawk = new window.HawkCatcher({ - token: window.HAWK_TOKEN, - disableGlobalErrorsHandling: true, - beforeSend() { - return false; - }, - }); - - hawk.send(new Error('beforeSend: drop test')); - - bsOutput.textContent = - 'Expected: event NOT sent (dropped by beforeSend)\n' - + 'Check: DevTools → Network tab, no new WebSocket message should appear'; -}); - -document.getElementById('btn-bs-void').addEventListener('click', () => { - bsOutput.textContent = ''; - - const hawk = new window.HawkCatcher({ - token: window.HAWK_TOKEN, - disableGlobalErrorsHandling: true, - beforeSend() { - /* no return */ - }, - }); - - hawk.send(new Error('beforeSend: void test')); - - bsOutput.textContent = - 'Expected: original event sent as-is, warning logged\n' - + 'Check: DevTools → Console should show "[Hawk] Invalid beforeSend value: (undefined)..."'; -}); - -/** - * beforeBreadcrumb test - * - * BreadcrumbManager is a singleton — only the first init() takes effect. - * We test all three scenarios in a single run with one beforeBreadcrumb - * that handles each case based on the breadcrumb message. - * - * Messages: - * - "modify me" → returns modified breadcrumb (message prefixed with "MODIFIED:") - * - "drop me" → returns false (breadcrumb discarded) - * - "void me" → returns undefined (original stored, warning in console) - */ -const bbcOutput = document.getElementById('before-bc-output'); - -document.getElementById('btn-bbc-run').addEventListener('click', () => { - bbcOutput.textContent = 'Running...'; - - const hawk = new window.HawkCatcher({ - token: window.HAWK_TOKEN, - disableGlobalErrorsHandling: true, - breadcrumbs: { - trackFetch: false, - trackNavigation: false, - trackClicks: false, - beforeBreadcrumb(bc) { - if (bc.message === 'modify me') { - bc.message = 'MODIFIED: ' + bc.message; - - return bc; - } - - if (bc.message === 'drop me') { - return false; - } - - /* "void me" — no return */ - }, - }, - }); - - hawk.breadcrumbs.clear(); - - hawk.breadcrumbs.add({ type: 'logic', message: 'modify me', level: 'info' }); - hawk.breadcrumbs.add({ type: 'logic', message: 'drop me', level: 'info' }); - hawk.breadcrumbs.add({ type: 'logic', message: 'void me', level: 'info' }); - - const crumbs = hawk.breadcrumbs.get(); - const messages = crumbs.map((c) => c.message); - - const modifyPass = messages.includes('MODIFIED: modify me'); - const dropPass = !messages.includes('drop me'); - const voidPass = messages.includes('void me'); - - const lines = [ - `1. Modify: ${modifyPass ? 'PASS' : 'FAIL'} — stored "${messages.find((m) => m.startsWith('MODIFIED:')) || '(not found)'}"`, - `2. Drop: ${dropPass ? 'PASS' : 'FAIL'} — "drop me" ${dropPass ? 'not in list' : 'still present'}`, - `3. Void: ${voidPass ? 'PASS' : 'FAIL'} — "void me" ${voidPass ? 'stored as-is' : 'missing'}`, - '', - 'Console should show: [Hawk] beforeBreadcrumb returned nothing...', - '', - `All stored messages: ${JSON.stringify(messages)}`, - ]; - - bbcOutput.textContent = lines.join('\n'); -}); diff --git a/packages/javascript/example/hooks-tests.html b/packages/javascript/example/hooks-tests.html deleted file mode 100644 index 3229b89..0000000 --- a/packages/javascript/example/hooks-tests.html +++ /dev/null @@ -1,113 +0,0 @@ - - - - - Hawk — beforeSend / beforeBreadcrumb tests - - - - -
-

beforeSend & beforeBreadcrumb hook tests

-

Each button creates a fresh HawkCatcher with the specific hook. Open DevTools → Console & Network.

-
- -
-

beforeSend

-
- - - -
-
-
- -
-

beforeBreadcrumb

- -
-
- - - - - diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 80eee50..dfb7b94 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -20,6 +20,8 @@ "dev": "vite", "build": "vite build", "stats": "size-limit > stats.txt", + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint --fix \"src/**/*.{js,ts}\"" }, "repository": { @@ -40,9 +42,11 @@ "error-stack-parser": "^2.1.4" }, "devDependencies": { - "@hawk.so/types": "0.5.2", + "@hawk.so/types": "0.5.8", + "jsdom": "^28.0.0", "vite": "^7.3.1", "vite-plugin-dts": "^4.2.4", + "vitest": "^4.0.18", "vue": "^2" } } diff --git a/packages/javascript/tests/before-send.test.ts b/packages/javascript/tests/before-send.test.ts new file mode 100644 index 0000000..b59d456 --- /dev/null +++ b/packages/javascript/tests/before-send.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CatcherMessage } from '../src/types/catcher-message'; +import type { HawkJavaScriptEvent } from '../src/types/event'; +import Catcher from '../src/catcher'; + +/** + * Mock Socket — replaces WebSocket transport with a simple spy. + */ +const socketSendSpy = vi.fn<(msg: CatcherMessage) => Promise>().mockResolvedValue(undefined); + +vi.mock('../src/modules/socket', () => ({ + default: class FakeSocket { + send = socketSendSpy; + constructor() { /* noop */ } + }, +})); + +/** + * Valid base64-encoded integration token (Socket is mocked — nothing is sent) + */ +const TEST_TOKEN = 'eyJpbnRlZ3JhdGlvbklkIjoiOTU3MmQyOWQtNWJhZS00YmYyLTkwN2MtZDk5ZDg5MGIwOTVmIiwic2VjcmV0IjoiZTExODFiZWItMjdlMS00ZDViLWEwZmEtZmUwYTM1Mzg5OWMyIn0='; + +/** + * Flush microtask queue so fire-and-forget async calls complete + */ +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); + +/** + * Extract payload from the last socket.send() call + */ +function getSentPayload(): HawkJavaScriptEvent | null { + const calls = socketSendSpy.mock.calls; + + return calls.length ? calls[calls.length - 1][0].payload : null; +} + +/** + * Single Catcher instance. beforeSend routes by event.title: + * + * "pass-through" → return event as-is + * "modify" → mutate context, return event + * "drop" → return false + * "void" → no return (undefined) + * "null" → return null + * "invalid" → return true + * "empty-obj" → return {} + * "optional" → delete release, return event + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const hawk = new Catcher({ + token: TEST_TOKEN, + disableGlobalErrorsHandling: true, + beforeSend(event) { + switch (event.title) { + case 'drop': + return false; + + case 'modify': + event.context = { sanitized: true }; + + return event; + + case 'void': + return; + + case 'null': + return null as any; + + case 'invalid': + return true as any; + + case 'empty-obj': + return {} as any; + + case 'optional': + delete event.release; + + return event; + + default: + return event; + } + }, +}); + +describe('beforeSend', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + socketSendSpy.mockClear(); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('return event → event sent', async () => { + hawk.send(new Error('pass-through')); + await flush(); + + expect(socketSendSpy).toHaveBeenCalledOnce(); + + const payload = getSentPayload()!; + + expect(payload.title).toBe('pass-through'); + expect(payload.backtrace).toBeInstanceOf(Array); + }); + + it('return modified event → sent event with changes', async () => { + hawk.send(new Error('modify')); + await flush(); + + expect(socketSendSpy).toHaveBeenCalledOnce(); + + const payload = getSentPayload()!; + + expect(payload.title).toBe('modify'); + expect(payload.context).toEqual({ sanitized: true }); + }); + + it('return false → event not sent', async () => { + hawk.send(new Error('drop')); + await flush(); + + expect(socketSendSpy).not.toHaveBeenCalled(); + }); + + it('return undefined → sent original event + warn', async () => { + hawk.send(new Error('void')); + await flush(); + + expect(socketSendSpy).toHaveBeenCalledOnce(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Hawk] Invalid beforeSend value: (undefined)'), + expect.anything(), + expect.anything() + ); + expect(getSentPayload()!.title).toBe('void'); + }); + + it('return null → sent original event + warn', async () => { + hawk.send(new Error('null')); + await flush(); + + expect(socketSendSpy).toHaveBeenCalledOnce(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Hawk] Invalid beforeSend value: (null)'), + expect.anything(), + expect.anything() + ); + expect(getSentPayload()!.title).toBe('null'); + }); + + it('return true → sent original event + warn', async () => { + hawk.send(new Error('invalid')); + await flush(); + + expect(socketSendSpy).toHaveBeenCalledOnce(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Hawk] beforeSend produced invalid payload'), + expect.anything(), + expect.anything() + ); + expect(getSentPayload()!.title).toBe('invalid'); + }); + + it('return {} → sent original event + warn', async () => { + hawk.send(new Error('empty-obj')); + await flush(); + + expect(socketSendSpy).toHaveBeenCalledOnce(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Hawk] beforeSend produced invalid payload'), + expect.anything(), + expect.anything() + ); + expect(getSentPayload()!.title).toBe('empty-obj'); + }); + + it('delete optional fields → sent event without them', async () => { + hawk.send(new Error('optional')); + await flush(); + + expect(socketSendSpy).toHaveBeenCalledOnce(); + + const payload = getSentPayload()!; + + expect(payload.title).toBe('optional'); + expect(payload.release).toBeUndefined(); + }); +}); diff --git a/packages/javascript/tests/breadcrumbs.test.ts b/packages/javascript/tests/breadcrumbs.test.ts new file mode 100644 index 0000000..6109840 --- /dev/null +++ b/packages/javascript/tests/breadcrumbs.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import type { Breadcrumb } from '@hawk.so/types'; + +/** + * Reset singleton so each test group starts fresh + */ +function resetManager(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (BreadcrumbManager as any).instance = null; +} + +describe('BreadcrumbManager — basics', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + resetManager(); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('no breadcrumbs → empty array', () => { + const m = BreadcrumbManager.getInstance(); + + m.init(); + expect(m.getBreadcrumbs()).toEqual([]); + }); + + it('add breadcrumb → breadcrumb stored with timestamp', () => { + const m = BreadcrumbManager.getInstance(); + + m.init(); + m.addBreadcrumb({ type: 'default', message: 'test', level: 'info' }); + + const crumbs = m.getBreadcrumbs(); + + expect(crumbs).toHaveLength(1); + expect(crumbs[0].message).toBe('test'); + expect(crumbs[0].timestamp).toBeTypeOf('number'); + }); + + it('explicit timestamp → breadcrumb kept as-is', () => { + const m = BreadcrumbManager.getInstance(); + + m.init(); + m.addBreadcrumb({ type: 'default', message: 'test', level: 'info', timestamp: 12345 }); + + expect(m.getBreadcrumbs()[0].timestamp).toBe(12345); + }); + + it('overflow → oldest breadcrumbs dropped (FIFO) and stored in buffer', () => { + const m = BreadcrumbManager.getInstance(); + + m.init({ maxBreadcrumbs: 3 }); + + for (let i = 0; i < 5; i++) { + m.addBreadcrumb({ type: 'default', message: `msg-${i}`, level: 'info' }); + } + + const crumbs = m.getBreadcrumbs(); + + expect(crumbs).toHaveLength(3); + expect(crumbs[0].message).toBe('msg-2'); + expect(crumbs[2].message).toBe('msg-4'); + }); + + it('default limit → 15 breadcrumbs max stored in buffer', () => { + const m = BreadcrumbManager.getInstance(); + + m.init(); + + for (let i = 0; i < 20; i++) { + m.addBreadcrumb({ type: 'default', message: `msg-${i}`, level: 'info' }); + } + + expect(m.getBreadcrumbs()).toHaveLength(15); + }); + + it('clear → empty breadcrumbs buffer', () => { + const m = BreadcrumbManager.getInstance(); + + m.init(); + m.addBreadcrumb({ type: 'default', message: 'test', level: 'info' }); + m.clearBreadcrumbs(); + + expect(m.getBreadcrumbs()).toEqual([]); + }); + + it('get → returns copy of breadcrumbs, not internal array', () => { + const m = BreadcrumbManager.getInstance(); + + m.init(); + m.addBreadcrumb({ type: 'default', message: 'test', level: 'info' }); + + const first = m.getBreadcrumbs(); + const second = m.getBreadcrumbs(); + + expect(first).not.toBe(second); + expect(first).toEqual(second); + + first.push({ type: 'default', message: 'injected', level: 'info', timestamp: 0 } as Breadcrumb); + + expect(m.getBreadcrumbs()).toHaveLength(1); + }); + + it('second init → ignored and stored in buffer', () => { + const m = BreadcrumbManager.getInstance(); + + m.init({ maxBreadcrumbs: 5 }); + m.init({ maxBreadcrumbs: 100 }); + + for (let i = 0; i < 10; i++) { + m.addBreadcrumb({ type: 'default', message: `msg-${i}`, level: 'info' }); + } + + expect(m.getBreadcrumbs()).toHaveLength(5); + }); +}); + +/** + * Single manager with a branching beforeBreadcrumb. + * Routes by bc.message: + * + * "modify" → mutate message, return bc + * "drop" → return false + * "void" → no return (undefined) + * "null" → return null + * "invalid" → return true + * "bad-ts" → return object with non-numeric timestamp + * "secret" → return false (category filter) + * default → return bc as-is + */ +describe('beforeBreadcrumb', () => { + let manager: BreadcrumbManager; + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + manager.clearBreadcrumbs(); + warnSpy.mockRestore(); + }); + + /** + * Init once before all tests in this block + */ + resetManager(); + manager = BreadcrumbManager.getInstance(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + manager.init({ + beforeBreadcrumb(bc) { + switch (bc.message) { + case 'modify': + bc.message = 'MODIFIED'; + + return bc; + + case 'drop': + case 'secret': + return false; + + case 'void': + return; + + case 'null': + return null as any; + + case 'invalid': + return true as any; + + case 'bad-ts': + return { timestamp: 'nope', message: 'bad' } as any; + + default: + return bc; + } + }, + }); + + it('return modified breadcrumb → breadcrumb stored with changes', () => { + manager.addBreadcrumb({ type: 'default', message: 'modify', level: 'info' }); + + expect(manager.getBreadcrumbs()[0].message).toBe('MODIFIED'); + }); + + it('return false → breadcrumb not stored in buffer', () => { + manager.addBreadcrumb({ type: 'default', message: 'drop', level: 'info' }); + + expect(manager.getBreadcrumbs()).toHaveLength(0); + }); + + it('return undefined → original breadcrumb stored in buffer + warn', () => { + manager.addBreadcrumb({ type: 'default', message: 'void', level: 'info' }); + + expect(manager.getBreadcrumbs()[0].message).toBe('void'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Hawk] beforeBreadcrumb returned nothing'), + expect.anything(), + expect.anything() + ); + }); + + it('return null → original breadcrumb stored in buffer + warn', () => { + manager.addBreadcrumb({ type: 'default', message: 'null', level: 'info' }); + + expect(manager.getBreadcrumbs()[0].message).toBe('null'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Hawk] beforeBreadcrumb returned nothing'), + expect.anything(), + expect.anything() + ); + }); + + it('return true → original breadcrumb stored in buffer + warn', () => { + manager.addBreadcrumb({ type: 'default', message: 'invalid', level: 'info' }); + + expect(manager.getBreadcrumbs()[0].message).toBe('invalid'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Hawk] beforeBreadcrumb produced invalid breadcrumb'), + expect.anything(), + expect.anything() + ); + }); + + it('return bad timestamp → original breadcrumb stored + warn', () => { + manager.addBreadcrumb({ type: 'default', message: 'bad-ts', level: 'info' }); + + const bc = manager.getBreadcrumbs()[0]; + + expect(bc.message).toBe('bad-ts'); + expect(bc.timestamp).toBeTypeOf('number'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Hawk] beforeBreadcrumb produced invalid breadcrumb'), + expect.anything(), + expect.anything() + ); + }); + + it('return false by category → breadcrumb filtered out', () => { + manager.addBreadcrumb({ type: 'default', message: 'keep', level: 'info', category: 'public' }); + manager.addBreadcrumb({ type: 'default', message: 'secret', level: 'info', category: 'secret' }); + + const crumbs = manager.getBreadcrumbs(); + + expect(crumbs).toHaveLength(1); + expect(crumbs[0].message).toBe('keep'); + }); +}); diff --git a/packages/javascript/tsconfig.test.json b/packages/javascript/tsconfig.test.json new file mode 100644 index 0000000..166c447 --- /dev/null +++ b/packages/javascript/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": null, + "declaration": false + }, + "include": [ + "src/**/*", + "tests/**/*", + "vitest.config.ts" + ] +} diff --git a/packages/javascript/vitest.config.ts b/packages/javascript/vitest.config.ts new file mode 100644 index 0000000..47ad6a2 --- /dev/null +++ b/packages/javascript/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + include: ['tests/**/*.test.ts'], + }, + define: { + VERSION: JSON.stringify('0.0.0-test'), + }, + resolve: { + alias: { + '@/types': path.resolve(__dirname, './src/types'), + }, + }, +}); diff --git a/yarn.lock b/yarn.lock index 88bec46..317217e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,46 @@ __metadata: version: 8 cacheKey: 10c0 +"@acemir/cssom@npm:^0.9.31": + version: 0.9.31 + resolution: "@acemir/cssom@npm:0.9.31" + checksum: 10c0/cbfff98812642104ec3b37de1ad3a53f216ddc437e7b9276a23f46f2453844ea3c3f46c200bc4656a2f747fb26567560b3cc5183d549d119a758926551b5f566 + languageName: node + linkType: hard + +"@asamuzakjp/css-color@npm:^4.1.1": + version: 4.1.2 + resolution: "@asamuzakjp/css-color@npm:4.1.2" + dependencies: + "@csstools/css-calc": "npm:^3.0.0" + "@csstools/css-color-parser": "npm:^4.0.1" + "@csstools/css-parser-algorithms": "npm:^4.0.0" + "@csstools/css-tokenizer": "npm:^4.0.0" + lru-cache: "npm:^11.2.5" + checksum: 10c0/e432fdef978b37654a2ca31169a149b9173e708f70c82612acb123a36dbc7dd99913c48cbf2edd6fe3652cc627d4bc94bf87571463da0b788f15b973d4ce5b0f + languageName: node + linkType: hard + +"@asamuzakjp/dom-selector@npm:^6.7.6": + version: 6.7.8 + resolution: "@asamuzakjp/dom-selector@npm:6.7.8" + dependencies: + "@asamuzakjp/nwsapi": "npm:^2.3.9" + bidi-js: "npm:^1.0.3" + css-tree: "npm:^3.1.0" + is-potential-custom-element-name: "npm:^1.0.1" + lru-cache: "npm:^11.2.5" + checksum: 10c0/4274e5025e6e399654cb066f33a165f4dc65596a33612b0a345dce80666ad1f234b68b8b6db6f005fc76be365ea36e09bd7b08990442461f390c77b19cfea885 + languageName: node + linkType: hard + +"@asamuzakjp/nwsapi@npm:^2.3.9": + version: 2.3.9 + resolution: "@asamuzakjp/nwsapi@npm:2.3.9" + checksum: 10c0/869b81382e775499c96c45c6dbe0d0766a6da04bcf0abb79f5333535c4e19946851acaa43398f896e2ecc5a1de9cf3db7cf8c4b1afac1ee3d15e21584546d74d + languageName: node + linkType: hard + "@babel/code-frame@npm:7.12.11": version: 7.12.11 resolution: "@babel/code-frame@npm:7.12.11" @@ -61,6 +101,59 @@ __metadata: languageName: node linkType: hard +"@csstools/color-helpers@npm:^6.0.1": + version: 6.0.1 + resolution: "@csstools/color-helpers@npm:6.0.1" + checksum: 10c0/866844267d5aa5a02fe9d54f6db6fc18f6306595edb03664cc8ef15c99d3e6f3b42eb1a413c98bafa5b2dc0d8e0193da9b3bcc9d6a04f5de74cbd44935e74b3c + languageName: node + linkType: hard + +"@csstools/css-calc@npm:^3.0.0": + version: 3.0.1 + resolution: "@csstools/css-calc@npm:3.0.1" + peerDependencies: + "@csstools/css-parser-algorithms": ^4.0.0 + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10c0/ccda2b5d78ce6fecce58c59cc5747f5a8db6b5cae9b6d8a38444169d2d6591bf1f5e8bf147e5248e55e28a736e09be26a52621009089987786d69c209df39e0c + languageName: node + linkType: hard + +"@csstools/css-color-parser@npm:^4.0.1": + version: 4.0.1 + resolution: "@csstools/css-color-parser@npm:4.0.1" + dependencies: + "@csstools/color-helpers": "npm:^6.0.1" + "@csstools/css-calc": "npm:^3.0.0" + peerDependencies: + "@csstools/css-parser-algorithms": ^4.0.0 + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10c0/c46be5b9f5c0ef3cd25b47a71bd2a4d1c4856b123ecba4abe8eaa0688d3fc47f58fa67ea281d6b9efca4b9fdfa88fb045c51d0f9b8c612a56bd546d38260b138 + languageName: node + linkType: hard + +"@csstools/css-parser-algorithms@npm:^4.0.0": + version: 4.0.0 + resolution: "@csstools/css-parser-algorithms@npm:4.0.0" + peerDependencies: + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10c0/94558c2428d6ef0ddef542e86e0a8376aa1263a12a59770abb13ba50d7b83086822c75433f32aa2e7fef00555e1cc88292f9ca5bce79aed232bb3fed73b1528d + languageName: node + linkType: hard + +"@csstools/css-syntax-patches-for-csstree@npm:^1.0.21": + version: 1.0.27 + resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.0.27" + checksum: 10c0/ef3f2a639109758c0f3c04520465800ca4c830174bd6f7979795083877c82ace51ab8353857b06a818cb6c0de6d4dc88f84a86fc3b021be47f11a0f1c4b74e7e + languageName: node + linkType: hard + +"@csstools/css-tokenizer@npm:^4.0.0": + version: 4.0.0 + resolution: "@csstools/css-tokenizer@npm:4.0.0" + checksum: 10c0/669cf3d0f9c8e1ffdf8c9955ad8beba0c8cfe03197fe29a4fcbd9ee6f7a18856cfa42c62670021a75183d9ab37f5d14a866e6a9df753a6c07f59e36797a9ea9f + languageName: node + linkType: hard + "@es-joy/jsdoccomment@npm:~0.41.0": version: 0.41.0 resolution: "@es-joy/jsdoccomment@npm:0.41.0" @@ -471,14 +564,28 @@ __metadata: languageName: node linkType: hard +"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.6.0": + version: 1.13.0 + resolution: "@exodus/bytes@npm:1.13.0" + peerDependencies: + "@noble/hashes": ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + "@noble/hashes": + optional: true + checksum: 10c0/f854f2685544c9695f0f92583c65e35ed9f85dff1921ad17de6bf4d24d53075f31ce15a30402a3205958e63ec70211dbf525e97cdd874b157948814f9f2b208d + languageName: node + linkType: hard + "@hawk.so/javascript@npm:^3.0.0, @hawk.so/javascript@workspace:packages/javascript": version: 0.0.0-use.local resolution: "@hawk.so/javascript@workspace:packages/javascript" dependencies: - "@hawk.so/types": "npm:0.5.2" + "@hawk.so/types": "npm:0.5.8" error-stack-parser: "npm:^2.1.4" + jsdom: "npm:^28.0.0" vite: "npm:^7.3.1" vite-plugin-dts: "npm:^4.2.4" + vitest: "npm:^4.0.18" vue: "npm:^2" languageName: unknown linkType: soft @@ -506,12 +613,12 @@ __metadata: languageName: unknown linkType: soft -"@hawk.so/types@npm:0.5.2": - version: 0.5.2 - resolution: "@hawk.so/types@npm:0.5.2" +"@hawk.so/types@npm:0.5.8": + version: 0.5.8 + resolution: "@hawk.so/types@npm:0.5.8" dependencies: bson: "npm:^7.0.0" - checksum: 10c0/4a76aebc3bc4c87649d4a9991f51c81fac2ad457896ba19261afa66d8979739bed6108db322122400e99b3ca81cb4fbc3be7dee9b7c334d3cbfef51c2ec57719 + checksum: 10c0/95d45be58594b30aad097d25beb800e2d236cf869b3f57916a67bea841263cab96eecf4d678dd86f94ec46318759b9d566c42228a450c1d05689b3f326654d53 languageName: node linkType: hard @@ -1094,6 +1201,16 @@ __metadata: languageName: node linkType: hard +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f + languageName: node + linkType: hard + "@types/cookie@npm:^0.6.0": version: 0.6.0 resolution: "@types/cookie@npm:0.6.0" @@ -1101,6 +1218,13 @@ __metadata: languageName: node linkType: hard +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 + languageName: node + linkType: hard + "@types/estree@npm:1.0.8, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.5, @types/estree@npm:^1.0.6": version: 1.0.8 resolution: "@types/estree@npm:1.0.8" @@ -1252,6 +1376,86 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/expect@npm:4.0.18" + dependencies: + "@standard-schema/spec": "npm:^1.0.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.0.18" + "@vitest/utils": "npm:4.0.18" + chai: "npm:^6.2.1" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/123b0aa111682e82ec5289186df18037b1a1768700e468ee0f9879709aaa320cf790463c15c0d8ee10df92b402f4394baf5d27797e604d78e674766d87bcaadc + languageName: node + linkType: hard + +"@vitest/mocker@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/mocker@npm:4.0.18" + dependencies: + "@vitest/spy": "npm:4.0.18" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/fb0a257e7e167759d4ad228d53fa7bad2267586459c4a62188f2043dd7163b4b02e1e496dc3c227837f776e7d73d6c4343613e89e7da379d9d30de8260f1ee4b + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/pretty-format@npm:4.0.18" + dependencies: + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/0086b8c88eeca896d8e4b98fcdef452c8041a1b63eb9e85d3e0bcc96c8aa76d8e9e0b6990ebb0bb0a697c4ebab347e7735888b24f507dbff2742ddce7723fd94 + languageName: node + linkType: hard + +"@vitest/runner@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/runner@npm:4.0.18" + dependencies: + "@vitest/utils": "npm:4.0.18" + pathe: "npm:^2.0.3" + checksum: 10c0/fdb4afa411475133c05ba266c8092eaf1e56cbd5fb601f92ec6ccb9bab7ca52e06733ee8626599355cba4ee71cb3a8f28c84d3b69dc972e41047edc50229bc01 + languageName: node + linkType: hard + +"@vitest/snapshot@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/snapshot@npm:4.0.18" + dependencies: + "@vitest/pretty-format": "npm:4.0.18" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/d3bfefa558db9a69a66886ace6575eb96903a5ba59f4d9a5d0fecb4acc2bb8dbb443ef409f5ac1475f2e1add30bd1d71280f98912da35e89c75829df9e84ea43 + languageName: node + linkType: hard + +"@vitest/spy@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/spy@npm:4.0.18" + checksum: 10c0/6de537890b3994fcadb8e8d8ac05942320ae184f071ec395d978a5fba7fa928cbb0c5de85af86a1c165706c466e840de8779eaff8c93450c511c7abaeb9b8a4e + languageName: node + linkType: hard + +"@vitest/utils@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/utils@npm:4.0.18" + dependencies: + "@vitest/pretty-format": "npm:4.0.18" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/4a3c43c1421eb90f38576926496f6c80056167ba111e63f77cf118983902673737a1a38880b890d7c06ec0a12475024587344ee502b3c43093781533022f2aeb + languageName: node + linkType: hard + "@volar/language-core@npm:2.4.28, @volar/language-core@npm:~2.4.11": version: 2.4.28 resolution: "@volar/language-core@npm:2.4.28" @@ -1626,6 +1830,13 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + "astral-regex@npm:^2.0.0": version: 2.0.0 resolution: "astral-regex@npm:2.0.0" @@ -1670,6 +1881,15 @@ __metadata: languageName: node linkType: hard +"bidi-js@npm:^1.0.3": + version: 1.0.3 + resolution: "bidi-js@npm:1.0.3" + dependencies: + require-from-string: "npm:^2.0.2" + checksum: 10c0/fdddea4aa4120a34285486f2267526cd9298b6e8b773ad25e765d4f104b6d7437ab4ba542e6939e3ac834a7570bcf121ee2cf6d3ae7cd7082c4b5bedc8f271e1 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.12 resolution: "brace-expansion@npm:1.1.12" @@ -1786,6 +2006,13 @@ __metadata: languageName: node linkType: hard +"chai@npm:^6.2.1": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 + languageName: node + linkType: hard + "chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" @@ -1922,6 +2149,16 @@ __metadata: languageName: node linkType: hard +"css-tree@npm:^3.1.0": + version: 3.1.0 + resolution: "css-tree@npm:3.1.0" + dependencies: + mdn-data: "npm:2.12.2" + source-map-js: "npm:^1.0.1" + checksum: 10c0/b5715852c2f397c715ca00d56ec53fc83ea596295ae112eb1ba6a1bda3b31086380e596b1d8c4b980fe6da09e7d0fc99c64d5bb7313030dd0fba9c1415f30979 + languageName: node + linkType: hard + "cssesc@npm:^3.0.0": version: 3.0.0 resolution: "cssesc@npm:3.0.0" @@ -1931,6 +2168,18 @@ __metadata: languageName: node linkType: hard +"cssstyle@npm:^5.3.7": + version: 5.3.7 + resolution: "cssstyle@npm:5.3.7" + dependencies: + "@asamuzakjp/css-color": "npm:^4.1.1" + "@csstools/css-syntax-patches-for-csstree": "npm:^1.0.21" + css-tree: "npm:^3.1.0" + lru-cache: "npm:^11.2.4" + checksum: 10c0/9330f014f4209df06305264b92b8e963dfef636fdc2ae7d13f24ea7da6468aba1dc5eb13082621258bdd22cbd7fb7cb291894e188a3cdf660e8b79cd2c5e5e0e + languageName: node + linkType: hard + "csstype@npm:^3.1.0": version: 3.2.3 resolution: "csstype@npm:3.2.3" @@ -1938,6 +2187,16 @@ __metadata: languageName: node linkType: hard +"data-urls@npm:^7.0.0": + version: 7.0.0 + resolution: "data-urls@npm:7.0.0" + dependencies: + whatwg-mimetype: "npm:^5.0.0" + whatwg-url: "npm:^16.0.0" + checksum: 10c0/08d88ef50d8966a070ffdaa703e1e4b29f01bb2da364dfbc1612b1c2a4caa8045802c9532d81347b21781100132addb36a585071c8323b12cce97973961dee9f + languageName: node + linkType: hard + "data-view-buffer@npm:^1.0.2": version: 1.0.2 resolution: "data-view-buffer@npm:1.0.2" @@ -1999,6 +2258,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.6.0": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 10c0/07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -2113,6 +2379,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^6.0.0": + version: 6.0.1 + resolution: "entities@npm:6.0.1" + checksum: 10c0/ed836ddac5acb34341094eb495185d527bd70e8632b6c0d59548cbfa23defdbae70b96f9a405c82904efa421230b5b3fd2283752447d737beffd3f3e6ee74414 + languageName: node + linkType: hard + "entities@npm:^7.0.0": version: 7.0.1 resolution: "entities@npm:7.0.1" @@ -2219,6 +2492,13 @@ __metadata: languageName: node linkType: hard +"es-module-lexer@npm:^1.7.0": + version: 1.7.0 + resolution: "es-module-lexer@npm:1.7.0" + checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b + languageName: node + linkType: hard + "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": version: 1.1.1 resolution: "es-object-atoms@npm:1.1.1" @@ -2833,6 +3113,15 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + "esutils@npm:^2.0.2, esutils@npm:^2.0.3": version: 2.0.3 resolution: "esutils@npm:2.0.3" @@ -2840,6 +3129,13 @@ __metadata: languageName: node linkType: hard +"expect-type@npm:^1.2.2": + version: 1.3.0 + resolution: "expect-type@npm:1.3.0" + checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.3 resolution: "exponential-backoff@npm:3.1.3" @@ -3288,6 +3584,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^6.0.0": + version: 6.0.0 + resolution: "html-encoding-sniffer@npm:6.0.0" + dependencies: + "@exodus/bytes": "npm:^1.6.0" + checksum: 10c0/66dc3f6f5539cc3beb814fcbfae7eacf4ec38cf824d6e1425b72039b51a40f4456bd8541ba66f4f4fe09cdf885ab5cd5bae6ec6339d6895a930b2fdb83c53025 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" @@ -3295,7 +3600,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0": +"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" dependencies: @@ -3305,7 +3610,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^7.0.1": +"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.6": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" dependencies: @@ -3562,6 +3867,13 @@ __metadata: languageName: node linkType: hard +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9 + languageName: node + linkType: hard + "is-reference@npm:^3.0.3": version: 3.0.3 resolution: "is-reference@npm:3.0.3" @@ -3718,6 +4030,39 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^28.0.0": + version: 28.0.0 + resolution: "jsdom@npm:28.0.0" + dependencies: + "@acemir/cssom": "npm:^0.9.31" + "@asamuzakjp/dom-selector": "npm:^6.7.6" + "@exodus/bytes": "npm:^1.11.0" + cssstyle: "npm:^5.3.7" + data-urls: "npm:^7.0.0" + decimal.js: "npm:^10.6.0" + html-encoding-sniffer: "npm:^6.0.0" + http-proxy-agent: "npm:^7.0.2" + https-proxy-agent: "npm:^7.0.6" + is-potential-custom-element-name: "npm:^1.0.1" + parse5: "npm:^8.0.0" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^6.0.0" + undici: "npm:^7.20.0" + w3c-xmlserializer: "npm:^5.0.0" + webidl-conversions: "npm:^8.0.1" + whatwg-mimetype: "npm:^5.0.0" + whatwg-url: "npm:^16.0.0" + xml-name-validator: "npm:^5.0.0" + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/6aa2419506f912f40c5f1c6ca52c6dfdfde5970cfbaf105ebfc55ab975dda2d2492b6f8dc4c62b94e46501c4f77dfd2a60ea229ee67f924d59fe6c51bf653043 + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -3870,6 +4215,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.2.4, lru-cache@npm:^11.2.5": + version: 11.2.6 + resolution: "lru-cache@npm:11.2.6" + checksum: 10c0/73bbffb298760e71b2bfe8ebc16a311c6a60ceddbba919cfedfd8635c2d125fbfb5a39b71818200e67973b11f8d59c5a9e31d6f90722e340e90393663a66e5cd + languageName: node + linkType: hard + "lru-cache@npm:^6.0.0": version: 6.0.0 resolution: "lru-cache@npm:6.0.0" @@ -3914,6 +4266,13 @@ __metadata: languageName: node linkType: hard +"mdn-data@npm:2.12.2": + version: 2.12.2 + resolution: "mdn-data@npm:2.12.2" + checksum: 10c0/b22443b71d70f72ccc3c6ba1608035431a8fc18c3c8fc53523f06d20e05c2ac10f9b53092759a2ca85cf02f0d37036f310b581ce03e7b99ac74d388ef8152ade + languageName: node + linkType: hard + "merge2@npm:^1.3.0, merge2@npm:^1.4.1": version: 1.4.1 resolution: "merge2@npm:1.4.1" @@ -4241,7 +4600,7 @@ __metadata: languageName: node linkType: hard -"obug@npm:^2.1.0": +"obug@npm:^2.1.0, obug@npm:^2.1.1": version: 2.1.1 resolution: "obug@npm:2.1.1" checksum: 10c0/59dccd7de72a047e08f8649e94c1015ec72f94eefb6ddb57fb4812c4b425a813bc7e7cd30c9aca20db3c59abc3c85cc7a62bb656a968741d770f4e8e02bc2e78 @@ -4305,6 +4664,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^8.0.0": + version: 8.0.0 + resolution: "parse5@npm:8.0.0" + dependencies: + entities: "npm:^6.0.0" + checksum: 10c0/8279892dcd77b2f2229707f60eb039e303adf0288812b2a8fd5acf506a4d432da833c6c5d07a6554bef722c2367a81ef4a1f7e9336564379a7dba3e798bf16b3 + languageName: node + linkType: hard + "path-browserify@npm:^1.0.1": version: 1.0.1 resolution: "path-browserify@npm:1.0.1" @@ -4504,7 +4872,7 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0": +"punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 @@ -4808,6 +5176,15 @@ __metadata: languageName: node linkType: hard +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74 + languageName: node + linkType: hard + "semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -4945,6 +5322,13 @@ __metadata: languageName: node linkType: hard +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + "sirv@npm:^3.0.0": version: 3.0.2 resolution: "sirv@npm:3.0.2" @@ -5019,7 +5403,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.2.1": +"source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf @@ -5121,6 +5505,13 @@ __metadata: languageName: node linkType: hard +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + "stackframe@npm:^1.3.4": version: 1.3.4 resolution: "stackframe@npm:1.3.4" @@ -5128,6 +5519,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.10.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.1.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0" @@ -5293,6 +5691,13 @@ __metadata: languageName: node linkType: hard +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 + languageName: node + linkType: hard + "table@npm:^6.0.9": version: 6.9.0 resolution: "table@npm:6.9.0" @@ -5326,6 +5731,20 @@ __metadata: languageName: node linkType: hard +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^1.0.2": + version: 1.0.2 + resolution: "tinyexec@npm:1.0.2" + checksum: 10c0/1261a8e34c9b539a9aae3b7f0bb5372045ff28ee1eba035a2a059e532198fe1a182ec61ac60fa0b4a4129f0c4c4b1d2d57355b5cb9aa2d17ac9454ecace502ee + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.11, tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" @@ -5336,6 +5755,31 @@ __metadata: languageName: node linkType: hard +"tinyrainbow@npm:^3.0.3": + version: 3.0.3 + resolution: "tinyrainbow@npm:3.0.3" + checksum: 10c0/1e799d35cd23cabe02e22550985a3051dc88814a979be02dc632a159c393a998628eacfc558e4c746b3006606d54b00bcdea0c39301133956d10a27aa27e988c + languageName: node + linkType: hard + +"tldts-core@npm:^7.0.23": + version: 7.0.23 + resolution: "tldts-core@npm:7.0.23" + checksum: 10c0/b3d936a75b5f65614c356a58ef37563681c6224187dcce9f57aac76d92aae83b1a6fe6ab910f77472b35456bc145a8441cb3e572b4850be43cb4f3465e0610ec + languageName: node + linkType: hard + +"tldts@npm:^7.0.5": + version: 7.0.23 + resolution: "tldts@npm:7.0.23" + dependencies: + tldts-core: "npm:^7.0.23" + bin: + tldts: bin/cli.js + checksum: 10c0/492874770afaade724a10f8a97cce511d74bed07735c7f1100b7957254d7a5bbbc18becaf5cd049f9d7b0feeb945a64af64d5a300dfb851a4ac57cf3a5998afc + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -5352,6 +5796,24 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^6.0.0": + version: 6.0.0 + resolution: "tough-cookie@npm:6.0.0" + dependencies: + tldts: "npm:^7.0.5" + checksum: 10c0/7b17a461e9c2ac0d0bea13ab57b93b4346d0b8c00db174c963af1e46e4ea8d04148d2a55f2358fc857db0c0c65208a98e319d0c60693e32e0c559a9d9cf20cb5 + languageName: node + linkType: hard + +"tr46@npm:^6.0.0": + version: 6.0.0 + resolution: "tr46@npm:6.0.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10c0/83130df2f649228aa91c17754b66248030a3af34911d713b5ea417066fa338aa4bc8668d06bd98aa21a2210f43fc0a3db8b9099e7747fb5830e40e39a6a1058e + languageName: node + linkType: hard + "ts-api-utils@npm:^1.0.1": version: 1.4.3 resolution: "ts-api-utils@npm:1.4.3" @@ -5501,6 +5963,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:^7.20.0": + version: 7.21.0 + resolution: "undici@npm:7.21.0" + checksum: 10c0/ec5d7524125ac9c392a8a67b84fe4f9301936ca6b2afd7be12e73ab98726e55761dc9624ac10361d2f346e6fdaf66043381a62f7d0b565facd61bbfda975a586 + languageName: node + linkType: hard + "unique-filename@npm:^5.0.0": version: 5.0.0 resolution: "unique-filename@npm:5.0.0" @@ -5572,7 +6041,7 @@ __metadata: languageName: node linkType: hard -"vite@npm:^7.3.1": +"vite@npm:^6.0.0 || ^7.0.0, vite@npm:^7.3.1": version: 7.3.1 resolution: "vite@npm:7.3.1" dependencies: @@ -5639,6 +6108,65 @@ __metadata: languageName: node linkType: hard +"vitest@npm:^4.0.18": + version: 4.0.18 + resolution: "vitest@npm:4.0.18" + dependencies: + "@vitest/expect": "npm:4.0.18" + "@vitest/mocker": "npm:4.0.18" + "@vitest/pretty-format": "npm:4.0.18" + "@vitest/runner": "npm:4.0.18" + "@vitest/snapshot": "npm:4.0.18" + "@vitest/spy": "npm:4.0.18" + "@vitest/utils": "npm:4.0.18" + es-module-lexer: "npm:^1.7.0" + expect-type: "npm:^1.2.2" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^3.10.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.0.3" + vite: "npm:^6.0.0 || ^7.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.0.18 + "@vitest/browser-preview": 4.0.18 + "@vitest/browser-webdriverio": 4.0.18 + "@vitest/ui": 4.0.18 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/b913cd32032c95f29ff08c931f4b4c6fd6d2da498908d6770952c561a1b8d75c62499a1f04cadf82fb89cc0f9a33f29fb5dfdb899f6dbb27686a9d91571be5fa + languageName: node + linkType: hard + "vscode-uri@npm:^3.0.8": version: 3.1.0 resolution: "vscode-uri@npm:3.1.0" @@ -5656,6 +6184,40 @@ __metadata: languageName: node linkType: hard +"w3c-xmlserializer@npm:^5.0.0": + version: 5.0.0 + resolution: "w3c-xmlserializer@npm:5.0.0" + dependencies: + xml-name-validator: "npm:^5.0.0" + checksum: 10c0/8712774c1aeb62dec22928bf1cdfd11426c2c9383a1a63f2bcae18db87ca574165a0fbe96b312b73652149167ac6c7f4cf5409f2eb101d9c805efe0e4bae798b + languageName: node + linkType: hard + +"webidl-conversions@npm:^8.0.1": + version: 8.0.1 + resolution: "webidl-conversions@npm:8.0.1" + checksum: 10c0/3f6f327ca5fa0c065ed8ed0ef3b72f33623376e68f958e9b7bd0df49fdb0b908139ac2338d19fb45bd0e05595bda96cb6d1622222a8b413daa38a17aacc4dd46 + languageName: node + linkType: hard + +"whatwg-mimetype@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-mimetype@npm:5.0.0" + checksum: 10c0/eead164fe73a00dd82f817af6fc0bd22e9c273e1d55bf4bc6bdf2da7ad8127fca82ef00ea6a37892f5f5641f8e34128e09508f92126086baba126b9e0d57feb4 + languageName: node + linkType: hard + +"whatwg-url@npm:^16.0.0": + version: 16.0.0 + resolution: "whatwg-url@npm:16.0.0" + dependencies: + "@exodus/bytes": "npm:^1.11.0" + tr46: "npm:^6.0.0" + webidl-conversions: "npm:^8.0.1" + checksum: 10c0/9b8cb392be244d0e9687ffe543f9ea63b7aa051a98547ea362a38d182d89bfbd96e13e7ed3f40df1f7566bb7c3581f6c081ddea950cf5382532716ce33000ff4 + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" @@ -5739,6 +6301,18 @@ __metadata: languageName: node linkType: hard +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" @@ -5753,6 +6327,20 @@ __metadata: languageName: node linkType: hard +"xml-name-validator@npm:^5.0.0": + version: 5.0.0 + resolution: "xml-name-validator@npm:5.0.0" + checksum: 10c0/3fcf44e7b73fb18be917fdd4ccffff3639373c7cb83f8fc35df6001fecba7942f1dbead29d91ebb8315e2f2ff786b508f0c9dc0215b6353f9983c6b7d62cb1f5 + languageName: node + linkType: hard + +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 + languageName: node + linkType: hard + "yallist@npm:^4.0.0": version: 4.0.0 resolution: "yallist@npm:4.0.0" From e79e9c31d710cc6c59afd120f0615c067fafd94f Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:57:30 +0300 Subject: [PATCH 05/12] fix --- packages/javascript/src/addons/breadcrumbs.ts | 4 ++-- packages/javascript/src/catcher.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index f821d11..8dbaa95 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -249,12 +249,12 @@ export class BreadcrumbManager { * void/undefined/null — warn and keep original breadcrumb */ if (result === undefined || result === null) { - log('[Hawk] beforeBreadcrumb returned nothing, storing original breadcrumb.', 'warn'); + log('beforeBreadcrumb returned nothing, storing original breadcrumb.', 'warn'); } else if (isValidBreadcrumb(result)) { Object.assign(bc, result); } else { log( - '[Hawk] beforeBreadcrumb produced invalid breadcrumb (must be an object with numeric timestamp), storing original. ' + 'beforeBreadcrumb produced invalid breadcrumb (must be an object with numeric timestamp), storing original. ' + `Received: ${Object.prototype.toString.call(result)}`, 'warn' ); diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index a0be03d..aa5e959 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -451,7 +451,7 @@ export default class Catcher { * If user returned nothing (void/undefined/null) — warn and keep original payload */ if (result === undefined || result === null) { - log(`[Hawk] Invalid beforeSend value: (${String(result)}). It should return event or false. Event is sent without changes.`, 'warn'); + log(`Invalid beforeSend value: (${String(result)}). It should return event or false. Event is sent without changes.`, 'warn'); } else if (isValidEventPayload(result)) { payload = result; } else { @@ -468,7 +468,7 @@ export default class Catcher { } log( - '[Hawk] beforeSend produced invalid payload (missing required fields), sending original. ' + 'beforeSend produced invalid payload (missing required fields), sending original. ' + `Received: ${received}`, 'warn' ); From c1a81f2ec35bf2b47b816ecd9482a400cdcc01ad Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:09:12 +0300 Subject: [PATCH 06/12] fix --- packages/javascript/src/catcher.ts | 30 ++++++------------- .../src/types/hawk-initial-settings.ts | 2 +- packages/javascript/src/utils/validation.ts | 6 ++-- packages/javascript/tests/before-send.test.ts | 8 ++--- packages/javascript/tests/breadcrumbs.test.ts | 8 ++--- 5 files changed, 20 insertions(+), 34 deletions(-) diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index aa5e959..41f21aa 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -76,7 +76,7 @@ export default class Catcher { * This Method allows developer to filter any data you don't want sending to Hawk. * - Return modified event — it will be sent instead of the original. * - Return `false` — the event will be dropped entirely. - * - Return nothing (`void` / `undefined` / `null`) — the original event is sent as-is (a warning is logged). + * - Any other value is invalid — the original event is sent as-is (a warning is logged). */ private readonly beforeSend: undefined | ((event: HawkJavaScriptEvent) => HawkJavaScriptEvent | false | void); @@ -441,35 +441,23 @@ export default class Catcher { const result = this.beforeSend(payload); /** - * Allow user to intentionally drop event by returning false + * false → drop event */ if (result === false) { throw new EventRejectedError('Event rejected by beforeSend method.'); } /** - * If user returned nothing (void/undefined/null) — warn and keep original payload + * Valid event payload → use it */ - if (result === undefined || result === null) { - log(`Invalid beforeSend value: (${String(result)}). It should return event or false. Event is sent without changes.`, 'warn'); - } else if (isValidEventPayload(result)) { - payload = result; + if (isValidEventPayload(result)) { + payload = result as HawkJavaScriptEvent; } else { - let received: string; - - try { - received = JSON.stringify(result); - } catch { - try { - received = String(result); - } catch { - received = Object.prototype.toString.call(result); - } - } - + /** + * Anything else is invalid — warn and send original + */ log( - 'beforeSend produced invalid payload (missing required fields), sending original. ' - + `Received: ${received}`, + `Invalid beforeSend value: (${String(result)}). It should return event or false. Event is sent without changes.`, 'warn' ); } diff --git a/packages/javascript/src/types/hawk-initial-settings.ts b/packages/javascript/src/types/hawk-initial-settings.ts index 9a32b2c..75c15a8 100644 --- a/packages/javascript/src/types/hawk-initial-settings.ts +++ b/packages/javascript/src/types/hawk-initial-settings.ts @@ -67,7 +67,7 @@ export interface HawkInitialSettings { * This Method allows you to filter any data you don't want sending to Hawk. * - Return modified event — it will be sent instead of the original. * - Return `false` — the event will be dropped entirely. - * - Return nothing (`void` / `undefined` / `null`) — the original event is sent as-is (a warning is logged). + * - Any other value is invalid — the original event is sent as-is (a warning is logged). */ beforeSend?(event: HawkJavaScriptEvent): HawkJavaScriptEvent | false | void; diff --git a/packages/javascript/src/utils/validation.ts b/packages/javascript/src/utils/validation.ts index 68a0c1e..9dbc1ec 100644 --- a/packages/javascript/src/utils/validation.ts +++ b/packages/javascript/src/utils/validation.ts @@ -75,13 +75,11 @@ export function isValidEventPayload(payload: unknown): payload is EventData; - - if (record.timestamp !== undefined && typeof record.timestamp !== 'number') { + if (breadcrumb.timestamp !== undefined && typeof breadcrumb.timestamp !== 'number') { return false; } diff --git a/packages/javascript/tests/before-send.test.ts b/packages/javascript/tests/before-send.test.ts index b59d456..2c136e4 100644 --- a/packages/javascript/tests/before-send.test.ts +++ b/packages/javascript/tests/before-send.test.ts @@ -132,7 +132,7 @@ describe('beforeSend', () => { expect(socketSendSpy).toHaveBeenCalledOnce(); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Hawk] Invalid beforeSend value: (undefined)'), + expect.stringContaining('Invalid beforeSend value: (undefined)'), expect.anything(), expect.anything() ); @@ -145,7 +145,7 @@ describe('beforeSend', () => { expect(socketSendSpy).toHaveBeenCalledOnce(); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Hawk] Invalid beforeSend value: (null)'), + expect.stringContaining('Invalid beforeSend value: (null)'), expect.anything(), expect.anything() ); @@ -158,7 +158,7 @@ describe('beforeSend', () => { expect(socketSendSpy).toHaveBeenCalledOnce(); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Hawk] beforeSend produced invalid payload'), + expect.stringContaining('Invalid beforeSend value:'), expect.anything(), expect.anything() ); @@ -171,7 +171,7 @@ describe('beforeSend', () => { expect(socketSendSpy).toHaveBeenCalledOnce(); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Hawk] beforeSend produced invalid payload'), + expect.stringContaining('Invalid beforeSend value:'), expect.anything(), expect.anything() ); diff --git a/packages/javascript/tests/breadcrumbs.test.ts b/packages/javascript/tests/breadcrumbs.test.ts index 6109840..053c4b3 100644 --- a/packages/javascript/tests/breadcrumbs.test.ts +++ b/packages/javascript/tests/breadcrumbs.test.ts @@ -199,7 +199,7 @@ describe('beforeBreadcrumb', () => { expect(manager.getBreadcrumbs()[0].message).toBe('void'); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Hawk] beforeBreadcrumb returned nothing'), + expect.stringContaining('beforeBreadcrumb returned nothing'), expect.anything(), expect.anything() ); @@ -210,7 +210,7 @@ describe('beforeBreadcrumb', () => { expect(manager.getBreadcrumbs()[0].message).toBe('null'); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Hawk] beforeBreadcrumb returned nothing'), + expect.stringContaining('beforeBreadcrumb returned nothing'), expect.anything(), expect.anything() ); @@ -221,7 +221,7 @@ describe('beforeBreadcrumb', () => { expect(manager.getBreadcrumbs()[0].message).toBe('invalid'); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Hawk] beforeBreadcrumb produced invalid breadcrumb'), + expect.stringContaining('beforeBreadcrumb produced invalid breadcrumb'), expect.anything(), expect.anything() ); @@ -235,7 +235,7 @@ describe('beforeBreadcrumb', () => { expect(bc.message).toBe('bad-ts'); expect(bc.timestamp).toBeTypeOf('number'); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Hawk] beforeBreadcrumb produced invalid breadcrumb'), + expect.stringContaining('beforeBreadcrumb produced invalid breadcrumb'), expect.anything(), expect.anything() ); From 5505090e60e2cb2120ef401d10ed053d24523d1d Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:26:23 +0300 Subject: [PATCH 07/12] fix: lint --- .../javascript/src/addons/consoleCatcher.ts | 4 +- packages/javascript/src/catcher.ts | 2 +- packages/javascript/src/utils/selector.ts | 7 +- packages/javascript/src/utils/validation.ts | 7 +- packages/javascript/tests/before-send.test.ts | 91 +++--------------- packages/javascript/tests/breadcrumbs.test.ts | 94 ++++--------------- 6 files changed, 47 insertions(+), 158 deletions(-) diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index 16815ab..29519ea 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -150,7 +150,7 @@ export class ConsoleCatcher { * This ensures DevTools will navigate to the user's code, not the interceptor's code. * * @param errorStack - Full stack trace string from Error.stack - * @returns Object with userStack (full stack from user code) and fileLine (first frame for DevTools link) + * @returns {object} Object with userStack (full stack from user code) and fileLine (first frame for DevTools link) */ private extractUserStack(errorStack: string | undefined): { userStack: string; @@ -250,7 +250,7 @@ export class ConsoleCatcher { * 4. Store it in the buffer * 5. Forward the call to the native console (so output still appears in DevTools) * - * @param {...any} args + * @param args - console method arguments */ window.console[method] = (...args: unknown[]): void => { // Capture full stack trace and extract user code stack diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 41f21aa..bb67a39 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -10,7 +10,7 @@ import type { EventContext, JavaScriptAddons, VueIntegrationAddons, - Json, EncodedIntegrationToken, DecodedIntegrationToken, + Json, EncodedIntegrationToken, DecodedIntegrationToken } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from './types/integrations'; import { EventRejectedError } from './errors'; diff --git a/packages/javascript/src/utils/selector.ts b/packages/javascript/src/utils/selector.ts index d886884..f14aedb 100644 --- a/packages/javascript/src/utils/selector.ts +++ b/packages/javascript/src/utils/selector.ts @@ -4,13 +4,14 @@ * * @param element - HTML element to build selector from * @param maxDepth - Maximum recursion depth (default: 3) - * @returns CSS selector string (e.g., "div#myId.class1.class2" or ".some-parent button") + * @returns {string} CSS selector string (e.g., "div#myId.class1.class2" or ".some-parent button") */ export function buildElementSelector(element: HTMLElement, maxDepth: number = 3): string { let selector = element.tagName.toLowerCase(); if (element.id) { selector += `#${element.id}`; + return selector; } @@ -23,7 +24,9 @@ export function buildElementSelector(element: HTMLElement, maxDepth: number = 3) const classNameStr = String(element.className); if (classNameStr) { - selector += `.${classNameStr.split(' ').filter(Boolean).join('.')}`; + selector += `.${classNameStr.split(' ').filter(Boolean) + .join('.')}`; + return selector; } } diff --git a/packages/javascript/src/utils/validation.ts b/packages/javascript/src/utils/validation.ts index 9dbc1ec..40f7262 100644 --- a/packages/javascript/src/utils/validation.ts +++ b/packages/javascript/src/utils/validation.ts @@ -5,7 +5,7 @@ import Sanitizer from '../modules/sanitizer'; /** * Validates user data - basic security checks * - * @param user + * @param user - user data to validate */ export function validateUser(user: AffectedUser): boolean { if (!user || !Sanitizer.isObject(user)) { @@ -27,7 +27,7 @@ export function validateUser(user: AffectedUser): boolean { /** * Validates context data - basic security checks * - * @param context + * @param context - context data to validate */ export function validateContext(context: EventContext | undefined): boolean { if (context && !Sanitizer.isObject(context)) { @@ -41,6 +41,7 @@ export function validateContext(context: EventContext | undefined): boolean { /** * Checks if value is a plain object (not array, Date, etc.) + * * @param value - value to check */ function isPlainObject(value: unknown): value is Record { @@ -51,6 +52,7 @@ function isPlainObject(value: unknown): value is Record { * Runtime check for required EventData fields. * Per @hawk.so/types EventData, `title` is the only non-optional field. * Additionally validates `backtrace` shape if present (must be an array). + * * @param payload - value to validate */ export function isValidEventPayload(payload: unknown): payload is EventData { @@ -72,6 +74,7 @@ export function isValidEventPayload(payload: unknown): payload is EventData { warnSpy.mockRestore(); }); - it('return event → event sent', async () => { + it('should send event as-is when returned unchanged', async () => { hawk.send(new Error('pass-through')); await flush(); expect(socketSendSpy).toHaveBeenCalledOnce(); - - const payload = getSentPayload()!; - - expect(payload.title).toBe('pass-through'); - expect(payload.backtrace).toBeInstanceOf(Array); + expect(getSentPayload()!.title).toBe('pass-through'); }); - it('return modified event → sent event with changes', async () => { + it('should send modified event when hook mutates and returns it', async () => { hawk.send(new Error('modify')); await flush(); expect(socketSendSpy).toHaveBeenCalledOnce(); - - const payload = getSentPayload()!; - - expect(payload.title).toBe('modify'); - expect(payload.context).toEqual({ sanitized: true }); + expect(getSentPayload()!.context).toEqual({ sanitized: true }); }); - it('return false → event not sent', async () => { + it('should drop event when hook returns false', async () => { hawk.send(new Error('drop')); await flush(); expect(socketSendSpy).not.toHaveBeenCalled(); }); - it('return undefined → sent original event + warn', async () => { - hawk.send(new Error('void')); - await flush(); - - expect(socketSendSpy).toHaveBeenCalledOnce(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid beforeSend value: (undefined)'), - expect.anything(), - expect.anything() - ); - expect(getSentPayload()!.title).toBe('void'); - }); - - it('return null → sent original event + warn', async () => { - hawk.send(new Error('null')); - await flush(); - - expect(socketSendSpy).toHaveBeenCalledOnce(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid beforeSend value: (null)'), - expect.anything(), - expect.anything() - ); - expect(getSentPayload()!.title).toBe('null'); - }); - - it('return true → sent original event + warn', async () => { + it('should send original event and warn when hook returns invalid value', async () => { hawk.send(new Error('invalid')); await flush(); expect(socketSendSpy).toHaveBeenCalledOnce(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid beforeSend value:'), - expect.anything(), - expect.anything() - ); expect(getSentPayload()!.title).toBe('invalid'); - }); - - it('return {} → sent original event + warn', async () => { - hawk.send(new Error('empty-obj')); - await flush(); - - expect(socketSendSpy).toHaveBeenCalledOnce(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Invalid beforeSend value:'), expect.anything(), expect.anything() ); - expect(getSentPayload()!.title).toBe('empty-obj'); }); - it('delete optional fields → sent event without them', async () => { + it('should send event without deleted optional fields', async () => { hawk.send(new Error('optional')); await flush(); expect(socketSendSpy).toHaveBeenCalledOnce(); - - const payload = getSentPayload()!; - - expect(payload.title).toBe('optional'); - expect(payload.release).toBeUndefined(); + expect(getSentPayload()!.release).toBeUndefined(); }); }); diff --git a/packages/javascript/tests/breadcrumbs.test.ts b/packages/javascript/tests/breadcrumbs.test.ts index 053c4b3..031a3ef 100644 --- a/packages/javascript/tests/breadcrumbs.test.ts +++ b/packages/javascript/tests/breadcrumbs.test.ts @@ -2,15 +2,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { BreadcrumbManager } from '../src/addons/breadcrumbs'; import type { Breadcrumb } from '@hawk.so/types'; -/** - * Reset singleton so each test group starts fresh - */ function resetManager(): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any (BreadcrumbManager as any).instance = null; } -describe('BreadcrumbManager — basics', () => { +describe('BreadcrumbManager', () => { let warnSpy: ReturnType; beforeEach(() => { @@ -22,14 +19,14 @@ describe('BreadcrumbManager — basics', () => { warnSpy.mockRestore(); }); - it('no breadcrumbs → empty array', () => { + it('should return empty array when no breadcrumbs added', () => { const m = BreadcrumbManager.getInstance(); m.init(); expect(m.getBreadcrumbs()).toEqual([]); }); - it('add breadcrumb → breadcrumb stored with timestamp', () => { + it('should store breadcrumb with auto-generated timestamp', () => { const m = BreadcrumbManager.getInstance(); m.init(); @@ -42,7 +39,7 @@ describe('BreadcrumbManager — basics', () => { expect(crumbs[0].timestamp).toBeTypeOf('number'); }); - it('explicit timestamp → breadcrumb kept as-is', () => { + it('should keep explicit timestamp as-is', () => { const m = BreadcrumbManager.getInstance(); m.init(); @@ -51,7 +48,7 @@ describe('BreadcrumbManager — basics', () => { expect(m.getBreadcrumbs()[0].timestamp).toBe(12345); }); - it('overflow → oldest breadcrumbs dropped (FIFO) and stored in buffer', () => { + it('should drop oldest breadcrumbs when buffer overflows (FIFO)', () => { const m = BreadcrumbManager.getInstance(); m.init({ maxBreadcrumbs: 3 }); @@ -67,7 +64,7 @@ describe('BreadcrumbManager — basics', () => { expect(crumbs[2].message).toBe('msg-4'); }); - it('default limit → 15 breadcrumbs max stored in buffer', () => { + it('should store max 15 breadcrumbs by default', () => { const m = BreadcrumbManager.getInstance(); m.init(); @@ -79,7 +76,7 @@ describe('BreadcrumbManager — basics', () => { expect(m.getBreadcrumbs()).toHaveLength(15); }); - it('clear → empty breadcrumbs buffer', () => { + it('should empty buffer on clear', () => { const m = BreadcrumbManager.getInstance(); m.init(); @@ -89,7 +86,7 @@ describe('BreadcrumbManager — basics', () => { expect(m.getBreadcrumbs()).toEqual([]); }); - it('get → returns copy of breadcrumbs, not internal array', () => { + it('should return a copy, not the internal array', () => { const m = BreadcrumbManager.getInstance(); m.init(); @@ -106,7 +103,7 @@ describe('BreadcrumbManager — basics', () => { expect(m.getBreadcrumbs()).toHaveLength(1); }); - it('second init → ignored and stored in buffer', () => { + it('should ignore second init call', () => { const m = BreadcrumbManager.getInstance(); m.init({ maxBreadcrumbs: 5 }); @@ -124,14 +121,11 @@ describe('BreadcrumbManager — basics', () => { * Single manager with a branching beforeBreadcrumb. * Routes by bc.message: * - * "modify" → mutate message, return bc - * "drop" → return false - * "void" → no return (undefined) - * "null" → return null - * "invalid" → return true - * "bad-ts" → return object with non-numeric timestamp - * "secret" → return false (category filter) - * default → return bc as-is + * "modify" → mutate message, return bc + * "drop" → return false + * "invalid" → return undefined (no return) + * "secret" → return false (category filter) + * default → return bc as-is */ describe('beforeBreadcrumb', () => { let manager: BreadcrumbManager; @@ -146,9 +140,6 @@ describe('beforeBreadcrumb', () => { warnSpy.mockRestore(); }); - /** - * Init once before all tests in this block - */ resetManager(); manager = BreadcrumbManager.getInstance(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -164,17 +155,8 @@ describe('beforeBreadcrumb', () => { case 'secret': return false; - case 'void': - return; - - case 'null': - return null as any; - case 'invalid': - return true as any; - - case 'bad-ts': - return { timestamp: 'nope', message: 'bad' } as any; + return; default: return bc; @@ -182,66 +164,30 @@ describe('beforeBreadcrumb', () => { }, }); - it('return modified breadcrumb → breadcrumb stored with changes', () => { + it('should store modified breadcrumb when hook returns changed object', () => { manager.addBreadcrumb({ type: 'default', message: 'modify', level: 'info' }); expect(manager.getBreadcrumbs()[0].message).toBe('MODIFIED'); }); - it('return false → breadcrumb not stored in buffer', () => { + it('should discard breadcrumb when hook returns false', () => { manager.addBreadcrumb({ type: 'default', message: 'drop', level: 'info' }); expect(manager.getBreadcrumbs()).toHaveLength(0); }); - it('return undefined → original breadcrumb stored in buffer + warn', () => { - manager.addBreadcrumb({ type: 'default', message: 'void', level: 'info' }); - - expect(manager.getBreadcrumbs()[0].message).toBe('void'); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('beforeBreadcrumb returned nothing'), - expect.anything(), - expect.anything() - ); - }); - - it('return null → original breadcrumb stored in buffer + warn', () => { - manager.addBreadcrumb({ type: 'default', message: 'null', level: 'info' }); - - expect(manager.getBreadcrumbs()[0].message).toBe('null'); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('beforeBreadcrumb returned nothing'), - expect.anything(), - expect.anything() - ); - }); - - it('return true → original breadcrumb stored in buffer + warn', () => { + it('should store original breadcrumb and warn when hook returns invalid value', () => { manager.addBreadcrumb({ type: 'default', message: 'invalid', level: 'info' }); expect(manager.getBreadcrumbs()[0].message).toBe('invalid'); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('beforeBreadcrumb produced invalid breadcrumb'), - expect.anything(), - expect.anything() - ); - }); - - it('return bad timestamp → original breadcrumb stored + warn', () => { - manager.addBreadcrumb({ type: 'default', message: 'bad-ts', level: 'info' }); - - const bc = manager.getBreadcrumbs()[0]; - - expect(bc.message).toBe('bad-ts'); - expect(bc.timestamp).toBeTypeOf('number'); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('beforeBreadcrumb produced invalid breadcrumb'), + expect.stringContaining('beforeBreadcrumb returned nothing'), expect.anything(), expect.anything() ); }); - it('return false by category → breadcrumb filtered out', () => { + it('should filter breadcrumbs by category', () => { manager.addBreadcrumb({ type: 'default', message: 'keep', level: 'info', category: 'public' }); manager.addBreadcrumb({ type: 'default', message: 'secret', level: 'info', category: 'secret' }); From e02ffd52ec97254c2ff38642268308a6ef38886e Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:09:53 +0300 Subject: [PATCH 08/12] feat: enhance Catcher and BreadcrumbManager with custom transport and validation improvements --- packages/javascript/src/addons/breadcrumbs.ts | 14 +- packages/javascript/src/catcher.ts | 12 +- .../src/types/hawk-initial-settings.ts | 7 + packages/javascript/src/types/index.ts | 2 + packages/javascript/src/types/transport.ts | 8 + packages/javascript/src/utils/validation.ts | 6 +- packages/javascript/tests/before-send.test.ts | 178 ++++++++++-------- packages/javascript/tests/breadcrumbs.test.ts | 126 ++++++++----- 8 files changed, 221 insertions(+), 132 deletions(-) create mode 100644 packages/javascript/src/types/transport.ts diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index 8dbaa95..c84ce64 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -236,6 +236,7 @@ export class BreadcrumbManager { * Apply beforeBreadcrumb hook */ if (this.options.beforeBreadcrumb) { + const original = structuredClone(bc); const result = this.options.beforeBreadcrumb(bc, hint); /** @@ -246,18 +247,19 @@ export class BreadcrumbManager { } /** - * void/undefined/null — warn and keep original breadcrumb + * Valid breadcrumb → use it */ - if (result === undefined || result === null) { - log('beforeBreadcrumb returned nothing, storing original breadcrumb.', 'warn'); - } else if (isValidBreadcrumb(result)) { + if (isValidBreadcrumb(result)) { Object.assign(bc, result); } else { + /** + * Anything else is invalid — warn and restore original + */ log( - 'beforeBreadcrumb produced invalid breadcrumb (must be an object with numeric timestamp), storing original. ' - + `Received: ${Object.prototype.toString.call(result)}`, + `Invalid beforeBreadcrumb value. It should return breadcrumb or false. Breadcrumb is stored without changes.`, 'warn' ); + Object.assign(bc, original); } } diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index bb67a39..9bfff08 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -2,7 +2,7 @@ import Socket from './modules/socket'; import Sanitizer from './modules/sanitizer'; import log from './utils/log'; import StackParser from './modules/stackParser'; -import type { CatcherMessage, HawkInitialSettings, BreadcrumbsAPI } from './types'; +import type { CatcherMessage, HawkInitialSettings, BreadcrumbsAPI, Transport } from './types'; import { VueIntegration } from './integrations/vue'; import { id } from './utils/id'; import type { @@ -82,9 +82,9 @@ export default class Catcher { /** * Transport for dialog between Catcher and Collector - * (WebSocket decorator) + * (WebSocket decorator by default, or custom via settings.transport) */ - private readonly transport: Socket; + private readonly transport: Transport; /** * Module for parsing backtrace @@ -150,7 +150,7 @@ export default class Catcher { /** * Init transport */ - this.transport = new Socket({ + this.transport = settings.transport ?? new Socket({ collectorEndpoint: settings.collectorEndpoint || `wss://${this.getIntegrationId()}.k1.hawk.so:443/ws`, reconnectionAttempts: settings.reconnectionAttempts, reconnectionTimeout: settings.reconnectionTimeout, @@ -438,6 +438,7 @@ export default class Catcher { * Filter sensitive data */ if (typeof this.beforeSend === 'function') { + const original = structuredClone(payload); const result = this.beforeSend(payload); /** @@ -457,9 +458,10 @@ export default class Catcher { * Anything else is invalid — warn and send original */ log( - `Invalid beforeSend value: (${String(result)}). It should return event or false. Event is sent without changes.`, + `Invalid beforeSend value. It should return event or false. Event is sent without changes.`, 'warn' ); + payload = original; } } diff --git a/packages/javascript/src/types/hawk-initial-settings.ts b/packages/javascript/src/types/hawk-initial-settings.ts index 75c15a8..987cdf4 100644 --- a/packages/javascript/src/types/hawk-initial-settings.ts +++ b/packages/javascript/src/types/hawk-initial-settings.ts @@ -1,5 +1,6 @@ import type { EventContext, AffectedUser } from '@hawk.so/types'; import type { HawkJavaScriptEvent } from './event'; +import type { Transport } from './transport'; import type { BreadcrumbsOptions } from '../addons/breadcrumbs'; /** @@ -91,4 +92,10 @@ export interface HawkInitialSettings { * @default enabled with default options */ breadcrumbs?: false | BreadcrumbsOptions; + + /** + * Custom transport for sending events. + * If not provided, default WebSocket transport is used. + */ + transport?: Transport; } diff --git a/packages/javascript/src/types/index.ts b/packages/javascript/src/types/index.ts index ef0556e..f3354c3 100644 --- a/packages/javascript/src/types/index.ts +++ b/packages/javascript/src/types/index.ts @@ -1,5 +1,6 @@ import type { CatcherMessage } from './catcher-message'; import type { HawkInitialSettings } from './hawk-initial-settings'; +import type { Transport } from './transport'; import type { HawkJavaScriptEvent } from './event'; import type { VueIntegrationData, NuxtIntegrationData, NuxtIntegrationAddons, JavaScriptCatcherIntegrations } from './integrations'; import type { BreadcrumbsAPI } from './breadcrumbs-api'; @@ -7,6 +8,7 @@ import type { BreadcrumbsAPI } from './breadcrumbs-api'; export type { CatcherMessage, HawkInitialSettings, + Transport, HawkJavaScriptEvent, VueIntegrationData, NuxtIntegrationData, diff --git a/packages/javascript/src/types/transport.ts b/packages/javascript/src/types/transport.ts new file mode 100644 index 0000000..f2237dc --- /dev/null +++ b/packages/javascript/src/types/transport.ts @@ -0,0 +1,8 @@ +import type { CatcherMessage } from './catcher-message'; + +/** + * Transport interface — anything that can send a CatcherMessage + */ +export interface Transport { + send(message: CatcherMessage): Promise; +} diff --git a/packages/javascript/src/utils/validation.ts b/packages/javascript/src/utils/validation.ts index 40f7262..c0f9f66 100644 --- a/packages/javascript/src/utils/validation.ts +++ b/packages/javascript/src/utils/validation.ts @@ -73,7 +73,7 @@ export function isValidEventPayload(payload: unknown): payload is EventData Promise>().mockResolvedValue(undefined); - -vi.mock('../src/modules/socket', () => ({ - default: class FakeSocket { - send = socketSendSpy; - constructor() { /* noop */ } - }, -})); - -/** - * Valid base64-encoded integration token (Socket is mocked — nothing is sent) - */ const TEST_TOKEN = 'eyJpbnRlZ3JhdGlvbklkIjoiOTU3MmQyOWQtNWJhZS00YmYyLTkwN2MtZDk5ZDg5MGIwOTVmIiwic2VjcmV0IjoiZTExODFiZWItMjdlMS00ZDViLWEwZmEtZmUwYTM1Mzg5OWMyIn0='; - -/** - * Flush microtask queue so fire-and-forget async calls complete - */ const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); -/** - * Extract payload from the last socket.send() call - */ -function getSentPayload(): HawkJavaScriptEvent | null { - const calls = socketSendSpy.mock.calls; +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; } /** - * Single Catcher instance. beforeSend routes by event.title: - * - * "modify" → mutate context, return event - * "drop" → return false - * "invalid" → return undefined (no return) - * "optional"→ delete release, return event - * default → return event as-is + * Shared Catcher config — no breadcrumbs, no global handlers, fake transport */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const hawk = new Catcher({ - token: TEST_TOKEN, - disableGlobalErrorsHandling: true, - beforeSend(event) { - switch (event.title) { - case 'drop': - return false; - - case 'modify': - event.context = { sanitized: true }; - - return event; - - case 'invalid': - return; - - case 'optional': - delete event.release; - - return event; - - default: - return event; - } - }, -}); +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(() => { - socketSendSpy.mockClear(); warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -83,47 +44,116 @@ describe('beforeSend', () => { warnSpy.mockRestore(); }); - it('should send event as-is when returned unchanged', async () => { - hawk.send(new Error('pass-through')); + 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 flush(); - expect(socketSendSpy).toHaveBeenCalledOnce(); - expect(getSentPayload()!.title).toBe('pass-through'); + // Assert + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getSentPayload(sendSpy)!.title).toBe('hello'); }); - it('should send modified event when hook mutates and returns it', async () => { + 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 flush(); - expect(socketSendSpy).toHaveBeenCalledOnce(); - expect(getSentPayload()!.context).toEqual({ sanitized: true }); + // Assert + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getSentPayload(sendSpy)!.context).toEqual({ sanitized: true }); }); - it('should drop event when hook returns false', async () => { + 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 flush(); - expect(socketSendSpy).not.toHaveBeenCalled(); + // Assert + expect(sendSpy).not.toHaveBeenCalled(); }); - it('should send original event and warn when hook returns invalid value', async () => { + 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 flush(); - expect(socketSendSpy).toHaveBeenCalledOnce(); - expect(getSentPayload()!.title).toBe('invalid'); + // Assert + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getSentPayload(sendSpy)!.title).toBe('invalid'); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid beforeSend value:'), + 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 flush(); + + // 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 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 flush(); - expect(socketSendSpy).toHaveBeenCalledOnce(); - expect(getSentPayload()!.release).toBeUndefined(); + // Assert + expect(sendSpy).toHaveBeenCalledOnce(); + expect(getSentPayload(sendSpy)!.release).toBeUndefined(); }); }); diff --git a/packages/javascript/tests/breadcrumbs.test.ts b/packages/javascript/tests/breadcrumbs.test.ts index 031a3ef..f02d7dc 100644 --- a/packages/javascript/tests/breadcrumbs.test.ts +++ b/packages/javascript/tests/breadcrumbs.test.ts @@ -117,83 +117,117 @@ describe('BreadcrumbManager', () => { }); }); -/** - * Single manager with a branching beforeBreadcrumb. - * Routes by bc.message: - * - * "modify" → mutate message, return bc - * "drop" → return false - * "invalid" → return undefined (no return) - * "secret" → return false (category filter) - * default → return bc as-is - */ describe('beforeBreadcrumb', () => { - let manager: BreadcrumbManager; let warnSpy: ReturnType; beforeEach(() => { + resetManager(); warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { - manager.clearBreadcrumbs(); warnSpy.mockRestore(); }); - resetManager(); - manager = BreadcrumbManager.getInstance(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - manager.init({ - beforeBreadcrumb(bc) { - switch (bc.message) { - case 'modify': - bc.message = 'MODIFIED'; + it('should store modified breadcrumb when hook returns changed object', () => { + // Arrange + const m = BreadcrumbManager.getInstance(); - return bc; + m.init({ + beforeBreadcrumb(bc) { + bc.message = 'MODIFIED'; - case 'drop': - case 'secret': - return false; + return bc; + }, + }); - case 'invalid': - return; + // Act + m.addBreadcrumb({ type: 'default', message: 'original', level: 'info' }); - default: - return bc; - } - }, + // Assert + expect(m.getBreadcrumbs()[0].message).toBe('MODIFIED'); }); - it('should store modified breadcrumb when hook returns changed object', () => { - manager.addBreadcrumb({ type: 'default', message: 'modify', level: 'info' }); + it('should not store breadcrumb when hook returns false', () => { + // Arrange + const m = BreadcrumbManager.getInstance(); - expect(manager.getBreadcrumbs()[0].message).toBe('MODIFIED'); - }); + m.init({ + beforeBreadcrumb: () => false, + }); - it('should discard breadcrumb when hook returns false', () => { - manager.addBreadcrumb({ type: 'default', message: 'drop', level: 'info' }); + // Act + m.addBreadcrumb({ type: 'default', message: 'drop', level: 'info' }); - expect(manager.getBreadcrumbs()).toHaveLength(0); + // Assert + expect(m.getBreadcrumbs()).toHaveLength(0); }); - it('should store original breadcrumb and warn when hook returns invalid value', () => { - manager.addBreadcrumb({ type: 'default', message: 'invalid', level: 'info' }); + 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 store original breadcrumb and warn when hook returns $label', ({ value }) => { + // Arrange + const m = BreadcrumbManager.getInstance(); + + m.init({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + beforeBreadcrumb: () => value as any, + }); - expect(manager.getBreadcrumbs()[0].message).toBe('invalid'); + // Act + m.addBreadcrumb({ type: 'default', message: 'original', level: 'info' }); + + // Assert + expect(m.getBreadcrumbs()[0].message).toBe('original'); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('beforeBreadcrumb returned nothing'), + expect.stringContaining('Invalid beforeBreadcrumb value'), expect.anything(), expect.anything() ); }); - it('should filter breadcrumbs by category', () => { - manager.addBreadcrumb({ type: 'default', message: 'keep', level: 'info', category: 'public' }); - manager.addBreadcrumb({ type: 'default', message: 'secret', level: 'info', category: 'secret' }); + it('should store original breadcrumb and warn when hook deletes required field (message)', () => { + // Arrange + const m = BreadcrumbManager.getInstance(); - const crumbs = manager.getBreadcrumbs(); + m.init({ + beforeBreadcrumb(bc) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (bc as any).message; + + return bc; + }, + }); + + // Act + m.addBreadcrumb({ type: 'default', message: 'keep-me', level: 'info' }); + + // Assert — fallback to original, message preserved + expect(m.getBreadcrumbs()[0].message).toBe('keep-me'); + }); + + it('should filter breadcrumbs by category using hook', () => { + // Arrange + const m = BreadcrumbManager.getInstance(); + + m.init({ + beforeBreadcrumb(bc) { + return bc.category === 'secret' ? false : bc; + }, + }); + + // Act + m.addBreadcrumb({ type: 'default', message: 'public', level: 'info', category: 'public' }); + m.addBreadcrumb({ type: 'default', message: 'secret', level: 'info', category: 'secret' }); + + // Assert + const crumbs = m.getBreadcrumbs(); expect(crumbs).toHaveLength(1); - expect(crumbs[0].message).toBe('keep'); + expect(crumbs[0].message).toBe('public'); }); }); From 4279d770d40fd1216fae154cb35cb93cc4853f5c Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:27:43 +0300 Subject: [PATCH 09/12] refactor: improve handling of beforeSend and beforeBreadcrumb hooks by using clones for payload modifications --- packages/javascript/src/addons/breadcrumbs.ts | 17 ++++++++--------- packages/javascript/src/catcher.ts | 17 ++++++++--------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index c84ce64..1bb8356 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -236,30 +236,29 @@ export class BreadcrumbManager { * Apply beforeBreadcrumb hook */ if (this.options.beforeBreadcrumb) { - const original = structuredClone(bc); - const result = this.options.beforeBreadcrumb(bc, hint); + const breadcrumbClone = structuredClone(bc); + const modified = this.options.beforeBreadcrumb(breadcrumbClone, hint); /** * false means discard */ - if (result === false) { + if (modified === false) { return; } /** - * Valid breadcrumb → use it + * Valid breadcrumb → apply changes from hook */ - if (isValidBreadcrumb(result)) { - Object.assign(bc, result); + if (isValidBreadcrumb(modified)) { + Object.assign(bc, modified); } else { /** - * Anything else is invalid — warn and restore original + * Anything else is invalid — warn, bc stays untouched (hook only received a clone) */ log( - `Invalid beforeBreadcrumb value. It should return breadcrumb or false. Breadcrumb is stored without changes.`, + 'Invalid beforeBreadcrumb value. It should return breadcrumb or false. Breadcrumb is stored without changes.', 'warn' ); - Object.assign(bc, original); } } diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 9bfff08..ef2850c 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -438,30 +438,29 @@ export default class Catcher { * Filter sensitive data */ if (typeof this.beforeSend === 'function') { - const original = structuredClone(payload); - const result = this.beforeSend(payload); + const eventClone = structuredClone(payload); + const modified = this.beforeSend(eventClone); /** * false → drop event */ - if (result === false) { + if (modified === false) { throw new EventRejectedError('Event rejected by beforeSend method.'); } /** - * Valid event payload → use it + * Valid event payload → use it instead of original */ - if (isValidEventPayload(result)) { - payload = result as HawkJavaScriptEvent; + if (isValidEventPayload(modified)) { + payload = modified as HawkJavaScriptEvent; } else { /** - * Anything else is invalid — warn and send original + * Anything else is invalid — warn, payload stays untouched (hook only received a clone) */ log( - `Invalid beforeSend value. It should return event or false. Event is sent without changes.`, + 'Invalid beforeSend value. It should return event or false. Event is sent without changes.', 'warn' ); - payload = original; } } From 7e087f775056f752b998d1d87c4855e1a1040f45 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:32:42 +0300 Subject: [PATCH 10/12] refactor: streamline beforeSend and beforeBreadcrumb hook handling by renaming variables for clarity --- packages/javascript/src/addons/breadcrumbs.ts | 8 ++++---- packages/javascript/src/catcher.ts | 8 ++++---- packages/javascript/src/modules/socket.ts | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index 1bb8356..10a7a96 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -237,20 +237,20 @@ export class BreadcrumbManager { */ if (this.options.beforeBreadcrumb) { const breadcrumbClone = structuredClone(bc); - const modified = this.options.beforeBreadcrumb(breadcrumbClone, hint); + const result = this.options.beforeBreadcrumb(breadcrumbClone, hint); /** * false means discard */ - if (modified === false) { + if (result === false) { return; } /** * Valid breadcrumb → apply changes from hook */ - if (isValidBreadcrumb(modified)) { - Object.assign(bc, modified); + if (isValidBreadcrumb(result)) { + Object.assign(bc, result); } else { /** * Anything else is invalid — warn, bc stays untouched (hook only received a clone) diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index ef2850c..bfc7e52 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -439,20 +439,20 @@ export default class Catcher { */ if (typeof this.beforeSend === 'function') { const eventClone = structuredClone(payload); - const modified = this.beforeSend(eventClone); + const result = this.beforeSend(eventClone); /** * false → drop event */ - if (modified === false) { + if (result === false) { throw new EventRejectedError('Event rejected by beforeSend method.'); } /** * Valid event payload → use it instead of original */ - if (isValidEventPayload(modified)) { - payload = modified as HawkJavaScriptEvent; + if (isValidEventPayload(result)) { + payload = result as HawkJavaScriptEvent; } else { /** * Anything else is invalid — warn, payload stays untouched (hook only received a clone) diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index c07af1d..eb64b59 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -1,12 +1,13 @@ import log from '../utils/log'; import type { CatcherMessage } from '@/types'; +import type { Transport } from '../types/transport'; /** * Custom WebSocket wrapper class * * @copyright CodeX */ -export default class Socket { +export default class Socket implements Transport { /** * Socket connection endpoint */ From 93e0bfb0a2f6386072372bf9aa00ab01f046a6b5 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:40:21 +0300 Subject: [PATCH 11/12] refactor: rename variable for clarity in beforeSend hook payload handling --- packages/javascript/src/catcher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index bfc7e52..0646507 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -438,8 +438,8 @@ export default class Catcher { * Filter sensitive data */ if (typeof this.beforeSend === 'function') { - const eventClone = structuredClone(payload); - const result = this.beforeSend(eventClone); + const eventPayloadClone = structuredClone(payload); + const result = this.beforeSend(eventPayloadClone); /** * false → drop event From d2b081270028c594d1c5fb3b4a3086973628edfa Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:46:38 +0300 Subject: [PATCH 12/12] refactor: replace flush function with waitForAsync for improved clarity in beforeSend tests --- packages/javascript/tests/before-send.test.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/javascript/tests/before-send.test.ts b/packages/javascript/tests/before-send.test.ts index cdbc313..e9652a1 100644 --- a/packages/javascript/tests/before-send.test.ts +++ b/packages/javascript/tests/before-send.test.ts @@ -5,7 +5,10 @@ import type { HawkJavaScriptEvent } from '../src/types/event'; import Catcher from '../src/catcher'; const TEST_TOKEN = 'eyJpbnRlZ3JhdGlvbklkIjoiOTU3MmQyOWQtNWJhZS00YmYyLTkwN2MtZDk5ZDg5MGIwOTVmIiwic2VjcmV0IjoiZTExODFiZWItMjdlMS00ZDViLWEwZmEtZmUwYTM1Mzg5OWMyIn0='; -const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); +/** + * 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); @@ -51,7 +54,7 @@ describe('beforeSend', () => { // Act hawk.send(new Error('hello')); - await flush(); + await wait(); // Assert expect(sendSpy).toHaveBeenCalledOnce(); @@ -69,7 +72,7 @@ describe('beforeSend', () => { // Act hawk.send(new Error('modify')); - await flush(); + await wait(); // Assert expect(sendSpy).toHaveBeenCalledOnce(); @@ -83,7 +86,7 @@ describe('beforeSend', () => { // Act hawk.send(new Error('drop')); - await flush(); + await wait(); // Assert expect(sendSpy).not.toHaveBeenCalled(); @@ -103,7 +106,7 @@ describe('beforeSend', () => { // Act hawk.send(new Error('invalid')); - await flush(); + await wait(); // Assert expect(sendSpy).toHaveBeenCalledOnce(); @@ -127,7 +130,7 @@ describe('beforeSend', () => { // Act hawk.send(new Error('required-field')); - await flush(); + await wait(); // Assert — fallback to original payload, title preserved expect(sendSpy).toHaveBeenCalledOnce(); @@ -150,7 +153,7 @@ describe('beforeSend', () => { // Act hawk.send(new Error('optional')); - await flush(); + await wait(); // Assert expect(sendSpy).toHaveBeenCalledOnce();