@tinytapanalytics/sdk
Version:
Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time
733 lines (565 loc) • 20.2 kB
text/typescript
/**
* Tests for AutoTracking feature
*/
import { AutoTracking } from '../AutoTracking';
import { TinyTapAnalyticsConfig } from '../../types/index';
describe('AutoTracking', () => {
let autoTracking: AutoTracking;
let mockConfig: TinyTapAnalyticsConfig;
let mockSdk: any;
let mockEventListeners: Map<string, EventListener>;
beforeEach(() => {
mockConfig = {
apiKey: 'test-key',
endpoint: 'https://api.test.com',
debug: false
};
mockSdk = {
track: jest.fn(),
trackClick: jest.fn()
};
mockEventListeners = new Map();
// Mock addEventListener to track listeners
jest.spyOn(document, 'addEventListener').mockImplementation((event, handler) => {
mockEventListeners.set(`document:${event}`, handler as EventListener);
});
jest.spyOn(window, 'addEventListener').mockImplementation((event, handler) => {
mockEventListeners.set(`window:${event}`, handler as EventListener);
});
jest.spyOn(document, 'removeEventListener').mockImplementation(() => {});
jest.spyOn(window, 'removeEventListener').mockImplementation(() => {});
// Mock IntersectionObserver
global.IntersectionObserver = jest.fn().mockImplementation((callback) => ({
observe: jest.fn(),
disconnect: jest.fn(),
unobserve: jest.fn(),
takeRecords: jest.fn(),
root: null,
rootMargin: '',
thresholds: []
}));
// Mock MutationObserver
global.MutationObserver = jest.fn().mockImplementation((callback) => ({
observe: jest.fn(),
disconnect: jest.fn(),
takeRecords: jest.fn()
}));
autoTracking = new AutoTracking(mockConfig, mockSdk);
});
afterEach(() => {
jest.restoreAllMocks();
mockEventListeners.clear();
});
describe('start/stop', () => {
it('should start auto-tracking', () => {
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
autoTracking.start();
const stats = autoTracking.getStats();
expect(stats.isActive).toBe(true);
expect(stats.listeners).toBeGreaterThan(0);
consoleLogSpy.mockRestore();
});
it('should not start if already active', () => {
autoTracking.start();
const initialStats = autoTracking.getStats();
autoTracking.start();
const afterStats = autoTracking.getStats();
expect(initialStats.listeners).toBe(afterStats.listeners);
});
it('should log in debug mode', () => {
const debugConfig = { ...mockConfig, debug: true };
const debugTracking = new AutoTracking(debugConfig, mockSdk);
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
debugTracking.start();
expect(consoleLogSpy).toHaveBeenCalledWith('TinyTapAnalytics: Auto-tracking started');
consoleLogSpy.mockRestore();
});
it('should stop auto-tracking', () => {
autoTracking.start();
autoTracking.stop();
const stats = autoTracking.getStats();
expect(stats.isActive).toBe(false);
expect(stats.listeners).toBe(0);
expect(stats.observers).toBe(0);
});
it('should not stop if already inactive', () => {
autoTracking.stop();
const stats = autoTracking.getStats();
expect(stats.isActive).toBe(false);
});
});
describe('click tracking', () => {
it('should track button clicks', () => {
autoTracking.start();
const button = document.createElement('button');
button.textContent = 'Click me';
document.body.appendChild(button);
const clickEvent = new MouseEvent('click', {
bubbles: true,
clientX: 100,
clientY: 200
});
Object.defineProperty(clickEvent, 'target', {
value: button,
enumerable: true
});
const clickHandler = mockEventListeners.get('document:click');
if (clickHandler) {
clickHandler(clickEvent);
}
expect(mockSdk.trackClick).toHaveBeenCalledWith(button, {
auto_tracked: true,
coordinates: { x: 100, y: 200 },
timestamp: expect.any(Number)
});
document.body.removeChild(button);
});
it('should track link clicks', () => {
autoTracking.start();
const link = document.createElement('a');
link.href = 'https://example.com';
document.body.appendChild(link);
const clickEvent = new MouseEvent('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', {
value: link,
enumerable: true
});
const clickHandler = mockEventListeners.get('document:click');
if (clickHandler) {
clickHandler(clickEvent);
}
expect(mockSdk.trackClick).toHaveBeenCalledWith(link, expect.any(Object));
document.body.removeChild(link);
});
it('should track elements with data-track attribute', () => {
autoTracking.start();
const div = document.createElement('div');
div.setAttribute('data-track', 'true');
document.body.appendChild(div);
const clickEvent = new MouseEvent('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', {
value: div,
enumerable: true
});
const clickHandler = mockEventListeners.get('document:click');
if (clickHandler) {
clickHandler(clickEvent);
}
expect(mockSdk.trackClick).toHaveBeenCalledWith(div, expect.any(Object));
document.body.removeChild(div);
});
it('should track elements with CTA classes', () => {
autoTracking.start();
const div = document.createElement('div');
div.className = 'btn-primary';
document.body.appendChild(div);
const clickEvent = new MouseEvent('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', {
value: div,
enumerable: true
});
const clickHandler = mockEventListeners.get('document:click');
if (clickHandler) {
clickHandler(clickEvent);
}
expect(mockSdk.trackClick).toHaveBeenCalledWith(div, expect.any(Object));
document.body.removeChild(div);
});
it('should not track regular divs without tracking attributes', () => {
autoTracking.start();
const div = document.createElement('div');
document.body.appendChild(div);
const clickEvent = new MouseEvent('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', {
value: div,
enumerable: true
});
const clickHandler = mockEventListeners.get('document:click');
if (clickHandler) {
clickHandler(clickEvent);
}
expect(mockSdk.trackClick).not.toHaveBeenCalled();
document.body.removeChild(div);
});
});
describe('form tracking', () => {
it('should track form submissions', () => {
autoTracking.start();
const form = document.createElement('form');
form.id = 'test-form';
form.action = '/submit';
form.method = 'post';
const input = document.createElement('input');
input.name = 'email';
input.value = 'test@example.com';
form.appendChild(input);
document.body.appendChild(form);
const submitEvent = new Event('submit', { bubbles: true });
Object.defineProperty(submitEvent, 'target', {
value: form,
enumerable: true
});
const submitHandler = mockEventListeners.get('document:submit');
if (submitHandler) {
submitHandler(submitEvent);
}
expect(mockSdk.track).toHaveBeenCalledWith('form_submit', {
form_id: 'test-form',
form_action: expect.stringContaining('/submit'),
form_method: 'post',
form_fields: ['email'],
auto_tracked: true
});
document.body.removeChild(form);
});
it('should track form field focus', () => {
autoTracking.start();
const form = document.createElement('form');
form.id = 'test-form';
const input = document.createElement('input');
input.name = 'username';
input.type = 'text';
input.id = 'username-field';
form.appendChild(input);
document.body.appendChild(form);
const focusEvent = new Event('focus', { bubbles: true });
Object.defineProperty(focusEvent, 'target', {
value: input,
enumerable: true
});
const focusHandler = mockEventListeners.get('document:focus');
if (focusHandler) {
focusHandler(focusEvent);
}
expect(mockSdk.track).toHaveBeenCalledWith('form_field_interaction', {
field_name: 'username',
field_type: 'text',
field_id: 'username-field',
form_id: 'test-form',
event_type: 'focus',
auto_tracked: true
});
document.body.removeChild(form);
});
it('should not track password fields', () => {
autoTracking.start();
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'password';
input.type = 'password';
form.appendChild(input);
document.body.appendChild(form);
const focusEvent = new Event('focus', { bubbles: true });
Object.defineProperty(focusEvent, 'target', {
value: input,
enumerable: true
});
const focusHandler = mockEventListeners.get('document:focus');
if (focusHandler) {
focusHandler(focusEvent);
}
expect(mockSdk.track).not.toHaveBeenCalled();
document.body.removeChild(form);
});
it('should not track fields with data-track-disable', () => {
autoTracking.start();
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'ssn';
input.type = 'text';
input.setAttribute('data-track-disable', 'true');
form.appendChild(input);
document.body.appendChild(form);
const focusEvent = new Event('focus', { bubbles: true });
Object.defineProperty(focusEvent, 'target', {
value: input,
enumerable: true
});
const focusHandler = mockEventListeners.get('document:focus');
if (focusHandler) {
focusHandler(focusEvent);
}
expect(mockSdk.track).not.toHaveBeenCalled();
document.body.removeChild(form);
});
});
describe('scroll tracking', () => {
beforeEach(() => {
// Mock scroll-related properties
Object.defineProperty(document.documentElement, 'scrollHeight', {
configurable: true,
value: 2000
});
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 800
});
Object.defineProperty(window, 'pageYOffset', {
configurable: true,
writable: true,
value: 0
});
});
it('should track scroll depth milestones', (done) => {
autoTracking.start();
const scrollHandler = mockEventListeners.get('window:scroll');
expect(scrollHandler).toBeDefined();
// Simulate scroll to 50%
Object.defineProperty(window, 'pageYOffset', {
configurable: true,
writable: true,
value: 600 // 50% of (2000 - 800)
});
if (scrollHandler) {
scrollHandler(new Event('scroll'));
}
// Wait for debounce timeout
setTimeout(() => {
// First milestone crossed is 25%
expect(mockSdk.track).toHaveBeenCalledWith('scroll_depth', {
depth: 25,
auto_tracked: true,
page_height: 2000,
viewport_height: 800
});
done();
}, 300);
}, 15000);
});
describe('element visibility tracking', () => {
it('should setup IntersectionObserver', () => {
autoTracking.start();
expect(global.IntersectionObserver).toHaveBeenCalled();
});
it('should track visible elements', () => {
autoTracking.start();
const observeCallback = (global.IntersectionObserver as jest.Mock).mock.calls[0][0];
const element = document.createElement('div');
element.id = 'test-element';
element.setAttribute('data-track-view', 'true');
const entry = {
isIntersecting: true,
target: element,
intersectionRatio: 0.75
};
observeCallback([entry]);
expect(mockSdk.track).toHaveBeenCalledWith('element_view', {
element: '#test-element',
element_type: 'div',
visibility_ratio: 0.75,
auto_tracked: true
});
});
it('should not track when IntersectionObserver is not available', () => {
const originalIntersectionObserver = global.IntersectionObserver;
delete (global as any).IntersectionObserver;
const noObserverTracking = new AutoTracking(mockConfig, mockSdk);
noObserverTracking.start();
const stats = noObserverTracking.getStats();
// Should still be active, just without observers
expect(stats.isActive).toBe(true);
expect(mockSdk.track).not.toHaveBeenCalledWith('element_view', expect.any(Object));
global.IntersectionObserver = originalIntersectionObserver;
});
});
describe('page engagement tracking', () => {
beforeEach(() => {
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false
});
});
it('should track page engagement on beforeunload', () => {
autoTracking.start();
const beforeUnloadHandler = mockEventListeners.get('window:beforeunload');
expect(beforeUnloadHandler).toBeDefined();
// Simulate time passing
jest.useFakeTimers();
jest.setSystemTime(Date.now() + 5000);
if (beforeUnloadHandler) {
beforeUnloadHandler(new Event('beforeunload'));
}
expect(mockSdk.track).toHaveBeenCalledWith('page_engagement', {
total_time: expect.any(Number),
page_url: window.location.href,
auto_tracked: true
});
jest.useRealTimers();
});
it('should handle visibility changes', () => {
autoTracking.start();
const visibilityHandler = mockEventListeners.get('document:visibilitychange');
expect(visibilityHandler).toBeDefined();
// Simulate hiding the page
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => true
});
if (visibilityHandler) {
visibilityHandler(new Event('visibilitychange'));
}
// Simulate showing the page again
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false
});
if (visibilityHandler) {
visibilityHandler(new Event('visibilitychange'));
}
// Should not crash
expect(true).toBe(true);
});
});
describe('error tracking', () => {
it('should track JavaScript errors', () => {
autoTracking.start();
const errorHandler = mockEventListeners.get('window:error');
expect(errorHandler).toBeDefined();
const errorEvent = new ErrorEvent('error', {
message: 'Test error',
filename: 'test.js',
lineno: 10,
colno: 5,
error: new Error('Test error')
});
if (errorHandler) {
errorHandler(errorEvent);
}
expect(mockSdk.track).toHaveBeenCalledWith('javascript_error', {
message: 'Test error',
filename: 'test.js',
lineno: 10,
colno: 5,
stack: expect.any(String),
auto_tracked: true
});
});
it('should track promise rejections', () => {
autoTracking.start();
const rejectionHandler = mockEventListeners.get('window:unhandledrejection');
expect(rejectionHandler).toBeDefined();
const rejectionEvent = {
reason: new Error('Promise rejected')
} as PromiseRejectionEvent;
if (rejectionHandler) {
rejectionHandler(rejectionEvent as any);
}
expect(mockSdk.track).toHaveBeenCalledWith('promise_rejection', {
reason: 'Error: Promise rejected',
auto_tracked: true
});
});
it('should handle promise rejection without reason', () => {
autoTracking.start();
const rejectionHandler = mockEventListeners.get('window:unhandledrejection');
const rejectionEvent = {
reason: null
} as PromiseRejectionEvent;
if (rejectionHandler) {
rejectionHandler(rejectionEvent as any);
}
expect(mockSdk.track).toHaveBeenCalledWith('promise_rejection', {
reason: 'Unknown',
auto_tracked: true
});
});
});
describe('dynamic element tracking', () => {
it('should setup MutationObserver', () => {
autoTracking.start();
expect(global.MutationObserver).toHaveBeenCalled();
});
it('should not setup MutationObserver when not available', () => {
const originalMutationObserver = global.MutationObserver;
const originalIntersectionObserver = global.IntersectionObserver;
delete (global as any).MutationObserver;
delete (global as any).IntersectionObserver;
const noObserverTracking = new AutoTracking(mockConfig, mockSdk);
noObserverTracking.start();
const stats = noObserverTracking.getStats();
// Should still work without MutationObserver
expect(stats.isActive).toBe(true);
global.MutationObserver = originalMutationObserver;
global.IntersectionObserver = originalIntersectionObserver;
});
});
describe('helper methods', () => {
it('should generate selector with ID', () => {
const element = document.createElement('div');
element.id = 'test-id';
autoTracking.start();
// Access via element visibility tracking
const observeCallback = (global.IntersectionObserver as jest.Mock).mock.calls[0][0];
const entry = {
isIntersecting: true,
target: element,
intersectionRatio: 1
};
observeCallback([entry]);
expect(mockSdk.track).toHaveBeenCalledWith('element_view', {
element: '#test-id',
element_type: 'div',
visibility_ratio: 1,
auto_tracked: true
});
});
it('should generate selector with classes', () => {
const element = document.createElement('div');
element.className = 'class1 class2';
autoTracking.start();
const observeCallback = (global.IntersectionObserver as jest.Mock).mock.calls[0][0];
const entry = {
isIntersecting: true,
target: element,
intersectionRatio: 1
};
observeCallback([entry]);
expect(mockSdk.track).toHaveBeenCalledWith('element_view', {
element: '.class1.class2',
element_type: 'div',
visibility_ratio: 1,
auto_tracked: true
});
});
it('should generate selector with nth-child', () => {
const parent = document.createElement('div');
const child1 = document.createElement('span');
const child2 = document.createElement('span');
parent.appendChild(child1);
parent.appendChild(child2);
document.body.appendChild(parent);
autoTracking.start();
const observeCallback = (global.IntersectionObserver as jest.Mock).mock.calls[0][0];
const entry = {
isIntersecting: true,
target: child2,
intersectionRatio: 1
};
observeCallback([entry]);
expect(mockSdk.track).toHaveBeenCalledWith('element_view', {
element: 'span:nth-child(2)',
element_type: 'span',
visibility_ratio: 1,
auto_tracked: true
});
document.body.removeChild(parent);
});
});
describe('getStats', () => {
it('should return correct stats when inactive', () => {
const stats = autoTracking.getStats();
expect(stats).toEqual({
isActive: false,
observers: 0,
listeners: 0
});
});
it('should return correct stats when active', () => {
autoTracking.start();
const stats = autoTracking.getStats();
expect(stats.isActive).toBe(true);
expect(stats.observers).toBeGreaterThan(0);
expect(stats.listeners).toBeGreaterThan(0);
});
});
});