diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1493d90..4040b2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,10 @@ jobs: matrix: node-version: [18.x, 20.x] + defaults: + run: + working-directory: sdk/feature-management + steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} @@ -27,8 +31,19 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'npm' - - run: npm ci - - run: npm run lint - - run: npm run build - - run: npm run test - - run: npm run test-browser \ No newline at end of file + cache-dependency-path: sdk/feature-management/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run lint check + run: npm run lint + + - name: Run build + run: npm run build + + - name: Run tests + run: npm run test + + - name: Run browser tests + run: npm run test-browser \ No newline at end of file diff --git a/scripts/build-and-pack.sh b/scripts/build-and-pack.sh index dba0204..aa244b9 100755 --- a/scripts/build-and-pack.sh +++ b/scripts/build-and-pack.sh @@ -9,19 +9,38 @@ SCRIPT_DIR=$(dirname $(readlink -f $0)) # Get the directory of the project. PROJECT_BASE_DIR=$(dirname $SCRIPT_DIR) -# Change to the project directory. -cd $PROJECT_BASE_DIR - -# Install dependencies, build, and test. -echo "npm clean install" -npm ci - -echo "npm run build" -npm run build - -echo "npm run test" -npm run test - -# Create a tarball. -echo "npm pack" -npm pack +# Define the SDK directory. +SDK_DIR="$PROJECT_BASE_DIR/sdk" + +# Check if a package directory argument is provided. +if [ -z "$1" ]; then + echo "Please specify a package directory." + exit 1 +fi + +# The directory of the package to build. +PACKAGE_DIR="$SDK_DIR/$1" + +if [ -d "$PACKAGE_DIR" ]; then + echo "Building package in $PACKAGE_DIR" + + # Change to the package directory. + cd "$PACKAGE_DIR" + + # Install dependencies, build, and test. + echo "npm clean install in $PACKAGE_DIR" + npm ci + + echo "npm run build in $PACKAGE_DIR" + npm run build + + echo "npm run test in $PACKAGE_DIR" + npm run test + + # Create a tarball. + echo "npm pack in $PACKAGE_DIR" + npm pack +else + echo "The specified package directory does not exist." + exit 1 +fi diff --git a/.eslintrc b/sdk/feature-management/.eslintrc similarity index 100% rename from .eslintrc rename to sdk/feature-management/.eslintrc diff --git a/package-lock.json b/sdk/feature-management/package-lock.json similarity index 100% rename from package-lock.json rename to sdk/feature-management/package-lock.json diff --git a/package.json b/sdk/feature-management/package.json similarity index 100% rename from package.json rename to sdk/feature-management/package.json diff --git a/playwright.config.ts b/sdk/feature-management/playwright.config.ts similarity index 100% rename from playwright.config.ts rename to sdk/feature-management/playwright.config.ts diff --git a/rollup.config.mjs b/sdk/feature-management/rollup.config.mjs similarity index 95% rename from rollup.config.mjs rename to sdk/feature-management/rollup.config.mjs index 42018b0..2b19caf 100644 --- a/rollup.config.mjs +++ b/sdk/feature-management/rollup.config.mjs @@ -1,57 +1,57 @@ -// rollup.config.js -import typescript from "@rollup/plugin-typescript"; -import dts from "rollup-plugin-dts"; - -export default [ - { - external: ["crypto"], - input: "src/index.ts", - output: [ - { - dir: "dist/commonjs/", - format: "cjs", - sourcemap: true, - preserveModules: true, - }, - { - dir: "dist/esm/", - format: "esm", - sourcemap: true, - preserveModules: true, - }, - { - file: "dist/umd/index.js", - format: "umd", - name: 'FeatureManagement', - sourcemap: true - } - ], - plugins: [ - typescript({ - compilerOptions: { - "lib": [ - "DOM", - "WebWorker", - "ESNext" - ], - "skipDefaultLibCheck": true, - "module": "ESNext", - "moduleResolution": "Node", - "target": "ES2022", - "strictNullChecks": true, - "strictFunctionTypes": true, - "sourceMap": true, - "inlineSources": true - }, - "exclude": [ - "test/**/*" - ] - }) - ], - }, - { - input: "src/index.ts", - output: [{ file: "types/index.d.ts", format: "esm" }], - plugins: [dts()], - }, -]; +// rollup.config.js +import typescript from "@rollup/plugin-typescript"; +import dts from "rollup-plugin-dts"; + +export default [ + { + external: ["crypto"], + input: "src/index.ts", + output: [ + { + dir: "dist/commonjs/", + format: "cjs", + sourcemap: true, + preserveModules: true, + }, + { + dir: "dist/esm/", + format: "esm", + sourcemap: true, + preserveModules: true, + }, + { + file: "dist/umd/index.js", + format: "umd", + name: 'FeatureManagement', + sourcemap: true + } + ], + plugins: [ + typescript({ + compilerOptions: { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + }, + "exclude": [ + "test/**/*" + ] + }) + ], + }, + { + input: "src/index.ts", + output: [{ file: "types/index.d.ts", format: "esm" }], + plugins: [dts()], + }, +]; diff --git a/src/IFeatureManager.ts b/sdk/feature-management/src/IFeatureManager.ts similarity index 97% rename from src/IFeatureManager.ts rename to sdk/feature-management/src/IFeatureManager.ts index f7975f1..d673dce 100644 --- a/src/IFeatureManager.ts +++ b/sdk/feature-management/src/IFeatureManager.ts @@ -1,26 +1,26 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { ITargetingContext } from "./common/ITargetingContext"; -import { Variant } from "./variant/Variant"; - -export interface IFeatureManager { - /** - * Get the list of feature names. - */ - listFeatureNames(): Promise; - - /** - * Check if a feature is enabled. - * @param featureName name of the feature. - * @param context an object providing information that can be used to evaluate whether a feature should be on or off. - */ - isEnabled(featureName: string, context?: unknown): Promise; - - /** - * Get the allocated variant of a feature given the targeting context. - * @param featureName name of the feature. - * @param context a targeting context object used to evaluate which variant the user will be assigned. - */ - getVariant(featureName: string, context: ITargetingContext): Promise; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ITargetingContext } from "./common/ITargetingContext"; +import { Variant } from "./variant/Variant"; + +export interface IFeatureManager { + /** + * Get the list of feature names. + */ + listFeatureNames(): Promise; + + /** + * Check if a feature is enabled. + * @param featureName name of the feature. + * @param context an object providing information that can be used to evaluate whether a feature should be on or off. + */ + isEnabled(featureName: string, context?: unknown): Promise; + + /** + * Get the allocated variant of a feature given the targeting context. + * @param featureName name of the feature. + * @param context a targeting context object used to evaluate which variant the user will be assigned. + */ + getVariant(featureName: string, context: ITargetingContext): Promise; +} diff --git a/src/common/ITargetingContext.ts b/sdk/feature-management/src/common/ITargetingContext.ts similarity index 95% rename from src/common/ITargetingContext.ts rename to sdk/feature-management/src/common/ITargetingContext.ts index 758ec52..1d5a426 100644 --- a/src/common/ITargetingContext.ts +++ b/sdk/feature-management/src/common/ITargetingContext.ts @@ -1,8 +1,8 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export interface ITargetingContext { - userId?: string; - groups?: string[]; -} - +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface ITargetingContext { + userId?: string; + groups?: string[]; +} + diff --git a/src/common/targetingEvaluator.ts b/sdk/feature-management/src/common/targetingEvaluator.ts similarity index 97% rename from src/common/targetingEvaluator.ts rename to sdk/feature-management/src/common/targetingEvaluator.ts index 02d1277..a946fa4 100644 --- a/src/common/targetingEvaluator.ts +++ b/sdk/feature-management/src/common/targetingEvaluator.ts @@ -1,124 +1,124 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -/** - * Determines if the user is part of the audience, based on the user id and the percentage range. - * - * @param userId user id from app context - * @param hint hint string to be included in the context id - * @param from percentage range start - * @param to percentage range end - * @returns true if the user is part of the audience, false otherwise - */ -export async function isTargetedPercentile(userId: string | undefined, hint: string, from: number, to: number): Promise { - if (from < 0 || from > 100) { - throw new Error("The 'from' value must be between 0 and 100."); - } - if (to < 0 || to > 100) { - throw new Error("The 'to' value must be between 0 and 100."); - } - if (from > to) { - throw new Error("The 'from' value cannot be larger than the 'to' value."); - } - - const audienceContextId = constructAudienceContextId(userId, hint); - - // Cryptographic hashing algorithms ensure adequate entropy across hash values. - const contextMarker = await stringToUint32(audienceContextId); - const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; - - // Handle edge case of exact 100 bucket - if (to === 100) { - return contextPercentage >= from; - } - - return contextPercentage >= from && contextPercentage < to; -} - -/** - * Determines if the user is part of the audience, based on the groups they belong to. - * - * @param sourceGroups user groups from app context - * @param targetedGroups targeted groups from feature configuration - * @returns true if the user is part of the audience, false otherwise - */ -export function isTargetedGroup(sourceGroups: string[] | undefined, targetedGroups: string[]): boolean { - if (sourceGroups === undefined) { - return false; - } - - return sourceGroups.some(group => targetedGroups.includes(group)); -} - -/** - * Determines if the user is part of the audience, based on the user id. - * @param userId user id from app context - * @param users targeted users from feature configuration - * @returns true if the user is part of the audience, false otherwise - */ -export function isTargetedUser(userId: string | undefined, users: string[]): boolean { - if (userId === undefined) { - return false; - } - - return users.includes(userId); -} - -/** - * Constructs the context id for the audience. - * The context id is used to determine if the user is part of the audience for a feature. - * - * @param userId userId from app context - * @param hint hint string to be included in the context id - * @returns a string that represents the context id for the audience - */ -function constructAudienceContextId(userId: string | undefined, hint: string): string { - return `${userId ?? ""}\n${hint}`; -} - -/** - * Converts a string to a uint32 in little-endian encoding. - * @param str the string to convert. - * @returns a uint32 value. - */ -async function stringToUint32(str: string): Promise { - let crypto; - - // Check for browser environment - if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) { - crypto = window.crypto; - } - // Check for Node.js environment - else if (typeof global !== "undefined" && global.crypto) { - crypto = global.crypto; - } - // Fallback to native Node.js crypto module - else { - try { - if (typeof module !== "undefined" && module.exports) { - crypto = require("crypto"); - } - else { - crypto = await import("crypto"); - } - } catch (error) { - console.error("Failed to load the crypto module:", error.message); - throw error; - } - } - - // In the browser, use crypto.subtle.digest - if (crypto.subtle) { - const data = new TextEncoder().encode(str); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const dataView = new DataView(hashBuffer); - const uint32 = dataView.getUint32(0, true); - return uint32; - } - // In Node.js, use the crypto module's hash function - else { - const hash = crypto.createHash("sha256").update(str).digest(); - const uint32 = hash.readUInt32LE(0); - return uint32; - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Determines if the user is part of the audience, based on the user id and the percentage range. + * + * @param userId user id from app context + * @param hint hint string to be included in the context id + * @param from percentage range start + * @param to percentage range end + * @returns true if the user is part of the audience, false otherwise + */ +export async function isTargetedPercentile(userId: string | undefined, hint: string, from: number, to: number): Promise { + if (from < 0 || from > 100) { + throw new Error("The 'from' value must be between 0 and 100."); + } + if (to < 0 || to > 100) { + throw new Error("The 'to' value must be between 0 and 100."); + } + if (from > to) { + throw new Error("The 'from' value cannot be larger than the 'to' value."); + } + + const audienceContextId = constructAudienceContextId(userId, hint); + + // Cryptographic hashing algorithms ensure adequate entropy across hash values. + const contextMarker = await stringToUint32(audienceContextId); + const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; + + // Handle edge case of exact 100 bucket + if (to === 100) { + return contextPercentage >= from; + } + + return contextPercentage >= from && contextPercentage < to; +} + +/** + * Determines if the user is part of the audience, based on the groups they belong to. + * + * @param sourceGroups user groups from app context + * @param targetedGroups targeted groups from feature configuration + * @returns true if the user is part of the audience, false otherwise + */ +export function isTargetedGroup(sourceGroups: string[] | undefined, targetedGroups: string[]): boolean { + if (sourceGroups === undefined) { + return false; + } + + return sourceGroups.some(group => targetedGroups.includes(group)); +} + +/** + * Determines if the user is part of the audience, based on the user id. + * @param userId user id from app context + * @param users targeted users from feature configuration + * @returns true if the user is part of the audience, false otherwise + */ +export function isTargetedUser(userId: string | undefined, users: string[]): boolean { + if (userId === undefined) { + return false; + } + + return users.includes(userId); +} + +/** + * Constructs the context id for the audience. + * The context id is used to determine if the user is part of the audience for a feature. + * + * @param userId userId from app context + * @param hint hint string to be included in the context id + * @returns a string that represents the context id for the audience + */ +function constructAudienceContextId(userId: string | undefined, hint: string): string { + return `${userId ?? ""}\n${hint}`; +} + +/** + * Converts a string to a uint32 in little-endian encoding. + * @param str the string to convert. + * @returns a uint32 value. + */ +async function stringToUint32(str: string): Promise { + let crypto; + + // Check for browser environment + if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) { + crypto = window.crypto; + } + // Check for Node.js environment + else if (typeof global !== "undefined" && global.crypto) { + crypto = global.crypto; + } + // Fallback to native Node.js crypto module + else { + try { + if (typeof module !== "undefined" && module.exports) { + crypto = require("crypto"); + } + else { + crypto = await import("crypto"); + } + } catch (error) { + console.error("Failed to load the crypto module:", error.message); + throw error; + } + } + + // In the browser, use crypto.subtle.digest + if (crypto.subtle) { + const data = new TextEncoder().encode(str); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const dataView = new DataView(hashBuffer); + const uint32 = dataView.getUint32(0, true); + return uint32; + } + // In Node.js, use the crypto module's hash function + else { + const hash = crypto.createHash("sha256").update(str).digest(); + const uint32 = hash.readUInt32LE(0); + return uint32; + } +} diff --git a/src/featureManager.ts b/sdk/feature-management/src/featureManager.ts similarity index 97% rename from src/featureManager.ts rename to sdk/feature-management/src/featureManager.ts index 4ea34e1..fa6a3fc 100644 --- a/src/featureManager.ts +++ b/sdk/feature-management/src/featureManager.ts @@ -1,273 +1,273 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { TimeWindowFilter } from "./filter/TimeWindowFilter.js"; -import { IFeatureFilter } from "./filter/FeatureFilter.js"; -import { FeatureFlag, RequirementType, VariantDefinition } from "./model.js"; -import { IFeatureFlagProvider } from "./featureProvider.js"; -import { TargetingFilter } from "./filter/TargetingFilter.js"; -import { Variant } from "./variant/Variant.js"; -import { IFeatureManager } from "./IFeatureManager.js"; -import { ITargetingContext } from "./common/ITargetingContext.js"; -import { isTargetedGroup, isTargetedPercentile, isTargetedUser } from "./common/targetingEvaluator.js"; - -export class FeatureManager implements IFeatureManager { - #provider: IFeatureFlagProvider; - #featureFilters: Map = new Map(); - - constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) { - this.#provider = provider; - - const builtinFilters = [new TimeWindowFilter(), new TargetingFilter()]; - - // If a custom filter shares a name with an existing filter, the custom filter overrides the existing one. - for (const filter of [...builtinFilters, ...(options?.customFilters ?? [])]) { - this.#featureFilters.set(filter.name, filter); - } - } - - async listFeatureNames(): Promise { - const features = await this.#provider.getFeatureFlags(); - const featureNameSet = new Set(features.map((feature) => feature.id)); - return Array.from(featureNameSet); - } - - // If multiple feature flags are found, the first one takes precedence. - async isEnabled(featureName: string, context?: unknown): Promise { - const result = await this.#evaluateFeature(featureName, context); - return result.enabled; - } - - async getVariant(featureName: string, context?: ITargetingContext): Promise { - const result = await this.#evaluateFeature(featureName, context); - return result.variant; - } - - async #assignVariant(featureFlag: FeatureFlag, context: ITargetingContext): Promise { - // user allocation - if (featureFlag.allocation?.user !== undefined) { - for (const userAllocation of featureFlag.allocation.user) { - if (isTargetedUser(context.userId, userAllocation.users)) { - return getVariantAssignment(featureFlag, userAllocation.variant, VariantAssignmentReason.User); - } - } - } - - // group allocation - if (featureFlag.allocation?.group !== undefined) { - for (const groupAllocation of featureFlag.allocation.group) { - if (isTargetedGroup(context.groups, groupAllocation.groups)) { - return getVariantAssignment(featureFlag, groupAllocation.variant, VariantAssignmentReason.Group); - } - } - } - - // percentile allocation - if (featureFlag.allocation?.percentile !== undefined) { - for (const percentileAllocation of featureFlag.allocation.percentile) { - const hint = featureFlag.allocation.seed ?? `allocation\n${featureFlag.id}`; - if (await isTargetedPercentile(context.userId, hint, percentileAllocation.from, percentileAllocation.to)) { - return getVariantAssignment(featureFlag, percentileAllocation.variant, VariantAssignmentReason.Percentile); - } - } - } - - return { variant: undefined, reason: VariantAssignmentReason.None }; - } - - async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise { - if (featureFlag.enabled !== true) { - // If the feature is not explicitly enabled, then it is disabled by default. - return false; - } - - const clientFilters = featureFlag.conditions?.client_filters; - if (clientFilters === undefined || clientFilters.length <= 0) { - // If there are no client filters, then the feature is enabled. - return true; - } - - const requirementType: RequirementType = featureFlag.conditions?.requirement_type ?? "Any"; // default to any. - - /** - * While iterating through the client filters, we short-circuit the evaluation based on the requirement type. - * - When requirement type is "All", the feature is enabled if all client filters are matched. If any client filter is not matched, the feature is disabled, otherwise it is enabled. `shortCircuitEvaluationResult` is false. - * - When requirement type is "Any", the feature is enabled if any client filter is matched. If any client filter is matched, the feature is enabled, otherwise it is disabled. `shortCircuitEvaluationResult` is true. - */ - const shortCircuitEvaluationResult: boolean = requirementType === "Any"; - - for (const clientFilter of clientFilters) { - const matchedFeatureFilter = this.#featureFilters.get(clientFilter.name); - const contextWithFeatureName = { featureName: featureFlag.id, parameters: clientFilter.parameters }; - if (matchedFeatureFilter === undefined) { - console.warn(`Feature filter ${clientFilter.name} is not found.`); - return false; - } - if (await matchedFeatureFilter.evaluate(contextWithFeatureName, context) === shortCircuitEvaluationResult) { - return shortCircuitEvaluationResult; - } - } - - // If we get here, then we have not found a client filter that matches the requirement type. - return !shortCircuitEvaluationResult; - } - - async #evaluateFeature(featureName: string, context: unknown): Promise { - const featureFlag = await this.#provider.getFeatureFlag(featureName); - const result = new EvaluationResult(featureFlag); - - if (featureFlag === undefined) { - return result; - } - - // Ensure that the feature flag is in the correct format. Feature providers should validate the feature flags, but we do it here as a safeguard. - // TODO: move to the feature flag provider implementation. - validateFeatureFlagFormat(featureFlag); - - // Evaluate if the feature is enabled. - result.enabled = await this.#isEnabled(featureFlag, context); - - // Determine Variant - let variantDef: VariantDefinition | undefined; - let reason: VariantAssignmentReason = VariantAssignmentReason.None; - - // featureFlag.variant not empty - if (featureFlag.variants !== undefined && featureFlag.variants.length > 0) { - if (!result.enabled) { - // not enabled, assign default if specified - if (featureFlag.allocation?.default_when_disabled !== undefined) { - variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_disabled); - reason = VariantAssignmentReason.DefaultWhenDisabled; - } else { - // no default specified - variantDef = undefined; - reason = VariantAssignmentReason.DefaultWhenDisabled; - } - } else { - // enabled, assign based on allocation - if (context !== undefined && featureFlag.allocation !== undefined) { - const variantAndReason = await this.#assignVariant(featureFlag, context as ITargetingContext); - variantDef = variantAndReason.variant; - reason = variantAndReason.reason; - } - - // allocation failed, assign default if specified - if (variantDef === undefined && reason === VariantAssignmentReason.None) { - if (featureFlag.allocation?.default_when_enabled !== undefined) { - variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_enabled); - reason = VariantAssignmentReason.DefaultWhenEnabled; - } else { - variantDef = undefined; - reason = VariantAssignmentReason.DefaultWhenEnabled; - } - } - } - } - - // TODO: send telemetry for variant assignment reason in the future. - console.log(`Variant assignment for feature ${featureName}: ${variantDef?.name ?? "default"} (${reason})`); - - result.variant = variantDef !== undefined ? new Variant(variantDef.name, variantDef.configuration_value) : undefined; - result.variantAssignmentReason = reason; - - // Status override for isEnabled - if (variantDef !== undefined && featureFlag.enabled) { - if (variantDef.status_override === "Enabled") { - result.enabled = true; - } else if (variantDef.status_override === "Disabled") { - result.enabled = false; - } - } - - return result; - } -} - -interface FeatureManagerOptions { - customFilters?: IFeatureFilter[]; -} - -/** - * Validates the format of the feature flag definition. - * - * FeatureFlag data objects are from IFeatureFlagProvider, depending on the implementation. - * Thus the properties are not guaranteed to have the expected types. - * - * @param featureFlag The feature flag definition to validate. - */ -function validateFeatureFlagFormat(featureFlag: any): void { - if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") { - throw new Error(`Feature flag ${featureFlag.id} has an invalid 'enabled' value.`); - } - // TODO: add more validations. - // TODO: should be moved to the feature flag provider. -} - -/** - * Try to get the variant assignment for the given variant name. If the variant is not found, override the reason with VariantAssignmentReason.None. - * - * @param featureFlag feature flag definition - * @param variantName variant name - * @param reason variant assignment reason - * @returns variant assignment containing the variant definition and the reason - */ -function getVariantAssignment(featureFlag: FeatureFlag, variantName: string, reason: VariantAssignmentReason): VariantAssignment { - const variant = featureFlag.variants?.find(v => v.name == variantName); - if (variant !== undefined) { - return { variant, reason }; - } else { - console.warn(`Variant ${variantName} not found for feature ${featureFlag.id}.`); - return { variant: undefined, reason: VariantAssignmentReason.None }; - } -} - -type VariantAssignment = { - variant: VariantDefinition | undefined; - reason: VariantAssignmentReason; -}; - -enum VariantAssignmentReason { - /** - * Variant allocation did not happen. No variant is assigned. - */ - None, - - /** - * The default variant is assigned when a feature flag is disabled. - */ - DefaultWhenDisabled, - - /** - * The default variant is assigned because of no applicable user/group/percentile allocation when a feature flag is enabled. - */ - DefaultWhenEnabled, - - /** - * The variant is assigned because of the user allocation when a feature flag is enabled. - */ - User, - - /** - * The variant is assigned because of the group allocation when a feature flag is enabled. - */ - Group, - - /** - * The variant is assigned because of the percentile allocation when a feature flag is enabled. - */ - Percentile -} - -class EvaluationResult { - constructor( - // feature flag definition - public readonly feature: FeatureFlag | undefined, - - // enabled state - public enabled: boolean = false, - - // variant assignment - public variant: Variant | undefined = undefined, - public variantAssignmentReason: VariantAssignmentReason = VariantAssignmentReason.None - ) { } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TimeWindowFilter } from "./filter/TimeWindowFilter.js"; +import { IFeatureFilter } from "./filter/FeatureFilter.js"; +import { FeatureFlag, RequirementType, VariantDefinition } from "./model.js"; +import { IFeatureFlagProvider } from "./featureProvider.js"; +import { TargetingFilter } from "./filter/TargetingFilter.js"; +import { Variant } from "./variant/Variant.js"; +import { IFeatureManager } from "./IFeatureManager.js"; +import { ITargetingContext } from "./common/ITargetingContext.js"; +import { isTargetedGroup, isTargetedPercentile, isTargetedUser } from "./common/targetingEvaluator.js"; + +export class FeatureManager implements IFeatureManager { + #provider: IFeatureFlagProvider; + #featureFilters: Map = new Map(); + + constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) { + this.#provider = provider; + + const builtinFilters = [new TimeWindowFilter(), new TargetingFilter()]; + + // If a custom filter shares a name with an existing filter, the custom filter overrides the existing one. + for (const filter of [...builtinFilters, ...(options?.customFilters ?? [])]) { + this.#featureFilters.set(filter.name, filter); + } + } + + async listFeatureNames(): Promise { + const features = await this.#provider.getFeatureFlags(); + const featureNameSet = new Set(features.map((feature) => feature.id)); + return Array.from(featureNameSet); + } + + // If multiple feature flags are found, the first one takes precedence. + async isEnabled(featureName: string, context?: unknown): Promise { + const result = await this.#evaluateFeature(featureName, context); + return result.enabled; + } + + async getVariant(featureName: string, context?: ITargetingContext): Promise { + const result = await this.#evaluateFeature(featureName, context); + return result.variant; + } + + async #assignVariant(featureFlag: FeatureFlag, context: ITargetingContext): Promise { + // user allocation + if (featureFlag.allocation?.user !== undefined) { + for (const userAllocation of featureFlag.allocation.user) { + if (isTargetedUser(context.userId, userAllocation.users)) { + return getVariantAssignment(featureFlag, userAllocation.variant, VariantAssignmentReason.User); + } + } + } + + // group allocation + if (featureFlag.allocation?.group !== undefined) { + for (const groupAllocation of featureFlag.allocation.group) { + if (isTargetedGroup(context.groups, groupAllocation.groups)) { + return getVariantAssignment(featureFlag, groupAllocation.variant, VariantAssignmentReason.Group); + } + } + } + + // percentile allocation + if (featureFlag.allocation?.percentile !== undefined) { + for (const percentileAllocation of featureFlag.allocation.percentile) { + const hint = featureFlag.allocation.seed ?? `allocation\n${featureFlag.id}`; + if (await isTargetedPercentile(context.userId, hint, percentileAllocation.from, percentileAllocation.to)) { + return getVariantAssignment(featureFlag, percentileAllocation.variant, VariantAssignmentReason.Percentile); + } + } + } + + return { variant: undefined, reason: VariantAssignmentReason.None }; + } + + async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise { + if (featureFlag.enabled !== true) { + // If the feature is not explicitly enabled, then it is disabled by default. + return false; + } + + const clientFilters = featureFlag.conditions?.client_filters; + if (clientFilters === undefined || clientFilters.length <= 0) { + // If there are no client filters, then the feature is enabled. + return true; + } + + const requirementType: RequirementType = featureFlag.conditions?.requirement_type ?? "Any"; // default to any. + + /** + * While iterating through the client filters, we short-circuit the evaluation based on the requirement type. + * - When requirement type is "All", the feature is enabled if all client filters are matched. If any client filter is not matched, the feature is disabled, otherwise it is enabled. `shortCircuitEvaluationResult` is false. + * - When requirement type is "Any", the feature is enabled if any client filter is matched. If any client filter is matched, the feature is enabled, otherwise it is disabled. `shortCircuitEvaluationResult` is true. + */ + const shortCircuitEvaluationResult: boolean = requirementType === "Any"; + + for (const clientFilter of clientFilters) { + const matchedFeatureFilter = this.#featureFilters.get(clientFilter.name); + const contextWithFeatureName = { featureName: featureFlag.id, parameters: clientFilter.parameters }; + if (matchedFeatureFilter === undefined) { + console.warn(`Feature filter ${clientFilter.name} is not found.`); + return false; + } + if (await matchedFeatureFilter.evaluate(contextWithFeatureName, context) === shortCircuitEvaluationResult) { + return shortCircuitEvaluationResult; + } + } + + // If we get here, then we have not found a client filter that matches the requirement type. + return !shortCircuitEvaluationResult; + } + + async #evaluateFeature(featureName: string, context: unknown): Promise { + const featureFlag = await this.#provider.getFeatureFlag(featureName); + const result = new EvaluationResult(featureFlag); + + if (featureFlag === undefined) { + return result; + } + + // Ensure that the feature flag is in the correct format. Feature providers should validate the feature flags, but we do it here as a safeguard. + // TODO: move to the feature flag provider implementation. + validateFeatureFlagFormat(featureFlag); + + // Evaluate if the feature is enabled. + result.enabled = await this.#isEnabled(featureFlag, context); + + // Determine Variant + let variantDef: VariantDefinition | undefined; + let reason: VariantAssignmentReason = VariantAssignmentReason.None; + + // featureFlag.variant not empty + if (featureFlag.variants !== undefined && featureFlag.variants.length > 0) { + if (!result.enabled) { + // not enabled, assign default if specified + if (featureFlag.allocation?.default_when_disabled !== undefined) { + variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_disabled); + reason = VariantAssignmentReason.DefaultWhenDisabled; + } else { + // no default specified + variantDef = undefined; + reason = VariantAssignmentReason.DefaultWhenDisabled; + } + } else { + // enabled, assign based on allocation + if (context !== undefined && featureFlag.allocation !== undefined) { + const variantAndReason = await this.#assignVariant(featureFlag, context as ITargetingContext); + variantDef = variantAndReason.variant; + reason = variantAndReason.reason; + } + + // allocation failed, assign default if specified + if (variantDef === undefined && reason === VariantAssignmentReason.None) { + if (featureFlag.allocation?.default_when_enabled !== undefined) { + variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_enabled); + reason = VariantAssignmentReason.DefaultWhenEnabled; + } else { + variantDef = undefined; + reason = VariantAssignmentReason.DefaultWhenEnabled; + } + } + } + } + + // TODO: send telemetry for variant assignment reason in the future. + console.log(`Variant assignment for feature ${featureName}: ${variantDef?.name ?? "default"} (${reason})`); + + result.variant = variantDef !== undefined ? new Variant(variantDef.name, variantDef.configuration_value) : undefined; + result.variantAssignmentReason = reason; + + // Status override for isEnabled + if (variantDef !== undefined && featureFlag.enabled) { + if (variantDef.status_override === "Enabled") { + result.enabled = true; + } else if (variantDef.status_override === "Disabled") { + result.enabled = false; + } + } + + return result; + } +} + +interface FeatureManagerOptions { + customFilters?: IFeatureFilter[]; +} + +/** + * Validates the format of the feature flag definition. + * + * FeatureFlag data objects are from IFeatureFlagProvider, depending on the implementation. + * Thus the properties are not guaranteed to have the expected types. + * + * @param featureFlag The feature flag definition to validate. + */ +function validateFeatureFlagFormat(featureFlag: any): void { + if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") { + throw new Error(`Feature flag ${featureFlag.id} has an invalid 'enabled' value.`); + } + // TODO: add more validations. + // TODO: should be moved to the feature flag provider. +} + +/** + * Try to get the variant assignment for the given variant name. If the variant is not found, override the reason with VariantAssignmentReason.None. + * + * @param featureFlag feature flag definition + * @param variantName variant name + * @param reason variant assignment reason + * @returns variant assignment containing the variant definition and the reason + */ +function getVariantAssignment(featureFlag: FeatureFlag, variantName: string, reason: VariantAssignmentReason): VariantAssignment { + const variant = featureFlag.variants?.find(v => v.name == variantName); + if (variant !== undefined) { + return { variant, reason }; + } else { + console.warn(`Variant ${variantName} not found for feature ${featureFlag.id}.`); + return { variant: undefined, reason: VariantAssignmentReason.None }; + } +} + +type VariantAssignment = { + variant: VariantDefinition | undefined; + reason: VariantAssignmentReason; +}; + +enum VariantAssignmentReason { + /** + * Variant allocation did not happen. No variant is assigned. + */ + None, + + /** + * The default variant is assigned when a feature flag is disabled. + */ + DefaultWhenDisabled, + + /** + * The default variant is assigned because of no applicable user/group/percentile allocation when a feature flag is enabled. + */ + DefaultWhenEnabled, + + /** + * The variant is assigned because of the user allocation when a feature flag is enabled. + */ + User, + + /** + * The variant is assigned because of the group allocation when a feature flag is enabled. + */ + Group, + + /** + * The variant is assigned because of the percentile allocation when a feature flag is enabled. + */ + Percentile +} + +class EvaluationResult { + constructor( + // feature flag definition + public readonly feature: FeatureFlag | undefined, + + // enabled state + public enabled: boolean = false, + + // variant assignment + public variant: Variant | undefined = undefined, + public variantAssignmentReason: VariantAssignmentReason = VariantAssignmentReason.None + ) { } +} diff --git a/src/featureProvider.ts b/sdk/feature-management/src/featureProvider.ts similarity index 97% rename from src/featureProvider.ts rename to sdk/feature-management/src/featureProvider.ts index 9b63876..0f90ce6 100644 --- a/src/featureProvider.ts +++ b/sdk/feature-management/src/featureProvider.ts @@ -1,58 +1,58 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { IGettable } from "./gettable.js"; -import { FeatureFlag, FeatureManagementConfiguration, FEATURE_MANAGEMENT_KEY, FEATURE_FLAGS_KEY } from "./model.js"; - -export interface IFeatureFlagProvider { - /** - * Get all feature flags. - */ - getFeatureFlags(): Promise; - - /** - * Get a feature flag by name. - * @param featureName The name of the feature flag. - */ - getFeatureFlag(featureName: string): Promise; -} - -/** - * A feature flag provider that uses a map-like configuration to provide feature flags. - */ -export class ConfigurationMapFeatureFlagProvider implements IFeatureFlagProvider { - #configuration: IGettable; - - constructor(configuration: IGettable) { - this.#configuration = configuration; - } - async getFeatureFlag(featureName: string): Promise { - const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY); - return featureConfig?.[FEATURE_FLAGS_KEY]?.find((feature) => feature.id === featureName); - } - - async getFeatureFlags(): Promise { - const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY); - return featureConfig?.[FEATURE_FLAGS_KEY] ?? []; - } -} - -/** - * A feature flag provider that uses an object-like configuration to provide feature flags. - */ -export class ConfigurationObjectFeatureFlagProvider implements IFeatureFlagProvider { - #configuration: Record; - - constructor(configuration: Record) { - this.#configuration = configuration; - } - - async getFeatureFlag(featureName: string): Promise { - const featureFlags = this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY]; - return featureFlags?.find((feature: FeatureFlag) => feature.id === featureName); - } - - async getFeatureFlags(): Promise { - return this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY] ?? []; - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { IGettable } from "./gettable.js"; +import { FeatureFlag, FeatureManagementConfiguration, FEATURE_MANAGEMENT_KEY, FEATURE_FLAGS_KEY } from "./model.js"; + +export interface IFeatureFlagProvider { + /** + * Get all feature flags. + */ + getFeatureFlags(): Promise; + + /** + * Get a feature flag by name. + * @param featureName The name of the feature flag. + */ + getFeatureFlag(featureName: string): Promise; +} + +/** + * A feature flag provider that uses a map-like configuration to provide feature flags. + */ +export class ConfigurationMapFeatureFlagProvider implements IFeatureFlagProvider { + #configuration: IGettable; + + constructor(configuration: IGettable) { + this.#configuration = configuration; + } + async getFeatureFlag(featureName: string): Promise { + const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY); + return featureConfig?.[FEATURE_FLAGS_KEY]?.find((feature) => feature.id === featureName); + } + + async getFeatureFlags(): Promise { + const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY); + return featureConfig?.[FEATURE_FLAGS_KEY] ?? []; + } +} + +/** + * A feature flag provider that uses an object-like configuration to provide feature flags. + */ +export class ConfigurationObjectFeatureFlagProvider implements IFeatureFlagProvider { + #configuration: Record; + + constructor(configuration: Record) { + this.#configuration = configuration; + } + + async getFeatureFlag(featureName: string): Promise { + const featureFlags = this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY]; + return featureFlags?.find((feature: FeatureFlag) => feature.id === featureName); + } + + async getFeatureFlags(): Promise { + return this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY] ?? []; + } +} diff --git a/src/filter/FeatureFilter.ts b/sdk/feature-management/src/filter/FeatureFilter.ts similarity index 96% rename from src/filter/FeatureFilter.ts rename to sdk/feature-management/src/filter/FeatureFilter.ts index 4c259b9..f7e572b 100644 --- a/src/filter/FeatureFilter.ts +++ b/sdk/feature-management/src/filter/FeatureFilter.ts @@ -1,12 +1,12 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export interface IFeatureFilter { - name: string; // e.g. Microsoft.TimeWindow - evaluate(context: IFeatureFilterEvaluationContext, appContext?: unknown): boolean | Promise; -} - -export interface IFeatureFilterEvaluationContext { - featureName: string; - parameters?: unknown; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface IFeatureFilter { + name: string; // e.g. Microsoft.TimeWindow + evaluate(context: IFeatureFilterEvaluationContext, appContext?: unknown): boolean | Promise; +} + +export interface IFeatureFilterEvaluationContext { + featureName: string; + parameters?: unknown; +} diff --git a/src/filter/TargetingFilter.ts b/sdk/feature-management/src/filter/TargetingFilter.ts similarity index 97% rename from src/filter/TargetingFilter.ts rename to sdk/feature-management/src/filter/TargetingFilter.ts index a1f3669..b675d87 100644 --- a/src/filter/TargetingFilter.ts +++ b/sdk/feature-management/src/filter/TargetingFilter.ts @@ -1,95 +1,95 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { IFeatureFilter } from "./FeatureFilter.js"; -import { isTargetedPercentile } from "../common/targetingEvaluator.js"; -import { ITargetingContext } from "../common/ITargetingContext.js"; - -type TargetingFilterParameters = { - Audience: { - DefaultRolloutPercentage: number; - Users?: string[]; - Groups?: { - Name: string; - RolloutPercentage: number; - }[]; - Exclusion?: { - Users?: string[]; - Groups?: string[]; - }; - } -} - -type TargetingFilterEvaluationContext = { - featureName: string; - parameters: TargetingFilterParameters; -} - -export class TargetingFilter implements IFeatureFilter { - name: string = "Microsoft.Targeting"; - - async evaluate(context: TargetingFilterEvaluationContext, appContext?: ITargetingContext): Promise { - const { featureName, parameters } = context; - TargetingFilter.#validateParameters(parameters); - - if (appContext === undefined) { - throw new Error("The app context is required for targeting filter."); - } - - if (parameters.Audience.Exclusion !== undefined) { - // check if the user is in the exclusion list - if (appContext?.userId !== undefined && - parameters.Audience.Exclusion.Users !== undefined && - parameters.Audience.Exclusion.Users.includes(appContext.userId)) { - return false; - } - // check if the user is in a group within exclusion list - if (appContext?.groups !== undefined && - parameters.Audience.Exclusion.Groups !== undefined) { - for (const excludedGroup of parameters.Audience.Exclusion.Groups) { - if (appContext.groups.includes(excludedGroup)) { - return false; - } - } - } - } - - // check if the user is being targeted directly - if (appContext?.userId !== undefined && - parameters.Audience.Users !== undefined && - parameters.Audience.Users.includes(appContext.userId)) { - return true; - } - - // check if the user is in a group that is being targeted - if (appContext?.groups !== undefined && - parameters.Audience.Groups !== undefined) { - for (const group of parameters.Audience.Groups) { - if (appContext.groups.includes(group.Name)) { - const hint = `${featureName}\n${group.Name}`; - if (await isTargetedPercentile(appContext.userId, hint, 0, group.RolloutPercentage)) { - return true; - } - } - } - } - - // check if the user is being targeted by a default rollout percentage - const hint = featureName; - return isTargetedPercentile(appContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage); - } - - static #validateParameters(parameters: TargetingFilterParameters): void { - if (parameters.Audience.DefaultRolloutPercentage < 0 || parameters.Audience.DefaultRolloutPercentage > 100) { - throw new Error("Audience.DefaultRolloutPercentage must be a number between 0 and 100."); - } - // validate RolloutPercentage for each group - if (parameters.Audience.Groups !== undefined) { - for (const group of parameters.Audience.Groups) { - if (group.RolloutPercentage < 0 || group.RolloutPercentage > 100) { - throw new Error(`RolloutPercentage of group ${group.Name} must be a number between 0 and 100.`); - } - } - } - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { IFeatureFilter } from "./FeatureFilter.js"; +import { isTargetedPercentile } from "../common/targetingEvaluator.js"; +import { ITargetingContext } from "../common/ITargetingContext.js"; + +type TargetingFilterParameters = { + Audience: { + DefaultRolloutPercentage: number; + Users?: string[]; + Groups?: { + Name: string; + RolloutPercentage: number; + }[]; + Exclusion?: { + Users?: string[]; + Groups?: string[]; + }; + } +} + +type TargetingFilterEvaluationContext = { + featureName: string; + parameters: TargetingFilterParameters; +} + +export class TargetingFilter implements IFeatureFilter { + name: string = "Microsoft.Targeting"; + + async evaluate(context: TargetingFilterEvaluationContext, appContext?: ITargetingContext): Promise { + const { featureName, parameters } = context; + TargetingFilter.#validateParameters(parameters); + + if (appContext === undefined) { + throw new Error("The app context is required for targeting filter."); + } + + if (parameters.Audience.Exclusion !== undefined) { + // check if the user is in the exclusion list + if (appContext?.userId !== undefined && + parameters.Audience.Exclusion.Users !== undefined && + parameters.Audience.Exclusion.Users.includes(appContext.userId)) { + return false; + } + // check if the user is in a group within exclusion list + if (appContext?.groups !== undefined && + parameters.Audience.Exclusion.Groups !== undefined) { + for (const excludedGroup of parameters.Audience.Exclusion.Groups) { + if (appContext.groups.includes(excludedGroup)) { + return false; + } + } + } + } + + // check if the user is being targeted directly + if (appContext?.userId !== undefined && + parameters.Audience.Users !== undefined && + parameters.Audience.Users.includes(appContext.userId)) { + return true; + } + + // check if the user is in a group that is being targeted + if (appContext?.groups !== undefined && + parameters.Audience.Groups !== undefined) { + for (const group of parameters.Audience.Groups) { + if (appContext.groups.includes(group.Name)) { + const hint = `${featureName}\n${group.Name}`; + if (await isTargetedPercentile(appContext.userId, hint, 0, group.RolloutPercentage)) { + return true; + } + } + } + } + + // check if the user is being targeted by a default rollout percentage + const hint = featureName; + return isTargetedPercentile(appContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage); + } + + static #validateParameters(parameters: TargetingFilterParameters): void { + if (parameters.Audience.DefaultRolloutPercentage < 0 || parameters.Audience.DefaultRolloutPercentage > 100) { + throw new Error("Audience.DefaultRolloutPercentage must be a number between 0 and 100."); + } + // validate RolloutPercentage for each group + if (parameters.Audience.Groups !== undefined) { + for (const group of parameters.Audience.Groups) { + if (group.RolloutPercentage < 0 || group.RolloutPercentage > 100) { + throw new Error(`RolloutPercentage of group ${group.Name} must be a number between 0 and 100.`); + } + } + } + } +} diff --git a/src/filter/TimeWindowFilter.ts b/sdk/feature-management/src/filter/TimeWindowFilter.ts similarity index 97% rename from src/filter/TimeWindowFilter.ts rename to sdk/feature-management/src/filter/TimeWindowFilter.ts index e1442c8..3cd0ead 100644 --- a/src/filter/TimeWindowFilter.ts +++ b/sdk/feature-management/src/filter/TimeWindowFilter.ts @@ -1,33 +1,33 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { IFeatureFilter } from "./FeatureFilter.js"; - -// [Start, End) -type TimeWindowParameters = { - Start?: string; - End?: string; -} - -type TimeWindowFilterEvaluationContext = { - featureName: string; - parameters: TimeWindowParameters; -} - -export class TimeWindowFilter implements IFeatureFilter { - name: string = "Microsoft.TimeWindow"; - - evaluate(context: TimeWindowFilterEvaluationContext): boolean { - const {featureName, parameters} = context; - const startTime = parameters.Start !== undefined ? new Date(parameters.Start) : undefined; - const endTime = parameters.End !== undefined ? new Date(parameters.End) : undefined; - - if (startTime === undefined && endTime === undefined) { - // If neither start nor end time is specified, then the filter is not applicable. - console.warn(`The ${this.name} feature filter is not valid for feature ${featureName}. It must specify either 'Start', 'End', or both.`); - return false; - } - const now = new Date(); - return (startTime === undefined || startTime <= now) && (endTime === undefined || now < endTime); - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { IFeatureFilter } from "./FeatureFilter.js"; + +// [Start, End) +type TimeWindowParameters = { + Start?: string; + End?: string; +} + +type TimeWindowFilterEvaluationContext = { + featureName: string; + parameters: TimeWindowParameters; +} + +export class TimeWindowFilter implements IFeatureFilter { + name: string = "Microsoft.TimeWindow"; + + evaluate(context: TimeWindowFilterEvaluationContext): boolean { + const {featureName, parameters} = context; + const startTime = parameters.Start !== undefined ? new Date(parameters.Start) : undefined; + const endTime = parameters.End !== undefined ? new Date(parameters.End) : undefined; + + if (startTime === undefined && endTime === undefined) { + // If neither start nor end time is specified, then the filter is not applicable. + console.warn(`The ${this.name} feature filter is not valid for feature ${featureName}. It must specify either 'Start', 'End', or both.`); + return false; + } + const now = new Date(); + return (startTime === undefined || startTime <= now) && (endTime === undefined || now < endTime); + } +} diff --git a/src/gettable.ts b/sdk/feature-management/src/gettable.ts similarity index 97% rename from src/gettable.ts rename to sdk/feature-management/src/gettable.ts index 98dc1f6..9597211 100644 --- a/src/gettable.ts +++ b/sdk/feature-management/src/gettable.ts @@ -1,10 +1,10 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export interface IGettable { - get(key: string): T | undefined; -} - -export function isGettable(object: unknown): object is IGettable { - return typeof object === "object" && object !== null && typeof (object as IGettable).get === "function"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface IGettable { + get(key: string): T | undefined; +} + +export function isGettable(object: unknown): object is IGettable { + return typeof object === "object" && object !== null && typeof (object as IGettable).get === "function"; } diff --git a/src/index.ts b/sdk/feature-management/src/index.ts similarity index 98% rename from src/index.ts rename to sdk/feature-management/src/index.ts index 7e99672..4e5207d 100644 --- a/src/index.ts +++ b/sdk/feature-management/src/index.ts @@ -1,6 +1,6 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export { FeatureManager } from "./featureManager.js"; -export { ConfigurationMapFeatureFlagProvider, ConfigurationObjectFeatureFlagProvider, IFeatureFlagProvider } from "./featureProvider.js"; -export { IFeatureFilter } from "./filter/FeatureFilter.js"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { FeatureManager } from "./featureManager.js"; +export { ConfigurationMapFeatureFlagProvider, ConfigurationObjectFeatureFlagProvider, IFeatureFlagProvider } from "./featureProvider.js"; +export { IFeatureFilter } from "./filter/FeatureFilter.js"; diff --git a/src/model.ts b/sdk/feature-management/src/model.ts similarity index 96% rename from src/model.ts rename to sdk/feature-management/src/model.ts index 231b05d..ccdb9d5 100644 --- a/src/model.ts +++ b/sdk/feature-management/src/model.ts @@ -1,183 +1,183 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -// Converted from: -// https://github.com/Azure/AppConfiguration/blob/6e544296a5607f922a423df165f60801717c7800/docs/FeatureManagement/FeatureFlag.v2.0.0.schema.json - -/** - * A feature flag is a named property that can be toggled to enable or disable some feature of an application. - */ -export interface FeatureFlag { - /** - * An ID used to uniquely identify and reference the feature. - */ - id: string; - /** - * A description of the feature. - */ - description?: string; - /** - * A display name for the feature to use for display rather than the ID. - */ - display_name?: string; - /** - * A feature is OFF if enabled is false. If enabled is true, then the feature is ON if there are no conditions (null or empty) or if the conditions are satisfied. - */ - enabled?: boolean; - /** - * The declaration of conditions used to dynamically enable the feature. - */ - conditions?: FeatureEnablementConditions; - /** - * The list of variants defined for this feature. A variant represents a configuration value of a feature flag that can be a string, a number, a boolean, or a JSON object. - */ - variants?: VariantDefinition[]; - /** - * Determines how variants should be allocated for the feature to various users. - */ - allocation?: VariantAllocation; - /** - * The declaration of options used to configure telemetry for this feature. - */ - telemetry?: TelemetryOptions -} - -/** -* The declaration of conditions used to dynamically enable the feature -*/ -interface FeatureEnablementConditions { - /** - * Determines whether any or all registered client filters must be evaluated as true for the feature to be considered enabled. - */ - requirement_type?: RequirementType; - /** - * Filters that must run on the client and be evaluated as true for the feature to be considered enabled. - */ - client_filters?: ClientFilter[]; -} - -export type RequirementType = "Any" | "All"; - -interface ClientFilter { - /** - * The name used to refer to a client filter. - */ - name: string; - /** - * Parameters for a given client filter. A client filter can require any set of parameters of any type. - */ - parameters?: Record; -} - -export interface VariantDefinition { - /** - * The name used to refer to a feature variant. - */ - name: string; - /** - * The configuration value for this feature variant. - */ - configuration_value?: unknown; - /** - * Overrides the enabled state of the feature if the given variant is assigned. Does not override the state if value is None. - */ - status_override?: "None" | "Enabled" | "Disabled"; -} - -/** -* Determines how variants should be allocated for the feature to various users. -*/ -interface VariantAllocation { - /** - * Specifies which variant should be used when the feature is considered disabled. - */ - default_when_disabled?: string; - /** - * Specifies which variant should be used when the feature is considered enabled and no other allocation rules are applicable. - */ - default_when_enabled?: string; - /** - * A list of objects, each containing a variant name and list of users for whom that variant should be used. - */ - user?: UserAllocation[]; - /** - * A list of objects, each containing a variant name and list of groups for which that variant should be used. - */ - group?: GroupAllocation[]; - /** - * A list of objects, each containing a variant name and percentage range for which that variant should be used. - */ - percentile?: PercentileAllocation[] - /** - * The value percentile calculations are based on. The calculated percentile is consistent across features for a given user if the same nonempty seed is used. - */ - seed?: string; -} - -interface UserAllocation { - /** - * The name of the variant to use if the user allocation matches the current user. - */ - variant: string; - /** - * Collection of users where if any match the current user, the variant specified in the user allocation is used. - */ - users: string[]; -} - -interface GroupAllocation { - /** - * The name of the variant to use if the group allocation matches a group the current user is in. - */ - variant: string; - /** - * Collection of groups where if the current user is in any of these groups, the variant specified in the group allocation is used. - */ - groups: string[]; -} - -interface PercentileAllocation { - /** - * The name of the variant to use if the calculated percentile for the current user falls in the provided range. - */ - variant: string; - /** - * The lower end of the percentage range for which this variant will be used. - */ - from: number; - /** - * The upper end of the percentage range for which this variant will be used. - */ - to: number; -} - -/** -* The declaration of options used to configure telemetry for this feature. -*/ -interface TelemetryOptions { - /** - * Indicates if telemetry is enabled. - */ - enabled?: boolean; - /** - * A container for metadata that should be bundled with flag telemetry. - */ - metadata?: Record; -} - -// Feature Management Section fed into feature manager. -// Converted from https://github.com/Azure/AppConfiguration/blob/main/docs/FeatureManagement/FeatureManagement.v1.0.0.schema.json - -export const FEATURE_MANAGEMENT_KEY = "feature_management"; -export const FEATURE_FLAGS_KEY = "feature_flags"; - -export interface FeatureManagementConfiguration { - feature_management: FeatureManagement -} - -/** - * Declares feature management configuration. - */ -export interface FeatureManagement { - feature_flags: FeatureFlag[]; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Converted from: +// https://github.com/Azure/AppConfiguration/blob/6e544296a5607f922a423df165f60801717c7800/docs/FeatureManagement/FeatureFlag.v2.0.0.schema.json + +/** + * A feature flag is a named property that can be toggled to enable or disable some feature of an application. + */ +export interface FeatureFlag { + /** + * An ID used to uniquely identify and reference the feature. + */ + id: string; + /** + * A description of the feature. + */ + description?: string; + /** + * A display name for the feature to use for display rather than the ID. + */ + display_name?: string; + /** + * A feature is OFF if enabled is false. If enabled is true, then the feature is ON if there are no conditions (null or empty) or if the conditions are satisfied. + */ + enabled?: boolean; + /** + * The declaration of conditions used to dynamically enable the feature. + */ + conditions?: FeatureEnablementConditions; + /** + * The list of variants defined for this feature. A variant represents a configuration value of a feature flag that can be a string, a number, a boolean, or a JSON object. + */ + variants?: VariantDefinition[]; + /** + * Determines how variants should be allocated for the feature to various users. + */ + allocation?: VariantAllocation; + /** + * The declaration of options used to configure telemetry for this feature. + */ + telemetry?: TelemetryOptions +} + +/** +* The declaration of conditions used to dynamically enable the feature +*/ +interface FeatureEnablementConditions { + /** + * Determines whether any or all registered client filters must be evaluated as true for the feature to be considered enabled. + */ + requirement_type?: RequirementType; + /** + * Filters that must run on the client and be evaluated as true for the feature to be considered enabled. + */ + client_filters?: ClientFilter[]; +} + +export type RequirementType = "Any" | "All"; + +interface ClientFilter { + /** + * The name used to refer to a client filter. + */ + name: string; + /** + * Parameters for a given client filter. A client filter can require any set of parameters of any type. + */ + parameters?: Record; +} + +export interface VariantDefinition { + /** + * The name used to refer to a feature variant. + */ + name: string; + /** + * The configuration value for this feature variant. + */ + configuration_value?: unknown; + /** + * Overrides the enabled state of the feature if the given variant is assigned. Does not override the state if value is None. + */ + status_override?: "None" | "Enabled" | "Disabled"; +} + +/** +* Determines how variants should be allocated for the feature to various users. +*/ +interface VariantAllocation { + /** + * Specifies which variant should be used when the feature is considered disabled. + */ + default_when_disabled?: string; + /** + * Specifies which variant should be used when the feature is considered enabled and no other allocation rules are applicable. + */ + default_when_enabled?: string; + /** + * A list of objects, each containing a variant name and list of users for whom that variant should be used. + */ + user?: UserAllocation[]; + /** + * A list of objects, each containing a variant name and list of groups for which that variant should be used. + */ + group?: GroupAllocation[]; + /** + * A list of objects, each containing a variant name and percentage range for which that variant should be used. + */ + percentile?: PercentileAllocation[] + /** + * The value percentile calculations are based on. The calculated percentile is consistent across features for a given user if the same nonempty seed is used. + */ + seed?: string; +} + +interface UserAllocation { + /** + * The name of the variant to use if the user allocation matches the current user. + */ + variant: string; + /** + * Collection of users where if any match the current user, the variant specified in the user allocation is used. + */ + users: string[]; +} + +interface GroupAllocation { + /** + * The name of the variant to use if the group allocation matches a group the current user is in. + */ + variant: string; + /** + * Collection of groups where if the current user is in any of these groups, the variant specified in the group allocation is used. + */ + groups: string[]; +} + +interface PercentileAllocation { + /** + * The name of the variant to use if the calculated percentile for the current user falls in the provided range. + */ + variant: string; + /** + * The lower end of the percentage range for which this variant will be used. + */ + from: number; + /** + * The upper end of the percentage range for which this variant will be used. + */ + to: number; +} + +/** +* The declaration of options used to configure telemetry for this feature. +*/ +interface TelemetryOptions { + /** + * Indicates if telemetry is enabled. + */ + enabled?: boolean; + /** + * A container for metadata that should be bundled with flag telemetry. + */ + metadata?: Record; +} + +// Feature Management Section fed into feature manager. +// Converted from https://github.com/Azure/AppConfiguration/blob/main/docs/FeatureManagement/FeatureManagement.v1.0.0.schema.json + +export const FEATURE_MANAGEMENT_KEY = "feature_management"; +export const FEATURE_FLAGS_KEY = "feature_flags"; + +export interface FeatureManagementConfiguration { + feature_management: FeatureManagement +} + +/** + * Declares feature management configuration. + */ +export interface FeatureManagement { + feature_flags: FeatureFlag[]; +} diff --git a/src/variant/Variant.ts b/sdk/feature-management/src/variant/Variant.ts similarity index 95% rename from src/variant/Variant.ts rename to sdk/feature-management/src/variant/Variant.ts index f41e1f8..30ce195 100644 --- a/src/variant/Variant.ts +++ b/sdk/feature-management/src/variant/Variant.ts @@ -1,9 +1,9 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export class Variant { - constructor( - public name: string, - public configuration: unknown - ) {} -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export class Variant { + constructor( + public name: string, + public configuration: unknown + ) {} +} diff --git a/test/browser/browser.test.ts b/sdk/feature-management/test/browser/browser.test.ts similarity index 100% rename from test/browser/browser.test.ts rename to sdk/feature-management/test/browser/browser.test.ts diff --git a/test/browser/index.html b/sdk/feature-management/test/browser/index.html similarity index 100% rename from test/browser/index.html rename to sdk/feature-management/test/browser/index.html diff --git a/test/browser/testcases.js b/sdk/feature-management/test/browser/testcases.js similarity index 100% rename from test/browser/testcases.js rename to sdk/feature-management/test/browser/testcases.js diff --git a/test/featureManager.test.ts b/sdk/feature-management/test/featureManager.test.ts similarity index 97% rename from test/featureManager.test.ts rename to sdk/feature-management/test/featureManager.test.ts index 4c57a1e..29ce2fe 100644 --- a/test/featureManager.test.ts +++ b/sdk/feature-management/test/featureManager.test.ts @@ -1,77 +1,77 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import * as chai from "chai"; -import * as chaiAsPromised from "chai-as-promised"; -chai.use(chaiAsPromised); -const expect = chai.expect; - -import { FeatureManager, ConfigurationObjectFeatureFlagProvider, ConfigurationMapFeatureFlagProvider } from "../"; - -describe("feature manager", () => { - it("should load from json string", () => { - const jsonObject = { - "feature_management": { - "feature_flags": [ - { "id": "Alpha", "description": "", "enabled": true} - ] - } - }; - - const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); - const featureManager = new FeatureManager(provider); - return expect(featureManager.isEnabled("Alpha")).eventually.eq(true); - }); - - it("should load from map", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [ - { id: "Alpha", enabled: true } - ], - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - return expect(featureManager.isEnabled("Alpha")).eventually.eq(true); - }); - - it("should load latest data if source is updated after initialization", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [ - { id: "Alpha", enabled: true } - ], - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - dataSource.set("feature_management", { - feature_flags: [ - { id: "Alpha", enabled: false } - ], - }); - - return expect(featureManager.isEnabled("Alpha")).eventually.eq(false); - }); - - it("should evaluate features without conditions", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [ - { "id": "Alpha", "description": "", "enabled": true, "conditions": { "client_filters": [] } }, - { "id": "Beta", "description": "", "enabled": false, "conditions": { "client_filters": [] } } - ], - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - return Promise.all([ - expect(featureManager.isEnabled("Alpha")).eventually.eq(true), - expect(featureManager.isEnabled("Beta")).eventually.eq(false) - ]); - }); - - it("should evaluate features with conditions"); - it("should override default filters with custom filters"); -}); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager, ConfigurationObjectFeatureFlagProvider, ConfigurationMapFeatureFlagProvider } from "../"; + +describe("feature manager", () => { + it("should load from json string", () => { + const jsonObject = { + "feature_management": { + "feature_flags": [ + { "id": "Alpha", "description": "", "enabled": true} + ] + } + }; + + const provider = new ConfigurationObjectFeatureFlagProvider(jsonObject); + const featureManager = new FeatureManager(provider); + return expect(featureManager.isEnabled("Alpha")).eventually.eq(true); + }); + + it("should load from map", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + { id: "Alpha", enabled: true } + ], + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + return expect(featureManager.isEnabled("Alpha")).eventually.eq(true); + }); + + it("should load latest data if source is updated after initialization", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + { id: "Alpha", enabled: true } + ], + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + dataSource.set("feature_management", { + feature_flags: [ + { id: "Alpha", enabled: false } + ], + }); + + return expect(featureManager.isEnabled("Alpha")).eventually.eq(false); + }); + + it("should evaluate features without conditions", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + { "id": "Alpha", "description": "", "enabled": true, "conditions": { "client_filters": [] } }, + { "id": "Beta", "description": "", "enabled": false, "conditions": { "client_filters": [] } } + ], + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + return Promise.all([ + expect(featureManager.isEnabled("Alpha")).eventually.eq(true), + expect(featureManager.isEnabled("Beta")).eventually.eq(false) + ]); + }); + + it("should evaluate features with conditions"); + it("should override default filters with custom filters"); +}); diff --git a/test/noFilters.test.ts b/sdk/feature-management/test/noFilters.test.ts similarity index 97% rename from test/noFilters.test.ts rename to sdk/feature-management/test/noFilters.test.ts index 55639f7..b2bd946 100644 --- a/test/noFilters.test.ts +++ b/sdk/feature-management/test/noFilters.test.ts @@ -1,70 +1,70 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import * as chai from "chai"; -import * as chaiAsPromised from "chai-as-promised"; -chai.use(chaiAsPromised); -const expect = chai.expect; - -import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../"; - -const featureFlagsDataObject = { - "feature_management": { - "feature_flags": [ - { - "id": "BooleanTrue", - "description": "A feature flag with no Filters, that returns true.", - "enabled": true, - "conditions": { - "client_filters": [] - } - }, - { - "id": "BooleanFalse", - "description": "A feature flag with no Filters, that returns false.", - "enabled": false, - "conditions": { - "client_filters": [] - } - }, - { - "id": "InvalidEnabled", - "description": "A feature flag with an invalid 'enabled' value, that throws an exception.", - "enabled": "invalid", - "conditions": { - "client_filters": [] - } - }, - { - "id": "Minimal", - "enabled": true - }, - { - "id": "NoEnabled" - }, - { - "id": "EmptyConditions", - "description": "A feature flag with no values in conditions, that returns true.", - "enabled": true, - "conditions": { - } - } - ] - } -}; - -describe("feature flags with no filters", () => { - it("should validate feature flags without filters", () => { - const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsDataObject); - const featureManager = new FeatureManager(provider); - - return Promise.all([ - expect(featureManager.isEnabled("BooleanTrue")).eventually.eq(true), - expect(featureManager.isEnabled("BooleanFalse")).eventually.eq(false), - expect(featureManager.isEnabled("InvalidEnabled")).eventually.rejectedWith("Feature flag InvalidEnabled has an invalid 'enabled' value."), - expect(featureManager.isEnabled("Minimal")).eventually.eq(true), - expect(featureManager.isEnabled("NoEnabled")).eventually.eq(false), - expect(featureManager.isEnabled("EmptyConditions")).eventually.eq(true) - ]); - }); -}); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../"; + +const featureFlagsDataObject = { + "feature_management": { + "feature_flags": [ + { + "id": "BooleanTrue", + "description": "A feature flag with no Filters, that returns true.", + "enabled": true, + "conditions": { + "client_filters": [] + } + }, + { + "id": "BooleanFalse", + "description": "A feature flag with no Filters, that returns false.", + "enabled": false, + "conditions": { + "client_filters": [] + } + }, + { + "id": "InvalidEnabled", + "description": "A feature flag with an invalid 'enabled' value, that throws an exception.", + "enabled": "invalid", + "conditions": { + "client_filters": [] + } + }, + { + "id": "Minimal", + "enabled": true + }, + { + "id": "NoEnabled" + }, + { + "id": "EmptyConditions", + "description": "A feature flag with no values in conditions, that returns true.", + "enabled": true, + "conditions": { + } + } + ] + } +}; + +describe("feature flags with no filters", () => { + it("should validate feature flags without filters", () => { + const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsDataObject); + const featureManager = new FeatureManager(provider); + + return Promise.all([ + expect(featureManager.isEnabled("BooleanTrue")).eventually.eq(true), + expect(featureManager.isEnabled("BooleanFalse")).eventually.eq(false), + expect(featureManager.isEnabled("InvalidEnabled")).eventually.rejectedWith("Feature flag InvalidEnabled has an invalid 'enabled' value."), + expect(featureManager.isEnabled("Minimal")).eventually.eq(true), + expect(featureManager.isEnabled("NoEnabled")).eventually.eq(false), + expect(featureManager.isEnabled("EmptyConditions")).eventually.eq(true) + ]); + }); +}); diff --git a/test/sampleFeatureFlags.ts b/sdk/feature-management/test/sampleFeatureFlags.ts similarity index 97% rename from test/sampleFeatureFlags.ts rename to sdk/feature-management/test/sampleFeatureFlags.ts index 2ad48f4..9f95b36 100644 --- a/test/sampleFeatureFlags.ts +++ b/sdk/feature-management/test/sampleFeatureFlags.ts @@ -1,532 +1,532 @@ -export enum Features { - VariantFeatureDefaultDisabled = "VariantFeatureDefaultDisabled", - VariantFeatureDefaultEnabled = "VariantFeatureDefaultEnabled", - VariantFeaturePercentileOn = "VariantFeaturePercentileOn", - VariantFeaturePercentileOff = "VariantFeaturePercentileOff", - VariantFeatureAlwaysOff = "VariantFeatureAlwaysOff", - VariantFeatureUser = "VariantFeatureUser", - VariantFeatureGroup = "VariantFeatureGroup", - VariantFeatureNoVariants = "VariantFeatureNoVariants", - VariantFeatureNoAllocation = "VariantFeatureNoAllocation", - VariantFeatureAlwaysOffNoAllocation = "VariantFeatureAlwaysOffNoAllocation", - VariantFeatureBothConfigurations = "VariantFeatureBothConfigurations", - VariantFeatureInvalidStatusOverride = "VariantFeatureInvalidStatusOverride", - VariantFeatureInvalidFromTo = "VariantFeatureInvalidFromTo", - VariantImplementationFeature = "VariantImplementationFeature", -} - -export const featureFlagsConfigurationObject = { - "feature_management": { - "feature_flags": [ - { - "id": "OnTestFeature", - "enabled": true - }, - { - "id": "OffTestFeature", - "enabled": false - }, - { - "id": "ConditionalFeature", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "Test", - "parameters": { - "P1": "V1" - } - } - ] - } - }, - { - "id": "ContextualFeature", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "ContextualTest", - "parameters": { - "AllowedAccounts": [ - "abc" - ] - } - } - ] - } - }, - { - "id": "AnyFilterFeature", - "enabled": true, - "conditions": { - "requirement_type": "Any", - "client_filters": [ - { - "name": "Test", - "parameters": { - "Id": "1" - } - }, - { - "name": "Test", - "parameters": { - "Id": "2" - } - } - ] - } - }, - { - "id": "AllFilterFeature", - "enabled": true, - "conditions": { - "requirement_type": "All", - "client_filters": [ - { - "name": "Test", - "parameters": { - "Id": "1" - } - }, - { - "name": "Test", - "parameters": { - "Id": "2" - } - } - ] - } - }, - { - "id": "FeatureUsesFiltersWithDuplicatedAlias", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "DuplicatedFilterName" - }, - { - "name": "Percentage", - "parameters": { - "Value": 100 - } - } - ] - } - }, - { - "id": "TargetingTestFeature", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "Targeting", - "parameters": { - "Audience": { - "Users": [ - "Jeff", - "Alicia" - ], - "Groups": [ - { - "Name": "Ring0", - "RolloutPercentage": 100 - }, - { - "Name": "Ring1", - "RolloutPercentage": 50 - } - ], - "DefaultRolloutPercentage": 20 - } - } - } - ] - } - }, - { - "id": "TargetingTestFeatureWithExclusion", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "Targeting", - "parameters": { - "Audience": { - "Users": [ - "Jeff", - "Alicia" - ], - "Groups": [ - { - "Name": "Ring0", - "RolloutPercentage": 100 - }, - { - "Name": "Ring1", - "RolloutPercentage": 50 - } - ], - "DefaultRolloutPercentage": 20, - "Exclusion": { - "Users": [ - "Jeff" - ], - "Groups": [ - "Ring0", - "Ring2" - ] - } - } - } - } - ] - } - }, - { - "id": "CustomFilterFeature", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "CustomTargetingFilter", - "parameters": { - "Audience": { - "Users": [ - "Jeff" - ] - } - } - } - ] - } - }, - { - "id": "VariantFeaturePercentileOn", - "enabled": true, - "variants": [ - { - "name": "Big", - "status_override": "Disabled" - } - ], - "allocation": { - "percentile": [ - { - "variant": "Big", - "from": 0, - "to": 50 - } - ], - "seed": 1234 - }, - "telemetry": { - "enabled": true - } - }, - { - "id": "VariantFeaturePercentileOff", - "enabled": true, - "variants": [ - { - "name": "Big" - } - ], - "allocation": { - "percentile": [ - { - "variant": "Big", - "from": 0, - "to": 50 - } - ], - "seed": 12345 - }, - "telemetry": { - "enabled": true - } - }, - { - "id": "VariantFeatureAlwaysOff", - "enabled": false, - "variants": [ - { - "name": "Big" - } - ], - "allocation": { - "percentile": [ - { - "variant": "Big", - "from": 0, - "to": 100 - } - ], - "seed": 12345 - }, - "telemetry": { - "enabled": true - } - }, - { - "id": "VariantFeatureDefaultDisabled", - "enabled": false, - "variants": [ - { - "name": "Small", - "configuration_value": "300px" - } - ], - "allocation": { - "default_when_disabled": "Small" - }, - "telemetry": { - "enabled": true - } - }, - { - "id": "VariantFeatureDefaultEnabled", - "enabled": true, - "variants": [ - { - "name": "Medium", - "configuration_value": { - "Size": "450px", - "Color": "Purple" - } - }, - { - "name": "Small", - "configuration_value": "300px" - } - ], - "allocation": { - "default_when_enabled": "Medium", - "user": [ - { - "variant": "Small", - "users": [ - "Jeff" - ] - } - ] - }, - "telemetry": { - "enabled": true - } - }, - { - "id": "VariantFeatureUser", - "enabled": true, - "variants": [ - { - "name": "Small", - "configuration_value": "300px" - } - ], - "allocation": { - "user": [ - { - "variant": "Small", - "users": [ - "Marsha" - ] - } - ] - }, - "telemetry": { - "enabled": true - } - }, - { - "id": "VariantFeatureGroup", - "enabled": true, - "variants": [ - { - "name": "Small", - "configuration_value": "300px" - } - ], - "allocation": { - "group": [ - { - "variant": "Small", - "groups": [ - "Group1" - ] - } - ] - }, - "telemetry": { - "enabled": true - } - }, - { - "id": "VariantFeatureNoVariants", - "enabled": true, - "variants": [], - "allocation": { - "user": [ - { - "variant": "Small", - "users": [ - "Marsha" - ] - } - ] - }, - "telemetry": { - "enabled": true - } - }, - { - "id": "VariantFeatureNoAllocation", - "enabled": true, - "variants": [ - { - "name": "Small", - "configuration_value": "300px" - } - ], - "telemetry": { - "enabled": true - } - }, - { - "id": "VariantFeatureAlwaysOffNoAllocation", - "enabled": false, - "variants": [ - { - "name": "Small", - "configuration_value": "300px" - } - ], - "telemetry": { - "enabled": true - } - }, - { - "id": "VariantFeatureBothConfigurations", - "enabled": true, - "variants": [ - { - "name": "Small", - "configuration_value": "600px" - } - ], - "allocation": { - "default_when_enabled": "Small" - } - }, - { - "id": "VariantFeatureInvalidStatusOverride", - "enabled": true, - "variants": [ - { - "name": "Small", - "configuration_value": "300px", - "status_override": "InvalidValue" - } - ], - "allocation": { - "default_when_enabled": "Small" - } - }, - { - "id": "VariantFeatureInvalidFromTo", - "enabled": true, - "variants": [ - { - "name": "Small", - "configuration_value": "300px" - } - ], - "allocation": { - "percentile": [ - { - "variant": "Small", - "from": "Invalid", - "to": "Invalid" - } - ] - } - }, - { - "id": "VariantImplementationFeature", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "Targeting", - "parameters": { - "Audience": { - "Users": [ - "UserOmega", - "UserSigma", - "UserBeta" - ] - } - } - } - ] - }, - "variants": [ - { - "name": "AlgorithmBeta" - }, - { - "name": "Sigma", - "configuration_value": "AlgorithmSigma" - }, - { - "name": "Omega" - } - ], - "allocation": { - "user": [ - { - "variant": "AlgorithmBeta", - "users": [ - "UserBeta" - ] - }, - { - "variant": "Omega", - "users": [ - "UserOmega" - ] - }, - { - "variant": "Sigma", - "users": [ - "UserSigma" - ] - } - ] - } - }, - { - "id": "OnTelemetryTestFeature", - "enabled": true, - "telemetry": { - "enabled": true, - "metadata": { - "Tags.Tag1": "Tag1Value", - "Tags.Tag2": "Tag2Value", - "Etag": "EtagValue", - "Label": "LabelValue" - } - } - }, - { - "id": "OffTelemetryTestFeature", - "enabled": false, - "telemetry": { - "enabled": true - } - } - ] - } -}; - +export enum Features { + VariantFeatureDefaultDisabled = "VariantFeatureDefaultDisabled", + VariantFeatureDefaultEnabled = "VariantFeatureDefaultEnabled", + VariantFeaturePercentileOn = "VariantFeaturePercentileOn", + VariantFeaturePercentileOff = "VariantFeaturePercentileOff", + VariantFeatureAlwaysOff = "VariantFeatureAlwaysOff", + VariantFeatureUser = "VariantFeatureUser", + VariantFeatureGroup = "VariantFeatureGroup", + VariantFeatureNoVariants = "VariantFeatureNoVariants", + VariantFeatureNoAllocation = "VariantFeatureNoAllocation", + VariantFeatureAlwaysOffNoAllocation = "VariantFeatureAlwaysOffNoAllocation", + VariantFeatureBothConfigurations = "VariantFeatureBothConfigurations", + VariantFeatureInvalidStatusOverride = "VariantFeatureInvalidStatusOverride", + VariantFeatureInvalidFromTo = "VariantFeatureInvalidFromTo", + VariantImplementationFeature = "VariantImplementationFeature", +} + +export const featureFlagsConfigurationObject = { + "feature_management": { + "feature_flags": [ + { + "id": "OnTestFeature", + "enabled": true + }, + { + "id": "OffTestFeature", + "enabled": false + }, + { + "id": "ConditionalFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Test", + "parameters": { + "P1": "V1" + } + } + ] + } + }, + { + "id": "ContextualFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "ContextualTest", + "parameters": { + "AllowedAccounts": [ + "abc" + ] + } + } + ] + } + }, + { + "id": "AnyFilterFeature", + "enabled": true, + "conditions": { + "requirement_type": "Any", + "client_filters": [ + { + "name": "Test", + "parameters": { + "Id": "1" + } + }, + { + "name": "Test", + "parameters": { + "Id": "2" + } + } + ] + } + }, + { + "id": "AllFilterFeature", + "enabled": true, + "conditions": { + "requirement_type": "All", + "client_filters": [ + { + "name": "Test", + "parameters": { + "Id": "1" + } + }, + { + "name": "Test", + "parameters": { + "Id": "2" + } + } + ] + } + }, + { + "id": "FeatureUsesFiltersWithDuplicatedAlias", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "DuplicatedFilterName" + }, + { + "name": "Percentage", + "parameters": { + "Value": 100 + } + } + ] + } + }, + { + "id": "TargetingTestFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20 + } + } + } + ] + } + }, + { + "id": "TargetingTestFeatureWithExclusion", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20, + "Exclusion": { + "Users": [ + "Jeff" + ], + "Groups": [ + "Ring0", + "Ring2" + ] + } + } + } + } + ] + } + }, + { + "id": "CustomFilterFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "CustomTargetingFilter", + "parameters": { + "Audience": { + "Users": [ + "Jeff" + ] + } + } + } + ] + } + }, + { + "id": "VariantFeaturePercentileOn", + "enabled": true, + "variants": [ + { + "name": "Big", + "status_override": "Disabled" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 50 + } + ], + "seed": 1234 + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeaturePercentileOff", + "enabled": true, + "variants": [ + { + "name": "Big" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 50 + } + ], + "seed": 12345 + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureAlwaysOff", + "enabled": false, + "variants": [ + { + "name": "Big" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 100 + } + ], + "seed": 12345 + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureDefaultDisabled", + "enabled": false, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "default_when_disabled": "Small" + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureDefaultEnabled", + "enabled": true, + "variants": [ + { + "name": "Medium", + "configuration_value": { + "Size": "450px", + "Color": "Purple" + } + }, + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "default_when_enabled": "Medium", + "user": [ + { + "variant": "Small", + "users": [ + "Jeff" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureUser", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "user": [ + { + "variant": "Small", + "users": [ + "Marsha" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureGroup", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "group": [ + { + "variant": "Small", + "groups": [ + "Group1" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureNoVariants", + "enabled": true, + "variants": [], + "allocation": { + "user": [ + { + "variant": "Small", + "users": [ + "Marsha" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureNoAllocation", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureAlwaysOffNoAllocation", + "enabled": false, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureBothConfigurations", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "600px" + } + ], + "allocation": { + "default_when_enabled": "Small" + } + }, + { + "id": "VariantFeatureInvalidStatusOverride", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px", + "status_override": "InvalidValue" + } + ], + "allocation": { + "default_when_enabled": "Small" + } + }, + { + "id": "VariantFeatureInvalidFromTo", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Small", + "from": "Invalid", + "to": "Invalid" + } + ] + } + }, + { + "id": "VariantImplementationFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "UserOmega", + "UserSigma", + "UserBeta" + ] + } + } + } + ] + }, + "variants": [ + { + "name": "AlgorithmBeta" + }, + { + "name": "Sigma", + "configuration_value": "AlgorithmSigma" + }, + { + "name": "Omega" + } + ], + "allocation": { + "user": [ + { + "variant": "AlgorithmBeta", + "users": [ + "UserBeta" + ] + }, + { + "variant": "Omega", + "users": [ + "UserOmega" + ] + }, + { + "variant": "Sigma", + "users": [ + "UserSigma" + ] + } + ] + } + }, + { + "id": "OnTelemetryTestFeature", + "enabled": true, + "telemetry": { + "enabled": true, + "metadata": { + "Tags.Tag1": "Tag1Value", + "Tags.Tag2": "Tag2Value", + "Etag": "EtagValue", + "Label": "LabelValue" + } + } + }, + { + "id": "OffTelemetryTestFeature", + "enabled": false, + "telemetry": { + "enabled": true + } + } + ] + } +}; + diff --git a/test/targetingFilter.test.ts b/sdk/feature-management/test/targetingFilter.test.ts similarity index 98% rename from test/targetingFilter.test.ts rename to sdk/feature-management/test/targetingFilter.test.ts index ac33f88..a89c833 100644 --- a/test/targetingFilter.test.ts +++ b/sdk/feature-management/test/targetingFilter.test.ts @@ -1,146 +1,146 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import * as chai from "chai"; -import * as chaiAsPromised from "chai-as-promised"; -chai.use(chaiAsPromised); -const expect = chai.expect; - -import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "../"; - -const complexTargetingFeature = { - "id": "ComplexTargeting", - "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2. Dave and Stage3 are excluded. The default rollout percentage is 25%.", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "Microsoft.Targeting", - "parameters": { - "Audience": { - "Users": [ - "Alice" - ], - "Groups": [ - { - "Name": "Stage1", - "RolloutPercentage": 100 - }, - { - "Name": "Stage2", - "RolloutPercentage": 50 - } - ], - "DefaultRolloutPercentage": 25, - "Exclusion": { - "Users": ["Dave"], - "Groups": ["Stage3"] - } - } - } - } - ] - } -}; - -const createTargetingFeatureWithRolloutPercentage = (name: string, defaultRolloutPercentage: number, groups?: { Name: string, RolloutPercentage: number }[]) => { - const featureFlag = { - "id": name, - "description": "A feature flag using a targeting filter with invalid parameters.", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "Microsoft.Targeting", - "parameters": { - "Audience": { - "DefaultRolloutPercentage": defaultRolloutPercentage - } - } - } - ] - } - }; - if (groups && groups.length > 0) { - (featureFlag.conditions.client_filters[0].parameters.Audience as any).Groups = groups; - } - return featureFlag; -}; - -describe("targeting filter", () => { - it("should validate parameters", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [ - createTargetingFeatureWithRolloutPercentage("InvalidTargeting1", -1), - createTargetingFeatureWithRolloutPercentage("InvalidTargeting2", 101), - // invalid group rollout percentage - createTargetingFeatureWithRolloutPercentage("InvalidTargeting3", 25, [{ Name: "Stage1", RolloutPercentage: -1 }]), - createTargetingFeatureWithRolloutPercentage("InvalidTargeting4", 25, [{ Name: "Stage1", RolloutPercentage: 101 }]), - ] - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - - return Promise.all([ - expect(featureManager.isEnabled("InvalidTargeting1", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), - expect(featureManager.isEnabled("InvalidTargeting2", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), - expect(featureManager.isEnabled("InvalidTargeting3", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), - expect(featureManager.isEnabled("InvalidTargeting4", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), - ]); - }); - - it("should evaluate feature with targeting filter", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [complexTargetingFeature] - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - - return Promise.all([ - // default rollout 25% - // - "Aiden\nComplexTargeting": ~62.9% - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden" })).eventually.eq(false, "Aiden is not in the 25% default rollout"), - - // - "Blossom\nComplexTargeting": ~20.2% - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom" })).eventually.eq(true, "Blossom is in the 25% default rollout"), - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice" })).eventually.eq(true, "Alice is directly targeted"), - - // Stage1 group is 100% rollout - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage1"] })).eventually.eq(true, "Aiden is in because Stage1 is 100% rollout"), - - // Stage2 group is 50% rollout - // - "\nComplexTargeting\nStage2": ~78.7% >= 50% (Stage2 is 50% rollout) - // - "\nComplexTargeting": ~38.9% >= 25% (default rollout percentage) - expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage2"] })).eventually.eq(false, "Empty user is not in the 50% rollout of group Stage2"), - - // - "Aiden\nComplexTargeting\nStage2": ~15.6% - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage2"] })).eventually.eq(true, "Aiden is in the 50% rollout of group Stage2"), - - // - "Chris\nComplexTargeting\nStage2": 55.3% >= 50% (Stage2 is 50% rollout) - // - "Chris\nComplexTargeting": 72.3% >= 25% (default rollout percentage) - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Chris", groups: ["Stage2"] })).eventually.eq(false, "Chris is not in the 50% rollout of group Stage2"), - - // exclusions - expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage3"] })).eventually.eq(false, "Stage3 group is excluded"), - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice", groups: ["Stage3"] })).eventually.eq(false, "Alice is excluded because she is part of Stage3 group"), - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom", groups: ["Stage3"] })).eventually.eq(false, "Blossom is excluded because she is part of Stage3 group"), - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Dave", groups: ["Stage1"] })).eventually.eq(false, "Dave is excluded because he is in the exclusion list"), - ]); - }); - - it("should throw error if app context is not provided", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [complexTargetingFeature] - }); - - const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - - return expect(featureManager.isEnabled("ComplexTargeting")).eventually.rejectedWith("The app context is required for targeting filter."); - }); -}); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "../"; + +const complexTargetingFeature = { + "id": "ComplexTargeting", + "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2. Dave and Stage3 are excluded. The default rollout percentage is 25%.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [ + "Alice" + ], + "Groups": [ + { + "Name": "Stage1", + "RolloutPercentage": 100 + }, + { + "Name": "Stage2", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 25, + "Exclusion": { + "Users": ["Dave"], + "Groups": ["Stage3"] + } + } + } + } + ] + } +}; + +const createTargetingFeatureWithRolloutPercentage = (name: string, defaultRolloutPercentage: number, groups?: { Name: string, RolloutPercentage: number }[]) => { + const featureFlag = { + "id": name, + "description": "A feature flag using a targeting filter with invalid parameters.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "DefaultRolloutPercentage": defaultRolloutPercentage + } + } + } + ] + } + }; + if (groups && groups.length > 0) { + (featureFlag.conditions.client_filters[0].parameters.Audience as any).Groups = groups; + } + return featureFlag; +}; + +describe("targeting filter", () => { + it("should validate parameters", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + createTargetingFeatureWithRolloutPercentage("InvalidTargeting1", -1), + createTargetingFeatureWithRolloutPercentage("InvalidTargeting2", 101), + // invalid group rollout percentage + createTargetingFeatureWithRolloutPercentage("InvalidTargeting3", 25, [{ Name: "Stage1", RolloutPercentage: -1 }]), + createTargetingFeatureWithRolloutPercentage("InvalidTargeting4", 25, [{ Name: "Stage1", RolloutPercentage: 101 }]), + ] + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + return Promise.all([ + expect(featureManager.isEnabled("InvalidTargeting1", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting2", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting3", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting4", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), + ]); + }); + + it("should evaluate feature with targeting filter", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [complexTargetingFeature] + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + return Promise.all([ + // default rollout 25% + // - "Aiden\nComplexTargeting": ~62.9% + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden" })).eventually.eq(false, "Aiden is not in the 25% default rollout"), + + // - "Blossom\nComplexTargeting": ~20.2% + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom" })).eventually.eq(true, "Blossom is in the 25% default rollout"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice" })).eventually.eq(true, "Alice is directly targeted"), + + // Stage1 group is 100% rollout + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage1"] })).eventually.eq(true, "Aiden is in because Stage1 is 100% rollout"), + + // Stage2 group is 50% rollout + // - "\nComplexTargeting\nStage2": ~78.7% >= 50% (Stage2 is 50% rollout) + // - "\nComplexTargeting": ~38.9% >= 25% (default rollout percentage) + expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage2"] })).eventually.eq(false, "Empty user is not in the 50% rollout of group Stage2"), + + // - "Aiden\nComplexTargeting\nStage2": ~15.6% + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage2"] })).eventually.eq(true, "Aiden is in the 50% rollout of group Stage2"), + + // - "Chris\nComplexTargeting\nStage2": 55.3% >= 50% (Stage2 is 50% rollout) + // - "Chris\nComplexTargeting": 72.3% >= 25% (default rollout percentage) + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Chris", groups: ["Stage2"] })).eventually.eq(false, "Chris is not in the 50% rollout of group Stage2"), + + // exclusions + expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage3"] })).eventually.eq(false, "Stage3 group is excluded"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice", groups: ["Stage3"] })).eventually.eq(false, "Alice is excluded because she is part of Stage3 group"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom", groups: ["Stage3"] })).eventually.eq(false, "Blossom is excluded because she is part of Stage3 group"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Dave", groups: ["Stage1"] })).eventually.eq(false, "Dave is excluded because he is in the exclusion list"), + ]); + }); + + it("should throw error if app context is not provided", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [complexTargetingFeature] + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + return expect(featureManager.isEnabled("ComplexTargeting")).eventually.rejectedWith("The app context is required for targeting filter."); + }); +}); diff --git a/test/variant.test.ts b/sdk/feature-management/test/variant.test.ts similarity index 97% rename from test/variant.test.ts rename to sdk/feature-management/test/variant.test.ts index d5f558c..21b09a4 100644 --- a/test/variant.test.ts +++ b/sdk/feature-management/test/variant.test.ts @@ -1,95 +1,95 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import * as chai from "chai"; -import * as chaiAsPromised from "chai-as-promised"; -chai.use(chaiAsPromised); -const expect = chai.expect; - -import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../"; -import { Features, featureFlagsConfigurationObject } from "./sampleFeatureFlags.js"; - -describe("feature variant", () => { - - let featureManager: FeatureManager; - - before(() => { - const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); - featureManager = new FeatureManager(provider); - }); - - describe("valid scenarios", () => { - const context = { userId: "Marsha", groups: ["Group1"] }; - - it("default allocation with disabled feature", async () => { - const variant = await featureManager.getVariant(Features.VariantFeatureDefaultDisabled, context); - expect(variant).not.to.be.undefined; - expect(variant?.name).eq("Small"); - expect(variant?.configuration).eq("300px"); - }); - - it("default allocation with enabled feature", async () => { - const variant = await featureManager.getVariant(Features.VariantFeatureDefaultEnabled, context); - expect(variant).not.to.be.undefined; - expect(variant?.name).eq("Medium"); - expect(variant?.configuration).deep.eq({ Size: "450px", Color: "Purple" }); - }); - - it("user allocation", async () => { - const variant = await featureManager.getVariant(Features.VariantFeatureUser, context); - expect(variant).not.to.be.undefined; - expect(variant?.name).eq("Small"); - expect(variant?.configuration).eq("300px"); - }); - - it("group allocation", async () => { - const variant = await featureManager.getVariant(Features.VariantFeatureGroup, context); - expect(variant).not.to.be.undefined; - expect(variant?.name).eq("Small"); - expect(variant?.configuration).eq("300px"); - }); - - it("percentile allocation with seed", async () => { - const variant = await featureManager.getVariant(Features.VariantFeaturePercentileOn, context); - expect(variant).not.to.be.undefined; - expect(variant?.name).eq("Big"); - - const variant2 = await featureManager.getVariant(Features.VariantFeaturePercentileOff, context); - expect(variant2).to.be.undefined; - }); - - it("overwrite enabled status", async () => { - const enabledStatus = await featureManager.isEnabled(Features.VariantFeaturePercentileOn, context); - expect(enabledStatus).to.be.false; // featureFlag.enabled = true, overridden to false by variant `Big`. - }); - - }); - - describe("invalid scenarios", () => { - const context = { userId: "Jeff" }; - - it("return undefined when no variants are specified", async () => { - const variant = await featureManager.getVariant(Features.VariantFeatureNoVariants, context); - expect(variant).to.be.undefined; - }); - - it("return undefined when no allocation is specified", async () => { - const variant = await featureManager.getVariant(Features.VariantFeatureNoAllocation, context); - expect(variant).to.be.undefined; - }); - - it("only support configuration value", async () => { - const variant = await featureManager.getVariant(Features.VariantFeatureBothConfigurations, context); - expect(variant).not.to.be.undefined; - expect(variant?.configuration).eq("600px"); - }); - - // requires IFeatureFlagProvider to throw an exception on validation - it("throw exception for invalid StatusOverride value"); - - // requires IFeatureFlagProvider to throw an exception on validation - it("throw exception for invalid doubles From and To in the Percentile section"); - - }); - -}); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../"; +import { Features, featureFlagsConfigurationObject } from "./sampleFeatureFlags.js"; + +describe("feature variant", () => { + + let featureManager: FeatureManager; + + before(() => { + const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); + featureManager = new FeatureManager(provider); + }); + + describe("valid scenarios", () => { + const context = { userId: "Marsha", groups: ["Group1"] }; + + it("default allocation with disabled feature", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureDefaultDisabled, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("default allocation with enabled feature", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureDefaultEnabled, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Medium"); + expect(variant?.configuration).deep.eq({ Size: "450px", Color: "Purple" }); + }); + + it("user allocation", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureUser, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("group allocation", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureGroup, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("percentile allocation with seed", async () => { + const variant = await featureManager.getVariant(Features.VariantFeaturePercentileOn, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Big"); + + const variant2 = await featureManager.getVariant(Features.VariantFeaturePercentileOff, context); + expect(variant2).to.be.undefined; + }); + + it("overwrite enabled status", async () => { + const enabledStatus = await featureManager.isEnabled(Features.VariantFeaturePercentileOn, context); + expect(enabledStatus).to.be.false; // featureFlag.enabled = true, overridden to false by variant `Big`. + }); + + }); + + describe("invalid scenarios", () => { + const context = { userId: "Jeff" }; + + it("return undefined when no variants are specified", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureNoVariants, context); + expect(variant).to.be.undefined; + }); + + it("return undefined when no allocation is specified", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureNoAllocation, context); + expect(variant).to.be.undefined; + }); + + it("only support configuration value", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureBothConfigurations, context); + expect(variant).not.to.be.undefined; + expect(variant?.configuration).eq("600px"); + }); + + // requires IFeatureFlagProvider to throw an exception on validation + it("throw exception for invalid StatusOverride value"); + + // requires IFeatureFlagProvider to throw an exception on validation + it("throw exception for invalid doubles From and To in the Percentile section"); + + }); + +}); diff --git a/tsconfig.base.json b/sdk/feature-management/tsconfig.json similarity index 95% rename from tsconfig.base.json rename to sdk/feature-management/tsconfig.json index 358df9e..d71e705 100644 --- a/tsconfig.base.json +++ b/sdk/feature-management/tsconfig.json @@ -1,21 +1,21 @@ -{ - "compilerOptions": { - "lib": [ - "DOM", - "WebWorker", - "ESNext" - ], - "skipDefaultLibCheck": true, - "module": "ESNext", - "moduleResolution": "Node", - "target": "ES2022", - "strictNullChecks": true, - "strictFunctionTypes": true, - "sourceMap": true, - "inlineSources": true - }, - "exclude": [ - "node_modules", - "**/node_modules/*" - ] +{ + "compilerOptions": { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + }, + "exclude": [ + "node_modules", + "**/node_modules/*" + ] } \ No newline at end of file diff --git a/tsconfig.test.json b/sdk/feature-management/tsconfig.test.json similarity index 73% rename from tsconfig.test.json rename to sdk/feature-management/tsconfig.test.json index 8a81b80..f717b9f 100644 --- a/tsconfig.test.json +++ b/sdk/feature-management/tsconfig.test.json @@ -1,10 +1,10 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "module": "CommonJS", - "outDir": "./out" - }, - "include": [ - "test/*" - ] +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./out" + }, + "include": [ + "test/*" + ] } \ No newline at end of file