diff --git a/packages/core/src/domain/session/sessionManager.spec.ts b/packages/core/src/domain/session/sessionManager.spec.ts index bdcdd7aa22..29c181c42c 100644 --- a/packages/core/src/domain/session/sessionManager.spec.ts +++ b/packages/core/src/domain/session/sessionManager.spec.ts @@ -1,684 +1,883 @@ import { + collectAsyncCalls, + createFakeSessionStoreStrategy, createNewEvent, - expireCookie, - getSessionState, + HIGH_HASH_UUID, + LOW_HASH_UUID, mockClock, registerCleanupTask, + replaceMockable, restorePageVisibility, setPageVisibility, } from '../../../test' import type { Clock } from '../../../test' -import { getCookie, setCookie } from '../../browser/cookie' import { DOM_EVENT } from '../../browser/addEventListener' -import { ONE_HOUR, ONE_SECOND } from '../../tools/utils/timeUtils' +import { display } from '../../tools/display' +import { ONE_SECOND } from '../../tools/utils/timeUtils' import type { Configuration } from '../configuration' import type { TrackingConsentState } from '../trackingConsent' import { TrackingConsent, createTrackingConsentState } from '../trackingConsent' import type { SessionManager } from './sessionManager' -import { startSessionManager, stopSessionManager, VISIBILITY_CHECK_DELAY } from './sessionManager' import { - SESSION_EXPIRATION_DELAY, - SESSION_NOT_TRACKED, - SESSION_TIME_OUT_DELAY, - SessionPersistence, -} from './sessionConstants' + startSessionManager, + startSessionManagerStub, + stopSessionManager, + TRACKED_SESSION_MAX_AGE, + VISIBILITY_CHECK_DELAY, +} from './sessionManager' +import { getSessionStoreStrategy } from './sessionStore' +import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants' import type { SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy' -import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' -import { STORAGE_POLL_DELAY } from './sessionStore' - -const enum FakeTrackingType { - NOT_TRACKED = SESSION_NOT_TRACKED, - TRACKED = 'tracked', -} +import type { SessionState } from './sessionState' +import { EXPIRED } from './sessionState' describe('startSessionManager', () => { - const DURATION = 123456 - const FIRST_PRODUCT_KEY = 'first' - const SECOND_PRODUCT_KEY = 'second' const STORE_TYPE: SessionStoreStrategyType = { type: SessionPersistence.COOKIE, cookieOptions: {} } + let fakeStrategy: ReturnType let clock: Clock - - function expireSessionCookie() { - expireCookie() - clock.tick(STORAGE_POLL_DELAY) - } - - function deleteSessionCookie() { - setCookie(SESSION_STORE_KEY, '', DURATION) - clock.tick(STORAGE_POLL_DELAY) - } - - function expectSessionIdToBe(sessionManager: SessionManager, sessionId: string) { - expect(sessionManager.findSession()!.id).toBe(sessionId) - expect(getSessionState(SESSION_STORE_KEY).id).toBe(sessionId) - } - - function expectSessionIdToBeDefined(sessionManager: SessionManager) { - expect(sessionManager.findSession()!.id).toMatch(/^[a-f0-9-]+$/) - expect(sessionManager.findSession()?.isExpired).toBeUndefined() - - expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/^[a-f0-9-]+$/) - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBeUndefined() - } - - function expectSessionToBeExpired(sessionManager: SessionManager) { - expect(sessionManager.findSession()).toBeUndefined() - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') - } - - function expectSessionIdToNotBeDefined(sessionManager: SessionManager) { - expect(sessionManager.findSession()!.id).toBeUndefined() - expect(getSessionState(SESSION_STORE_KEY).id).toBeUndefined() - } - - function expectTrackingTypeToBe( - sessionManager: SessionManager, - productKey: string, - trackingType: FakeTrackingType - ) { - expect(sessionManager.findSession()!.trackingType).toEqual(trackingType) - expect(getSessionState(SESSION_STORE_KEY)[productKey]).toEqual(trackingType) - } - - function expectTrackingTypeToNotBeDefined(sessionManager: SessionManager, productKey: string) { - expect(sessionManager.findSession()?.trackingType).toBeUndefined() - expect(getSessionState(SESSION_STORE_KEY)[productKey]).toBeUndefined() + let sessionObservableSpy!: jasmine.Spy + + /** + * Creates a fresh fake strategy and updates the mockable reference. + * Since `replaceMockable` can only be called once per test, we use a mutable + * container that always returns the current `fakeStrategy`. + */ + function setupFakeStrategy(options?: Parameters[0]) { + fakeStrategy = createFakeSessionStoreStrategy(options) } beforeEach(() => { + sessionObservableSpy = jasmine.createSpy('sessionObservable') clock = mockClock() + fakeStrategy = createFakeSessionStoreStrategy() + fakeStrategy.sessionObservable.subscribe(sessionObservableSpy) + // Register the mockable once, pointing to a function that always returns the current fakeStrategy + replaceMockable(getSessionStoreStrategy, () => fakeStrategy) registerCleanupTask(() => { - // remove intervals first stopSessionManager() - // flush pending callbacks to avoid random failures - clock.tick(ONE_HOUR) + clock.tick(SESSION_TIME_OUT_DELAY) }) }) - describe('resume from a frozen tab ', () => { - it('when session in store, do nothing', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&first=tracked', DURATION) - const sessionManager = startSessionManagerWithDefaults() + async function startSessionManagerWithDefaults({ + configuration, + trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED), + }: { + configuration?: Partial + trackingConsentState?: TrackingConsentState + } = {}): Promise { + const sessionManager = await startSessionManager( + { + sessionStoreStrategyType: STORE_TYPE, + sessionSampleRate: 100, + ...configuration, + } as Configuration, + trackingConsentState + ) + return sessionManager! + } - window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) + describe('initialization', () => { + it('should not start if no session store strategy type is configured', async () => { + const displayWarnSpy = spyOn(display, 'warn') - expectSessionIdToBe(sessionManager, 'abcdef') - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) + const sessionManager = await startSessionManager( + { sessionStoreStrategyType: undefined } as Configuration, + createTrackingConsentState(TrackingConsent.GRANTED) + ) + + expect(displayWarnSpy).toHaveBeenCalledWith('No storage available for session. We will not send any data.') + expect(sessionManager).toBeUndefined() }) - it('when session not in store, reinitialize a session in store', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should call setSessionState to initialize the session', async () => { + await startSessionManagerWithDefaults() - deleteSessionCookie() + expect(fakeStrategy.setSessionState).toHaveBeenCalled() + }) - expect(sessionManager.findSession()).toBeUndefined() - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + it('should resolve with undefined if session initialization fails', async () => { + fakeStrategy.setSessionState.and.returnValue(Promise.reject(new Error('storage failure'))) - window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) + const sessionManager = await startSessionManager( + { sessionStoreStrategyType: STORE_TYPE, sessionSampleRate: 100, trackAnonymousUser: false } as Configuration, + createTrackingConsentState(TrackingConsent.GRANTED) + ) - expectSessionToBeExpired(sessionManager) + expect(sessionManager).toBeUndefined() }) - }) - describe('cookie management', () => { - it('when tracked, should store tracking type and session id', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should resolve after initialization', async () => { + const sessionManager = await startSessionManager( + { sessionStoreStrategyType: STORE_TYPE, sessionSampleRate: 100, trackAnonymousUser: false } as Configuration, + createTrackingConsentState(TrackingConsent.GRANTED) + ) - expectSessionIdToBeDefined(sessionManager) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) + expect(sessionManager).toBeDefined() }) - it('when not tracked should store tracking type', () => { - const sessionManager = startSessionManagerWithDefaults({ - computeTrackingType: () => FakeTrackingType.NOT_TRACKED, - }) + it('should start with an active session on fresh initialization', async () => { + await startSessionManagerWithDefaults() - expectSessionIdToNotBeDefined(sessionManager) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) + // Fresh init creates a session immediately (initialize + expand) + const state = fakeStrategy.getInternalState() + expect(state.isExpired).toBeUndefined() + expect(state.id).toMatch(/^[a-f0-9-]+$/) }) - it('when tracked should keep existing tracking type and session id', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&first=tracked', DURATION) - - const sessionManager = startSessionManagerWithDefaults() + it('should create a session with a real id after user activity', async () => { + const sessionManager = await startSessionManagerWithDefaults() - expectSessionIdToBe(sessionManager, 'abcdef') - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) + expect(sessionManager.findSession()).toBeDefined() + expect(sessionManager.findSession()!.id).toMatch(/^[a-f0-9-]+$/) }) - it('when not tracked should keep existing tracking type', () => { - setCookie(SESSION_STORE_KEY, `first=${SESSION_NOT_TRACKED}`, DURATION) - - const sessionManager = startSessionManagerWithDefaults({ - computeTrackingType: () => FakeTrackingType.NOT_TRACKED, + it('should generate an anonymousId when trackAnonymousUser is enabled', async () => { + const sessionManager = await startSessionManagerWithDefaults({ + configuration: { trackAnonymousUser: true }, }) - expectSessionIdToNotBeDefined(sessionManager) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) + expect(sessionManager.findSession()!.anonymousId).toMatch(/^[a-f0-9-]+$/) }) - }) - - describe('computeTrackingType', () => { - let spy: (rawTrackingType?: string) => FakeTrackingType - beforeEach(() => { - spy = jasmine.createSpy().and.returnValue(FakeTrackingType.TRACKED) - }) + it('should not generate an anonymousId when trackAnonymousUser is disabled', async () => { + const sessionManager = await startSessionManagerWithDefaults({ + configuration: { trackAnonymousUser: false }, + }) - it('should be called with an empty value if the cookie is not defined', () => { - startSessionManagerWithDefaults({ computeTrackingType: spy }) - expect(spy).toHaveBeenCalledWith(undefined) + expect(sessionManager.findSession()!.anonymousId).toBeUndefined() }) - it('should be called with an invalid value if the cookie has an invalid value', () => { - setCookie(SESSION_STORE_KEY, 'first=invalid', DURATION) - startSessionManagerWithDefaults({ computeTrackingType: spy }) - expect(spy).toHaveBeenCalledWith('invalid') - }) + it('should keep existing session when strategy has an active session', async () => { + setupFakeStrategy({ + initialSession: { + id: 'existing-id', + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }, + }) - it('should be called with TRACKED', () => { - setCookie(SESSION_STORE_KEY, 'first=tracked', DURATION) - startSessionManagerWithDefaults({ computeTrackingType: spy }) - expect(spy).toHaveBeenCalledWith(FakeTrackingType.TRACKED) - }) + const sessionManager = await startSessionManagerWithDefaults() - it('should be called with NOT_TRACKED', () => { - setCookie(SESSION_STORE_KEY, `first=${SESSION_NOT_TRACKED}`, DURATION) - startSessionManagerWithDefaults({ computeTrackingType: spy }) - expect(spy).toHaveBeenCalledWith(FakeTrackingType.NOT_TRACKED) + expect(sessionManager.findSession()!.id).toBe('existing-id') }) }) describe('session renewal', () => { - it('should renew on activity after expiration', () => { - const sessionManager = startSessionManagerWithDefaults() - const renewSessionSpy = jasmine.createSpy() - sessionManager.renewObservable.subscribe(renewSessionSpy) + it('should renew on user activity after expiration', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const renewSpy = jasmine.createSpy('renew') + sessionManager.renewObservable.subscribe(renewSpy) - expireSessionCookie() + const initialId = sessionManager.findSession()!.id - expect(renewSessionSpy).not.toHaveBeenCalled() + // Expire the session + sessionManager.expire() - expectSessionToBeExpired(sessionManager) - expectTrackingTypeToNotBeDefined(sessionManager, FIRST_PRODUCT_KEY) + expect(renewSpy).not.toHaveBeenCalled() + // Wait for throttle to clear + clock.tick(ONE_SECOND) + + // Activity triggers expandOrRenew document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expect(renewSessionSpy).toHaveBeenCalled() - expectSessionIdToBeDefined(sessionManager) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) + await collectAsyncCalls(sessionObservableSpy, 3) // 1 for initial session, 1 for expire, 1 for renew + + expect(renewSpy).toHaveBeenCalledTimes(1) + expect(sessionManager.findSession()!.id).toBeDefined() + expect(sessionManager.findSession()!.id).not.toBe(initialId) }) - it('should not renew on visibility after expiration', () => { - const sessionManager = startSessionManagerWithDefaults() - const renewSessionSpy = jasmine.createSpy() - sessionManager.renewObservable.subscribe(renewSessionSpy) + it('should not renew on visibility check after expiration', async () => { + setPageVisibility('visible') + registerCleanupTask(restorePageVisibility) - expireSessionCookie() + const sessionManager = await startSessionManagerWithDefaults() + const renewSpy = jasmine.createSpy('renew') + sessionManager.renewObservable.subscribe(renewSpy) + + sessionManager.expire() clock.tick(VISIBILITY_CHECK_DELAY) - expect(renewSessionSpy).not.toHaveBeenCalled() - expectSessionToBeExpired(sessionManager) + expect(renewSpy).not.toHaveBeenCalled() }) - it('should not renew on activity if cookie is deleted by a 3rd party', () => { - const sessionManager = startSessionManagerWithDefaults() - const renewSessionSpy = jasmine.createSpy('renewSessionSpy') - sessionManager.renewObservable.subscribe(renewSessionSpy) + it('should throttle expandOrRenew calls from activity', async () => { + await startSessionManagerWithDefaults() - deleteSessionCookie() + // The initial click + expandOrRenew already consumed the first throttle window. + // Wait for throttle to clear. + clock.tick(ONE_SECOND) - expect(renewSessionSpy).not.toHaveBeenCalled() - - expect(sessionManager.findSession()).toBeUndefined() - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + const callCountBefore = fakeStrategy.setSessionState.calls.count() + // Multiple rapid clicks within the throttle window + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expect(renewSessionSpy).not.toHaveBeenCalled() - expect(sessionManager.findSession()).toBeUndefined() - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + // Only one call (leading edge) should have fired immediately + expect(fakeStrategy.setSessionState.calls.count() - callCountBefore).toBe(1) + + // After throttle delay, the trailing call fires (from the queued clicks) + clock.tick(ONE_SECOND) + + // Leading (1) + trailing (1) = 2 calls total + expect(fakeStrategy.setSessionState.calls.count() - callCountBefore).toBe(2) }) }) - describe('multiple startSessionManager calls', () => { - it('should re-use the same session id', () => { - const firstSessionManager = startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }) - const idA = firstSessionManager.findSession()!.id + describe('session expiration', () => { + it('should fire expireObservable when session expires', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const expireSpy = jasmine.createSpy('expire') + sessionManager.expireObservable.subscribe(expireSpy) - const secondSessionManager = startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) - const idB = secondSessionManager.findSession()!.id + sessionManager.expire() - expect(idA).toBe(idB) + expect(expireSpy).toHaveBeenCalledTimes(1) }) - it('should not erase other session type', () => { - startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }) - - // schedule an expandOrRenewSession - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + it('should only fire expireObservable once for multiple expire calls', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const expireSpy = jasmine.createSpy('expire') + sessionManager.expireObservable.subscribe(expireSpy) - clock.tick(STORAGE_POLL_DELAY / 2) + sessionManager.expire() + sessionManager.expire() - // expand first session cookie cache - document.dispatchEvent(createNewEvent(DOM_EVENT.VISIBILITY_CHANGE)) + expect(expireSpy).toHaveBeenCalledTimes(1) + }) - startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) + it('should set isExpired in the strategy state after expire()', async () => { + const sessionManager = await startSessionManagerWithDefaults() - // cookie correctly set - expect(getSessionState(SESSION_STORE_KEY).first).toBeDefined() - expect(getSessionState(SESSION_STORE_KEY).second).toBeDefined() + const stateBefore = fakeStrategy.getInternalState() + expect(stateBefore.isExpired).toBeUndefined() + expect(stateBefore.id).toBeDefined() - clock.tick(STORAGE_POLL_DELAY / 2) + sessionManager.expire() - // scheduled expandOrRenewSession should not use cached value - expect(getSessionState(SESSION_STORE_KEY).first).toBeDefined() - expect(getSessionState(SESSION_STORE_KEY).second).toBeDefined() + const stateAfter = fakeStrategy.getInternalState() + expect(stateAfter.isExpired).toBe(EXPIRED) }) - it('should have independent tracking types', () => { - const firstSessionManager = startSessionManagerWithDefaults({ - productKey: FIRST_PRODUCT_KEY, - computeTrackingType: () => FakeTrackingType.TRACKED, - }) - const secondSessionManager = startSessionManagerWithDefaults({ - productKey: SECOND_PRODUCT_KEY, - computeTrackingType: () => FakeTrackingType.NOT_TRACKED, - }) + it('should renew on user activity after expire()', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const initialId = sessionManager.findSession()!.id + + sessionManager.expire() + expect(sessionManager.findSession()).toBeUndefined() + + // Wait for throttle + clock.tick(ONE_SECOND) + + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + await collectAsyncCalls(sessionObservableSpy, 3) // 1 for initial session, 1 for expire, 1 for renew - expect(firstSessionManager.findSession()!.trackingType).toEqual(FakeTrackingType.TRACKED) - expect(secondSessionManager.findSession()!.trackingType).toEqual(FakeTrackingType.NOT_TRACKED) + expect(sessionManager.findSession()).toBeDefined() + expect(sessionManager.findSession()!.id).not.toBe(initialId) }) + }) - it('should notify each expire and renew observables', () => { - const firstSessionManager = startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }) - const expireSessionASpy = jasmine.createSpy() - firstSessionManager.expireObservable.subscribe(expireSessionASpy) - const renewSessionASpy = jasmine.createSpy() - firstSessionManager.renewObservable.subscribe(renewSessionASpy) + describe('automatic session expiration', () => { + beforeEach(() => { + setPageVisibility('hidden') + registerCleanupTask(restorePageVisibility) + }) + + it('should expand session duration on activity', async () => { + const sessionManager = await startSessionManagerWithDefaults() - const secondSessionManager = startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) - const expireSessionBSpy = jasmine.createSpy() - secondSessionManager.expireObservable.subscribe(expireSessionBSpy) - const renewSessionBSpy = jasmine.createSpy() - secondSessionManager.renewObservable.subscribe(renewSessionBSpy) + expect(sessionManager.findSession()).toBeDefined() - expireSessionCookie() + clock.tick(SESSION_EXPIRATION_DELAY - 100) - expect(expireSessionASpy).toHaveBeenCalled() - expect(expireSessionBSpy).toHaveBeenCalled() - expect(renewSessionASpy).not.toHaveBeenCalled() - expect(renewSessionBSpy).not.toHaveBeenCalled() + // Wait for throttle to clear before dispatching activity + clock.tick(ONE_SECOND) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expect(renewSessionASpy).toHaveBeenCalled() - expect(renewSessionBSpy).toHaveBeenCalled() + // Session should still be active (expire time was extended) + const state = fakeStrategy.getInternalState() + expect(state.expire).toBeDefined() + expect(Number(state.expire)).toBeGreaterThan(Date.now()) }) - }) - describe('session timeout', () => { - it('should expire the session when the time out delay is reached', () => { - const sessionManager = startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + it('should expand session on visibility when visible', async () => { + setPageVisibility('visible') + + const sessionManager = await startSessionManagerWithDefaults() expect(sessionManager.findSession()).toBeDefined() - expect(getCookie(SESSION_STORE_KEY)).toBeDefined() - clock.tick(SESSION_TIME_OUT_DELAY) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalled() + const initialExpire = fakeStrategy.getInternalState().expire + + clock.tick(VISIBILITY_CHECK_DELAY) + + // Visibility check should have expanded the session + const newExpire = fakeStrategy.getInternalState().expire + expect(Number(newExpire)).toBeGreaterThan(Number(initialExpire)) + }) + + it('should not expand expired session on visibility check', async () => { + setPageVisibility('visible') + + const sessionManager = await startSessionManagerWithDefaults() + sessionManager.expire() + + const stateAfterExpire = fakeStrategy.getInternalState() + expect(stateAfterExpire.isExpired).toBe(EXPIRED) + + clock.tick(VISIBILITY_CHECK_DELAY) + + // expandOnly should not modify an expired session + const state = fakeStrategy.getInternalState() + expect(state.isExpired).toBe(EXPIRED) + }) + + it('should not expand another tab session via visibility check after expiration', async () => { + const sessionManager = await startSessionManagerWithDefaults() + sessionManager.expire() + + // Simulate another tab writing its own session to the shared store + const otherTabExpire = String(Date.now() + SESSION_EXPIRATION_DELAY) + fakeStrategy.simulateExternalChange({ + id: 'other-tab-session', + created: String(Date.now()), + expire: otherTabExpire, + }) + + clock.tick(VISIBILITY_CHECK_DELAY) + + // The other tab's expire should not have been pushed forward + expect(fakeStrategy.getInternalState().expire).toBe(otherTabExpire) }) - it('should renew an existing timed out session', () => { - setCookie(SESSION_STORE_KEY, `id=abcde&first=tracked&created=${Date.now() - SESSION_TIME_OUT_DELAY}`, DURATION) + it('should expire session after SESSION_EXPIRATION_DELAY without any activity in a hidden tab', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const expireSpy = jasmine.createSpy('expire') + sessionManager.expireObservable.subscribe(expireSpy) + + expect(sessionManager.findSession()).toBeDefined() + + // Advance past the session expiration delay without any user activity + clock.tick(SESSION_EXPIRATION_DELAY + ONE_SECOND) - const sessionManager = startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + await collectAsyncCalls(expireSpy, 1) - expect(sessionManager.findSession()!.id).not.toBe('abcde') - expect(getSessionState(SESSION_STORE_KEY).created).toEqual(Date.now().toString()) - expect(expireSessionSpy).not.toHaveBeenCalled() // the session has not been active from the start + expect(expireSpy).toHaveBeenCalledTimes(1) + expect(sessionManager.findSession()).toBeUndefined() }) - it('should not add created date to an existing session from an older versions', () => { - setCookie(SESSION_STORE_KEY, 'id=abcde&first=tracked', DURATION) + it('should expire session after SESSION_TIME_OUT_DELAY even on a continuously visible page', async () => { + setPageVisibility('visible') + const sessionManager = await startSessionManagerWithDefaults() + const expireSpy = jasmine.createSpy('expire') + sessionManager.expireObservable.subscribe(expireSpy) - const sessionManager = startSessionManagerWithDefaults() + expect(sessionManager.findSession()).toBeDefined() - expect(sessionManager.findSession()!.id).toBe('abcde') - expect(getSessionState(SESSION_STORE_KEY).created).toBeUndefined() + // Fast forward to the end of the session + clock.tick(SESSION_TIME_OUT_DELAY) + // Drain the pending setSessionState microtasks so that scheduleExpirationTimeout runs + // and registers the 0ms expiry timeout. + await Promise.resolve() + // Fire the 0ms expiry timeout. + clock.tick(0) + + await collectAsyncCalls(expireSpy, 1) + + expect(fakeStrategy.getInternalState()).toEqual({ isExpired: EXPIRED }) + expect(expireSpy).toHaveBeenCalledTimes(1) + expect(sessionManager.findSession()).toBeUndefined() }) }) - describe('automatic session expiration', () => { - beforeEach(() => { - setPageVisibility('hidden') + describe('cross-tab changes (simulateExternalChange)', () => { + it('should not adopt a session created by another tab when it replaces our session directly', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const renewSpy = jasmine.createSpy('renew') + sessionManager.renewObservable.subscribe(renewSpy) + + // Another tab expires our session and immediately starts a new one + fakeStrategy.simulateExternalChange({ + id: 'other-tab-session', + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }) + + expect(renewSpy).not.toHaveBeenCalled() + expect(sessionManager.findSession()).toBeUndefined() }) - afterEach(() => { - restorePageVisibility() + it('should update session context in history when forcedReplay changes externally', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const currentId = sessionManager.findSession()!.id + const currentState = fakeStrategy.getInternalState() + + expect(sessionManager.findSession()!.isReplayForced).toBe(false) + + fakeStrategy.simulateExternalChange({ + ...currentState, + id: currentId, + forcedReplay: '1', + }) + + expect(sessionManager.findSession()!.isReplayForced).toBe(true) }) - it('should expire the session after expiration delay', () => { - const sessionManager = startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + it('should fire expireObservable when external change removes the session', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const expireSpy = jasmine.createSpy('expire') + sessionManager.expireObservable.subscribe(expireSpy) - expectSessionIdToBeDefined(sessionManager) + fakeStrategy.simulateExternalChange({ isExpired: EXPIRED }) - clock.tick(SESSION_EXPIRATION_DELAY) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalled() + expect(expireSpy).toHaveBeenCalledTimes(1) + expect(sessionManager.findSession()).toBeUndefined() }) - it('should expand duration on activity', () => { - const sessionManager = startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + it('should not adopt a session created by another tab after expiry', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const renewSpy = jasmine.createSpy('renew') + sessionManager.renewObservable.subscribe(renewSpy) - expectSessionIdToBeDefined(sessionManager) + // First expire + sessionManager.expire() - clock.tick(SESSION_EXPIRATION_DELAY - 10) - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + // Then another tab creates a new session + fakeStrategy.simulateExternalChange({ + id: 'new-session-from-other-tab', + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }) - clock.tick(10) - expectSessionIdToBeDefined(sessionManager) - expect(expireSessionSpy).not.toHaveBeenCalled() + expect(renewSpy).not.toHaveBeenCalled() + expect(sessionManager.findSession()).toBeUndefined() + }) + }) - clock.tick(SESSION_EXPIRATION_DELAY) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalled() + describe('tracking consent', () => { + it('should expire the session when tracking consent is withdrawn', async () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) + + expect(sessionManager.findSession()).toBeDefined() + + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + + expect(sessionManager.findSession()).toBeUndefined() + expect(fakeStrategy.getInternalState().isExpired).toBe(EXPIRED) }) - it('should expand not tracked session duration on activity', () => { - const sessionManager = startSessionManagerWithDefaults({ - computeTrackingType: () => FakeTrackingType.NOT_TRACKED, - }) - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + it('should not renew on activity when tracking consent is withdrawn', async () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) + trackingConsentState.update(TrackingConsent.NOT_GRANTED) - clock.tick(SESSION_EXPIRATION_DELAY - 10) + clock.tick(ONE_SECOND) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - clock.tick(10) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) - expect(expireSessionSpy).not.toHaveBeenCalled() - - clock.tick(SESSION_EXPIRATION_DELAY) - expectTrackingTypeToNotBeDefined(sessionManager, FIRST_PRODUCT_KEY) - expect(expireSessionSpy).toHaveBeenCalled() + expect(sessionManager.findSession()).toBeUndefined() }) - it('should expand session on visibility', () => { - setPageVisibility('visible') + it('should renew the session when tracking consent is re-granted', async () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) + const initialId = sessionManager.findSession()!.id - const sessionManager = startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + trackingConsentState.update(TrackingConsent.NOT_GRANTED) - clock.tick(3 * VISIBILITY_CHECK_DELAY) - setPageVisibility('hidden') - expectSessionIdToBeDefined(sessionManager) - expect(expireSessionSpy).not.toHaveBeenCalled() + expect(sessionManager.findSession()).toBeUndefined() - clock.tick(SESSION_EXPIRATION_DELAY - 10) - expectSessionIdToBeDefined(sessionManager) - expect(expireSessionSpy).not.toHaveBeenCalled() + trackingConsentState.update(TrackingConsent.GRANTED) - clock.tick(10) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalled() - }) + await collectAsyncCalls(sessionObservableSpy, 3) // 1 for initial session, 1 for expire, 1 for renew - it('should expand not tracked session on visibility', () => { - setPageVisibility('visible') + expect(sessionManager.findSession()).toBeDefined() + expect(sessionManager.findSession()!.id).not.toBe(initialId) + }) - const sessionManager = startSessionManagerWithDefaults({ - computeTrackingType: () => FakeTrackingType.NOT_TRACKED, + it('should remove anonymousId when tracking consent is withdrawn', async () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + await startSessionManagerWithDefaults({ + trackingConsentState, + configuration: { trackAnonymousUser: true }, }) - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) - clock.tick(3 * VISIBILITY_CHECK_DELAY) - setPageVisibility('hidden') - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) - expect(expireSessionSpy).not.toHaveBeenCalled() + expect(fakeStrategy.getInternalState().anonymousId).toBeDefined() - clock.tick(SESSION_EXPIRATION_DELAY - 10) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) - expect(expireSessionSpy).not.toHaveBeenCalled() + trackingConsentState.update(TrackingConsent.NOT_GRANTED) - clock.tick(10) - expectTrackingTypeToNotBeDefined(sessionManager, FIRST_PRODUCT_KEY) - expect(expireSessionSpy).toHaveBeenCalled() + expect(fakeStrategy.getInternalState().anonymousId).toBeUndefined() + }) + + it('should expire the session when consent is revoked before initialization completes', async () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + + // Create a strategy where setSessionState returns a pending promise (to simulate async init) + let resolveInit!: () => void + const delayedStrategy = createFakeSessionStoreStrategy() + delayedStrategy.setSessionState = jasmine + .createSpy('setSessionState') + .and.callFake((fn: (state: SessionState) => SessionState): Promise => { + fn({}) + return new Promise((resolve) => { + resolveInit = resolve + }) + }) + + fakeStrategy = delayedStrategy + + const sessionManagerPromise = startSessionManager( + { + sessionStoreStrategyType: STORE_TYPE, + sessionSampleRate: 100, + trackAnonymousUser: false, + } as Configuration, + trackingConsentState + ) + + // Consent revoked while initialization promise is pending + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + + // Resolve the initialization promise + resolveInit() + + // Should resolve with undefined because consent was revoked + const sessionManager = await sessionManagerPromise + expect(sessionManager).toBeUndefined() }) }) - describe('manual session expiration', () => { - it('expires the session when calling expire()', () => { - const sessionManager = startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + describe('findSession', () => { + it('should return the current session when no startTime is provided', async () => { + const sessionManager = await startSessionManagerWithDefaults() + + const session = sessionManager.findSession() + expect(session).toBeDefined() + expect(session!.id).toBeDefined() + }) + + it('should return undefined when the session is expired and no startTime is provided', async () => { + const sessionManager = await startSessionManagerWithDefaults() sessionManager.expire() - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalled() + expect(sessionManager.findSession()).toBeUndefined() }) - it('notifies expired session only once when calling expire() multiple times', () => { - const sessionManager = startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + it('should return the session at the given startTime from history', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const firstId = sessionManager.findSession()!.id + + // Advance time, expire, then renew + clock.tick(10 * ONE_SECOND) sessionManager.expire() - sessionManager.expire() - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalledTimes(1) + clock.tick(10 * ONE_SECOND) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + await collectAsyncCalls(sessionObservableSpy, 3) // 1 for initial session, 1 for expire, 1 for renew + + const secondId = sessionManager.findSession()!.id + + // Look up first session at t=5s + expect(sessionManager.findSession(clock.relative(5 * ONE_SECOND))!.id).toBe(firstId) + // Look up gap at t=15s + expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND))).toBeUndefined() + // Look up second session at t=25s + expect(sessionManager.findSession(clock.relative(25 * ONE_SECOND))!.id).toBe(secondId) }) - it('notifies expired session only once when calling expire() after the session has been expired', () => { - const sessionManager = startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + it('should return the current session context in the renewObservable callback', async () => { + const sessionManager = await startSessionManagerWithDefaults() + let currentSession: ReturnType + sessionManager.renewObservable.subscribe(() => { + currentSession = sessionManager.findSession() + }) - clock.tick(SESSION_EXPIRATION_DELAY) sessionManager.expire() + clock.tick(ONE_SECOND) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalledTimes(1) + await collectAsyncCalls(sessionObservableSpy, 3) // 1 for initial session, 1 for expire, 1 for renew + + expect(currentSession!).toBeDefined() }) - it('renew the session on user activity', () => { - const sessionManager = startSessionManagerWithDefaults() - clock.tick(STORAGE_POLL_DELAY) + it('should still return the session in the expireObservable callback (before history close)', async () => { + const sessionManager = await startSessionManagerWithDefaults() + let currentSession: ReturnType + sessionManager.expireObservable.subscribe(() => { + currentSession = sessionManager.findSession() + }) sessionManager.expire() - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + // expireObservable fires before sessionContextHistory.closeActive, so the session is still findable + expect(currentSession!).toBeDefined() + }) - expectSessionIdToBeDefined(sessionManager) + describe('option returnInactive', () => { + it('should return the session even when expired if returnInactive is true', async () => { + const sessionManager = await startSessionManagerWithDefaults() + + clock.tick(10 * ONE_SECOND) + sessionManager.expire() + clock.tick(10 * ONE_SECOND) + + expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND), { returnInactive: true })).toBeDefined() + expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND), { returnInactive: false })).toBeUndefined() + }) }) }) - describe('session history', () => { - it('should return undefined when there is no current session and no startTime', () => { - const sessionManager = startSessionManagerWithDefaults() - expireSessionCookie() + describe('findTrackedSession', () => { + it('should return undefined when session is not sampled (sessionSampleRate: 0)', async () => { + const sessionManager = await startSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0 }, + }) - expect(sessionManager.findSession()).toBeUndefined() + expect(sessionManager.findTrackedSession()).toBeUndefined() }) - it('should return the current session context when there is no start time', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should return the session when sampled (sessionSampleRate: 100)', async () => { + const sessionManager = await startSessionManagerWithDefaults() - expect(sessionManager.findSession()!.id).toBeDefined() - expect(sessionManager.findSession()!.trackingType).toBeDefined() + const session = sessionManager.findTrackedSession() + expect(session).toBeDefined() + expect(session!.id).toBeDefined() }) - it('should return the session context corresponding to startTime', () => { - const sessionManager = startSessionManagerWithDefaults() - - // 0s to 10s: first session - clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY) - const firstSessionId = sessionManager.findSession()!.id - const firstSessionTrackingType = sessionManager.findSession()!.trackingType - expireSessionCookie() + it('should pass through startTime and options', async () => { + const sessionManager = await startSessionManagerWithDefaults() - // 10s to 20s: no session clock.tick(10 * ONE_SECOND) - - // 20s to end: second session - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + sessionManager.expire() clock.tick(10 * ONE_SECOND) - const secondSessionId = sessionManager.findSession()!.id - const secondSessionTrackingType = sessionManager.findSession()!.trackingType - expect(sessionManager.findSession(clock.relative(5 * ONE_SECOND))!.id).toBe(firstSessionId) - expect(sessionManager.findSession(clock.relative(5 * ONE_SECOND))!.trackingType).toBe(firstSessionTrackingType) - expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND))).toBeUndefined() - expect(sessionManager.findSession(clock.relative(25 * ONE_SECOND))!.id).toBe(secondSessionId) - expect(sessionManager.findSession(clock.relative(25 * ONE_SECOND))!.trackingType).toBe(secondSessionTrackingType) + expect(sessionManager.findTrackedSession(clock.relative(5 * ONE_SECOND))).toBeDefined() + expect(sessionManager.findTrackedSession(clock.relative(15 * ONE_SECOND))).toBeUndefined() }) - describe('option `returnInactive` is true', () => { - it('should return the session context even when the session is expired', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should return isReplayForced from the session context', async () => { + const sessionManager = await startSessionManagerWithDefaults() - // 0s to 10s: first session - clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY) + expect(sessionManager.findTrackedSession()!.isReplayForced).toBe(false) - expireSessionCookie() + sessionManager.updateSessionState({ forcedReplay: '1' }) + await collectAsyncCalls(sessionObservableSpy, 2) // 1 for initial session, 1 for updateSessionState - // 10s to 20s: no session - clock.tick(10 * ONE_SECOND) + expect(sessionManager.findTrackedSession()!.isReplayForced).toBe(true) + }) - expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND), { returnInactive: true })).toBeDefined() + it('should return the session if it has expired when returnInactive is true', async () => { + const sessionManager = await startSessionManagerWithDefaults() - expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND), { returnInactive: false })).toBeUndefined() + sessionManager.expire() + + expect(sessionManager.findTrackedSession(undefined, { returnInactive: true })).toBeDefined() + }) + + it('should return undefined when the session is older than TRACKED_SESSION_MAX_AGE', async () => { + const sessionManager = await startSessionManagerWithDefaults() + + fakeStrategy.simulateExternalChange({ + id: LOW_HASH_UUID, + created: String(Date.now() - TRACKED_SESSION_MAX_AGE - 1), + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), }) + + await collectAsyncCalls(sessionObservableSpy, 2) // 1 for initial session, 1 for external change + + expect(sessionManager.findTrackedSession()).toBeUndefined() }) - it('should return the current session context in the renewObservable callback', () => { - const sessionManager = startSessionManagerWithDefaults() - let currentSession - sessionManager.renewObservable.subscribe(() => (currentSession = sessionManager.findSession())) + describe('deterministic sampling', () => { + it('should track a session whose ID has a low hash, even with a low sessionSampleRate', async () => { + setupFakeStrategy({ + initialSession: { + id: LOW_HASH_UUID, + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }, + }) - // new session - expireSessionCookie() - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - clock.tick(STORAGE_POLL_DELAY) + const sessionManager = await startSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 1 }, + }) + + expect(sessionManager.findTrackedSession()).toBeDefined() + }) + + it('should not track a session whose ID has a high hash, even with a high sessionSampleRate', async () => { + setupFakeStrategy({ + initialSession: { + id: HIGH_HASH_UUID, + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }, + }) + + const sessionManager = await startSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 99 }, + }) - expect(currentSession).toBeDefined() + expect(sessionManager.findTrackedSession()).toBeUndefined() + }) }) + }) - it('should return the current session context in the expireObservable callback', () => { - const sessionManager = startSessionManagerWithDefaults() - let currentSession - sessionManager.expireObservable.subscribe(() => (currentSession = sessionManager.findSession())) + describe('updateSessionState', () => { + it('should merge partial state via setSessionState', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const callCountBefore = fakeStrategy.setSessionState.calls.count() - // new session - expireSessionCookie() - clock.tick(STORAGE_POLL_DELAY) + sessionManager.updateSessionState({ extra: 'value' }) - expect(currentSession).toBeDefined() + expect(fakeStrategy.setSessionState.calls.count()).toBe(callCountBefore + 1) + expect(fakeStrategy.getInternalState().extra).toBe('value') + }) + + it('should rebuild session context when forcedReplay is updated', async () => { + const sessionManager = await startSessionManagerWithDefaults() + + expect(sessionManager.findSession()!.isReplayForced).toBe(false) + + sessionManager.updateSessionState({ forcedReplay: '1' }) + await collectAsyncCalls(sessionObservableSpy, 2) // 1 for initial session, 1 for updateSessionState + + expect(sessionManager.findSession()!.isReplayForced).toBe(true) }) }) - describe('tracking consent', () => { - it('expires the session when tracking consent is withdrawn', () => { - const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + describe('resume from frozen tab', () => { + it('should do nothing when session is still active', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const initialId = sessionManager.findSession()!.id - trackingConsentState.update(TrackingConsent.NOT_GRANTED) + window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) - expectSessionToBeExpired(sessionManager) - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') + expect(sessionManager.findSession()!.id).toBe(initialId) }) - it('does not renew the session when tracking consent is withdrawn', () => { - const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + it('should reinitialize session in store when store is empty', async () => { + await startSessionManagerWithDefaults() - trackingConsentState.update(TrackingConsent.NOT_GRANTED) + // Simulate store being cleared (e.g., by another tab or browser clearing storage) + fakeStrategy.simulateExternalChange({}) - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) - expectSessionToBeExpired(sessionManager) + // initializeSession on empty state creates an expired state + const state = fakeStrategy.getInternalState() + expect(state.isExpired).toBe(EXPIRED) }) + }) - it('renews the session when tracking consent is granted', () => { - const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) - const initialSessionId = sessionManager.findSession()!.id + describe('multiple startSessionManager calls', () => { + it('should re-use the same session when sharing a strategy', async () => { + const firstManager = await startSessionManagerWithDefaults() + // Second manager shares the same fakeStrategy + const secondManager = await startSessionManagerWithDefaults() - trackingConsentState.update(TrackingConsent.NOT_GRANTED) + // The second manager inherits the state from the strategy (which already has a session) + expect(firstManager?.findSession()!.id).toBe(secondManager?.findSession()!.id) + }) - expectSessionToBeExpired(sessionManager) + it('should notify expire observables on both managers when session expires externally', async () => { + const firstManager = await startSessionManagerWithDefaults() + const secondManager = await startSessionManagerWithDefaults() - trackingConsentState.update(TrackingConsent.GRANTED) + const expireSpy1 = jasmine.createSpy('expire1') + const expireSpy2 = jasmine.createSpy('expire2') - clock.tick(STORAGE_POLL_DELAY) + firstManager?.expireObservable.subscribe(expireSpy1) + secondManager?.expireObservable.subscribe(expireSpy2) - expectSessionIdToBeDefined(sessionManager) - expect(sessionManager.findSession()!.id).not.toBe(initialSessionId) + // Expire via external change + fakeStrategy.simulateExternalChange({ isExpired: EXPIRED }) + + expect(expireSpy1).toHaveBeenCalled() + expect(expireSpy2).toHaveBeenCalled() }) + }) - it('Remove anonymousId when tracking consent is withdrawn', () => { - const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) - const session = sessionManager.findSession()! + describe('session timeout', () => { + it('should create a new session when the existing session has timed out', async () => { + setupFakeStrategy({ + initialSession: { + id: 'old-session', + created: String(Date.now() - SESSION_TIME_OUT_DELAY - 1), + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + }, + }) - trackingConsentState.update(TrackingConsent.NOT_GRANTED) + // The timed-out session is treated as expired by isSessionInExpiredState + // initializeSession keeps it as-is (since it's not empty), but it's expired + const sessionManager = await startSessionManagerWithDefaults() - expect(session.anonymousId).toBeUndefined() + // After user activity (from startSessionManagerWithDefaults), a new session is created + expect(sessionManager.findSession()).toBeDefined() + expect(sessionManager.findSession()!.id).not.toBe('old-session') }) }) - describe('session state update', () => { - it('should notify session manager update observable', () => { - const sessionStateUpdateSpy = jasmine.createSpy() - const sessionManager = startSessionManagerWithDefaults() - sessionManager.sessionStateUpdateObservable.subscribe(sessionStateUpdateSpy) + describe('stop', () => { + it('should stop listening to activity events after stopSessionManager', async () => { + await startSessionManagerWithDefaults() - sessionManager.updateSessionState({ extra: 'extra' }) + stopSessionManager() + + // Wait for throttle to clear + clock.tick(ONE_SECOND) - expectSessionIdToBeDefined(sessionManager) - expect(sessionStateUpdateSpy).toHaveBeenCalledTimes(1) + const callCountAfterStop = fakeStrategy.setSessionState.calls.count() + + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - const callArgs = sessionStateUpdateSpy.calls.argsFor(0)[0] - expect(callArgs.previousState.extra).toBeUndefined() - expect(callArgs.newState.extra).toBe('extra') + expect(fakeStrategy.setSessionState.calls.count()).toBe(callCountAfterStop) }) + + it('should unsubscribe from strategy observable after stopSessionManager', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const renewSpy = jasmine.createSpy('renew') + sessionManager.renewObservable.subscribe(renewSpy) + + stopSessionManager() + + // External change should not trigger renew + fakeStrategy.simulateExternalChange({ + id: 'new-external-session', + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }) + + expect(renewSpy).not.toHaveBeenCalled() + }) + }) +}) + +describe('startSessionManagerStub', () => { + it('should always return a tracked session', async () => { + const sessionManager = await startSessionManagerStub() + expect(sessionManager.findTrackedSession()).toBeDefined() + expect(sessionManager.findTrackedSession()!.id).toBeDefined() }) - function startSessionManagerWithDefaults({ - configuration, - productKey = FIRST_PRODUCT_KEY, - computeTrackingType = () => FakeTrackingType.TRACKED, - trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED), - }: { - configuration?: Partial - productKey?: string - computeTrackingType?: () => FakeTrackingType - trackingConsentState?: TrackingConsentState - } = {}) { - return startSessionManager( - { - sessionStoreStrategyType: STORE_TYPE, - ...configuration, - } as Configuration, - productKey, - computeTrackingType, - trackingConsentState - ) - } + it('should allow updating session state', async () => { + const sessionManager = await startSessionManagerStub() + + sessionManager.updateSessionState({ extra: 'value' }) + + expect(sessionManager.findSession()).toBeDefined() + }) }) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index f6547dc44c..1f164230d5 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -1,129 +1,362 @@ import { Observable } from '../../tools/observable' -import type { Context } from '../../tools/serialisation/context' import { createValueHistory } from '../../tools/valueHistory' -import type { RelativeTime } from '../../tools/utils/timeUtils' -import { clocksOrigin, ONE_MINUTE, relativeNow } from '../../tools/utils/timeUtils' +import type { RelativeTime, TimeStamp } from '../../tools/utils/timeUtils' +import { + clocksOrigin, + dateNow, + elapsed, + ONE_HOUR, + ONE_MINUTE, + ONE_SECOND, + relativeNow, + timeStampNow, +} from '../../tools/utils/timeUtils' import { addEventListener, addEventListeners, DOM_EVENT } from '../../browser/addEventListener' -import { clearInterval, setInterval } from '../../tools/timer' +import { clearInterval, clearTimeout, setInterval, setTimeout } from '../../tools/timer' +import { mockable } from '../../tools/mockable' +import { noop, throttle } from '../../tools/utils/functionUtils' +import { generateUUID } from '../../tools/utils/stringUtils' import type { Configuration } from '../configuration' import type { TrackingConsentState } from '../trackingConsent' -import { addTelemetryDebug } from '../telemetry' -import { isSyntheticsTest } from '../synthetics/syntheticsWorkerValues' -import type { CookieStore } from '../../browser/browser.types' -import { getCurrentSite } from '../../browser/cookie' -import { SESSION_NOT_TRACKED, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants' -import { startSessionStore } from './sessionStore' -import type { SessionState } from './sessionState' -import { retrieveSessionCookie } from './storeStrategies/sessionInCookie' +import { isWorkerEnvironment } from '../../tools/globalObject' +import { display } from '../../tools/display' +import { isSampled } from '../sampler' +import { TelemetryMetrics, addTelemetryMetrics } from '../telemetry' +import { monitorError } from '../../tools/monitor' +import { getCookies } from '../../browser/cookie' +import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../tools/experimentalFeatures' import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' -import { retrieveSessionFromLocalStorage } from './storeStrategies/sessionInLocalStorage' - -export interface SessionManager { - findSession: ( - startTime?: RelativeTime, - options?: { returnInactive: boolean } - ) => SessionContext | undefined - renewObservable: Observable +import { SESSION_TIME_OUT_DELAY } from './sessionConstants' +import type { SessionState } from './sessionState' +import { + expandOnly, + expandOrRenew, + getCreatedDate, + getExpireDate, + getExpiredSessionState, + initializeSession, + isSessionInExpiredState, +} from './sessionState' +import { getSessionStoreStrategy } from './sessionStore' + +export interface SessionManager { + findSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => SessionContext | undefined + findTrackedSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => SessionContext | undefined + renewObservable: Observable expireObservable: Observable - sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }> expire: () => void updateSessionState: (state: Partial) => void } -export interface SessionContext extends Context { +interface SessionDebugContext { + previousSession?: SessionContext + newState: SessionState + from: string + cookieValues: string[] | undefined + cookies: string[] + locksAvailable: boolean + cookieStoreAvailable: boolean +} +export interface SessionRenewalEvent { + expire: SessionDebugContext | undefined + renew: SessionDebugContext +} + +export interface SessionContext { id: string - trackingType: TrackingType - isReplayForced: boolean - anonymousId: string | undefined + anonymousId?: string | undefined + isReplayForced?: boolean + createdAt: TimeStamp } export const VISIBILITY_CHECK_DELAY = ONE_MINUTE const SESSION_CONTEXT_TIMEOUT_DELAY = SESSION_TIME_OUT_DELAY + +// Maximum duration for which we can send data related to a session. +// +// The backend behavior depends on how old the session is when it receives an event: +// - Session started < 4h ago: the backend updates the session normally. +// - Session started between 4h and 24h ago: the backend ignores the event (safe). +// - Session started > 24h ago: the backend recreates a session with that id (problematic). +// +// We choose 12h as a threshold — safely between 4h and 24h — to avoid both recreating +// sessions and discarding too many legitimate late events. +export const TRACKED_SESSION_MAX_AGE = ONE_HOUR * 12 let stopCallbacks: Array<() => void> = [] -export function startSessionManager( +export async function startSessionManager( configuration: Configuration, - productKey: string, - computeTrackingType: (rawTrackingType?: string) => TrackingType, trackingConsentState: TrackingConsentState -): SessionManager { - const renewObservable = new Observable() +): Promise { + const startTime = relativeNow() + const renewObservable = new Observable() const expireObservable = new Observable() + let expireContext: SessionDebugContext | undefined - // TODO - Improve configuration type and remove assertion - const sessionStore = startSessionStore( - configuration.sessionStoreStrategyType!, - configuration, - productKey, - computeTrackingType - ) - stopCallbacks.push(() => sessionStore.stop()) + if (!configuration.sessionStoreStrategyType) { + display.warn('No storage available for session. We will not send any data.') + return + } + + const strategy = mockable(getSessionStoreStrategy)(configuration.sessionStoreStrategyType, configuration) - const sessionContextHistory = createValueHistory>({ + const sessionContextHistory = createValueHistory({ expireDelay: SESSION_CONTEXT_TIMEOUT_DELAY, }) stopCallbacks.push(() => sessionContextHistory.stop()) - sessionStore.renewObservable.subscribe(() => { - sessionContextHistory.add(buildSessionContext(), relativeNow()) - renewObservable.notify() + let sessionExpired = false + + const { throttled: throttledExpandOrRenew, cancel: cancelExpandOrRenew } = throttle(() => { + sessionExpired = false + strategy.setSessionState((state) => expandOrRenew(state, configuration)).catch(monitorError) + }, ONE_SECOND) + stopCallbacks.push(cancelExpandOrRenew) + + let expirationTimeoutId: ReturnType | undefined + stopCallbacks.push(() => clearTimeout(expirationTimeoutId)) + + let stopped = false + stopCallbacks.push(() => { + stopped = true }) - sessionStore.expireObservable.subscribe(() => { - expireObservable.notify() - sessionContextHistory.closeActive(relativeNow()) + + const initialState = await resolveInitialState().catch(monitorError) + if (!initialState || stopped) { + return + } + + // Consent is always granted when the session manager is started, but it may + // be revoked during the async initialization (e.g., while waiting for cookie lock). + if (!trackingConsentState.isGranted()) { + expire() + return + } + + sessionContextHistory.add(buildSessionContext(initialState), clocksOrigin().relative) + scheduleExpirationTimeout(initialState) + subscribeToSessionChanges() + setupSessionTracking() + + // monitor-until: 2026-10-15 + addTelemetryMetrics(TelemetryMetrics.SESSION_MANAGER_INIT_METRICS_TELEMETRY_NAME, { + metrics: { duration: elapsed(startTime, relativeNow()) }, }) - // We expand/renew session unconditionally as tracking consent is always granted when the session - // manager is started. - sessionStore.expandOrRenewSession() - sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative) + return buildSessionManager() + + async function resolveInitialState() { + let state: SessionState = {} + await strategy.setSessionState((currentState) => { + const initialState = initializeSession(currentState, configuration) + state = expandOrRenew(initialState, configuration) + return state + }) + return state + } + + function subscribeToSessionChanges() { + const subscription = strategy.sessionObservable.subscribe(({ cookieValues, sessionState }) => { + scheduleExpirationTimeout(sessionState) + handleStateChange(sessionState, { from: 'sessionObservable', cookieValues }) + }) + stopCallbacks.push(() => subscription.unsubscribe()) + } + + function scheduleExpirationTimeout(state: SessionState) { + clearTimeout(expirationTimeoutId) + const expireDate = getExpireDate(state) + if (expireDate) { + const delay = expireDate - dateNow() + expirationTimeoutId = setTimeout(() => { + strategy + .setSessionState((state) => { + if (isSessionInExpiredState(state)) { + if (!trackingConsentState.isGranted()) { + delete state.anonymousId + } + + return getExpiredSessionState(state, configuration) + } - trackingConsentState.observable.subscribe(() => { - if (trackingConsentState.isGranted()) { - sessionStore.expandOrRenewSession() - } else { - sessionStore.expire(false) + return state + }) + .catch(monitorError) + }, delay) } - }) + } - trackActivity(configuration, () => { - if (trackingConsentState.isGranted()) { - sessionStore.expandOrRenewSession() + function setupSessionTracking() { + trackingConsentState.observable.subscribe(() => { + if (trackingConsentState.isGranted()) { + sessionExpired = false + strategy.setSessionState((state) => expandOrRenew(state, configuration)).catch(monitorError) + } else { + expire() + } + }) + + if (!isWorkerEnvironment) { + trackActivity(configuration, () => { + if (trackingConsentState.isGranted()) { + throttledExpandOrRenew() + } + }) + trackVisibility(configuration, () => { + if (!sessionExpired) { + strategy.setSessionState((state) => expandOnly(state)).catch(monitorError) + } + }) + trackResume(configuration, () => { + strategy.setSessionState((state) => initializeSession(state, configuration)).catch(monitorError) + }) } - }) - trackVisibility(configuration, () => sessionStore.expandSession()) - trackResume(configuration, () => sessionStore.restartSession()) + } - function buildSessionContext() { - const session = sessionStore.getSession() + function buildSessionManager(): SessionManager { + return { + findSession: (startTime, options) => sessionContextHistory.find(startTime, options), + findTrackedSession: (startTime, options) => { + const session = sessionContextHistory.find(startTime, options) + + if (!session || session.id === 'invalid' || !isSampled(session.id, configuration.sessionSampleRate)) { + return + } + + if (dateNow() - session.createdAt > TRACKED_SESSION_MAX_AGE) { + return + } + + return session + }, + renewObservable, + expireObservable, + expire, + updateSessionState: (partialState) => { + strategy.setSessionState((state) => ({ ...state, ...partialState })).catch(monitorError) + }, + } + } + + function handleStateChange( + newState: SessionState, + { from, cookieValues }: { from: string; cookieValues?: string[] } + ) { + const previousSession = sessionContextHistory.find() + const hadSession = previousSession?.id !== undefined + const hasSession = newState.id !== undefined + const sessionIdChanged = hadSession && hasSession && previousSession.id !== newState.id + + if (hadSession && (!hasSession || sessionIdChanged)) { + // Session expired or replaced + if (isExperimentalFeatureEnabled(ExperimentalFeature.SESSION_RENEWAL_DEBUG_CONTEXT)) { + expireContext = { + previousSession: previousSession && { ...previousSession }, + newState: { ...newState }, + from, + cookieValues, + cookies: getCookies(SESSION_STORE_KEY), + locksAvailable: Boolean(globalThis.navigator?.locks), + cookieStoreAvailable: Boolean(globalThis.cookieStore), + } + } + sessionExpired = true + expireObservable.notify() + sessionContextHistory.closeActive(relativeNow()) + } + + if (hasSession && (!hadSession || sessionIdChanged)) { + if (sessionExpired) { + // Don't adopt another tab's session — this tab needs its own user interaction to renew + return + } + // New session appeared + sessionContextHistory.add(buildSessionContext(newState), relativeNow()) + renewObservable.notify( + isExperimentalFeatureEnabled(ExperimentalFeature.SESSION_RENEWAL_DEBUG_CONTEXT) + ? { + expire: expireContext, + renew: { + previousSession: previousSession && { ...previousSession }, + newState: { ...newState }, + from, + cookieValues, + cookies: getCookies(SESSION_STORE_KEY), + locksAvailable: Boolean(globalThis.navigator?.locks), + cookieStoreAvailable: Boolean(globalThis.cookieStore), + }, + } + : undefined + ) + } else if (hadSession && hasSession && !sessionIdChanged) { + // Same session, + // Mutate the session context in the history for replay forced changes + + previousSession.isReplayForced = !!newState.forcedReplay + } + } + + function expire() { + cancelExpandOrRenew() + // Update in-memory state synchronously so events stop being collected immediately + const expiredState = getExpiredSessionState(sessionContextHistory.find(), configuration) + if (!trackingConsentState.isGranted()) { + delete expiredState.anonymousId + } + handleStateChange(expiredState, { from: 'expire' }) + // Persist to storage asynchronously + strategy + .setSessionState((state) => { + if (!trackingConsentState.isGranted()) { + delete state.anonymousId + } + return getExpiredSessionState(state, configuration) + }) + .catch(monitorError) + } - if (!session) { - reportUnexpectedSessionState(configuration).catch(() => void 0) // Ignore errors + function buildSessionContext(sessionState: SessionState): SessionContext { + const createdAt = getCreatedDate(sessionState) ?? timeStampNow() + if (!sessionState.id) { return { id: 'invalid', - trackingType: SESSION_NOT_TRACKED as TrackingType, isReplayForced: false, anonymousId: undefined, + createdAt, } } return { - id: session.id!, - trackingType: session[productKey] as TrackingType, - isReplayForced: !!session.forcedReplay, - anonymousId: session.anonymousId, + id: sessionState.id, + isReplayForced: !!sessionState.forcedReplay, + anonymousId: sessionState.anonymousId, + createdAt, } } +} - return { - findSession: (startTime, options) => sessionContextHistory.find(startTime, options), - renewObservable, - expireObservable, - sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable, - expire: sessionStore.expire, - updateSessionState: sessionStore.updateSessionState, +export function startSessionManagerStub(): Promise { + const stubSessionId = generateUUID() + let sessionContext: SessionContext = { + id: stubSessionId, + isReplayForced: false, + anonymousId: undefined, + createdAt: timeStampNow(), } + return Promise.resolve({ + findSession: () => sessionContext, + findTrackedSession: () => sessionContext, + renewObservable: new Observable(), + expireObservable: new Observable(), + expire: noop, + updateSessionState: (state) => { + sessionContext = { + ...sessionContext, + ...state, + } + }, + }) } export function stopSessionManager() { @@ -162,48 +395,3 @@ function trackResume(configuration: Configuration, cb: () => void) { const { stop } = addEventListener(configuration, window, DOM_EVENT.RESUME, cb, { capture: true }) stopCallbacks.push(stop) } - -async function reportUnexpectedSessionState(configuration: Configuration) { - const sessionStoreStrategyType = configuration.sessionStoreStrategyType - if (!sessionStoreStrategyType) { - return - } - - let rawSession - let cookieContext - - if (sessionStoreStrategyType.type === SessionPersistence.COOKIE) { - rawSession = retrieveSessionCookie(sessionStoreStrategyType.cookieOptions, configuration) - - cookieContext = { - cookie: await getSessionCookies(), - currentDomain: `${window.location.protocol}//${window.location.hostname}`, - } - } else { - rawSession = retrieveSessionFromLocalStorage() - } - // monitor-until: forever, could be handy to troubleshoot issues until session manager rework - addTelemetryDebug('Unexpected session state', { - sessionStoreStrategyType: sessionStoreStrategyType.type, - session: rawSession, - isSyntheticsTest: isSyntheticsTest(), - createdTimestamp: rawSession?.created, - expireTimestamp: rawSession?.expire, - ...cookieContext, - }) -} - -async function getSessionCookies(): Promise<{ count: number; domain: string }> { - let sessionCookies: string[] | Awaited> - if ('cookieStore' in window) { - sessionCookies = await (window as { cookieStore: CookieStore }).cookieStore.getAll(SESSION_STORE_KEY) - } else { - sessionCookies = document.cookie.split(/\s*;\s*/).filter((cookie) => cookie.startsWith(SESSION_STORE_KEY)) - } - - return { - count: sessionCookies.length, - domain: getCurrentSite() || 'undefined', - ...sessionCookies, - } -} diff --git a/packages/core/src/domain/trackingConsent.spec.ts b/packages/core/src/domain/trackingConsent.spec.ts index 35ae358576..35381e6d56 100644 --- a/packages/core/src/domain/trackingConsent.spec.ts +++ b/packages/core/src/domain/trackingConsent.spec.ts @@ -41,4 +41,22 @@ describe('createTrackingConsentState', () => { trackingConsentState.tryToInit(TrackingConsent.NOT_GRANTED) expect(trackingConsentState.isGranted()).toBeTrue() }) + + describe('onGrantedOnce', () => { + it('calls onGrantedOnce when consent was already granted', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + const spy = jasmine.createSpy() + trackingConsentState.onGrantedOnce(spy) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('calls onGrantedOnce when consent is granted', () => { + const trackingConsentState = createTrackingConsentState() + const spy = jasmine.createSpy() + trackingConsentState.onGrantedOnce(spy) + expect(spy).toHaveBeenCalledTimes(0) + trackingConsentState.update(TrackingConsent.GRANTED) + expect(spy).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/core/src/domain/trackingConsent.ts b/packages/core/src/domain/trackingConsent.ts index 02e8150eee..4d7d02fe03 100644 --- a/packages/core/src/domain/trackingConsent.ts +++ b/packages/core/src/domain/trackingConsent.ts @@ -11,24 +11,39 @@ export interface TrackingConsentState { update: (trackingConsent: TrackingConsent) => void isGranted: () => boolean observable: Observable + onGrantedOnce: (callback: () => void) => void } export function createTrackingConsentState(currentConsent?: TrackingConsent): TrackingConsentState { const observable = new Observable() + function isGranted() { + return currentConsent === TrackingConsent.GRANTED + } + return { tryToInit(trackingConsent: TrackingConsent) { if (!currentConsent) { currentConsent = trackingConsent } }, + onGrantedOnce(fn) { + if (isGranted()) { + fn() + } else { + const subscription = observable.subscribe(() => { + if (isGranted()) { + fn() + subscription.unsubscribe() + } + }) + } + }, update(trackingConsent: TrackingConsent) { currentConsent = trackingConsent observable.notify() }, - isGranted() { - return currentConsent === TrackingConsent.GRANTED - }, + isGranted, observable, } } diff --git a/packages/core/test/collectAsyncCalls.ts b/packages/core/test/collectAsyncCalls.ts index 0ac7325b89..a5c874af3a 100644 --- a/packages/core/test/collectAsyncCalls.ts +++ b/packages/core/test/collectAsyncCalls.ts @@ -1,5 +1,7 @@ import { getCurrentJasmineSpec } from './getCurrentJasmineSpec' +const originalPlanForGuard = new WeakMap<() => void, () => void>() + export function collectAsyncCalls( spy: jasmine.Spy, expectedCallsCount = 1 @@ -13,23 +15,28 @@ export function collectAsyncCalls( const checkCalls = () => { if (spy.calls.count() === expectedCallsCount) { - spy.and.callFake(extraCallDetected as F) resolve(spy.calls) } else if (spy.calls.count() > expectedCallsCount) { - extraCallDetected() + const message = `Unexpected extra call for spec '${currentSpec.fullName}'` + fail(message) + reject(new Error(message)) } } checkCalls() - spy.and.callFake((() => { + const originalPlan = getOriginalPlan(spy) + const guard = ((...args: Parameters) => { checkCalls() - }) as F) - - function extraCallDetected() { - const message = `Unexpected extra call for spec '${currentSpec!.fullName}'` - fail(message) - reject(new Error(message)) - } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return originalPlan(...args) + }) as F + originalPlanForGuard.set(guard, originalPlan) + spy.and.callFake(guard) }) } + +function getOriginalPlan void>(spy: jasmine.Spy): F { + const originalPlanOrGuard: F = (spy.and as unknown as { plan: F }).plan + return (originalPlanForGuard.get(originalPlanOrGuard) as F | undefined) ?? originalPlanOrGuard +} diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts index ab898056e9..b563bd2751 100644 --- a/packages/core/test/index.ts +++ b/packages/core/test/index.ts @@ -30,3 +30,5 @@ export * from './createHooks' export * from './fakeSessionStoreStrategy' export * from './readFormData' export * from './replaceMockable' +export * from './sampling' +export * from './mockSessionManager' diff --git a/packages/core/test/mockSessionManager.ts b/packages/core/test/mockSessionManager.ts new file mode 100644 index 0000000000..50bf38e89a --- /dev/null +++ b/packages/core/test/mockSessionManager.ts @@ -0,0 +1,66 @@ +import type { SessionManager, startSessionManager } from '@datadog/browser-core' +import { Observable } from '../src/tools/observable' +import { noop } from '../src/tools/utils/functionUtils' +import { timeStampNow } from '../src/tools/utils/timeUtils' +import { LOW_HASH_UUID } from './sampling' + +export interface SessionManagerMock extends SessionManager { + setId(id: string): SessionManagerMock + setNotTracked(): SessionManagerMock + setTracked(): SessionManagerMock + setForcedReplay(): SessionManagerMock +} + +export const MOCK_SESSION_ID = LOW_HASH_UUID + +const enum SessionStatus { + TRACKED, + NOT_TRACKED, +} + +export function createSessionManagerMock(): SessionManagerMock { + let id = MOCK_SESSION_ID + let sessionIsActive = true + let sessionStatus: SessionStatus = SessionStatus.TRACKED + let forcedReplay = false + + return { + findSession: () => { + if (sessionStatus === SessionStatus.TRACKED && sessionIsActive) { + return { id, isReplayForced: forcedReplay, anonymousId: 'device-123', createdAt: timeStampNow() } + } + }, + findTrackedSession: (_startTime, options) => { + if (sessionStatus === SessionStatus.TRACKED && (sessionIsActive || options?.returnInactive)) { + return { id, anonymousId: 'device-123', isReplayForced: forcedReplay, createdAt: timeStampNow() } + } + }, + expire() { + sessionIsActive = false + this.expireObservable.notify() + }, + expireObservable: new Observable(), + renewObservable: new Observable(), + updateSessionState: noop, + setId(newId) { + id = newId + return this + }, + setNotTracked() { + sessionStatus = SessionStatus.NOT_TRACKED + return this + }, + setTracked() { + sessionStatus = SessionStatus.TRACKED + return this + }, + setForcedReplay() { + forcedReplay = true + return this + }, + } +} + +export function createStartSessionManagerMock(): typeof startSessionManager { + return () => Promise.resolve(createSessionManagerMock()) +}