diff --git a/packages/experiment-browser/src/util/convert.ts b/packages/experiment-browser/src/util/convert.ts index b3b5c5c..561b9f9 100644 --- a/packages/experiment-browser/src/util/convert.ts +++ b/packages/experiment-browser/src/util/convert.ts @@ -12,9 +12,12 @@ export const convertUserToContext = ( const context: Record = { user: user }; // add page context const globalScope = getGlobalScope(); - if (globalScope) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const currentUrl = user.currentUrl || globalScope?.location.href; + if (currentUrl) { context.page = { - url: globalScope.location.href, + url: currentUrl, }; } const groups: Record> = {}; diff --git a/packages/experiment-tag/src/config.ts b/packages/experiment-tag/src/config.ts new file mode 100644 index 0000000..062b5ac --- /dev/null +++ b/packages/experiment-tag/src/config.ts @@ -0,0 +1,17 @@ +import { ExperimentConfig } from '@amplitude/experiment-js-client'; + +export interface WebExperimentConfig extends ExperimentConfig { + /** + * Determines whether variants actions should be reverted and reapplied on navigation events. + */ + reapplyVariantsOnNavigation?: boolean; + /** + * Determines whether anti-flicker CSS should be applied for remote blocking flags. + */ + applyAntiFlickerForRemoteBlocking?: boolean; +} + +export const Defaults: WebExperimentConfig = { + reapplyVariantsOnNavigation: true, + applyAntiFlickerForRemoteBlocking: true, +}; diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index c622e56..342cd65 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -4,21 +4,28 @@ import { EvaluationSegment, getGlobalScope, isLocalStorageAvailable, + safeGlobal, } from '@amplitude/experiment-core'; -import { safeGlobal } from '@amplitude/experiment-core'; import { Experiment, Variant, - Variants, AmplitudeIntegrationPlugin, - ExperimentConfig, + ExperimentClient, + Variants, } from '@amplitude/experiment-js-client'; import * as FeatureExperiment from '@amplitude/experiment-js-client'; import mutate, { MutationController } from 'dom-mutator'; +import { WebExperimentConfig } from './config'; import { getInjectUtils } from './inject-utils'; import { WindowMessenger } from './messenger'; import { + ApplyVariantsOption, + RevertVariantsOptions, + WebExperimentContext, +} from './types'; +import { + convertEvaluationVariantToVariant, getUrlParams, removeQueryParams, urlWithoutParamsAndAnchor, @@ -26,439 +33,674 @@ import { concatenateQueryParamsOf, } from './util'; -safeGlobal.Experiment = FeatureExperiment; +export const PAGE_NOT_TARGETED_SEGMENT_NAME = 'Page not targeted'; +export const PAGE_IS_EXCLUDED_SEGMENT_NAME = 'Page is excluded'; -const appliedInjections: Set = new Set(); -const appliedMutations: MutationController[] = []; -let previousUrl: string | undefined; -// Cache to track exposure for the current URL, should be cleared on URL change -let urlExposureCache: { [url: string]: { [key: string]: string | undefined } }; - -export const initializeExperiment = async ( - apiKey: string, - initialFlags: string, - config: ExperimentConfig = {}, -) => { - const globalScope = getGlobalScope(); - if (globalScope?.webExperiment) { - return; - } - WindowMessenger.setup(); - if (!isLocalStorageAvailable() || !globalScope) { - return; - } - previousUrl = undefined; - urlExposureCache = {}; - const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`; - let user; - try { - user = JSON.parse( - globalScope.localStorage.getItem(experimentStorageName) || '{}', - ); - } catch (error) { - user = {}; - } +safeGlobal.Experiment = FeatureExperiment; - // if web_exp_id does not exist: - // 1. if device_id exists, migrate device_id to web_exp_id and remove device_id - // 2. if device_id does not exist, create a new web_exp_id - // 3. if both device_id and web_exp_id exist, remove device_id - if (!user.web_exp_id) { - user.web_exp_id = user.device_id || UUID(); - delete user.device_id; - globalScope.localStorage.setItem( - experimentStorageName, - JSON.stringify(user), - ); - } else if (user.web_exp_id && user.device_id) { - delete user.device_id; - globalScope.localStorage.setItem( - experimentStorageName, - JSON.stringify(user), - ); - } +export class WebExperiment { + private readonly apiKey: string; + private readonly initialFlags: []; + private readonly config: WebExperimentConfig; + private readonly globalScope = getGlobalScope(); + private readonly experimentClient: ExperimentClient | undefined; + private appliedInjections: Set = new Set(); + private appliedMutations: Record = {}; + private previousUrl: string | undefined = undefined; + // Cache to track exposure for the current URL, should be cleared on URL change + private urlExposureCache: Record> = + {}; + private flagVariantMap: Record> = {}; + private localFlagKeys: string[] = []; + private remoteFlagKeys: string[] = []; + private isRemoteBlocking = false; + + constructor( + apiKey: string, + initialFlags: string, + config: WebExperimentConfig = {}, + ) { + this.apiKey = apiKey; + this.config = config; + this.initialFlags = JSON.parse(initialFlags); + if (this.globalScope?.webExperiment) { + return; + } - 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; - } + WindowMessenger.setup(); + if (!isLocalStorageAvailable() || !this.globalScope) { + return; + } - let isRemoteBlocking = false; - const remoteFlagKeys: Set = new Set(); - const localFlagKeys: Set = new Set(); - const parsedFlags = JSON.parse(initialFlags); + this.globalScope.webExperiment = this; + const experimentStorageName = `EXP_${this.apiKey.slice(0, 10)}`; + let user; + try { + user = JSON.parse( + this.globalScope.localStorage.getItem(experimentStorageName) || '{}', + ); + } catch (error) { + user = {}; + } - parsedFlags.forEach((flag: EvaluationFlag) => { - const { key, variants, segments, metadata = {} } = flag; + // if web_exp_id does not exist: + // 1. if device_id exists, migrate device_id to web_exp_id and remove device_id + // 2. if device_id does not exist, create a new web_exp_id + // 3. if both device_id and web_exp_id exist, remove device_id + if (!user.web_exp_id) { + user.web_exp_id = user.device_id || UUID(); + delete user.device_id; + this.globalScope.localStorage.setItem( + experimentStorageName, + JSON.stringify(user), + ); + } else if (user.web_exp_id && user.device_id) { + delete user.device_id; + this.globalScope.localStorage.setItem( + experimentStorageName, + JSON.stringify(user), + ); + } - // Force variant if in preview mode - if ( - urlParams['PREVIEW'] && - key in urlParams && - urlParams[key] in variants - ) { - // Remove preview-related query parameters from the URL - globalScope.history.replaceState( + const urlParams = getUrlParams(); + // if in visual edit mode, remove the query param + if (urlParams['VISUAL_EDITOR']) { + this.globalScope.history.replaceState( {}, '', - removeQueryParams(globalScope.location.href, ['PREVIEW', key]), + removeQueryParams(this.globalScope.location.href, ['VISUAL_EDITOR']), ); + return; + } - // Retain only page-targeting segments - const pageTargetingSegments = segments.filter(isPageTargetingSegment); + this.initialFlags.forEach((flag: EvaluationFlag) => { + const { key, variants, segments, metadata = {} } = flag; + + this.flagVariantMap[key] = {}; + Object.keys(variants).forEach((variantKey) => { + this.flagVariantMap[key][variantKey] = + convertEvaluationVariantToVariant(variants[variantKey]); + }); + + // Force variant if in preview mode + if ( + urlParams['PREVIEW'] && + key in urlParams && + urlParams[key] in variants + ) { + // Remove preview-related query parameters from the URL + this.globalScope?.history.replaceState( + {}, + '', + removeQueryParams(this.globalScope.location.href, ['PREVIEW', key]), + ); + + // Retain only page-targeting segments + const pageTargetingSegments = segments.filter( + this.isPageTargetingSegment, + ); + + // Add or update the preview segment + const previewSegment = { + metadata: { segmentName: 'preview' }, + variant: urlParams[key], + }; + + // Update the flag's segments to include the preview segment + flag.segments = [...pageTargetingSegments, previewSegment]; + + // make all preview flags local + metadata.evaluationMode = 'local'; + } - // Add or update the preview segment - const previewSegment = { - metadata: { segmentName: 'preview' }, - variant: urlParams[key], - }; + if (metadata.evaluationMode !== 'local') { + this.remoteFlagKeys.push(key); - // Update the flag's segments to include the preview segment - flag.segments = [...pageTargetingSegments, previewSegment]; + // allow local evaluation for remote flags + metadata.evaluationMode = 'local'; + } else { + // Add locally evaluable flags to the local flag set + this.localFlagKeys.push(key); + } - // make all preview flags local - metadata.evaluationMode = 'local'; + flag.metadata = metadata; + }); + + const initialFlagsString = JSON.stringify(this.initialFlags); + + // initialize the experiment + this.experimentClient = Experiment.initialize(this.apiKey, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + internalInstanceNameSuffix: 'web', + initialFlags: initialFlagsString, + // timeout for fetching remote flags + fetchTimeoutMillis: 1000, + pollOnStart: false, + fetchOnStart: false, + ...this.config, + }); + + // evaluate variants for page targeting + const variants: Variants = this.experimentClient.all(); + + for (const [key, variant] of Object.entries(variants)) { + // only apply antiflicker for remote flags active on the page + if ( + this.remoteFlagKeys.includes(key) && + variant.metadata?.blockingEvaluation && + variant.metadata?.segmentName !== PAGE_NOT_TARGETED_SEGMENT_NAME && + variant.metadata?.segmentName !== PAGE_IS_EXCLUDED_SEGMENT_NAME + ) { + this.isRemoteBlocking = true; + // Apply anti-flicker CSS to prevent UI flicker + this.applyAntiFlickerCss(); + } } - if (metadata.evaluationMode !== 'local') { - remoteFlagKeys.add(key); - - // allow local evaluation for remote flags - metadata.evaluationMode = 'local'; - } else { - // Add locally evaluable flags to the local flag set - localFlagKeys.add(key); + // If no integration has been set, use an amplitude integration. + if (!this.globalScope.experimentIntegration) { + const connector = AnalyticsConnector.getInstance('$default_instance'); + this.globalScope.experimentIntegration = new AmplitudeIntegrationPlugin( + this.apiKey, + connector, + 0, + ); } + this.globalScope.experimentIntegration.type = 'integration'; + this.experimentClient.addPlugin(this.globalScope.experimentIntegration); + this.experimentClient.setUser(user); + + // If no integration has been set, use an amplitude integration. + if (!this.globalScope.experimentIntegration) { + const connector = AnalyticsConnector.getInstance('$default_instance'); + this.globalScope.experimentIntegration = new AmplitudeIntegrationPlugin( + apiKey, + connector, + 0, + ); + } + } - flag.metadata = metadata; - }); - - initialFlags = JSON.stringify(parsedFlags); + /** + * Start the experiment. + */ + public async start() { + if (!this.experimentClient) { + return; + } - // initialize the experiment - globalScope.webExperiment = Experiment.initialize(apiKey, { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - internalInstanceNameSuffix: 'web', - initialFlags: initialFlags, - // timeout for fetching remote flags - fetchTimeoutMillis: 1000, - pollOnStart: false, - fetchOnStart: false, - ...config, - }); + if (this.config.reapplyVariantsOnNavigation) { + this.setUrlChangeListener([ + ...this.localFlagKeys, + ...this.remoteFlagKeys, + ]); + } - // evaluate variants for page targeting - const variants: Variants = globalScope.webExperiment.all(); + // apply local variants + this.applyVariants({ flagKeys: this.localFlagKeys }); - for (const [key, variant] of Object.entries(variants)) { - // only apply antiflicker for remote flags active on the page if ( - remoteFlagKeys.has(key) && - variant.metadata?.blockingEvaluation && - variant.metadata?.segmentName !== 'Page not targeted' && - variant.metadata?.segmentName !== 'Page is excluded' + !this.isRemoteBlocking || + !this.config.applyAntiFlickerForRemoteBlocking ) { - isRemoteBlocking = true; - // Apply anti-flicker CSS to prevent UI flicker - applyAntiFlickerCss(); + // Remove anti-flicker css if remote flags are not blocking + this.globalScope?.document.getElementById?.('amp-exp-css')?.remove(); } - } - // If no integration has been set, use an amplitude integration. - if (!globalScope.experimentIntegration) { - const connector = AnalyticsConnector.getInstance('$default_instance'); - globalScope.experimentIntegration = new AmplitudeIntegrationPlugin( - apiKey, - connector, - 0, - ); - } - globalScope.experimentIntegration.type = 'integration'; - globalScope.webExperiment.addPlugin(globalScope.experimentIntegration); - globalScope.webExperiment.setUser(user); - - setUrlChangeListener(new Set([...localFlagKeys, ...remoteFlagKeys])); - - // apply local variants - applyVariants(globalScope.webExperiment.all(), localFlagKeys); + if (this.remoteFlagKeys.length === 0) { + return; + } - if (!isRemoteBlocking) { - // Remove anti-flicker css if remote flags are not blocking - globalScope.document.getElementById?.('amp-exp-css')?.remove(); + await this.fetchRemoteFlags(); + // apply remote variants - if fetch is unsuccessful, fallback order: 1. localStorage flags, 2. initial flags + this.applyVariants({ flagKeys: this.remoteFlagKeys }); } - if (remoteFlagKeys.size === 0) { - return; + /** + * Get the underlying ExperimentClient instance. + */ + public getExperimentClient(): ExperimentClient | undefined { + return this.experimentClient; } - try { - await globalScope.webExperiment.doFlags(); - } catch (error) { - console.warn('Error fetching remote flags:', error); + /** + * Set the context for evaluating experiments. + * If user is undefined, the current user is used. + * If currentUrl is undefined, the current URL is used. + * @param webExperimentContext + */ + public setContext(webExperimentContext: WebExperimentContext) { + if (this.experimentClient) { + const existingUser = this.experimentClient?.getUser(); + if (webExperimentContext.user) { + if (webExperimentContext.currentUrl) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + user.currentUrl = currentUrl; + } + this.experimentClient.setUser(webExperimentContext.user); + } else { + this.experimentClient.setUser({ + ...existingUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + currentUrl: webExperimentContext.currentUrl, + }); + } + } } - // apply remote variants - if fetch is unsuccessful, fallback order: 1. localStorage flags, 2. initial flags - applyVariants(globalScope.webExperiment.all(), remoteFlagKeys); -}; - -const applyVariants = ( - variants: Variants, - flagKeys: Set | undefined = undefined, -) => { - if (Object.keys(variants).length === 0) { - return; + + /** + * Set the previous URL for tracking back/forward navigation. Set previous URL to prevent infinite redirection loop + * in single-page applications. + * @param url + */ + public setPreviousUrl(url: string) { + this.previousUrl = url; } - const globalScope = getGlobalScope(); - if (!globalScope) { - return; + + /** + * Apply evaluated variants to the page. + * @param applyVariantsOption + */ + public applyVariants(applyVariantsOption?: ApplyVariantsOption) { + const { flagKeys } = applyVariantsOption || {}; + const variants = this.experimentClient?.all() || {}; + if (Object.keys(variants).length === 0) { + return; + } + const globalScope = getGlobalScope(); + if (!globalScope) { + return; + } + const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); + // Initialize the cache if on a new URL + if (!this.urlExposureCache?.[currentUrl]) { + this.urlExposureCache = {}; + this.urlExposureCache[currentUrl] = {}; + } + for (const key in variants) { + if (flagKeys && !flagKeys.includes(key)) { + continue; + } + 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) { + this.exposureWithDedupe(key, variant); + continue; + } + + if (payloadIsArray) { + this.handleVariantAction(key, variant); + } + } + } } - const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); - // Initialize the cache if on a new URL - if (!urlExposureCache?.[currentUrl]) { - urlExposureCache = {}; - urlExposureCache[currentUrl] = {}; + + /** + * Revert mutations applied by the experiment. + * @param revertVariantsOptions + */ + public revertVariants(revertVariantsOptions?: RevertVariantsOptions) { + let { flagKeys } = revertVariantsOptions || {}; + if (!flagKeys) { + flagKeys = Object.keys(this.appliedMutations); + } + for (const key of flagKeys) { + this.appliedMutations[key]?.forEach((mutationController) => { + mutationController.revert(); + }); + delete this.appliedMutations[key]; + } } - for (const key in variants) { - if (flagKeys && !flagKeys.has(key)) { - continue; - } - 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) { - exposureWithDedupe(key, variant); + + /** + * Get redirect URLs for flags. + * @param flagKeys + */ + public getRedirectUrls( + flagKeys?: string[], + ): Record> { + const redirectUrlMap: Record> = {}; + if (!flagKeys) { + flagKeys = Object.keys(this.flagVariantMap); + } + for (const key of flagKeys) { + if (this.flagVariantMap[key] === undefined) { 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); + const variants = this.flagVariantMap[key]; + const redirectUrls = {}; + Object.keys(variants).forEach((variantKey) => { + const variant = variants[variantKey]; + const payload = variant.payload; + if (payload && Array.isArray(payload)) { + for (const action of variant.payload) { + if (action.action === 'redirect') { + const url = action.data?.url; + if (url) { + redirectUrls[variantKey] = action.data.url; + } + } } } + }); + if (Object.keys(redirectUrls).length > 0) { + redirectUrlMap[key] = redirectUrls; } } + return redirectUrlMap; } -}; -const handleRedirect = (action, key: string, variant: Variant) => { - const globalScope = getGlobalScope(); - if (!globalScope) { - return; + /** + * Preview the effect of a variant on the page. + * @param key + * @param variant + */ + public previewVariant(key: string, variant: string) { + if (this.appliedMutations[key]) { + this.revertVariants({ flagKeys: [key] }); + } + const flag = this.flagVariantMap[key]; + if (!flag) { + return; + } + const variantObject = flag[variant]; + if (!variantObject) { + return; + } + const payload = variantObject.payload; + if (!payload || !Array.isArray(payload)) { + return; + } + this.handleVariantAction(key, variantObject); } - const referrerUrl = urlWithoutParamsAndAnchor( - previousUrl || globalScope.document.referrer, - ); - const redirectUrl = action?.data?.url; - const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); + /** + * Get all variants for a given WebExperimentContext. + * If user is undefined, the current user is used. + * If currentUrl is undefined, the current URL is used. + * If flagKeys is undefined, all variants are returned. + * @param webExperimentContext + * @param flagKeys + */ + public getVariants( + webExperimentContext?: WebExperimentContext, + flagKeys?: string[], + ): Variants { + if (!this.experimentClient) { + return {}; + } + const existingContext: WebExperimentContext = { + user: this.experimentClient?.getUser(), + }; + webExperimentContext && this.setContext(webExperimentContext); + const variants = this.experimentClient.all(); + if (flagKeys) { + const filteredVariants = {}; + for (const key of flagKeys) { + filteredVariants[key] = variants[key]; + } + return filteredVariants; + } + this.setContext(existingContext); + return variants; + } + + /** + * Fetch remote flags based on the current user. + */ + public async fetchRemoteFlags() { + if (!this.experimentClient) { + return; + } + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await this.experimentClient.doFlags(); + } catch (error) { + console.warn('Error fetching remote flags:', error); + } + } - // prevent infinite redirection loop - if (currentUrl === referrerUrl) { - return; + public getActiveExperimentsOnPage(currentUrl?: string): string[] { + const variants = this.getVariants({ currentUrl: currentUrl }); + return Object.keys(variants).filter((key) => { + return ( + variants[key].metadata?.segmentName !== + PAGE_NOT_TARGETED_SEGMENT_NAME && + variants[key].metadata?.segmentName !== PAGE_IS_EXCLUDED_SEGMENT_NAME + ); + }); } - const targetUrl = concatenateQueryParamsOf( - globalScope.location.href, - redirectUrl, - ); + /** + * Set URL change listener to revert mutations and apply variants on back/forward navigation. + * @param flagKeys + */ + public setUrlChangeListener(flagKeys: string[]) { + const globalScope = getGlobalScope(); + if (!globalScope) { + return; + } + // Add URL change listener for back/forward navigation + globalScope.addEventListener('popstate', () => { + this.revertVariants(); + this.applyVariants({ flagKeys: flagKeys }); + }); + + const handleUrlChange = () => { + this.revertVariants(); + this.applyVariants({ flagKeys: flagKeys }); + this.previousUrl = globalScope.location.href; + }; - exposureWithDedupe(key, variant); + // Create wrapper functions for pushState and replaceState + const wrapHistoryMethods = () => { + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + + // Wrapper for pushState + history.pushState = function (...args) { + // Call the original pushState + const result = originalPushState.apply(this, args); + // Revert mutations and apply variants + handleUrlChange(); + return result; + }; - // set previous url - relevant for SPA if redirect happens before push/replaceState is complete - previousUrl = globalScope.location.href; - // perform redirection - globalScope.location.replace(targetUrl); -}; + // Wrapper for replaceState + history.replaceState = function (...args) { + // Call the original replaceState + const result = originalReplaceState.apply(this, args); + // Revert mutations and apply variants + handleUrlChange(); + return result; + }; + }; -const handleMutate = (action, key: string, variant: Variant) => { - const globalScope = getGlobalScope(); - if (!globalScope) { - return; + // Initialize the wrapper + wrapHistoryMethods(); } - const mutations = action.data?.mutations; - mutations.forEach((m) => { - appliedMutations.push(mutate.declarative(m)); - }); - exposureWithDedupe(key, variant); -}; - -const revertMutations = () => { - while (appliedMutations.length > 0) { - appliedMutations.pop()?.revert(); - } -}; -const handleInject = (action, key: string, variant: Variant) => { - const globalScope = getGlobalScope(); - if (!globalScope) { - return; - } - // Validate and transform ID - let id = action.data.id; - if (!id || typeof id !== 'string' || id.length === 0) { - return; - } - // Replace the `-` characters in the UUID to support function name - id = id.replace(/-/g, ''); - // Check for repeat invocations - if (appliedInjections.has(id)) { - return; + private handleVariantAction(key: string, variant: Variant) { + for (const action of variant.payload) { + if (action.action === 'redirect') { + this.handleRedirect(action, key, variant); + } else if (action.action === 'mutate') { + this.handleMutate(action, key, variant); + } else if (action.action === 'inject') { + this.handleInject(action, key, variant); + } + } } - // Create JS - const rawJs = action.data.js; - let script: HTMLScriptElement | undefined; - if (rawJs) { - script = globalScope.document.createElement('script'); - if (script) { - script.innerHTML = `function ${id}(html, utils, id){${rawJs}};`; - script.id = `js-${id}`; - globalScope.document.head.appendChild(script); + + private handleRedirect(action, key: string, variant: Variant) { + const globalScope = getGlobalScope(); + if (!globalScope) { + return; + } + const referrerUrl = urlWithoutParamsAndAnchor( + this.previousUrl || globalScope.document.referrer, + ); + const redirectUrl = action?.data?.url; + + const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); + + // prevent infinite redirection loop + if (currentUrl === referrerUrl) { + return; } + + const targetUrl = concatenateQueryParamsOf( + globalScope.location.href, + redirectUrl, + ); + + this.exposureWithDedupe(key, variant); + + // set previous url - relevant for SPA if redirect happens before push/replaceState is complete + this.previousUrl = globalScope.location.href; + // perform redirection + globalScope.location.replace(targetUrl); } - // Create CSS - const rawCss = action.data.css; - let style: HTMLStyleElement | undefined; - if (rawCss) { - style = globalScope.document.createElement('style'); - if (style) { - style.innerHTML = rawCss; - style.id = `css-${id}`; - globalScope.document.head.appendChild(style); + + private handleMutate(action, key: string, variant: Variant) { + const globalScope = getGlobalScope(); + if (!globalScope) { + return; } + const mutations = action.data?.mutations; + const mutationControllers: MutationController[] = []; + mutations.forEach((m) => { + mutationControllers.push(mutate.declarative(m)); + }); + this.appliedMutations[key] = mutationControllers; + this.exposureWithDedupe(key, variant); } - // Create HTML - const rawHtml = action.data.html; - let html: Element | undefined; - if (rawHtml) { - html = - new DOMParser().parseFromString(rawHtml, 'text/html').body - .firstElementChild ?? undefined; + + private handleInject(action, key: string, variant: Variant) { + const globalScope = getGlobalScope(); + if (!globalScope) { + return; + } + // Validate and transform ID + let id = action.data.id; + if (!id || typeof id !== 'string' || id.length === 0) { + return; + } + // Replace the `-` characters in the UUID to support function name + id = id.replace(/-/g, ''); + // Check for repeat invocations + if (this.appliedInjections.has(id)) { + return; + } + // Create JS + const rawJs = action.data.js; + let script: HTMLScriptElement | undefined; + if (rawJs) { + script = globalScope.document.createElement('script'); + if (script) { + script.innerHTML = `function ${id}(html, utils, id){${rawJs}};`; + script.id = `js-${id}`; + globalScope.document.head.appendChild(script); + } + } + // Create CSS + const rawCss = action.data.css; + let style: HTMLStyleElement | undefined; + if (rawCss) { + style = globalScope.document.createElement('style'); + if (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(); + this.appliedInjections.add(id); + try { + const fn = globalScope[id]; + if (fn && typeof fn === 'function') { + fn(html, utils, id); + } + } catch (e) { + script?.remove(); + 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. + this.appliedMutations[key] = [ + { + revert: () => { + if (utils.remove) utils.remove(); + style?.remove(); + script?.remove(); + this.appliedInjections.delete(id); + }, + }, + ]; + this.exposureWithDedupe(key, variant); } - // Inject - const utils = getInjectUtils(); - appliedInjections.add(id); - try { - const fn = globalScope[id]; - if (fn && typeof fn === 'function') { - fn(html, utils, id); - } - } catch (e) { - script?.remove(); - console.error( - `Experiment inject failed for ${key} variant ${variant.key}. Reason:`, - e, + + private isPageTargetingSegment(segment: EvaluationSegment) { + return ( + segment.metadata?.trackExposure === false && + (segment.metadata?.segmentName === PAGE_NOT_TARGETED_SEGMENT_NAME || + segment.metadata?.segmentName === PAGE_IS_EXCLUDED_SEGMENT_NAME) ); } - // Push mutation to remove CSS and any custom state cleanup set in utils. - appliedMutations.push({ - revert: () => { - if (utils.remove) utils.remove(); - style?.remove(); - script?.remove(); - appliedInjections.delete(id); - }, - }); - exposureWithDedupe(key, variant); -}; - -export const setUrlChangeListener = (flagKeys: Set) => { - const globalScope = getGlobalScope(); - if (!globalScope) { - return; - } - // Add URL change listener for back/forward navigation - globalScope.addEventListener('popstate', () => { - revertMutations(); - applyVariants(globalScope.webExperiment.all(), flagKeys); - }); - - // Create wrapper functions for pushState and replaceState - const wrapHistoryMethods = () => { - const originalPushState = history.pushState; - const originalReplaceState = history.replaceState; - - // Wrapper for pushState - history.pushState = function (...args) { - // Call the original pushState - const result = originalPushState.apply(this, args); - // Revert mutations and apply variants - revertMutations(); - applyVariants(globalScope.webExperiment.all(), flagKeys); - previousUrl = globalScope.location.href; - return result; - }; - // Wrapper for replaceState - history.replaceState = function (...args) { - // Call the original replaceState - const result = originalReplaceState.apply(this, args); - // Revert mutations and apply variants - revertMutations(); - applyVariants(globalScope.webExperiment.all(), flagKeys); - previousUrl = globalScope.location.href; - return result; - }; - }; - - // 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') - ); -}; - -const exposureWithDedupe = (key: string, variant: Variant) => { - const globalScope = getGlobalScope(); - if (!globalScope) return; - - const shouldTrackVariant = variant.metadata?.['trackExposure'] ?? true; - const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); - - // if on the same base URL, only track exposure if variant has changed or has not been tracked - const hasTrackedVariant = - urlExposureCache?.[currentUrl]?.[key] === variant.key; - const shouldTrackExposure = shouldTrackVariant && !hasTrackedVariant; - - if (shouldTrackExposure) { - globalScope.webExperiment.exposure(key); - urlExposureCache[currentUrl][key] = variant.key; + private exposureWithDedupe(key: string, variant: Variant) { + const globalScope = getGlobalScope(); + if (!globalScope) return; + + const shouldTrackVariant = variant.metadata?.['trackExposure'] ?? true; + const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href); + + // if on the same base URL, only track exposure if variant has changed or has not been tracked + const hasTrackedVariant = + this.urlExposureCache?.[currentUrl]?.[key] === variant.key; + const shouldTrackExposure = shouldTrackVariant && !hasTrackedVariant; + + if (shouldTrackExposure) { + this.experimentClient?.exposure(key); + this.urlExposureCache[currentUrl][key] = variant.key; + } } -}; - -const applyAntiFlickerCss = () => { - const globalScope = getGlobalScope(); - if (!globalScope) return; - if (!globalScope.document.getElementById('amp-exp-css')) { - const id = 'amp-exp-css'; - const s = document.createElement('style'); - s.id = id; - s.innerText = - '* { visibility: hidden !important; background-image: none !important; }'; - document.head.appendChild(s); - globalScope.window.setTimeout(function () { - s.remove(); - }, 1000); + + private applyAntiFlickerCss() { + const globalScope = getGlobalScope(); + if (!globalScope) return; + if (!globalScope.document.getElementById('amp-exp-css')) { + const id = 'amp-exp-css'; + const s = document.createElement('style'); + s.id = id; + s.innerText = + '* { visibility: hidden !important; background-image: none !important; }'; + document.head.appendChild(s); + globalScope.window.setTimeout(function () { + s.remove(); + }, 1000); + } } -}; +} diff --git a/packages/experiment-tag/src/index.ts b/packages/experiment-tag/src/index.ts new file mode 100644 index 0000000..1eee51a --- /dev/null +++ b/packages/experiment-tag/src/index.ts @@ -0,0 +1,2 @@ +export { WebExperiment } from './experiment'; +export { WebExperimentConfig } from './config'; diff --git a/packages/experiment-tag/src/script.ts b/packages/experiment-tag/src/script.ts index 321859c..498a325 100644 --- a/packages/experiment-tag/src/script.ts +++ b/packages/experiment-tag/src/script.ts @@ -1,12 +1,16 @@ -import { initializeExperiment } from './experiment'; +import { Defaults } from './config'; +import { WebExperiment } from './experiment'; const API_KEY = '{{DEPLOYMENT_KEY}}'; const initialFlags = '{{INITIAL_FLAGS}}'; const serverZone = '{{SERVER_ZONE}}'; -initializeExperiment(API_KEY, initialFlags, { serverZone: serverZone }).then( - () => { - // Remove anti-flicker css if it exists - document.getElementById('amp-exp-css')?.remove(); - }, -); +const webExperimentClient = new WebExperiment(API_KEY, initialFlags, { + reapplyVariantsOnNavigation: Defaults.reapplyVariantsOnNavigation, + applyAntiFlickerForRemoteBlocking: Defaults.applyAntiFlickerForRemoteBlocking, + serverZone: serverZone, +}); +webExperimentClient.start().then(() => { + // Remove anti-flicker css if it exists + document.getElementById('amp-exp-css')?.remove(); +}); diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts new file mode 100644 index 0000000..766bd26 --- /dev/null +++ b/packages/experiment-tag/src/types.ts @@ -0,0 +1,14 @@ +import { ExperimentUser } from '@amplitude/experiment-js-client'; + +export type WebExperimentContext = { + user?: ExperimentUser; + currentUrl?: string; +}; + +export type ApplyVariantsOption = { + flagKeys?: string[]; +}; + +export type RevertVariantsOptions = { + flagKeys?: string[]; +}; diff --git a/packages/experiment-tag/src/util.ts b/packages/experiment-tag/src/util.ts index ec7ac8e..21154fa 100644 --- a/packages/experiment-tag/src/util.ts +++ b/packages/experiment-tag/src/util.ts @@ -1,4 +1,5 @@ -import { getGlobalScope } from '@amplitude/experiment-core'; +import { EvaluationVariant, getGlobalScope } from '@amplitude/experiment-core'; +import { Variant } from '@amplitude/experiment-js-client'; export const getUrlParams = (): Record => { const globalScope = getGlobalScope(); @@ -87,3 +88,27 @@ export const concatenateQueryParamsOf = ( return resultUrlObj.toString(); }; + +export const convertEvaluationVariantToVariant = ( + evaluationVariant: EvaluationVariant, +): Variant => { + if (!evaluationVariant) { + return {}; + } + let experimentKey: string | undefined = undefined; + if (evaluationVariant.metadata) { + if (typeof evaluationVariant.metadata['experimentKey'] === 'string') { + experimentKey = evaluationVariant.metadata['experimentKey']; + } else { + experimentKey = undefined; + } + } + const variant: Variant = {}; + if (evaluationVariant.key) variant.key = evaluationVariant.key; + if (evaluationVariant.value) + variant.value = evaluationVariant.value as string; + if (evaluationVariant.payload) variant.payload = evaluationVariant.payload; + if (experimentKey) variant.expKey = experimentKey; + if (evaluationVariant.metadata) variant.metadata = evaluationVariant.metadata; + return variant; +}; diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 71fec36..8607efb 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1,10 +1,12 @@ import * as experimentCore from '@amplitude/experiment-core'; -import * as coreUtil from '@amplitude/experiment-core'; import { safeGlobal } from '@amplitude/experiment-core'; import { ExperimentClient } from '@amplitude/experiment-js-client'; import { Base64 } from 'js-base64'; -import { initializeExperiment } from 'src/experiment'; -import * as experiment from 'src/experiment'; +import { + PAGE_IS_EXCLUDED_SEGMENT_NAME, + PAGE_NOT_TARGETED_SEGMENT_NAME, + WebExperiment, +} from 'src/experiment'; import * as util from 'src/util'; import { stringify } from 'ts-jest'; @@ -25,7 +27,9 @@ describe('initializeExperiment', () => { const mockGetGlobalScope = jest.spyOn(experimentCore, 'getGlobalScope'); jest.spyOn(ExperimentClient.prototype, 'setUser'); jest.spyOn(ExperimentClient.prototype, 'all'); - jest.spyOn(experiment, 'setUrlChangeListener').mockReturnValue(undefined); + jest + .spyOn(WebExperiment.prototype, 'setUrlChangeListener') + .mockReturnValue(undefined); const mockExposure = jest.spyOn(ExperimentClient.prototype, 'exposure'); jest.spyOn(util, 'UUID').mockReturnValue('mock'); let mockGlobal; @@ -53,12 +57,16 @@ describe('initializeExperiment', () => { }); test('should initialize experiment with empty user', () => { - initializeExperiment(stringify(apiKey), JSON.stringify([])); + const webExperiment = new WebExperiment( + stringify(apiKey), + JSON.stringify([]), + ); + webExperiment.start(); expect(ExperimentClient.prototype.setUser).toHaveBeenCalledWith({ web_exp_id: 'mock', }); expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( - 'EXP_1', + 'EXP_' + stringify(apiKey), JSON.stringify({ web_exp_id: 'mock' }), ); }); @@ -67,17 +75,22 @@ describe('initializeExperiment', () => { jest .spyOn(experimentCore, 'isLocalStorageAvailable') .mockReturnValue(false); - initializeExperiment(stringify(apiKey), ''); + const webExperiment = new WebExperiment( + stringify(apiKey), + JSON.stringify([]), + ); + webExperiment.start(); expect(mockGlobal.localStorage.getItem).toHaveBeenCalledTimes(0); }); test('treatment variant on control page - should redirect and call exposure', () => { - initializeExperiment( + const webExperiment = new WebExperiment( stringify(apiKey), JSON.stringify([ createRedirectFlag('test', 'treatment', 'http://test.com/2'), ]), ); + webExperiment.start(); expect(mockGlobal.location.replace).toHaveBeenCalledWith( 'http://test.com/2', @@ -86,13 +99,13 @@ describe('initializeExperiment', () => { }); test('control variant on control page - should not redirect but call exposure', () => { - initializeExperiment( + const webExperiment = new WebExperiment( stringify(apiKey), JSON.stringify([ createRedirectFlag('test', 'control', 'http://test.com/2'), ]), ); - + webExperiment.start(); expect(mockGlobal.location.replace).toBeCalledTimes(0); expect(mockExposure).toHaveBeenCalledWith('test'); expect(mockGlobal.history.replaceState).toBeCalledTimes(0); @@ -117,13 +130,13 @@ describe('initializeExperiment', () => { // @ts-ignore mockGetGlobalScope.mockReturnValue(mockGlobal); - initializeExperiment( + const webExperiment = new WebExperiment( stringify(apiKey), JSON.stringify([ createRedirectFlag('test', 'treatment', 'http://test.com/2'), ]), ); - + webExperiment.start(); expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); expect(mockGlobal.history.replaceState).toHaveBeenCalledWith( {}, @@ -151,12 +164,13 @@ describe('initializeExperiment', () => { // @ts-ignore mockGetGlobalScope.mockReturnValue(mockGlobal); - initializeExperiment( + const webExperiment = new WebExperiment( stringify(apiKey), JSON.stringify([ createRedirectFlag('test', 'treatment', 'http://test.com/2'), ]), ); + webExperiment.start(); expect(mockGlobal.location.replace).toHaveBeenCalledWith( 'http://test.com/2', @@ -194,14 +208,14 @@ describe('initializeExperiment', () => { ], ], metadata: { - segmentName: 'Page not targeted', + segmentName: PAGE_NOT_TARGETED_SEGMENT_NAME, trackExposure: false, }, variant: 'off', }, ]; - initializeExperiment( + const webExperiment = new WebExperiment( stringify(apiKey), JSON.stringify([ createRedirectFlag( @@ -213,6 +227,7 @@ describe('initializeExperiment', () => { ), ]), ); + webExperiment.start(); expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); expect(mockExposure).toHaveBeenCalledTimes(0); @@ -241,7 +256,7 @@ describe('initializeExperiment', () => { // @ts-ignore mockGetGlobalScope.mockReturnValue(mockGlobal); - initializeExperiment( + const webExperiment = new WebExperiment( stringify(apiKey), JSON.stringify([ createRedirectFlag( @@ -252,6 +267,7 @@ describe('initializeExperiment', () => { ), ]), ); + webExperiment.start(); expect(mockGlobal.location.replace).toHaveBeenCalledWith( 'http://test.com/2?param3=c¶m1=a¶m2=b', @@ -260,12 +276,13 @@ describe('initializeExperiment', () => { }); test('should behave as control variant when payload is empty', () => { - initializeExperiment( + const webExperiment = new WebExperiment( stringify(apiKey), JSON.stringify([ createRedirectFlag('test', 'control', 'http://test.com/2?param3=c'), ]), ); + webExperiment.start(); expect(mockGlobal.location.replace).not.toHaveBeenCalled(); expect(mockExposure).toHaveBeenCalledWith('test'); @@ -291,13 +308,13 @@ describe('initializeExperiment', () => { ], ], metadata: { - segmentName: 'Page not targeted', + segmentName: PAGE_NOT_TARGETED_SEGMENT_NAME, trackExposure: false, }, variant: 'off', }, ]; - initializeExperiment( + const webExperiment = new WebExperiment( stringify(apiKey), JSON.stringify([ createRedirectFlag( @@ -309,6 +326,7 @@ describe('initializeExperiment', () => { ), ]), ); + webExperiment.start(); expect(mockExposure).toHaveBeenCalledWith('test'); }); @@ -331,13 +349,13 @@ describe('initializeExperiment', () => { ], ], metadata: { - segmentName: 'Page is excluded', + segmentName: PAGE_IS_EXCLUDED_SEGMENT_NAME, trackExposure: false, }, variant: 'off', }, ]; - initializeExperiment( + const webExperiment = new WebExperiment( stringify(apiKey), JSON.stringify([ createRedirectFlag( @@ -349,6 +367,7 @@ describe('initializeExperiment', () => { ), ]), ); + webExperiment.start(); expect(mockExposure).not.toHaveBeenCalled(); }); @@ -363,9 +382,14 @@ describe('initializeExperiment', () => { const mockHttpClient = new MockHttpClient(JSON.stringify([])); - initializeExperiment(stringify(apiKey), JSON.stringify(initialFlags), { - httpClient: mockHttpClient, - }).then(() => { + const webExperiment = new WebExperiment( + stringify(apiKey), + JSON.stringify(initialFlags), + { + httpClient: mockHttpClient, + }, + ); + webExperiment.start().then(() => { expect(mockHttpClient.requestUrl).toBe( 'https://flag.lab.amplitude.com/sdk/v2/flags?delivery_method=web', ); @@ -387,9 +411,14 @@ describe('initializeExperiment', () => { const mockHttpClient = new MockHttpClient(JSON.stringify(remoteFlags)); - initializeExperiment(stringify(apiKey), JSON.stringify(initialFlags), { - httpClient: mockHttpClient, - }).then(() => { + const webExperiment = new WebExperiment( + stringify(apiKey), + JSON.stringify(initialFlags), + { + httpClient: mockHttpClient, + }, + ); + webExperiment.start().then(() => { // check remote flag variant actions called after successful fetch expect(mockExposure).toHaveBeenCalledTimes(2); expect(mockExposure).toHaveBeenCalledWith('test-2'); @@ -410,9 +439,14 @@ describe('initializeExperiment', () => { const mockHttpClient = new MockHttpClient(JSON.stringify(remoteFlags), 404); - initializeExperiment(stringify(apiKey), JSON.stringify(initialFlags), { - httpClient: mockHttpClient, - }).then(() => { + const webExperiment = new WebExperiment( + stringify(apiKey), + JSON.stringify(initialFlags), + { + httpClient: mockHttpClient, + }, + ); + webExperiment.start().then(() => { // check remote fetch failed safely expect(mockExposure).toHaveBeenCalledTimes(2); }); @@ -429,9 +463,14 @@ describe('initializeExperiment', () => { const mockHttpClient = new MockHttpClient('', 404); - initializeExperiment(stringify(apiKey), JSON.stringify(initialFlags), { - httpClient: mockHttpClient, - }).then(() => { + const webExperiment = new WebExperiment( + stringify(apiKey), + JSON.stringify(initialFlags), + { + httpClient: mockHttpClient, + }, + ); + webExperiment.start().then(() => { // check remote variant actions applied expect(mockExposure).toHaveBeenCalledTimes(1); expect(mockExposure).toHaveBeenCalledWith('test'); @@ -467,9 +506,14 @@ describe('initializeExperiment', () => { ExperimentClient.prototype as any, 'doFlags', ); - initializeExperiment(stringify(apiKey), JSON.stringify(initialFlags), { - httpClient: mockHttpClient, - }).then(() => { + const webExperiment = new WebExperiment( + stringify(apiKey), + JSON.stringify(initialFlags), + { + httpClient: mockHttpClient, + }, + ); + webExperiment.start().then(() => { // check remote fetch not called expect(doFlagsMock).toHaveBeenCalledTimes(0); }); @@ -492,13 +536,14 @@ describe('initializeExperiment', () => { ]; const mockHttpClient = new MockHttpClient(JSON.stringify(remoteFlags), 200); - await initializeExperiment( + const webExperiment = new WebExperiment( stringify(apiKey), JSON.stringify(initialFlags), { httpClient: mockHttpClient, }, ); + await webExperiment.start(); // check treatment variant called expect(mockExposure).toHaveBeenCalledTimes(1); expect(mockExposure).toHaveBeenCalledWith('test'); @@ -511,3 +556,149 @@ describe('initializeExperiment', () => { test('feature experiment on global Experiment object', () => { expect(safeGlobal.Experiment).toBeDefined(); }); + +describe('helper methods', () => { + beforeEach(() => { + const mockGetGlobalScope = jest.spyOn(experimentCore, 'getGlobalScope'); + const mockGlobal = { + localStorage: { + getItem: jest.fn().mockReturnValue(undefined), + setItem: jest.fn(), + }, + location: { + href: 'http://test.com', + replace: jest.fn(), + search: '', + }, + document: { referrer: '' }, + history: { replaceState: jest.fn() }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockGetGlobalScope.mockReturnValue(mockGlobal); + apiKey++; + jest.spyOn(util, 'UUID').mockReturnValue('mock'); + jest.clearAllMocks(); + }); + + const originalLocation = global.location; + + afterEach(() => { + Object.defineProperty(global, 'location', { + value: originalLocation, + writable: true, + }); + jest.restoreAllMocks(); + }); + test('get redirect urls', () => { + const initialFlags = [ + // remote flag + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + 'http://test.com', + ), + ]; + const webExperiment = new WebExperiment( + stringify(apiKey), + JSON.stringify(initialFlags), + ); + const redirectUrls = webExperiment.getRedirectUrls(); + expect(redirectUrls).toEqual({ + test: { control: 'http://test.com', treatment: 'http://test.com/2' }, + }); + }); + + test('get active experiments on current page', () => { + Object.defineProperty(global, 'location', { + value: { + href: 'http://test.com', + }, + writable: true, + }); + jest.spyOn(experimentCore, 'getGlobalScope'); + const targetedSegment = [ + { + conditions: [ + [ + { + op: 'regex does not match', + selector: ['context', 'page', 'url'], + values: ['^http:\\/\\/test.*'], + }, + ], + ], + metadata: { + segmentName: PAGE_NOT_TARGETED_SEGMENT_NAME, + trackExposure: false, + }, + variant: 'off', + }, + ]; + const nonTargetedSegment = [ + { + conditions: [ + [ + { + op: 'regex match', + selector: ['context', 'page', 'url'], + values: ['.*test\\.com$'], + }, + ], + ], + metadata: { + segmentName: PAGE_IS_EXCLUDED_SEGMENT_NAME, + trackExposure: false, + }, + variant: 'off', + }, + ]; + const webExperiment = new WebExperiment( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'targeted', + 'treatment', + '', + undefined, + targetedSegment, + ), + createRedirectFlag( + 'non-targeted', + 'treatment', + '', + undefined, + nonTargetedSegment, + ), + ]), + ); + let activeExperiments = webExperiment.getActiveExperimentsOnPage(); + expect(activeExperiments).toEqual(['targeted']); + activeExperiments = webExperiment.getActiveExperimentsOnPage( + 'http://override.com', + ); + expect(activeExperiments).toEqual(['non-targeted']); + }); + + test('get variants', () => { + const targetedSegment = [ + { + metadata: { + segmentName: 'match segment', + }, + variant: 'treatment', + }, + ]; + const webExperiment = new WebExperiment( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag('flag-1', 'control', '', undefined, targetedSegment), + createRedirectFlag('flag-2', 'control', '', undefined), + ]), + ); + const variants = webExperiment.getVariants(); + expect(variants['flag-1'].key).toEqual('treatment'); + expect(variants['flag-2'].key).toEqual('control'); + }); +});