diff --git a/packages/client/src/provider/in-memory-provider/flag-configuration.ts b/packages/client/src/provider/in-memory-provider/flag-configuration.ts new file mode 100644 index 000000000..2bbfc9173 --- /dev/null +++ b/packages/client/src/provider/in-memory-provider/flag-configuration.ts @@ -0,0 +1,36 @@ +/** + * Don't export types from this file publicly. + * It might cause confusion since these types are not a part of the general API, + * but just for the in-memory provider. + */ +import { EvaluationContext, JsonValue } from '@openfeature/core'; + +type Variants = Record; + +/** + * A Feature Flag definition, containing it's specification + */ +export type Flag = { + /** + * An object containing all possible flags mappings (variant -> flag value) + */ + variants: Variants | Variants | Variants | Variants; + /** + * The variant it will resolve to in STATIC evaluation + */ + defaultVariant: string; + /** + * Determines if flag evaluation is enabled or not for this flag. + * If false, falls back to the default value provided to the client + */ + disabled: boolean; + /** + * Function used in order to evaluate a flag to a specific value given the provided context. + * It should return a variant key. + * If it does not return a valid variant it falls back to the default value provided to the client + * @param EvaluationContext + */ + contextEvaluator?: (ctx: EvaluationContext) => string; +}; + +export type FlagConfiguration = Record; diff --git a/packages/client/src/provider/in-memory-provider/in-memory-provider.ts b/packages/client/src/provider/in-memory-provider/in-memory-provider.ts new file mode 100644 index 000000000..46a8165f9 --- /dev/null +++ b/packages/client/src/provider/in-memory-provider/in-memory-provider.ts @@ -0,0 +1,179 @@ +import { + EvaluationContext, + FlagNotFoundError, + FlagValueType, + GeneralError, + JsonValue, + Logger, + OpenFeatureError, + ProviderEvents, + ResolutionDetails, + StandardResolutionReasons, + TypeMismatchError, + ProviderStatus, +} from '@openfeature/core'; +import { Provider } from '../provider'; +import { OpenFeatureEventEmitter } from '../../events'; +import { FlagConfiguration, Flag } from './flag-configuration'; +import { VariantNotFoundError } from './variant-not-found-error'; + +/** + * A simple OpenFeature provider intended for demos and as a test stub. + */ +export class InMemoryProvider implements Provider { + public readonly events = new OpenFeatureEventEmitter(); + public readonly runsOn = 'client'; + status: ProviderStatus = ProviderStatus.NOT_READY; + readonly metadata = { + name: 'in-memory', + } as const; + private _flagConfiguration: FlagConfiguration; + private _context: EvaluationContext | undefined; + + constructor(flagConfiguration: FlagConfiguration = {}) { + this._flagConfiguration = { ...flagConfiguration }; + } + + async initialize(context?: EvaluationContext | undefined): Promise { + try { + + for (const key in this._flagConfiguration) { + this.resolveFlagWithReason(key, context); + } + + this._context = context; + // set the provider's state, but don't emit events manually; + // the SDK does this based on the resolution/rejection of the init promise + this.status = ProviderStatus.READY; + } catch (error) { + this.status = ProviderStatus.ERROR; + throw error; + } + } + + /** + * Overwrites the configured flags. + * @param { FlagConfiguration } flagConfiguration new flag configuration + */ + async putConfiguration(flagConfiguration: FlagConfiguration) { + const flagsChanged = Object.entries(flagConfiguration) + .filter(([key, value]) => this._flagConfiguration[key] !== value) + .map(([key]) => key); + + this.status = ProviderStatus.STALE; + this.events.emit(ProviderEvents.Stale); + + this._flagConfiguration = { ...flagConfiguration }; + this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged }); + + try { + await this.initialize(this._context); + // we need to emit our own events in this case, since it's not part of the init flow. + this.events.emit(ProviderEvents.Ready); + } catch (err) { + this.events.emit(ProviderEvents.Error); + throw err; + } + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + logger?: Logger, + ): ResolutionDetails { + return this.resolveAndCheckFlag(flagKey, defaultValue, context || this._context, logger); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + logger?: Logger, + ): ResolutionDetails { + return this.resolveAndCheckFlag(flagKey, defaultValue, context || this._context, logger); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + logger?: Logger, + ): ResolutionDetails { + return this.resolveAndCheckFlag(flagKey, defaultValue, context || this._context, logger); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + logger?: Logger, + ): ResolutionDetails { + return this.resolveAndCheckFlag(flagKey, defaultValue, context || this._context, logger); + } + + private resolveAndCheckFlag(flagKey: string, + defaultValue: T, context?: EvaluationContext, logger?: Logger): ResolutionDetails { + if (!(flagKey in this._flagConfiguration)) { + const message = `no flag found with key ${flagKey}`; + logger?.debug(message); + throw new FlagNotFoundError(message); + } + + if (this._flagConfiguration[flagKey].disabled) { + return { value: defaultValue, reason: StandardResolutionReasons.DISABLED }; + } + + const resolvedFlag = this.resolveFlagWithReason(flagKey, context) as ResolutionDetails; + + if (resolvedFlag.value === undefined) { + const message = `no value associated with variant provided for ${flagKey} found`; + logger?.error(message); + throw new VariantNotFoundError(message); + } + + if (typeof resolvedFlag.value != typeof defaultValue) { + throw new TypeMismatchError(); + } + + return resolvedFlag; + } + + private resolveFlagWithReason( + flagKey: string, + ctx?: EvaluationContext, + ): ResolutionDetails { + try { + const resolutionResult = this.lookupFlagValue(flagKey, ctx); + + return resolutionResult; + } catch (error: unknown) { + if (!(error instanceof OpenFeatureError)) { + throw new GeneralError((error as Error)?.message || 'unknown error'); + } + throw error; + } + } + + private lookupFlagValue( + flagKey: string, + ctx?: EvaluationContext, + ): ResolutionDetails { + const flagSpec: Flag = this._flagConfiguration[flagKey]; + + const isContextEval = ctx && flagSpec?.contextEvaluator; + const variant = isContextEval ? flagSpec.contextEvaluator?.(ctx) : flagSpec.defaultVariant; + + const value = variant && flagSpec?.variants[variant]; + + const evalReason = isContextEval ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC; + + const reason = this.status === ProviderStatus.STALE ? StandardResolutionReasons.CACHED : evalReason; + + return { + value: value as T, + ...(variant && { variant }), + reason, + }; + } +} diff --git a/packages/client/src/provider/in-memory-provider/index.ts b/packages/client/src/provider/in-memory-provider/index.ts new file mode 100644 index 000000000..1bf0d5376 --- /dev/null +++ b/packages/client/src/provider/in-memory-provider/index.ts @@ -0,0 +1 @@ +export * from './in-memory-provider'; diff --git a/packages/client/src/provider/in-memory-provider/variant-not-found-error.ts b/packages/client/src/provider/in-memory-provider/variant-not-found-error.ts new file mode 100644 index 000000000..8b6439829 --- /dev/null +++ b/packages/client/src/provider/in-memory-provider/variant-not-found-error.ts @@ -0,0 +1,15 @@ +import { ErrorCode, OpenFeatureError } from '@openfeature/core'; + +/** + * A custom error for the in-memory provider. + * Indicates the resolved or default variant doesn't exist. + */ +export class VariantNotFoundError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, VariantNotFoundError.prototype); + this.name = 'VariantNotFoundError'; + this.code = ErrorCode.GENERAL; + } +} diff --git a/packages/client/src/provider/index.ts b/packages/client/src/provider/index.ts index 79eff2101..33e12fea3 100644 --- a/packages/client/src/provider/index.ts +++ b/packages/client/src/provider/index.ts @@ -1,2 +1,3 @@ export * from './provider'; export * from './no-op-provider'; +export * from './in-memory-provider'; diff --git a/packages/client/test/in-memory-provider.spec.ts b/packages/client/test/in-memory-provider.spec.ts new file mode 100644 index 000000000..23854cbc0 --- /dev/null +++ b/packages/client/test/in-memory-provider.spec.ts @@ -0,0 +1,602 @@ +import { FlagNotFoundError, GeneralError, ProviderEvents, ProviderStatus, StandardResolutionReasons, TypeMismatchError } from '@openfeature/core'; +import { InMemoryProvider } from '../src'; +import { FlagConfiguration } from '../src/provider/in-memory-provider/flag-configuration'; +import { VariantNotFoundError } from '../src/provider/in-memory-provider/variant-not-found-error'; + +describe(InMemoryProvider, () => { + describe('initialize', () => { + it('Should have provider status as NOT_READY after instantiation and emit READY and have READY state after initialuzation', async () => { + const booleanFlagSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + const provider = new InMemoryProvider(booleanFlagSpec); + expect(provider.status).toBe(ProviderStatus.NOT_READY); + + await provider.initialize(); + expect(provider.status).toBe(ProviderStatus.READY); + + }); + + it('Should have provider status as ERROR after instantiation, emit ERROR and have ERROR state if initialization throws', async () => { + const throwingFlagSpec: FlagConfiguration = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + contextEvaluator: () => { throw new GeneralError('context eval error'); }, + }, + }; + const provider = new InMemoryProvider(throwingFlagSpec); + + expect(provider.status).toBe(ProviderStatus.NOT_READY); + const someContext = {}; + + await expect(provider.initialize(someContext)).rejects.toThrow(); + expect(provider.status).toBe(ProviderStatus.ERROR); + }); + }); + + describe('boolean flags', () => { + const provider = new InMemoryProvider({}); + it('resolves to default variant with reason static', async () => { + const booleanFlagSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + await provider.putConfiguration(booleanFlagSpec); + + const resolution = provider.resolveBooleanEvaluation('a-boolean-flag', true); + + expect(resolution).toEqual({ value: true, reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + + it('throws FlagNotFound if flag does not exist', async () => { + const booleanFlagSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + await provider.putConfiguration(booleanFlagSpec); + + expect(() => provider.resolveBooleanEvaluation('another-boolean-flag', false)).toThrow(); + }); + + it('resolves to default value with reason disabled if flag is disabled', async () => { + const booleanFlagDisabledSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: true, + defaultVariant: 'on', + }, + }; + await provider.putConfiguration(booleanFlagDisabledSpec); + + const resolution = provider.resolveBooleanEvaluation('a-boolean-flag', false); + + expect(resolution).toEqual({ value: false, reason: StandardResolutionReasons.DISABLED }); + }); + + it('throws VariantNotFoundError if variant does not exist', async () => { + const booleanFlagSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + await provider.putConfiguration(booleanFlagSpec); + + expect(() => provider.resolveBooleanEvaluation('a-boolean-flag', false)).toThrow(VariantNotFoundError); + }); + + it('throws TypeMismatchError if variant type does not match with accessors', async () => { + const booleanFlagSpec = { + 'a-boolean-flag': { + variants: { + on: 'yes', + off: 'no', + }, + disabled: false, + defaultVariant: 'on', + }, + }; + await provider.putConfiguration(booleanFlagSpec); + + expect(() => provider.resolveBooleanEvaluation('a-boolean-flag', false)).toThrow(TypeMismatchError); + }); + + it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => { + const booleanFlagCtxSpec = { + 'a-boolean-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + contextEvaluator: () => 'off', + }, + }; + const dummyContext = {}; + await provider.putConfiguration(booleanFlagCtxSpec); + + const resolution = provider.resolveBooleanEvaluation('a-boolean-flag', true, dummyContext); + + expect(resolution).toEqual({ value: false, reason: StandardResolutionReasons.TARGETING_MATCH, variant: 'off' }); + }); + }); + + describe('string flags', () => { + const provider = new InMemoryProvider({}); + const itsDefault = "it's deafault"; + const itsOn = "it's on"; + const itsOff = "it's off"; + it('resolves to default variant with reason static', async () => { + const stringFlagSpec = { + 'a-string-flag': { + variants: { + on: itsOn, + off: itsOff, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + await provider.putConfiguration(stringFlagSpec); + + const resolution = provider.resolveStringEvaluation('a-string-flag', itsDefault); + + expect(resolution).toEqual({ value: itsOn, reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + + it('throws FlagNotFound if flag does not exist', async () => { + const StringFlagSpec = { + 'a-string-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + + await provider.putConfiguration(StringFlagSpec); + + expect(() => provider.resolveStringEvaluation('another-string-flag', itsDefault)).toThrow(FlagNotFoundError); + }); + + it('resolves to default value with reason disabled if flag is disabled', async () => { + const StringFlagDisabledSpec = { + 'a-string-flag': { + variants: { + on: itsOn, + off: itsOff, + }, + disabled: true, + defaultVariant: 'on', + }, + }; + await provider.putConfiguration(StringFlagDisabledSpec); + + const resolution = provider.resolveStringEvaluation('a-string-flag', itsDefault); + + expect(resolution).toEqual({ value: itsDefault, reason: StandardResolutionReasons.DISABLED }); + }); + + it('throws VariantNotFoundError if variant does not exist', async () => { + const StringFlagSpec = { + 'a-string-flag': { + variants: { + on: itsOn, + off: itsOff, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + await provider.putConfiguration(StringFlagSpec); + + expect(() => provider.resolveStringEvaluation('a-string-flag', itsDefault)).toThrow(VariantNotFoundError); + }); + + it('throws TypeMismatchError if variant does not match with accessor method type', async () => { + const StringFlagSpec = { + 'a-string-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + + await provider.putConfiguration(StringFlagSpec); + + expect(() => provider.resolveStringEvaluation('a-string-flag', itsDefault)).toThrow(TypeMismatchError); + }); + + it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => { + const StringFlagCtxSpec = { + 'a-string-flag': { + variants: { + on: itsOn, + off: itsOff, + }, + disabled: false, + defaultVariant: 'on', + contextEvaluator: () => 'off', + }, + }; + const dummyContext = {}; + await provider.putConfiguration(StringFlagCtxSpec); + + const resolution = provider.resolveStringEvaluation('a-string-flag', itsDefault, dummyContext); + + expect(resolution).toEqual({ value: itsOff, reason: StandardResolutionReasons.TARGETING_MATCH, variant: 'off' }); + }); + }); + + describe('number flags', () => { + const provider = new InMemoryProvider({}); + const defaultNumber = 42; + const onNumber = -528; + const offNumber = 0; + it('resolves to default variant with reason static', async () => { + const numberFlagSpec = { + 'a-number-flag': { + variants: { + on: onNumber, + off: offNumber, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + await provider.putConfiguration(numberFlagSpec); + + const resolution = provider.resolveNumberEvaluation('a-number-flag', defaultNumber); + + expect(resolution).toEqual({ value: onNumber, reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + + it('throws FlagNotFound if flag does not exist', async () => { + const numberFlagSpec = { + 'a-number-flag': { + variants: { + on: onNumber, + off: offNumber, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + await provider.putConfiguration(numberFlagSpec); + + expect(() => provider.resolveNumberEvaluation('another-number-flag', defaultNumber)).toThrow(FlagNotFoundError); + }); + + it('resolves to default value with reason disabled if flag is disabled', async () => { + const numberFlagDisabledSpec = { + 'a-number-flag': { + variants: { + on: onNumber, + off: offNumber, + }, + disabled: true, + defaultVariant: 'on', + }, + }; + await provider.putConfiguration(numberFlagDisabledSpec); + + const resolution = provider.resolveNumberEvaluation('a-number-flag', defaultNumber); + + expect(resolution).toEqual({ value: defaultNumber, reason: StandardResolutionReasons.DISABLED }); + }); + + it('throws VariantNotFoundError if variant does not exist', async () => { + const numberFlagSpec = { + 'a-number-flag': { + variants: { + on: onNumber, + off: offNumber, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + await provider.putConfiguration(numberFlagSpec); + + expect(() => provider.resolveNumberEvaluation('a-number-flag', defaultNumber)).toThrow(VariantNotFoundError); + }); + + it('throws TypeMismatchError if variant does not match with accessor method type', async () => { + const numberFlagSpec = { + 'a-number-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + + await provider.putConfiguration(numberFlagSpec); + + expect(() => provider.resolveNumberEvaluation('a-number-flag', defaultNumber)).toThrow(TypeMismatchError); + }); + + it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => { + const numberFlagCtxSpec = { + 'a-number-flag': { + variants: { + on: onNumber, + off: offNumber, + }, + disabled: false, + defaultVariant: 'on', + contextEvaluator: () => 'off', + }, + }; + const dummyContext = {}; + await provider.putConfiguration(numberFlagCtxSpec); + + const resolution = provider.resolveNumberEvaluation('a-number-flag', defaultNumber, dummyContext); + + expect(resolution).toEqual({ + value: offNumber, + reason: StandardResolutionReasons.TARGETING_MATCH, + variant: 'off', + }); + }); + }); + + describe('Object flags', () => { + const provider = new InMemoryProvider({}); + const defaultObject = { someKey: 'default' }; + const onObject = { someKey: 'on' }; + const offObject = { someKey: 'off' }; + it('resolves to default variant with reason static', async () => { + const ObjectFlagSpec = { + 'a-object-flag': { + variants: { + on: onObject, + off: offObject, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + await provider.putConfiguration(ObjectFlagSpec); + + const resolution = provider.resolveObjectEvaluation('a-object-flag', defaultObject); + + expect(resolution).toEqual({ value: onObject, reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + + it('throws FlagNotFound if flag does not exist', async () => { + const ObjectFlagSpec = { + 'a-Object-flag': { + variants: { + on: onObject, + off: offObject, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + await provider.putConfiguration(ObjectFlagSpec); + + expect(() => provider.resolveObjectEvaluation('another-number-flag', defaultObject)).toThrow(FlagNotFoundError); + }); + + it('resolves to default value with reason disabled if flag is disabled', async () => { + const ObjectFlagDisabledSpec = { + 'a-object-flag': { + variants: { + on: onObject, + off: offObject, + }, + disabled: true, + defaultVariant: 'on', + }, + }; + await provider.putConfiguration(ObjectFlagDisabledSpec); + + const resolution = provider.resolveObjectEvaluation('a-object-flag', defaultObject); + + expect(resolution).toEqual({ value: defaultObject, reason: StandardResolutionReasons.DISABLED }); + }); + + it('throws VariantNotFoundError if variant does not exist', async () => { + const ObjectFlagSpec = { + 'a-Object-flag': { + variants: { + on: onObject, + off: offObject, + }, + disabled: false, + defaultVariant: 'dummy', + }, + }; + await provider.putConfiguration(ObjectFlagSpec); + + expect(() => provider.resolveObjectEvaluation('a-Object-flag', defaultObject)).toThrow(VariantNotFoundError); + }); + + it('throws TypeMismatchError if variant does not match with accessor method type', async () => { + const ObjectFlagSpec = { + 'a-object-flag': { + variants: { + on: true, + off: false, + }, + disabled: false, + defaultVariant: 'on', + }, + }; + + await provider.putConfiguration(ObjectFlagSpec); + + expect(() => provider.resolveObjectEvaluation('a-object-flag', defaultObject)).toThrow(TypeMismatchError); + }); + + it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => { + const ObjectFlagCtxSpec = { + 'a-object-flag': { + variants: { + on: onObject, + off: offObject, + }, + disabled: false, + defaultVariant: 'on', + contextEvaluator: () => 'off', + }, + }; + const dummyContext = {}; + await provider.putConfiguration(ObjectFlagCtxSpec); + + const resolution = provider.resolveObjectEvaluation('a-object-flag', defaultObject, dummyContext); + + expect(resolution).toEqual({ + value: offObject, + reason: StandardResolutionReasons.TARGETING_MATCH, + variant: 'off', + }); + }); + }); + + describe('events', () => { + it('emits provider changed event, ready event and has READY status', async () => { + const flagsSpec = { + 'some-flag': { + variants: { + on: 'initial-value', + }, + defaultVariant: 'on', + disabled: false, + }, + }; + const provider = new InMemoryProvider(flagsSpec); + + const configChangedSpy = jest.fn(); + provider.events.addHandler(ProviderEvents.ConfigurationChanged, configChangedSpy); + + const readySpy = jest.fn(); + provider.events.addHandler(ProviderEvents.Ready, readySpy); + + const newFlagSpec = { + 'some-flag': { + variants: { + off: 'some-other-value', + }, + defaultVariant: 'off', + disabled: false, + }, + }; + await provider.putConfiguration(newFlagSpec); + + expect(configChangedSpy).toHaveBeenCalledWith({ flagsChanged: ['some-flag'] }); + expect(readySpy).toHaveBeenCalled(); + expect(provider.status).toBe(ProviderStatus.READY); + }); + }); + + describe('Flags configuration', () => { + it('reflects changes in flag configuration', async () => { + const provider = new InMemoryProvider({ + 'some-flag': { + variants: { + on: 'initial-value', + }, + defaultVariant: 'on', + disabled: false, + }, + }); + + await provider.initialize(); + + const firstResolution = provider.resolveStringEvaluation('some-flag', 'deafaultFirstResolution'); + + expect(firstResolution).toEqual({ + value: 'initial-value', + reason: StandardResolutionReasons.STATIC, + variant: 'on', + }); + + await provider.putConfiguration({ + 'some-flag': { + variants: { + on: 'new-value', + }, + defaultVariant: 'on', + disabled: false, + }, + }); + + const secondResolution = provider.resolveStringEvaluation('some-flag', 'defaultSecondResolution'); + + expect(secondResolution).toEqual({ value: 'new-value', reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + + it('does not let you change values with the configuration passed by reference', async () => { + const flagsSpec = { + 'some-flag': { + variants: { + on: 'initial-value', + }, + defaultVariant: 'on', + disabled: false, + }, + }; + + const substituteSpec = { + variants: { + on: 'some-other-value', + }, + defaultVariant: 'on', + disabled: false, + }; + + const provider = new InMemoryProvider(flagsSpec); + + await provider.initialize(); + + // I passed configuration by reference, so maybe I can mess + // with it behind the providers back! + flagsSpec['some-flag'] = substituteSpec; + + const resolution = provider.resolveStringEvaluation('some-flag', 'default value'); + expect(resolution).toEqual({ value: 'initial-value', reason: StandardResolutionReasons.STATIC, variant: 'on' }); + }); + }); +});