diff --git a/packages/universal/core-sdk/src/CoreStateful.test.ts b/packages/universal/core-sdk/src/CoreStateful.test.ts index d616baa95..4fc9c7914 100644 --- a/packages/universal/core-sdk/src/CoreStateful.test.ts +++ b/packages/universal/core-sdk/src/CoreStateful.test.ts @@ -76,6 +76,10 @@ class CoreStatefulTestHarness extends CoreStateful { setOnlineState(isOnline: boolean): void { this.online = isOnline } + + async forceFlushWithBeacon(beacon: (url: string, body: string) => boolean): Promise { + await this.flushQueues({ force: true, beacon }) + } } describe('CoreStateful blocked event handling', () => { @@ -345,6 +349,99 @@ describe('CoreStateful blocked event handling', () => { } }) + it('retains Insights events queued while a successful flush is in flight', async () => { + const core = createCoreStatefulHarness({ + defaults: { + consent: true, + profile: profileFixture, + }, + }) + const firstRequest = createDeferred() + const sendBatchEvents = rs + .spyOn(core.api.insights, 'sendBatchEvents') + .mockImplementationOnce(async () => { + await firstRequest.promise + return true + }) + .mockResolvedValue(true) + + await core.trackClick({ componentId: 'first-click' }) + const firstFlush = core.flush() + await flushMicrotasks() + + await core.trackClick({ componentId: 'queued-during-flush' }) + firstRequest.resolve() + await firstFlush + await core.flush() + + expect(sendBatchEvents).toHaveBeenCalledTimes(2) + expect(sendBatchEvents.mock.calls[1]?.[0][0]?.events).toEqual([ + expect.objectContaining({ componentId: 'queued-during-flush' }), + ]) + }) + + it('does not skip a forced beacon flush while a normal Insights flush is in flight', async () => { + const core = createCoreStatefulHarness({ + defaults: { + consent: true, + profile: profileFixture, + }, + }) + const firstRequest = createDeferred() + const sendBatchEvents = rs + .spyOn(core.api.insights, 'sendBatchEvents') + .mockImplementationOnce(async () => { + await firstRequest.promise + return true + }) + .mockResolvedValue(true) + const beacon = rs.fn<(url: string, body: string) => boolean>(() => true) + + await core.trackClick({ componentId: 'first-click' }) + const normalFlush = core.flush() + await flushMicrotasks() + await core.trackClick({ componentId: 'lifecycle-click' }) + + const lifecycleFlush = core.forceFlushWithBeacon(beacon) + await flushMicrotasks() + + try { + expect(sendBatchEvents).toHaveBeenCalledTimes(2) + expect(sendBatchEvents.mock.calls[1]?.[1]?.beacon).toBe(beacon) + expect(sendBatchEvents.mock.calls[1]?.[0][0]?.events).toEqual( + expect.arrayContaining([expect.objectContaining({ componentId: 'lifecycle-click' })]), + ) + } finally { + firstRequest.resolve() + await normalFlush + await lifecycleFlush + } + }) + + it('does not treat beacon queue acceptance as server acknowledgement', async () => { + const core = createCoreStatefulHarness({ + defaults: { + consent: true, + profile: profileFixture, + }, + }) + const beacon = rs.fn<(url: string, body: string) => boolean>(() => true) + const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + + await core.trackClick({ componentId: 'beacon-replay-click' }) + await core.forceFlushWithBeacon(beacon) + await core.flush() + + expect(sendBatchEvents).toHaveBeenCalledTimes(2) + expect(sendBatchEvents.mock.calls[0]?.[1]?.beacon).toBe(beacon) + + const lifecycleEvent = sendBatchEvents.mock.calls[0]?.[0][0]?.events[0] + const acknowledgedEvent = sendBatchEvents.mock.calls[1]?.[0][0]?.events[0] + + expect(acknowledgedEvent?.messageId).toBe(lifecycleEvent?.messageId) + expect(sendBatchEvents.mock.calls[1]?.[1]).toBeUndefined() + }) + it('uses queuePolicy.offlineMaxEvents and onOfflineDrop for Experience buffering', async () => { rs.useFakeTimers() diff --git a/packages/web/web-sdk/src/ContentfulOptimization.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.test.ts index 86bd6e209..7fdc3a80f 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.test.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.test.ts @@ -704,6 +704,37 @@ describe('ContentfulOptimization', () => { expect(invocations).toEqual(['flushActiveInteractions', 'sendBatchEvents']) }) + it('includes lifecycle-flushed entry interactions in the beacon payload', async () => { + const web = new ContentfulOptimization({ + ...config, + defaults: { consent: true, profile: DEFAULT_PROFILE }, + }) + + const runtime: unknown = Reflect.get(web, 'entryInteractionRuntime') + if (!(runtime instanceof EntryInteractionRuntime)) { + throw new Error('entryInteractionRuntime is unavailable') + } + + rs.spyOn(runtime, 'flushActiveInteractions').mockImplementation(() => { + void web.trackView({ + componentId: 'lifecycle-final-view', + viewDurationMs: 1750, + viewId: 'lifecycle-view-id', + }) + }) + const sendBeacon = rs.spyOn(window.navigator, 'sendBeacon').mockReturnValue(true) + + await web.trackClick({ componentId: 'already-queued-click' }) + window.dispatchEvent(new Event('pagehide')) + await Promise.resolve() + await Promise.resolve() + + expect(sendBeacon).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('"componentId":"lifecycle-final-view"'), + ) + }) + it('allows creating a new instance after destroy', () => { const first = new ContentfulOptimization(config) const createSecondOptimization = (): ContentfulOptimization => diff --git a/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts b/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts index b9b3cd8c4..d021f6b8f 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts @@ -74,6 +74,37 @@ describe('EntryHoverTracker', () => { cleanup() }) + it('emits a final duration update when an active tracked entry is removed', async () => { + const entry = document.createElement('div') + entry.dataset.ctflEntryId = 'entry-removed-hover' + document.body.append(entry) + + const { core, trackHover } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryHoverDetector(core)) + + tracker.start({ dwellTimeMs: 0, hoverDurationUpdateIntervalMs: 10_000 }) + + dispatchHoverEnter(entry) + await advance(0) + await advance(500) + + entry.remove() + await advance(0) + + const firstPayload = trackHover.mock.calls[0]?.[0] + const finalPayload = trackHover.mock.calls[1]?.[0] + cleanup() + + expect(trackHover).toHaveBeenCalledTimes(2) + expect(finalPayload).toEqual( + expect.objectContaining({ + componentId: 'entry-removed-hover', + hoverDurationMs: 500, + hoverId: firstPayload?.hoverId, + }), + ) + }) + it('prefers manual data when manually observing an element', async () => { const element = document.createElement('section') document.body.append(element) diff --git a/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts b/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts index 1e2568ed1..fd1898c02 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/view/createEntryViewDetector.test.ts @@ -107,6 +107,43 @@ describe('EntryViewTracker', () => { cleanup() }) + it('emits a final duration update when an active tracked entry is removed', async () => { + const entry = document.createElement('div') + entry.dataset.ctflEntryId = 'entry-removed-view' + document.body.append(entry) + + const { core, trackView } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryViewDetector(core)) + + tracker.start({ dwellTimeMs: 0, viewDurationUpdateIntervalMs: 10_000 }) + + const instance = io.getLast() + + if (!instance) { + throw new Error('IntersectionObserver polyfill instance not found') + } + + instance.trigger({ target: entry, isIntersecting: true, intersectionRatio: 1 }) + await advance(0) + await advance(500) + + entry.remove() + await advance(0) + + const firstPayload = trackView.mock.calls[0]?.[0] + const finalPayload = trackView.mock.calls[1]?.[0] + cleanup() + + expect(trackView).toHaveBeenCalledTimes(2) + expect(finalPayload).toEqual( + expect.objectContaining({ + componentId: 'entry-removed-view', + viewDurationMs: 500, + viewId: firstPayload?.viewId, + }), + ) + }) + it('tracks a display:contents entry through its single rendered child', async () => { const entry = document.createElement('div') entry.dataset.ctflEntryId = 'entry-single-child-view'