From dcb5c911a73563d9326b84d68a8581fa92702a5d Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Mon, 8 Jun 2020 16:24:12 -0300 Subject: [PATCH] Introduce an inter-service communication layer - Extract the event dispatching mechanism into a new service - Refactor token-related services to handle external changes - Add support for external services to dispatch events - Restructure token store concepts - Restrict tracker access to the token to read-only operation - Add a prefix to tracking-related events to disambiguate with other events --- src/activeRecord.ts | 4 +- src/cache/cookieCache.ts | 12 +- src/cache/fallbackCache.ts | 10 +- src/cache/inMemoryCache.ts | 12 +- src/cache/index.ts | 13 +- src/cache/localStorageCache.ts | 85 +++++ src/cache/storageCache.ts | 26 -- src/container.ts | 71 ++++- src/context.ts | 92 ++++-- src/evaluator.ts | 4 +- src/eventManager.ts | 52 ++++ src/facade/sdkFacade.ts | 31 +- src/facade/sessionPatch.ts | 2 +- src/facade/trackerFacade.ts | 6 +- src/facade/userPatch.ts | 2 +- src/sdk.ts | 6 + src/sdkEvents.ts | 15 + src/tab.ts | 38 +-- src/token/cachedTokenStore.ts | 34 ++ ...MemoryStorage.ts => inMemoryTokenStore.ts} | 4 +- src/token/index.ts | 10 +- src/token/persistentStorage.ts | 36 --- ...atedStorage.ts => replicatedTokenStore.ts} | 10 +- src/tracker.ts | 165 +++++----- src/{event.ts => trackingEvents.ts} | 58 ++-- test/cache/cookieCache.test.ts | 2 +- test/cache/fallbackCache.test.ts | 25 ++ test/cache/inMemoryCache.test.ts | 2 +- test/cache/localStorageCache.test.ts | 118 +++++++ test/cache/storageCache.test.ts | 24 -- test/channel/beaconSocketChannel.test.ts | 4 +- test/container.test.ts | 81 ++++- test/context.test.ts | 290 ++++++++++++++++-- test/evaluator.test.ts | 27 +- test/eventManager.test.ts | 50 +++ test/facade/sdkFacade.test.ts | 83 ++++- test/facade/sessionFacade.test.ts | 2 +- test/facade/sessionPatch.test.ts | 2 +- test/facade/trackerFacade.test.ts | 6 +- test/facade/userFacade.test.ts | 2 +- test/facade/userPatch.test.ts | 2 +- test/schemas/ecommerceSchemas.test.ts | 2 +- test/schemas/eventSchemas.test.ts | 2 +- test/sdk.test.ts | 43 ++- test/token/cachedTokenStore.test.ts | 48 +++ test/token/inMemoryStorage.test.ts | 16 +- test/token/index.test.ts | 8 +- test/token/persistentStorage.test.ts | 45 --- test/token/replicatedStorage.test.ts | 32 +- test/tracker.test.ts | 138 +++++---- test/utils/tabEventEmulator.ts | 4 +- 51 files changed, 1356 insertions(+), 500 deletions(-) create mode 100644 src/cache/localStorageCache.ts delete mode 100644 src/cache/storageCache.ts create mode 100644 src/eventManager.ts create mode 100644 src/sdkEvents.ts create mode 100644 src/token/cachedTokenStore.ts rename src/token/{inMemoryStorage.ts => inMemoryTokenStore.ts} (64%) delete mode 100644 src/token/persistentStorage.ts rename src/token/{replicatedStorage.ts => replicatedTokenStore.ts} (53%) rename src/{event.ts => trackingEvents.ts} (82%) create mode 100644 test/cache/localStorageCache.test.ts delete mode 100644 test/cache/storageCache.test.ts create mode 100644 test/eventManager.test.ts create mode 100644 test/token/cachedTokenStore.test.ts delete mode 100644 test/token/persistentStorage.test.ts diff --git a/src/activeRecord.ts b/src/activeRecord.ts index b678d8ab..b977594e 100644 --- a/src/activeRecord.ts +++ b/src/activeRecord.ts @@ -1,6 +1,6 @@ import {Operation, Patch} from './patch'; import {JsonArray, JsonObject, JsonValue} from './json'; -import {Event} from './event'; +import {TrackingEvent} from './trackingEvents'; import { addOperation, clearOperation, @@ -23,7 +23,7 @@ const operationSchema = { unset: unsetOperation, }; -export default abstract class ActiveRecord { +export default abstract class ActiveRecord { private readonly operations: Operation[] = []; public set(value: JsonValue): this; diff --git a/src/cache/cookieCache.ts b/src/cache/cookieCache.ts index e60967bc..32cd98c8 100644 --- a/src/cache/cookieCache.ts +++ b/src/cache/cookieCache.ts @@ -15,13 +15,11 @@ export default class CookieCache implements Cache { return getCookie(this.cookieName); } - public put(value: string|null): void { - if (value === null) { - unsetCookie(this.cookieName); - - return; - } - + public put(value: string): void { setCookie(this.cookieName, value, this.options); } + + public clear(): void { + unsetCookie(this.cookieName); + } } diff --git a/src/cache/fallbackCache.ts b/src/cache/fallbackCache.ts index d21ea338..6352138d 100644 --- a/src/cache/fallbackCache.ts +++ b/src/cache/fallbackCache.ts @@ -19,9 +19,11 @@ export default class FallbackCache implements Cache { return null; } - public put(value: string | null): void { - for (const cache of this.caches) { - cache.put(value); - } + public put(value: string): void { + this.caches.forEach(cache => cache.put(value)); + } + + public clear(): void { + this.caches.forEach(cache => cache.clear()); } } diff --git a/src/cache/inMemoryCache.ts b/src/cache/inMemoryCache.ts index edab2923..f1597d91 100644 --- a/src/cache/inMemoryCache.ts +++ b/src/cache/inMemoryCache.ts @@ -1,17 +1,21 @@ import Cache from './index'; export default class InMemoryCache implements Cache { - private cache: string | null = null; + private cache?: string; - public constructor(cache: string | null = null) { + public constructor(cache?: string) { this.cache = cache; } public get(): string | null { - return this.cache; + return this.cache ?? null; } - public put(value: string | null): void { + public put(value: string): void { this.cache = value; } + + public clear(): void { + delete this.cache; + } } diff --git a/src/cache/index.ts b/src/cache/index.ts index 3f0ca8ac..1ffd8e35 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,4 +1,15 @@ export default interface Cache { get(): string|null; - put(value: string|null): void; + put(value: string): void; + clear(): void; +} + +export interface CacheListener { + (value: string|null): void; +} + +export interface ObservableCache extends Cache { + addListener(listener: CacheListener): void; + + removeListener(listener: CacheListener): void; } diff --git a/src/cache/localStorageCache.ts b/src/cache/localStorageCache.ts new file mode 100644 index 00000000..80a2f94d --- /dev/null +++ b/src/cache/localStorageCache.ts @@ -0,0 +1,85 @@ +import {CacheListener, ObservableCache} from './index'; + +export default class LocalStorageCache implements ObservableCache { + private readonly storage: Storage; + + private readonly key: string; + + private value: string|null; + + private readonly listeners: CacheListener[] = []; + + public constructor(storage: Storage, key: string) { + this.storage = storage; + this.key = key; + this.value = storage.getItem(key); + } + + public static autoSync(cache: LocalStorageCache): (() => void) { + const listener = cache.sync.bind(cache); + + window.addEventListener('storage', listener); + + return (): void => window.removeEventListener('storage', listener); + } + + public get(): string|null { + return this.value; + } + + public put(value: string): void { + this.storage.setItem(this.key, value); + + if (this.value !== value) { + this.value = value; + this.notifyChange(value); + } + } + + public clear(): void { + this.storage.removeItem(this.key); + + if (this.value !== null) { + this.value = null; + this.notifyChange(null); + } + } + + public addListener(listener: CacheListener): void { + if (!this.listeners.includes(listener)) { + this.listeners.push(listener); + } + } + + public removeListener(listener: CacheListener): void { + const index = this.listeners.indexOf(listener); + + if (index > -1) { + this.listeners.splice(index, 1); + } + } + + private notifyChange(value: string|null): void { + this.listeners.forEach(listener => listener(value)); + } + + private sync(event: StorageEvent): void { + if (event.storageArea !== this.storage || (event.key !== null && event.key !== this.key)) { + // Ignore unrelated changes + return; + } + + /* + * Retrieving the value from the store rather than the event ensures + * the cache will be in sync with the latest value set. + * In case of cascading changes, it prevents notifying listeners + * about intermediate states already outdated at this point. + */ + const value = this.storage.getItem(this.key); + + if (this.value !== value) { + this.value = value; + this.notifyChange(value); + } + } +} diff --git a/src/cache/storageCache.ts b/src/cache/storageCache.ts deleted file mode 100644 index 5da3391d..00000000 --- a/src/cache/storageCache.ts +++ /dev/null @@ -1,26 +0,0 @@ -import Cache from './index'; - -export default class StorageCache implements Cache { - private readonly storage: Storage; - - private readonly key: string; - - public constructor(storage: Storage, key: string) { - this.storage = storage; - this.key = key; - } - - public get(): string|null { - return this.storage.getItem(this.key); - } - - public put(value: string | null): void { - if (value === null) { - this.storage.removeItem(this.key); - - return; - } - - this.storage.setItem(this.key, value); - } -} diff --git a/src/container.ts b/src/container.ts index 1c2a4e4c..29424294 100644 --- a/src/container.ts +++ b/src/container.ts @@ -13,9 +13,9 @@ import MonitoredQueue from './queue/monitoredQueue'; import CapacityRestrictedQueue from './queue/capacityRestrictedQueue'; import EncodedChannel from './channel/encodedChannel'; import BeaconSocketChannel from './channel/beaconSocketChannel'; -import {Beacon} from './event'; +import {Beacon} from './trackingEvents'; import {SocketChannel} from './channel/socketChannel'; -import Token, {TokenProvider} from './token'; +import {TokenProvider} from './token'; import Tracker from './tracker'; import Evaluator from './evaluator'; import NamespacedLogger from './logging/namespacedLogger'; @@ -24,10 +24,12 @@ import CidAssigner from './cid/index'; import CachedAssigner from './cid/cachedAssigner'; import RemoteAssigner from './cid/remoteAssigner'; import FallbackCache from './cache/fallbackCache'; -import StorageCache from './cache/storageCache'; import CookieCache from './cache/cookieCache'; import {getBaseDomain} from './cookie'; import FixedCidAssigner from './cid/fixedCidAssigner'; +import {EventManager, SynchronousEventManager} from './eventManager'; +import {SdkEventMap} from './sdkEvents'; +import LocalStorageCache from './cache/localStorageCache'; export type Configuration = { appId: string, @@ -47,6 +49,8 @@ export class Container { private context?: Context; + private tokenProvider?: TokenProvider; + private tracker?: Tracker; private evaluator?: Evaluator; @@ -57,6 +61,10 @@ export class Container { private beaconQueue?: MonitoredQueue; + private removeTokenSyncListener?: {(): void}; + + private readonly eventManager = new SynchronousEventManager(); + public constructor(configuration: Configuration) { this.configuration = configuration; } @@ -74,16 +82,10 @@ export class Container { } private createEvaluator(): Evaluator { - const context = this.getContext(); - return new Evaluator({ appId: this.configuration.appId, endpointUrl: this.configuration.evaluationEndpointUrl, - tokenProvider: new class implements TokenProvider { - public getToken(): Promise { - return Promise.resolve(context.getToken()); - } - }(), + tokenProvider: this.getTokenProvider(), cidAssigner: this.getCidAssigner(), }); } @@ -97,8 +99,11 @@ export class Container { } private createTracker(): Tracker { + const context = this.getContext(); + const tracker = new Tracker({ - context: this.getContext(), + tab: context.getTab(), + tokenProvider: this.getTokenProvider(), logger: this.getLogger('Tracker'), channel: this.getBeaconChannel(), eventMetadata: this.configuration.eventMetadata || {}, @@ -112,6 +117,15 @@ export class Container { return tracker; } + public getTokenProvider(): TokenProvider { + if (this.tokenProvider === undefined) { + const context = this.getContext(); + this.tokenProvider = {getToken: context.getToken.bind(context)}; + } + + return this.tokenProvider; + } + public getContext(): Context { if (this.context === undefined) { this.context = this.createContext(); @@ -121,11 +135,21 @@ export class Container { } private createContext(): Context { - return Context.load( - this.getGlobalTabStorage('context'), - this.getGlobalBrowserStorage('context'), - this.configuration.tokenScope, - ); + const browserStorage = this.getLocalStorage(); + const browserCache = new LocalStorageCache(browserStorage, 'croct.token'); + const tabStorage = this.getSessionStorage(); + + this.removeTokenSyncListener = LocalStorageCache.autoSync(browserCache); + + return Context.load({ + tokenScope: this.configuration.tokenScope, + eventDispatcher: this.getEventManager(), + cache: { + tabId: new LocalStorageCache(tabStorage, 'croct.tab'), + tabToken: new LocalStorageCache(tabStorage, 'croct.token'), + browserToken: browserCache, + }, + }); } private getBeaconChannel(): OutputChannel { @@ -196,7 +220,7 @@ export class Container { logger, ), new FallbackCache( - new StorageCache(this.getLocalStorage(), 'croct.cid'), + new LocalStorageCache(this.getLocalStorage(), 'croct.cid'), new CookieCache('croct.cid', { sameSite: 'strict', domain: getBaseDomain(), @@ -277,6 +301,10 @@ export class Container { return sessionStorage; } + public getEventManager(): EventManager { + return this.eventManager; + } + public async dispose(): Promise { const logger = this.getLogger(); @@ -286,6 +314,12 @@ export class Container { await this.beaconChannel.close(); } + if (this.removeTokenSyncListener) { + logger.debug('Removing token sync listener...'); + + this.removeTokenSyncListener(); + } + if (this.tracker) { if (this.beaconQueue) { logger.debug('Removing queue listeners...'); @@ -302,10 +336,13 @@ export class Container { } delete this.context; + delete this.tokenProvider; + delete this.cidAssigner; delete this.tracker; delete this.evaluator; delete this.beaconChannel; delete this.beaconQueue; + delete this.removeTokenSyncListener; logger.debug('Container resources released.'); } diff --git a/src/context.ts b/src/context.ts index b27aae44..368ca9a9 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,24 +1,50 @@ -import Token, {TokenStorage} from './token'; +import Token, {TokenStore} from './token'; import Tab from './tab'; -import PersistentStorage from './token/persistentStorage'; -import ReplicatedStorage from './token/replicatedStorage'; -import InMemoryStorage from './token/inMemoryStorage'; +import CachedTokenStore from './token/cachedTokenStore'; +import ReplicatedTokenStore from './token/replicatedTokenStore'; +import InMemoryTokenStore from './token/inMemoryTokenStore'; import {uuid4} from './uuid'; +import {EventDispatcher} from './eventManager'; +import {SdkEventMap} from './sdkEvents'; +import LocalStorageCache from './cache/localStorageCache'; export type TokenScope = 'isolated' | 'global' | 'contextual'; +export type Configuration = { + tokenScope: TokenScope, + eventDispatcher: ContextEventDispatcher, + cache: { + tabId: LocalStorageCache, + tabToken: LocalStorageCache, + browserToken: LocalStorageCache, + }, +}; + +type ContextEventDispatcher = EventDispatcher>; + +function tokenEquals(left: Token|null, right: Token|null): boolean { + return left === right || (left !== null && right !== null && left.toString() === right.toString()); +} + export default class Context { private readonly tab: Tab; - private readonly tokenStorage: TokenStorage; + private readonly tokenStore: TokenStore; - public constructor(tab: Tab, tokenStorage: TokenStorage) { + private readonly eventDispatcher: ContextEventDispatcher; + + private lastToken: Token|null; + + private constructor(tab: Tab, tokenStore: TokenStore, eventDispatcher: ContextEventDispatcher) { this.tab = tab; - this.tokenStorage = tokenStorage; + this.tokenStore = tokenStore; + this.eventDispatcher = eventDispatcher; + this.lastToken = tokenStore.getToken(); + this.syncToken = this.syncToken.bind(this); } - public static load(tabStorage: Storage, browserStorage: Storage, tokenScope: TokenScope): Context { - let tabId: string | null = tabStorage.getItem('tab'); + public static load({cache, tokenScope, eventDispatcher}: Configuration): Context { + let tabId: string | null = cache.tabId.get(); let newTab = false; if (tabId === null) { @@ -28,20 +54,24 @@ export default class Context { const tab = new Tab(tabId, newTab); - tabStorage.removeItem('tab'); + cache.tabId.clear(); - tab.addListener('unload', () => tabStorage.setItem('tab', tab.id)); + tab.addListener('unload', () => cache.tabId.put(tab.id)); switch (tokenScope) { case 'isolated': - return new Context(tab, new InMemoryStorage()); + return new Context(tab, new InMemoryTokenStore(), eventDispatcher); - case 'global': - return new Context(tab, new PersistentStorage(browserStorage)); + case 'global': { + const context = new Context(tab, new CachedTokenStore(cache.browserToken), eventDispatcher); + cache.browserToken.addListener(context.syncToken); + + return context; + } case 'contextual': { - const primaryStorage = new PersistentStorage(tabStorage, `${tabId}.token`); - const secondaryStorage = new PersistentStorage(browserStorage); + const primaryStorage = new CachedTokenStore(cache.tabToken); + const secondaryStorage = new CachedTokenStore(cache.browserToken); if (tab.isNew) { primaryStorage.setToken(secondaryStorage.getToken()); @@ -53,7 +83,7 @@ export default class Context { } }); - return new Context(tab, new ReplicatedStorage(primaryStorage, secondaryStorage)); + return new Context(tab, new ReplicatedTokenStore(primaryStorage, secondaryStorage), eventDispatcher); } } } @@ -75,10 +105,34 @@ export default class Context { } public getToken(): Token | null { - return this.tokenStorage.getToken(); + return this.tokenStore.getToken(); } public setToken(token: Token | null): void { - this.tokenStorage.setToken(token); + const oldToken = this.lastToken; + + this.lastToken = token; + this.tokenStore.setToken(token); + + if (!tokenEquals(oldToken, token)) { + this.eventDispatcher.dispatch('tokenChanged', { + oldToken: oldToken, + newToken: token, + }); + } + } + + private syncToken(): void { + const newToken = this.tokenStore.getToken(); + const oldToken = this.lastToken; + + if (!tokenEquals(oldToken, newToken)) { + this.lastToken = newToken; + + this.eventDispatcher.dispatch('tokenChanged', { + oldToken: oldToken, + newToken: newToken, + }); + } } } diff --git a/src/evaluator.ts b/src/evaluator.ts index 4271b573..f860777f 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -181,8 +181,8 @@ export default class Evaluator { private async fetch(endpoint: string): Promise { const {tokenProvider, cidAssigner, appId} = this.configuration; - - const [token, cid] = await Promise.all([tokenProvider.getToken(), cidAssigner.assignCid()]); + const token = tokenProvider.getToken(); + const cid = await cidAssigner.assignCid(); const headers = { 'X-App-Id': appId, diff --git a/src/eventManager.ts b/src/eventManager.ts new file mode 100644 index 00000000..63b91433 --- /dev/null +++ b/src/eventManager.ts @@ -0,0 +1,52 @@ +export interface EventListener { + (event: T): void; +} + +export type EventMap = Record; + +export interface EventDispatcher { + dispatch(eventName: T, event: TEvents[T]): void; +} + +export interface EventSubscriber { + addListener(eventName: T, listener: EventListener): void; + + removeListener(eventName: T, listener: EventListener): void; +} + +export interface EventManager extends EventDispatcher, EventSubscriber { +} + +export class SynchronousEventManager implements EventManager { + private readonly listeners: {[type in keyof TEvents]?: EventListener[]} = {}; + + public addListener(type: T, listener: EventListener): void { + const listeners: EventListener[] = this.listeners[type] ?? []; + listeners.push(listener); + + this.listeners[type] = listeners; + } + + public removeListener(eventName: T, listener: EventListener): void { + const listeners = this.listeners[eventName]; + + if (listeners === undefined) { + return; + } + + const index = listeners.indexOf(listener); + + if (index >= 0) { + listeners.splice(index, 1); + } + } + + public dispatch(eventName: T, event: TEvents[T]): void { + const listeners = this.listeners[eventName]; + + if (listeners !== undefined) { + listeners.forEach(listener => listener(event)); + } + } +} + diff --git a/src/facade/sdkFacade.ts b/src/facade/sdkFacade.ts index 590567ae..93dffdf9 100644 --- a/src/facade/sdkFacade.ts +++ b/src/facade/sdkFacade.ts @@ -9,7 +9,14 @@ import {configurationSchema} from '../schema/sdkFacadeSchemas'; import Sdk from '../sdk'; import SessionFacade from './sessionFacade'; import {Logger} from '../logging'; -import {ExternalEvent, ExternalEventPayload, ExternalEventType, PartialEvent} from '../event'; +import { + ExternalTrackingEvent as ExternalEvent, + ExternalTrackingEventPayload as ExternalEventPayload, + ExternalTrackingEventType as ExternalEventType, + PartialTrackingEvent as PartialEvent, +} from '../trackingEvents'; +import {SdkEvent, SdkEventMap, SdkEventType} from '../sdkEvents'; +import {EventListener, EventManager} from '../eventManager'; export type Configuration = { appId: string, @@ -37,7 +44,7 @@ function validateConfiguration(configuration: unknown): asserts configuration is } } -export default class SdkFacade { +export default class SdkFacade implements EventManager { private readonly sdk: Sdk; private trackerFacade?: TrackerFacade; @@ -231,6 +238,26 @@ export default class SdkFacade { return this.sdk.getBrowserStorage(namespace, ...subnamespace); } + public addListener(type: T, listener: EventListener>): void { + this.sdk.getEventManager().addListener(type, listener); + } + + public removeListener(type: T, listener: EventListener>): void { + this.sdk.getEventManager().removeListener(type, listener); + } + + public dispatch(eventName: T, event: SdkEventMap[T]): void { + if (!/[a-z][a-z_]+\.[a-z][a-z_]+/i.test(eventName)) { + throw new Error( + 'The event name must be in the form of "namespaced.eventName", where ' + + 'both the namespace and event name must start with a letter, followed by ' + + 'any series of letters and underscores.', + ); + } + + this.sdk.getEventManager().dispatch(eventName, event); + } + public close(): Promise { return this.sdk.close(); } diff --git a/src/facade/sessionPatch.ts b/src/facade/sessionPatch.ts index d0c9db95..950e9c26 100644 --- a/src/facade/sessionPatch.ts +++ b/src/facade/sessionPatch.ts @@ -1,6 +1,6 @@ import ActiveRecord from '../activeRecord'; import Tracker from '../tracker'; -import {SessionAttributesChanged} from '../event'; +import {SessionAttributesChanged} from '../trackingEvents'; export default class SessionPatch extends ActiveRecord { private readonly tracker: Tracker; diff --git a/src/facade/trackerFacade.ts b/src/facade/trackerFacade.ts index af2fd2c9..c4a0ae43 100644 --- a/src/facade/trackerFacade.ts +++ b/src/facade/trackerFacade.ts @@ -1,4 +1,8 @@ -import {ExternalEvent, ExternalEventPayload, ExternalEventType} from '../event'; +import { + ExternalTrackingEvent as ExternalEvent, + ExternalTrackingEventPayload as ExternalEventPayload, + ExternalTrackingEventType as ExternalEventType, +} from '../trackingEvents'; import {formatCause} from '../error'; import Tracker, {EventListener} from '../tracker'; import { diff --git a/src/facade/userPatch.ts b/src/facade/userPatch.ts index e36fa25c..2b2e9b92 100644 --- a/src/facade/userPatch.ts +++ b/src/facade/userPatch.ts @@ -1,6 +1,6 @@ import ActiveRecord from '../activeRecord'; import Tracker from '../tracker'; -import {UserProfileChanged} from '../event'; +import {UserProfileChanged} from '../trackingEvents'; export default class UserPatch extends ActiveRecord { private readonly tracker: Tracker; diff --git a/src/sdk.ts b/src/sdk.ts index 592a840e..b64a4b24 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -6,6 +6,8 @@ import {configurationSchema} from './schema/sdkSchemas'; import {formatCause} from './error'; import Tracker from './tracker'; import Evaluator from './evaluator'; +import {SdkEventMap} from './sdkEvents'; +import {EventManager} from './eventManager'; export type Configuration = { appId: string, @@ -127,6 +129,10 @@ export default class Sdk { return this.container.getBrowserStorage(namespace, ...subnamespace); } + public getEventManager(): EventManager { + return this.container.getEventManager(); + } + public async close(): Promise { if (this.closed) { return; diff --git a/src/sdkEvents.ts b/src/sdkEvents.ts new file mode 100644 index 00000000..52ff1482 --- /dev/null +++ b/src/sdkEvents.ts @@ -0,0 +1,15 @@ +import Token from './token/index'; + +export interface TokenChanged { + oldToken: Token|null; + newToken: Token|null; +} + +export type SdkEventMap = Record & { + tokenChanged: TokenChanged, +} + +export type SdkEventType = keyof SdkEventMap; + +export type SdkEvent = + T extends SdkEventType ? SdkEventMap[T] : SdkEventMap[SdkEventType]; diff --git a/src/tab.ts b/src/tab.ts index 733dbf01..0b0e96fe 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -1,3 +1,5 @@ +import {SynchronousEventManager, EventListener} from './eventManager'; + export type TabEvent = CustomEvent<{tab: Tab} & T>; export type TabVisibilityChangeEvent = TabEvent<{visible: boolean}>; export type TabUrlChangeEvent = TabEvent<{url: string}>; @@ -11,10 +13,6 @@ type TabEventMap = { urlChange: TabUrlChangeEvent, } -interface TabEventListener { - (event: TabEventMap[T]): void; -} - const EventMap: {[key: string]: keyof TabEventMap} = { focus: 'focus', blur: 'blur', @@ -32,7 +30,7 @@ export default class Tab { public readonly isNew: boolean; - private readonly listeners: Partial<{[key in keyof TabEventMap]: TabEventListener[]}> = {}; + private readonly eventManager = new SynchronousEventManager(); public constructor(id: string, isNew: boolean) { this.id = id; @@ -42,7 +40,7 @@ export default class Tab { } private initialize(): void { - const listener: EventListener = event => { + const listener = (event: Event): void => { this.emit(EventMap[event.type], new CustomEvent(EventMap[event.type], {detail: {tab: this}})); }; @@ -93,34 +91,16 @@ export default class Tab { return document; } - public addListener(type: T, listener: TabEventListener): void { - if (this.listeners[type] === undefined) { - this.listeners[type] = []; - } - - (this.listeners[type] as TabEventListener[]).push(listener); + public addListener(type: T, listener: EventListener): void { + this.eventManager.addListener(type, listener); } - public removeListener(type: T, listener: TabEventListener): void { - const listeners = this.listeners[type] as TabEventListener[]; - - if (!listeners) { - return; - } - - const index = listeners.indexOf(listener); - - if (index >= 0) { - listeners.splice(index, 1); - } + public removeListener(type: T, listener: EventListener): void { + this.eventManager.removeListener(type, listener); } private emit(type: T, event: TabEventMap[T]): void { - const listeners = this.listeners[type] as TabEventListener[]; - - if (listeners !== undefined) { - listeners.forEach(listener => listener(event)); - } + this.eventManager.dispatch(type, event); } private static addUrlChangeListener(listener: {(url: string): void}): void { diff --git a/src/token/cachedTokenStore.ts b/src/token/cachedTokenStore.ts new file mode 100644 index 00000000..5e661815 --- /dev/null +++ b/src/token/cachedTokenStore.ts @@ -0,0 +1,34 @@ +import Token, {TokenStore} from './index'; +import Cache from '../cache/index'; + +export default class CachedTokenStore implements TokenStore { + private readonly cache: Cache; + + public constructor(cache: Cache) { + this.cache = cache; + } + + public getToken(): Token | null { + const data: string | null = this.cache.get(); + + if (data === null) { + return null; + } + + try { + return Token.parse(data); + } catch (error) { + return null; + } + } + + public setToken(token: Token | null): void { + if (token === null) { + this.cache.clear(); + + return; + } + + this.cache.put(token.toString()); + } +} diff --git a/src/token/inMemoryStorage.ts b/src/token/inMemoryTokenStore.ts similarity index 64% rename from src/token/inMemoryStorage.ts rename to src/token/inMemoryTokenStore.ts index e92ff671..e2c71d0f 100644 --- a/src/token/inMemoryStorage.ts +++ b/src/token/inMemoryTokenStore.ts @@ -1,6 +1,6 @@ -import Token, {TokenStorage} from './index'; +import Token, {TokenStore} from './index'; -export default class InMemoryStorage implements TokenStorage { +export default class InMemoryTokenStore implements TokenStore { private token: Token | null = null; public getToken(): Token | null { diff --git a/src/token/index.ts b/src/token/index.ts index 93a8845f..ffcbd344 100644 --- a/src/token/index.ts +++ b/src/token/index.ts @@ -134,12 +134,10 @@ export default class Token { } export interface TokenProvider { - getToken(): Promise; -} - -export interface TokenStorage { getToken(): Token | null; +} +export interface TokenStore extends TokenProvider { setToken(token: Token | null): void; } @@ -150,7 +148,7 @@ export class FixedTokenProvider implements TokenProvider { this.token = token; } - public getToken(): Promise { - return Promise.resolve(this.token); + public getToken(): Token | null { + return this.token; } } diff --git a/src/token/persistentStorage.ts b/src/token/persistentStorage.ts deleted file mode 100644 index 584a9eb8..00000000 --- a/src/token/persistentStorage.ts +++ /dev/null @@ -1,36 +0,0 @@ -import Token, {TokenStorage} from './index'; - -export default class PersistentStorage implements TokenStorage { - private readonly storage: Storage; - - private readonly key: string; - - public constructor(storage: Storage, key = 'token') { - this.storage = storage; - this.key = key; - } - - public getToken(): Token | null { - const data: string | null = this.storage.getItem(this.key); - - if (data === null) { - return null; - } - - try { - return Token.parse(data); - } catch (error) { - return null; - } - } - - public setToken(token: Token | null): void { - if (token === null) { - this.storage.removeItem(this.key); - - return; - } - - this.storage.setItem(this.key, token.toString()); - } -} diff --git a/src/token/replicatedStorage.ts b/src/token/replicatedTokenStore.ts similarity index 53% rename from src/token/replicatedStorage.ts rename to src/token/replicatedTokenStore.ts index 243e97aa..12d1d8b1 100644 --- a/src/token/replicatedStorage.ts +++ b/src/token/replicatedTokenStore.ts @@ -1,11 +1,11 @@ -import Token, {TokenStorage} from './index'; +import Token, {TokenStore} from './index'; -export default class ReplicatedStorage implements TokenStorage { - private primary: TokenStorage; +export default class ReplicatedTokenStore implements TokenStore { + private primary: TokenStore; - private secondary: TokenStorage; + private secondary: TokenStore; - public constructor(primary: TokenStorage, secondary: TokenStorage) { + public constructor(primary: TokenStore, secondary: TokenStore) { this.primary = primary; this.secondary = secondary; } diff --git a/src/tracker.ts b/src/tracker.ts index a056ec65..a25d2dad 100644 --- a/src/tracker.ts +++ b/src/tracker.ts @@ -1,5 +1,4 @@ import {Logger} from './logging'; -import Context from './context'; import Tab, {TabEvent, TabUrlChangeEvent, TabVisibilityChangeEvent} from './tab'; import {OutputChannel} from './channel'; import NullLogger from './logging/nullLogger'; @@ -7,12 +6,13 @@ import {formatCause} from './error'; import { Beacon, BeaconPayload, - Event, - EventContext, + TrackingEvent, + TrackingEventContext, isCartPartialEvent, isIdentifiedUserEvent, - PartialEvent, -} from './event'; + PartialTrackingEvent, +} from './trackingEvents'; +import {TokenProvider} from './token/index'; type Options = { inactivityInterval?: number, @@ -20,13 +20,25 @@ type Options = { }; export type Configuration = Options & { - context: Context, channel: OutputChannel, logger?: Logger, + tab: Tab, + tokenProvider: TokenProvider, } -export type EventInfo = { - context: EventContext, +type State = { + initialized: boolean, + enabled: boolean, + suspended: boolean, +} + +type InactivityTimer = { + id?: number, + since: number, +} + +export type EventInfo = { + context: TrackingEventContext, event: T, timestamp: number, status: 'pending' | 'confirmed' | 'failed' | 'ignored', @@ -41,7 +53,9 @@ const trackedEvents: {[key: string]: {[key: string]: boolean}} = {}; export default class Tracker { private readonly options: Required; - private readonly context: Context; + private tab: Tab; + + private tokenProvider: TokenProvider; private readonly channel: OutputChannel; @@ -49,27 +63,21 @@ export default class Tracker { private readonly listeners: EventListener[] = []; - private initialized = false; - - private enabled = false; - - private suspended = false; - private readonly pending: Promise[] = []; - private inactivityTimer: number; + private readonly state: State = { + enabled: false, + initialized: false, + suspended: false, + }; - private inactiveSince: number; + private readonly inactivityTimer: InactivityTimer = { + since: 0, + }; - public constructor( - { - context, - channel, - logger, - ...options - }: Configuration, - ) { - this.context = context; + public constructor({tab, tokenProvider, channel, logger, ...options}: Configuration) { + this.tab = tab; + this.tokenProvider = tokenProvider; this.channel = channel; this.logger = logger ?? new NullLogger(); this.options = { @@ -110,130 +118,126 @@ export default class Tracker { } public isEnabled(): boolean { - return this.enabled; + return this.state.enabled; } public isSuspended(): boolean { - return this.suspended; + return this.state.suspended; } public enable(): void { - if (this.enabled) { + if (this.state.enabled) { return; } this.logger.info('Tracker enabled'); - this.enabled = true; + this.state.enabled = true; - if (this.suspended) { + if (this.state.suspended) { return; } this.startInactivityTimer(); - if (!this.initialized) { - this.initialized = true; + if (!this.state.initialized) { + this.state.initialized = true; this.initialize(); } - const tab = this.context.getTab(); - - tab.addListener('load', this.trackPageLoad); - tab.addListener('urlChange', this.trackTabUrlChange); - tab.addListener('visibilityChange', this.trackTabVisibilityChange); + this.tab.addListener('load', this.trackPageLoad); + this.tab.addListener('urlChange', this.trackTabUrlChange); + this.tab.addListener('visibilityChange', this.trackTabVisibilityChange); } public disable(): void { - if (!this.enabled) { + if (!this.state.enabled) { return; } this.logger.info('Tracker disabled'); - this.enabled = false; + this.state.enabled = false; - if (this.suspended) { + if (this.state.suspended) { return; } - const tab = this.context.getTab(); - - tab.removeListener('load', this.trackPageLoad); - tab.removeListener('urlChange', this.trackTabUrlChange); - tab.removeListener('visibilityChange', this.trackTabVisibilityChange); + this.tab.removeListener('load', this.trackPageLoad); + this.tab.removeListener('urlChange', this.trackTabUrlChange); + this.tab.removeListener('visibilityChange', this.trackTabVisibilityChange); this.stopInactivityTimer(); } public suspend(): void { - if (this.suspended) { + if (this.state.suspended) { return; } this.logger.info('Tracker suspended'); - if (this.enabled) { + if (this.state.enabled) { this.disable(); - this.enabled = true; + this.state.enabled = true; } - this.suspended = true; + this.state.suspended = true; } public unsuspend(): void { - if (!this.suspended) { + if (!this.state.suspended) { return; } this.logger.info('Tracker unsuspended'); - this.suspended = false; + this.state.suspended = false; - if (this.enabled) { - this.enabled = false; + if (this.state.enabled) { + this.state.enabled = false; this.enable(); } } private initialize(): void { - const tab: Tab = this.context.getTab(); - - if (trackedEvents[tab.id] === undefined) { - trackedEvents[tab.id] = {}; + if (trackedEvents[this.tab.id] === undefined) { + trackedEvents[this.tab.id] = {}; } - const initEvents = trackedEvents[tab.id]; + const initEvents = trackedEvents[this.tab.id]; - if (tab.isNew && !initEvents.tabOpened) { + if (this.tab.isNew && !initEvents.tabOpened) { initEvents.tabOpened = true; - this.trackTabOpen({tabId: tab.id}); + this.trackTabOpen({tabId: this.tab.id}); } if (!initEvents.pageOpened) { initEvents.pageOpened = true; this.trackPageOpen({ - url: tab.url, - referrer: tab.referrer, + url: this.tab.url, + referrer: this.tab.referrer, }); } } private stopInactivityTimer(): void { - window.clearInterval(this.inactivityTimer); + if (this.inactivityTimer.id !== undefined) { + window.clearInterval(this.inactivityTimer.id); - delete this.inactivityTimer; + delete this.inactivityTimer.id; + } } private startInactivityTimer(): void { this.stopInactivityTimer(); - this.inactivityTimer = window.setInterval(this.trackInactivity, this.options.inactivityInterval); + this.inactivityTimer.id = window.setInterval(this.trackInactivity, this.options.inactivityInterval); } - public track(event: T, timestamp: number = Date.now()): Promise { + public track(event: T, timestamp: number = Date.now()): Promise { return this.publish(this.enrichEvent(event, timestamp), timestamp).then(() => event); } @@ -280,11 +284,11 @@ export default class Tracker { private trackInactivity(): void { this.enqueue({ type: 'nothingChanged', - sinceTime: this.inactiveSince, + sinceTime: this.inactivityTimer.since, }); } - private enqueue(event: Event, timestamp: number = Date.now()): void { + private enqueue(event: TrackingEvent, timestamp: number = Date.now()): void { this.publish(event, timestamp).catch(() => { // suppress error }); @@ -294,14 +298,13 @@ export default class Tracker { this.listeners.map(listener => listener(event)); } - private publish(event: T, timestamp: number): Promise { + private publish(event: T, timestamp: number): Promise { this.stopInactivityTimer(); - const tab = this.context.getTab(); const metadata = this.options.eventMetadata; - const context: EventContext = { - tabId: tab.id, - url: tab.url, + const context: TrackingEventContext = { + tabId: this.tab.id, + url: this.tab.url, ...(Object.keys(metadata).length > 0 ? {metadata: metadata} : {}), }; @@ -312,7 +315,7 @@ export default class Tracker { status: 'pending', }; - if (this.suspended) { + if (this.state.suspended) { this.logger.warn(`Tracker is suspended, ignoring event "${event.type}"`); this.notifyEvent({...eventInfo, status: 'ignored'}); @@ -349,16 +352,16 @@ export default class Tracker { }); if (event.type !== 'nothingChanged') { - this.inactiveSince = Date.now(); + this.inactivityTimer.since = Date.now(); } - if (this.enabled) { + if (this.state.enabled) { this.startInactivityTimer(); } }); } - private enrichEvent(event: PartialEvent, timestamp: number): Event { + private enrichEvent(event: PartialTrackingEvent, timestamp: number): TrackingEvent { if (isCartPartialEvent(event)) { const {cart: {lastUpdateTime = timestamp, ...cart}, ...payload} = event; @@ -374,8 +377,8 @@ export default class Tracker { return event; } - private createBeacon(event: Event, timestamp: number, context: EventContext): Beacon { - const token = this.context.getToken(); + private createBeacon(event: TrackingEvent, timestamp: number, context: TrackingEventContext): Beacon { + const token = this.tokenProvider.getToken(); return { timestamp: timestamp, @@ -385,7 +388,7 @@ export default class Tracker { }; } - private createBeaconPayload(event: Event): BeaconPayload { + private createBeaconPayload(event: TrackingEvent): BeaconPayload { if (!isIdentifiedUserEvent(event)) { return event; } diff --git a/src/event.ts b/src/trackingEvents.ts similarity index 82% rename from src/event.ts rename to src/trackingEvents.ts index 7ddbc891..c01203ae 100644 --- a/src/event.ts +++ b/src/trackingEvents.ts @@ -122,7 +122,7 @@ export const eventTypes = [ ...miscEventTypes, ] as const; -interface AbstractEvent { +interface BaseEvent { type: string; } @@ -130,7 +130,7 @@ interface AbstractEvent { * User events */ -export interface UserProfileChanged extends AbstractEvent { +export interface UserProfileChanged extends BaseEvent { type: 'userProfileChanged'; patch: Patch; } @@ -165,18 +165,18 @@ type UserProfile = { }, } -export interface UserSignedUp extends AbstractEvent { +export interface UserSignedUp extends BaseEvent { type: 'userSignedUp'; userId: string; profile?: UserProfile; } -export interface UserSignedIn extends AbstractEvent { +export interface UserSignedIn extends BaseEvent { type: 'userSignedIn'; userId: string; } -export interface UserSignedOut extends AbstractEvent { +export interface UserSignedOut extends BaseEvent { type: 'userSignedOut'; userId: string; } @@ -190,7 +190,7 @@ export type UserEvent = UserProfileChanged | IdentifiedUserEvent; export type CartEventType = typeof cartEventTypes[number]; -interface BaseCartEvent extends AbstractEvent { +interface BaseCartEvent extends BaseEvent { type: CartEventType; cart: Cart; } @@ -210,12 +210,12 @@ export interface CheckoutStarted extends BaseCartEvent { export type CartEvent = CartModified | CartViewed | CheckoutStarted; -export interface OrderPlaced extends AbstractEvent { +export interface OrderPlaced extends BaseEvent { type: 'orderPlaced'; order: Order; } -export interface ProductViewed extends AbstractEvent { +export interface ProductViewed extends BaseEvent { type: 'productViewed'; product: ProductDetails; } @@ -228,7 +228,7 @@ export type EcommerceEvent = OrderPlaced | ProductViewed | CartEvent; export type TabEventType = typeof tabEventTypes[number]; -interface BaseTabEvent extends AbstractEvent { +interface BaseTabEvent extends BaseEvent { type: TabEventType; tabId: string; } @@ -255,7 +255,7 @@ export type TabEvent = TabVisibilityChanged | TabUrlChanged | TabOpened; export type PageEventType = typeof pageEventTypes[number]; -interface BasePageEvent extends AbstractEvent { +interface BasePageEvent extends BaseEvent { type: PageEventType; url: string; } @@ -277,30 +277,30 @@ export type PageEvent = PageLoaded | PageOpened; * Misc events */ -export interface NothingChanged extends AbstractEvent { +export interface NothingChanged extends BaseEvent { type: 'nothingChanged'; sinceTime: number; } -export interface SessionAttributesChanged extends AbstractEvent { +export interface SessionAttributesChanged extends BaseEvent { type: 'sessionAttributesChanged'; patch: Patch; } -export interface TestGroupAssigned extends AbstractEvent { +export interface TestGroupAssigned extends BaseEvent { type: 'testGroupAssigned'; testId: string; groupId: string; } -export interface GoalCompleted extends AbstractEvent { +export interface GoalCompleted extends BaseEvent { type: 'goalCompleted'; goalId: string; value?: number; currency?: string; } -export interface EventOccurred extends AbstractEvent { +export interface EventOccurred extends BaseEvent { type: 'eventOccurred'; name: string; testId?: string; @@ -344,10 +344,10 @@ type EventMap = { eventOccurred: EventOccurred, } -export type EventType = keyof EventMap; +export type TrackingEventType = keyof EventMap; -export type Event = - T extends EventType ? EventMap[T] : EventMap[EventType]; +export type TrackingEvent = + T extends TrackingEventType ? EventMap[T] : EventMap[TrackingEventType]; /** * Partial Events @@ -356,7 +356,7 @@ export type Event = type CartPartialEvent = DistributiveOmit & Record<'cart', Optional>; -export type PartialEvent = Exclude | CartPartialEvent; +export type PartialTrackingEvent = Exclude | CartPartialEvent; /** * External Events @@ -374,24 +374,24 @@ type ExternalEventMap = { eventOccurred: EventOccurred, }; -export type ExternalEventType = keyof ExternalEventMap; +export type ExternalTrackingEventType = keyof ExternalEventMap; -export type ExternalEvent = - T extends ExternalEventType +export type ExternalTrackingEvent = + T extends ExternalTrackingEventType ? ExternalEventMap[T] - : ExternalEventMap[ExternalEventType] + : ExternalEventMap[ExternalTrackingEventType] -export type ExternalEventPayload = Omit; +export type ExternalTrackingEventPayload = Omit; /* * Type guards */ -export function isIdentifiedUserEvent(event: Event): event is IdentifiedUserEvent { +export function isIdentifiedUserEvent(event: TrackingEvent): event is IdentifiedUserEvent { return identifiedUserEventTypes.includes((event as IdentifiedUserEvent).type); } -export function isCartPartialEvent(event: PartialEvent): event is CartPartialEvent { +export function isCartPartialEvent(event: PartialTrackingEvent): event is CartPartialEvent { return cartEventTypes.includes((event as CartEvent).type); } @@ -399,14 +399,14 @@ export function isCartPartialEvent(event: PartialEvent): event is CartPartialEve * Beacon */ -export type EventContext = { +export type TrackingEventContext = { tabId: string, url: string, metadata?: {[key: string]: string}, }; export type BeaconPayload = - Exclude + Exclude // Renames "userId" to "externalUserId" | DistributiveOmit, 'userId'> & Record<'externalUserId', IdentifiedUserEvent['userId']> @@ -418,6 +418,6 @@ export type BeaconPayload = export type Beacon = { timestamp: number, token?: string, - context: EventContext, + context: TrackingEventContext, payload: BeaconPayload, } diff --git a/test/cache/cookieCache.test.ts b/test/cache/cookieCache.test.ts index 30f1789f..9127d025 100644 --- a/test/cache/cookieCache.test.ts +++ b/test/cache/cookieCache.test.ts @@ -25,7 +25,7 @@ describe('A cookie cache', () => { expect(setCookie).toBeCalledWith('zita', 'foo', options); - cache.put(null); + cache.clear(); expect(unsetCookie).toBeCalledWith('zita'); }); diff --git a/test/cache/fallbackCache.test.ts b/test/cache/fallbackCache.test.ts index e6cd3e48..7e5714b7 100644 --- a/test/cache/fallbackCache.test.ts +++ b/test/cache/fallbackCache.test.ts @@ -8,11 +8,13 @@ describe('An fallback cache', () => { .mockReturnValueOnce(null) .mockReturnValueOnce('foo'), put: jest.fn(), + clear: jest.fn(), }; const secondCache: Cache = { get: jest.fn().mockReturnValue(null), put: jest.fn(), + clear: jest.fn(), }; const cache = new FallbackCache(firstCache, secondCache); @@ -32,11 +34,13 @@ describe('An fallback cache', () => { const firstCache: Cache = { get: jest.fn(), put: jest.fn(), + clear: jest.fn(), }; const secondCache: Cache = { get: jest.fn(), put: jest.fn(), + clear: jest.fn(), }; const cache = new FallbackCache(firstCache, secondCache); @@ -48,4 +52,25 @@ describe('An fallback cache', () => { expect(secondCache.put).toBeCalledTimes(1); expect(secondCache.put).toBeCalledWith('bar'); }); + + test('should clear all underlying caches', async () => { + const firstCache: Cache = { + get: jest.fn(), + put: jest.fn(), + clear: jest.fn(), + }; + + const secondCache: Cache = { + get: jest.fn(), + put: jest.fn(), + clear: jest.fn(), + }; + + const cache = new FallbackCache(firstCache, secondCache); + + cache.clear(); + + expect(firstCache.clear).toBeCalledTimes(1); + expect(secondCache.clear).toBeCalledTimes(1); + }); }); diff --git a/test/cache/inMemoryCache.test.ts b/test/cache/inMemoryCache.test.ts index afec64f3..7c737411 100644 --- a/test/cache/inMemoryCache.test.ts +++ b/test/cache/inMemoryCache.test.ts @@ -10,7 +10,7 @@ describe('An in-memory cache', () => { expect(cache.get()).toBe('foo'); - cache.put(null); + cache.clear() expect(cache.get()).toBeNull(); }); diff --git a/test/cache/localStorageCache.test.ts b/test/cache/localStorageCache.test.ts new file mode 100644 index 00000000..2ad26305 --- /dev/null +++ b/test/cache/localStorageCache.test.ts @@ -0,0 +1,118 @@ +import LocalStorageCache from '../../src/cache/localStorageCache'; + +describe('A storage cache', () => { + afterEach(() => { + localStorage.clear(); + }); + + test('should cache data into the provided storage', async () => { + const cache = new LocalStorageCache(localStorage, 'key'); + + expect(localStorage.getItem('key')).toBeNull(); + expect(cache.get()).toBeNull(); + + cache.put('foo'); + + expect(localStorage.getItem('key')).toBe('foo'); + expect(cache.get()).toBe('foo'); + + cache.clear(); + + expect(localStorage.getItem('key')).toBeNull(); + expect(cache.get()).toBeNull(); + }); + + test('should allow ot subscribe and unsubscribe listeners to get notified about changes to the cache', async () => { + const cache = new LocalStorageCache(localStorage, 'key'); + const listener = jest.fn(); + + cache.addListener(listener); + + // Put twice to ensure the listener will be called only once + cache.put('foo'); + cache.put('foo'); + cache.clear(); + + cache.removeListener(listener); + + cache.put('bar'); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenNthCalledWith(1, 'foo'); + expect(listener).toHaveBeenNthCalledWith(2, null); + }); + + test('should ensure consistency against external changes', async () => { + const cache = new LocalStorageCache(localStorage, 'key'); + + cache.put('foo'); + + expect(cache.get()).toBe('foo'); + + localStorage.setItem('key', 'bar'); + + expect(cache.get()).toBe('foo'); + + cache.put('bar'); + + expect(cache.get()).toBe('bar'); + }); + + test('should provide a mechanism to sync the cache with external changes', async () => { + const cache = new LocalStorageCache(localStorage, 'key'); + + const disable = LocalStorageCache.autoSync(cache); + + const listener = jest.fn(); + cache.addListener(listener); + + cache.put('bar'); + + localStorage.setItem('key', 'foo'); + + window.dispatchEvent( + new StorageEvent('storage', { + bubbles: false, + cancelable: false, + key: 'key', + oldValue: 'bar', + // Should ignore this value and retrieve from the store + newValue: 'foo1', + storageArea: localStorage, + }), + ); + + // Should ignore unrelated changes + window.dispatchEvent( + new StorageEvent('storage', { + bubbles: false, + cancelable: false, + key: 'unrelatedKey', + oldValue: 'foo', + newValue: 'baz', + storageArea: localStorage, + }), + ); + + disable(); + + localStorage.setItem('key', 'qux'); + + window.dispatchEvent( + new StorageEvent('storage', { + bubbles: false, + cancelable: false, + key: 'croct.token', + oldValue: 'foo', + newValue: 'qux', + storageArea: localStorage, + }), + ); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenNthCalledWith(1, 'bar'); + expect(listener).toHaveBeenNthCalledWith(2, 'foo'); + + expect(cache.get()).toBe('foo'); + }); +}); diff --git a/test/cache/storageCache.test.ts b/test/cache/storageCache.test.ts deleted file mode 100644 index c480b52d..00000000 --- a/test/cache/storageCache.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import StorageCache from '../../src/cache/storageCache'; - -describe('A storage cache', () => { - afterEach(() => { - localStorage.clear(); - }); - - test('should cache data into the provided storage', async () => { - const cache = new StorageCache(localStorage, 'key'); - - expect(localStorage.getItem('key')).toBeNull(); - expect(cache.get()).toBeNull(); - - cache.put('foo'); - - expect(localStorage.getItem('key')).toBe('foo'); - expect(cache.get()).toBe('foo'); - - cache.put(null); - - expect(localStorage.getItem('key')).toBeNull(); - expect(cache.get()).toBeNull(); - }); -}); diff --git a/test/channel/beaconSocketChannel.test.ts b/test/channel/beaconSocketChannel.test.ts index 5dcd7411..21023983 100644 --- a/test/channel/beaconSocketChannel.test.ts +++ b/test/channel/beaconSocketChannel.test.ts @@ -2,7 +2,7 @@ import SandboxChannel from '../../src/channel/sandboxChannel'; import BeaconSocketChannel, {DuplexChannelFactory} from '../../src/channel/beaconSocketChannel'; import {DuplexChannel} from '../../src/channel'; import {Envelope} from '../../src/channel/guaranteedChannel'; -import {Beacon, BeaconPayload, EventContext} from '../../src/event'; +import {Beacon, BeaconPayload, TrackingEventContext} from '../../src/trackingEvents'; import FixedCidAssigner from '../../src/cid/fixedCidAssigner'; describe('A beacon socket channel', () => { @@ -10,7 +10,7 @@ describe('A beacon socket channel', () => { jest.restoreAllMocks(); }); - const context: EventContext = { + const context: TrackingEventContext = { tabId: '123', url: 'https://localhost', metadata: { diff --git a/test/container.test.ts b/test/container.test.ts index 01109551..a9a6240a 100644 --- a/test/container.test.ts +++ b/test/container.test.ts @@ -3,7 +3,9 @@ import * as fetchMock from 'fetch-mock'; import {Configuration, Container} from '../src/container'; import NullLogger from '../src/logging/nullLogger'; import {Logger} from '../src/logging'; -import {BeaconPayload} from '../src/event'; +import {BeaconPayload} from '../src/trackingEvents'; +import LocalStorageCache from '../src/cache/localStorageCache' +import Token from '../src/token'; beforeEach(() => { localStorage.clear(); @@ -67,6 +69,61 @@ test('should load the beacon queue only once', () => { expect(container.getBeaconQueue()).toBe(container.getBeaconQueue()); }); +test('should configure the event manager to notify about token changes', () => { + const container = new Container(configuration); + const eventManager = container.getEventManager(); + + const tokenChangedListener = jest.fn(); + + eventManager.addListener('tokenChanged', tokenChangedListener); + + const firstToken = Token.issue(configuration.appId); + + const context = container.getContext(); + + // Set twice to ensure the listener will be called only once + context.setToken(firstToken); + context.setToken(firstToken); + + // Simulate a login + const secondToken = Token.issue(configuration.appId, 'c4r0l'); + + context.setToken(secondToken); + + // Then simulate switching an account from another tab + const thirdToken = Token.issue(configuration.appId, '3r1ck'); + + localStorage.setItem('croct.token', thirdToken.toString()); + + window.dispatchEvent( + new StorageEvent('storage', { + bubbles: false, + cancelable: false, + key: 'croct.token', + oldValue: secondToken.toString(), + newValue: thirdToken.toString(), + storageArea: localStorage, + }), + ); + + expect(tokenChangedListener).toHaveBeenCalledTimes(3); + + expect(tokenChangedListener).toHaveBeenNthCalledWith(1, { + newToken: firstToken, + oldToken: null, + }); + + expect(tokenChangedListener).toHaveBeenNthCalledWith(2, { + newToken: secondToken, + oldToken: firstToken, + }); + + expect(tokenChangedListener).toHaveBeenNthCalledWith(3, { + newToken: thirdToken, + oldToken: secondToken, + }); +}); + test('should flush the beacon queue on initialization', async () => { fetchMock.mock({ method: 'GET', @@ -287,17 +344,39 @@ test('should delegate logging to the provided logger', () => { }); test('should release managed resources once disposed', async () => { + const {autoSync} = LocalStorageCache; + + const removeListener: jest.Mock = jest.fn(); + + jest.spyOn(LocalStorageCache, 'autoSync').mockImplementation((...args) => { + const listenerRemover = autoSync(...args); + + removeListener.mockImplementation(() => listenerRemover()); + + return removeListener; + }); + const container = new Container(configuration); const tracker = container.getTracker(); const evaluator = container.getEvaluator(); const context = container.getContext(); + const tokenProvider = container.getTokenProvider(); const beaconQueue = container.getBeaconQueue(); + const cidAssigner = container.getCidAssigner(); + + expect(LocalStorageCache.autoSync).toHaveBeenCalled(); + + expect(removeListener).not.toHaveBeenCalled(); await expect(container.dispose()).resolves.toBeUndefined(); + expect(removeListener).toHaveBeenCalled(); + expect(tracker).not.toBe(container.getTracker()); expect(evaluator).not.toBe(container.getEvaluator()); expect(context).not.toBe(container.getContext()); + expect(tokenProvider).not.toBe(container.getTokenProvider()); expect(beaconQueue).not.toBe(container.getBeaconQueue()); + expect(cidAssigner).not.toBe(container.getCidAssigner()); }); diff --git a/test/context.test.ts b/test/context.test.ts index eb3be1d7..26020fa0 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -1,25 +1,41 @@ -import Context from '../src/context'; +import Context, {TokenScope} from '../src/context'; import Token from '../src/token'; import TabEventEmulator from './utils/tabEventEmulator'; +import LocalStorageCache from '../src/cache/localStorageCache'; import {DumbStorage} from './utils/dumbStorage'; +import {EventDispatcher} from '../src/eventManager'; +import {SdkEventMap} from '../src/sdkEvents'; describe('A context', () => { const tabEventEmulator: TabEventEmulator = new TabEventEmulator(); - const carolToken = Token.issue('1ec38bc1-8512-4c59-a011-7cc169bf9939', 'c4r0l'); - const erickToken = Token.issue('1ec38bc1-8512-4c59-a011-7cc169bf9939', '3r1ck'); + const appId = '1ec38bc1-8512-4c59-a011-7cc169bf9939'; + const carolToken = Token.issue(appId, 'c4r0l'); + const erickToken = Token.issue(appId, '3r1ck'); beforeEach(() => { tabEventEmulator.registerListeners(); + localStorage.clear(); }); afterEach(() => { tabEventEmulator.reset(); - localStorage.clear(); - sessionStorage.clear(); }); test('should have a tab', () => { - const context = Context.load(new DumbStorage(), new DumbStorage(), 'global'); + const tabStorage = new DumbStorage(); + + const context = Context.load({ + tokenScope: 'global', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(tabStorage, 'tab'), + tabToken: new LocalStorageCache(tabStorage, 'token'), + browserToken: new LocalStorageCache(localStorage, 'token'), + }, + }); + const tab = context.getTab(); expect(tab.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/); @@ -27,14 +43,37 @@ describe('A context', () => { }); test('should share token across all tabs if the token scope is global', () => { - const tabStorage = new DumbStorage(); - const browserStorage = new DumbStorage(); - - const contextA = Context.load(tabStorage, browserStorage, 'global'); + const browserCache = new LocalStorageCache(localStorage, 'token'); + + const aTabStorage = new DumbStorage(); + + const contextA = Context.load({ + tokenScope: 'global', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(aTabStorage, 'tab'), + tabToken: new LocalStorageCache(aTabStorage, 'token'), + browserToken: browserCache, + }, + }); contextA.setToken(carolToken); - const contextB = Context.load(tabStorage, browserStorage, 'global'); + const bTabStorage = new DumbStorage(); + + const contextB = Context.load({ + tokenScope: 'global', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(bTabStorage, 'tab'), + tabToken: new LocalStorageCache(bTabStorage, 'token'), + browserToken: browserCache, + }, + }); expect(contextA.getToken()).toEqual(carolToken); expect(contextB.getToken()).toEqual(carolToken); @@ -46,11 +85,22 @@ describe('A context', () => { }); test('should share token across related tabs if the token scope is contextual', () => { - const tabStorage = new DumbStorage(); - const browserStorage = new DumbStorage(); + const browserCache = new LocalStorageCache(localStorage, 'token'); // Open the tab A - const contextA = Context.load(tabStorage, browserStorage, 'contextual'); + const aTabStorage = new DumbStorage(); + + const contextA = Context.load({ + tokenScope: 'contextual', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(aTabStorage, 'tab'), + tabToken: new LocalStorageCache(aTabStorage, 'token'), + browserToken: browserCache, + }, + }); contextA.setToken(carolToken); @@ -60,7 +110,19 @@ describe('A context', () => { tabEventEmulator.newTab(); // Open tab B from tab A - const contextB = Context.load(tabStorage, browserStorage, 'contextual'); + const bTabStorage = new DumbStorage(); + + const contextB = Context.load({ + tokenScope: 'contextual', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(bTabStorage, 'tab'), + tabToken: new LocalStorageCache(bTabStorage, 'token'), + browserToken: browserCache, + }, + }); // Both tabs should have carol's token expect(contextA.getToken()).toEqual(carolToken); @@ -75,7 +137,19 @@ describe('A context', () => { tabEventEmulator.newTab(); // Open tab C from tab B - const contextC = Context.load(tabStorage, browserStorage, 'contextual'); + const cTabStorage = new DumbStorage(); + + const contextC = Context.load({ + tokenScope: 'contextual', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(cTabStorage, 'tab'), + tabToken: new LocalStorageCache(cTabStorage, 'token'), + browserToken: browserCache, + }, + }); // Both tab B and C should have the erick's token expect(contextA.getToken()).toEqual(carolToken); @@ -88,7 +162,19 @@ describe('A context', () => { tabEventEmulator.newTab(); // Open tab D from tab A - const contextD = Context.load(tabStorage, browserStorage, 'contextual'); + const dTabStorage = new DumbStorage(); + + const contextD = Context.load({ + tokenScope: 'contextual', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(dTabStorage, 'tab'), + tabToken: new LocalStorageCache(dTabStorage, 'token'), + browserToken: browserCache, + }, + }); // Both tab A and D should have the carol's token expect(contextA.getToken()).toEqual(carolToken); @@ -98,14 +184,37 @@ describe('A context', () => { }); test('should not share token across tabs if the token scope is isolated', () => { - const tabStorage = new DumbStorage(); - const browserStorage = new DumbStorage(); - - const contextA = Context.load(tabStorage, browserStorage, 'isolated'); + const browserCache = new LocalStorageCache(localStorage, 'token'); + + const aTabStorage = new DumbStorage(); + + const contextA = Context.load({ + tokenScope: 'isolated', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(aTabStorage, 'tab'), + tabToken: new LocalStorageCache(aTabStorage, 'token'), + browserToken: browserCache, + }, + }); contextA.setToken(carolToken); - const contextB = Context.load(tabStorage, browserStorage, 'isolated'); + const bTabStorage = new DumbStorage(); + + const contextB = Context.load({ + tokenScope: 'isolated', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(bTabStorage, 'tab'), + tabToken: new LocalStorageCache(bTabStorage, 'token'), + browserToken: browserCache, + }, + }); expect(contextA.getToken()).toEqual(carolToken); expect(contextB.getToken()).toBeNull(); @@ -117,7 +226,19 @@ describe('A context', () => { }); test('should allow setting a user token', () => { - const context = Context.load(new DumbStorage(), new DumbStorage(), 'global'); + const tabStorage = new DumbStorage(); + + const context = Context.load({ + tokenScope: 'global', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(tabStorage, 'tab'), + tabToken: new LocalStorageCache(tabStorage, 'token'), + browserToken: new LocalStorageCache(localStorage, 'token'), + }, + }); expect(context.getToken()).toBeNull(); @@ -127,7 +248,19 @@ describe('A context', () => { }); test('should provide the token subject', () => { - const context = Context.load(new DumbStorage(), new DumbStorage(), 'global'); + const tabStorage = new DumbStorage(); + + const context = Context.load({ + tokenScope: 'global', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(tabStorage, 'tab'), + tabToken: new LocalStorageCache(tabStorage, 'token'), + browserToken: new LocalStorageCache(localStorage, 'token'), + }, + }); expect(context.getUser()).toBeNull(); @@ -137,8 +270,33 @@ describe('A context', () => { }); test('should determine whether token is from anonymous user', () => { - const identifiedContext = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const anonymousContext = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); + const identifiedStorage = new DumbStorage(); + + const identifiedContext = Context.load({ + tokenScope: 'isolated', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(identifiedStorage, 'tab'), + tabToken: new LocalStorageCache(identifiedStorage, 'token'), + browserToken: new LocalStorageCache(localStorage, 'token'), + }, + }); + + const anonymousStorage = new DumbStorage(); + + const anonymousContext = Context.load({ + tokenScope: 'isolated', + eventDispatcher: { + dispatch: jest.fn(), + }, + cache: { + tabId: new LocalStorageCache(anonymousStorage, 'tab'), + tabToken: new LocalStorageCache(anonymousStorage, 'token'), + browserToken: new LocalStorageCache(localStorage, 'token'), + }, + }); identifiedContext.setToken(carolToken); anonymousContext.setToken(Token.parse('eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIiwiYXBwSWQiOiI3ZTlkNTlhOS1lNG' @@ -148,4 +306,84 @@ describe('A context', () => { expect(identifiedContext.isAnonymous()).toBeFalsy(); expect(anonymousContext.isAnonymous()).toBeTruthy(); }); + + test.each<[TokenScope]>([ + ['isolated'], + ['contextual'], + ['global'], + ])('should report token changes', (tokenScope: TokenScope) => { + const eventDispatcher: EventDispatcher = {dispatch: jest.fn()}; + + localStorage.setItem('token', erickToken.toString()); + + const context = Context.load({ + tokenScope: tokenScope, + eventDispatcher: eventDispatcher, + cache: { + tabId: new LocalStorageCache(new DumbStorage(), 'tab'), + tabToken: new LocalStorageCache(new DumbStorage(), 'token'), + browserToken: new LocalStorageCache(localStorage, 'token'), + }, + }); + + // Set twice to ensure the event will be fired once + context.setToken(carolToken); + context.setToken(carolToken); + + context.setToken(null); + + expect(eventDispatcher.dispatch).toHaveBeenCalledTimes(2); + + expect(eventDispatcher.dispatch).toHaveBeenNthCalledWith(1, 'tokenChanged', { + oldToken: tokenScope === 'isolated' ? null : erickToken, + newToken: carolToken, + }); + + expect(eventDispatcher.dispatch).toHaveBeenNthCalledWith(2, 'tokenChanged', { + oldToken: carolToken, + newToken: null, + }); + }); + + test('should report external token changes', () => { + const browserCache = new LocalStorageCache(localStorage, 'token'); + const tabStorage = new DumbStorage(); + const eventDispatcher: EventDispatcher = {dispatch: jest.fn()}; + + browserCache.put(erickToken.toString()); + + const context = Context.load({ + tokenScope: 'global', + eventDispatcher: eventDispatcher, + cache: { + tabId: new LocalStorageCache(tabStorage, 'tab'), + tabToken: new LocalStorageCache(tabStorage, 'token'), + browserToken: browserCache, + }, + }); + + context.setToken(carolToken); + + const anonymousToken = Token.issue(appId); + + browserCache.put(anonymousToken.toString()); + browserCache.put(erickToken.toString()); + + expect(eventDispatcher.dispatch).toHaveBeenCalledTimes(3); + + expect(eventDispatcher.dispatch).toHaveBeenNthCalledWith(1, 'tokenChanged', { + oldToken: erickToken, + newToken: carolToken, + }); + + expect(eventDispatcher.dispatch).toHaveBeenNthCalledWith(2, 'tokenChanged', { + oldToken: carolToken, + newToken: anonymousToken, + }); + + expect(eventDispatcher.dispatch).toHaveBeenNthCalledWith(3, 'tokenChanged', { + oldToken: anonymousToken, + newToken: erickToken, + }); + }); }); diff --git a/test/evaluator.test.ts b/test/evaluator.test.ts index d2c547fc..0e7c2b36 100644 --- a/test/evaluator.test.ts +++ b/test/evaluator.test.ts @@ -8,7 +8,7 @@ import Evaluator, { ExpressionError, ExpressionErrorResponse, } from '../src/evaluator'; -import Token, {FixedTokenProvider, TokenProvider} from '../src/token'; +import Token, {FixedTokenProvider} from '../src/token'; import CidAssigner from '../src/cid'; import FixedCidAssigner from '../src/cid/fixedCidAssigner'; @@ -322,31 +322,6 @@ describe('An evaluator', () => { await expect(promise).rejects.toThrow(EvaluationError); await expect(promise).rejects.toEqual(expect.objectContaining({response: response})); }); - - test('should report an unexpected error occurring while retrieving the token', async () => { - const tokenProvider: TokenProvider = { - getToken: jest.fn().mockRejectedValue(new Error('Unexpected token error.')), - }; - - const evaluator = new Evaluator({ - appId: appId, - endpointUrl: endpoint, - tokenProvider: tokenProvider, - cidAssigner: new FixedCidAssigner('123'), - }); - - const response: ErrorResponse = { - title: 'Unexpected token error.', - type: EvaluationErrorType.UNEXPECTED_ERROR, - detail: 'Please try again or contact Croct support if the error persists.', - status: 500, - }; - - const promise = evaluator.evaluate(expression); - - await expect(promise).rejects.toThrow(EvaluationError); - await expect(promise).rejects.toEqual(expect.objectContaining({response: response})); - }); }); describe('An evaluation error', () => { diff --git a/test/eventManager.test.ts b/test/eventManager.test.ts new file mode 100644 index 00000000..d3ed2754 --- /dev/null +++ b/test/eventManager.test.ts @@ -0,0 +1,50 @@ +import {SynchronousEventManager} from '../src/eventManager'; + +type TestEventMap = { + foo: object, + bar: object, +} + +describe('A synchronous event manager', () => { + test('should dispatch events to subscribed listeners', () => { + const eventManager = new SynchronousEventManager(); + + const firstListener = jest.fn(); + const secondListener = jest.fn(); + + eventManager.addListener('foo', firstListener); + eventManager.addListener('foo', secondListener); + + const firstEvent = {}; + eventManager.dispatch('foo', firstEvent); + + eventManager.removeListener('foo', firstListener); + + const secondEvent = {}; + eventManager.dispatch('foo', secondEvent); + + expect(firstListener).toHaveBeenCalledTimes(1); + expect(secondListener).toHaveBeenCalledTimes(2); + + expect(firstListener).toHaveBeenCalledWith(firstEvent); + expect(secondListener).toHaveBeenCalledWith(firstEvent); + expect(secondListener).toHaveBeenCalledWith(secondEvent); + }); + + test('should not fail if no listener is subscribed to a given event', () => { + const eventManager = new SynchronousEventManager(); + + const listener = jest.fn(); + eventManager.addListener('foo', listener); + + eventManager.dispatch('bar', {}); + + expect(listener).not.toHaveBeenCalled(); + }); + + test('should ignore attempts to unsubscribe an non-existing listener', () => { + const eventManager = new SynchronousEventManager(); + + expect(() => eventManager.removeListener('foo', jest.fn())).not.toThrow(); + }); +}); diff --git a/test/facade/sdkFacade.test.ts b/test/facade/sdkFacade.test.ts index 925543d5..f945888a 100644 --- a/test/facade/sdkFacade.test.ts +++ b/test/facade/sdkFacade.test.ts @@ -10,7 +10,9 @@ import Evaluator from '../../src/evaluator'; import Tab from '../../src/tab'; import NullLogger from '../../src/logging/nullLogger'; import {DumbStorage} from '../utils/dumbStorage'; -import {ExternalEvent} from '../../src/event'; +import {ExternalTrackingEvent} from '../../src/trackingEvents'; +import {SynchronousEventManager} from '../../src/eventManager'; +import {SdkEventMap} from '../../src/sdkEvents'; describe('A SDK facade', () => { const appId = '7e9d59a9-e4b3-45d4-b1c7-48287f1e5e8a'; @@ -753,7 +755,7 @@ describe('A SDK facade', () => { return sdk; }); - const event: ExternalEvent = { + const event: ExternalTrackingEvent = { type: 'userSignedUp', userId: '1ed2fd65-a027-4f3a-a35f-c6dd97537392', }; @@ -896,6 +898,83 @@ describe('A SDK facade', () => { expect(getCid).toHaveBeenCalled(); }); + test('should allow to subscribe and unsubscribe to events', () => { + const eventManager = new SynchronousEventManager(); + + jest.spyOn(eventManager, 'addListener'); + jest.spyOn(eventManager, 'removeListener'); + + jest.spyOn(Sdk, 'init') + .mockImplementationOnce(config => { + const sdk = Sdk.init(config); + + jest.spyOn(sdk, 'getEventManager').mockReturnValue(eventManager); + + return sdk; + }); + + const sdkFacade = SdkFacade.init({ + appId: appId, + track: false, + }); + + const listener = jest.fn(); + + sdkFacade.addListener('foo.bar', listener); + sdkFacade.removeListener('foo.bar', listener); + + expect(eventManager.addListener).toHaveBeenCalledWith('foo.bar', listener); + expect(eventManager.removeListener).toHaveBeenCalledWith('foo.bar', listener); + }); + + test('should allow external services to dispatch custom events', () => { + const eventManager = new SynchronousEventManager(); + + jest.spyOn(eventManager, 'dispatch'); + + jest.spyOn(Sdk, 'init') + .mockImplementationOnce(config => { + const sdk = Sdk.init(config); + + jest.spyOn(sdk, 'getEventManager').mockReturnValue(eventManager); + + return sdk; + }); + + const sdkFacade = SdkFacade.init({ + appId: appId, + track: false, + }); + + const event = {}; + + sdkFacade.dispatch('foo.bar', event); + + expect(eventManager.dispatch).toHaveBeenCalledWith('foo.bar', event); + }); + + test.each<[string]>([ + [''], + ['.'], + ['f'], + ['0'], + ['foo'], + ['foo.'], + ['foo.b'], + ['foo.0'], + ['0foo.0'], + ['0foo.0bar'], + ['0.0'], + ])('should only allow dispatching custom events specifying a fully-qualified name', (eventName: string) => { + const sdkFacade = SdkFacade.init({ + appId: appId, + track: false, + }); + + expect(() => sdkFacade.dispatch(eventName, {})) + .toThrow('The event name must be in the form of "namespaced.eventName"'); + }); + test('should close the SDK on close', async () => { const close = jest.fn(() => Promise.resolve()); diff --git a/test/facade/sessionFacade.test.ts b/test/facade/sessionFacade.test.ts index 8c8f5caa..a36aa7ff 100644 --- a/test/facade/sessionFacade.test.ts +++ b/test/facade/sessionFacade.test.ts @@ -1,6 +1,6 @@ import Tracker from '../../src/tracker'; import SessionFacade from '../../src/facade/sessionFacade'; -import {SessionAttributesChanged} from '../../src/event'; +import {SessionAttributesChanged} from '../../src/trackingEvents'; describe('A session facade', () => { let tracker: Tracker; diff --git a/test/facade/sessionPatch.test.ts b/test/facade/sessionPatch.test.ts index d1f47310..bdbd142f 100644 --- a/test/facade/sessionPatch.test.ts +++ b/test/facade/sessionPatch.test.ts @@ -1,5 +1,5 @@ import Tracker from '../../src/tracker'; -import {SessionAttributesChanged} from '../../src/event'; +import {SessionAttributesChanged} from '../../src/trackingEvents'; import SessionPatch from '../../src/facade/sessionPatch'; describe('A session patch', () => { diff --git a/test/facade/trackerFacade.test.ts b/test/facade/trackerFacade.test.ts index 56541816..79e10045 100644 --- a/test/facade/trackerFacade.test.ts +++ b/test/facade/trackerFacade.test.ts @@ -1,6 +1,10 @@ import Tracker from '../../src/tracker'; import TrackerFacade from '../../src/facade/trackerFacade'; -import {ExternalEvent, ExternalEventPayload, ExternalEventType} from '../../src/event'; +import { + ExternalTrackingEvent as ExternalEvent, + ExternalTrackingEventPayload as ExternalEventPayload, + ExternalTrackingEventType as ExternalEventType, +} from '../../src/trackingEvents'; describe('A tracker facade', () => { afterEach(() => { diff --git a/test/facade/userFacade.test.ts b/test/facade/userFacade.test.ts index 10a72edc..313c5f18 100644 --- a/test/facade/userFacade.test.ts +++ b/test/facade/userFacade.test.ts @@ -1,6 +1,6 @@ import Tracker from '../../src/tracker'; import UserFacade from '../../src/facade/userFacade'; -import {UserProfileChanged} from '../../src/event'; +import {UserProfileChanged} from '../../src/trackingEvents'; import Context from '../../src/context'; describe('A user facade', () => { diff --git a/test/facade/userPatch.test.ts b/test/facade/userPatch.test.ts index 958c9080..7ebb5a7f 100644 --- a/test/facade/userPatch.test.ts +++ b/test/facade/userPatch.test.ts @@ -1,5 +1,5 @@ import Tracker from '../../src/tracker'; -import {UserProfileChanged} from '../../src/event'; +import {UserProfileChanged} from '../../src/trackingEvents'; import UserPatch from '../../src/facade/userPatch'; describe('A user patch', () => { diff --git a/test/schemas/ecommerceSchemas.test.ts b/test/schemas/ecommerceSchemas.test.ts index c14f0411..c1466162 100644 --- a/test/schemas/ecommerceSchemas.test.ts +++ b/test/schemas/ecommerceSchemas.test.ts @@ -1,4 +1,4 @@ -import {Cart, CartItem, Order, OrderItem, ProductDetails} from '../../src/event'; +import {Cart, CartItem, Order, OrderItem, ProductDetails} from '../../src/trackingEvents'; import {cart, cartItem, order, orderItem, productDetails} from '../../src/schema/ecommerceSchemas'; import {Optional} from '../../src/utilityTypes'; diff --git a/test/schemas/eventSchemas.test.ts b/test/schemas/eventSchemas.test.ts index cc7ada6e..fcc34e88 100644 --- a/test/schemas/eventSchemas.test.ts +++ b/test/schemas/eventSchemas.test.ts @@ -1,4 +1,4 @@ -import {Cart, CartItem, Order, OrderItem, ProductDetails} from '../../src/event'; +import {Cart, CartItem, Order, OrderItem, ProductDetails} from '../../src/trackingEvents'; import { cartViewed, cartModified, diff --git a/test/sdk.test.ts b/test/sdk.test.ts index ef630a94..573fb212 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -5,7 +5,7 @@ import NullLogger from '../src/logging/nullLogger'; import Token from '../src/token'; import TabEventEmulator from './utils/tabEventEmulator'; import {Logger} from '../src/logging'; -import {BeaconPayload, NothingChanged} from '../src/event'; +import {BeaconPayload, NothingChanged} from '../src/trackingEvents'; import {VERSION} from '../src/constants'; jest.mock('../src/constants', () => ({ @@ -110,7 +110,7 @@ describe('A SDK', () => { tokenScope: 'global', }); - tabEventEmulator.newTab(); + const tabIndex = tabEventEmulator.newTab(); const sdkTabB = Sdk.init({ ...configuration, @@ -119,11 +119,37 @@ describe('A SDK', () => { sdkTabA.context.setToken(token); + tabEventEmulator.dispatchEvent( + window, + new StorageEvent('storage', { + bubbles: false, + cancelable: false, + key: 'croct.token', + oldValue: null, + newValue: token.toString(), + storageArea: localStorage, + }), + tabIndex, + ); + expect(sdkTabA.context.getToken()).toEqual(token); expect(sdkTabB.context.getToken()).toEqual(token); sdkTabB.context.setToken(null); + tabEventEmulator.dispatchEvent( + window, + new StorageEvent('storage', { + bubbles: false, + cancelable: false, + key: 'croct.token', + oldValue: token.toString(), + newValue: null, + storageArea: localStorage, + }), + tabIndex - 1, + ); + expect(sdkTabA.context.getToken()).toEqual(null); expect(sdkTabB.context.getToken()).toEqual(null); }); @@ -359,6 +385,19 @@ describe('A SDK', () => { await expect(sdk.getCid()).resolves.toEqual(configuration.cid); }); + test('should provide an event manager to allow inter-service communication', () => { + const sdk = Sdk.init(configuration); + const eventManager = sdk.getEventManager(); + + const listener = jest.fn(); + eventManager.addListener('somethingHappened', listener); + + const event = {}; + eventManager.dispatch('somethingHappened', {}); + + expect(listener).toHaveBeenCalledWith(event); + }); + test('should provide an isolated session storage', () => { jest.spyOn(Storage.prototype, 'setItem'); diff --git a/test/token/cachedTokenStore.test.ts b/test/token/cachedTokenStore.test.ts new file mode 100644 index 00000000..3f0effaa --- /dev/null +++ b/test/token/cachedTokenStore.test.ts @@ -0,0 +1,48 @@ +import Token from '../../src/token'; +import CachedTokenStore from '../../src/token/cachedTokenStore'; +import InMemoryCache from '../../src/cache/inMemoryCache'; + +describe('A cache token store', () => { + const token = Token.issue('7e9d59a9-e4b3-45d4-b1c7-48287f1e5e8a', 'c4r0l', 1440982923); + + test('should store tokens into cache', () => { + const store = new CachedTokenStore(new InMemoryCache()); + + expect(store.getToken()).toBeNull(); + + store.setToken(token); + + expect(store.getToken()).toEqual(token); + }); + + test('should remove a token set to null from the cache', () => { + const store = new CachedTokenStore(new InMemoryCache()); + + store.setToken(token); + + expect(store.getToken()).toEqual(token); + + store.setToken(null); + + expect(store.getToken()).toBeNull(); + }); + + test('should retrieve the token from the cache', () => { + const store = new CachedTokenStore(new InMemoryCache()); + + expect(store.getToken()).toBeNull(); + + store.setToken(token); + + expect(store.getToken()).toEqual(token); + }); + + test('should consider corrupted tokens as null', () => { + const cache = new InMemoryCache(); + cache.put('bad'); + + const store = new CachedTokenStore(cache); + + expect(store.getToken()).toBeNull(); + }); +}); diff --git a/test/token/inMemoryStorage.test.ts b/test/token/inMemoryStorage.test.ts index 47d851f7..b9a2bd48 100644 --- a/test/token/inMemoryStorage.test.ts +++ b/test/token/inMemoryStorage.test.ts @@ -1,22 +1,22 @@ import Token from '../../src/token'; -import InMemoryStorage from '../../src/token/inMemoryStorage'; +import InMemoryTokenStore from '../../src/token/inMemoryTokenStore'; -describe('An in-memory storage', () => { +describe('An in-memory token store', () => { const token = Token.issue('7e9d59a9-e4b3-45d4-b1c7-48287f1e5e8a', 'c4r0l', 1440982923); test('should store tokens in memory', () => { - const storage = new InMemoryStorage(); + const store = new InMemoryTokenStore(); - storage.setToken(token); + store.setToken(token); - expect(storage.getToken()).toEqual(token); + expect(store.getToken()).toEqual(token); }); test('should retrieve tokens from memory', () => { - const storage = new InMemoryStorage(); + const store = new InMemoryTokenStore(); - storage.setToken(token); + store.setToken(token); - expect(storage.getToken()).toEqual(token); + expect(store.getToken()).toEqual(token); }); }); diff --git a/test/token/index.test.ts b/test/token/index.test.ts index 14e969ce..64727be6 100644 --- a/test/token/index.test.ts +++ b/test/token/index.test.ts @@ -144,12 +144,12 @@ describe('A token', () => { }); describe('A fixed token provider', () => { - test('should always provide the same token', async () => { + test('should always provide the same token', () => { const token = Token.issue('7e9d59a9-e4b3-45d4-b1c7-48287f1e5e8a', 'c4r0l', 1440982923); const provider = new FixedTokenProvider(token); - await expect(provider.getToken()).resolves.toEqual(token); - await expect(provider.getToken()).resolves.toEqual(token); - await expect(provider.getToken()).resolves.toEqual(token); + expect(provider.getToken()).toEqual(token); + expect(provider.getToken()).toEqual(token); + expect(provider.getToken()).toEqual(token); }); }); diff --git a/test/token/persistentStorage.test.ts b/test/token/persistentStorage.test.ts deleted file mode 100644 index 8103a674..00000000 --- a/test/token/persistentStorage.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import Token from '../../src/token'; -import PersistentStorage from '../../src/token/persistentStorage'; -import {DumbStorage} from '../utils/dumbStorage'; - -describe('A persistent storage', () => { - const token = Token.issue('7e9d59a9-e4b3-45d4-b1c7-48287f1e5e8a', 'c4r0l', 1440982923); - - test('should store tokens in the storage', () => { - const storage = new PersistentStorage(new DumbStorage(), 'token'); - - expect(storage.getToken()).toBeNull(); - - storage.setToken(token); - - expect(storage.getToken()).toEqual(token); - }); - - test('should remove a token set to null from the storage', () => { - const storage = new PersistentStorage(new DumbStorage(), 'token'); - - storage.setToken(token); - - expect(storage.getToken()).toEqual(token); - - storage.setToken(null); - - expect(storage.getToken()).toBeNull(); - }); - - test('should retrieve the token from the storage', () => { - const storage = new PersistentStorage(new DumbStorage(), 'token'); - - expect(storage.getToken()).toBeNull(); - - storage.setToken(token); - - expect(storage.getToken()).toEqual(token); - }); - - test('should consider corrupted tokens as null', () => { - const storage = new PersistentStorage(new DumbStorage(true), 'token'); - - expect(storage.getToken()).toBeNull(); - }); -}); diff --git a/test/token/replicatedStorage.test.ts b/test/token/replicatedStorage.test.ts index 5664792b..b8b7e214 100644 --- a/test/token/replicatedStorage.test.ts +++ b/test/token/replicatedStorage.test.ts @@ -1,29 +1,29 @@ import Token from '../../src/token'; -import ReplicatedStorage from '../../src/token/replicatedStorage'; -import InMemoryStorage from '../../src/token/inMemoryStorage'; +import ReplicatedTokenStore from '../../src/token/replicatedTokenStore'; +import InMemoryTokenStore from '../../src/token/inMemoryTokenStore'; -describe('A replicated storage', () => { +describe('A replicated token store', () => { const token = Token.issue('7e9d59a9-e4b3-45d4-b1c7-48287f1e5e8a', 'c4r0l', 1440982923); - test('should set the token in both storages', () => { - const firstStorage = new InMemoryStorage(); - const secondStorage = new InMemoryStorage(); - const storage = new ReplicatedStorage(firstStorage, secondStorage); + test('should set the token in both stores', () => { + const firstStore = new InMemoryTokenStore(); + const secondStore = new InMemoryTokenStore(); + const store = new ReplicatedTokenStore(firstStore, secondStore); - expect(firstStorage.getToken()).toBeNull(); - expect(secondStorage.getToken()).toBeNull(); + expect(firstStore.getToken()).toBeNull(); + expect(secondStore.getToken()).toBeNull(); - storage.setToken(token); + store.setToken(token); - expect(firstStorage.getToken()).toEqual(token); - expect(secondStorage.getToken()).toEqual(token); + expect(firstStore.getToken()).toEqual(token); + expect(secondStore.getToken()).toEqual(token); }); - test('should retrieve the token from the primary storage', () => { - const storage = new ReplicatedStorage(new InMemoryStorage(), new InMemoryStorage()); + test('should retrieve the token from the primary store', () => { + const store = new ReplicatedTokenStore(new InMemoryTokenStore(), new InMemoryTokenStore()); - storage.setToken(token); + store.setToken(token); - expect(storage.getToken()).toEqual(token); + expect(store.getToken()).toEqual(token); }); }); diff --git a/test/tracker.test.ts b/test/tracker.test.ts index ff1bc0c1..2a30ba0f 100644 --- a/test/tracker.test.ts +++ b/test/tracker.test.ts @@ -1,12 +1,13 @@ import Tracker, {EventInfo, EventListener} from '../src/tracker'; -import Context from '../src/context'; import SandboxChannel from '../src/channel/sandboxChannel'; import TabEventEmulator from './utils/tabEventEmulator'; -import {Beacon, BeaconPayload, Event, PartialEvent} from '../src/event'; +import {Beacon, BeaconPayload, TrackingEvent, PartialTrackingEvent} from '../src/trackingEvents'; import {OutputChannel} from '../src/channel'; -import {DumbStorage} from './utils/dumbStorage'; import {Optional} from '../src/utilityTypes'; import Token from '../src/token'; +import InMemoryTokenStore from '../src/token/inMemoryTokenStore'; +import Tab from '../src/tab'; +import {uuid4} from '../src/uuid'; describe('A tracker', () => { const now = Date.now(); @@ -20,6 +21,9 @@ describe('A tracker', () => { const date = jest.spyOn(Date, 'now'); date.mockReturnValue(now); + sessionStorage.clear(); + localStorage.clear(); + tabEventEmulator.registerListeners(); window.document.title = 'Welcome to Foo Inc.'; @@ -36,7 +40,8 @@ describe('A tracker', () => { test('should determine whether it is enabled or disabled', () => { const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab('123', true), channel: new SandboxChannel(), }); @@ -53,7 +58,8 @@ describe('A tracker', () => { test('should not fail if enabled more than once', () => { const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab(uuid4(), true), channel: new SandboxChannel(), }); @@ -68,7 +74,8 @@ describe('A tracker', () => { test('should not fail if disabled more than once', () => { const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab(uuid4(), true), channel: new SandboxChannel(), }); @@ -87,10 +94,12 @@ describe('A tracker', () => { .mockRejectedValueOnce(new Error()) .mockResolvedValueOnce(undefined), }; - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + + const tab = new Tab(uuid4(), true); + const tracker = new Tracker({ - context: context, + tokenProvider: new InMemoryTokenStore(), + tab: tab, channel: channel, }); @@ -98,7 +107,7 @@ describe('A tracker', () => { tracker.addListener(listener); - const event: Event = { + const event: TrackingEvent = { type: 'nothingChanged', sinceTime: 0, }; @@ -162,7 +171,8 @@ describe('A tracker', () => { test('should allow to be enabled even if it is suspended', () => { const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab(uuid4(), true), channel: new SandboxChannel(), }); @@ -179,7 +189,8 @@ describe('A tracker', () => { test('should allow to be disabled even if it is suspended', () => { const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab(uuid4(), true), channel: new SandboxChannel(), }); @@ -197,7 +208,8 @@ describe('A tracker', () => { test('should determine whether it is suspended or not', () => { const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab(uuid4(), true), channel: new SandboxChannel(), }); @@ -214,7 +226,8 @@ describe('A tracker', () => { test('should not fail if suspended more than once', () => { const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab(uuid4(), true), channel: new SandboxChannel(), }); @@ -229,7 +242,8 @@ describe('A tracker', () => { test('should not fail if unsuspended more than once', () => { const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab(uuid4(), true), channel: new SandboxChannel(), }); @@ -247,7 +261,8 @@ describe('A tracker', () => { }; const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab(uuid4(), true), channel: channel, }); @@ -276,11 +291,11 @@ describe('A tracker', () => { tabEventEmulator.newTab(); - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + const tab = new Tab(uuid4(), true); const tracker = new Tracker({ - context: context, + tokenProvider: new InMemoryTokenStore(), + tab: tab, channel: channel, }); @@ -290,7 +305,7 @@ describe('A tracker', () => { tracker.suspend(); - const event: Event = { + const event: TrackingEvent = { type: 'nothingChanged', sinceTime: 0, }; @@ -322,7 +337,8 @@ describe('A tracker', () => { }; const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab(uuid4(), true), channel: channel, }); @@ -330,7 +346,7 @@ describe('A tracker', () => { publish.mockClear(); - const event: Event = { + const event: TrackingEvent = { type: 'nothingChanged', sinceTime: 0, }; @@ -350,7 +366,8 @@ describe('A tracker', () => { }; const tracker = new Tracker({ - context: Context.load(new DumbStorage(), new DumbStorage(), 'isolated'), + tokenProvider: new InMemoryTokenStore(), + tab: new Tab(uuid4(), true), channel: channel, eventMetadata: metadata, }); @@ -377,11 +394,12 @@ describe('A tracker', () => { const token = Token.issue('7e9d59a9-e4b3-45d4-b1c7-48287f1e5e8a', 'c4r0l'); - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - context.setToken(token); + const store = new InMemoryTokenStore(); + store.setToken(token); const tracker = new Tracker({ - context: context, + tokenProvider: store, + tab: new Tab(uuid4(), true), channel: channel, }); @@ -405,11 +423,12 @@ describe('A tracker', () => { publish: publish, }; - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + const tab = new Tab(uuid4(), true); + const store = new InMemoryTokenStore(); let tracker = new Tracker({ - context: context, + tokenProvider: store, + tab: tab, channel: channel, }); @@ -433,7 +452,8 @@ describe('A tracker', () => { publish.mockClear(); tracker = new Tracker({ - context: context, + tokenProvider: store, + tab: tab, channel: channel, }); @@ -450,11 +470,12 @@ describe('A tracker', () => { publish: publish, }; - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + const store = new InMemoryTokenStore(); + const tab = new Tab(uuid4(), true); let tracker = new Tracker({ - context: context, + tokenProvider: store, + tab: tab, channel: channel, }); @@ -478,7 +499,8 @@ describe('A tracker', () => { publish.mockClear(); tracker = new Tracker({ - context: context, + tokenProvider: store, + tab: tab, channel: channel, }); @@ -495,11 +517,11 @@ describe('A tracker', () => { publish: publish, }; - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + const tab = new Tab(uuid4(), true); const tracker = new Tracker({ - context: context, + tokenProvider: new InMemoryTokenStore(), + tab: tab, channel: channel, }); @@ -534,11 +556,11 @@ describe('A tracker', () => { publish: publish, }; - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + const tab = new Tab(uuid4(), true); const tracker = new Tracker({ - context: context, + tokenProvider: new InMemoryTokenStore(), + tab: tab, channel: channel, }); @@ -572,11 +594,11 @@ describe('A tracker', () => { publish: publish, }; - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + const tab = new Tab(uuid4(), true); const tracker = new Tracker({ - context: context, + tokenProvider: new InMemoryTokenStore(), + tab: tab, channel: channel, }); @@ -627,11 +649,11 @@ describe('A tracker', () => { tabEventEmulator.newTab(); - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + const tab = new Tab(uuid4(), true); const tracker = new Tracker({ - context: context, + tokenProvider: new InMemoryTokenStore(), + tab: tab, channel: channel, }); @@ -680,11 +702,11 @@ describe('A tracker', () => { publish: publish, }; - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + const tab = new Tab(uuid4(), true); const tracker = new Tracker({ - context: context, + tokenProvider: new InMemoryTokenStore(), + tab: tab, channel: channel, inactivityInterval: 10, }); @@ -710,7 +732,7 @@ describe('A tracker', () => { expect(publish).toHaveBeenCalledTimes(1); }); - test.each<[PartialEvent, BeaconPayload | undefined]>([ + test.each<[PartialTrackingEvent, BeaconPayload | undefined]>([ [ { type: 'productViewed', @@ -989,17 +1011,17 @@ describe('A tracker', () => { }, undefined, ], - ])('can track event %#', async (partialEvent: PartialEvent, beaconPayload?: BeaconPayload) => { + ])('can track event %#', async (partialEvent: PartialTrackingEvent, beaconPayload?: BeaconPayload) => { const channel: OutputChannel = { close: jest.fn(), publish: jest.fn().mockResolvedValue(undefined), }; - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + const tab = new Tab(uuid4(), true); const tracker = new Tracker({ - context: context, + tokenProvider: new InMemoryTokenStore(), + tab: tab, channel: channel, }); @@ -1027,17 +1049,17 @@ describe('A tracker', () => { publish: publish, }; - const context = Context.load(new DumbStorage(), new DumbStorage(), 'isolated'); - const tab = context.getTab(); + const tab = new Tab(uuid4(), true); const tracker = new Tracker({ - context: context, + tokenProvider: new InMemoryTokenStore(), + tab: tab, channel: channel, }); await expect(tracker.flushed).resolves.toBeUndefined(); - const event: Event = { + const event: TrackingEvent = { type: 'nothingChanged', sinceTime: 0, }; diff --git a/test/utils/tabEventEmulator.ts b/test/utils/tabEventEmulator.ts index 3e5dd4e5..a4da7233 100644 --- a/test/utils/tabEventEmulator.ts +++ b/test/utils/tabEventEmulator.ts @@ -74,8 +74,8 @@ export default class TabEventEmulator { this.dispatchEvent(window, new Event('DOMContentLoaded', {bubbles: true, cancelable: true})); } - public dispatchEvent(eventTarget: EventTarget, event: Event): void { - for (const {target, type, listener} of this.listeners[this.tabIndex]) { + public dispatchEvent(eventTarget: EventTarget, event: Event, tabIndex: number = this.tabIndex): void { + for (const {target, type, listener} of this.listeners[tabIndex]) { if (target === eventTarget && type === event.type) { listener.apply(target, [event]); }