diff --git a/packages/analytics-core/src/types/element-interactions.ts b/packages/analytics-core/src/types/element-interactions.ts index c50a90260..760a40dca 100644 --- a/packages/analytics-core/src/types/element-interactions.ts +++ b/packages/analytics-core/src/types/element-interactions.ts @@ -142,7 +142,7 @@ export type Filter = { export type LabeledEvent = { id: string; definition: { - event_type: 'click' | 'change'; // [Amplitude] Element Clicked | [Amplitude] Element Changed + event_type: '[Amplitude] Element Clicked' | '[Amplitude] Element Changed'; filters: Filter[]; }[]; }; diff --git a/packages/plugin-autocapture-browser/package.json b/packages/plugin-autocapture-browser/package.json index 80a44245d..423083c12 100644 --- a/packages/plugin-autocapture-browser/package.json +++ b/packages/plugin-autocapture-browser/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "@amplitude/analytics-core": "^2.18.0", + "@amplitude/analytics-remote-config": "^0.6.3", "rxjs": "^7.8.1", "tslib": "^2.4.1" }, diff --git a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts index 192665643..812349a18 100644 --- a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts +++ b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts @@ -1,38 +1,36 @@ /* eslint-disable no-restricted-globals */ import { - BrowserClient, - BrowserConfig, - EnrichmentPlugin, - ElementInteractionsOptions, + type BrowserClient, + type BrowserConfig, + type EnrichmentPlugin, + type ElementInteractionsOptions, DEFAULT_CSS_SELECTOR_ALLOWLIST, DEFAULT_ACTION_CLICK_ALLOWLIST, DEFAULT_DATA_ATTRIBUTE_PREFIX, } from '@amplitude/analytics-core'; +import { createRemoteConfigFetch } from '@amplitude/analytics-remote-config'; import * as constants from './constants'; -import { fromEvent, map, Observable, Subscription, share } from 'rxjs'; +import { fromEvent, map, type Observable, type Subscription, share } from 'rxjs'; import { addAdditionalEventProperties, createShouldTrackEvent, getEventProperties, - ElementBasedTimestampedEvent, - TimestampedEvent, - ElementBasedEvent, - NavigateEvent, + type ElementBasedTimestampedEvent, + type TimestampedEvent, + type NavigateEvent, } from './helpers'; import { WindowMessenger } from './libs/messenger'; import { trackClicks } from './autocapture/track-click'; import { trackChange } from './autocapture/track-change'; import { trackActionClick } from './autocapture/track-action-click'; -import { HasEventTargetAddRemove } from 'rxjs/internal/observable/fromEvent'; +import type { HasEventTargetAddRemove } from 'rxjs/internal/observable/fromEvent'; import { createMutationObservable, createClickObservable } from './observables'; import { createLabeledEventToTriggerMap, + createTriggerEvaluator, groupLabeledEventIdsByEventType, - matchEventToLabeledEvents, - matchLabeledEventsToTriggers, } from './pageActions/triggers'; -import { executeActions } from './pageActions/actions'; declare global { interface Window { @@ -113,7 +111,7 @@ export const autocapturePlugin = (options: ElementInteractionsOptions = {}): Bro // ); // Create observable for URL changes - let navigateObservable; + let navigateObservable: Observable> | undefined; /* istanbul ignore next */ if (window.navigation) { navigateObservable = fromEvent(window.navigation, 'navigate').pipe( @@ -152,32 +150,29 @@ export const autocapturePlugin = (options: ElementInteractionsOptions = {}): Bro }; // Group labeled events by event type (eg. click, change) - const groupedLabeledEvents = groupLabeledEventIdsByEventType(Object.values(options.pageActions?.labeledEvents ?? {})); + let groupedLabeledEvents = groupLabeledEventIdsByEventType(Object.values(options.pageActions?.labeledEvents ?? {})); - const labeledEventToTriggerMap = createLabeledEventToTriggerMap(options.pageActions?.triggers ?? []); + let labeledEventToTriggerMap = createLabeledEventToTriggerMap(options.pageActions?.triggers ?? []); // Evaluate triggers for the given event by running the actions associated with the matching triggers - const evaluateTriggers = ( - event: ElementBasedTimestampedEvent, - ): ElementBasedTimestampedEvent => { - // If there is no pageActions, return the event as is - const { pageActions } = options; - if (!pageActions) { - return event; + const evaluateTriggers = createTriggerEvaluator(groupedLabeledEvents, labeledEventToTriggerMap, options); + + // Function to recalculate internal variables when remote config is updated + const recomputePageActionsData = (remotePageActions: ElementInteractionsOptions['pageActions']) => { + if (remotePageActions) { + // Merge remote config with local options + options.pageActions = { + ...options.pageActions, + ...remotePageActions, + }; + + // Recalculate internal variables + groupedLabeledEvents = groupLabeledEventIdsByEventType(Object.values(options.pageActions.labeledEvents ?? {})); + labeledEventToTriggerMap = createLabeledEventToTriggerMap(options.pageActions.triggers ?? []); + + // Update evaluateTriggers function + evaluateTriggers.update(groupedLabeledEvents, labeledEventToTriggerMap, options); } - - // Find matching labeled events - const matchingLabeledEvents = matchEventToLabeledEvents( - event, - Array.from(groupedLabeledEvents[event.type]).map((id) => pageActions.labeledEvents[id]), - ); - // Find matching conditions - const matchingTriggers = matchLabeledEventsToTriggers(matchingLabeledEvents, labeledEventToTriggerMap); - for (const trigger of matchingTriggers) { - executeActions(trigger.actions, event); - } - - return event; }; const setup: BrowserEnrichmentPlugin['setup'] = async (config, amplitude) => { @@ -186,6 +181,29 @@ export const autocapturePlugin = (options: ElementInteractionsOptions = {}): Bro return; } + // Fetch remote config for pageActions in a non-blocking manner + if (config.fetchRemoteConfig) { + createRemoteConfigFetch({ + localConfig: config, + configKeys: ['analyticsSDK.pageActions'], + }) + .then(async (remoteConfigFetch) => { + try { + const remotePageActions = await remoteConfigFetch.getRemoteConfig('analyticsSDK', 'pageActions'); + recomputePageActionsData(remotePageActions as ElementInteractionsOptions['pageActions']); + } catch (error) { + // Log error but don't fail the setup + /* istanbul ignore next */ + config?.loggerProvider?.error(`Failed to fetch remote config: ${String(error)}`); + } + }) + .catch((error) => { + // Log error but don't fail the setup + /* istanbul ignore next */ + config?.loggerProvider?.error(`Failed to create remote config fetch: ${String(error)}`); + }); + } + // Create should track event functions the different allowlists const shouldTrackEvent = createShouldTrackEvent( options, @@ -205,7 +223,7 @@ export const autocapturePlugin = (options: ElementInteractionsOptions = {}): Bro options: options as AutoCaptureOptionsWithDefaults, amplitude, shouldTrackEvent: shouldTrackEvent, - evaluateTriggers, + evaluateTriggers: evaluateTriggers.evaluate.bind(evaluateTriggers), }); subscriptions.push(clickTrackingSubscription); @@ -214,7 +232,7 @@ export const autocapturePlugin = (options: ElementInteractionsOptions = {}): Bro getEventProperties: (...args) => getEventProperties(...args, dataAttributePrefix), amplitude, shouldTrackEvent: shouldTrackEvent, - evaluateTriggers, + evaluateTriggers: evaluateTriggers.evaluate.bind(evaluateTriggers), }); subscriptions.push(changeSubscription); diff --git a/packages/plugin-autocapture-browser/src/helpers.ts b/packages/plugin-autocapture-browser/src/helpers.ts index a94ebf447..663d8b3e1 100644 --- a/packages/plugin-autocapture-browser/src/helpers.ts +++ b/packages/plugin-autocapture-browser/src/helpers.ts @@ -389,9 +389,9 @@ export type ElementBasedTimestampedEvent = BaseTimestampedEvent & { targetElementProperties: Record; }; -export type evaluateTriggersFn = ( - event: ElementBasedTimestampedEvent, -) => ElementBasedTimestampedEvent; +export type evaluateTriggersFn = ( + event: ElementBasedTimestampedEvent, +) => ElementBasedTimestampedEvent; // Union type for all possible TimestampedEvents export type TimestampedEvent = BaseTimestampedEvent | ElementBasedTimestampedEvent; diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts index 5c534ba07..0086baa99 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -1,5 +1,5 @@ -import { isNonSensitiveElement, JSONValue } from './helpers'; -import { Hierarchy, HierarchyNode } from './typings/autocapture'; +import { isNonSensitiveElement, type JSONValue } from './helpers'; +import type { Hierarchy, HierarchyNode } from './typings/autocapture'; const BLOCKED_ATTRIBUTES = [ // Already captured elsewhere in the hierarchy object diff --git a/packages/plugin-autocapture-browser/src/libs/messenger.ts b/packages/plugin-autocapture-browser/src/libs/messenger.ts index eac53a7de..f03cc0bdf 100644 --- a/packages/plugin-autocapture-browser/src/libs/messenger.ts +++ b/packages/plugin-autocapture-browser/src/libs/messenger.ts @@ -1,5 +1,6 @@ /* istanbul ignore file */ /* eslint-disable no-restricted-globals */ +import { extractDataFromDataSource } from '../pageActions/actions'; import { AMPLITUDE_ORIGIN, AMPLITUDE_VISUAL_TAGGING_SELECTOR_SCRIPT_URL, @@ -195,6 +196,7 @@ export class WindowMessenger implements Messenger { messenger: this, cssSelectorAllowlist, actionClickAllowlist, + extractDataFromDataSource, }); this.notify({ action: 'selector-loaded' }); }) diff --git a/packages/plugin-autocapture-browser/src/pageActions/matchEventToFilter.ts b/packages/plugin-autocapture-browser/src/pageActions/matchEventToFilter.ts index 8bdcd3634..518efae33 100644 --- a/packages/plugin-autocapture-browser/src/pageActions/matchEventToFilter.ts +++ b/packages/plugin-autocapture-browser/src/pageActions/matchEventToFilter.ts @@ -12,7 +12,7 @@ export const matchEventToFilter = (event: ElementBasedTimestampedEvent { - const groupedLabeledEvents = { - click: new Set(), - change: new Set(), - }; +export const groupLabeledEventIdsByEventType = (labeledEvents: LabeledEvent[] | null | undefined) => { + // Initialize all event types with empty sets + const groupedLabeledEvents = Object.values(eventTypeToBrowserEventMap).reduce((acc, browserEvent) => { + acc[browserEvent] = new Set(); + return acc; + }, {} as Record>); + + // If there are no labeled events, return the initialized groupedLabeledEvents if (!labeledEvents) { return groupedLabeledEvents; } + // Group labeled events by event type for (const le of labeledEvents) { try { for (const def of le.definition) { - groupedLabeledEvents[def.event_type]?.add(le.id); + const browserEvent = eventTypeToBrowserEventMap[def.event_type]; + if (browserEvent) { + groupedLabeledEvents[browserEvent].add(le.id); + } } } catch (e) { // Skip this labeled event if there is an error @@ -64,7 +79,10 @@ export const matchEventToLabeledEvents = ( ) => { return labeledEvents.filter((le) => { return le.definition.some((def) => { - return def.event_type === event.type && def.filters.every((filter) => matchEventToFilter(event, filter)); + return ( + eventTypeToBrowserEventMap[def.event_type] === event.type && + def.filters.every((filter) => matchEventToFilter(event, filter)) + ); }); }); }; @@ -74,8 +92,57 @@ export const matchLabeledEventsToTriggers = (labeledEvents: LabeledEvent[], leTo for (const le of labeledEvents) { const triggers = leToTriggerMap.get(le.id); if (triggers) { - triggers.forEach((trigger) => matchingTriggers.add(trigger)); + for (const trigger of triggers) { + matchingTriggers.add(trigger); + } } } return Array.from(matchingTriggers); }; + +export class TriggerEvaluator { + constructor( + private groupedLabeledEvents: ReturnType, + private labeledEventToTriggerMap: ReturnType, + private options: ElementInteractionsOptions, + ) {} + + evaluate(event: ElementBasedTimestampedEvent) { + // If there is no pageActions, return the event as is + const { pageActions } = this.options; + if (!pageActions) { + return event; + } + + // Find matching labeled events + const matchingLabeledEvents = matchEventToLabeledEvents( + event, + Array.from(this.groupedLabeledEvents[event.type]).map((id) => pageActions.labeledEvents[id]), + ); + // Find matching conditions + const matchingTriggers = matchLabeledEventsToTriggers(matchingLabeledEvents, this.labeledEventToTriggerMap); + for (const trigger of matchingTriggers) { + executeActions(trigger.actions, event); + } + + return event; + } + + update( + groupedLabeledEvents: ReturnType, + labeledEventToTriggerMap: ReturnType, + options: ElementInteractionsOptions, + ) { + this.groupedLabeledEvents = groupedLabeledEvents; + this.labeledEventToTriggerMap = labeledEventToTriggerMap; + this.options = options; + } +} + +export const createTriggerEvaluator = ( + groupedLabeledEvents: ReturnType, + labeledEventToTriggerMap: ReturnType, + options: ElementInteractionsOptions, +) => { + return new TriggerEvaluator(groupedLabeledEvents, labeledEventToTriggerMap, options); +}; diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/actions.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/actions.test.ts index 97a0f9332..f1078d4b7 100644 --- a/packages/plugin-autocapture-browser/test/autocapture-plugin/actions.test.ts +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/actions.test.ts @@ -53,11 +53,11 @@ describe('page actions', () => { id: '123', definition: [ { - event_type: 'click', + event_type: '[Amplitude] Element Clicked', filters: [ { subprop_key: '[Amplitude] Element Text', - subprop_op: 'exact', + subprop_op: 'is', subprop_value: ['Add to Cart'], }, { diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-action-clicks.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-action-clicks.test.ts index 1d02bcffa..a15bdb0b7 100644 --- a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-action-clicks.test.ts +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-action-clicks.test.ts @@ -47,6 +47,7 @@ describe('action clicks:', () => { const loggerProvider: Partial = { log: jest.fn(), warn: jest.fn(), + error: jest.fn(), }; const config: Partial = { defaultTracking: false, diff --git a/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts b/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts index d7079f24b..6600d1bf2 100644 --- a/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts +++ b/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts @@ -62,6 +62,7 @@ describe('autoTrackingPlugin', () => { const loggerProvider: Partial = { log: jest.fn(), warn: jest.fn(), + error: jest.fn(), }; const config: Partial = { defaultTracking: false, @@ -86,6 +87,7 @@ describe('autoTrackingPlugin', () => { const loggerProvider: Partial = { log: jest.fn(), warn: jest.fn(), + error: jest.fn(), }; const config: Partial = { defaultTracking: false, @@ -599,6 +601,7 @@ describe('autoTrackingPlugin', () => { const loggerProvider: Partial = { log: jest.fn(), warn: jest.fn(), + error: jest.fn(), }; const config: Partial = { defaultTracking: false, @@ -641,6 +644,7 @@ describe('autoTrackingPlugin', () => { const loggerProvider: Partial = { log: jest.fn(), warn: jest.fn(), + error: jest.fn(), }; const config: Partial = { defaultTracking: false, @@ -897,6 +901,7 @@ describe('autoTrackingPlugin', () => { const loggerProvider: Partial = { log: jest.fn(), warn: jest.fn(), + error: jest.fn(), }; const config: Partial = { defaultTracking: false, @@ -922,6 +927,7 @@ describe('autoTrackingPlugin', () => { const loggerProvider: Partial = { log: jest.fn(), warn: jest.fn(), + error: jest.fn(), }; const config: Partial = { defaultTracking: false, @@ -942,6 +948,7 @@ describe('autoTrackingPlugin', () => { const loggerProvider: Partial = { log: jest.fn(), warn: jest.fn(), + error: jest.fn(), }; const config: Partial = { defaultTracking: false, diff --git a/packages/plugin-autocapture-browser/test/pageActions/matchEventToFilter.test.ts b/packages/plugin-autocapture-browser/test/pageActions/matchEventToFilter.test.ts index 12bcfdeff..423484847 100644 --- a/packages/plugin-autocapture-browser/test/pageActions/matchEventToFilter.test.ts +++ b/packages/plugin-autocapture-browser/test/pageActions/matchEventToFilter.test.ts @@ -52,31 +52,31 @@ describe('matchEventToFilter', () => { testContainer.appendChild(dummyElement); }); - test('should return true if subprop_op is "exact" and text matches', () => { + test('should return true if subprop_op is "is" and text matches', () => { const event = createEventForTesting('Hello World', dummyElement); const filter: Filter = { subprop_key: textFilterKey, - subprop_op: 'exact', + subprop_op: 'is', subprop_value: ['Hello World'], }; expect(matchEventToFilter(event, filter)).toBe(true); }); - test('should return true if subprop_op is "exact" and text is one of the values', () => { + test('should return true if subprop_op is "is" and text is one of the values', () => { const event = createEventForTesting('Click Here', dummyElement); const filter: Filter = { subprop_key: textFilterKey, - subprop_op: 'exact', + subprop_op: 'is', subprop_value: ['Submit', 'Click Here', 'View More'], }; expect(matchEventToFilter(event, filter)).toBe(true); }); - test('should return false if subprop_op is "exact" and text does not match', () => { + test('should return false if subprop_op is "is" and text does not match', () => { const event = createEventForTesting('Goodbye World', dummyElement); const filter: Filter = { subprop_key: textFilterKey, - subprop_op: 'exact', + subprop_op: 'is', subprop_value: ['Hello World'], }; expect(matchEventToFilter(event, filter)).toBe(false); @@ -86,14 +86,14 @@ describe('matchEventToFilter', () => { const event = createEventForTesting(undefined, dummyElement); const filter: Filter = { subprop_key: textFilterKey, - subprop_op: 'exact', + subprop_op: 'is', subprop_value: ['Hello World'], }; expect(matchEventToFilter(event, filter)).toBe(false); }); // TODO: add tests for other operators - test('should return false if subprop_op is not "exact" (due to other operators not implemented)', () => { + test('should return false if subprop_op is not "is" (due to other operators not implemented)', () => { const event = createEventForTesting('Hello World', dummyElement); const filter: Filter = { subprop_key: textFilterKey, @@ -103,11 +103,11 @@ describe('matchEventToFilter', () => { expect(matchEventToFilter(event, filter)).toBe(false); }); - test('should return false if subprop_value is an empty array for "exact" text match', () => { + test('should return false if subprop_value is an empty array for "is" text match', () => { const event = createEventForTesting('Hello World', dummyElement); const filter: Filter = { subprop_key: textFilterKey, - subprop_op: 'exact', + subprop_op: 'is', subprop_value: [], }; expect(matchEventToFilter(event, filter)).toBe(false); @@ -175,7 +175,7 @@ describe('matchEventToFilter', () => { const event = createEventForTesting('Any text', button); const filter: Filter = { subprop_key: hierarchyFilterKey, - subprop_op: 'exact', // Any other operator + subprop_op: 'is', // Any other operator subprop_value: ['div > .my-button'], }; expect(matchEventToFilter(event, filter)).toBe(false); @@ -230,7 +230,7 @@ describe('matchEventToFilter', () => { const event = createEventForTesting('Some Text', dummyElement); const filter: Filter = { subprop_key: '[Amplitude] Unknown Key' as EventSubpropKey, // Intentionally unknown - subprop_op: 'exact', + subprop_op: 'is', subprop_value: ['Some Text'], }; expect(matchEventToFilter(event, filter)).toBe(false); diff --git a/packages/plugin-autocapture-browser/test/pageActions/triggers.test.ts b/packages/plugin-autocapture-browser/test/pageActions/triggers.test.ts index 7ad2499b1..15efe262b 100644 --- a/packages/plugin-autocapture-browser/test/pageActions/triggers.test.ts +++ b/packages/plugin-autocapture-browser/test/pageActions/triggers.test.ts @@ -1,9 +1,34 @@ -import { ElementBasedTimestampedEvent } from './../../src/helpers'; -import type { LabeledEvent } from '@amplitude/analytics-core/lib/esm/types/element-interactions'; -import { groupLabeledEventIdsByEventType, matchEventToLabeledEvents } from '../../src/pageActions/triggers'; +import { mockWindowLocationFromURL } from './../utils'; +import type { ElementBasedTimestampedEvent } from '../../src/helpers'; +import type { + LabeledEvent, + Trigger, + ElementInteractionsOptions, +} from '@amplitude/analytics-core/lib/esm/types/element-interactions'; +import { + groupLabeledEventIdsByEventType, + matchEventToLabeledEvents, + createLabeledEventToTriggerMap, + createTriggerEvaluator, +} from '../../src/pageActions/triggers'; import * as matchEventToFilterModule from '../../src/pageActions/matchEventToFilter'; +import * as actionsModule from '../../src/pageActions/actions'; +import { AMPLITUDE_ELEMENT_CLICKED_EVENT, AMPLITUDE_ELEMENT_CHANGED_EVENT } from '../../src/constants'; +import { autocapturePlugin } from '../../src/autocapture-plugin'; +import type { BrowserClient, BrowserConfig, EnrichmentPlugin, ILogger } from '@amplitude/analytics-core'; +import { createInstance } from '@amplitude/analytics-browser'; +import { createRemoteConfigFetch } from '@amplitude/analytics-remote-config'; +import * as triggersModule from '../../src/pageActions/triggers'; + +/* eslint-disable @typescript-eslint/unbound-method */ jest.mock('../../src/pageActions/matchEventToFilter'); +jest.mock('../../src/pageActions/actions'); + +// Mock the remote config fetch +jest.mock('@amplitude/analytics-remote-config', () => ({ + createRemoteConfigFetch: jest.fn(), +})); describe('groupLabeledEventIdsByEventType', () => { // Test 1: Handles an empty array @@ -28,13 +53,13 @@ describe('groupLabeledEventIdsByEventType', () => { // Test 3: Groups 'click' events correctly test('should group click event IDs correctly', () => { const labeledEvents: LabeledEvent[] = [ - { id: 'event1', definition: [{ event_type: 'click', filters: [] }] }, + { id: 'event1', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, { id: 'event2', definition: [ { - event_type: 'click', - filters: [{ subprop_key: '[Amplitude] Element Text', subprop_op: 'exact', subprop_value: ['v'] }], + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, + filters: [{ subprop_key: '[Amplitude] Element Text', subprop_op: 'is', subprop_value: ['v'] }], }, ], }, @@ -47,8 +72,8 @@ describe('groupLabeledEventIdsByEventType', () => { // Test 4: Groups 'change' events correctly test('should group change event IDs correctly', () => { const labeledEvents: LabeledEvent[] = [ - { id: 'event3', definition: [{ event_type: 'change', filters: [] }] }, - { id: 'event4', definition: [{ event_type: 'change', filters: [] }] }, + { id: 'event3', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, + { id: 'event4', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, ]; const result = groupLabeledEventIdsByEventType(labeledEvents); expect(result.change).toEqual(new Set(['event3', 'event4'])); @@ -58,10 +83,10 @@ describe('groupLabeledEventIdsByEventType', () => { // Test 5: Groups a mix of 'click' and 'change' events test('should group a mix of click and change event IDs', () => { const labeledEvents: LabeledEvent[] = [ - { id: 'event1', definition: [{ event_type: 'click', filters: [] }] }, - { id: 'event5', definition: [{ event_type: 'change', filters: [] }] }, - { id: 'event2', definition: [{ event_type: 'click', filters: [] }] }, - { id: 'event6', definition: [{ event_type: 'change', filters: [] }] }, + { id: 'event1', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, + { id: 'event5', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, + { id: 'event2', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, + { id: 'event6', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, ]; const result = groupLabeledEventIdsByEventType(labeledEvents); expect(result.click).toEqual(new Set(['event1', 'event2'])); @@ -74,13 +99,13 @@ describe('groupLabeledEventIdsByEventType', () => { { id: 'eventA', definition: [ - { event_type: 'click', filters: [] }, - { event_type: 'change', filters: [] }, + { event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }, + { event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }, ], }, { id: 'eventB', // Belongs only to click - definition: [{ event_type: 'click', filters: [] }], + definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }], }, ]; const result = groupLabeledEventIdsByEventType(labeledEvents); @@ -91,8 +116,8 @@ describe('groupLabeledEventIdsByEventType', () => { // Test 6b: Handles separate LabeledEvent items with the same ID but different event types test('should handle separate LabeledEvent items with the same ID for different types', () => { const labeledEvents: LabeledEvent[] = [ - { id: 'eventC', definition: [{ event_type: 'click', filters: [] }] }, - { id: 'eventC', definition: [{ event_type: 'change', filters: [] }] }, + { id: 'eventC', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, + { id: 'eventC', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, ]; const result = groupLabeledEventIdsByEventType(labeledEvents); expect(result.click).toEqual(new Set(['eventC'])); @@ -102,17 +127,17 @@ describe('groupLabeledEventIdsByEventType', () => { // Test 7: Handles duplicate event IDs for the same event type (Set should ensure uniqueness) test('should handle duplicate event IDs for the same type, ensuring uniqueness', () => { const labeledEvents: LabeledEvent[] = [ - { id: 'event1', definition: [{ event_type: 'click', filters: [] }] }, - { id: 'event1', definition: [{ event_type: 'click', filters: [] }] }, // Processed, but ID is already in Set + { id: 'event1', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, + { id: 'event1', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, // Processed, but ID is already in Set { id: 'event1', // Same ID, multiple definitions, one of which is click definition: [ - { event_type: 'change', filters: [] }, // This would add 'event1' to change set - { event_type: 'click', filters: [] }, // This would attempt to add 'event1' to click set (no change if already there) + { event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }, // This would add 'event1' to change set + { event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }, // This would attempt to add 'event1' to click set (no change if already there) ], }, - { id: 'event8', definition: [{ event_type: 'change', filters: [] }] }, - { id: 'event8', definition: [{ event_type: 'change', filters: [] }] }, + { id: 'event8', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, + { id: 'event8', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, ]; const result = groupLabeledEventIdsByEventType(labeledEvents); expect(result.click).toEqual(new Set(['event1'])); @@ -125,7 +150,7 @@ describe('groupLabeledEventIdsByEventType', () => { { id: 'event9', definition: [] }, // Empty definition array { id: 'event10' }, // Missing definition property { id: 'event10b', definition: null }, // Null definition property - { id: 'event11', definition: [{ event_type: 'click', filters: [] }] }, + { id: 'event11', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, ] as LabeledEvent[]; const result = groupLabeledEventIdsByEventType(labeledEvents); expect(result.click).toEqual(new Set(['event11'])); @@ -136,12 +161,12 @@ describe('groupLabeledEventIdsByEventType', () => { test('should ignore definitions with unknown or malformed event_types', () => { const labeledEvents: LabeledEvent[] = [ { id: 'event12', definition: [{ event_type: 'mouseover', filters: [] }] }, // 'mouseover' is not in groupedLabeledEvents - { id: 'event13', definition: [{ event_type: 'click', filters: [] }] }, + { id: 'event13', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, { id: 'event14', definition: [ { event_type: 'custom_event', filters: [] }, // Ignored - { event_type: 'change', filters: [] }, // Processed + { event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }, // Processed ], }, { id: 'event14b', definition: [{ event_type: null, filters: [] }] }, // Malformed event_type @@ -155,10 +180,10 @@ describe('groupLabeledEventIdsByEventType', () => { // Test 10: Handles LabeledEvents with definition items that are null or malformed test('should handle LabeledEvents with null or malformed definition items', () => { const labeledEvents: LabeledEvent[] = [ - { id: 'event15', definition: [null, { event_type: 'click', filters: [] }] }, // Null item in definition array - { id: 'event16', definition: [{ event_type: 'change', filters: [] }, {}] }, // Empty object as definition item - { id: 'event17', definition: [{ event_type: 'click', filters: [] }] }, - { id: 'event18', definition: [undefined, { event_type: 'change', filters: [] }] }, // Undefined item + { id: 'event15', definition: [null, { event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, // Null item in definition array + { id: 'event16', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }, {}] }, // Empty object as definition item + { id: 'event17', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, + { id: 'event18', definition: [undefined, { event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, // Undefined item ] as LabeledEvent[]; const result = groupLabeledEventIdsByEventType(labeledEvents); expect(result.click).toEqual(new Set(['event17'])); @@ -168,44 +193,68 @@ describe('groupLabeledEventIdsByEventType', () => { // Test 11: Handles LabeledEvent items that are null or not objects within the input array test('should gracefully handle null or non-object items in labeledEvents array', () => { const labeledEvents: LabeledEvent[] = [ - { id: 'c1', definition: [{ event_type: 'click', filters: [] }] }, + { id: 'c1', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, null, // Null item in labeledEvents - { id: 'ch1', definition: [{ event_type: 'change', filters: [] }] }, + { id: 'ch1', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, 'not_an_object', // String item in labeledEvents undefined, // Undefined item - { id: 'c2', definition: [{ event_type: 'click', filters: [] }] }, + { id: 'c2', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, ] as LabeledEvent[]; const result = groupLabeledEventIdsByEventType(labeledEvents); expect(result.click).toEqual(new Set(['c1', 'c2'])); expect(result.change).toEqual(new Set(['ch1'])); }); - // Test 12: Complex scenario with mixed valid, invalid, and duplicate data + // Test 12: Should ignore unknown Amplitude event types + test('should ignore unknown Amplitude event types like "[Amplitude] Random Event"', () => { + const labeledEvents: LabeledEvent[] = [ + { id: 'validClick', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, + { id: 'validChange', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, + { + id: 'randomEvent', + definition: [ + { event_type: '[Amplitude] Random Event' as unknown as typeof AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }, + ], + }, + { + id: 'mixedEvent', + definition: [ + { event_type: '[Amplitude] Unknown Event' as unknown as typeof AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }, // Should be ignored + { event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }, // Should be processed + ], + }, + ]; + const result = groupLabeledEventIdsByEventType(labeledEvents); + expect(result.click).toEqual(new Set(['validClick', 'mixedEvent'])); + expect(result.change).toEqual(new Set(['validChange'])); + }); + + // Test 13: Complex scenario with mixed valid, invalid, and duplicate data test('should handle a complex mix of data correctly', () => { const labeledEvents: LabeledEvent[] = [ - { id: 'c1', definition: [{ event_type: 'click', filters: [] }] }, + { id: 'c1', definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, null, - { id: 'ch1', definition: [{ event_type: 'change', filters: [] }] }, + { id: 'ch1', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, { id: 'c2', definition: [ - { event_type: 'click', filters: [] }, + { event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }, { event_type: 'focus', filters: [] }, ], }, - { id: 'ch1', definition: [{ event_type: 'change', filters: [] }] }, // Duplicate ID for change + { id: 'ch1', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, // Duplicate ID for change { id: 'multi1', definition: [ - { event_type: 'click', filters: [] }, - { event_type: 'change', filters: [] }, + { event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }, + { event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }, ], }, { id: 'eventWithoutDef' }, // Missing definition { id: 'eventWithEmptyDef', definition: [] }, // Empty definition array - { id: 'eventWithNullDefItem', definition: [null, { event_type: 'click', filters: [] }] }, + { id: 'eventWithNullDefItem', definition: [null, { event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }] }, { id: 'eventWithInvalidDefObj', definition: [{ some_other_prop: 'value' }] }, // Missing event_type - { id: 'eventWithOnlyChange', definition: [{ event_type: 'change', filters: [] }] }, + { id: 'eventWithOnlyChange', definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }] }, ] as LabeledEvent[]; const result = groupLabeledEventIdsByEventType(labeledEvents); expect(result.click).toEqual(new Set(['c1', 'c2', 'multi1'])); @@ -240,11 +289,11 @@ describe('matchEventToLabeledEvents', () => { id: '1', definition: [ { - event_type: 'click', + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [ { subprop_key: '[Amplitude] Element Text', - subprop_op: 'exact', + subprop_op: 'is', subprop_value: ['Button A'], }, ], @@ -266,7 +315,7 @@ describe('matchEventToLabeledEvents', () => { id: '2', definition: [ { - event_type: 'click', + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [ { subprop_key: '[Amplitude] Element Hierarchy', @@ -281,7 +330,7 @@ describe('matchEventToLabeledEvents', () => { id: '3', definition: [ { - event_type: 'change', + event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [ { subprop_key: '[Amplitude] Element Text', @@ -307,7 +356,7 @@ describe('matchEventToLabeledEvents', () => { id: '4', definition: [ { - event_type: 'click', + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [ { subprop_key: '[Amplitude] Element Text', @@ -322,7 +371,7 @@ describe('matchEventToLabeledEvents', () => { id: '5', definition: [ { - event_type: 'click', + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [ { subprop_key: '[Amplitude] Element Hierarchy', @@ -337,7 +386,7 @@ describe('matchEventToLabeledEvents', () => { id: '6', definition: [ { - event_type: 'change', + event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [ { subprop_key: '[Amplitude] Element Text', @@ -360,7 +409,7 @@ describe('matchEventToLabeledEvents', () => { id: '7', definition: [ { - event_type: 'click', // This definition will not match `mockEvent.type` + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, // This definition will not match `mockEvent.type` filters: [ { subprop_key: '[Amplitude] Element Text', @@ -370,11 +419,11 @@ describe('matchEventToLabeledEvents', () => { ], }, { - event_type: 'click', // This definition will match + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, // This definition will match filters: [ { subprop_key: '[Amplitude] Element Hierarchy', - subprop_op: 'starts_with', + subprop_op: 'autotrack css match', subprop_value: ['body > div'], }, ], @@ -399,7 +448,7 @@ describe('matchEventToLabeledEvents', () => { id: '8', definition: [ { - event_type: 'click', + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [ { subprop_key: '[Amplitude] Element Text', @@ -409,11 +458,11 @@ describe('matchEventToLabeledEvents', () => { ], }, { - event_type: 'click', + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [ { subprop_key: '[Amplitude] Element Hierarchy', - subprop_op: 'contains', + subprop_op: 'autotrack css match', subprop_value: ['path2'], }, ], @@ -433,7 +482,7 @@ describe('matchEventToLabeledEvents', () => { id: '9', definition: [ { - event_type: 'click', + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [ { subprop_key: '[Amplitude] Element Text', @@ -442,7 +491,7 @@ describe('matchEventToLabeledEvents', () => { }, { subprop_key: '[Amplitude] Element Hierarchy', - subprop_op: 'ends_with', + subprop_op: 'autotrack css match', subprop_value: ['button'], }, ], @@ -462,7 +511,7 @@ describe('matchEventToLabeledEvents', () => { id: '10', definition: [ { - event_type: 'click', + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [ { subprop_key: '[Amplitude] Element Text', @@ -471,7 +520,7 @@ describe('matchEventToLabeledEvents', () => { }, { subprop_key: '[Amplitude] Element Hierarchy', - subprop_op: 'ends_with', + subprop_op: 'autotrack css match', subprop_value: ['non-matching-path'], }, ], @@ -493,7 +542,7 @@ describe('matchEventToLabeledEvents', () => { id: '11', definition: [ { - event_type: 'click', + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [], // Empty filters array }, ], @@ -521,3 +570,753 @@ describe('matchEventToLabeledEvents', () => { expect(spy).not.toHaveBeenCalled(); }); }); + +describe('TriggerEvaluator', () => { + const executeActionsSpy = jest.spyOn(actionsModule, 'executeActions'); + const matchEventToFilterSpy = jest.spyOn(matchEventToFilterModule, 'matchEventToFilter'); + + beforeEach(() => { + executeActionsSpy.mockClear(); + matchEventToFilterSpy.mockClear(); + }); + + const mockMouseEvent = new MouseEvent('click'); + const mockEvent: ElementBasedTimestampedEvent = { + event: mockMouseEvent, + type: 'click', + closestTrackedAncestor: document.createElement('div'), + targetElementProperties: {}, + timestamp: 0, + }; + + const labeledEvents: Record = { + 'le-click': { + id: 'le-click', + definition: [ + { + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, + filters: [{ subprop_key: '[Amplitude] Element Text', subprop_op: 'is', subprop_value: ['value'] }], + }, + ], + }, + 'le-change': { + id: 'le-change', + definition: [{ event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, filters: [] }], + }, + }; + + const triggers: Trigger[] = [ + { + id: 'trigger-1', + name: 'Trigger 1', + actions: [ + { + id: 'action-1', + actionType: 'ATTACH_EVENT_PROPERTY', + destinationKey: 'prop', + dataSource: { sourceType: 'DOM_ELEMENT', selector: 'div', elementExtractType: 'TEXT' }, + }, + ], + conditions: [{ type: 'LABELED_EVENT', match: { eventId: 'le-click' } }], + }, + ]; + + const options: ElementInteractionsOptions = { + pageActions: { + labeledEvents: labeledEvents, + triggers: triggers, + }, + }; + + const groupedLabeledEvents = groupLabeledEventIdsByEventType(Object.values(labeledEvents)); + const labeledEventToTriggerMap = createLabeledEventToTriggerMap(triggers); + + it('should do nothing if pageActions is not configured', () => { + const triggerEvaluator = createTriggerEvaluator(groupedLabeledEvents, labeledEventToTriggerMap, {}); + const result = triggerEvaluator.evaluate(mockEvent); + + expect(result).toBe(mockEvent); + expect(executeActionsSpy).not.toHaveBeenCalled(); + }); + + it('should not call executeActions if no labeled event matches', () => { + matchEventToFilterSpy.mockReturnValue(false); // No filter match + + const triggerEvaluator = createTriggerEvaluator(groupedLabeledEvents, labeledEventToTriggerMap, options); + triggerEvaluator.evaluate(mockEvent); + + expect(executeActionsSpy).not.toHaveBeenCalled(); + }); + + it('should not call executeActions if a labeled event matches but has no trigger', () => { + const optionsWithUntriggeredEvent: ElementInteractionsOptions = { + pageActions: { + labeledEvents: { + 'untriggered-event': { + id: 'untriggered-event', + definition: [{ event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, filters: [] }], + }, + }, + triggers: [], // No triggers + }, + }; + + const grouped = groupLabeledEventIdsByEventType( + Object.values(optionsWithUntriggeredEvent.pageActions?.labeledEvents || {}), + ); + const triggerMap = createLabeledEventToTriggerMap(optionsWithUntriggeredEvent.pageActions?.triggers || []); + + matchEventToFilterSpy.mockReturnValue(true); // event matches + + const triggerEvaluator = createTriggerEvaluator(grouped, triggerMap, optionsWithUntriggeredEvent); + triggerEvaluator.evaluate(mockEvent); + + expect(executeActionsSpy).not.toHaveBeenCalled(); + }); + + it('should call executeActions with correct actions when a trigger is matched', () => { + matchEventToFilterSpy.mockReturnValue(true); // Labeled event matches + + const triggerEvaluator = createTriggerEvaluator(groupedLabeledEvents, labeledEventToTriggerMap, options); + triggerEvaluator.evaluate(mockEvent); + + expect(executeActionsSpy).toHaveBeenCalledTimes(1); + expect(executeActionsSpy).toHaveBeenCalledWith(triggers[0].actions, mockEvent); + }); + + it('should handle multiple matching triggers for a single event', () => { + const multiTrigger: Trigger[] = [ + { + id: 'trigger-1', + name: 'Trigger 1', + actions: [ + { + id: 'action-1', + actionType: 'ATTACH_EVENT_PROPERTY', + destinationKey: 'prop1', + dataSource: { sourceType: 'DOM_ELEMENT', selector: 'div', elementExtractType: 'TEXT' }, + }, + ], + conditions: [{ type: 'LABELED_EVENT', match: { eventId: 'le-click' } }], + }, + { + id: 'trigger-2', + name: 'Trigger 2', + actions: [ + { + id: 'action-2', + actionType: 'ATTACH_EVENT_PROPERTY', + destinationKey: 'prop2', + dataSource: { sourceType: 'DOM_ELEMENT', selector: 'div', elementExtractType: 'TEXT' }, + }, + ], + conditions: [{ type: 'LABELED_EVENT', match: { eventId: 'le-click' } }], + }, + ]; + + const multiTriggerOptions: ElementInteractionsOptions = { + pageActions: { + labeledEvents, + triggers: multiTrigger, + }, + }; + + const triggerMap = createLabeledEventToTriggerMap(multiTrigger); + matchEventToFilterSpy.mockReturnValue(true); + + const triggerEvaluator = createTriggerEvaluator(groupedLabeledEvents, triggerMap, multiTriggerOptions); + triggerEvaluator.evaluate(mockEvent); + + expect(executeActionsSpy).toHaveBeenCalledTimes(2); + expect(executeActionsSpy).toHaveBeenCalledWith(multiTrigger[0].actions, mockEvent); + expect(executeActionsSpy).toHaveBeenCalledWith(multiTrigger[1].actions, mockEvent); + }); + + it('should update state when update method is called', () => { + const triggerEvaluator = createTriggerEvaluator(groupedLabeledEvents, labeledEventToTriggerMap, {}); + + // Initially should do nothing since pageActions is empty + triggerEvaluator.evaluate(mockEvent); + expect(executeActionsSpy).not.toHaveBeenCalled(); + + // Update the evaluator with proper options + triggerEvaluator.update(groupedLabeledEvents, labeledEventToTriggerMap, options); + matchEventToFilterSpy.mockReturnValue(true); + + // Now it should execute actions + triggerEvaluator.evaluate(mockEvent); + expect(executeActionsSpy).toHaveBeenCalledTimes(1); + expect(executeActionsSpy).toHaveBeenCalledWith(triggers[0].actions, mockEvent); + }); +}); + +describe('autocapturePlugin recomputePageActionsData functionality', () => { + let plugin: EnrichmentPlugin | undefined; + let instance: BrowserClient; + let loggerProvider: ILogger; + + // Mock data + const mockLabeledEvents: Record = { + 'local-event-1': { + id: 'local-event-1', + definition: [ + { + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, + filters: [ + { + subprop_key: '[Amplitude] Element Text', + subprop_op: 'is', + subprop_value: ['Local Button'], + }, + ], + }, + ], + }, + 'local-event-2': { + id: 'local-event-2', + definition: [ + { + event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, + filters: [ + { + subprop_key: '[Amplitude] Element Text', + subprop_op: 'is', + subprop_value: ['Local Input'], + }, + ], + }, + ], + }, + }; + + const mockTriggers: Trigger[] = [ + { + id: 'local-trigger-1', + name: 'Local Trigger', + conditions: [ + { + type: 'LABELED_EVENT', + match: { + eventId: 'local-event-1', + }, + }, + ], + actions: [ + { + id: 'local-action-1', + actionType: 'ATTACH_EVENT_PROPERTY', + dataSource: { + sourceType: 'DOM_ELEMENT', + elementExtractType: 'TEXT', + scope: '.container', + selector: '.title', + }, + destinationKey: 'title', + }, + ], + }, + ]; + + const mockRemoteLabeledEvents: Record = { + 'remote-event-1': { + id: 'remote-event-1', + definition: [ + { + event_type: AMPLITUDE_ELEMENT_CLICKED_EVENT, + filters: [ + { + subprop_key: '[Amplitude] Element Text', + subprop_op: 'is', + subprop_value: ['Remote Button'], + }, + ], + }, + ], + }, + 'remote-event-2': { + id: 'remote-event-2', + definition: [ + { + event_type: AMPLITUDE_ELEMENT_CHANGED_EVENT, + filters: [ + { + subprop_key: '[Amplitude] Element Text', + subprop_op: 'is', + subprop_value: ['Remote Input'], + }, + ], + }, + ], + }, + }; + + const mockRemoteTriggers: Trigger[] = [ + { + id: 'remote-trigger-1', + name: 'Remote Trigger', + conditions: [ + { + type: 'LABELED_EVENT', + match: { + eventId: 'remote-event-1', + }, + }, + ], + actions: [ + { + id: 'remote-action-1', + actionType: 'ATTACH_EVENT_PROPERTY', + dataSource: { + sourceType: 'DOM_ELEMENT', + elementExtractType: 'TEXT', + scope: '.remote-container', + selector: '.remote-title', + }, + destinationKey: 'remote-title', + }, + ], + }, + ]; + + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { + hostname: '', + href: '', + pathname: '', + search: '', + }, + writable: true, + }); + }); + + beforeEach(async () => { + mockWindowLocationFromURL(new URL('https://test.com')); + + loggerProvider = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + } as unknown as ILogger; + + instance = createInstance(); + await instance.init('API_KEY', 'USER_ID').promise; + + // Reset all mocks + jest.clearAllMocks(); + }); + + afterEach(() => { + void plugin?.teardown?.(); + if (typeof document !== 'undefined') { + document.getElementsByTagName('body')[0].innerHTML = ''; + } + jest.clearAllMocks(); + }); + + describe('with local pageActions only', () => { + it('should initialize with local pageActions', async () => { + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: { + labeledEvents: mockLabeledEvents, + triggers: mockTriggers, + }, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + // Verify setup was called (indirectly through the functions being used) + expect(plugin?.name).toBe('@amplitude/plugin-autocapture-browser'); + expect(plugin?.type).toBe('enrichment'); + }); + }); + + describe('with remote config integration', () => { + it('should fetch remote config and merge with local pageActions', async () => { + const mockRemoteConfigFetch = { + getRemoteConfig: jest.fn().mockResolvedValue({ + labeledEvents: mockRemoteLabeledEvents, + triggers: mockRemoteTriggers, + }), + }; + + (createRemoteConfigFetch as jest.Mock).mockResolvedValue(mockRemoteConfigFetch); + + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: { + labeledEvents: mockLabeledEvents, + triggers: mockTriggers, + }, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + fetchRemoteConfig: true, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + // Wait for remote config to be fetched and processed + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify remote config fetch was called + expect(createRemoteConfigFetch).toHaveBeenCalledWith({ + localConfig: config, + configKeys: ['analyticsSDK.pageActions'], + }); + + expect(mockRemoteConfigFetch.getRemoteConfig).toHaveBeenCalledWith('analyticsSDK', 'pageActions'); + }); + + it('should handle remote config fetch errors gracefully', async () => { + const mockRemoteConfigFetch = { + getRemoteConfig: jest.fn().mockRejectedValue(new Error('Remote config fetch failed')), + }; + + (createRemoteConfigFetch as jest.Mock).mockResolvedValue(mockRemoteConfigFetch); + + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: { + labeledEvents: mockLabeledEvents, + triggers: mockTriggers, + }, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + fetchRemoteConfig: true, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + // Wait for remote config to be processed + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify error was logged + expect(loggerProvider.error).toHaveBeenCalledWith( + 'Failed to fetch remote config: Error: Remote config fetch failed', + ); + }); + + it('should handle createRemoteConfigFetch errors gracefully', async () => { + (createRemoteConfigFetch as jest.Mock).mockRejectedValue(new Error('Failed to create remote config fetch')); + + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: { + labeledEvents: mockLabeledEvents, + triggers: mockTriggers, + }, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + fetchRemoteConfig: true, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + // Wait for remote config to be processed + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify error was logged + expect(loggerProvider.error).toHaveBeenCalledWith( + 'Failed to create remote config fetch: Error: Failed to create remote config fetch', + ); + }); + + it('should not fetch remote config when fetchRemoteConfig is false', async () => { + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: { + labeledEvents: mockLabeledEvents, + triggers: mockTriggers, + }, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + fetchRemoteConfig: false, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + // Wait to ensure no remote config processing happens + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify remote config fetch was not called + expect(createRemoteConfigFetch).not.toHaveBeenCalled(); + }); + + it('should handle null/undefined remote pageActions', async () => { + const mockRemoteConfigFetch = { + getRemoteConfig: jest.fn().mockResolvedValue(null), + }; + + (createRemoteConfigFetch as jest.Mock).mockResolvedValue(mockRemoteConfigFetch); + + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: { + labeledEvents: mockLabeledEvents, + triggers: mockTriggers, + }, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + fetchRemoteConfig: true, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + // Wait for remote config to be processed + await new Promise((resolve) => setTimeout(resolve, 10)); + + // No errors should be logged for null remote config + expect(loggerProvider.error).not.toHaveBeenCalled(); + }); + + it('should handle when local pageActions is undefined and remote config provides pageActions', async () => { + const mockRemoteConfigFetch = { + getRemoteConfig: jest.fn().mockResolvedValue({ + labeledEvents: mockRemoteLabeledEvents, + triggers: mockRemoteTriggers, + }), + }; + + (createRemoteConfigFetch as jest.Mock).mockResolvedValue(mockRemoteConfigFetch); + + // Start with undefined pageActions + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: undefined, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + fetchRemoteConfig: true, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + // Wait for remote config to be processed + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify remote config fetch was called + expect(createRemoteConfigFetch).toHaveBeenCalledWith({ + localConfig: config, + configKeys: ['analyticsSDK.pageActions'], + }); + + expect(mockRemoteConfigFetch.getRemoteConfig).toHaveBeenCalledWith('analyticsSDK', 'pageActions'); + + // No errors should be logged when starting with undefined pageActions + expect(loggerProvider.error).not.toHaveBeenCalled(); + + // Plugin should still function normally + expect(plugin?.name).toBe('@amplitude/plugin-autocapture-browser'); + expect(plugin?.type).toBe('enrichment'); + }); + + it('should handle when remote pageActions overwrites local labeledEvents with undefined', async () => { + // Mock the module function before creating the plugin + const originalGroupLabeledEvents = triggersModule.groupLabeledEventIdsByEventType; + const groupLabeledEventsSpy = jest.spyOn(triggersModule, 'groupLabeledEventIdsByEventType'); + groupLabeledEventsSpy.mockImplementation(originalGroupLabeledEvents); + + const mockRemoteConfigFetch = { + getRemoteConfig: jest.fn().mockResolvedValue({ + // Remote config explicitly sets labeledEvents to undefined, overwriting local + labeledEvents: undefined, + triggers: mockRemoteTriggers, + }), + }; + + (createRemoteConfigFetch as jest.Mock).mockResolvedValue(mockRemoteConfigFetch); + + // Start with local pageActions that has labeledEvents + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: { + labeledEvents: mockLabeledEvents, + triggers: mockTriggers, + }, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + fetchRemoteConfig: true, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + // Wait for remote config to be processed + await new Promise((resolve) => setTimeout(resolve, 100)); + + // The spy should have been called twice: + // 1. Once during initial plugin creation with local labeledEvents + // 2. Once during recomputePageActionsData with empty array (due to undefined labeledEvents after merge) + expect(groupLabeledEventsSpy).toHaveBeenCalledTimes(2); + + // Check the second call (recomputePageActionsData) received empty array + const secondCall = groupLabeledEventsSpy.mock.calls[1]; + expect(secondCall[0]).toEqual([]); // Object.values({}) when labeledEvents is undefined + + // No errors should be logged + expect(loggerProvider.error).not.toHaveBeenCalled(); + + groupLabeledEventsSpy.mockRestore(); + }); + + it('should handle when both local and remote labeledEvents are undefined', async () => { + // Mock the module function before creating the plugin + const originalGroupLabeledEvents = triggersModule.groupLabeledEventIdsByEventType; + const groupLabeledEventsSpy = jest.spyOn(triggersModule, 'groupLabeledEventIdsByEventType'); + groupLabeledEventsSpy.mockImplementation(originalGroupLabeledEvents); + + const mockRemoteConfigFetch = { + getRemoteConfig: jest.fn().mockResolvedValue({ + labeledEvents: undefined, + triggers: mockRemoteTriggers, + }), + }; + + (createRemoteConfigFetch as jest.Mock).mockResolvedValue(mockRemoteConfigFetch); + + // Start with local pageActions that also has undefined labeledEvents + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: { + labeledEvents: undefined as unknown as Record, + triggers: mockTriggers, + }, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + fetchRemoteConfig: true, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + // Wait for remote config to be processed + await new Promise((resolve) => setTimeout(resolve, 100)); + + // The spy should have been called twice: + // 1. Once during initial plugin creation with empty array (local labeledEvents undefined) + // 2. Once during recomputePageActionsData with empty array (remote labeledEvents also undefined) + expect(groupLabeledEventsSpy).toHaveBeenCalledTimes(2); + + // Both calls should receive empty array due to the "?? {}" fallback + expect(groupLabeledEventsSpy.mock.calls[0][0]).toEqual([]); // Initial call + expect(groupLabeledEventsSpy.mock.calls[1][0]).toEqual([]); // recomputePageActionsData call + + // No errors should be logged + expect(loggerProvider.error).not.toHaveBeenCalled(); + + groupLabeledEventsSpy.mockRestore(); + }); + + it('should handle when both local and remote triggers are undefined', async () => { + // Mock the module function before creating the plugin + const originalCreateLabeledEventToTriggerMap = triggersModule.createLabeledEventToTriggerMap; + const createLabeledEventToTriggerMapSpy = jest.spyOn(triggersModule, 'createLabeledEventToTriggerMap'); + createLabeledEventToTriggerMapSpy.mockImplementation(originalCreateLabeledEventToTriggerMap); + + const mockRemoteConfigFetch = { + getRemoteConfig: jest.fn().mockResolvedValue({ + labeledEvents: mockRemoteLabeledEvents, + triggers: undefined, + }), + }; + + (createRemoteConfigFetch as jest.Mock).mockResolvedValue(mockRemoteConfigFetch); + + // Start with local pageActions that also has undefined triggers + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: { + labeledEvents: mockLabeledEvents, + triggers: undefined as unknown as Trigger[], + }, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + fetchRemoteConfig: true, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + // Wait for remote config to be processed + await new Promise((resolve) => setTimeout(resolve, 100)); + + // The spy should have been called twice: + // 1. Once during initial plugin creation with empty array (local triggers undefined) + // 2. Once during recomputePageActionsData with empty array (remote triggers also undefined) + expect(createLabeledEventToTriggerMapSpy).toHaveBeenCalledTimes(2); + + // Both calls should receive empty array due to the "?? []" fallback + expect(createLabeledEventToTriggerMapSpy.mock.calls[0][0]).toEqual([]); // Initial call + expect(createLabeledEventToTriggerMapSpy.mock.calls[1][0]).toEqual([]); // recomputePageActionsData call + + // No errors should be logged + expect(loggerProvider.error).not.toHaveBeenCalled(); + + createLabeledEventToTriggerMapSpy.mockRestore(); + }); + }); + + describe('plugin initialization without pageActions', () => { + it('should handle initialization without pageActions', async () => { + const autocaptureConfig: ElementInteractionsOptions = {}; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + expect(plugin?.name).toBe('@amplitude/plugin-autocapture-browser'); + expect(plugin?.type).toBe('enrichment'); + }); + + it('should handle undefined pageActions', async () => { + const autocaptureConfig: ElementInteractionsOptions = { + pageActions: undefined, + }; + + plugin = autocapturePlugin(autocaptureConfig); + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + + await plugin?.setup?.(config as BrowserConfig, instance); + + expect(plugin?.name).toBe('@amplitude/plugin-autocapture-browser'); + expect(plugin?.type).toBe('enrichment'); + }); + }); +});