diff --git a/lib/flags/local_flags.js b/lib/flags/local_flags.js index b3961cc..4b0fa61 100644 --- a/lib/flags/local_flags.js +++ b/lib/flags/local_flags.js @@ -12,7 +12,12 @@ * */ const FeatureFlagsProvider = require("./flags"); -const { normalizedHash } = require("./utils"); +const { + normalizedHash, + lowercaseAllKeysAndValues, + lowercaseLeafNodes, +} = require("./utils"); +const { apply } = require("json-logic-js"); class LocalFeatureFlagsProvider extends FeatureFlagsProvider { /** @@ -316,13 +321,39 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { }; } + _extractRuntimeParameters(context) { + const customProperties = context.custom_properties; + if (!customProperties || typeof customProperties !== "object") { + return null; + } + return lowercaseAllKeysAndValues(customProperties); + } + + _isRuntimeRuleSatisfied(rollout, context) { + try { + return apply( + lowercaseLeafNodes(rollout.runtime_evaluation_rule), + this._extractRuntimeParameters(context), + ); + } catch (error) { + this.logger?.error(`Error evaluating runtime rule: ${error.message}`); + return false; + } + } + _isRuntimeEvaluationSatisfied(rollout, context) { - if (!rollout.runtime_evaluation_definition) { + if (rollout.runtime_evaluation_rule) { + return this._isRuntimeRuleSatisfied(rollout, context); + } else if (rollout.runtime_evaluation_definition) { + return this._isLegacyRuntimeEvaluationSatisfied(rollout, context); + } else { return true; } + } - const customProperties = context.custom_properties; - if (!customProperties || typeof customProperties !== "object") { + _isLegacyRuntimeEvaluationSatisfied(rollout, context) { + const customProperties = this._extractRuntimeParameters(context); + if (!customProperties) { return false; } diff --git a/lib/flags/utils.js b/lib/flags/utils.js index ed9511d..2d247b9 100644 --- a/lib/flags/utils.js +++ b/lib/flags/utils.js @@ -70,10 +70,41 @@ function generateTraceparent() { return `${version}-${traceId}-${parentId}-${traceFlags}`; } +function lowercaseJson(obj, lowercaseKeys) { + if (obj === null || obj === undefined) { + return obj; + } else if (typeof obj === "string") { + return obj.toLowerCase(); + } else if (typeof obj === "object") { + if (Array.isArray(obj)) { + return obj.map((item) => lowercaseJson(item, lowercaseKeys)); + } else { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [ + lowercaseKeys ? k.toLowerCase() : k, + lowercaseJson(v, lowercaseKeys), + ]), + ); + } + } else { + return obj; + } +} + +function lowercaseAllKeysAndValues(obj) { + return lowercaseJson(obj, true); +} + +function lowercaseLeafNodes(obj) { + return lowercaseJson(obj, false); +} + module.exports = { EXPOSURE_EVENT, REQUEST_HEADERS, normalizedHash, prepareCommonQueryParams, generateTraceparent, + lowercaseAllKeysAndValues, + lowercaseLeafNodes, }; diff --git a/package-lock.json b/package-lock.json index 489134a..646d6b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.19.1", "license": "MIT", "dependencies": { - "https-proxy-agent": "7.0.6" + "https-proxy-agent": "7.0.6", + "json-logic-js": "^2.0.5" }, "devDependencies": { "@types/node": "^24.10.1", @@ -1519,6 +1520,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-logic-js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.5.tgz", + "integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==", + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -2963,6 +2970,11 @@ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true }, + "json-logic-js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.5.tgz", + "integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==" + }, "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", diff --git a/package.json b/package.json index a84c18e..f41bc17 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "vitest": "^4.0.8" }, "dependencies": { - "https-proxy-agent": "7.0.6" + "https-proxy-agent": "7.0.6", + "json-logic-js": "2.0.5" } } diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 03dd2e3..5db6376 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -20,13 +20,22 @@ const mockFailedFlagDefinitionsResponse = (statusCode) => { .reply(statusCode); }; +function randomString() { + return Math.random().toString(36).substring(7) + "x"; +} + +const USER_ID = "user123"; +const FALLBACK_NAME = "fallback"; +const FALLBACK = { variant_value: FALLBACK_NAME }; + const createTestFlag = ({ flagKey = "test_flag", context = "distinct_id", variants = null, variantOverride = null, rolloutPercentage = 100.0, - runtimeEvaluation = null, + legacyRuntimeRule = null, + runtimeEvaluationRule = null, testUsers = null, experimentId = null, isExperimentActive = null, @@ -41,7 +50,8 @@ const createTestFlag = ({ const rollout = [ { rollout_percentage: rolloutPercentage, - runtime_evaluation_definition: runtimeEvaluation, + runtime_evaluation_definition: legacyRuntimeRule, + runtime_evaluation_rule: runtimeEvaluationRule, variant_override: variantOverride, variant_splits: variantSplits, }, @@ -66,12 +76,54 @@ const createTestFlag = ({ }, }; }; +async function createFlagAndLoadItIntoSDK( + { + flagKey = "test_flag", + context = "distinct_id", + variants = null, + variantOverride = null, + rolloutPercentage = 100.0, + legacyRuntimeRule = null, + runtimeEvaluationRule = null, + testUsers = null, + experimentId = null, + isExperimentActive = null, + variantSplits = null, + hashSalt = null, + } = {}, + provider, +) { + const flag = createTestFlag({ + flagKey, + context, + variants, + variantOverride, + rolloutPercentage, + legacyRuntimeRule, + runtimeEvaluationRule, + testUsers, + experimentId, + isExperimentActive, + variantSplits, + hashSalt, + }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); +} describe("LocalFeatureFlagsProvider", () => { const TEST_TOKEN = "test-token"; const TEST_CONTEXT = { distinct_id: "test-user", }; + const FLAG_KEY = "test_flag"; + + function userContextWithRuntimeParameters(custom_properties) { + return { + ...TEST_CONTEXT, + custom_properties: custom_properties, + }; + } let mockTracker; let mockLogger; @@ -138,9 +190,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when flag does not exist", async () => { - const otherFlag = createTestFlag({ flagKey: "other_flag" }); - mockFlagDefinitionsResponse([otherFlag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ flagKey: "other_flag" }, provider); const result = provider.getVariant( "nonexistent_flag", @@ -151,29 +201,19 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when no context", async () => { - const flag = createTestFlag({ context: "distinct_id" }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ context: "distinct_id" }, provider); - const result = provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - {}, - ); - expect(result.variant_value).toBe("fallback"); + const result = provider.getVariant(FLAG_KEY, FALLBACK, {}); + expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should return fallback when wrong context key", async () => { - const flag = createTestFlag({ context: "user_id" }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ context: "user_id" }, provider); - const result = provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - { distinct_id: "user123" }, - ); - expect(result.variant_value).toBe("fallback"); + const result = provider.getVariant(FLAG_KEY, FALLBACK, { + distinct_id: USER_ID, + }); + expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should return test user variant when configured", async () => { @@ -181,16 +221,16 @@ describe("LocalFeatureFlagsProvider", () => { { key: "control", value: "false", is_control: true, split: 50.0 }, { key: "treatment", value: "true", is_control: false, split: 50.0 }, ]; - const flag = createTestFlag({ - variants: variants, - testUsers: { test_user: "treatment" }, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK( + { + variants: variants, + testUsers: { test_user: "treatment" }, + }, + provider, + ); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "control" }, { distinct_id: "test_user" }, ); @@ -202,93 +242,299 @@ describe("LocalFeatureFlagsProvider", () => { { key: "control", value: "false", is_control: true, split: 50.0 }, { key: "treatment", value: "true", is_control: false, split: 50.0 }, ]; - const flag = createTestFlag({ - variants: variants, - testUsers: { test_user: "nonexistent_variant" }, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - { distinct_id: "test_user" }, + await createFlagAndLoadItIntoSDK( + { + variants: variants, + testUsers: { test_user: "nonexistent_variant" }, + }, + provider, ); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, { + distinct_id: "test_user", + }); expect(["false", "true"]).toContain(result.variant_value); }); it("should return fallback when rollout percentage zero", async () => { - const flag = createTestFlag({ rolloutPercentage: 0.0 }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ rolloutPercentage: 0.0 }, provider); - const result = provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - TEST_CONTEXT, - ); - expect(result.variant_value).toBe("fallback"); + const result = provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); + expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should return variant when rollout percentage hundred", async () => { - const flag = createTestFlag({ rolloutPercentage: 100.0 }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ rolloutPercentage: 100.0 }, provider); - const result = provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - TEST_CONTEXT, - ); - expect(result.variant_value).not.toBe("fallback"); - expect(["control", "treatment"]).toContain(result.variant_value); + const result = provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); + assertVariantReturned(result); }); - it("should respect runtime evaluation when satisfied", async () => { - const runtimeEval = { plan: "premium", region: "US" }; - const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); + it("should return variant when runtime evaluation satisfied", async () => { + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "Premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + const context = userContextWithRuntimeParameters({ + plan: "Premium", + }); - const context = { - distinct_id: "user123", - custom_properties: { - plan: "premium", - region: "US", - }, + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return fallback when runtime evaluation not satisfied", async () => { + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "Premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + plan: randomString(), + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return fallback when no runtime parameters are provided", async () => { + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "Premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters(null); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return fallback when runtime rule is invalid", async () => { + const runtimeEvaluationRule = { "=oops=": [{ var: "plan" }, "Premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters(null); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return variant when runtime evaluation parameters case-insensitively satisfied", async () => { + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + plan: "PREMIUM", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return variant when runtime evaluation parameters case-insensitively satisfied - multi-condition", async () => { + const runtimeEvaluationRule = { + and: [ + { "==": [{ var: "plan" }, "prEmium"] }, + { ">=": [{ var: "date" }, "2025-11-24T09:23"] }, + ], }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); - const result = provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - context, - ); - expect(result.variant_value).not.toBe("fallback"); + const context = userContextWithRuntimeParameters({ + plan: "PReMIuM", + date: "2025-11-24t09:23", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); }); - it("should return fallback when runtime evaluation not satisfied", async () => { - const runtimeEval = { plan: "premium", region: "US" }; - const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); + it("should return variant when runtime evaluation rule case-insensitively satisfied", async () => { + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "PREMIUM"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + const context = userContextWithRuntimeParameters({ + plan: "premium", + }); - const context = { - distinct_id: "user123", - custom_properties: { - plan: "basic", - region: "US", - }, + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return variant when runtime parameter key case-insensitively satisfied", async () => { + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + Plan: "premium", // Capital P instead of lowercase p + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return variant when runtime evaluation with in operator satisfied", async () => { + const runtimeEvaluationRule = { in: ["Springfield", { var: "url" }] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + url: "https://helloworld.com/Springfield/all-about-it", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return fallback when runtime evaluation with in operator not satisfied", async () => { + const runtimeEvaluationRule = { in: ["Springfield", { var: "url" }] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + url: "https://helloworld.com/Boston/all-about-it", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return variant when runtime evaluation with in operator for array satisfied", async () => { + const runtimeEvaluationRule = { + in: [{ var: "name" }, ["a", "b", "c", "all-from-the-ui"]], }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); - const result = provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - context, + const context = userContextWithRuntimeParameters({ + name: "b", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return fallback when runtime evaluation with in operator for array not satisfied", async () => { + const runtimeEvaluationRule = { + in: [{ var: "name" }, ["a", "b", "c", "all-from-the-ui"]], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + name: "d", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return fallback when runtime evaluation with in operator for array not satisfied because only substring match", async () => { + const runtimeEvaluationRule = { + in: [{ var: "name" }, ["a", "b", "c", "all-from-the-ui"]], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + name: "all", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return fallback when runtime evaluation with in operator for array not satisfied because only superstring match", async () => { + const runtimeEvaluationRule = { + in: [{ var: "name" }, ["a", "b", "c", "all-from-the-ui"]], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + name: "all-from-the-ui-and-more", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return variant when runtime evaluation with AND operator satisfied", async () => { + const runtimeEvaluationRule = { + and: [ + { "==": [{ var: "name" }, "Johannes"] }, + { "==": [{ var: "country" }, "Deutschland"] }, + ], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + name: "Johannes", + country: "Deutschland", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return fallback when runtime evaluation with and operator not satisfied", async () => { + const runtimeEvaluationRule = { + and: [ + { "==": [{ var: "name" }, "Johannes"] }, + { "==": [{ var: "country" }, "Deutschland"] }, + ], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + name: "Johannes", + country: "USA", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return variant when runtime evaluation with greater than operator satisfied", async () => { + const runtimeEvaluationRule = { ">": [{ var: "queries_ran" }, 25] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + queries_ran: 27, + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return fallback when runtime evaluation with greater than operator not satisfied", async () => { + const runtimeEvaluationRule = { ">": [{ var: "queries_ran" }, 25] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + queries_ran: 20, + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should respect legacy runtime evaluation when satisfied", async () => { + const legacyRuntimeRule = { plan: "premium", region: "US" }; + await createFlagAndLoadItIntoSDK( + { runtimeEvaluation: legacyRuntimeRule }, + provider, ); - expect(result.variant_value).toBe("fallback"); + + const context = userContextWithRuntimeParameters({ + plan: "premium", + region: "US", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return fallback when legacy runtime evaluation not satisfied", async () => { + const legacyRuntimeRule = { plan: "premium", region: "US" }; + await createFlagAndLoadItIntoSDK({ legacyRuntimeRule }, provider); + + const context = userContextWithRuntimeParameters({ + plan: randomString(), + region: "US", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should pick correct variant with hundred percent split", async () => { @@ -297,19 +543,15 @@ describe("LocalFeatureFlagsProvider", () => { { key: "B", value: "variant_b", is_control: false, split: 0.0 }, { key: "C", value: "variant_c", is_control: false, split: 0.0 }, ]; - const flag = createTestFlag({ - variants: variants, - rolloutPercentage: 100.0, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - TEST_CONTEXT, + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + }, + provider, ); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); expect(result.variant_value).toBe("variant_a"); }); @@ -320,20 +562,16 @@ describe("LocalFeatureFlagsProvider", () => { { key: "C", value: "variant_c", is_control: false, split: 0.0 }, ]; const variantSplits = { A: 0.0, B: 100.0, C: 0.0 }; - const flag = createTestFlag({ - variants: variants, - rolloutPercentage: 100.0, - variantSplits: variantSplits, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - TEST_CONTEXT, + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + variantSplits: variantSplits, + }, + provider, ); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); expect(result.variant_value).toBe("variant_b"); }); @@ -344,20 +582,16 @@ describe("LocalFeatureFlagsProvider", () => { { key: "C", value: "variant_c", is_control: false }, ]; const variantSplits = { A: 0.0, B: 0.0, C: 100.0 }; - const flag = createTestFlag({ - variants: variants, - rolloutPercentage: 100.0, - variantSplits: variantSplits, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - TEST_CONTEXT, + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + variantSplits: variantSplits, + }, + provider, ); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); expect(result.variant_value).toBe("variant_c"); }); @@ -366,16 +600,16 @@ describe("LocalFeatureFlagsProvider", () => { { key: "A", value: "variant_a", is_control: false, split: 100.0 }, { key: "B", value: "variant_b", is_control: false, split: 0.0 }, ]; - const flag = createTestFlag({ - variants: variants, - variantOverride: { key: "B" }, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK( + { + variants: variants, + variantOverride: { key: "B" }, + }, + provider, + ); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "control" }, TEST_CONTEXT, ); @@ -383,34 +617,24 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should track exposure when variant selected", async () => { - const flag = createTestFlag(); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({}, provider); - provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - TEST_CONTEXT, - ); + provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); expect(mockTracker).toHaveBeenCalledTimes(1); }); it("should track exposure with correct properties", async () => { - const flag = createTestFlag({ - experimentId: "exp-123", - isExperimentActive: true, - testUsers: { qa_user: "treatment" }, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - { distinct_id: "qa_user" }, + await createFlagAndLoadItIntoSDK( + { + experimentId: "exp-123", + isExperimentActive: true, + testUsers: { qa_user: "treatment" }, + }, + provider, ); + provider.getVariant(FLAG_KEY, FALLBACK, { distinct_id: "qa_user" }); + expect(mockTracker).toHaveBeenCalledTimes(1); const call = mockTracker.mock.calls[0]; @@ -425,24 +649,14 @@ describe("LocalFeatureFlagsProvider", () => { mockFlagDefinitionsResponse([]); await provider.startPollingForDefinitions(); - provider.getVariant( - "nonexistent_flag", - { variant_value: "fallback" }, - TEST_CONTEXT, - ); + provider.getVariant("nonexistent_flag", FALLBACK, TEST_CONTEXT); expect(mockTracker).not.toHaveBeenCalled(); }); it("should not track exposure without distinct_id", async () => { - const flag = createTestFlag({ context: "company" }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ context: "company" }, provider); - provider.getVariant( - "test_flag", - { variant_value: "fallback" }, - { company_id: "company123" }, - ); + provider.getVariant(FLAG_KEY, FALLBACK, { company_id: "company123" }); expect(mockTracker).not.toHaveBeenCalled(); }); }); @@ -543,16 +757,16 @@ describe("LocalFeatureFlagsProvider", () => { const variants = [ { key: "treatment", value: "blue", is_control: false, split: 100.0 }, ]; - const flag = createTestFlag({ - variants: variants, - rolloutPercentage: 100.0, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + }, + provider, + ); const result = provider.getVariantValue( - "test_flag", + FLAG_KEY, "default", TEST_CONTEXT, ); @@ -599,15 +813,15 @@ describe("LocalFeatureFlagsProvider", () => { const variants = [ { key: "treatment", value: true, is_control: false, split: 100.0 }, ]; - const flag = createTestFlag({ - variants: variants, - rolloutPercentage: 100.0, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + }, + provider, + ); - const result = provider.isEnabled("test_flag", TEST_CONTEXT); + const result = provider.isEnabled(FLAG_KEY, TEST_CONTEXT); expect(result).toBe(true); }); @@ -616,17 +830,22 @@ describe("LocalFeatureFlagsProvider", () => { const variants = [ { key: "control", value: false, is_control: true, split: 100.0 }, ]; - const flag = createTestFlag({ - variants: variants, - rolloutPercentage: 100.0, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + }, + provider, + ); - const result = provider.isEnabled("test_flag", TEST_CONTEXT); + const result = provider.isEnabled(FLAG_KEY, TEST_CONTEXT); expect(result).toBe(false); }); }); }); + +function assertVariantReturned(result) { + expect(result.variant_value).not.toBe(FALLBACK_NAME); + expect(["control", "treatment"]).toContain(result.variant_value); +}