diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 2973141..211df57 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -61,6 +61,32 @@ interface RoktExtensionEntry { value: string; } +// A captured page view, persisted (newest last) under PAGE_VIEWS_KEY. +// pageUrl and eventAttributes are stored verbatim and may contain PII; they are +// persisted to browser storage and sent to Rokt on the next selectPlacements call. +interface StoredPageView { + event_name: string; + pageUrl: string; + sourceMessageId: string; + timestamp: number; + activeTimeOnSite: number; + eventAttributes?: { [key: string]: string }; +} + +// A page view flattened for the outgoing page_events array. Event attributes are +// exploded onto attr_-namespaced keys (the index signature), and timeOnPage is +// derived at read time so it is omitted for the still-open last view. +interface PageEvent { + event_name: string; + page_name?: string; + pageUrl: string; + sourceMessageId: string; + timestamp: number; + activeTimeOnSite: number; + timeOnPage?: number; + [attr: string]: unknown; +} + interface RoktSelection { context?: { sessionId?: Promise; @@ -244,6 +270,15 @@ const ROKT_INTEGRATION_SCRIPT_ID = 'rokt-launcher'; const ROKT_THANK_YOU_ELEMENT_SCRIPT_ID = 'rokt-thank-you-element'; const USER_IDENTIFIED_IN_WORKSPACE_KEY = 'userIdentifiedInWorkspace'; +const MESSAGE_TYPE_PAGE_VIEW = 3; // mParticle MessageType.PageView +const PAGE_VIEWS_KEY = 'mpPageViews'; +const MAX_PAGE_VIEWS = 25; +const PAGE_EVENTS_KEY = 'page_events'; +const PAGE_EVENT_ATTR_PREFIX = 'attr_'; +// The page-view event attribute surfaced as the dedicated page_name field rather +// than an attr_-namespaced key. +const PAGE_TITLE_ATTRIBUTE = 'title'; + // Bound on how long selectPlacements will wait for an in-flight Workspace // IDSync search before proceeding without the userIdentifiedInWorkspace flag. // Long enough to cover the typical /v1/search round-trip (~50ms); short enough that a @@ -450,6 +485,12 @@ function isString(value: unknown): value is string { return typeof value === 'string'; } +// Isolates page-view URL handling. Returns the URL verbatim for now; tightening +// to strip query/fragment (which may carry PII) later is a one-line change here. +function sanitizeUrl(href: string): string { + return href; +} + function generateIntegrationName(customIntegrationName?: string): string { const coreSdkVersion = mp().getVersion(); const kitVersion = process.env.PACKAGE_VERSION; @@ -845,6 +886,34 @@ class RoktKit implements KitInterface { } } + // Appends a page-view record to the persisted list under PAGE_VIEWS_KEY, + // capping at MAX_PAGE_VIEWS (oldest evicted). Wrapped so a malformed event + // can never throw out of the forwarder. Callers must confirm the event is a + // page view and that setLocalSessionAttribute is available. + private capturePageView(event: SDKEvent): void { + try { + const existing = mp().Rokt.getLocalSessionAttributes?.()?.[PAGE_VIEWS_KEY]; + const pageViews: StoredPageView[] = Array.isArray(existing) ? (existing as StoredPageView[]) : []; + + pageViews.push({ + event_name: event.EventName, + pageUrl: sanitizeUrl(window.location.href), + sourceMessageId: event.SourceMessageId, + timestamp: event.Timestamp, + activeTimeOnSite: event.ActiveTimeOnSite, + eventAttributes: event.EventAttributes, + }); + + while (pageViews.length > MAX_PAGE_VIEWS) { + pageViews.shift(); + } + + mp().Rokt.setLocalSessionAttribute?.(PAGE_VIEWS_KEY, pageViews); + } catch (err) { + console.error('Rokt Kit: Failed to capture page view', err); + } + } + private isLauncherReadyToAttach(): boolean { return !!window.Rokt && typeof window.Rokt.createLauncher === 'function'; } @@ -866,12 +935,44 @@ class RoktKit implements KitInterface { if (!mp().Rokt || typeof mp().Rokt.getLocalSessionAttributes !== 'function') { return {}; } - if (isEmpty(this.placementEventMappingLookup) && isEmpty(this.placementEventAttributeMappingLookup)) { - return {}; - } return mp().Rokt.getLocalSessionAttributes!(); } + private buildPageEvents(pageViews: StoredPageView[]): PageEvent[] { + return pageViews.map((pv, i) => { + const flat: PageEvent = { + event_name: pv.event_name, + pageUrl: pv.pageUrl, + sourceMessageId: pv.sourceMessageId, + timestamp: pv.timestamp, + activeTimeOnSite: pv.activeTimeOnSite, + }; + if (pv.eventAttributes) { + for (const [key, value] of Object.entries(pv.eventAttributes)) { + // `title` is surfaced as the dedicated page_name field below, so it is + // not also emitted as an attr_-namespaced key. + if (key === PAGE_TITLE_ATTRIBUTE) { + continue; + } + flat[`${PAGE_EVENT_ATTR_PREFIX}${key}`] = value; + } + } + flat.page_name = pv.eventAttributes?.[PAGE_TITLE_ATTRIBUTE]; + + // Active time on this page = diff to the next view's activeTimeOnSite. + // Omitted for the still-open last view and for negative diffs (clock skew, + // reset, out-of-order) rather than surfacing a misleading value. + const next = pageViews[i + 1]; + if (next && typeof next.activeTimeOnSite === 'number' && typeof pv.activeTimeOnSite === 'number') { + const diff = next.activeTimeOnSite - pv.activeTimeOnSite; + if (diff >= 0) { + flat.timeOnPage = diff; + } + } + return flat; + }); + } + private replaceOtherIdentityWithEmailsha256(userIdentities: IUserIdentities): Record { const newUserIdentities: Record = { ...(userIdentities || {}) }; const key = this._mappedEmailSha256Key; @@ -1165,7 +1266,12 @@ class RoktKit implements KitInterface { if (!this.isKitReady()) { return 'Kit not ready for forwarder: ' + name; } + if (typeof mp().Rokt?.setLocalSessionAttribute === 'function') { + if (event.EventDataType === MESSAGE_TYPE_PAGE_VIEW) { + this.capturePageView(event); + } + if (!isEmpty(this.placementEventAttributeMappingLookup)) { this.applyPlacementEventAttributeMapping(event); } @@ -1375,11 +1481,18 @@ class RoktKit implements KitInterface { const localSessionAttributes = this.returnLocalSessionAttributes(); + // Derive the flat page_events array from the stored page views, then drop the + // raw nested mpPageViews so Rokt receives only the flattened copy. + const rawPageViews = localSessionAttributes[PAGE_VIEWS_KEY]; + const pageEvents = Array.isArray(rawPageViews) ? this.buildPageEvents(rawPageViews as StoredPageView[]) : []; + delete localSessionAttributes[PAGE_VIEWS_KEY]; + const selectPlacementsAttributes: Record = { ...(filteredUserIdentities as Record), ...filteredAttributes, ...optimizelyAttributes, ...localSessionAttributes, + ...(pageEvents.length ? { [PAGE_EVENTS_KEY]: pageEvents } : {}), ...(this.userIdentifiedInWorkspace ? { [USER_IDENTIFIED_IN_WORKSPACE_KEY]: true } : {}), mpid, }; diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 8eaae70..90c031c 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -13,6 +13,15 @@ declare const mParticle: any; const sdkVersion = 'mParticle_wsdkv_1.2.3'; const kitVersion = 'kitv_' + packageVersion; +// Returns localSessionAttributes without the mpPageViews list. Processing a +// PageView legitimately captures a page-view record into the same store, so +// attribute-mapping tests strip it to assert only the mapped keys they set. +const mappedSessionAttributes = () => { + const { mpPageViews, ...attrs } = (window as any).mParticle._Store.localSessionAttributes; + void mpPageViews; + return attrs; +}; + const waitForCondition = async (conditionFn: () => boolean, timeout = 200, interval = 10) => { return new Promise((resolve, reject) => { const startTime = Date.now(); @@ -4901,7 +4910,7 @@ describe('Rokt Forwarder', () => { EventDataType: MessageType.PageEvent, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ 'foo-mapped-flag': true, }); }); @@ -4944,7 +4953,7 @@ describe('Rokt Forwarder', () => { URL: 'https://example.com/home', }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({}); + expect(mappedSessionAttributes()).toEqual({}); (window as any).mParticle._Store.localSessionAttributes = {}; (window as any).mParticle.forwarder.process({ @@ -4955,7 +4964,7 @@ describe('Rokt Forwarder', () => { URL: 'https://example.com/sale/items', }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ saleSeeker: true, }); }); @@ -4993,7 +5002,7 @@ describe('Rokt Forwarder', () => { }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ hasUrl: true, }); }); @@ -5031,7 +5040,7 @@ describe('Rokt Forwarder', () => { }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({}); + expect(mappedSessionAttributes()).toEqual({}); }); it('should support exists operator for placementEventAttributeMapping conditions', async () => { @@ -5068,7 +5077,7 @@ describe('Rokt Forwarder', () => { }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ hasUrl: true, }); @@ -5082,7 +5091,7 @@ describe('Rokt Forwarder', () => { }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({}); + expect(mappedSessionAttributes()).toEqual({}); }); it('should evaluate equals for placementEventAttributeMapping conditions', async () => { @@ -5123,7 +5132,7 @@ describe('Rokt Forwarder', () => { number_of_products: 2, }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ multipleproducts: true, }); @@ -5136,7 +5145,7 @@ describe('Rokt Forwarder', () => { number_of_products: '2', }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ multipleproducts: true, }); }); @@ -5179,7 +5188,7 @@ describe('Rokt Forwarder', () => { number_of_products: 2, }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ containsNumber: true, }); }); @@ -5297,7 +5306,7 @@ describe('Rokt Forwarder', () => { }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ lowerCaseMatches: true, zeroMatches: true, digitMatches: true, @@ -5343,7 +5352,7 @@ describe('Rokt Forwarder', () => { }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({}); + expect(mappedSessionAttributes()).toEqual({}); (window as any).mParticle._Store.localSessionAttributes = {}; (window as any).mParticle.forwarder.process({ @@ -5352,7 +5361,7 @@ describe('Rokt Forwarder', () => { EventDataType: MessageType.PageView, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({}); + expect(mappedSessionAttributes()).toEqual({}); }); it('should require ALL rules for the same mapped key to match (AND across rules)', async () => { @@ -5408,7 +5417,7 @@ describe('Rokt Forwarder', () => { URL: 'https://example.com/sale', }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({}); + expect(mappedSessionAttributes()).toEqual({}); (window as any).mParticle._Store.localSessionAttributes = {}; (window as any).mParticle.forwarder.process({ @@ -5419,7 +5428,7 @@ describe('Rokt Forwarder', () => { URL: 'https://example.com/sale/items', }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ saleSeeker: true, }); }); @@ -5474,7 +5483,7 @@ describe('Rokt Forwarder', () => { URL: 'https://example.com/sale', }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ saleSeeker: true, }); @@ -5487,7 +5496,7 @@ describe('Rokt Forwarder', () => { URL: 'https://example.com/sale/items', }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ saleSeeker: true, saleSeeker1: true, }); @@ -5543,7 +5552,7 @@ describe('Rokt Forwarder', () => { }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ zeroExists: true, falseExists: true, emptyStringExists: true, @@ -5589,7 +5598,7 @@ describe('Rokt Forwarder', () => { }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({}); + expect(mappedSessionAttributes()).toEqual({}); }); it('should support both placementEventMapping and placementEventAttributeMapping together', async () => { @@ -5636,7 +5645,7 @@ describe('Rokt Forwarder', () => { }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ hasUrl: true, }); @@ -5650,7 +5659,7 @@ describe('Rokt Forwarder', () => { }, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ hasUrl: true, 'foo-mapped-flag': true, }); @@ -5662,10 +5671,434 @@ describe('Rokt Forwarder', () => { EventDataType: MessageType.PageEvent, }); - expect((window as any).mParticle._Store.localSessionAttributes).toEqual({ + expect(mappedSessionAttributes()).toEqual({ 'foo-mapped-flag': true, }); }); + + describe('page view capture', () => { + it('appends a page view record with the expected fields when the event is a PageView', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle._Store.localSessionAttributes = {}; + (window as any).mParticle.forwarder.process({ + EventName: 'Home Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-1', + Timestamp: 1712345678000, + ActiveTimeOnSite: 4200, + EventAttributes: { + hostname: 'example.com', + title: 'Home', + }, + }); + + expect((window as any).mParticle._Store.localSessionAttributes.mpPageViews).toEqual([ + { + event_name: 'Home Page', + pageUrl: window.location.href, + sourceMessageId: 'source-message-id-1', + timestamp: 1712345678000, + activeTimeOnSite: 4200, + eventAttributes: { + hostname: 'example.com', + title: 'Home', + }, + }, + ]); + }); + + it('does not append a page view record for a non-PageView event', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle._Store.localSessionAttributes = {}; + (window as any).mParticle.forwarder.process({ + EventName: 'Video Watched', + EventCategory: EventType.Other, + EventDataType: MessageType.PageEvent, + SourceMessageId: 'source-message-id-2', + Timestamp: 1712345679000, + ActiveTimeOnSite: 100, + }); + + expect((window as any).mParticle._Store.localSessionAttributes.mpPageViews).toBeUndefined(); + }); + + it('caps the stored list at 25 entries and evicts the oldest', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle._Store.localSessionAttributes = {}; + for (let i = 0; i < 30; i++) { + (window as any).mParticle.forwarder.process({ + EventName: 'Page ' + i, + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-' + i, + Timestamp: 1712345678000 + i, + ActiveTimeOnSite: i, + }); + } + + const stored = (window as any).mParticle._Store.localSessionAttributes.mpPageViews; + expect(stored.length).toBe(25); + // Oldest five (Page 0..4) evicted; newest retained. + expect(stored[0].event_name).toBe('Page 5'); + expect(stored[24].event_name).toBe('Page 29'); + }); + + it('does not throw when setLocalSessionAttribute is unavailable', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + delete (window as any).mParticle.Rokt.setLocalSessionAttribute; + (window as any).mParticle._Store.localSessionAttributes = {}; + + expect(() => { + (window as any).mParticle.forwarder.process({ + EventName: 'Home Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-3', + Timestamp: 1712345678000, + ActiveTimeOnSite: 10, + }); + }).not.toThrow(); + + expect((window as any).mParticle._Store.localSessionAttributes.mpPageViews).toBeUndefined(); + }); + + it('surfaces stored page views through selectPlacements as page_events without any placement mapping configured', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle._Store.localSessionAttributes = {}; + (window as any).mParticle.forwarder.process({ + EventName: 'Home Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-4', + Timestamp: 1712345678000, + ActiveTimeOnSite: 4200, + }); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + const forwardedAttributes = (window as any).mParticle.Rokt.selectPlacementsOptions.attributes; + // The raw nested store must not ride along; only the flat page_events array is sent. + expect(forwardedAttributes.mpPageViews).toBeUndefined(); + expect(forwardedAttributes.page_events).toEqual([ + { + event_name: 'Home Page', + pageUrl: window.location.href, + sourceMessageId: 'source-message-id-4', + timestamp: 1712345678000, + activeTimeOnSite: 4200, + }, + ]); + }); + + it('explodes eventAttributes into attr_-namespaced keys in page_events', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle._Store.localSessionAttributes = {}; + (window as any).mParticle.forwarder.process({ + EventName: 'Product Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-5', + Timestamp: 1712345678000, + ActiveTimeOnSite: 4200, + EventAttributes: { + category: 'shoes', + promo: 'x', + title: 'Product Page Title', + }, + }); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + const forwardedAttributes = (window as any).mParticle.Rokt.selectPlacementsOptions.attributes; + // `title` is surfaced as page_name and must NOT also appear as attr_title. + expect(forwardedAttributes.page_events).toEqual([ + { + attr_category: 'shoes', + attr_promo: 'x', + event_name: 'Product Page', + page_name: 'Product Page Title', + pageUrl: window.location.href, + sourceMessageId: 'source-message-id-5', + timestamp: 1712345678000, + activeTimeOnSite: 4200, + }, + ]); + }); + + it('namespaces a colliding eventAttribute key so it cannot clobber a base field', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle._Store.localSessionAttributes = {}; + (window as any).mParticle.forwarder.process({ + EventName: 'Real Name', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-6', + Timestamp: 1712345678000, + ActiveTimeOnSite: 4200, + EventAttributes: { + event_name: 'attribute-event-name', + }, + }); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + const forwardedAttributes = (window as any).mParticle.Rokt.selectPlacementsOptions.attributes; + expect(forwardedAttributes.page_events).toEqual([ + { + attr_event_name: 'attribute-event-name', + event_name: 'Real Name', + page_name: undefined, + pageUrl: window.location.href, + sourceMessageId: 'source-message-id-6', + timestamp: 1712345678000, + activeTimeOnSite: 4200, + }, + ]); + }); + + it('does not add a page_events attribute when no page views are stored', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle._Store.localSessionAttributes = {}; + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + const forwardedAttributes = (window as any).mParticle.Rokt.selectPlacementsOptions.attributes; + expect(forwardedAttributes.page_events).toBeUndefined(); + expect(forwardedAttributes.mpPageViews).toBeUndefined(); + }); + + it('surfaces timeOnPage as the active-time diff to the next page view', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle._Store.localSessionAttributes = {}; + (window as any).mParticle.forwarder.process({ + EventName: 'Home Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-7', + Timestamp: 1712345678000, + ActiveTimeOnSite: 1000, + }); + (window as any).mParticle.forwarder.process({ + EventName: 'Product Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-8', + Timestamp: 1712345679000, + ActiveTimeOnSite: 4200, + }); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + const forwardedAttributes = (window as any).mParticle.Rokt.selectPlacementsOptions.attributes; + // First page's time-on-page is how long it was viewed before the next page: + // 4200 - 1000 = 3200. The last (still-open) page has no timeOnPage. + expect(forwardedAttributes.page_events).toEqual([ + { + event_name: 'Home Page', + pageUrl: window.location.href, + sourceMessageId: 'source-message-id-7', + timestamp: 1712345678000, + activeTimeOnSite: 1000, + timeOnPage: 3200, + }, + { + event_name: 'Product Page', + pageUrl: window.location.href, + sourceMessageId: 'source-message-id-8', + timestamp: 1712345679000, + activeTimeOnSite: 4200, + }, + ]); + }); + + it('computes a consecutive timeOnPage diff for each non-last page view', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle._Store.localSessionAttributes = {}; + (window as any).mParticle.forwarder.process({ + EventName: 'Page A', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-9', + Timestamp: 1712345678000, + ActiveTimeOnSite: 1000, + }); + (window as any).mParticle.forwarder.process({ + EventName: 'Page B', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-10', + Timestamp: 1712345679000, + ActiveTimeOnSite: 2500, + }); + (window as any).mParticle.forwarder.process({ + EventName: 'Page C', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-11', + Timestamp: 1712345680000, + ActiveTimeOnSite: 9000, + }); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + const forwardedAttributes = (window as any).mParticle.Rokt.selectPlacementsOptions.attributes; + const pageEvents = forwardedAttributes.page_events; + expect(pageEvents[0].timeOnPage).toBe(1500); // 2500 - 1000 + expect(pageEvents[1].timeOnPage).toBe(6500); // 9000 - 2500 + expect(pageEvents[2].timeOnPage).toBeUndefined(); // still open + }); + + it('omits timeOnPage when the active-time diff would be negative', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle._Store.localSessionAttributes = {}; + (window as any).mParticle.forwarder.process({ + EventName: 'Page A', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-12', + Timestamp: 1712345678000, + ActiveTimeOnSite: 5000, + }); + (window as any).mParticle.forwarder.process({ + EventName: 'Page B', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-13', + Timestamp: 1712345679000, + ActiveTimeOnSite: 1000, + }); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + const forwardedAttributes = (window as any).mParticle.Rokt.selectPlacementsOptions.attributes; + const pageEvents = forwardedAttributes.page_events; + // 1000 - 5000 = -4000 → omitted rather than emitting a misleading value. + expect(pageEvents[0].timeOnPage).toBeUndefined(); + expect(pageEvents[1].timeOnPage).toBeUndefined(); + }); + }); }); describe('#_setRoktSessionId', () => {