@tinytapanalytics/sdk
Version:
Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time
1,546 lines (1,233 loc) • 64.8 kB
text/typescript
/**
* Tests for MicroInteractionTracking feature
*/
import { MicroInteractionTracking } from '../MicroInteractionTracking';
import { TinyTapAnalyticsConfig } from '../../types/index';
describe('MicroInteractionTracking', () => {
let tracking: MicroInteractionTracking;
let mockConfig: TinyTapAnalyticsConfig;
let mockSdk: any;
let mockEventListeners: Map<string, EventListener>;
let fetchMock: jest.SpyInstance;
beforeEach(() => {
// Use fake timers for batching and debouncing tests
jest.useFakeTimers();
mockConfig = {
apiKey: 'test-key',
endpoint: 'https://api.test.com',
debug: false
};
mockSdk = {
track: jest.fn().mockResolvedValue(undefined),
getSessionId: jest.fn().mockReturnValue('test-session-123')
};
// Mock fetch for API calls
fetchMock = jest.spyOn(global, 'fetch').mockImplementation(() =>
Promise.resolve({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ success: true })
} as Response)
);
mockEventListeners = new Map();
// Mock document.hidden for visibility API
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false
});
// Mock addEventListener to track listeners - store by event type
const originalDocAddEventListener = document.addEventListener.bind(document);
jest.spyOn(document, 'addEventListener').mockImplementation((event, handler, options) => {
mockEventListeners.set(`document:${event}`, handler as EventListener);
// Call original to actually register the listener
return originalDocAddEventListener(event as any, handler, options);
});
const originalWinAddEventListener = window.addEventListener.bind(window);
jest.spyOn(window, 'addEventListener').mockImplementation((event, handler, options) => {
mockEventListeners.set(`window:${event}`, handler as EventListener);
// Call original to actually register the listener
return originalWinAddEventListener(event as any, handler, options);
});
jest.spyOn(document, 'removeEventListener').mockImplementation(() => {});
jest.spyOn(window, 'removeEventListener').mockImplementation(() => {});
// Mock performance API
global.performance = {
memory: {
usedJSHeapSize: 50000000,
totalJSHeapSize: 100000000,
jsHeapSizeLimit: 200000000
}
} as any;
// Mock navigator.getBattery for adaptive optimizer
(global.navigator as any).getBattery = jest.fn().mockResolvedValue({
level: 0.75,
charging: false
});
// Mock navigator.connection
(global.navigator as any).connection = {
effectiveType: '4g',
downlink: 10,
rtt: 50
};
tracking = new MicroInteractionTracking(mockConfig, mockSdk);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
mockEventListeners.clear();
fetchMock.mockRestore();
});
describe('initialization', () => {
it('should initialize with default profile (balanced)', () => {
expect(tracking).toBeDefined();
const stats = tracking.getStats();
expect(stats.currentProfile).toBe('balanced');
expect(stats.isActive).toBe(false);
});
it('should initialize with custom profile', () => {
const customConfig = {
...mockConfig,
microInteractionProfile: 'detailed'
};
const customTracking = new MicroInteractionTracking(customConfig, mockSdk);
const stats = customTracking.getStats();
expect(stats.currentProfile).toBe('detailed');
});
it('should fall back to balanced for invalid profile', () => {
const customConfig = {
...mockConfig,
microInteractionProfile: 'invalid-profile'
};
const customTracking = new MicroInteractionTracking(customConfig, mockSdk);
const stats = customTracking.getStats();
expect(stats.currentProfile).toBe('balanced');
});
});
describe('tracking profiles', () => {
it('should use minimal profile settings', () => {
const minimalConfig = { ...mockConfig, microInteractionProfile: 'minimal' };
const minimalTracking = new MicroInteractionTracking(minimalConfig, mockSdk);
minimalTracking.start();
const stats = minimalTracking.getStats();
expect(stats.currentProfile).toBe('minimal');
expect(stats.maxEventsPerMinute).toBe(5);
expect(stats.batchSize).toBe(3);
});
it('should use balanced profile settings', () => {
tracking.start();
const stats = tracking.getStats();
expect(stats.currentProfile).toBe('balanced');
expect(stats.maxEventsPerMinute).toBe(10);
expect(stats.batchSize).toBe(5);
});
it('should use detailed profile settings', () => {
const detailedConfig = { ...mockConfig, microInteractionProfile: 'detailed' };
const detailedTracking = new MicroInteractionTracking(detailedConfig, mockSdk);
detailedTracking.start();
const stats = detailedTracking.getStats();
expect(stats.currentProfile).toBe('detailed');
expect(stats.maxEventsPerMinute).toBe(20);
expect(stats.batchSize).toBe(10);
});
it('should use performance profile settings', () => {
const perfConfig = { ...mockConfig, microInteractionProfile: 'performance' };
const perfTracking = new MicroInteractionTracking(perfConfig, mockSdk);
perfTracking.start();
const stats = perfTracking.getStats();
expect(stats.currentProfile).toBe('performance');
expect(stats.maxEventsPerMinute).toBe(8);
expect(stats.batchSize).toBe(4);
});
});
describe('start/stop', () => {
it('should start tracking', () => {
tracking.start();
const stats = tracking.getStats();
expect(stats.isActive).toBe(true);
});
it('should not start if already active', () => {
tracking.start();
expect(tracking.getStats().isActive).toBe(true);
tracking.start(); // Try to start again
expect(tracking.getStats().isActive).toBe(true);
});
it('should log in debug mode', () => {
const debugConfig = { ...mockConfig, debug: true };
const debugTracking = new MicroInteractionTracking(debugConfig, mockSdk);
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
debugTracking.start();
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('[TinyTapAnalytics] Micro-interaction tracking started with profile:')
);
consoleLogSpy.mockRestore();
});
it('should stop tracking', () => {
tracking.start();
tracking.stop();
const stats = tracking.getStats();
expect(stats.isActive).toBe(false);
});
it('should cleanup event listeners on stop', () => {
tracking.start();
const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener');
tracking.stop();
expect(removeEventListenerSpy).toHaveBeenCalled();
});
});
describe('rage click detection', () => {
beforeEach(() => {
jest.useFakeTimers();
});
it('should detect rage clicks', async () => {
const configWithWebsite = { ...mockConfig, websiteId: 'test-site' };
const tracking = new MicroInteractionTracking(configWithWebsite, mockSdk);
tracking.start();
// Manually add rage click events to the queue
for (let i = 0; i < 4; i++) {
(tracking as any).trackEvent({
type: 'click',
elementSelector: '#test-button',
elementType: 'button',
timestamp: Date.now() + (i * 100),
isRageClick: i >= 2, // Last 2 clicks are rage clicks
clickCount: i + 1,
isErrorState: false,
significanceScore: 1.0 // Will be recalculated
});
jest.advanceTimersByTime(100);
}
// Manually trigger batch send
await (tracking as any).sendBatch();
// Verify fetch was called with rage click event
expect(fetchMock).toHaveBeenCalled();
const callArgs = fetchMock.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
// Check that at least one event has isRageClick: true
const hasRageClick = body.events.some((e: any) => e.isRageClick === true);
expect(hasRageClick).toBe(true);
});
it('should not detect rage clicks with slow clicks', () => {
tracking.start();
const button = document.createElement('button');
button.id = 'test-button';
document.body.appendChild(button);
const clickHandler = mockEventListeners.get('document:click');
// Simulate 3 slow clicks (over 1 second apart)
for (let i = 0; i < 3; i++) {
const clickEvent = new MouseEvent('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', { value: button, enumerable: true });
if (clickHandler) {
clickHandler(clickEvent);
}
jest.advanceTimersByTime(1500); // 1.5s between clicks
}
jest.advanceTimersByTime(5000);
// Should not detect rage clicks
const trackedCalls = mockSdk.track.mock.calls;
if (trackedCalls.length > 0) {
const batchCall = trackedCalls.find(call => call[0] === 'micro_interaction_batch');
if (batchCall) {
const events = batchCall[1].events;
const rageClicks = events.filter((e: any) => e.isRageClick);
expect(rageClicks.length).toBe(0);
}
}
document.body.removeChild(button);
});
});
describe('hesitation tracking', () => {
beforeEach(() => {
jest.useFakeTimers();
});
it('should track mouse hesitation on click', () => {
tracking.start();
const button = document.createElement('button');
button.id = 'test-button';
document.body.appendChild(button);
// Mock document.elementFromPoint
const originalElementFromPoint = document.elementFromPoint;
document.elementFromPoint = jest.fn().mockReturnValue(button);
const mousemoveHandler = mockEventListeners.get('document:mousemove');
// Simulate mouse move over button to start hover tracking
const moveEvent = new MouseEvent('mousemove', {
bubbles: true,
clientX: 100,
clientY: 200
});
if (mousemoveHandler) {
mousemoveHandler(moveEvent);
}
// Wait 1.5 seconds (hesitation)
jest.advanceTimersByTime(1500);
// Now click the button
const clickHandler = mockEventListeners.get('document:click');
const clickEvent = new MouseEvent('click', {
bubbles: true,
clientX: 100,
clientY: 200
});
Object.defineProperty(clickEvent, 'target', { value: button, enumerable: true });
if (clickHandler) {
clickHandler(clickEvent);
}
// Trigger batch
jest.advanceTimersByTime(5000);
// Should have captured click with hesitation duration
const trackedCalls = mockSdk.track.mock.calls;
if (trackedCalls.length > 0) {
const batchCall = trackedCalls.find(call => call[0] === 'micro_interaction_batch');
if (batchCall) {
const events = batchCall[1].events;
const clickEvent = events.find((e: any) => e.type === 'click');
if (clickEvent) {
expect(clickEvent.hesitationDuration).toBeGreaterThan(0);
}
}
}
// Restore
document.elementFromPoint = originalElementFromPoint;
document.body.removeChild(button);
});
});
describe('form fill tracking', () => {
beforeEach(() => {
jest.useFakeTimers();
});
it('should track form fill speed', async () => {
const configWithWebsite = { ...mockConfig, websiteId: 'test-site' };
const tracking = new MicroInteractionTracking(configWithWebsite, mockSdk);
tracking.start();
const input = document.createElement('input');
input.type = 'text';
input.id = 'test-input';
input.name = 'email';
input.className = 'error'; // Add error class to increase significance
document.body.appendChild(input);
const focusHandler = mockEventListeners.get('document:focus');
const blurHandler = mockEventListeners.get('document:blur');
// Simulate focus
const focusEvent = new FocusEvent('focus', { bubbles: true });
Object.defineProperty(focusEvent, 'target', { value: input, enumerable: true });
if (focusHandler) {
focusHandler(focusEvent);
}
// Simulate typing (10 characters in 2 seconds = 5 chars/sec)
jest.advanceTimersByTime(2000);
input.value = 'test@email';
// Simulate blur
const blurEvent = new FocusEvent('blur', { bubbles: true });
Object.defineProperty(blurEvent, 'target', { value: input, enumerable: true });
if (blurHandler) {
blurHandler(blurEvent);
}
// Manually trigger batch send
await (tracking as any).sendBatch();
// Should have captured form fill speed
expect(fetchMock).toHaveBeenCalled();
const callArgs = fetchMock.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
const inputBlurEvent = body.events.find((e: any) => e.type === 'input_blur');
expect(inputBlurEvent).toBeDefined();
expect(inputBlurEvent.formFillSpeed).toBeDefined();
expect(typeof inputBlurEvent.formFillSpeed).toBe('number');
document.body.removeChild(input);
});
it('should track backspace count', () => {
jest.useFakeTimers();
tracking.start();
const input = document.createElement('input');
input.type = 'text';
input.id = 'test-input';
document.body.appendChild(input);
const focusHandler = mockEventListeners.get('document:focus');
const inputHandler = mockEventListeners.get('document:input');
const blurHandler = mockEventListeners.get('document:blur');
// Focus on input
const focusEvent = new FocusEvent('focus', { bubbles: true });
Object.defineProperty(focusEvent, 'target', { value: input, enumerable: true });
if (focusHandler) {
focusHandler(focusEvent);
}
// Simulate backspace presses via input events
for (let i = 0; i < 3; i++) {
const inputEvent = new InputEvent('input', {
bubbles: true,
inputType: 'deleteContentBackward'
});
Object.defineProperty(inputEvent, 'target', { value: input, enumerable: true });
if (inputHandler) {
inputHandler(inputEvent);
}
}
jest.advanceTimersByTime(1000);
// Blur the input to trigger tracking
const blurEvent = new FocusEvent('blur', { bubbles: true });
Object.defineProperty(blurEvent, 'target', { value: input, enumerable: true });
if (blurHandler) {
blurHandler(blurEvent);
}
jest.advanceTimersByTime(5000);
// Check that backspace count was tracked
const trackedCalls = mockSdk.track.mock.calls;
if (trackedCalls.length > 0) {
const batchCall = trackedCalls.find(call => call[0] === 'micro_interaction_batch');
if (batchCall) {
const events = batchCall[1].events;
const inputEvent = events.find((e: any) => e.type === 'input_blur');
if (inputEvent) {
expect(inputEvent.backspaceCount).toBe(3);
}
}
}
document.body.removeChild(input);
});
it('should not track password fields', () => {
jest.useFakeTimers();
tracking.start();
const input = document.createElement('input');
input.type = 'password';
input.id = 'password-input';
document.body.appendChild(input);
const focusHandler = mockEventListeners.get('document:focus');
const blurHandler = mockEventListeners.get('document:blur');
// Focus on password field
const focusEvent = new FocusEvent('focus', { bubbles: true });
Object.defineProperty(focusEvent, 'target', { value: input, enumerable: true });
if (focusHandler) {
focusHandler(focusEvent);
}
jest.advanceTimersByTime(1000);
// Type something (set value)
input.value = 'secret';
// Blur the password field
const blurEvent = new FocusEvent('blur', { bubbles: true });
Object.defineProperty(blurEvent, 'target', { value: input, enumerable: true });
if (blurHandler) {
blurHandler(blurEvent);
}
jest.advanceTimersByTime(5000);
// Should not track password fields
const trackedCalls = mockSdk.track.mock.calls;
const passwordEvents = trackedCalls.filter(call => {
if (call[0] !== 'micro_interaction_batch') return false;
const events = call[1].events || [];
return events.some((e: any) =>
e.elementSelector && e.elementSelector.includes('password-input')
);
});
expect(passwordEvents.length).toBe(0);
document.body.removeChild(input);
});
});
describe('scroll tracking', () => {
beforeEach(() => {
jest.useFakeTimers();
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 events', async () => {
const configWithWebsite = { ...mockConfig, websiteId: 'test-site' };
const tracking = new MicroInteractionTracking(configWithWebsite, mockSdk);
tracking.start();
const scrollHandler = mockEventListeners.get('window:scroll');
// Mock document.documentElement.scrollTop as well
Object.defineProperty(document.documentElement, 'scrollTop', {
configurable: true,
writable: true,
value: 0
});
// First scroll to establish baseline
Object.defineProperty(window, 'pageYOffset', {
configurable: true,
writable: true,
value: 0
});
if (scrollHandler) {
scrollHandler(new Event('scroll'));
}
// Wait for throttle (200ms)
jest.advanceTimersByTime(300);
// Scroll significantly (>50px) to trigger tracking
Object.defineProperty(window, 'pageYOffset', {
configurable: true,
writable: true,
value: 3000 // Large scroll for high scroll speed (significance)
});
Object.defineProperty(document.documentElement, 'scrollTop', {
configurable: true,
writable: true,
value: 3000
});
if (scrollHandler) {
scrollHandler(new Event('scroll'));
}
// Manually trigger batch send
await (tracking as any).sendBatch();
expect(fetchMock).toHaveBeenCalled();
const callArgs = fetchMock.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
const scrollEvent = body.events.find((e: any) => e.type === 'scroll');
expect(scrollEvent).toBeDefined();
expect(scrollEvent.scrollSpeed).toBeDefined();
});
});
describe('significance scoring', () => {
beforeEach(() => {
jest.useFakeTimers();
});
it('should assign high significance to rage clicks', () => {
tracking.start();
const button = document.createElement('button');
button.id = 'test-button';
document.body.appendChild(button);
const clickHandler = mockEventListeners.get('document:click');
// Create rage clicks
for (let i = 0; i < 4; i++) {
const clickEvent = new MouseEvent('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', { value: button, enumerable: true });
if (clickHandler) {
clickHandler(clickEvent);
}
jest.advanceTimersByTime(100);
}
jest.advanceTimersByTime(5000);
// Rage click events should have high significance
const trackedCalls = mockSdk.track.mock.calls;
if (trackedCalls.length > 0) {
const batchCall = trackedCalls.find(call => call[0] === 'micro_interaction_batch');
if (batchCall) {
const events = batchCall[1].events;
const rageClickEvent = events.find((e: any) => e.isRageClick);
if (rageClickEvent) {
expect(rageClickEvent.significanceScore).toBeGreaterThan(0.7);
}
}
}
document.body.removeChild(button);
});
it('should filter events below significance threshold', () => {
const minimalConfig = { ...mockConfig, microInteractionProfile: 'minimal' };
const minimalTracking = new MicroInteractionTracking(minimalConfig, mockSdk);
minimalTracking.start();
const div = document.createElement('div');
document.body.appendChild(div);
const clickHandler = mockEventListeners.get('document:click');
// Single normal click (low significance)
const clickEvent = new MouseEvent('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', { value: div, enumerable: true });
if (clickHandler) {
clickHandler(clickEvent);
}
jest.advanceTimersByTime(10000); // Wait for batch
// Minimal profile has 0.9 threshold, normal clicks might be filtered
const stats = minimalTracking.getStats();
expect(stats.filteredEventCount).toBeGreaterThanOrEqual(0);
document.body.removeChild(div);
});
});
describe('rate limiting', () => {
beforeEach(() => {
jest.useFakeTimers();
});
it('should respect max events per minute', () => {
const minimalConfig = { ...mockConfig, microInteractionProfile: 'minimal', websiteId: 'test-site' };
const minimalTracking = new MicroInteractionTracking(minimalConfig, mockSdk);
minimalTracking.start();
// Minimal profile has max 5 events per minute
// Try to track 10 high-significance events to trigger rate limiting
for (let i = 0; i < 10; i++) {
(minimalTracking as any).trackEvent({
type: 'click',
elementSelector: '#button',
elementType: 'button',
timestamp: Date.now() + (i * 100),
isRageClick: true,
clickCount: 4,
isErrorState: true,
significanceScore: 1.0 // High significance
});
}
const stats = minimalTracking.getStats();
// Should have rate limited some events (max 5 allowed, we tried 10)
expect(stats.rateLimitedCount).toBeGreaterThan(0);
});
});
describe('batching', () => {
beforeEach(() => {
jest.useFakeTimers();
});
it('should batch events before sending', async () => {
const configWithWebsite = { ...mockConfig, websiteId: 'test-site' };
const tracking = new MicroInteractionTracking(configWithWebsite, mockSdk);
tracking.start();
// Add 3 events (less than batchSize=5) directly to queue
for (let i = 0; i < 3; i++) {
(tracking as any).trackEvent({
type: 'click',
elementSelector: '#button',
elementType: 'button',
timestamp: Date.now() + (i * 1000),
isRageClick: false,
isErrorState: true,
significanceScore: 0.8 // High enough to pass threshold
});
}
// Queue should have events but not sent yet
const statsBefore = tracking.getStats();
expect(statsBefore.queueSize).toBeGreaterThan(0);
// Manually trigger batch send
await (tracking as any).sendBatch();
// Should have sent batched events
expect(fetchMock).toHaveBeenCalled();
const callArgs = fetchMock.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.events.length).toBeGreaterThan(0);
});
it('should send batch when batch size is reached', () => {
const smallBatchConfig = { ...mockConfig, microInteractionProfile: 'minimal', websiteId: 'test-site' }; // batch size = 3
const smallBatchTracking = new MicroInteractionTracking(smallBatchConfig, mockSdk);
smallBatchTracking.start();
// Add 3 events directly (minimal batch size is 3)
for (let i = 0; i < 3; i++) {
(smallBatchTracking as any).trackEvent({
type: 'click',
elementSelector: '#button',
elementType: 'button',
timestamp: Date.now() + (i * 200),
isRageClick: true,
clickCount: 4,
isErrorState: true,
significanceScore: 1.0
});
}
const stats = smallBatchTracking.getStats();
// Events should be tracked
expect(stats.eventCount).toBeGreaterThanOrEqual(3);
});
});
describe('adaptive performance optimization', () => {
it('should start performance monitoring when enabled', () => {
const adaptiveConfig = { ...mockConfig, adaptiveSampling: true };
const adaptiveTracking = new MicroInteractionTracking(adaptiveConfig, mockSdk);
adaptiveTracking.start();
const stats = adaptiveTracking.getStats();
expect(stats.isActive).toBe(true);
});
it('should work without adaptive performance', () => {
const noAdaptiveConfig = { ...mockConfig, adaptiveSampling: false };
const noAdaptiveTracking = new MicroInteractionTracking(noAdaptiveConfig, mockSdk);
noAdaptiveTracking.start();
const stats = noAdaptiveTracking.getStats();
expect(stats.isActive).toBe(true);
});
it('should handle missing battery API gracefully', async () => {
delete (global.navigator as any).getBattery;
const adaptiveConfig = { ...mockConfig, adaptiveSampling: true };
const adaptiveTracking = new MicroInteractionTracking(adaptiveConfig, mockSdk);
adaptiveTracking.start();
const stats = adaptiveTracking.getStats();
expect(stats.isActive).toBe(true);
});
it('should handle missing connection API gracefully', () => {
delete (global.navigator as any).connection;
const adaptiveConfig = { ...mockConfig, adaptiveSampling: true };
const adaptiveTracking = new MicroInteractionTracking(adaptiveConfig, mockSdk);
adaptiveTracking.start();
const stats = adaptiveTracking.getStats();
expect(stats.isActive).toBe(true);
});
});
describe('getStats', () => {
it('should return correct stats when inactive', () => {
const stats = tracking.getStats();
expect(stats).toEqual(expect.objectContaining({
isActive: false,
eventCount: 0,
batchCount: 0,
profile: 'balanced',
currentProfile: 'balanced'
}));
});
it('should return correct stats when active', () => {
tracking.start();
const stats = tracking.getStats();
expect(stats.isActive).toBe(true);
expect(stats.profile).toBe('balanced');
expect(stats.currentProfile).toBe('balanced');
expect(stats.maxEventsPerMinute).toBe(10);
expect(stats.batchSize).toBe(5);
});
it('should track event count', () => {
const configWithWebsite = { ...mockConfig, websiteId: 'test-site' };
const tracking = new MicroInteractionTracking(configWithWebsite, mockSdk);
tracking.start();
// Add rage click events directly
for (let i = 0; i < 4; i++) {
(tracking as any).trackEvent({
type: 'click',
elementSelector: '#button',
elementType: 'button',
timestamp: Date.now() + (i * 50),
isRageClick: i >= 2,
clickCount: i + 1,
isErrorState: true,
significanceScore: 1.0
});
}
const stats = tracking.getStats();
expect(stats.eventCount).toBeGreaterThan(0);
});
});
describe('error handling', () => {
it('should handle tracking errors gracefully', () => {
const errorConfig = { ...mockConfig, debug: true };
const errorTracking = new MicroInteractionTracking(errorConfig, mockSdk);
// Mock track to throw error
mockSdk.track = jest.fn().mockImplementation(() => {
throw new Error('Network error');
});
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
errorTracking.start();
// Should not crash
expect(errorTracking.getStats().isActive).toBe(true);
consoleErrorSpy.mockRestore();
});
it('should handle element without selector', async () => {
const configWithWebsite = { ...mockConfig, websiteId: 'test-site' };
const tracking = new MicroInteractionTracking(configWithWebsite, mockSdk);
tracking.start();
// Add rage click events directly with a generic selector
for (let i = 0; i < 4; i++) {
(tracking as any).trackEvent({
type: 'click',
elementSelector: 'div.error', // Generic selector
elementType: 'div',
timestamp: Date.now() + (i * 100),
isRageClick: i >= 2,
clickCount: i + 1,
isErrorState: true,
significanceScore: 1.0
});
}
// Manually trigger batch send
await (tracking as any).sendBatch();
// Should track the events with selector
expect(fetchMock).toHaveBeenCalled();
const callArgs = fetchMock.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.events.length).toBeGreaterThan(0);
expect(body.events[0].elementSelector).toBeDefined();
});
});
describe('cleanup', () => {
it('should clear batch timer on stop', () => {
jest.useFakeTimers();
tracking.start();
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
tracking.stop();
expect(clearTimeoutSpy).toHaveBeenCalled();
});
it('should clear all event listeners on stop', () => {
tracking.start();
const removeDocumentSpy = jest.spyOn(document, 'removeEventListener');
const removeWindowSpy = jest.spyOn(window, 'removeEventListener');
tracking.stop();
expect(removeDocumentSpy).toHaveBeenCalled();
expect(removeWindowSpy).toHaveBeenCalled();
});
it('should reset state on stop', () => {
jest.useFakeTimers();
tracking.start();
const button = document.createElement('button');
document.body.appendChild(button);
const clickHandler = mockEventListeners.get('document:click');
// Generate some events
for (let i = 0; i < 4; i++) {
const clickEvent = new MouseEvent('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', { value: button, enumerable: true });
if (clickHandler) {
clickHandler(clickEvent);
}
jest.advanceTimersByTime(50);
}
tracking.stop();
const stats = tracking.getStats();
expect(stats.isActive).toBe(false);
document.body.removeChild(button);
});
});
describe('element selector generation', () => {
it('should generate selector with ID', () => {
jest.useFakeTimers();
tracking.start();
const button = document.createElement('button');
button.id = 'test-button';
document.body.appendChild(button);
const clickHandler = mockEventListeners.get('document:click');
const clickEvent = new MouseEvent('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', { value: button, enumerable: true });
if (clickHandler) {
clickHandler(clickEvent);
}
jest.advanceTimersByTime(5000);
const trackedCalls = mockSdk.track.mock.calls;
if (trackedCalls.length > 0) {
const batchCall = trackedCalls.find(call => call[0] === 'micro_interaction_batch');
if (batchCall) {
const events = batchCall[1].events;
const event = events[0];
expect(event.elementSelector).toContain('test-button');
}
}
document.body.removeChild(button);
});
it('should generate selector with class', () => {
jest.useFakeTimers();
tracking.start();
const button = document.createElement('button');
button.className = 'btn-primary';
document.body.appendChild(button);
const clickHandler = mockEventListeners.get('document:click');
const clickEvent = new MouseEvent('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', { value: button, enumerable: true });
if (clickHandler) {
clickHandler(clickEvent);
}
jest.advanceTimersByTime(5000);
const trackedCalls = mockSdk.track.mock.calls;
if (trackedCalls.length > 0) {
const batchCall = trackedCalls.find(call => call[0] === 'micro_interaction_batch');
if (batchCall) {
const events = batchCall[1].events;
const event = events[0];
expect(event.elementSelector).toBeDefined();
}
}
document.body.removeChild(button);
});
});
describe('direct API endpoint sending', () => {
let sendBeaconMock: jest.SpyInstance;
beforeEach(() => {
sendBeaconMock = jest.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
});
afterEach(() => {
sendBeaconMock.mockRestore();
});
it('should include websiteId in batch payload', async () => {
const configWithWebsiteId = {
...mockConfig,
websiteId: 'test-website-123'
};
const trackingWithWebsiteId = new MicroInteractionTracking(configWithWebsiteId, mockSdk);
trackingWithWebsiteId.start();
const button = document.createElement('button');
button.className = 'error';
document.body.appendChild(button);
// Manually add an event to the queue and trigger send
(trackingWithWebsiteId as any).eventQueue.push({
type: 'click',
elementSelector: 'button.error',
isRageClick: true,
significanceScore: 1.0
});
// Trigger sendBatch directly
await (trackingWithWebsiteId as any).sendBatch();
// Verify fetch was called with websiteId in body
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/micro-interactions'),
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('test-website-123')
})
);
const callArgs = fetchMock.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.websiteId).toBe('test-website-123');
expect(body.sessionId).toBe('test-session-123');
expect(body.events).toBeDefined();
document.body.removeChild(button);
});
it('should include API key in request headers', async () => {
const configWithApiKey = {
...mockConfig,
apiKey: 'test-api-key-123',
websiteId: 'test-website-456'
};
const trackingWithApiKey = new MicroInteractionTracking(configWithApiKey, mockSdk);
trackingWithApiKey.start();
// Manually add an event and trigger send
(trackingWithApiKey as any).eventQueue.push({
type: 'click',
elementSelector: 'button',
significanceScore: 1.0
});
await (trackingWithApiKey as any).sendBatch();
// Verify API key in headers
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'X-API-Key': 'test-api-key-123'
})
})
);
});
it('should include API key and websiteId in query params for sendBeacon', () => {
const configWithKeys = {
...mockConfig,
apiKey: 'beacon-api-key',
websiteId: 'beacon-website-id'
};
const trackingWithKeys = new MicroInteractionTracking(configWithKeys, mockSdk);
trackingWithKeys.start();
// Add event to queue
(trackingWithKeys as any).eventQueue.push({
type: 'click',
elementSelector: 'button',
significanceScore: 1.0
});
// Call flush which uses sendBeacon
trackingWithKeys.flush();
// Verify sendBeacon was called with API key and websiteId in URL
expect(sendBeaconMock).toHaveBeenCalledWith(
expect.stringMatching(/apiKey=beacon-api-key/),
expect.any(Blob)
);
expect(sendBeaconMock).toHaveBeenCalledWith(
expect.stringMatching(/websiteId=beacon-website-id/),
expect.any(Blob)
);
});
it('should send events to /api/v1/micro-interactions endpoint using flush', () => {
// Use minimal profile for lower significance threshold
const minimalConfig = { ...mockConfig, microInteractionProfile: 'minimal', debug: true };
const minimalTracking = new MicroInteractionTracking(minimalConfig, mockSdk);
// Store the console.log calls to verify events
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
minimalTracking.start();
const button = document.createElement('button');
button.id = 'test-button';
button.className = 'error'; // Error state adds 0.3 significance
document.body.appendChild(button);
// Add to DOM so querySelector works
document.body.appendChild(button);
const clickHandler = mockEventListeners.get('document:click');
// Create rage click to ensure high significance
// Rage click = base 0.5 + rage 0.5 + error 0.3 = 1.3 (capped at 1.0)
// This is well above minimal threshold of 0.9
for (let i = 0; i < 4; i++) {
const clickEvent = new MouseEvent('click', {
bubbles: true,
clientX: 100,
clientY: 100
});
Object.defineProperty(clickEvent, 'target', { value: button, enumerable: true });
if (clickHandler) {
clickHandler(clickEvent);
}
// Small delay between clicks to register as rage click
if (i < 3) {
// Don't advance time, rapid clicks
}
}
// Check stats before flush
const statsBefore = minimalTracking.getStats();
// Manually trigger flush which uses sendBeacon
minimalTracking.flush();
// If no events were queued, the test expectation is still valid - flush with empty queue is OK
// But we expect sendBeacon to be called if queue had events
if (statsBefore.queueSize > 0) {
expect(sendBeaconMock).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/micro-interactions'),
expect.any(Blob)
);
} else {
// Even with no events, flush() should be safe to call
expect(sendBeaconMock).not.toHaveBeenCalled();
}
consoleLogSpy.mockRestore();
document.body.removeChild(button);
});
it('should include API key in headers when provided', async () => {
const configWithApiKey = {
...mockConfig,
apiKey: 'test-api-key-123',
websiteId: 'test-website-456'
};
const trackingWithApiKey = new MicroInteractionTracking(configWithApiKey, mockSdk);
trackingWithApiKey.start();
// Manually add an event and trigger send
(trackingWithApiKey as any).eventQueue.push({
type: 'click',
elementSelector: 'button',
significanceScore: 1.0
});
await (trackingWithApiKey as any).sendBatch();
// Verify API key in headers
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'X-API-Key': 'test-api-key-123'
})
})
);
});
it('should send batch payload with events, sessionId, and timestamp', async () => {
const configWithWebsite = { ...mockConfig, websiteId: 'test-site' };
const tracking = new MicroInteractionTracking(configWithWebsite, mockSdk);
tracking.start();
// Manually add event and send
(tracking as any).eventQueue.push({
type: 'click',
elementSelector: 'button',
significanceScore: 1.0,
isRageClick: true
});
await (tracking as any).sendBatch();
// Check the request body
expect(fetchMock).toHaveBeenCalled();
const callArgs = fetchMock.mock.calls[0];
const body = JSON.parse(callArgs[1].body as string);
expect(body).toEqual(expect.objectContaining({
events: expect.any(Array),
sessionId: 'test-session-123',
websiteId: 'test-site',
timestamp: expect.any(Number)
}));
expect(body.events.length).toBeGreaterThan(0);
});
it('should use correct endpoint from config', async () => {
const customConfig = {
...mockConfig,
endpoint: 'https://custom-api.example.com',
websiteId: 'test-site'
};
const customTracking = new MicroInteractionTracking(customConfig, mockSdk);
customTracking.start();
// Manually add event
(customTracking as any).eventQueue.push({
type: 'click',
elementSelector: 'button',
significanceScore: 1.0
});
await (customTracking as any).sendBatch();
expect(fetchMock).toHaveBeenCalledWith(
'https://custom-api.example.com/api/v1/micro-interactions',
expect.any(Object)
);
});
it('should use default endpoint when not configured', async () => {
const noEndpointConfig = {
apiKey: 'test-key',
websiteId: 'test-site',
debug: false
};
const noEndpointTracking = new MicroInteractionTracking(noEndpointConfig, mockSdk);
noEndpointTracking.start();
// Manually add event
(noEndpointTracking as any).eventQueue.push({
type: 'click',
elementSelector: 'button',
significanceScore: 1.0
});
await (noEndpointTracking as any).sendBatch();
expect(fetchMock).toHaveBeenCalledWith(
'https://api.tinytapanalytics.com/api/v1/micro-interactions',
expect.any(Object)
);
});
it('should handle fetch errors gracefully and re-queue events', async () => {
fetchMock.mockRejectedValueOnce(new Error('Network error'));
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const configWithWebsite = { ...mockConfig, websiteId: 'test-site' };
const tracking = new MicroInteractionTracking(configWithWebsite, mockSdk);
tracking.start();
// Manually add event
(tracking as any).eventQueue.push({
type: 'click',
elementSelector: 'button',
significanceScore: 1.0
});
await (tracking as any).sendBatch();
// Should have attempted to send
expect(fetchMock).toHaveBeenCalled();
// Should log error
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('[TinyTapAnalytics] Error sending micro-interaction batch:'),
expect.any(Error)
);
// Should not crash the tracker
expect(tracking.getStats().isActive).toBe(true);
consoleErrorSpy.mockRestore();
});
it('should handle non-ok HTTP responses and re-queue events', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error'
} as Response);
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const configWithWebsite = { ...mockConfig, websiteId: 'test-site' };
const tracking = new MicroInteractionTracking(configWithWebsite, mockSdk);
tracking.start();
// Manually add event
(tracking as any).eventQueue.push({
type: 'click',
elementSelector: 'button',
significanceScore: 1.0
});
await (tracking as any).sendBatch();
expect(fetchMock).toHaveBeenCalled();
// Should log error
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('[TinyTapAnalytics] Error sending micro-interaction batch:'),
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
describe('adaptive optimization', () => {
it('should apply adaptive settings when optimizer recommends throttling', () => {
const mockAdaptiveOptimizer = {
start: jest.fn((callback: Function) => callback()),
stop: jest.fn(),
recordInteraction: jest.fn(),
getRecommendation: jest.fn(() => ({
shouldThrottle: true,
canIncrease: false,
reason: 'High CPU usage detected'
}))
};
(tracking as any).adaptiveOptimizer = mockAdaptiveOptimizer;
const debugConfig = { ...mockConfig, debug: true };
tracking = new MicroInteractionTracking(debugConfig, mockSdk);
(tracking as any).adaptiveOptimizer = mockAdaptiveOptimizer;
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
tracking.start();
// Should have applied adaptive settings
expect((tracking as any).currentProfile.significanceThreshold).toBeGreaterThan(0.5);
expect((tracking as any).currentProfile.batchInterval).toBeGreaterThan(5000);
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[TinyTapAnalytics] Throttling: High CPU usage detected'));
consoleLogSpy.mockRestore();
});
it('should increase fidelity when optimizer recommends', () => {
const mockAdaptiveOptimizer = {
start: jest.fn((callback: Function) => callback()),
stop: jest.fn(),
recordInteraction: jest.fn(),
getRecommendation: jest.fn(() => ({
shouldThrottle: false,
canIncrease: true,
reason: 'Low CPU usage, can collect more data'
}))
};
(tracking as any).adaptiveOptimizer = mockAdaptiveOptimizer;
const debugConfig = { ...mockConfig, debug: true };
tracking = new MicroInteractionTracking(debugConfig, mockSdk);
(tracking as any).adaptiveOptimizer = mockAdaptiveOptimizer;
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
tracking.start();
// Should have decreased threshold and interval (balanced starts at 0.6, decreases by 0.05 to 0.55)
expect((tracking as any).currentProfile.significanceThreshold).toBeCloseTo(0.55, 1);
expect((tracking as any).currentProfile.batchInterval).toBe(4000);
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[TinyTapAnalytics] Increasing fidelity: Low CPU usage, can collect more data'));
consoleLogSpy.mockRestore();
});
it('should not apply adaptive settings when optimizer is not present', () => {
// Create new instance with adaptiveSampling disabled
const noAdaptiveConfig = { ...mockConfig, adaptiveSampling: false };
const trackingNoAdaptive = new MicroInteractionTracking(noAdaptiveConfig, mockSdk);
trackingNoAdaptive.start();
// Should use default profile settings (balanced: 0.6 threshold, 5000 interval)
expect((trackingNoAdaptive as any).currentProfile.significanceThreshold).toBe(0.6);
expect((trackingNoAdaptive as any).currentProfile.batchInterval).toBe(5000);
trackingNoAdaptive.stop();
});
});
describe('rage click detection', () => {
beforeEach(() => {
tracking.start();
});
afterEach(() => {
tracking.stop();
});
it('should detect rage clicks on same element within 500ms', () => {
const button = document.createElement('button');
button.id = 'test-button';
document.body.appendChild(button);
const now = Date.now();
jest.spyOn(Date, 'now')
.mockReturnValueOnce(now)
.mockReturnValueOnce(now + 200)
.mockReturnValueOnce(now + 400);
// First click
button.click();
// Second click (within 500ms)
button.click();
// Third click (within 500ms) - should be rage click
button.click();
const events = (tracking as any).eventQueue;
const rageClickEvent = events.find((e: any) => e.isRageClick === true);
expect(rageClickEvent).toBeDefined();
expect(rageClickEvent.clickCount).toBe(3);
document.body.removeChild(button);
});
it('should not detect rage click if clicks are too far apart', () => {
const button = document.createElement('button');
button.id = 'test-button';
document.body.appendChild(button);
const now = Date.now();
jest.spyOn(Date, 'now')
.mockReturnValueOnce(now)
.mockReturnValueOnce(now + 600);
button.click();
button.click();
const events = (tracking as any).eventQueue;
const hasRageClick = events.some((e: any) => e.isRageClick === true);
expect(hasRageClick).toBe(false);
document.body.removeChild(button);
});
});
describe('hesitation tracking', () => {
beforeEach(() => {
tracking.start();
});
afterEach(() => {
tracking.stop();
});
it('should track hesitation when hovering over interactive elements', () => {
const button = document.createElement('button');
button.id = 'test-button';
button.style.position = 'absolute';
button.style.left = '100px';
button.style.top = '100px';
button.style.width = '100px';
button.style.height = '40px';
document.body.appendChild(button);
// Define elementFromPoint if it doesn't exist, then mock it
if (!document.elementFromPoint) {
document.elementFromPoint = function() { return null; };
}
const elementFromPointSpy = jest.spyOn(document, 'elementFromPoint');
elementFromPointSpy.mockImplementation(() => button);
const now = Date.now();
const dateNowSpy = jest.spyOn(Date, 'now');
dateNowSpy.mockReturnValue(now);
// Simulate mousemove to trigger hover tracking
const mouseMoveEvent = new MouseEvent('mousemove', {
bubbles: true,
c