UNPKG

@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
/** * 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); }); }); });