Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/feature_toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends () => boolean, Y, N = unknown> = ReturnType<T> extends true ? Y : N;
4 changes: 3 additions & 1 deletion lib/notification_center/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ 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;
attributes?: UserAttributes;
}

export type ActivateListenerPayload = UserEventListenerPayload & {
experiment: Experiment | Holdout | null;
experiment: Experiment | null;
holdout: IfActive<typeof holdout, Holdout | null>;
variation: Variation | null;
logEvent: LogEvent;
}
Expand Down
63 changes: 55 additions & 8 deletions lib/optimizely/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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
);
});

Expand Down Expand Up @@ -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' },
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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' },
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 },
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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' },
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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' },
Expand All @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions lib/optimizely/index.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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],
Expand Down
21 changes: 17 additions & 4 deletions lib/optimizely/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
OptimizelyDecision,
Client,
UserProfileServiceAsync,
isHoldout,
} from '../shared_types';
import { newErrorDecision } from '../optimizely_decision';
import OptimizelyUserContext from '../optimizely_user_context';
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}

/**
Expand Down
9 changes: 9 additions & 0 deletions lib/shared_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading