diff --git a/packages/experiment-browser/src/experimentClient.ts b/packages/experiment-browser/src/experimentClient.ts index 9daacf87..880c4781 100644 --- a/packages/experiment-browser/src/experimentClient.ts +++ b/packages/experiment-browser/src/experimentClient.ts @@ -710,10 +710,18 @@ export class ExperimentClient implements Client { private async doFlags(): Promise { try { + const isWebExperiment = + this.config?.['internalInstanceNameSuffix'] === 'web'; + const user = this.addContext(this.getUser()); const flags = await this.flagApi.getFlags({ libraryName: 'experiment-js-client', libraryVersion: PACKAGE_VERSION, timeoutMillis: this.config.fetchTimeoutMillis, + deliveryMethod: isWebExperiment ? 'web' : undefined, + user: + isWebExperiment && (user?.user_id || user?.device_id) + ? { user_id: user?.user_id, device_id: user?.device_id } + : undefined, }); this.flags.clear(); this.flags.putAll(flags); diff --git a/packages/experiment-core/src/api/flag-api.ts b/packages/experiment-core/src/api/flag-api.ts index 23149fa6..49392ba3 100644 --- a/packages/experiment-core/src/api/flag-api.ts +++ b/packages/experiment-core/src/api/flag-api.ts @@ -1,3 +1,5 @@ +import { Base64 } from 'js-base64'; + import { EvaluationFlag } from '../evaluation/flag'; import { HttpClient } from '../transport/http'; @@ -6,7 +8,10 @@ export type GetFlagsOptions = { libraryVersion: string; evaluationMode?: string; timeoutMillis?: number; + user?: Record; + deliveryMethod?: string | undefined; }; + export interface FlagApi { getFlags(options?: GetFlagsOptions): Promise>; } @@ -25,6 +30,7 @@ export class SdkFlagApi implements FlagApi { this.serverUrl = serverUrl; this.httpClient = httpClient; } + public async getFlags( options?: GetFlagsOptions, ): Promise> { @@ -36,8 +42,17 @@ export class SdkFlagApi implements FlagApi { 'X-Amp-Exp-Library' ] = `${options.libraryName}/${options.libraryVersion}`; } + if (options?.user) { + headers['X-Amp-Exp-User'] = Base64.encodeURL( + JSON.stringify(options.user), + ); + } const response = await this.httpClient.request({ - requestUrl: `${this.serverUrl}/sdk/v2/flags`, + requestUrl: + `${this.serverUrl}/sdk/v2/flags` + + (options?.deliveryMethod + ? `?delivery_method=${options.deliveryMethod}` + : ''), method: 'GET', headers: headers, timeoutMillis: options?.timeoutMillis, diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index e8c3caa6..52254876 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -8,10 +8,10 @@ import { import { safeGlobal } from '@amplitude/experiment-core'; import { Experiment, - ExperimentUser, Variant, Variants, AmplitudeIntegrationPlugin, + ExperimentConfig, } from '@amplitude/experiment-js-client'; import * as FeatureExperiment from '@amplitude/experiment-js-client'; import mutate, { MutationController } from 'dom-mutator'; @@ -34,7 +34,11 @@ 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 = (apiKey: string, initialFlags: string) => { +export const initializeExperiment = async ( + apiKey: string, + initialFlags: string, + config: ExperimentConfig = {}, +) => { const globalScope = getGlobalScope(); if (globalScope?.webExperiment) { return; @@ -46,7 +50,7 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => { previousUrl = undefined; urlExposureCache = {}; const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`; - let user: ExperimentUser; + let user; try { user = JSON.parse( globalScope.localStorage.getItem(experimentStorageName) || '{}', @@ -55,14 +59,22 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => { 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 or web_exp_id + if (Object.keys(user).length === 0 || !user.device_id || !user.web_exp_id) { + if (!user.device_id || !user.web_exp_id) { + // if user has device_id, migrate it to web_exp_id + if (user.device_id) { + user.web_exp_id = user.device_id; + } else { + const uuid = UUID(); + // both IDs are set for backwards compatibility, to be removed in future update + user = { device_id: uuid, web_exp_id: uuid }; + } + globalScope.localStorage.setItem( + experimentStorageName, + JSON.stringify(user), + ); + } } const urlParams = getUrlParams(); @@ -75,41 +87,78 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => { ); 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]), - ); - - // Keep page targeting segments - const pageTargetingSegments = flag.segments.filter((segment) => - isPageTargetingSegment(segment), - ); - - // Create or update the preview segment - const previewSegment = { - metadata: { segmentName: 'preview' }, - variant: urlParams[flag.key], - }; - - flag.segments = [...pageTargetingSegments, previewSegment]; + + let isRemoteBlocking = false; + const remoteFlagKeys: Set = new Set(); + const localFlagKeys: Set = new Set(); + const parsedFlags = JSON.parse(initialFlags); + + parsedFlags.forEach((flag: EvaluationFlag) => { + const { key, variants, segments, metadata = {} } = flag; + + // 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( + {}, + '', + removeQueryParams(globalScope.location.href, ['PREVIEW', key]), + ); + + // Retain only page-targeting segments + const pageTargetingSegments = segments.filter(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'; + } + + if (metadata.evaluationMode !== 'local') { + remoteFlagKeys.add(key); + + // allow local evaluation for remote flags + metadata.evaluationMode = 'local'; + + // Check if any remote flags are blocking + if (!isRemoteBlocking && metadata.blockingEvaluation) { + isRemoteBlocking = true; + + // Apply anti-flicker CSS to prevent UI flicker + applyAntiFlickerCss(); } - }); - initialFlags = JSON.stringify(parsedFlags); - } + } else { + // Add locally evaluable flags to the local flag set + localFlagKeys.add(key); + } + flag.metadata = metadata; + }); + + initialFlags = JSON.stringify(parsedFlags); + + // initialize the experiment globalScope.webExperiment = Experiment.initialize(apiKey, { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore internalInstanceNameSuffix: 'web', - fetchOnStart: false, initialFlags: initialFlags, + // timeout for fetching remote flags + fetchTimeoutMillis: 1000, + pollOnStart: false, + fetchOnStart: false, + ...config, }); // If no integration has been set, use an amplitude integration. @@ -125,14 +174,34 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => { globalScope.webExperiment.addPlugin(globalScope.experimentIntegration); globalScope.webExperiment.setUser(user); - const variants = globalScope.webExperiment.all(); + setUrlChangeListener(new Set([...localFlagKeys, ...remoteFlagKeys])); + + // apply local variants + applyVariants(globalScope.webExperiment.all(), localFlagKeys); + + if (!isRemoteBlocking) { + // Remove anti-flicker css if remote flags are not blocking + globalScope.document.getElementById?.('amp-exp-css')?.remove(); + } + + if (remoteFlagKeys.size === 0) { + return; + } - setUrlChangeListener(); - applyVariants(variants); + try { + await globalScope.webExperiment.doFlags(); + } catch (error) { + console.warn('Error fetching remote flags:', error); + } + // apply remote variants - if fetch is unsuccessful, fallback order: 1. localStorage flags, 2. initial flags + applyVariants(globalScope.webExperiment.all(), remoteFlagKeys); }; -const applyVariants = (variants: Variants | undefined) => { - if (!variants) { +const applyVariants = ( + variants: Variants, + flagKeys: Set | undefined = undefined, +) => { + if (Object.keys(variants).length === 0) { return; } const globalScope = getGlobalScope(); @@ -146,6 +215,9 @@ const applyVariants = (variants: Variants | undefined) => { urlExposureCache[currentUrl] = {}; } for (const key in variants) { + if (flagKeys && !flagKeys.has(key)) { + continue; + } const variant = variants[key]; const isWebExperimentation = variant.metadata?.deliveryMethod === 'web'; if (isWebExperimentation) { @@ -296,7 +368,7 @@ const handleInject = (action, key: string, variant: Variant) => { exposureWithDedupe(key, variant); }; -export const setUrlChangeListener = () => { +export const setUrlChangeListener = (flagKeys: Set) => { const globalScope = getGlobalScope(); if (!globalScope) { return; @@ -304,7 +376,7 @@ export const setUrlChangeListener = () => { // Add URL change listener for back/forward navigation globalScope.addEventListener('popstate', () => { revertMutations(); - applyVariants(globalScope.webExperiment.all()); + applyVariants(globalScope.webExperiment.all(), flagKeys); }); // Create wrapper functions for pushState and replaceState @@ -318,7 +390,7 @@ export const setUrlChangeListener = () => { const result = originalPushState.apply(this, args); // Revert mutations and apply variants revertMutations(); - applyVariants(globalScope.webExperiment.all()); + applyVariants(globalScope.webExperiment.all(), flagKeys); previousUrl = globalScope.location.href; return result; }; @@ -329,7 +401,7 @@ export const setUrlChangeListener = () => { const result = originalReplaceState.apply(this, args); // Revert mutations and apply variants revertMutations(); - applyVariants(globalScope.webExperiment.all()); + applyVariants(globalScope.webExperiment.all(), flagKeys); previousUrl = globalScope.location.href; return result; }; @@ -364,3 +436,19 @@ const exposureWithDedupe = (key: string, variant: Variant) => { 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); + } +}; diff --git a/packages/experiment-tag/src/script.ts b/packages/experiment-tag/src/script.ts index d83d8453..68adbad7 100644 --- a/packages/experiment-tag/src/script.ts +++ b/packages/experiment-tag/src/script.ts @@ -2,6 +2,7 @@ import { initializeExperiment } from './experiment'; const API_KEY = '{{DEPLOYMENT_KEY}}'; const initialFlags = '{{INITIAL_FLAGS}}'; -initializeExperiment(API_KEY, initialFlags); -// Remove anti-flicker css if it exists -document.getElementById('amp-exp-css')?.remove(); +initializeExperiment(API_KEY, initialFlags).then(() => { + // Remove anti-flicker css if it exists + document.getElementById('amp-exp-css')?.remove(); +}); diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 9dca9c6d..4838e524 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1,9 +1,17 @@ +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 * as util from 'src/util'; +import { stringify } from 'ts-jest'; + +import { createMutateFlag, createRedirectFlag } from './util/create-flag'; +import { MockHttpClient } from './util/mock-http-client'; + +let apiKey = 0; jest.mock('src/messenger', () => { return { @@ -13,19 +21,19 @@ jest.mock('src/messenger', () => { }; }); -jest.spyOn(experiment, 'setUrlChangeListener').mockReturnValue(undefined); - describe('initializeExperiment', () => { - const mockGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); + const mockGetGlobalScope = jest.spyOn(experimentCore, 'getGlobalScope'); jest.spyOn(ExperimentClient.prototype, 'setUser'); jest.spyOn(ExperimentClient.prototype, 'all'); + jest.spyOn(experiment, 'setUrlChangeListener').mockReturnValue(undefined); const mockExposure = jest.spyOn(ExperimentClient.prototype, 'exposure'); jest.spyOn(util, 'UUID').mockReturnValue('mock'); let mockGlobal; beforeEach(() => { - jest.spyOn(coreUtil, 'isLocalStorageAvailable').mockReturnValue(true); + apiKey++; jest.clearAllMocks(); + jest.spyOn(experimentCore, 'isLocalStorageAvailable').mockReturnValue(true); mockGlobal = { localStorage: { getItem: jest.fn().mockReturnValue(undefined), @@ -45,134 +53,30 @@ describe('initializeExperiment', () => { }); test('should initialize experiment with empty user', () => { - initializeExperiment( - '1', - JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - experimentKey: 'exp-1', - flagType: 'experiment', - flagVersion: 20, - urlMatch: ['http://test.com'], - deliveryMethod: 'web', - }, - segments: [ - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'treatment', - }, - ], - 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', - }, - }, - }, - ]), - ); + initializeExperiment(stringify(apiKey), JSON.stringify([])); expect(ExperimentClient.prototype.setUser).toHaveBeenCalledWith({ device_id: 'mock', + web_exp_id: 'mock', }); expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( 'EXP_1', - JSON.stringify({ device_id: 'mock' }), + JSON.stringify({ device_id: 'mock', web_exp_id: 'mock' }), ); }); test('experiment should not run without localStorage', () => { - jest.spyOn(coreUtil, 'isLocalStorageAvailable').mockReturnValue(false); - initializeExperiment('2', ''); + jest + .spyOn(experimentCore, 'isLocalStorageAvailable') + .mockReturnValue(false); + initializeExperiment(stringify(apiKey), ''); expect(mockGlobal.localStorage.getItem).toHaveBeenCalledTimes(0); }); test('treatment variant on control page - should redirect and call exposure', () => { initializeExperiment( - '3', + stringify(apiKey), JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - experimentKey: 'exp-1', - flagType: 'experiment', - flagVersion: 20, - urlMatch: ['http://test.com'], - deliveryMethod: 'web', - }, - segments: [ - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'treatment', - }, - ], - 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', - }, - }, - }, + createRedirectFlag('test', 'treatment', 'http://test.com/2'), ]), ); @@ -184,47 +88,9 @@ describe('initializeExperiment', () => { test('control variant on control page - should not redirect but call exposure', () => { initializeExperiment( - '4', + stringify(apiKey), JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - experimentKey: 'exp-1', - flagType: 'experiment', - flagVersion: 20, - urlMatch: ['http://test.com'], - deliveryMethod: 'web', - }, - segments: [ - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'control', - }, - ], - variants: { - control: { - key: 'control', - payload: [], - value: 'control', - }, - treatment: { - key: 'treatment', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com/2', - }, - }, - ], - value: 'treatment', - }, - }, - }, + createRedirectFlag('test', 'control', 'http://test.com/2'), ]), ); @@ -253,52 +119,9 @@ describe('initializeExperiment', () => { mockGetGlobalScope.mockReturnValue(mockGlobal); initializeExperiment( - '5', + stringify(apiKey), JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - experimentKey: 'exp-1', - flagType: 'experiment', - flagVersion: 20, - deliveryMethod: 'web', - }, - segments: [ - { - metadata: { - segmentName: 'All Other Users', - }, - 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', - }, - }, - }, + createRedirectFlag('test', 'treatment', 'http://test.com/2'), ]), ); @@ -330,60 +153,9 @@ describe('initializeExperiment', () => { mockGetGlobalScope.mockReturnValue(mockGlobal); initializeExperiment( - '6', + stringify(apiKey), JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - experimentKey: 'exp-1', - flagType: 'experiment', - flagVersion: 20, - urlMatch: ['http://test.com'], - deliveryMethod: 'web', - }, - segments: [ - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'control', - }, - ], - variants: { - control: { - key: 'treatment', - 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', - }, - }, - }, + createRedirectFlag('test', 'treatment', 'http://test.com/2'), ]), ); @@ -411,69 +183,35 @@ describe('initializeExperiment', () => { // @ts-ignore mockGetGlobalScope.mockReturnValue(mockGlobal); - initializeExperiment( - '7', - JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - experimentKey: 'exp-1', - flagType: 'experiment', - flagVersion: 1, - deliveryMethod: 'web', - }, - segments: [ + const pageTargetingSegments = [ + { + conditions: [ + [ { - 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: 'treatment', + op: 'regex does not match', + selector: ['context', 'page', 'url'], + values: ['^http:\\/\\/test.com/$'], }, ], - 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', - }, - }, + ], + metadata: { + segmentName: 'Page not targeted', + trackExposure: false, }, + variant: 'off', + }, + ]; + + initializeExperiment( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + pageTargetingSegments, + ), ]), ); @@ -505,60 +243,14 @@ describe('initializeExperiment', () => { mockGetGlobalScope.mockReturnValue(mockGlobal); initializeExperiment( - '8', + stringify(apiKey), JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - experimentKey: 'exp-1', - flagType: 'experiment', - flagVersion: 20, - urlMatch: ['http://test.com'], - deliveryMethod: 'web', - }, - segments: [ - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'treatment', - }, - ], - 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?param3=c', - }, - }, - ], - value: 'treatment', - }, - }, - }, + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2?param3=c', + 'http://test.com/', + ), ]), ); @@ -570,46 +262,9 @@ describe('initializeExperiment', () => { test('should behave as control variant when payload is empty', () => { initializeExperiment( - '9', + stringify(apiKey), JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - experimentKey: 'exp-1', - flagType: 'experiment', - flagVersion: 20, - deliveryMethod: 'web', - }, - segments: [ - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'control', - }, - ], - variants: { - control: { - key: 'control', - payload: [], - value: 'control', - }, - treatment: { - key: 'treatment', - payload: [ - { - action: 'redirect', - data: { - url: 'http://test.com/2', - }, - }, - ], - value: 'treatment', - }, - }, - }, + createRedirectFlag('test', 'control', 'http://test.com/2?param3=c'), ]), ); @@ -624,54 +279,35 @@ describe('initializeExperiment', () => { }, writable: true, }); - jest.spyOn(coreUtil, 'getGlobalScope'); - initializeExperiment( - '10', - JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - flagType: 'experiment', - deliveryMethod: 'web', - }, - segments: [ + jest.spyOn(experimentCore, 'getGlobalScope'); + const pageTargetingSegments = [ + { + conditions: [ + [ { - 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', - }, - variant: 'treatment', + op: 'regex does not match', + selector: ['context', 'page', 'url'], + values: ['^http:\\/\\/test.*'], }, ], - variants: { - off: { - key: 'off', - metadata: { - default: true, - }, - }, - treatment: { - key: 'treatment', - }, - }, + ], + metadata: { + segmentName: 'Page not targeted', + trackExposure: false, }, + variant: 'off', + }, + ]; + initializeExperiment( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + pageTargetingSegments, + ), ]), ); expect(mockExposure).toHaveBeenCalledWith('test'); @@ -684,58 +320,193 @@ describe('initializeExperiment', () => { }, writable: true, }); - initializeExperiment( - '11', - JSON.stringify([ - { - key: 'test', - metadata: { - deployed: true, - evaluationMode: 'local', - flagType: 'experiment', - deliveryMethod: 'web', - }, - segments: [ + const pageTargetingSegments = [ + { + conditions: [ + [ { - 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: 'treatment', + op: 'regex match', + selector: ['context', 'page', 'url'], + values: ['.*test\\.com$'], }, ], - variants: { - off: { - key: 'off', - metadata: { - default: true, - }, - }, - treatment: { - key: 'treatment', - value: 'treatment', - }, - }, + ], + metadata: { + segmentName: 'Page is excluded', + trackExposure: false, }, + variant: 'off', + }, + ]; + initializeExperiment( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + pageTargetingSegments, + ), ]), ); expect(mockExposure).not.toHaveBeenCalled(); }); + + test('remote evaluation - request web remote flags', () => { + const mockUser = { user_id: 'user_id', device_id: 'device_id' }; + jest.spyOn(ExperimentClient.prototype, 'getUser').mockReturnValue(mockUser); + + const initialFlags = [ + // remote flag + createMutateFlag('test-2', 'treatment', [], [], [], 'remote'), + ]; + + const mockHttpClient = new MockHttpClient(JSON.stringify([])); + + initializeExperiment(stringify(apiKey), JSON.stringify(initialFlags), { + httpClient: mockHttpClient, + }).then(() => { + expect(mockHttpClient.requestUrl).toBe( + 'https://flag.lab.amplitude.com/sdk/v2/flags?delivery_method=web', + ); + // check flag fetch called with correct query param and header + expect(mockHttpClient.requestHeader['X-Amp-Exp-User']).toBe( + Base64.encodeURL(JSON.stringify(mockUser)), + ); + }); + }); + + test('remote evaluation - fetch successful', () => { + const initialFlags = [ + // remote flag + createMutateFlag('test-2', 'treatment', [], [], [], 'remote'), + // local flag + createMutateFlag('test-1', 'treatment'), + ]; + const remoteFlags = [createMutateFlag('test-2', 'treatment')]; + + const mockHttpClient = new MockHttpClient(JSON.stringify(remoteFlags)); + + initializeExperiment(stringify(apiKey), JSON.stringify(initialFlags), { + httpClient: mockHttpClient, + }).then(() => { + // check remote flag variant actions called after successful fetch + expect(mockExposure).toHaveBeenCalledTimes(2); + expect(mockExposure).toHaveBeenCalledWith('test-2'); + }); + // check local flag variant actions called + expect(mockExposure).toHaveBeenCalledTimes(1); + expect(mockExposure).toHaveBeenCalledWith('test-1'); + }); + + test('remote evaluation - fetch fail, locally evaluate remote and local flags success', () => { + const initialFlags = [ + // remote flag + createMutateFlag('test-2', 'treatment', [], [], [], 'remote'), + // local flag + createMutateFlag('test-1', 'treatment'), + ]; + const remoteFlags = [createMutateFlag('test-2', 'treatment')]; + + const mockHttpClient = new MockHttpClient(JSON.stringify(remoteFlags), 404); + + initializeExperiment(stringify(apiKey), JSON.stringify(initialFlags), { + httpClient: mockHttpClient, + }).then(() => { + // check remote fetch failed safely + expect(mockExposure).toHaveBeenCalledTimes(2); + }); + // check local flag variant actions called + expect(mockExposure).toHaveBeenCalledTimes(1); + expect(mockExposure).toHaveBeenCalledWith('test-1'); + }); + + test('remote evaluation - fetch fail, test initialFlags variant actions called', () => { + const initialFlags = [ + // remote flag + createMutateFlag('test', 'treatment', [], [], [], 'remote'), + ]; + + const mockHttpClient = new MockHttpClient('', 404); + + initializeExperiment(stringify(apiKey), JSON.stringify(initialFlags), { + httpClient: mockHttpClient, + }).then(() => { + // check remote variant actions applied + expect(mockExposure).toHaveBeenCalledTimes(1); + expect(mockExposure).toHaveBeenCalledWith('test'); + }); + // check local flag variant actions called + expect(mockExposure).toHaveBeenCalledTimes(0); + }); + + test('remote evaluation - test preview successful, does not fetch remote flags', () => { + const mockGlobal = { + localStorage: { + getItem: jest.fn().mockReturnValue(undefined), + setItem: jest.fn(), + }, + location: { + href: 'http://test.com/', + replace: jest.fn(), + search: '?test=treatment&PREVIEW=true', + }, + document: { referrer: '' }, + history: { replaceState: jest.fn() }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockGetGlobalScope.mockReturnValue(mockGlobal); + const initialFlags = [ + // remote flag + createMutateFlag('test', 'treatment', [], [], [], 'remote'), + ]; + const remoteFlags = [createMutateFlag('test', 'treatment')]; + const mockHttpClient = new MockHttpClient(JSON.stringify(remoteFlags), 200); + const doFlagsMock = jest.spyOn( + ExperimentClient.prototype as any, + 'doFlags', + ); + initializeExperiment(stringify(apiKey), JSON.stringify(initialFlags), { + httpClient: mockHttpClient, + }).then(() => { + // check remote fetch not called + expect(doFlagsMock).toHaveBeenCalledTimes(0); + }); + }); + + test('remote evaluation - fetch successful, fetched flag overwrites initial flag', async () => { + const initialFlags = [ + // remote flag + createRedirectFlag( + 'test', + 'control', + 'http://test.com/2', + undefined, + [], + 'remote', + ), + ]; + const remoteFlags = [ + createRedirectFlag('test', 'treatment', 'http://test.com/2'), + ]; + const mockHttpClient = new MockHttpClient(JSON.stringify(remoteFlags), 200); + + await initializeExperiment( + stringify(apiKey), + JSON.stringify(initialFlags), + { + httpClient: mockHttpClient, + }, + ); + // check treatment variant called + expect(mockExposure).toHaveBeenCalledTimes(1); + expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + }); }); test('feature experiment on global Experiment object', () => { diff --git a/packages/experiment-tag/test/util/create-flag.ts b/packages/experiment-tag/test/util/create-flag.ts new file mode 100644 index 00000000..ea782ca8 --- /dev/null +++ b/packages/experiment-tag/test/util/create-flag.ts @@ -0,0 +1,122 @@ +export const createRedirectFlag = ( + flagKey = 'test', + variant: 'treatment' | 'control' | 'off', + treatmentUrl: string, + controlUrl: string | undefined = undefined, + segments: any[] = [], + evaluationMode = 'local', +) => { + const controlPayload = controlUrl + ? [ + { + action: 'redirect', + data: { + url: controlUrl, + }, + }, + ] + : []; + return { + key: flagKey, + metadata: { + deployed: true, + evaluationMode: evaluationMode, + flagType: 'experiment', + deliveryMethod: 'web', + }, + segments: [ + ...segments, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: variant, + }, + ], + variants: { + control: { + key: 'control', + payload: controlPayload, + value: 'control', + }, + off: { + key: 'off', + metadata: { + default: true, + }, + }, + treatment: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: treatmentUrl, + }, + }, + ], + value: 'treatment', + }, + }, + }; +}; + +export const createMutateFlag = ( + flagKey = 'test', + variant: 'treatment' | 'control' | 'off', + treatmentMutations: any[] = [], + controlMutations: any[] = [], + segments: any[] = [], + evaluationMode = 'local', +) => { + return { + key: flagKey, + metadata: { + deployed: true, + evaluationMode: evaluationMode, + flagType: 'experiment', + deliveryMethod: 'web', + }, + segments: [ + ...segments, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: variant, + }, + ], + variants: { + control: { + key: 'control', + payload: [ + { + action: 'mutate', + data: { + mutations: controlMutations, + }, + }, + ], + value: 'control', + }, + off: { + key: 'off', + metadata: { + default: true, + }, + }, + treatment: { + key: 'treatment', + payload: [ + { + action: 'mutate', + data: { + mutations: treatmentMutations, + }, + }, + ], + value: 'treatment', + }, + }, + }; +}; diff --git a/packages/experiment-tag/test/util/mock-http-client.ts b/packages/experiment-tag/test/util/mock-http-client.ts new file mode 100644 index 00000000..5cd86767 --- /dev/null +++ b/packages/experiment-tag/test/util/mock-http-client.ts @@ -0,0 +1,41 @@ +// interfaces copied frm experiment-browser + +interface SimpleResponse { + status: number; + body: string; +} + +interface HttpClient { + request( + requestUrl: string, + method: string, + headers: Record, + data: string, + timeoutMillis?: number, + ): Promise; +} + +export class MockHttpClient implements HttpClient { + private response: SimpleResponse; + public requestUrl; + public requestHeader; + + constructor(responseBody: string, status = 200) { + this.response = { + status, + body: responseBody, + }; + } + + request( + requestUrl: string, + method: string, + headers: Record, + data: string, + timeoutMillis?: number, + ): Promise { + this.requestUrl = requestUrl; + this.requestHeader = headers; + return Promise.resolve(this.response); + } +}