diff --git a/lib/feature_toggle.ts b/lib/feature_toggle.ts index 0a647c169..cea8adf67 100644 --- a/lib/feature_toggle.ts +++ b/lib/feature_toggle.ts @@ -31,4 +31,6 @@ * flag and all associated checks can be removed from the codebase. */ -export const holdout = () => true; +export const holdout = () => true as const; + +export type IfActive boolean, Y, N = unknown> = ReturnType extends true ? Y : N; diff --git a/lib/notification_center/type.ts b/lib/notification_center/type.ts index cbf8467a4..01adc56e5 100644 --- a/lib/notification_center/type.ts +++ b/lib/notification_center/type.ts @@ -26,6 +26,7 @@ import { } from '../shared_types'; import { DecisionSource } from '../utils/enums'; import { Nullable } from '../utils/type'; +import { holdout, IfActive } from '../feature_toggle'; export type UserEventListenerPayload = { userId: string; @@ -33,7 +34,8 @@ export type UserEventListenerPayload = { } export type ActivateListenerPayload = UserEventListenerPayload & { - experiment: Experiment | Holdout | null; + experiment: Experiment | null; + holdout: IfActive; variation: Variation | null; logEvent: LogEvent; } diff --git a/lib/optimizely/index.spec.ts b/lib/optimizely/index.spec.ts index 0c7e2fc79..81509fd1e 100644 --- a/lib/optimizely/index.spec.ts +++ b/lib/optimizely/index.spec.ts @@ -249,7 +249,8 @@ describe('Optimizely', () => { let projectConfig: any; let optimizely: any; let decisionService: any; - let notificationSpy: any; + let flagNotificationSpy: any; + let activateNotificationSpy: any; let eventProcessor: any; beforeEach(() => { @@ -282,10 +283,16 @@ describe('Optimizely', () => { decisionService = optimizely.decisionService; // Setup notification spy - notificationSpy = vi.fn(); + flagNotificationSpy = vi.fn(); optimizely.notificationCenter.addNotificationListener( NOTIFICATION_TYPES.DECISION, - notificationSpy + flagNotificationSpy + ); + + activateNotificationSpy = vi.fn(); + optimizely.notificationCenter.addNotificationListener( + NOTIFICATION_TYPES.ACTIVATE, + activateNotificationSpy ); }); @@ -408,7 +415,7 @@ describe('Optimizely', () => { expect(decision.ruleKey).toBe('holdout_test_key'); // Verify decision notification was sent - expect(notificationSpy).toHaveBeenCalledWith({ + expect(flagNotificationSpy).toHaveBeenCalledWith({ type: DECISION_NOTIFICATION_TYPES.FLAG, userId: 'test_user', attributes: { country: 'US' }, @@ -422,6 +429,14 @@ describe('Optimizely', () => { decisionEventDispatched: true, }), }); + + expect(activateNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + experiment: null, + holdout: projectConfig.holdouts[0], + userId: 'test_user', + attributes: { country: 'US' }, + variation: projectConfig.holdouts[0].variations[0] + })); }); it('should handle holdout with included flags', async () => { @@ -455,7 +470,7 @@ describe('Optimizely', () => { expect(decision.variationKey).toBe('holdout_variation_key'); // Verify notification shows holdout details - expect(notificationSpy).toHaveBeenCalledWith({ + expect(flagNotificationSpy).toHaveBeenCalledWith({ type: DECISION_NOTIFICATION_TYPES.FLAG, userId: 'test_user', attributes: { country: 'US' }, @@ -465,6 +480,14 @@ describe('Optimizely', () => { ruleKey: 'holdout_test_key', }), }); + + expect(activateNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + experiment: null, + holdout: modifiedHoldout, + userId: 'test_user', + attributes: { country: 'US' }, + variation: modifiedHoldout.variations[0] + })); }); it('should handle holdout with excluded flags', async () => { @@ -499,7 +522,7 @@ describe('Optimizely', () => { expect(decision.variationKey).toBe('variation_3'); // Verify notification shows normal experiment details (not holdout) - expect(notificationSpy).toHaveBeenCalledWith({ + expect(flagNotificationSpy).toHaveBeenCalledWith({ type: DECISION_NOTIFICATION_TYPES.FLAG, userId: 'test_user', attributes: { country: 'BD', age: 80 }, @@ -509,6 +532,14 @@ describe('Optimizely', () => { ruleKey: 'exp_3', }), }); + + expect(activateNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + experiment: projectConfig.experimentKeyMap['exp_3'], + holdout: null, + userId: 'test_user', + attributes: { country: 'BD', age: 80 }, + variation: projectConfig.variationIdMap['5003'] + })); }); it('should handle multiple holdouts with correct priority', async () => { @@ -568,7 +599,7 @@ describe('Optimizely', () => { expect(decision.variationKey).toBe('holdout_variation_key_2'); // Verify notification shows details of selected holdout - expect(notificationSpy).toHaveBeenCalledWith({ + expect(flagNotificationSpy).toHaveBeenCalledWith({ type: DECISION_NOTIFICATION_TYPES.FLAG, userId: 'test_user', attributes: { country: 'US' }, @@ -578,6 +609,14 @@ describe('Optimizely', () => { ruleKey: 'holdout_test_key_2', }), }); + + expect(activateNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + experiment: null, + holdout: holdout2, + userId: 'test_user', + attributes: { country: 'US' }, + variation: holdout2.variations[0] + })); }); it('should respect sendFlagDecisions setting for holdout events - false', async () => { @@ -744,7 +783,7 @@ describe('Optimizely', () => { expect(typeof decision.variables).toBe('object'); // Verify notification includes variable information - expect(notificationSpy).toHaveBeenCalledWith({ + expect(flagNotificationSpy).toHaveBeenCalledWith({ type: DECISION_NOTIFICATION_TYPES.FLAG, userId: 'test_user', attributes: { country: 'US' }, @@ -754,6 +793,14 @@ describe('Optimizely', () => { enabled: false, }), }); + + expect(activateNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + experiment: null, + holdout: projectConfig.holdouts[0], + userId: 'test_user', + attributes: { country: 'US' }, + variation: projectConfig.holdouts[0].variations[0] + })); }); it('should handle disable decision event option for holdout', async () => { diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index dc9d6f6ed..d3f350bba 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -67,6 +67,7 @@ import { import { USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP } from '../core/bucketer'; import { resolvablePromise } from '../utils/promise/resolvablePromise'; +import { holdout } from '../feature_toggle'; var LOG_LEVEL = enums.LOG_LEVEL; var DECISION_SOURCES = enums.DECISION_SOURCES; @@ -2281,6 +2282,7 @@ describe('lib/optimizely', function() { var instanceExperiments = optlyInstance.projectConfigManager.getConfig().experiments; var expectedArgument = { experiment: instanceExperiments[0], + holdout: null, userId: 'testUser', attributes: undefined, variation: instanceExperiments[0].variations[1], @@ -2351,6 +2353,7 @@ describe('lib/optimizely', function() { var instanceExperiments = optlyInstance.projectConfigManager.getConfig().experiments; var expectedArgument = { experiment: instanceExperiments[0], + holdout: null, userId: 'testUser', attributes: attributes, variation: instanceExperiments[0].variations[1], diff --git a/lib/optimizely/index.ts b/lib/optimizely/index.ts index 2f3e3277f..f6e2b4f35 100644 --- a/lib/optimizely/index.ts +++ b/lib/optimizely/index.ts @@ -37,6 +37,7 @@ import { OptimizelyDecision, Client, UserProfileServiceAsync, + isHoldout, } from '../shared_types'; import { newErrorDecision } from '../optimizely_decision'; import OptimizelyUserContext from '../optimizely_user_context'; @@ -62,7 +63,7 @@ import { import { Fn, Maybe, OpType } from '../utils/type'; import { resolvablePromise } from '../utils/promise/resolvablePromise'; -import { NOTIFICATION_TYPES, DecisionNotificationType, DECISION_NOTIFICATION_TYPES } from '../notification_center/type'; +import { NOTIFICATION_TYPES, DecisionNotificationType, DECISION_NOTIFICATION_TYPES, ActivateListenerPayload } from '../notification_center/type'; import { FEATURE_NOT_IN_DATAFILE, INVALID_INPUT_FORMAT, @@ -382,13 +383,25 @@ export default class Optimizely extends BaseService implements Client { this.eventProcessor.process(impressionEvent); const logEvent = buildLogEvent([impressionEvent]); - this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, { - experiment: decisionObj.experiment, + + const activateNotificationPayload: ActivateListenerPayload = { + experiment: null, + holdout: null, userId: userId, attributes: attributes, variation: decisionObj.variation, logEvent, - }); + }; + + if (decisionObj.experiment) { + if (isHoldout(decisionObj.experiment)) { + activateNotificationPayload.holdout = decisionObj.experiment; + } else { + activateNotificationPayload.experiment = decisionObj.experiment; + } + } + + this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, activateNotificationPayload); } /** diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 65b9594b2..a79a88b03 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -181,6 +181,15 @@ export interface Holdout extends ExperimentCore { excludedFlags: string[]; } +export function isHoldout(obj: Experiment | Holdout): obj is Holdout { + // Holdout has 'status', 'includedFlags', and 'excludedFlags' properties + return ( + (obj as Holdout).status !== undefined && + Array.isArray((obj as Holdout).includedFlags) && + Array.isArray((obj as Holdout).excludedFlags) + ); +} + export enum VariableType { BOOLEAN = 'boolean', DOUBLE = 'double',