diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts
index fc82bc908..0f48dfe83 100644
--- a/packages/shared/src/open-feature.ts
+++ b/packages/shared/src/open-feature.ts
@@ -27,6 +27,8 @@ type AnyProviderStatus = ClientProviderStatus | ServerProviderStatus;
*/
export class ProviderWrapper
, S extends AnyProviderStatus> {
private _pendingContextChanges = 0;
+ private _initializing = false;
+ private _initialized = false;
constructor(
private _provider: P,
@@ -49,6 +51,8 @@ export class ProviderWrapper
, S exte
this._status = _statusEnumType.ERROR as S;
}
});
+
+ this._initialized = !(typeof _provider.initialize === 'function');
}
get provider(): P {
@@ -67,6 +71,22 @@ export class ProviderWrapper
, S exte
this._status = status;
}
+ get initializing() {
+ return this._initializing;
+ }
+
+ set initializing(initializing: boolean) {
+ this._initializing = initializing;
+ }
+
+ get initialized() {
+ return this._initialized;
+ }
+
+ set initialized(initialized: boolean) {
+ this._initialized = initialized;
+ }
+
get allContextChangesSettled() {
return this._pendingContextChanges === 0;
}
@@ -208,16 +228,67 @@ export abstract class OpenFeatureCommonAPI<
abstract setProviderAndWait(
clientOrProvider?: string | P,
providerContextOrUndefined?: P | EvaluationContext,
- contextOrUndefined?: EvaluationContext,
+ contextOptionsOrUndefined?: EvaluationContext | Record,
+ optionsOrUndefined?: Record,
): Promise;
abstract setProvider(
clientOrProvider?: string | P,
providerContextOrUndefined?: P | EvaluationContext,
- contextOrUndefined?: EvaluationContext,
+ contextOptionsOrUndefined?: EvaluationContext | Record,
+ optionsOrUndefined?: Record,
): this;
- protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void {
+ protected initializeProviderForDomain(wrapper: ProviderWrapper, domain?: string): Promise | void {
+ if (typeof wrapper.provider.initialize !== 'function'
+ || wrapper.initializing
+ || wrapper.initialized) {
+ return;
+ }
+
+ wrapper.initializing = true;
+ return wrapper.provider
+ .initialize(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context)
+ .then(() => {
+ wrapper.status = this._statusEnumType.READY;
+ const payload = { clientName: domain, domain, providerName: wrapper.provider.metadata.name };
+
+ // fetch the most recent event emitters, some may have been added during init
+ this.getAssociatedEventEmitters(domain).forEach((emitter) => {
+ emitter?.emit(AllProviderEvents.Ready, { ...payload });
+ });
+ this._apiEmitter?.emit(AllProviderEvents.Ready, { ...payload });
+ })
+ .catch((error) => {
+ // if this is a fatal error, transition to FATAL status
+ if ((error as OpenFeatureError)?.code === ErrorCode.PROVIDER_FATAL) {
+ wrapper.status = this._statusEnumType.FATAL;
+ } else {
+ wrapper.status = this._statusEnumType.ERROR;
+ }
+ const payload = {
+ clientName: domain,
+ domain,
+ providerName: wrapper.provider.metadata.name,
+ message: error?.message,
+ };
+
+ // fetch the most recent event emitters, some may have been added during init
+ this.getAssociatedEventEmitters(domain).forEach((emitter) => {
+ emitter?.emit(AllProviderEvents.Error, { ...payload });
+ });
+ this._apiEmitter?.emit(AllProviderEvents.Error, { ...payload });
+
+ // rethrow after emitting error events, so that public methods can control error handling
+ throw error;
+ })
+ .finally(() => {
+ wrapper.initialized = true;
+ wrapper.initializing = false;
+ });
+ }
+
+ protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P, skipInitialization = false): Promise | void {
const domain = stringOrUndefined(domainOrProvider);
const provider = objectOrUndefined(domainOrProvider) ?? objectOrUndefined
(providerOrUndefined);
@@ -250,48 +321,19 @@ export abstract class OpenFeatureCommonAPI<
this._statusEnumType,
);
- // initialize the provider if it implements "initialize" and it's not already registered
- if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) {
- initializationPromise = provider
- .initialize?.(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context)
- ?.then(() => {
- wrappedProvider.status = this._statusEnumType.READY;
- // fetch the most recent event emitters, some may have been added during init
- this.getAssociatedEventEmitters(domain).forEach((emitter) => {
- emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
- });
- this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
- })
- ?.catch((error) => {
- // if this is a fatal error, transition to FATAL status
- if ((error as OpenFeatureError)?.code === ErrorCode.PROVIDER_FATAL) {
- wrappedProvider.status = this._statusEnumType.FATAL;
- } else {
- wrappedProvider.status = this._statusEnumType.ERROR;
- }
- this.getAssociatedEventEmitters(domain).forEach((emitter) => {
- emitter?.emit(AllProviderEvents.Error, {
- clientName: domain,
- domain,
- providerName,
- message: error?.message,
- });
- });
- this._apiEmitter?.emit(AllProviderEvents.Error, {
- clientName: domain,
- domain,
- providerName,
- message: error?.message,
- });
- // rethrow after emitting error events, so that public methods can control error handling
- throw error;
+ if (!skipInitialization) {
+ // initialize the provider if it's not already registered and it implements "initialize"
+ if (!this.allProviders.includes(provider)) {
+ initializationPromise = this.initializeProviderForDomain(wrappedProvider, domain);
+ }
+
+ if (!initializationPromise) {
+ wrappedProvider.status = this._statusEnumType.READY;
+ emitters.forEach((emitter) => {
+ emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
});
- } else {
- wrappedProvider.status = this._statusEnumType.READY;
- emitters.forEach((emitter) => {
- emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
- });
- this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
+ this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
+ }
}
if (domain) {
diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts
index be6c7f845..f175f23f0 100644
--- a/packages/web/src/open-feature.ts
+++ b/packages/web/src/open-feature.ts
@@ -16,6 +16,17 @@ import type { Hook } from './hooks';
import type { Provider} from './provider';
import { NOOP_PROVIDER, ProviderStatus } from './provider';
+interface ProviderOptions {
+ /**
+ * If provided, will be used to check if the current context is valid during initialization and context changes.
+ * When calling `setProvider`, returning `false` will skip provider initialization. Throwing will move the provider to the ERROR state.
+ * When calling `setProviderAndWait`, returning `false` will skip provider initialization. Throwing will reject the promise.
+ * When calling `setContext`, returning `false` will skip provider reconciliation of the context change. Throwing will move the provider to the ERROR state.
+ * @param context The evaluation context to validate.
+ */
+ validateContext?: (context: EvaluationContext) => boolean;
+}
+
// use a symbol as a key for the global singleton
const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api');
@@ -42,6 +53,8 @@ export class OpenFeatureAPI
);
protected _domainScopedProviders: Map> = new Map();
protected _createEventEmitter = () => new OpenFeatureEventEmitter();
+ protected _defaultOptions: ProviderOptions = {};
+ protected _domainScopedOptions: Map = new Map();
private constructor() {
super('client');
@@ -71,6 +84,14 @@ export class OpenFeatureAPI
return this._domainScopedProviders.get(domain)?.status ?? this._defaultProvider.status;
}
+ private getProviderOptions(domain?: string): ProviderOptions {
+ if (!domain) {
+ return this._defaultOptions;
+ }
+
+ return this._domainScopedOptions.get(domain) ?? this._defaultOptions;
+ }
+
/**
* Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready.
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
@@ -86,10 +107,11 @@ export class OpenFeatureAPI
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
+ * @param {ProviderOptions} [options] Options for setting the provider.
* @returns {Promise}
* @throws {Error} If the provider throws an exception during initialization.
*/
- setProviderAndWait(provider: Provider, context: EvaluationContext): Promise;
+ setProviderAndWait(provider: Provider, context: EvaluationContext, options?: ProviderOptions): Promise;
/**
* Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain.
* A promise is returned that resolves when the provider is ready.
@@ -107,24 +129,47 @@ export class OpenFeatureAPI
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
* @param {EvaluationContext} context The evaluation context to use for flag evaluations.
+ * @param {ProviderOptions} [options] Options for setting the provider.
* @returns {Promise}
* @throws {Error} If the provider throws an exception during initialization.
*/
- setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise;
+ setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext, options?: ProviderOptions): Promise;
async setProviderAndWait(
clientOrProvider?: string | Provider,
providerContextOrUndefined?: Provider | EvaluationContext,
- contextOrUndefined?: EvaluationContext,
+ contextOptionsOrUndefined?: EvaluationContext | ProviderOptions,
+ optionsOrUndefined?: ProviderOptions,
): Promise {
const domain = stringOrUndefined(clientOrProvider);
const provider = domain
? objectOrUndefined(providerContextOrUndefined)
: objectOrUndefined(clientOrProvider);
const context = domain
- ? objectOrUndefined(contextOrUndefined)
+ ? objectOrUndefined(contextOptionsOrUndefined)
: objectOrUndefined(providerContextOrUndefined);
+ const options = domain
+ ? objectOrUndefined(optionsOrUndefined)
+ : objectOrUndefined(contextOptionsOrUndefined);
+ if (domain) {
+ this._domainScopedOptions.set(domain, options ?? {});
+ } else {
+ this._defaultOptions = options ?? {};
+ }
+
+ let skipInitialization = false;
if (context) {
+ // validate the context to decide if we should initialize the provider with it.
+ if (typeof options?.validateContext === 'function') {
+ // allow any error to propagate here to reject the promise.
+ skipInitialization = !options.validateContext(context);
+ if (skipInitialization) {
+ this._logger.debug(
+ `Skipping provider initialization during setProviderAndWait for domain '${domain ?? 'default'}' due to validateContext returning false.`,
+ );
+ }
+ }
+
// synonymously setting context prior to provider initialization.
// No context change event will be emitted.
if (domain) {
@@ -134,7 +179,7 @@ export class OpenFeatureAPI
}
}
- await this.setAwaitableProvider(domain, provider);
+ await this.setAwaitableProvider(domain, provider, skipInitialization);
}
/**
@@ -150,10 +195,11 @@ export class OpenFeatureAPI
* This provider will be used by domainless clients and clients associated with domains to which no provider is bound.
* Setting a provider supersedes the current provider used in new and existing unbound clients.
* @param {Provider} provider The provider responsible for flag evaluations.
- * @param context {EvaluationContext} The evaluation context to use for flag evaluations.
+ * @param {EvaluationContext} context The evaluation context to use for flag evaluations.
+ * @param {ProviderOptions} [options] Options for setting the provider.
* @returns {this} OpenFeature API
*/
- setProvider(provider: Provider, context: EvaluationContext): this;
+ setProvider(provider: Provider, context: EvaluationContext, options?: ProviderOptions): this;
/**
* Sets the provider for flag evaluations of providers with the given name.
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
@@ -167,24 +213,56 @@ export class OpenFeatureAPI
* Setting a provider supersedes the current provider used in new and existing clients bound to the same domain.
* @param {string} domain The name to identify the client
* @param {Provider} provider The provider responsible for flag evaluations.
- * @param context {EvaluationContext} The evaluation context to use for flag evaluations.
+ * @param {EvaluationContext} context The evaluation context to use for flag evaluations.
+ * @param {ProviderOptions} [options] Options for setting the provider.
* @returns {this} OpenFeature API
*/
- setProvider(domain: string, provider: Provider, context: EvaluationContext): this;
+ setProvider(domain: string, provider: Provider, context: EvaluationContext, options?: ProviderOptions): this;
setProvider(
domainOrProvider?: string | Provider,
providerContextOrUndefined?: Provider | EvaluationContext,
- contextOrUndefined?: EvaluationContext,
+ contextOptionsOrUndefined?: EvaluationContext | ProviderOptions,
+ optionsOrUndefined?: ProviderOptions,
): this {
const domain = stringOrUndefined(domainOrProvider);
const provider = domain
? objectOrUndefined(providerContextOrUndefined)
: objectOrUndefined(domainOrProvider);
const context = domain
- ? objectOrUndefined(contextOrUndefined)
+ ? objectOrUndefined(contextOptionsOrUndefined)
: objectOrUndefined(providerContextOrUndefined);
+ const options = domain
+ ? objectOrUndefined(optionsOrUndefined)
+ : objectOrUndefined(contextOptionsOrUndefined);
+ if (domain) {
+ this._domainScopedOptions.set(domain, options ?? {});
+ } else {
+ this._defaultOptions = options ?? {};
+ }
+
+ let skipInitialization = false;
+ let validateContextError: unknown;
if (context) {
+ // validate the context to decide if we should initialize the provider with it.
+ if (typeof options?.validateContext === 'function') {
+ try {
+ skipInitialization = !options.validateContext(context);
+ if (skipInitialization) {
+ this._logger.debug(
+ `Skipping provider initialization during setProvider for domain '${domain ?? 'default'}' due to validateContext returning false.`,
+ );
+ }
+ } catch (err) {
+ // capture the error to move the provider to ERROR state after setting it.
+ validateContextError = err;
+ skipInitialization = true;
+ this._logger.debug(
+ `Skipping provider initialization during setProvider for domain '${domain ?? 'default'}' due to validateContext throwing an error.`,
+ );
+ }
+ }
+
// synonymously setting context prior to provider initialization.
// No context change event will be emitted.
if (domain) {
@@ -194,7 +272,30 @@ export class OpenFeatureAPI
}
}
- const maybePromise = this.setAwaitableProvider(domain, provider);
+ const maybePromise = this.setAwaitableProvider(domain, provider, skipInitialization);
+
+ // If there was a validation error with the context, move the newly created provider to ERROR state.
+ // We know we've skipped initialization if this happens, so no need to worry about the promise changing the state later.
+ if (validateContextError) {
+ const wrapper = domain
+ ? this._domainScopedProviders.get(domain)
+ : this._defaultProvider;
+ if (wrapper) {
+ wrapper.status = this._statusEnumType.ERROR;
+ const payload = {
+ clientName: domain,
+ domain,
+ providerName: wrapper.provider?.metadata?.name || 'unnamed-provider',
+ message: `Error validating context during setProvider: ${validateContextError instanceof Error ? validateContextError.message : String(validateContextError)}`,
+ };
+
+ this.getAssociatedEventEmitters(domain).forEach((emitter) => {
+ emitter?.emit(ProviderEvents.Error, { ...payload });
+ });
+ this._apiEmitter?.emit(ProviderEvents.Error, { ...payload });
+ this._logger.error('Error validating context during setProvider:', validateContextError);
+ }
+ }
// The setProvider method doesn't return a promise so we need to catch and
// log any errors that occur during provider initialization to avoid having
@@ -389,6 +490,26 @@ export class OpenFeatureAPI
const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider';
try {
+ // validate the context to decide if we should run the context change handler.
+ // notably, the stored context will still be updated by this point, this just skips reconciliation.
+ const options = this.getProviderOptions(domain);
+ if (typeof options.validateContext === 'function') {
+ if (!options.validateContext(newContext)) {
+ this._logger.debug(
+ `Skipping context change for domain '${domain ?? 'default'}' due to validateContext returning false.`,
+ );
+ return;
+ }
+ }
+
+ // if the provider hasn't initialized yet, and isn't actively initializing, initialize instead of running context change handler
+ // the provider will be in this state if validateContext was used during setProvider to skip initialization
+ const initializationPromise = this.initializeProviderForDomain(wrapper, domain);
+ if (initializationPromise) {
+ await initializationPromise;
+ return;
+ }
+
if (typeof wrapper.provider.onContextChange === 'function') {
const maybePromise = wrapper.provider.onContextChange(oldContext, newContext);
diff --git a/packages/web/test/evaluation-context.spec.ts b/packages/web/test/evaluation-context.spec.ts
index 288301c88..3d2064ba7 100644
--- a/packages/web/test/evaluation-context.spec.ts
+++ b/packages/web/test/evaluation-context.spec.ts
@@ -1,7 +1,7 @@
import type { EvaluationContext, JsonValue, Provider, ProviderMetadata, ResolutionDetails } from '../src';
import { OpenFeature } from '../src';
-const initializeMock = jest.fn();
+const initializeMock = jest.fn().mockResolvedValue(undefined);
class MockProvider implements Provider {
readonly metadata: ProviderMetadata;
diff --git a/packages/web/test/validate-context.spec.ts b/packages/web/test/validate-context.spec.ts
new file mode 100644
index 000000000..24a6e090b
--- /dev/null
+++ b/packages/web/test/validate-context.spec.ts
@@ -0,0 +1,303 @@
+import type { JsonValue, Provider, ProviderMetadata, ResolutionDetails } from '../src';
+import { NOOP_PROVIDER, OpenFeature, ProviderStatus } from '../src';
+
+const initializeMock = jest.fn().mockResolvedValue(undefined);
+const contextChangeMock = jest.fn().mockResolvedValue(undefined);
+
+class MockProvider implements Provider {
+ readonly metadata: ProviderMetadata;
+
+ constructor(options?: { name?: string }) {
+ this.metadata = { name: options?.name ?? 'mock-provider' };
+ }
+
+ initialize = initializeMock;
+ onContextChange = contextChangeMock;
+
+ resolveBooleanEvaluation(): ResolutionDetails {
+ throw new Error('Not implemented');
+ }
+
+ resolveNumberEvaluation(): ResolutionDetails {
+ throw new Error('Not implemented');
+ }
+
+ resolveObjectEvaluation(): ResolutionDetails {
+ throw new Error('Not implemented');
+ }
+
+ resolveStringEvaluation(): ResolutionDetails {
+ throw new Error('Not implemented');
+ }
+}
+
+describe('validateContext', () => {
+ afterEach(async () => {
+ await OpenFeature.clearContexts();
+ await OpenFeature.setProviderAndWait(NOOP_PROVIDER, {});
+ jest.clearAllMocks();
+ });
+
+ describe('when validateContext is not provided', () => {
+ it('should call initialize on setProvider', async () => {
+ const provider = new MockProvider();
+ OpenFeature.setProvider(provider, {});
+
+ await new Promise(process.nextTick);
+
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+ });
+
+ it('should call initialize on setProviderAndWait', async () => {
+ const provider = new MockProvider();
+ await OpenFeature.setProviderAndWait(provider, {});
+
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+ });
+
+ it('should not call initialize on context change', async () => {
+ const provider = new MockProvider();
+ await OpenFeature.setProviderAndWait(provider, {});
+
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+
+ await OpenFeature.setContext({ user: 'test-user' });
+
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(contextChangeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getContext()).toEqual({ user: 'test-user' });
+ });
+ });
+
+ describe('when validateContext evaluates to true', () => {
+ it('should call initialize on setProvider', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockReturnValue(true);
+ OpenFeature.setProvider(provider, {}, { validateContext });
+
+ await new Promise(process.nextTick);
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+ });
+
+ it('should call initialize on setProviderAndWait', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockReturnValue(true);
+ await OpenFeature.setProviderAndWait(provider, {}, { validateContext });
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+ });
+
+ describe('when the provider is initialized', () => {
+ it('should not call initialize again on context change', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockReturnValue(true);
+ await OpenFeature.setProviderAndWait(provider, {}, { validateContext });
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+
+ await OpenFeature.setContext({ user: 'test-user' });
+
+ expect(validateContext).toHaveBeenCalledTimes(2);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(contextChangeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getContext()).toEqual({ user: 'test-user' });
+ });
+ });
+
+ describe('when the provider is not yet initialized', () => {
+ it('should call initialize on the first valid context change', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockReturnValue(false);
+ OpenFeature.setProvider(provider, {}, { validateContext });
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY);
+
+ validateContext.mockReturnValue(true);
+ await OpenFeature.setContext({ user: 'test-user' });
+
+ expect(validateContext).toHaveBeenCalledTimes(2);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(contextChangeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getContext()).toEqual({ user: 'test-user' });
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+
+ await OpenFeature.setContext({ user: 'another-user' });
+
+ expect(validateContext).toHaveBeenCalledTimes(3);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(contextChangeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getContext()).toEqual({ user: 'another-user' });
+ });
+ });
+ });
+
+ describe('when validateContext evaluates to false', () => {
+ it('should not call initialize on setProvider', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockReturnValue(false);
+ OpenFeature.setProvider(provider, {}, { validateContext });
+
+ await new Promise(process.nextTick);
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY);
+ });
+
+ it('should not call initialize on setProviderAndWait', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockReturnValue(false);
+ await OpenFeature.setProviderAndWait(provider, {}, { validateContext });
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY);
+ });
+
+ describe('when the provider is initialized', () => {
+ it('should not process a context change that fails validation', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockReturnValue(true);
+ await OpenFeature.setProviderAndWait(provider, {}, { validateContext });
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+
+ validateContext.mockReturnValue(false);
+ await OpenFeature.setContext({ user: 'test-user' });
+
+ expect(validateContext).toHaveBeenCalledTimes(2);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(contextChangeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getContext()).toEqual({ user: 'test-user' });
+ });
+ });
+
+ describe('when the provider is not yet initialized', () => {
+ it('should not call initialize until a valid context is provided', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockReturnValue(false);
+ OpenFeature.setProvider(provider, {}, { validateContext });
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY);
+
+ await OpenFeature.setContext({ user: 'test-user' });
+
+ expect(validateContext).toHaveBeenCalledTimes(2);
+ expect(initializeMock).toHaveBeenCalledTimes(0);
+ expect(contextChangeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getContext()).toEqual({ user: 'test-user' });
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY);
+
+ validateContext.mockReturnValue(true);
+ await OpenFeature.setContext({ user: 'another-user' });
+
+ expect(validateContext).toHaveBeenCalledTimes(3);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(contextChangeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getContext()).toEqual({ user: 'another-user' });
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+
+ await OpenFeature.setContext({ user: 'final-user' });
+
+ expect(validateContext).toHaveBeenCalledTimes(4);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(contextChangeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getContext()).toEqual({ user: 'final-user' });
+ });
+ });
+ });
+
+ describe('when validateContext throws an error', () => {
+ it('should move to ERROR status on setProvider', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockImplementation(() => {
+ throw new Error('Validation error');
+ });
+ OpenFeature.setProvider(provider, {}, { validateContext });
+
+ await new Promise(process.nextTick);
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR);
+ });
+
+ it('should propagate error on setProviderAndWait', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockImplementation(() => {
+ throw new Error('Validation error');
+ });
+
+ await expect(OpenFeature.setProviderAndWait(provider, {}, { validateContext })).rejects.toThrow(
+ 'Validation error',
+ );
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getProvider()).toBe(NOOP_PROVIDER);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+ });
+
+ describe('when the provider is initialized', () => {
+ it('should move to ERROR status on context change', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockReturnValue(true);
+ await OpenFeature.setProviderAndWait(provider, {}, { validateContext });
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY);
+
+ validateContext.mockImplementation(() => {
+ throw new Error('Validation error');
+ });
+ await OpenFeature.setContext({ user: 'test-user' });
+
+ expect(validateContext).toHaveBeenCalledTimes(2);
+ expect(initializeMock).toHaveBeenCalledTimes(1);
+ expect(contextChangeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getContext()).toEqual({ user: 'test-user' });
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR);
+ });
+ });
+
+ describe('when the provider is not yet initialized', () => {
+ it('should move to ERROR status on context change', async () => {
+ const provider = new MockProvider();
+ const validateContext = jest.fn().mockReturnValue(false);
+ await OpenFeature.setProviderAndWait(provider, {}, { validateContext });
+
+ expect(validateContext).toHaveBeenCalledTimes(1);
+ expect(initializeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY);
+
+ validateContext.mockImplementation(() => {
+ throw new Error('Validation error');
+ });
+ await OpenFeature.setContext({ user: 'test-user' });
+
+ expect(validateContext).toHaveBeenCalledTimes(2);
+ expect(initializeMock).toHaveBeenCalledTimes(0);
+ expect(contextChangeMock).toHaveBeenCalledTimes(0);
+ expect(OpenFeature.getContext()).toEqual({ user: 'test-user' });
+ expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR);
+ });
+ });
+ });
+});