diff --git a/packages/experiment-tag/CHANGELOG.md b/packages/experiment-tag/CHANGELOG.md index 108165aa..18f1f65f 100644 --- a/packages/experiment-tag/CHANGELOG.md +++ b/packages/experiment-tag/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.25.0](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-tag@0.24.0...@amplitude/experiment-tag@0.25.0) (2026-06-26) + + +### Features + +* **experiment-tag:** add cross-subdomain web_exp_id_v2 and first_seen cookies ([#332](https://github.com/amplitude/experiment-js-client/issues/332)) ([f9ee4e3](https://github.com/amplitude/experiment-js-client/commit/f9ee4e3b619956c5a622c0fa7010a3f532e8483c)) +* support cross-subdomain redirect impression tracking ([#238](https://github.com/amplitude/experiment-js-client/issues/238)) ([e4260a3](https://github.com/amplitude/experiment-js-client/commit/e4260a356cae6fa0001879ee03156718dbe5bfc4)) + + + + + # [0.24.0](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-tag@0.23.4...@amplitude/experiment-tag@0.24.0) (2026-06-04) diff --git a/packages/experiment-tag/package.json b/packages/experiment-tag/package.json index 58dad85c..78d117a9 100644 --- a/packages/experiment-tag/package.json +++ b/packages/experiment-tag/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/experiment-tag", - "version": "0.24.0", + "version": "0.25.0", "description": "Amplitude Experiment Javascript Snippet", "author": "Amplitude", "homepage": "https://github.com/amplitude/experiment-js-client", diff --git a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts index d709486d..f393cf51 100644 --- a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts +++ b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts @@ -2,6 +2,8 @@ import { BehavioralTargetingRules } from '../types'; import { BehavioralTargetingEvaluator } from './evaluator'; import { EventStorageManager } from './event-storage'; +import { RelayClient } from './relay-client'; +import { RelaySyncResult } from './relay-sync-result'; import { SessionManager } from './session-manager'; import { BehavioralTargeting } from './types'; @@ -51,6 +53,45 @@ export class BehavioralTargetingManager { this.evaluateEvent(eventType); } + /** + * Attach the relay client for cross-subdomain event dual-write. + */ + public setRelayClient(relayClient: RelayClient | null): void { + this.eventStorage.setRelayClient(relayClient); + } + + /** + * Inject relay iframe (non-blocking init) and run the Pass 2 relay merge. + * + * This only reads/merges relay state and reports the outcome; it never + * attaches the relay for dual-write. The caller owns relay lifecycle and + * decides whether to attach (via {@link setRelayClient}) based on the + * result and whether this client is still the active one. + */ + public async beginRelaySync( + relayClient: RelayClient, + ): Promise { + const behaviorsBefore = this.serializeMatchedBehaviors(); + + await relayClient.init(); + if (!relayClient.relayAvailable) { + await relayClient.waitForAvailable(); + } + if (!relayClient.relayAvailable) { + return { status: 'unavailable' }; + } + + const synced = await this.eventStorage.syncFromRelay(relayClient); + if (!synced) { + return { status: 'sync_failed' }; + } + + this.evaluateAll(); + return behaviorsBefore !== this.serializeMatchedBehaviors() + ? { status: 'behaviors_changed' } + : { status: 'unchanged' }; + } + /** * Check if a flag has behavioral targeting rules. * @param flagKey The flag key to check @@ -175,6 +216,14 @@ export class BehavioralTargetingManager { * @param flagKey The flag key to evaluate * @param behaviorId The specific behavior ID to evaluate */ + private serializeMatchedBehaviors(): string { + const snapshot: Record = {}; + for (const [flagKey, behaviorIds] of this.matchedBehaviors.entries()) { + snapshot[flagKey] = [...behaviorIds].sort(); + } + return JSON.stringify(snapshot); + } + private evaluateBehaviorId(flagKey: string, behaviorId: string): void { const rulesByIds = this.rules[flagKey]; if (!rulesByIds || !rulesByIds[behaviorId]) { diff --git a/packages/experiment-tag/src/behavioral-targeting/event-storage.ts b/packages/experiment-tag/src/behavioral-targeting/event-storage.ts index a03143a7..baff3383 100644 --- a/packages/experiment-tag/src/behavioral-targeting/event-storage.ts +++ b/packages/experiment-tag/src/behavioral-targeting/event-storage.ts @@ -1,3 +1,4 @@ +import { RelayClient } from './relay-client'; import { SessionManager } from './session-manager'; /** @@ -150,6 +151,21 @@ export class EventStorageManager { this.flushToLocalStorage(); } + /** + * Relay dual-write hook — implemented in the storage sync PR. + */ + setRelayClient(relayClient: RelayClient | null): void { + void relayClient; + } + + /** + * Pass 2 relay merge hook — no-op until storage sync lands. + */ + async syncFromRelay(relayClient?: RelayClient): Promise { + void relayClient; + return true; + } + /** * Gets event count for an event type. */ diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-sync-result.ts b/packages/experiment-tag/src/behavioral-targeting/relay-sync-result.ts new file mode 100644 index 00000000..b70a465b --- /dev/null +++ b/packages/experiment-tag/src/behavioral-targeting/relay-sync-result.ts @@ -0,0 +1,6 @@ +/** Outcome of non-blocking relay iframe init + Pass 2 merge. */ +export type RelaySyncResult = + | { status: 'behaviors_changed' } + | { status: 'unchanged' } + | { status: 'unavailable' } + | { status: 'sync_failed' }; diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 042e5160..b747cdc1 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -24,6 +24,7 @@ import * as domMutatorExports from 'dom-mutator'; import type { MutationController } from 'dom-mutator/dist/types'; import { BehavioralTargetingManager } from './behavioral-targeting'; +import { getRelayUrl, RelayClient } from './behavioral-targeting/relay-client'; import { showPreviewModeModal } from './preview/preview'; import { MessageBus } from './subscriptions/message-bus'; import { @@ -49,7 +50,11 @@ import { import type { AudienceEvaluationDebugInfo, DebugState } from './types/debug'; import { applyAntiFlickerCss, removeAntiFlickerCss } from './util/anti-flicker'; import { enrichUserWithCampaignData } from './util/campaign'; -import { getTopLevelDomain, setMarketingCookie } from './util/cookie'; +import { + getTopLevelDomain, + resolveCrossSubdomainValue, + setMarketingCookie, +} from './util/cookie'; import { cspSafeStyleSheet, type StyleSheetHandle, @@ -138,6 +143,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { public readonly behavioralTargetingManager: | BehavioralTargetingManager | undefined; + private relayClient: RelayClient | null = null; private subscriptionManager: SubscriptionManager | undefined; private isVisualEditorMode = false; private isDebugActive = false; @@ -391,6 +397,51 @@ export class DefaultWebExperimentClient implements WebExperimentClient { setStorageItem('localStorage', experimentStorageName, user); } + // Resolve web_exp_id_v2 and first_seen as root-domain cookies for + // cross-subdomain identity before getVariants() so anti-flicker and + // local evaluation use the shared first_seen, not a subdomain-local mint. + const rootDomain = await getTopLevelDomain( + this.globalScope.location.hostname, + ); + const crossSubdomainCookieStorage = new CookieStorage({ + ...(rootDomain && { domain: rootDomain }), + sameSite: 'Lax', + expirationDays: 365, + }); + + const webExpIdV2CookieKey = `${experimentStorageName}_web_exp_id_v2`; + // web_exp_id is guaranteed above; seed v2 from it when no cookie or local v2 exists. + user.web_exp_id_v2 = await resolveCrossSubdomainValue( + crossSubdomainCookieStorage, + webExpIdV2CookieKey, + user.web_exp_id_v2 ?? user.web_exp_id, + UUID, + ); + setStorageItem('localStorage', experimentStorageName, user); + + const defaultUserProviderStorageKey = `${experimentStorageName}_DEFAULT_USER_PROVIDER`; + const defaultUserProviderData = + getStorageItem<{ first_seen?: string }>( + 'localStorage', + defaultUserProviderStorageKey, + ) || {}; + const firstSeen = await resolveCrossSubdomainValue( + crossSubdomainCookieStorage, + `${experimentStorageName}_first_seen`, + defaultUserProviderData.first_seen, + () => (Date.now() / 1000).toString(), + ); + if (firstSeen !== defaultUserProviderData.first_seen) { + defaultUserProviderData.first_seen = firstSeen; + setStorageItem( + 'localStorage', + defaultUserProviderStorageKey, + defaultUserProviderData, + ); + } + user.first_seen = firstSeen; + this.experimentClient.setUser(user); + // evaluate variants for page targeting const variants: Variants = this.getVariants(); @@ -466,6 +517,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { Object.keys(this.previewFlags).includes(key), ) ) { + this.scheduleRelaySync(enrichedUser); this.isRunning = true; flushEventBuffer(this); return; @@ -474,6 +526,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { await this.fetchRemoteFlags(); // apply remote variants - if fetch is unsuccessful, fallback order: 1. localStorage flags, 2. initial flags await this.applyVariants({ flagKeys: this.remoteFlagKeys }); + this.scheduleRelaySync(enrichedUser); this.isRunning = true; flushEventBuffer(this); } @@ -710,6 +763,94 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } } + /** + * Non-blocking relay iframe init + Pass 2 sync. + */ + private scheduleRelaySync(user: WebExperimentUser): void { + if (!this.behavioralTargetingManager || this.isVisualEditorMode) { + return; + } + + const webExpIdV2 = user.web_exp_id_v2 ?? user.web_exp_id; + if (!webExpIdV2) { + return; + } + + if (this.relayClient) { + this.teardownRelay(this.relayClient); + } + const relayClient = new RelayClient( + this.apiKey, + webExpIdV2, + getRelayUrl(this.apiKey, this.config.serverZone, this.config.relayUrl), + ); + this.relayClient = relayClient; + + void this.behavioralTargetingManager + .beginRelaySync(relayClient) + .then((result) => { + // A newer scheduleRelaySync may have torn this client down and taken + // ownership; never attach or tear down a client we no longer own. + if (this.relayClient !== relayClient) { + return; + } + if ( + result.status === 'unavailable' || + result.status === 'sync_failed' + ) { + this.teardownRelay(relayClient); + return; + } + // Sync succeeded and this client still owns the relay: attach it for + // ongoing dual-write. + this.behavioralTargetingManager?.setRelayClient(relayClient); + if (result.status === 'behaviors_changed') { + return this.handleRelayPass2(true).catch((pass2Error) => { + console.warn('Experiment relay Pass 2 failed:', pass2Error); + }); + } + }) + .catch(() => { + if (this.relayClient !== relayClient) { + return; + } + this.teardownRelay(relayClient); + }); + } + + private teardownRelay(relayClient: RelayClient): void { + relayClient.destroy(); + this.relayClient = null; + this.behavioralTargetingManager?.setRelayClient(null); + } + + /** + * Pass 2: re-apply variants when relay sync changes matched behaviors. + */ + private async handleRelayPass2(behaviorsChanged: boolean): Promise { + if (!behaviorsChanged || !this.behavioralTargetingManager) { + return; + } + + this.updateUserWithBehaviors(); + + const flagKeys = Object.keys(this.behavioralTargetingRules); + const localKeys = flagKeys.filter((key) => + this.localFlagKeys.includes(key), + ); + const remoteKeys = flagKeys.filter((key) => + this.remoteFlagKeys.includes(key), + ); + + if (localKeys.length > 0) { + await this.applyVariants({ flagKeys: localKeys }); + } + if (remoteKeys.length > 0) { + await this.fetchRemoteFlags(); + await this.applyVariants({ flagKeys: remoteKeys }); + } + } + /** * Update the user with matched behavioral targeting IDs. * Sets the user property `behavioral_targeting` to an array of all matched behavior IDs. diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 048035af..5df8ae06 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -181,6 +181,8 @@ export interface WebExperimentClient { export type WebExperimentUser = { web_exp_id?: string; + /** Cross-subdomain visitor ID; falls back to web_exp_id for relay wiring. */ + web_exp_id_v2?: string; } & ExperimentUser; export type InitConfigs = { diff --git a/packages/experiment-tag/src/util/cookie.ts b/packages/experiment-tag/src/util/cookie.ts index d38436f7..a72e48de 100644 --- a/packages/experiment-tag/src/util/cookie.ts +++ b/packages/experiment-tag/src/util/cookie.ts @@ -148,6 +148,36 @@ export async function getTopLevelDomain(hostname: string): Promise { return (cachedDomain = ''); } +/** + * Resolves a cross-subdomain value using cookie storage as the authoritative + * source. Falls back to a provided localStorage value (migration path), then + * generates a new value if neither exists. Attempts to set the cookie when + * missing; cookie I/O failures fall back to localStorage / generateNew. + * Callers are responsible for syncing the returned value back to localStorage. + */ +export async function resolveCrossSubdomainValue( + cookieStorage: CookieStorage, + cookieKey: string, + localStorageValue: string | undefined, + generateNew: () => string, +): Promise { + try { + const cookieValue = await cookieStorage.get(cookieKey); + if (cookieValue) { + return cookieValue; + } + } catch { + // Cookie read blocked; fall through to localStorage / generateNew. + } + const value = localStorageValue ?? generateNew(); + try { + await cookieStorage.set(cookieKey, value); + } catch { + // Cookie write blocked; return value for localStorage-only persistence. + } + return value; +} + export async function setMarketingCookie(apiKey: string, hostname: string) { const domain = await getTopLevelDomain(hostname); const storage = new CookieStorage({ diff --git a/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts b/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts new file mode 100644 index 00000000..58899008 --- /dev/null +++ b/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts @@ -0,0 +1,95 @@ +import { EvaluationOperator } from '@amplitude/experiment-core'; +import { BehavioralTargetingManager } from 'src/behavioral-targeting/behavioral-targeting-manager'; +import { + RelayClient, + getRelayUrl, +} from 'src/behavioral-targeting/relay-client'; +import { RELAY_READY_MESSAGE } from 'src/behavioral-targeting/relay-protocol'; + +const API_KEY = 'test-api-key'; +const WEB_EXP_ID_V2 = 'oeu1383080393924r0-5047421827912331'; +const RELAY_URL = getRelayUrl(API_KEY); +const RELAY_ORIGIN = 'https://cdn.amplitude.com'; + +describe('BehavioralTargetingManager relay wiring', () => { + let relayClient: RelayClient | null = null; + + beforeEach(() => { + jest.useFakeTimers(); + localStorage.clear(); + sessionStorage.clear(); + document.body.innerHTML = ''; + }); + + afterEach(() => { + relayClient?.destroy(); + relayClient = null; + jest.useRealTimers(); + }); + + const signalRelayReady = () => { + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + const iframeWindow = { postMessage: jest.fn() }; + Object.defineProperty(iframe, 'contentWindow', { + value: iframeWindow, + configurable: true, + }); + window.dispatchEvent( + new MessageEvent('message', { + data: RELAY_READY_MESSAGE, + source: iframeWindow as unknown as MessageEventSource, + origin: RELAY_ORIGIN, + }), + ); + return iframeWindow; + }; + + test('beginRelaySync injects relay iframe and completes Pass 2 sync attempt', async () => { + const manager = new BehavioralTargetingManager(API_KEY, { + flag_a: { + behavior_1: [ + [ + { + condition: { + type: 'behavior', + event_type: 'click', + op: EvaluationOperator.GREATER_THAN_EQUALS, + value: 1, + time_type: 'rolling', + time_value: 7, + interval: 'day', + }, + }, + ], + ], + }, + }); + + const attachSpy = jest.spyOn(manager, 'setRelayClient'); + relayClient = new RelayClient(API_KEY, WEB_EXP_ID_V2, RELAY_URL); + const initPromise = manager.beginRelaySync(relayClient); + await jest.runAllTimersAsync(); + signalRelayReady(); + await initPromise; + + expect(document.querySelector('iframe')).not.toBeNull(); + expect(relayClient.relayAvailable).toBe(true); + // Attach is the caller's responsibility, never done inside beginRelaySync. + expect(attachSpy).not.toHaveBeenCalled(); + }); + + test('beginRelaySync waits for late relay ready after init timeout', async () => { + const manager = new BehavioralTargetingManager(API_KEY, {}); + + relayClient = new RelayClient(API_KEY, WEB_EXP_ID_V2, RELAY_URL); + const syncPromise = manager.beginRelaySync(relayClient); + + await jest.runAllTimersAsync(); + expect(relayClient.relayAvailable).toBe(false); + + signalRelayReady(); + await syncPromise; + + expect(relayClient.relayAvailable).toBe(true); + }); +}); diff --git a/packages/experiment-tag/test/experiment-relay-iframe.test.ts b/packages/experiment-tag/test/experiment-relay-iframe.test.ts new file mode 100644 index 00000000..52a47720 --- /dev/null +++ b/packages/experiment-tag/test/experiment-relay-iframe.test.ts @@ -0,0 +1,119 @@ +import * as experimentCore from '@amplitude/experiment-core'; +import { EvaluationOperator } from '@amplitude/experiment-core'; +import { ExperimentClient } from '@amplitude/experiment-js-client'; +import { RelayClient } from 'src/behavioral-targeting/relay-client'; +import { DefaultWebExperimentClient } from 'src/experiment'; +import { stringify } from 'ts-jest'; + +import { createPageObject } from './util/create-page-object'; +import { createMockGlobal, setupGlobalObservers } from './util/mocks'; + +const mockRelayInit = jest.fn().mockResolvedValue(undefined); +const mockRelayDestroy = jest.fn(); +const mockRelayWaitForAvailable = jest.fn().mockResolvedValue(false); + +jest.mock('src/behavioral-targeting/relay-client', () => { + const actual = jest.requireActual('src/behavioral-targeting/relay-client'); + return { + ...actual, + RelayClient: jest.fn().mockImplementation(() => ({ + init: mockRelayInit, + destroy: mockRelayDestroy, + relayAvailable: false, + waitForAvailable: mockRelayWaitForAvailable, + })), + }; +}); + +setupGlobalObservers(); + +describe('DefaultWebExperimentClient relay iframe', () => { + let apiKey = 0; + const mockGetGlobalScope = jest.spyOn(experimentCore, 'getGlobalScope'); + let mockGlobal: ReturnType; + + beforeEach(() => { + apiKey++; + jest.clearAllMocks(); + jest.spyOn(experimentCore, 'isLocalStorageAvailable').mockReturnValue(true); + mockGlobal = createMockGlobal(); + mockGetGlobalScope.mockReturnValue( + mockGlobal as unknown as typeof globalThis, + ); + jest.spyOn(ExperimentClient.prototype, 'setUser').mockImplementation(); + jest.spyOn(ExperimentClient.prototype, 'all').mockReturnValue({}); + jest + .spyOn(ExperimentClient.prototype, 'fetch') + .mockResolvedValue({} as never); + }); + + test('starts relay sync when behavioral targeting rules are present', async () => { + const key = stringify(apiKey); + const behavioralTargetingRules = { + flag_a: { + behavior_1: [ + [ + { + condition: { + type: 'behavior', + event_type: 'click', + op: EvaluationOperator.GREATER_THAN_EQUALS, + value: 1, + time_type: 'rolling', + time_value: 7, + interval: 'day', + }, + }, + ], + ], + }, + }; + + const client = DefaultWebExperimentClient.getInstance(key, { + initialFlags: JSON.stringify([]), + pageObjects: JSON.stringify({ + flag_a: createPageObject( + 'A', + 'url_change', + undefined, + 'http://test.com', + ), + }), + behavioralTargetingRules: JSON.stringify(behavioralTargetingRules), + }); + + mockGlobal.localStorage.getItem.mockImplementation((storageKey: string) => { + if (storageKey === `EXP_${key.slice(0, 10)}`) { + return JSON.stringify({ + web_exp_id: 'oeu1383080393924r0-5047421827912331', + }); + } + return null; + }); + + await client.start(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(RelayClient).toHaveBeenCalledWith( + key, + 'oeu1383080393924r0-5047421827912331', + expect.stringContaining('.relay.html'), + ); + expect(mockRelayInit).toHaveBeenCalled(); + expect(mockRelayWaitForAvailable).toHaveBeenCalled(); + expect(mockRelayDestroy).toHaveBeenCalled(); + }); + + test('skips relay sync when there are no behavioral targeting rules', async () => { + const key = stringify(apiKey); + const client = DefaultWebExperimentClient.getInstance(key, { + initialFlags: JSON.stringify([]), + pageObjects: JSON.stringify({}), + }); + + await client.start(); + + expect(RelayClient).not.toHaveBeenCalled(); + expect(mockRelayInit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index ccb011cf..ea8e4fd6 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -112,12 +112,47 @@ describe('initializeExperiment', () => { initialFlags: JSON.stringify([]), pageObjects: JSON.stringify({}), }).start(); - expect(ExperimentClient.prototype.setUser).toHaveBeenCalledWith({ - web_exp_id: 'mock', - }); + expect(ExperimentClient.prototype.setUser).toHaveBeenCalledWith( + expect.objectContaining({ + web_exp_id: 'mock', + web_exp_id_v2: expect.any(String), + }), + ); expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( 'EXP_' + stringify(apiKey), - JSON.stringify({ web_exp_id: 'mock' }), + JSON.stringify({ web_exp_id: 'mock', web_exp_id_v2: 'mock' }), + ); + }); + + test('seeds web_exp_id_v2 from existing web_exp_id when cookie is missing', async () => { + const key = stringify(apiKey); + const storageKey = 'EXP_' + key; + const cookieKey = storageKey + '_web_exp_id_v2'; + mockGlobal.localStorage.getItem.mockImplementation((name: string) => { + if (name === storageKey) { + return JSON.stringify({ web_exp_id: 'existing-id' }); + } + return null; + }); + + await DefaultWebExperimentClient.getInstance(key, { + initialFlags: JSON.stringify([]), + pageObjects: JSON.stringify({}), + }).start(); + + expect(ExperimentClient.prototype.setUser).toHaveBeenCalledWith( + expect.objectContaining({ + web_exp_id: 'existing-id', + web_exp_id_v2: 'existing-id', + }), + ); + expect(cookieStore[cookieKey]).toBe('existing-id'); + expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( + storageKey, + JSON.stringify({ + web_exp_id: 'existing-id', + web_exp_id_v2: 'existing-id', + }), ); }); @@ -1004,7 +1039,7 @@ describe('initializeExperiment', () => { expect(antiFlickerSpy).toHaveBeenCalledTimes(1); }); - test('remote evaluation - fetch fail, test initialFlags variant actions called', () => { + test('remote evaluation - fetch fail, test initialFlags variant actions called', async () => { const initialFlags = [ // remote flag createMutateFlag( @@ -1018,7 +1053,7 @@ describe('initializeExperiment', () => { const mockHttpClient = new MockHttpClient('', 404); - DefaultWebExperimentClient.getInstance( + await DefaultWebExperimentClient.getInstance( stringify(apiKey), { initialFlags: JSON.stringify(initialFlags), @@ -1027,15 +1062,10 @@ describe('initializeExperiment', () => { { httpClient: mockHttpClient, }, - ) - .start() - .then(() => { - // check remote variant actions applied - expect(mockExposure).toHaveBeenCalledTimes(1); - expect(mockExposure).toHaveBeenCalledWith('test'); - }); - // check local flag variant actions called - expect(mockExposure).toHaveBeenCalledTimes(0); + ).start(); + // check remote variant actions applied + expect(mockExposure).toHaveBeenCalledTimes(1); + expect(mockExposure).toHaveBeenCalledWith('test'); expect(antiFlickerSpy).toHaveBeenCalledTimes(1); });