From 75cfc1c1cf74804395a7a57dc87c4c7f7bf33b75 Mon Sep 17 00:00:00 2001 From: Ret2Hell Date: Sat, 25 Apr 2026 18:20:34 +0100 Subject: [PATCH 1/3] refactor validation --- eslint.config.mjs | 2 +- packages/core/src/autocapture/click.test.ts | 12 + packages/core/src/autocapture/click.ts | 8 +- packages/core/src/autocapture/form.test.ts | 11 + packages/core/src/autocapture/form.ts | 2 +- .../core/src/autocapture/page-view.test.ts | 3 + packages/core/src/autocapture/page-view.ts | 10 +- .../core/src/autocapture/scroll-depth.test.ts | 17 +- packages/core/src/autocapture/scroll-depth.ts | 9 +- .../core/src/autocapture/time-on-page.test.ts | 10 +- packages/core/src/autocapture/time-on-page.ts | 4 +- packages/core/src/config/device.ts | 6 +- packages/core/src/config/index.ts | 2 +- packages/core/src/config/validator.ts | 281 +++++++++--------- .../core/src/events/event-builder.test.ts | 5 + packages/core/src/events/event-builder.ts | 4 +- packages/core/src/tracker.test.ts | 1 + packages/core/src/tracker.ts | 3 +- packages/core/src/transport/queue.ts | 14 +- 19 files changed, 226 insertions(+), 178 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index b123854..3123148 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,7 +19,7 @@ export default [ 'error', { enforceBuildableLibDependency: true, - allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'], + allow: [String.raw`^.*/eslint(\.base)?\.config\.[cm]?[jt]s$`], depConstraints: [ { sourceTag: 'scope:adapter', diff --git a/packages/core/src/autocapture/click.test.ts b/packages/core/src/autocapture/click.test.ts index 3d2b2df..0bb76fe 100644 --- a/packages/core/src/autocapture/click.test.ts +++ b/packages/core/src/autocapture/click.test.ts @@ -84,6 +84,18 @@ describe('Click tracking', () => { expect(mockTrack).not.toHaveBeenCalled(); }); + it('tracks explicitly marked elements via data-analytics-track', () => { + document.body.innerHTML = ` +
Track this
+ `; + document.querySelector('div')!.click(); + + expect(mockTrack).toHaveBeenCalledWith( + 'click', + expect.objectContaining({ tag: 'div', text: 'Track this' }), + ); + }); + it('truncates long text to 50 characters', () => { const longText = 'A'.repeat(100); document.body.innerHTML = ``; diff --git a/packages/core/src/autocapture/click.ts b/packages/core/src/autocapture/click.ts index 3454625..d13587c 100644 --- a/packages/core/src/autocapture/click.ts +++ b/packages/core/src/autocapture/click.ts @@ -91,10 +91,10 @@ function findTrackableElement(target: HTMLElement): HTMLElement | null { if (!current) return null; // Explicitly ignored element - if (current.hasAttribute('data-analytics-ignore')) return null; + if (current.dataset.analyticsIgnore !== undefined) return null; // Explicitly tracked element - if (current.hasAttribute('data-analytics-track')) return current; + if (current.dataset.analyticsTrack !== undefined) return current; // Naturally trackable element if (TRACKED_TAGS.has(current.tagName)) return current; @@ -142,7 +142,7 @@ function getElementText(element: HTMLElement): string { '' ) .trim() - .replace(/\s+/g, ' '); + .replaceAll(/\s+/g, ' '); return text.slice(0, MAX_TEXT_LENGTH); } @@ -150,7 +150,7 @@ function getElementText(element: HTMLElement): string { function isExternalLink(href: string): boolean { try { const url = new URL(href); - return url.hostname !== window.location.hostname; + return url.hostname !== globalThis.location.hostname; } catch { return false; } diff --git a/packages/core/src/autocapture/form.test.ts b/packages/core/src/autocapture/form.test.ts index 29bcb83..8c08bf7 100644 --- a/packages/core/src/autocapture/form.test.ts +++ b/packages/core/src/autocapture/form.test.ts @@ -80,6 +80,17 @@ describe('Form tracking', () => { expect(mockTrack).not.toHaveBeenCalled(); }); + it('respects data-analytics-ignore attribute values', () => { + document.body.innerHTML = ` +
+ + +
+ `; + submitForm(document.querySelector('form')!); + expect(mockTrack).not.toHaveBeenCalled(); + }); + it('does not track after detach', () => { detachFormTracker(); document.body.innerHTML = ` diff --git a/packages/core/src/autocapture/form.ts b/packages/core/src/autocapture/form.ts index aaa4013..a0c0a8a 100644 --- a/packages/core/src/autocapture/form.ts +++ b/packages/core/src/autocapture/form.ts @@ -44,7 +44,7 @@ function handleSubmit(event: SubmitEvent): void { if (hasPasswordField(form)) return; // Never track forms explicitly opted out - if (form.hasAttribute('data-analytics-ignore')) return; + if (form.dataset.analyticsIgnore !== undefined) return; _track(EventTypes.FORM_SUBMIT, { eventName: 'form_submit', diff --git a/packages/core/src/autocapture/page-view.test.ts b/packages/core/src/autocapture/page-view.test.ts index 835399e..00d9140 100644 --- a/packages/core/src/autocapture/page-view.test.ts +++ b/packages/core/src/autocapture/page-view.test.ts @@ -46,6 +46,9 @@ function createBrowserHarness(initialHref = 'https://example.com/start') { }; vi.stubGlobal('window', windowObj); + vi.stubGlobal('location', location); + vi.stubGlobal('addEventListener', windowObj.addEventListener); + vi.stubGlobal('removeEventListener', windowObj.removeEventListener); vi.stubGlobal('history', historyObj); vi.stubGlobal('document', { title: 'Initial title' }); diff --git a/packages/core/src/autocapture/page-view.ts b/packages/core/src/autocapture/page-view.ts index 51a2edc..68b53c9 100644 --- a/packages/core/src/autocapture/page-view.ts +++ b/packages/core/src/autocapture/page-view.ts @@ -24,7 +24,7 @@ let _track: TrackFn | null = null; export function attachPageViewTracker(opts: PageViewTrackerOptions): void { _track = opts.track; - _lastUrl = window.location.href; + _lastUrl = globalThis.location.href; patchHistory(); attachPopStateListener(); @@ -81,12 +81,12 @@ function restoreHistory(): void { function attachPopStateListener(): void { _onPopState = () => handleNavigation('pop'); - window.addEventListener('popstate', _onPopState); + globalThis.addEventListener('popstate', _onPopState); } function detachPopStateListener(): void { if (_onPopState !== null) { - window.removeEventListener('popstate', _onPopState); + globalThis.removeEventListener('popstate', _onPopState); _onPopState = null; } } @@ -101,8 +101,8 @@ function handleNavigation(type: NavigationType): void { } function fireIfUrlChanged(type: NavigationType): void { - const currentUrl = window.location.href; - const currentPathname = window.location.pathname; + const currentUrl = globalThis.location.href; + const currentPathname = globalThis.location.pathname; const lastPathname = extractPathname(_lastUrl); if (currentUrl === _lastUrl) return; diff --git a/packages/core/src/autocapture/scroll-depth.test.ts b/packages/core/src/autocapture/scroll-depth.test.ts index 057d51c..a52d951 100644 --- a/packages/core/src/autocapture/scroll-depth.test.ts +++ b/packages/core/src/autocapture/scroll-depth.test.ts @@ -48,14 +48,14 @@ beforeEach(() => { mockTrack.mockClear(); document.body.innerHTML = ''; - // Create a proper constructor function that works with 'new' operator - class IntersectionObserverMock extends MockIntersectionObserver { - constructor(cb: IntersectionObserverCallback) { - super(cb); - // eslint-disable-next-line @typescript-eslint/no-this-alias - mockObserver = this; - } - } + // Return the shared mock instance while still supporting `new IntersectionObserver(...)`. + const IntersectionObserverMock = function ( + this: unknown, + cb: IntersectionObserverCallback, + ) { + mockObserver = new MockIntersectionObserver(cb); + return mockObserver; + } as unknown as typeof IntersectionObserver; vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); @@ -70,6 +70,7 @@ afterEach(() => { describe('Scroll depth tracking', () => { it('fires scroll event when sentinel becomes visible', () => { const sentinel = document.querySelector('[data-analytics-sentinel="25"]')!; + expect((sentinel as HTMLElement).dataset.analyticsSentinel).toBe('25'); mockObserver.trigger(sentinel); expect(mockTrack).toHaveBeenCalledWith( diff --git a/packages/core/src/autocapture/scroll-depth.ts b/packages/core/src/autocapture/scroll-depth.ts index e6d68a3..323602b 100644 --- a/packages/core/src/autocapture/scroll-depth.ts +++ b/packages/core/src/autocapture/scroll-depth.ts @@ -77,7 +77,7 @@ function placeSentinels(): void { MILESTONES.forEach((milestone) => { const sentinel = document.createElement('div'); - sentinel.setAttribute('data-analytics-sentinel', String(milestone)); + sentinel.dataset.analyticsSentinel = String(milestone); sentinel.setAttribute('aria-hidden', 'true'); // Position absolutely at the milestone depth @@ -101,7 +101,7 @@ function placeSentinels(): void { function removeSentinels(): void { _sentinels.forEach((sentinel) => { if (sentinel.parentNode) { - sentinel.parentNode.removeChild(sentinel); + sentinel.remove(); } _observer?.unobserve(sentinel); }); @@ -114,10 +114,11 @@ function handleIntersection(entries: IntersectionObserverEntry[]): void { entries.forEach((entry) => { if (!entry.isIntersecting) return; - const milestoneStr = entry.target.getAttribute('data-analytics-sentinel'); + const milestoneStr = (entry.target as HTMLElement).dataset + .analyticsSentinel; if (!milestoneStr) return; - const milestone = parseInt(milestoneStr, 10) as Milestone; + const milestone = Number.parseInt(milestoneStr, 10) as Milestone; // Fire each milestone exactly once per page view if (_milestonesReached.has(milestone)) return; diff --git a/packages/core/src/autocapture/time-on-page.test.ts b/packages/core/src/autocapture/time-on-page.test.ts index 3c6e399..6350346 100644 --- a/packages/core/src/autocapture/time-on-page.test.ts +++ b/packages/core/src/autocapture/time-on-page.test.ts @@ -29,7 +29,7 @@ afterEach(() => { describe('Time on page tracking', () => { it('fires page_leave event on page hide', () => { vi.advanceTimersByTime(5000); - window.dispatchEvent(new Event('pagehide')); + globalThis.dispatchEvent(new Event('pagehide')); expect(mockTrack).toHaveBeenCalledWith( 'page_leave', @@ -53,7 +53,7 @@ describe('Time on page tracking', () => { vi.advanceTimersByTime(2000); - window.dispatchEvent(new Event('pagehide')); + globalThis.dispatchEvent(new Event('pagehide')); const props = mockTrack.mock.calls[0][1]; // Should be ~5000ms not ~15000ms @@ -63,7 +63,7 @@ describe('Time on page tracking', () => { it('does not fire for very short visits under 1 second', () => { vi.advanceTimersByTime(500); - window.dispatchEvent(new Event('pagehide')); + globalThis.dispatchEvent(new Event('pagehide')); expect(mockTrack).not.toHaveBeenCalled(); }); @@ -73,7 +73,7 @@ describe('Time on page tracking', () => { // Fire pagehide to accumulate the 5000ms (calls accumulateActiveTime + flushActiveTime) // Note: flushActiveTime will fire the event and reset _activeTime back to 0 - window.dispatchEvent(new Event('pagehide')); + globalThis.dispatchEvent(new Event('pagehide')); // Should have fired for the previous page expect(mockTrack).toHaveBeenCalledTimes(1); @@ -88,7 +88,7 @@ describe('Time on page tracking', () => { // Fresh timer for new page vi.advanceTimersByTime(3000); - window.dispatchEvent(new Event('pagehide')); + globalThis.dispatchEvent(new Event('pagehide')); const props = mockTrack.mock.calls[0][1]; expect(props.duration).toBeLessThan(4000); diff --git a/packages/core/src/autocapture/time-on-page.ts b/packages/core/src/autocapture/time-on-page.ts index 7b6e859..5929bb5 100644 --- a/packages/core/src/autocapture/time-on-page.ts +++ b/packages/core/src/autocapture/time-on-page.ts @@ -32,7 +32,7 @@ export function attachTimeOnPageTracker(opts: TimeOnPageOptions): void { _onPageHide = handlePageHide; document.addEventListener('visibilitychange', _onVisibilityChange); - window.addEventListener('pagehide', _onPageHide); + globalThis.addEventListener('pagehide', _onPageHide); } export function detachTimeOnPageTracker(): void { @@ -45,7 +45,7 @@ export function detachTimeOnPageTracker(): void { } if (_onPageHide !== null) { - window.removeEventListener('pagehide', _onPageHide); + globalThis.removeEventListener('pagehide', _onPageHide); _onPageHide = null; } diff --git a/packages/core/src/config/device.ts b/packages/core/src/config/device.ts index 3dfcb77..8e1b48a 100644 --- a/packages/core/src/config/device.ts +++ b/packages/core/src/config/device.ts @@ -2,7 +2,11 @@ export type DeviceType = 'desktop' | 'mobile' | 'tablet' | 'unknown'; function readUserAgent(): string { try { - return typeof navigator !== 'undefined' ? navigator.userAgent || '' : ''; + if (typeof navigator === 'undefined') { + return ''; + } + + return navigator.userAgent || ''; } catch { return ''; } diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 95196de..c6fdae9 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -2,7 +2,7 @@ import { validate } from './validator'; import { merge } from './merger'; import type { AnalyticsConfig, ResolvedConfig } from './types'; -export type { AnalyticsConfig, ResolvedConfig }; +export type { AnalyticsConfig, ResolvedConfig } from './types'; /** * The single function the Tracker calls. diff --git a/packages/core/src/config/validator.ts b/packages/core/src/config/validator.ts index 75a9b44..e7f9608 100644 --- a/packages/core/src/config/validator.ts +++ b/packages/core/src/config/validator.ts @@ -1,8 +1,20 @@ import type { AnalyticsConfig } from './types'; export function validate(config: AnalyticsConfig): void { - // siteToken + validateSiteToken(config); + validateEndpoint(config); + validateFlushInterval(config); + validateMaxBatchSize(config); + validateMaxQueueSize(config); + validateBooleanField(config, 'compress'); + validateBooleanField(config, 'debug'); + validateBooleanField(config, 'requireConsent'); + validateBooleanField(config, 'respectDoNotTrack'); + validateAutocapture(config); + validateAdapters(config); +} +function validateSiteToken(config: AnalyticsConfig): void { if (!config.siteToken || typeof config.siteToken !== 'string') { throw new Error( '[AnalyticsSDK] siteToken is required.\n' + @@ -24,178 +36,171 @@ export function validate(config: AnalyticsConfig): void { 'The ingestion schema limits site_id to 64 characters.', ); } +} - // endpoint - - if (config.endpoint !== undefined) { - let parsed: URL; - - try { - parsed = new URL(config.endpoint); - } catch { - throw new Error( - `[AnalyticsSDK] endpoint "${config.endpoint}" is not a valid URL.\n` + - 'Example: endpoint: "https://ingest.your-instance.com/api/v1/events"', - ); - } - - const isLocalhost = - parsed.hostname === 'localhost' || - parsed.hostname === '127.0.0.1' || - parsed.hostname.endsWith('.local'); - - if (parsed.protocol !== 'https:' && !isLocalhost) { - throw new Error( - `[AnalyticsSDK] endpoint must use HTTPS. Received: "${config.endpoint}"\n` + - 'HTTP is only allowed for localhost during development.', - ); - } +function validateEndpoint(config: AnalyticsConfig): void { + if (config.endpoint === undefined) { + return; } - // flushInterval + let parsed: URL; - if (config.flushInterval !== undefined) { - if ( - typeof config.flushInterval !== 'number' || - !Number.isInteger(config.flushInterval) || - config.flushInterval < 500 - ) { - throw new Error( - `[AnalyticsSDK] flushInterval must be an integer >= 500ms.\n` + - `Received: ${config.flushInterval}`, - ); - } + try { + parsed = new URL(config.endpoint); + } catch { + throw new Error( + `[AnalyticsSDK] endpoint "${config.endpoint}" is not a valid URL.\n` + + 'Example: endpoint: "https://ingest.your-instance.com/api/v1/events"', + ); } - // maxBatchSize + const isLocalhost = + parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname.endsWith('.local'); - if (config.maxBatchSize !== undefined) { - if ( - typeof config.maxBatchSize !== 'number' || - !Number.isInteger(config.maxBatchSize) || - config.maxBatchSize < 1 || - config.maxBatchSize > 200 - ) { - throw new Error( - `[AnalyticsSDK] maxBatchSize must be an integer between 1 and 200.\n` + - `Received: ${config.maxBatchSize}`, - ); - } + if (parsed.protocol !== 'https:' && !isLocalhost) { + throw new Error( + `[AnalyticsSDK] endpoint must use HTTPS. Received: "${config.endpoint}"\n` + + 'HTTP is only allowed for localhost during development.', + ); } +} - // maxQueueSize - if (config.maxQueueSize !== undefined) { - if ( - typeof config.maxQueueSize !== 'number' || - !Number.isInteger(config.maxQueueSize) || - config.maxQueueSize < 1 || - config.maxQueueSize > 10000 - ) { - throw new Error( - `[AnalyticsSDK] maxQueueSize must be an integer between 1 and 10000.\n` + - `Received: ${config.maxQueueSize}`, - ); - } +function validateFlushInterval(config: AnalyticsConfig): void { + if (config.flushInterval === undefined) { + return; } - // compress - if (config.compress !== undefined && typeof config.compress !== 'boolean') { + if ( + typeof config.flushInterval !== 'number' || + !Number.isInteger(config.flushInterval) || + config.flushInterval < 500 + ) { throw new Error( - `[AnalyticsSDK] compress must be a boolean.\n` + - `Received: ${config.compress}`, + `[AnalyticsSDK] flushInterval must be an integer >= 500ms.\n` + + `Received: ${config.flushInterval}`, ); } +} - // debug - if (config.debug !== undefined && typeof config.debug !== 'boolean') { - throw new Error( - `[AnalyticsSDK] debug must be a boolean.\n` + `Received: ${config.debug}`, - ); +function validateMaxBatchSize(config: AnalyticsConfig): void { + if (config.maxBatchSize === undefined) { + return; } - // requireConsent if ( - config.requireConsent !== undefined && - typeof config.requireConsent !== 'boolean' + typeof config.maxBatchSize !== 'number' || + !Number.isInteger(config.maxBatchSize) || + config.maxBatchSize < 1 || + config.maxBatchSize > 200 ) { throw new Error( - `[AnalyticsSDK] requireConsent must be a boolean.\n` + - `Received: ${config.requireConsent}`, + `[AnalyticsSDK] maxBatchSize must be an integer between 1 and 200.\n` + + `Received: ${config.maxBatchSize}`, ); } - // respectDoNotTrack +} + +function validateMaxQueueSize(config: AnalyticsConfig): void { + if (config.maxQueueSize === undefined) { + return; + } + if ( - config.respectDoNotTrack !== undefined && - typeof config.respectDoNotTrack !== 'boolean' + typeof config.maxQueueSize !== 'number' || + !Number.isInteger(config.maxQueueSize) || + config.maxQueueSize < 1 || + config.maxQueueSize > 10000 ) { throw new Error( - `[AnalyticsSDK] respectDoNotTrack must be a boolean.\n` + - `Received: ${config.respectDoNotTrack}`, + `[AnalyticsSDK] maxQueueSize must be an integer between 1 and 10000.\n` + + `Received: ${config.maxQueueSize}`, ); } +} + +type BooleanConfigKey = + | 'compress' + | 'debug' + | 'requireConsent' + | 'respectDoNotTrack'; + +function validateBooleanField( + config: AnalyticsConfig, + field: BooleanConfigKey, +): void { + const value = config[field]; - // autocapture - if (config.autocapture !== undefined) { - if (typeof config.autocapture === 'boolean') { - // valid - } else { - if ( - typeof config.autocapture !== 'object' || - config.autocapture === null - ) { - throw new Error( - `[AnalyticsSDK] autocapture must be a boolean or an object.\n` + - `Received: ${config.autocapture}`, - ); - } - - for (const [key, value] of Object.entries(config.autocapture ?? {})) { - if (typeof value !== 'boolean') { - throw new Error( - `[AnalyticsSDK] autocapture.${key} must be a boolean.\n` + - `Received: ${value}`, - ); - } - } + if (value !== undefined && typeof value !== 'boolean') { + throw new Error( + `[AnalyticsSDK] ${field} must be a boolean.\n` + `Received: ${value}`, + ); + } +} + +function validateAutocapture(config: AnalyticsConfig): void { + if (config.autocapture === undefined) { + return; + } + + if (typeof config.autocapture === 'boolean') { + return; + } + + if (typeof config.autocapture !== 'object' || config.autocapture === null) { + throw new Error( + `[AnalyticsSDK] autocapture must be a boolean or an object.\n` + + `Received: ${config.autocapture}`, + ); + } + + for (const [key, value] of Object.entries(config.autocapture)) { + if (typeof value !== 'boolean') { + throw new TypeError( + `[AnalyticsSDK] autocapture.${key} must be a boolean.\n` + + `Received: ${value}`, + ); } } +} - if (config.adapters !== undefined) { - if (!Array.isArray(config.adapters)) { +function validateAdapters(config: AnalyticsConfig): void { + if (config.adapters === undefined) { + return; + } + + if (!Array.isArray(config.adapters)) { + throw new TypeError( + `[AnalyticsSDK] adapters must be an array.\n` + + `Received: ${config.adapters}`, + ); + } + + for (const [index, adapter] of config.adapters.entries()) { + if (typeof adapter !== 'object' || adapter === null) { + throw new Error( + `[AnalyticsSDK] adapters[${index}] must be an object.\n` + + `Received: ${adapter}`, + ); + } + + if (typeof adapter.name !== 'string' || adapter.name.trim().length === 0) { + throw new Error( + `[AnalyticsSDK] adapters[${index}].name must be a non-empty string.`, + ); + } + + if (typeof adapter.attach !== 'function') { throw new TypeError( - `[AnalyticsSDK] adapters must be an array.\n` + - `Received: ${config.adapters}`, + `[AnalyticsSDK] adapters[${index}].attach must be a function.`, ); } - for (const [index, adapter] of config.adapters.entries()) { - if (typeof adapter !== 'object' || adapter === null) { - throw new Error( - `[AnalyticsSDK] adapters[${index}] must be an object.\n` + - `Received: ${adapter}`, - ); - } - - if ( - typeof adapter.name !== 'string' || - adapter.name.trim().length === 0 - ) { - throw new Error( - `[AnalyticsSDK] adapters[${index}].name must be a non-empty string.`, - ); - } - - if (typeof adapter.attach !== 'function') { - throw new TypeError( - `[AnalyticsSDK] adapters[${index}].attach must be a function.`, - ); - } - - if (typeof adapter.detach !== 'function') { - throw new TypeError( - `[AnalyticsSDK] adapters[${index}].detach must be a function.`, - ); - } + if (typeof adapter.detach !== 'function') { + throw new TypeError( + `[AnalyticsSDK] adapters[${index}].detach must be a function.`, + ); } } } diff --git a/packages/core/src/events/event-builder.test.ts b/packages/core/src/events/event-builder.test.ts index d578662..8d070fc 100644 --- a/packages/core/src/events/event-builder.test.ts +++ b/packages/core/src/events/event-builder.test.ts @@ -13,6 +13,10 @@ import { import { EventTypes } from './event-types'; function stubBrowserGlobals() { + vi.stubGlobal('location', { + href: 'https://example.com/path?a=1&utm_source=news&utm_medium=email&utm_campaign=spring#cta', + }); + vi.stubGlobal('window', { location: { href: 'https://example.com/path?a=1&utm_source=news&utm_medium=email&utm_campaign=spring#cta', @@ -131,6 +135,7 @@ describe('events/event-builder', () => { }); it('falls back to safe defaults when browser globals are unavailable', () => { + vi.stubGlobal('location', undefined); vi.stubGlobal('window', undefined); vi.stubGlobal('document', undefined); vi.stubGlobal('navigator', undefined); diff --git a/packages/core/src/events/event-builder.ts b/packages/core/src/events/event-builder.ts index deef265..98e3546 100644 --- a/packages/core/src/events/event-builder.ts +++ b/packages/core/src/events/event-builder.ts @@ -93,7 +93,7 @@ function attachProperties( function readUrl(): string { try { - const url = new URL(window.location.href); + const url = new URL(globalThis.location.href); // clean up the url to return only canonical URL string url.search = ''; @@ -119,7 +119,7 @@ function readUtmParameters(): { utmCampaign?: string; } { try { - const params = new URL(window.location.href).searchParams; + const params = new URL(globalThis.location.href).searchParams; return { utmSource: params.get('utm_source') ?? undefined, diff --git a/packages/core/src/tracker.test.ts b/packages/core/src/tracker.test.ts index 36fc00d..79c72fc 100644 --- a/packages/core/src/tracker.test.ts +++ b/packages/core/src/tracker.test.ts @@ -14,6 +14,7 @@ function stubBrowserEnv({ referrer?: string; eventId?: string; }): void { + vi.stubGlobal('location', { href }); vi.stubGlobal('window', { location: { href }, screen: { width: 1920, height: 1080 }, diff --git a/packages/core/src/tracker.ts b/packages/core/src/tracker.ts index e936593..4ce9a26 100644 --- a/packages/core/src/tracker.ts +++ b/packages/core/src/tracker.ts @@ -5,9 +5,8 @@ import type { MediaAdapterInterface, MediaEventType, } from './types'; -import type { AnalyticsConfig } from './config/types'; +import type { AnalyticsConfig, ResolvedConfig } from './config/types'; import { validateAndMergeConfig } from './config'; -import type { ResolvedConfig } from './config/types'; import { configurePrivacy, isTrackingAllowed } from './privacy'; import { SessionManager } from './session'; import { diff --git a/packages/core/src/transport/queue.ts b/packages/core/src/transport/queue.ts index 6445d00..fe37e5e 100644 --- a/packages/core/src/transport/queue.ts +++ b/packages/core/src/transport/queue.ts @@ -142,21 +142,27 @@ export class EventQueue { } private attachUnloadListeners(): void { - if (typeof document === 'undefined' || typeof window === 'undefined') { + if ( + typeof document === 'undefined' || + typeof globalThis.window === 'undefined' + ) { return; } document.addEventListener('visibilitychange', this.onVisibilityChange); - window.addEventListener('pagehide', this.onPageHide); + globalThis.window.addEventListener('pagehide', this.onPageHide); } private detachUnloadListeners(): void { - if (typeof document === 'undefined' || typeof window === 'undefined') { + if ( + typeof document === 'undefined' || + typeof globalThis.window === 'undefined' + ) { return; } document.removeEventListener('visibilitychange', this.onVisibilityChange); - window.removeEventListener('pagehide', this.onPageHide); + globalThis.window.removeEventListener('pagehide', this.onPageHide); } private takeNextBatch(): QueuedEvent[] { From 08946fd9ccfd0729715f72f914ad50a7525d30e7 Mon Sep 17 00:00:00 2001 From: Ret2Hell Date: Sat, 25 Apr 2026 18:42:04 +0100 Subject: [PATCH 2/3] cleaner this way --- packages/core/src/transport/queue.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/transport/queue.ts b/packages/core/src/transport/queue.ts index fe37e5e..3d8bba2 100644 --- a/packages/core/src/transport/queue.ts +++ b/packages/core/src/transport/queue.ts @@ -105,7 +105,7 @@ export class EventQueue { // Page unload handling private handleVisibilityChange(): void { - if (typeof document === 'undefined') return; + if (globalThis.document === undefined) return; // Fire when tab becomes hidden — user switched tabs or minimised if (document.visibilityState === 'hidden') { @@ -143,8 +143,8 @@ export class EventQueue { private attachUnloadListeners(): void { if ( - typeof document === 'undefined' || - typeof globalThis.window === 'undefined' + globalThis.document === undefined || + globalThis.window === undefined ) { return; } @@ -155,8 +155,8 @@ export class EventQueue { private detachUnloadListeners(): void { if ( - typeof document === 'undefined' || - typeof globalThis.window === 'undefined' + globalThis.document === undefined || + globalThis.window === undefined ) { return; } From f69a9a4dbb0a9dea9bc15ab131789de5d4e6cb80 Mon Sep 17 00:00:00 2001 From: Ret2Hell Date: Sat, 25 Apr 2026 18:44:06 +0100 Subject: [PATCH 3/3] formatting.. --- packages/core/src/transport/queue.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/core/src/transport/queue.ts b/packages/core/src/transport/queue.ts index 3d8bba2..fee5ebb 100644 --- a/packages/core/src/transport/queue.ts +++ b/packages/core/src/transport/queue.ts @@ -142,10 +142,7 @@ export class EventQueue { } private attachUnloadListeners(): void { - if ( - globalThis.document === undefined || - globalThis.window === undefined - ) { + if (globalThis.document === undefined || globalThis.window === undefined) { return; } @@ -154,10 +151,7 @@ export class EventQueue { } private detachUnloadListeners(): void { - if ( - globalThis.document === undefined || - globalThis.window === undefined - ) { + if (globalThis.document === undefined || globalThis.window === undefined) { return; }