diff --git a/.gitignore b/.gitignore index 2a10b15b..5757ec87 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,5 @@ dist/ # For CI to ignore .npmrc file when publishing .npmrc -# Example Experiment tag script example +# Example Experiment tag script packages/experiment-tag/example/ diff --git a/packages/analytics-connector/package.json b/packages/analytics-connector/package.json index 7e89ae55..341d778d 100644 --- a/packages/analytics-connector/package.json +++ b/packages/analytics-connector/package.json @@ -27,6 +27,9 @@ "bugs": { "url": "https://github.com/amplitude/experiment-js-client/issues" }, + "dependencies": { + "@amplitude/experiment-core": "^0.8.0" + }, "devDependencies": { "@types/amplitude-js": "^8.0.2", "amplitude-js": "^8.12.0" diff --git a/packages/analytics-connector/src/eventBridge.ts b/packages/analytics-connector/src/eventBridge.ts index e89ebffa..6215622e 100644 --- a/packages/analytics-connector/src/eventBridge.ts +++ b/packages/analytics-connector/src/eventBridge.ts @@ -1,3 +1,8 @@ +import { + getGlobalScope, + isLocalStorageAvailable, +} from '@amplitude/experiment-core'; + export type AnalyticsEvent = { eventType: string; eventProperties?: Record; @@ -8,17 +13,47 @@ export type AnalyticsEventReceiver = (event: AnalyticsEvent) => void; export interface EventBridge { logEvent(event: AnalyticsEvent): void; + setEventReceiver(listener: AnalyticsEventReceiver): void; + + setInstanceName(instanceName: string): void; } export class EventBridgeImpl implements EventBridge { + private instanceName = ''; private receiver: AnalyticsEventReceiver; - private queue: AnalyticsEvent[] = []; + private inMemoryQueue: AnalyticsEvent[] = []; + private globalScope = getGlobalScope(); + + private getStorageKey(): string { + return `EXP_unsent_${this.instanceName}`; + } + + private getQueue(): AnalyticsEvent[] { + if (isLocalStorageAvailable()) { + const storageKey = this.getStorageKey(); + const storedQueue = this.globalScope.localStorage.getItem(storageKey); + this.inMemoryQueue = storedQueue ? JSON.parse(storedQueue) : []; + } + return this.inMemoryQueue; + } + + private setQueue(queue: AnalyticsEvent[]): void { + this.inMemoryQueue = queue; + if (isLocalStorageAvailable()) { + this.globalScope.localStorage.setItem( + this.getStorageKey(), + JSON.stringify(queue), + ); + } + } logEvent(event: AnalyticsEvent): void { if (!this.receiver) { - if (this.queue.length < 512) { - this.queue.push(event); + const queue = this.getQueue(); + if (queue.length < 512) { + queue.push(event); + this.setQueue(queue); } } else { this.receiver(event); @@ -27,11 +62,16 @@ export class EventBridgeImpl implements EventBridge { setEventReceiver(receiver: AnalyticsEventReceiver): void { this.receiver = receiver; - if (this.queue.length > 0) { - this.queue.forEach((event) => { + const queue = this.getQueue(); + if (queue.length > 0) { + queue.forEach((event) => { receiver(event); }); - this.queue = []; + this.setQueue([]); } } + + public setInstanceName(instanceName: string): void { + this.instanceName = instanceName; + } } diff --git a/packages/experiment-browser/src/factory.ts b/packages/experiment-browser/src/factory.ts index 71454d8f..6580dafd 100644 --- a/packages/experiment-browser/src/factory.ts +++ b/packages/experiment-browser/src/factory.ts @@ -61,6 +61,7 @@ const initializeWithAmplitudeAnalytics = ( const instanceKey = `${instanceName}.${apiKey}`; const connector = AnalyticsConnector.getInstance(instanceName); if (!instances[instanceKey]) { + connector.eventBridge.setInstanceName(instanceName); config = { userProvider: new DefaultUserProvider( connector.applicationContextProvider, diff --git a/packages/experiment-browser/src/util/convert.ts b/packages/experiment-browser/src/util/convert.ts index 8c96207f..b3b5c5cf 100644 --- a/packages/experiment-browser/src/util/convert.ts +++ b/packages/experiment-browser/src/util/convert.ts @@ -1,4 +1,4 @@ -import { EvaluationVariant } from '@amplitude/experiment-core'; +import { EvaluationVariant, getGlobalScope } from '@amplitude/experiment-core'; import { ExperimentUser } from '../types/user'; import { Variant } from '../types/variant'; @@ -10,6 +10,13 @@ export const convertUserToContext = ( return {}; } const context: Record = { user: user }; + // add page context + const globalScope = getGlobalScope(); + if (globalScope) { + context.page = { + url: globalScope.location.href, + }; + } const groups: Record> = {}; if (!user.groups) { return context; diff --git a/packages/experiment-browser/test/convert.test.ts b/packages/experiment-browser/test/convert.test.ts index 1b1e020d..a73b10f8 100644 --- a/packages/experiment-browser/test/convert.test.ts +++ b/packages/experiment-browser/test/convert.test.ts @@ -1,7 +1,13 @@ +import * as util from '@amplitude/experiment-core'; + import { ExperimentUser } from '../src/types/user'; import { convertUserToContext } from '../src/util/convert'; describe('convertUserToContext', () => { + beforeEach(() => { + jest.spyOn(util, 'getGlobalScope').mockReturnValue(undefined); + }); + describe('groups', () => { test('undefined user', () => { const user: ExperimentUser | undefined = undefined; diff --git a/packages/experiment-core/src/index.ts b/packages/experiment-core/src/index.ts index aeb520d1..09bd0b37 100644 --- a/packages/experiment-core/src/index.ts +++ b/packages/experiment-core/src/index.ts @@ -18,5 +18,9 @@ export { export { FlagApi, SdkFlagApi, GetFlagsOptions } from './api/flag-api'; export { HttpClient, HttpRequest, HttpResponse } from './transport/http'; export { Poller } from './util/poller'; -export { safeGlobal } from './util/global'; +export { + safeGlobal, + getGlobalScope, + isLocalStorageAvailable, +} from './util/global'; export { FetchError } from './evaluation/error'; diff --git a/packages/experiment-core/src/util/global.ts b/packages/experiment-core/src/util/global.ts index e399c678..67a05211 100644 --- a/packages/experiment-core/src/util/global.ts +++ b/packages/experiment-core/src/util/global.ts @@ -1,2 +1,33 @@ export const safeGlobal = typeof globalThis !== 'undefined' ? globalThis : global || self; + +export const getGlobalScope = (): typeof globalThis | undefined => { + if (typeof globalThis !== 'undefined') { + return globalThis; + } + if (typeof window !== 'undefined') { + return window; + } + if (typeof self !== 'undefined') { + return self; + } + if (typeof global !== 'undefined') { + return global; + } + return undefined; +}; + +export const isLocalStorageAvailable = (): boolean => { + const globalScope = getGlobalScope(); + if (globalScope) { + try { + const testKey = 'EXP_test'; + globalScope.localStorage.setItem(testKey, testKey); + globalScope.localStorage.removeItem(testKey); + return true; + } catch (e) { + return false; + } + } + return false; +}; diff --git a/packages/experiment-tag/package.json b/packages/experiment-tag/package.json index 7ed7e06c..e6194551 100644 --- a/packages/experiment-tag/package.json +++ b/packages/experiment-tag/package.json @@ -26,6 +26,7 @@ "url": "https://github.com/amplitude/experiment-js-client/issues" }, "dependencies": { + "@amplitude/experiment-core": "^0.8.0", "@amplitude/experiment-js-client": "^1.11.0", "dom-mutator": "^0.6.0" }, diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index f7ba81e7..329a54d2 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -1,23 +1,27 @@ -import { EvaluationFlag } from '@amplitude/experiment-core'; +import { + EvaluationFlag, + EvaluationSegment, + getGlobalScope, + isLocalStorageAvailable, +} from '@amplitude/experiment-core'; import { Experiment, ExperimentUser, Variant, + Variants, } from '@amplitude/experiment-js-client'; import mutate, { MutationController } from 'dom-mutator'; import { getInjectUtils } from './inject-utils'; import { WindowMessenger } from './messenger'; import { - getGlobalScope, getUrlParams, - isLocalStorageAvailable, - matchesUrl, removeQueryParams, urlWithoutParamsAndAnchor, UUID, concatenateQueryParamsOf, } from './util'; + const appliedInjections: Set = new Set(); const appliedMutations: MutationController[] = []; let previousUrl: string | undefined = undefined; @@ -27,104 +31,112 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => { const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`; const globalScope = getGlobalScope(); - if (isLocalStorageAvailable() && globalScope) { - let user: ExperimentUser; - try { - user = JSON.parse( - globalScope.localStorage.getItem(experimentStorageName) || '{}', - ); - } catch (error) { - user = {}; - } + if (!isLocalStorageAvailable() || !globalScope) { + return; + } + let user: ExperimentUser; + try { + user = JSON.parse( + globalScope.localStorage.getItem(experimentStorageName) || '{}', + ); + } catch (error) { + user = {}; + } - // create new user if it does not exist, or it does not have device_id - if (Object.keys(user).length === 0 || !user.device_id) { - user = {}; - user.device_id = UUID(); - globalScope.localStorage.setItem( - experimentStorageName, - JSON.stringify(user), - ); - } + // create new user if it does not exist, or it does not have device_id + if (Object.keys(user).length === 0 || !user.device_id) { + user = {}; + user.device_id = UUID(); + globalScope.localStorage.setItem( + experimentStorageName, + JSON.stringify(user), + ); + } - const urlParams = getUrlParams(); - // if in visual edit mode, remove the query param - if (urlParams['VISUAL_EDITOR']) { - globalScope.history.replaceState( - {}, - '', - removeQueryParams(globalScope.location.href, ['VISUAL_EDITOR']), - ); - return; - } - // force variant if in preview mode - if (urlParams['PREVIEW']) { - const parsedFlags = JSON.parse(initialFlags); - parsedFlags.forEach((flag: EvaluationFlag) => { - // if in preview mode, strip query params + const urlParams = getUrlParams(); + // if in visual edit mode, remove the query param + if (urlParams['VISUAL_EDITOR']) { + globalScope.history.replaceState( + {}, + '', + removeQueryParams(globalScope.location.href, ['VISUAL_EDITOR']), + ); + return; + } + // force variant if in preview mode + if (urlParams['PREVIEW']) { + const parsedFlags = JSON.parse(initialFlags); + parsedFlags.forEach((flag: EvaluationFlag) => { + if (flag.key in urlParams && urlParams[flag.key] in flag.variants) { + // Strip the preview query param globalScope.history.replaceState( {}, '', removeQueryParams(globalScope.location.href, ['PREVIEW', flag.key]), ); - if (flag.key in urlParams && urlParams[flag.key] in flag.variants) { - flag.segments = [ - { - metadata: { segmentName: 'preview' }, - variant: urlParams[flag.key], - }, - ]; - } - }); - initialFlags = JSON.stringify(parsedFlags); - } + // Keep page targeting segments + const pageTargetingSegments = flag.segments.filter((segment) => + isPageTargetingSegment(segment), + ); - globalScope.experiment = Experiment.initializeWithAmplitudeAnalytics( - apiKey, - { - debug: true, - fetchOnStart: false, - initialFlags: initialFlags, - }, - ); + // Create or update the preview segment + const previewSegment = { + metadata: { segmentName: 'preview' }, + variant: urlParams[flag.key], + }; + + flag.segments = [...pageTargetingSegments, previewSegment]; + } + }); + initialFlags = JSON.stringify(parsedFlags); + } - globalScope.experiment.setUser(user); + globalScope.experiment = Experiment.initializeWithAmplitudeAnalytics(apiKey, { + debug: true, + fetchOnStart: false, + initialFlags: initialFlags, + }); - const variants = globalScope.experiment.all(); + globalScope.experiment.setUser(user); - setUrlChangeListener(); - applyVariants(variants); - } + const variants = globalScope.experiment.all(); + + setUrlChangeListener(); + applyVariants(variants); }; -const applyVariants = (variants) => { +const applyVariants = (variants: Variants | undefined) => { + if (!variants) { + return; + } const globalScope = getGlobalScope(); - if (globalScope) { - for (const key in variants) { - const variant = variants[key]; - const isWebExperimentation = variant.metadata?.deliveryMethod === 'web'; - if (isWebExperimentation) { - const urlExactMatch = variant.metadata?.['urlMatch'] as string[]; - const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); - // if payload is falsy or empty array, consider it as control variant - const payloadIsArray = Array.isArray(variant.payload); - const isControlPayload = - !variant.payload || (payloadIsArray && variant.payload.length === 0); - if (matchesUrl(urlExactMatch, currentUrl) && isControlPayload) { - globalScope.experiment.exposure(key); - continue; - } + if (!globalScope) { + return; + } + for (const key in variants) { + const variant = variants[key]; + const isWebExperimentation = variant.metadata?.deliveryMethod === 'web'; + if (isWebExperimentation) { + const shouldTrackExposure = + (variant.metadata?.['trackExposure'] as boolean) ?? true; + // if payload is falsy or empty array, consider it as control variant + const payloadIsArray = Array.isArray(variant.payload); + const isControlPayload = + !variant.payload || (payloadIsArray && variant.payload.length === 0); + if (shouldTrackExposure && isControlPayload) { + globalScope.experiment.exposure(key); + continue; + } - if (payloadIsArray) { - for (const action of variant.payload) { - if (action.action === 'redirect') { - handleRedirect(action, key, variant); - } else if (action.action === 'mutate') { - handleMutate(action, key, variant); - } else if (action.action === 'inject') { - handleInject(action, key, variant); - } + if (payloadIsArray) { + for (const action of variant.payload) { + if (action.action === 'redirect') { + handleRedirect(action, key, variant); + } else if (action.action === 'mutate') { + handleMutate(action, key, variant); + } else if (action.action === 'inject') { + handleInject(action, key, variant); } } } @@ -134,52 +146,43 @@ const applyVariants = (variants) => { const handleRedirect = (action, key: string, variant: Variant) => { const globalScope = getGlobalScope(); - if (globalScope) { - const urlExactMatch = variant?.metadata?.['urlMatch'] as string[]; - const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); - const referrerUrl = urlWithoutParamsAndAnchor( - previousUrl || globalScope.document.referrer, - ); - const redirectUrl = action?.data?.url; - if (matchesUrl(urlExactMatch, currentUrl)) { - if ( - !matchesUrl([redirectUrl], currentUrl) && - currentUrl !== referrerUrl - ) { - const targetUrl = concatenateQueryParamsOf( - globalScope.location.href, - redirectUrl, - ); - // perform redirection - globalScope.location.replace(targetUrl); - } else { - // if redirection is not required - globalScope.experiment.exposure(key); - } - } else if ( - // if at the redirected page - matchesUrl(urlExactMatch, referrerUrl) && - matchesUrl([urlWithoutParamsAndAnchor(redirectUrl)], currentUrl) - ) { - globalScope.experiment.exposure(key); - } + if (!globalScope) { + return; + } + const referrerUrl = urlWithoutParamsAndAnchor( + previousUrl || globalScope.document.referrer, + ); + const redirectUrl = action?.data?.url; + + const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); + const shouldTrackExposure = + (variant.metadata?.['trackExposure'] as boolean) ?? true; + + // prevent infinite redirection loop + if (currentUrl === referrerUrl) { + return; } + const targetUrl = concatenateQueryParamsOf( + globalScope.location.href, + redirectUrl, + ); + shouldTrackExposure && globalScope.experiment.exposure(key); + // perform redirection + globalScope.location.replace(targetUrl); }; const handleMutate = (action, key: string, variant: Variant) => { const globalScope = getGlobalScope(); - if (globalScope) { - const urlExactMatch = variant?.metadata?.['urlMatch'] as string[]; - const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); - - if (matchesUrl(urlExactMatch, currentUrl)) { - const mutations = action.data?.mutations; - mutations.forEach((m) => { - appliedMutations.push(mutate.declarative(m)); - }); - globalScope.experiment.exposure(key); - } + if (!globalScope) { + return; } + const mutations = action.data?.mutations; + mutations.forEach((m) => { + appliedMutations.push(mutate.declarative(m)); + }); + const shouldTrackExposure = + (variant.metadata?.['trackExposure'] as boolean) ?? true; + shouldTrackExposure && globalScope.experiment.exposure(key); }; const revertMutations = () => { @@ -199,95 +202,102 @@ const handleInject = (action, key: string, variant: Variant) => { if (!globalScope) { return; } - const urlExactMatch = variant?.metadata?.['urlMatch'] as string[]; - const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); - if (matchesUrl(urlExactMatch, currentUrl)) { - // Check for repeat invocations - const id = action.data.id; - if (appliedInjections.has(id)) { - return; - } - // Create CSS - const rawCss = action.data.css; - let style: HTMLStyleElement | undefined; - if (rawCss) { - style = globalScope.document.createElement('style'); - style.innerHTML = rawCss; - style.id = `css-${id}`; - globalScope.document.head.appendChild(style); - } - // Create HTML - const rawHtml = action.data.html; - let html: Element | undefined; - if (rawHtml) { - html = - new DOMParser().parseFromString(rawHtml, 'text/html').body - .firstElementChild ?? undefined; - } - // Inject - const utils = getInjectUtils(); - const js = action.data.js; - appliedInjections.add(id); - try { - inject(js, html, utils, id); - } catch (e) { - console.error( - `Experiment inject failed for ${key} variant ${variant.key}. Reason:`, - e, - ); - } - // Push mutation to remove CSS and any custom state cleanup set in utils. - appliedMutations.push({ - revert: () => { - if (utils.remove) utils.remove(); - style?.remove(); - appliedInjections.delete(id); - }, - }); - globalScope.experiment.exposure(key); + // Check for repeat invocations + const id = action.data.id; + if (appliedInjections.has(id)) { + return; + } + // Create CSS + const rawCss = action.data.css; + let style: HTMLStyleElement | undefined; + if (rawCss) { + style = globalScope.document.createElement('style'); + style.innerHTML = rawCss; + style.id = `css-${id}`; + globalScope.document.head.appendChild(style); + } + // Create HTML + const rawHtml = action.data.html; + let html: Element | undefined; + if (rawHtml) { + html = + new DOMParser().parseFromString(rawHtml, 'text/html').body + .firstElementChild ?? undefined; + } + // Inject + const utils = getInjectUtils(); + const js = action.data.js; + appliedInjections.add(id); + try { + inject(js, html, utils, id); + } catch (e) { + console.error( + `Experiment inject failed for ${key} variant ${variant.key}. Reason:`, + e, + ); } + // Push mutation to remove CSS and any custom state cleanup set in utils. + appliedMutations.push({ + revert: () => { + if (utils.remove) utils.remove(); + style?.remove(); + appliedInjections.delete(id); + }, + }); + const shouldTrackExposure = + (variant.metadata?.['trackExposure'] as boolean) ?? true; + shouldTrackExposure && globalScope.experiment.exposure(key); }; export const setUrlChangeListener = () => { const globalScope = getGlobalScope(); - if (globalScope) { - // Add URL change listener for back/forward navigation - globalScope.addEventListener('popstate', () => { - revertMutations(); - applyVariants(globalScope.experiment.all()); - }); + if (!globalScope) { + return; + } + // Add URL change listener for back/forward navigation + globalScope.addEventListener('popstate', () => { + revertMutations(); + applyVariants(globalScope.experiment.all()); + }); - // Create wrapper functions for pushState and replaceState - const wrapHistoryMethods = () => { - const originalPushState = history.pushState; - const originalReplaceState = history.replaceState; + // Create wrapper functions for pushState and replaceState + const wrapHistoryMethods = () => { + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; - // Wrapper for pushState - history.pushState = function (...args) { - previousUrl = globalScope.location.href; - // Call the original pushState - const result = originalPushState.apply(this, args); - // Revert mutations and apply variants after pushing state - revertMutations(); - applyVariants(globalScope.experiment.all()); + // Wrapper for pushState + history.pushState = function (...args) { + previousUrl = globalScope.location.href; + // Call the original pushState + const result = originalPushState.apply(this, args); + // Revert mutations and apply variants after pushing state + revertMutations(); + applyVariants(globalScope.experiment.all()); - return result; - }; + return result; + }; - // Wrapper for replaceState - history.replaceState = function (...args) { - previousUrl = globalScope.location.href; - // Call the original replaceState - const result = originalReplaceState.apply(this, args); - // Revert mutations and apply variants after replacing state - revertMutations(); - applyVariants(globalScope.experiment.all()); + // Wrapper for replaceState + history.replaceState = function (...args) { + previousUrl = globalScope.location.href; + // Call the original replaceState + const result = originalReplaceState.apply(this, args); + // Revert mutations and apply variants after replacing state + revertMutations(); + applyVariants(globalScope.experiment.all()); - return result; - }; + return result; }; + }; - // Initialize the wrapper - wrapHistoryMethods(); - } + // Initialize the wrapper + wrapHistoryMethods(); +}; + +const isPageTargetingSegment = (segment: EvaluationSegment) => { + return ( + segment.metadata?.trackExposure === false && + (segment.metadata?.segmentName === 'Page not targeted' || + segment.metadata?.segmentName === 'Page is excluded') + ); }; diff --git a/packages/experiment-tag/src/messenger.ts b/packages/experiment-tag/src/messenger.ts index bd0f6424..80b0fa93 100644 --- a/packages/experiment-tag/src/messenger.ts +++ b/packages/experiment-tag/src/messenger.ts @@ -1,4 +1,4 @@ -import { getGlobalScope } from './util'; +import { getGlobalScope } from '@amplitude/experiment-core'; export class WindowMessenger { static setup() { diff --git a/packages/experiment-tag/src/util.ts b/packages/experiment-tag/src/util.ts index 4d7f032c..04fe5d91 100644 --- a/packages/experiment-tag/src/util.ts +++ b/packages/experiment-tag/src/util.ts @@ -1,20 +1,5 @@ -export const getGlobalScope = (): typeof globalThis | undefined => { - if (typeof globalThis !== 'undefined') { - return globalThis; - } - if (typeof window !== 'undefined') { - return window; - } - if (typeof self !== 'undefined') { - return self; - } - if (typeof global !== 'undefined') { - return global; - } - return undefined; -}; +import { getGlobalScope } from '@amplitude/experiment-core'; -// Get URL parameters export const getUrlParams = (): Record => { const globalScope = getGlobalScope(); const searchParams = new URLSearchParams(globalScope?.location.search); @@ -86,21 +71,6 @@ export const matchesUrl = (urlArray: string[], urlString: string): boolean => { }); }; -export const isLocalStorageAvailable = (): boolean => { - const globalScope = getGlobalScope(); - if (globalScope) { - try { - const testKey = 'EXP_test'; - globalScope.localStorage.setItem(testKey, testKey); - globalScope.localStorage.removeItem(testKey); - return true; - } catch (e) { - return false; - } - } - return false; -}; - export const concatenateQueryParamsOf = ( currentUrl: string, redirectUrl: string, diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 26d010b0..be210248 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1,3 +1,4 @@ +import * as coreUtil from '@amplitude/experiment-core'; import { ExperimentClient } from '@amplitude/experiment-js-client'; import { initializeExperiment } from 'src/experiment'; import * as experiment from 'src/experiment'; @@ -14,7 +15,7 @@ jest.mock('src/messenger', () => { jest.spyOn(experiment, 'setUrlChangeListener').mockReturnValue(undefined); describe('initializeExperiment', () => { - const mockGetGlobalScope = jest.spyOn(util, 'getGlobalScope'); + const mockGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); jest.spyOn(ExperimentClient.prototype, 'setUser'); jest.spyOn(ExperimentClient.prototype, 'all'); const mockExposure = jest.spyOn(ExperimentClient.prototype, 'exposure'); @@ -22,7 +23,7 @@ describe('initializeExperiment', () => { let mockGlobal; beforeEach(() => { - jest.spyOn(util, 'isLocalStorageAvailable').mockReturnValue(true); + jest.spyOn(coreUtil, 'isLocalStorageAvailable').mockReturnValue(true); jest.clearAllMocks(); mockGlobal = { localStorage: { @@ -44,7 +45,7 @@ describe('initializeExperiment', () => { test('should initialize experiment with empty user', () => { initializeExperiment( - 'apiKey_1', + '1', JSON.stringify([ { key: 'test', @@ -104,20 +105,20 @@ describe('initializeExperiment', () => { device_id: 'mock', }); expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( - 'EXP_apiKey_1', + 'EXP_1', JSON.stringify({ device_id: 'mock' }), ); }); test('experiment should not run without localStorage', () => { - jest.spyOn(util, 'isLocalStorageAvailable').mockReturnValue(false); - initializeExperiment('no_local', ''); + jest.spyOn(coreUtil, 'isLocalStorageAvailable').mockReturnValue(false); + initializeExperiment('2', ''); expect(mockGlobal.localStorage.getItem).toHaveBeenCalledTimes(0); }); - test('should redirect and not call exposure', () => { + test('should redirect and call exposure', () => { initializeExperiment( - 'apiKey_2', + '3', JSON.stringify([ { key: 'test', @@ -177,12 +178,12 @@ describe('initializeExperiment', () => { expect(mockGlobal.location.replace).toHaveBeenCalledWith( 'http://test.com/2', ); - expect(mockExposure).toHaveBeenCalledTimes(0); + expect(mockExposure).toHaveBeenCalledWith('test'); }); test('should not redirect but call exposure', () => { initializeExperiment( - 'apiKey_3', + '4', JSON.stringify([ { key: 'test', @@ -206,22 +207,9 @@ describe('initializeExperiment', () => { variants: { control: { key: 'control', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com', - }, - }, - ], + payload: [], value: 'control', }, - off: { - key: 'off', - metadata: { - default: true, - }, - }, treatment: { key: 'treatment', payload: [ @@ -244,88 +232,27 @@ describe('initializeExperiment', () => { expect(mockGlobal.history.replaceState).toBeCalledTimes(0); }); - test('should not redirect or exposure', () => { - initializeExperiment( - 'apiKey_4', - JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - experimentKey: 'exp-1', - flagType: 'experiment', - flagVersion: 20, - urlMatch: ['http://should.not.match'], - deliveryMethod: 'web', - }, - segments: [ - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'control', - }, - ], - variants: { - control: { - key: 'control', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com', - }, - }, - ], - value: 'control', - }, - off: { - key: 'off', - metadata: { - default: true, - }, - }, - treatment: { - key: 'treatment', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com/2', - }, - }, - ], - value: 'treatment', - }, - }, - }, - ]), - ); - - expect(mockGlobal.location.replace).toBeCalledTimes(0); - expect(mockExposure).toHaveBeenCalledTimes(0); - }); - - test('exposure fired when on redirected page', () => { + test('preview - force control variant', () => { const mockGlobal = { localStorage: { getItem: jest.fn().mockReturnValue(undefined), setItem: jest.fn(), }, location: { - href: 'http://test.com/2', + href: 'http://test.com', replace: jest.fn(), - search: '', + search: '?test=control&PREVIEW=true', }, - document: { referrer: 'http://test.com' }, + + document: { referrer: '' }, + history: { replaceState: jest.fn() }, }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mockGetGlobalScope.mockReturnValue(mockGlobal); initializeExperiment( - 'apiKey_5', + '5', JSON.stringify([ { key: 'test', @@ -335,7 +262,6 @@ describe('initializeExperiment', () => { experimentKey: 'exp-1', flagType: 'experiment', flagVersion: 20, - urlMatch: ['http://test.com'], deliveryMethod: 'web', }, segments: [ @@ -349,14 +275,7 @@ describe('initializeExperiment', () => { variants: { control: { key: 'control', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com', - }, - }, - ], + payload: [], value: 'control', }, off: { @@ -383,21 +302,25 @@ describe('initializeExperiment', () => { ); expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); + expect(mockGlobal.history.replaceState).toHaveBeenCalledWith( + {}, + '', + 'http://test.com/', + ); expect(mockExposure).toHaveBeenCalledWith('test'); }); - test('preview - force control variant', () => { + test('preview - force treatment variant when on control page', () => { const mockGlobal = { localStorage: { getItem: jest.fn().mockReturnValue(undefined), setItem: jest.fn(), }, location: { - href: 'http://test.com', + href: 'http://test.com/', replace: jest.fn(), - search: '?test=control&PREVIEW=true', + search: '?test=treatment&PREVIEW=true', }, - document: { referrer: '' }, history: { replaceState: jest.fn() }, }; @@ -406,7 +329,7 @@ describe('initializeExperiment', () => { mockGetGlobalScope.mockReturnValue(mockGlobal); initializeExperiment( - 'prev_control', + '6', JSON.stringify([ { key: 'test', @@ -424,7 +347,7 @@ describe('initializeExperiment', () => { metadata: { segmentName: 'All Other Users', }, - variant: 'treatment', + variant: 'control', }, ], variants: { @@ -463,23 +386,20 @@ describe('initializeExperiment', () => { ]), ); - expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); - expect(mockGlobal.history.replaceState).toHaveBeenCalledWith( - {}, - '', - 'http://test.com/', + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', ); expect(mockExposure).toHaveBeenCalledWith('test'); }); - test('preview - force treatment variant when on control page', () => { + test('preview - force treatment variant when on treatment page', () => { const mockGlobal = { localStorage: { getItem: jest.fn().mockReturnValue(undefined), setItem: jest.fn(), }, location: { - href: 'http://test.com/', + href: 'http://test.com/2', replace: jest.fn(), search: '?test=treatment&PREVIEW=true', }, @@ -491,7 +411,7 @@ describe('initializeExperiment', () => { mockGetGlobalScope.mockReturnValue(mockGlobal); initializeExperiment( - 'prev_treatment', + '7', JSON.stringify([ { key: 'test', @@ -500,29 +420,37 @@ describe('initializeExperiment', () => { evaluationMode: 'local', experimentKey: 'exp-1', flagType: 'experiment', - flagVersion: 20, - urlMatch: ['http://test.com'], + flagVersion: 1, deliveryMethod: 'web', }, segments: [ + { + conditions: [ + [ + { + op: 'regex does not match', + selector: ['context', 'page', 'url'], + values: ['^http:\\/\\/test.com/$'], + }, + ], + ], + metadata: { + segmentName: 'Page not targeted', + trackExposure: false, + }, + variant: 'off', + }, { metadata: { segmentName: 'All Other Users', }, - variant: 'control', + variant: 'treatment', }, ], variants: { control: { - key: 'treatment', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com', - }, - }, - ], + key: 'control', + payload: [], value: 'control', }, off: { @@ -548,22 +476,25 @@ describe('initializeExperiment', () => { ]), ); - expect(mockGlobal.location.replace).toHaveBeenCalledWith( + expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); + expect(mockExposure).toHaveBeenCalledTimes(0); + expect(mockGlobal.history.replaceState).toHaveBeenCalledWith( + {}, + '', 'http://test.com/2', ); - expect(mockExposure).toHaveBeenCalledTimes(0); }); - test('preview - force treatment variant when on treatment page', () => { + test('concatenate query params from original and redirected url', () => { const mockGlobal = { localStorage: { getItem: jest.fn().mockReturnValue(undefined), setItem: jest.fn(), }, location: { - href: 'http://test.com/2', + href: 'http://test.com/?param1=a¶m2=b', replace: jest.fn(), - search: '?test=treatment&PREVIEW=true', + search: '?param1=a¶m2=b', }, document: { referrer: '' }, history: { replaceState: jest.fn() }, @@ -573,7 +504,7 @@ describe('initializeExperiment', () => { mockGetGlobalScope.mockReturnValue(mockGlobal); initializeExperiment( - 'prev_treatment', + '8', JSON.stringify([ { key: 'test', @@ -591,12 +522,12 @@ describe('initializeExperiment', () => { metadata: { segmentName: 'All Other Users', }, - variant: 'control', + variant: 'treatment', }, ], variants: { control: { - key: 'treatment', + key: 'control', payload: [ { action: 'redirect', @@ -619,7 +550,7 @@ describe('initializeExperiment', () => { { action: 'redirect', data: { - url: 'http://test.com/2', + url: 'http://test.com/2?param3=c', }, }, ], @@ -630,35 +561,15 @@ describe('initializeExperiment', () => { ]), ); - expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); - expect(mockExposure).toHaveBeenCalledTimes(0); - expect(mockGlobal.history.replaceState).toHaveBeenCalledWith( - {}, - '', - 'http://test.com/2', + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2?param3=c¶m1=a¶m2=b', ); + expect(mockExposure).toHaveBeenCalledWith('test'); }); - test('concatenate query params from original and redirected url', () => { - const mockGlobal = { - localStorage: { - getItem: jest.fn().mockReturnValue(undefined), - setItem: jest.fn(), - }, - location: { - href: 'http://test.com/?param1=a¶m2=b', - replace: jest.fn(), - search: '?param1=a¶m2=b', - }, - document: { referrer: '' }, - history: { replaceState: jest.fn() }, - }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - mockGetGlobalScope.mockReturnValue(mockGlobal); - + test('should behave as control variant when payload is empty', () => { initializeExperiment( - 'merge_query', + '9', JSON.stringify([ { key: 'test', @@ -668,7 +579,6 @@ describe('initializeExperiment', () => { experimentKey: 'exp-1', flagType: 'experiment', flagVersion: 20, - urlMatch: ['http://test.com'], deliveryMethod: 'web', }, segments: [ @@ -676,35 +586,22 @@ describe('initializeExperiment', () => { metadata: { segmentName: 'All Other Users', }, - variant: 'treatment', + variant: 'control', }, ], variants: { control: { key: 'control', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com', - }, - }, - ], + payload: [], value: 'control', }, - off: { - key: 'off', - metadata: { - default: true, - }, - }, treatment: { key: 'treatment', payload: [ { action: 'redirect', data: { - url: 'http://test.com/2?param3=c', + url: 'http://test.com/2', }, }, ], @@ -715,45 +612,46 @@ describe('initializeExperiment', () => { ]), ); - expect(mockGlobal.location.replace).toHaveBeenCalledWith( - 'http://test.com/2?param3=c¶m1=a¶m2=b', - ); - expect(mockExposure).toHaveBeenCalledTimes(0); + expect(mockGlobal.location.replace).not.toHaveBeenCalled(); + expect(mockExposure).toHaveBeenCalledWith('test'); }); - test('exposure fired after params merged', () => { - const mockGlobal = { - localStorage: { - getItem: jest.fn().mockReturnValue(undefined), - setItem: jest.fn(), - }, - location: { - href: 'http://test.com/2?param3=c¶m1=a¶m2=b', - replace: jest.fn(), - search: '?param3=c¶m1=a¶m2=b', + test('on targeted page, should call exposure', () => { + Object.defineProperty(global, 'location', { + value: { + href: 'http://test.com', }, - document: { referrer: 'http://test.com/?param1=a¶m2=b' }, - history: { replaceState: jest.fn() }, - }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - mockGetGlobalScope.mockReturnValue(mockGlobal); - + writable: true, + }); + jest.spyOn(coreUtil, 'getGlobalScope'); initializeExperiment( - 'merge_expose', + '10', JSON.stringify([ { key: 'test', metadata: { deployed: true, evaluationMode: 'local', - experimentKey: 'exp-1', flagType: 'experiment', - flagVersion: 20, - urlMatch: ['http://test.com'], deliveryMethod: 'web', }, segments: [ + { + conditions: [ + [ + { + op: 'regex does not match', + selector: ['context', 'page', 'url'], + values: ['^http:\\/\\/test.*'], + }, + ], + ], + metadata: { + segmentName: 'Page not targeted', + trackExposure: false, + }, + variant: 'off', + }, { metadata: { segmentName: 'All Other Users', @@ -762,18 +660,6 @@ describe('initializeExperiment', () => { }, ], variants: { - control: { - key: 'control', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com', - }, - }, - ], - value: 'control', - }, off: { key: 'off', metadata: { @@ -781,72 +667,72 @@ describe('initializeExperiment', () => { }, }, treatment: { - key: 'treatment', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com/2?param3=c', - }, - }, - ], value: 'treatment', }, }, }, ]), ); - - expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); expect(mockExposure).toHaveBeenCalledWith('test'); }); - test('should behave as control variant when payload is empty', () => { + + test('on non-targeted page, should not call exposure', () => { + Object.defineProperty(global, 'location', { + value: { + href: 'http://test.com', + }, + writable: true, + }); initializeExperiment( - 'empty_payload', + '11', JSON.stringify([ { key: 'test', metadata: { deployed: true, evaluationMode: 'local', - experimentKey: 'exp-1', flagType: 'experiment', - flagVersion: 20, - urlMatch: ['http://test.com'], deliveryMethod: 'web', }, segments: [ + { + conditions: [ + [ + { + op: 'regex match', + selector: ['context', 'page', 'url'], + values: ['.*test\\.com$'], + }, + ], + ], + metadata: { + segmentName: 'Page is excluded', + trackExposure: false, + }, + variant: 'off', + }, { metadata: { segmentName: 'All Other Users', }, - variant: 'control', + variant: 'treatment', }, ], variants: { - control: { - key: 'control', - payload: [], - value: 'control', + off: { + key: 'off', + metadata: { + default: true, + }, }, treatment: { key: 'treatment', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com/2', - }, - }, - ], value: 'treatment', }, }, }, ]), ); - - expect(mockGlobal.location.replace).not.toHaveBeenCalled(); - expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockExposure).not.toHaveBeenCalled(); }); }); diff --git a/packages/experiment-tag/test/util.test.ts b/packages/experiment-tag/test/util.test.ts index 1e6d98a4..9c78ecc7 100644 --- a/packages/experiment-tag/test/util.test.ts +++ b/packages/experiment-tag/test/util.test.ts @@ -1,13 +1,13 @@ +import * as coreUtil from '@amplitude/experiment-core'; import { concatenateQueryParamsOf, getUrlParams, matchesUrl, urlWithoutParamsAndAnchor, } from 'src/util'; -import * as util from 'src/util'; // Mock the getGlobalScope function -const spyGetGlobalScope = jest.spyOn(util, 'getGlobalScope'); +const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); describe('matchesUrl', () => { // Existing test cases