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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/autocapture/click.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ describe('Click tracking', () => {
expect(mockTrack).not.toHaveBeenCalled();
});

it('tracks explicitly marked elements via data-analytics-track', () => {
document.body.innerHTML = `
<div data-analytics-track>Track this</div>
`;
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 = `<button>${longText}</button>`;
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/autocapture/click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -142,15 +142,15 @@ function getElementText(element: HTMLElement): string {
''
)
.trim()
.replace(/\s+/g, ' ');
.replaceAll(/\s+/g, ' ');

return text.slice(0, MAX_TEXT_LENGTH);
}

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;
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/autocapture/form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ describe('Form tracking', () => {
expect(mockTrack).not.toHaveBeenCalled();
});

it('respects data-analytics-ignore attribute values', () => {
document.body.innerHTML = `
<form data-analytics-ignore="true">
<input type="text" />
<button type="submit">Submit</button>
</form>
`;
submitForm(document.querySelector('form')!);
expect(mockTrack).not.toHaveBeenCalled();
});

it('does not track after detach', () => {
detachFormTracker();
document.body.innerHTML = `
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/autocapture/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/autocapture/page-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/autocapture/page-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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;
Expand Down
17 changes: 9 additions & 8 deletions packages/core/src/autocapture/scroll-depth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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(
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/autocapture/scroll-depth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
});
Expand All @@ -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;
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/autocapture/time-on-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand All @@ -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();
});

Expand All @@ -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);
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/autocapture/time-on-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -45,7 +45,7 @@ export function detachTimeOnPageTracker(): void {
}

if (_onPageHide !== null) {
window.removeEventListener('pagehide', _onPageHide);
globalThis.removeEventListener('pagehide', _onPageHide);
_onPageHide = null;
}

Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/config/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading