Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions packages/universal/core-sdk/src/CoreStateful.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ class CoreStatefulTestHarness extends CoreStateful {
setOnlineState(isOnline: boolean): void {
this.online = isOnline
}

async forceFlushWithBeacon(beacon: (url: string, body: string) => boolean): Promise<void> {
await this.flushQueues({ force: true, beacon })
}
}

describe('CoreStateful blocked event handling', () => {
Expand Down Expand Up @@ -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()

Expand Down
31 changes: 31 additions & 0 deletions packages/web/web-sdk/src/ContentfulOptimization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading