From 5c2e5a8d7453ff840e6fbf68898c2e56d3206d28 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 05:02:34 +0000 Subject: [PATCH 01/13] feat: add custom assignment rules evaluation Add support for targeting rules in the audience payload. Rules are evaluated after audience filter but before audienceStrict/traffic split, allowing forced variant assignment based on context attributes and environment scoping. - Add getEnvironment() to Client - Add evaluateRules() to AudienceMatcher (parses rules[].or[] structure) - Integrate rules into Context._assign() with custom=true flag - Add comprehensive tests for matcher and context integration --- src/__tests__/context.test.js | 154 ++++++++++++++++++++++ src/__tests__/matcher.test.js | 233 ++++++++++++++++++++++++++++++++++ src/client.ts | 4 + src/context.ts | 17 ++- src/matcher.ts | 35 +++++ 5 files changed, 442 insertions(+), 1 deletion(-) diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index 0dc3f89..9334176 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -2025,6 +2025,160 @@ describe("Context", () => { }); }); + describe("rules evaluation", () => { + const rulesContextResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_ab") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "US Internal Users", + and: [ + { eq: [{ var: "country" }, { value: "US" }] }, + ], + environments: [], + variant: 1, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + + const envScopedRulesContextResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_ab") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "Production Only", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: ["production"], + variant: 1, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + + const rulesStrictContextResponse = { + ...rulesContextResponse, + experiments: rulesContextResponse.experiments.map((x) => { + if (x.name === "exp_test_ab") { + return { + ...x, + audienceStrict: true, + audience: JSON.stringify({ + filter: [{ gte: [{ var: "age" }, { value: 20 }] }], + rules: [ + { + or: [ + { + name: "US Users", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + + it("should return rule variant when rule matches", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + expect(context.treatment("exp_test_ab")).toEqual(1); + }); + + it("should return normal assignment when no rules match", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "GB"); + expect(context.treatment("exp_test_ab")).toEqual(expectedVariants["exp_test_ab"]); + }); + + it("should skip rule when environment does not match", () => { + client.getEnvironment = jest.fn().mockReturnValue("staging"); + const context = new Context(sdk, contextOptions, contextParams, envScopedRulesContextResponse); + context.attribute("country", "US"); + expect(context.treatment("exp_test_ab")).toEqual(expectedVariants["exp_test_ab"]); + }); + + it("should match rule when environment matches", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, envScopedRulesContextResponse); + context.attribute("country", "US"); + expect(context.treatment("exp_test_ab")).toEqual(1); + }); + + it("should override takes priority over rules", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + context.override("exp_test_ab", 0); + expect(context.treatment("exp_test_ab")).toEqual(0); + }); + + it("should set custom=true in exposure when rule matches", (done) => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + expect(context.treatment("exp_test_ab")).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_ab"); + expect(exposure.custom).toBe(true); + expect(exposure.assigned).toBe(true); + expect(exposure.variant).toBe(1); + done(); + }); + }); + + it("rule should take priority over audienceStrict when rule matches", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesStrictContextResponse); + context.attribute("country", "US"); + expect(context.treatment("exp_test_ab")).toEqual(1); + }); + + it("should fall back to audienceStrict behavior when no rule matches", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesStrictContextResponse); + context.attribute("country", "GB"); + expect(context.treatment("exp_test_ab")).toEqual(0); + }); + }); + describe("variableValue()", () => { it("should not return variable values when unassigned", (done) => { const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); diff --git a/src/__tests__/matcher.test.js b/src/__tests__/matcher.test.js index 05c345c..c308536 100644 --- a/src/__tests__/matcher.test.js +++ b/src/__tests__/matcher.test.js @@ -26,6 +26,239 @@ describe("AudienceMatcher", () => { expect(matcher.evaluate('{"filter":[{"not":{"var":"returning"}}]}', { returning: true })).toBe(false); expect(matcher.evaluate('{"filter":[{"not":{"var":"returning"}}]}', { returning: false })).toBe(true); }); + describe("evaluateRules", () => { + it("should return null when no rules in audience", () => { + expect(matcher.evaluateRules("{}", "production", {})).toBe(null); + expect(matcher.evaluateRules('{"filter":[]}', "production", {})).toBe(null); + }); + + it("should return null when rules is empty array", () => { + expect(matcher.evaluateRules('{"rules":[]}', "production", {})).toBe(null); + }); + + it("should return variant when conditions match", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(1); + }); + + it("should return null when conditions do not match", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "GB" })).toBe(null); + }); + + it("should skip rules with non-matching environments", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: ["staging"], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(null); + }); + + it("should match when environment is in the environments list", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: ["production", "staging"], + variant: 2, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(2); + expect(matcher.evaluateRules(audience, "staging", { country: "US" })).toBe(2); + }); + + it("should match all environments when environments is empty", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ value: true }], + environments: [], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(1); + expect(matcher.evaluateRules(audience, "staging", {})).toBe(1); + expect(matcher.evaluateRules(audience, null, {})).toBe(1); + }); + + it("should skip rules when environments is non-empty and environmentName is null", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ value: true }], + environments: ["production"], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, null, {})).toBe(null); + }); + + it("should return first matching rule (first match wins)", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + { + name: "rule2", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 2, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(1); + }); + + it("should return variant when conditions are empty (matches all)", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [], + environments: [], + variant: 3, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(3); + }); + + it("should return variant when and field is absent (matches all)", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + environments: [], + variant: 3, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(3); + }); + + it("should handle malformed audience JSON gracefully", () => { + expect(matcher.evaluateRules("not json", "production", {})).toBe(null); + expect(matcher.evaluateRules("", "production", {})).toBe(null); + }); + + it("should handle malformed rules gracefully", () => { + expect(matcher.evaluateRules('{"rules":"not an array"}', "production", {})).toBe(null); + expect(matcher.evaluateRules('{"rules":[{"or":"not an array"}]}', "production", {})).toBe(null); + expect(matcher.evaluateRules('{"rules":[null]}', "production", {})).toBe(null); + }); + + it("should skip to second rule when first does not match", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "GB" }] }], + environments: [], + variant: 1, + }, + { + name: "rule2", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 2, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(2); + }); + + it("should support variant 0", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ value: true }], + environments: [], + variant: 0, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(0); + }); + }); }); /* diff --git a/src/client.ts b/src/client.ts index 6afdf66..1d4cd3e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -83,6 +83,10 @@ export default class Client { this._delay = 50; } + getEnvironment(): string { + return this._opts.environment; + } + getContext(options?: Partial) { return this.getUnauthed({ ...options, diff --git a/src/context.ts b/src/context.ts index 424a0b4..a481a4b 100644 --- a/src/context.ts +++ b/src/context.ts @@ -127,6 +127,7 @@ export default class Context { private readonly _audienceMatcher: AudienceMatcher; private readonly _cassignments: Record; private readonly _dataProvider: ContextDataProvider; + private readonly _environmentName: string | null; private readonly _eventLogger: EventLogger; private readonly _opts: ContextOptions; private readonly _publisher: ContextPublisher; @@ -166,6 +167,7 @@ export default class Context { this._units = {}; this._assigners = {}; this._audienceMatcher = new AudienceMatcher(); + this._environmentName = sdk.getClient()?.getEnvironment() ?? null; this._attrsSeq = 0; if (params.units) { @@ -536,7 +538,20 @@ export default class Context { } } - if (experiment.data.audienceStrict && assignment.audienceMismatch) { + const ruleVariant = + experiment.data.audience && experiment.data.audience.length > 0 + ? this._audienceMatcher.evaluateRules( + experiment.data.audience, + this._environmentName, + this._getAttributesMap() + ) + : null; + + if (ruleVariant !== null) { + assignment.assigned = true; + assignment.variant = ruleVariant; + assignment.custom = true; + } else if (experiment.data.audienceStrict && assignment.audienceMismatch) { assignment.variant = 0; } else if (experiment.data.fullOnVariant === 0) { if (unitType !== null) { diff --git a/src/matcher.ts b/src/matcher.ts index 424988e..2e921fa 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -17,5 +17,40 @@ export class AudienceMatcher { return null; } + evaluateRules( + audienceString: string, + environmentName: string | null, + vars: Record + ): number | null { + try { + const audience = JSON.parse(audienceString); + if (audience && Array.isArray(audience.rules)) { + for (const ruleGroup of audience.rules) { + if (!ruleGroup || !Array.isArray(ruleGroup.or)) continue; + for (const rule of ruleGroup.or) { + if (Array.isArray(rule.environments) && rule.environments.length > 0) { + if (environmentName == null || !rule.environments.includes(environmentName)) { + continue; + } + } + const conditions = rule.and; + if (!conditions || (Array.isArray(conditions) && conditions.length === 0)) { + return rule.variant; + } + if (Array.isArray(conditions)) { + const result = this._jsonExpr.evaluateBooleanExpr({ and: conditions }, vars); + if (result === true) { + return rule.variant; + } + } + } + } + } + } catch (error) { + // parse error or evaluation error - fall through to normal assignment + } + return null; + } + _jsonExpr = new JsonExpr(); } From 003dada590c41b7bc3a73049aeb16b18dd43fe90 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 05:31:18 +0000 Subject: [PATCH 02/13] fix: validate rule.variant is a number and improve test coverage - Guard against missing/non-number variant in evaluateRules returning undefined instead of null - Switch context tests from exp_test_ab (2 variants, normal=1) to exp_test_abc (3 variants, normal=2) so rule variant (1) differs from normal assignment, making tests actually meaningful - Add tests for missing variant and non-number variant properties --- src/__tests__/context.test.js | 39 +++++++++++++++++++++-------------- src/__tests__/matcher.test.js | 35 +++++++++++++++++++++++++++++++ src/matcher.ts | 4 ++-- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index 9334176..b5346c6 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -2026,10 +2026,12 @@ describe("Context", () => { }); describe("rules evaluation", () => { + // Uses exp_test_abc (3 variants, normal assignment = 2) with rules forcing variant 1 + // This ensures tests are meaningful: rule variant (1) differs from normal assignment (2) const rulesContextResponse = { ...getContextResponse, experiments: getContextResponse.experiments.map((x) => { - if (x.name === "exp_test_ab") { + if (x.name === "exp_test_abc") { return { ...x, audience: JSON.stringify({ @@ -2058,7 +2060,7 @@ describe("Context", () => { const envScopedRulesContextResponse = { ...getContextResponse, experiments: getContextResponse.experiments.map((x) => { - if (x.name === "exp_test_ab") { + if (x.name === "exp_test_abc") { return { ...x, audience: JSON.stringify({ @@ -2083,9 +2085,9 @@ describe("Context", () => { }; const rulesStrictContextResponse = { - ...rulesContextResponse, - experiments: rulesContextResponse.experiments.map((x) => { - if (x.name === "exp_test_ab") { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { return { ...x, audienceStrict: true, @@ -2114,49 +2116,53 @@ describe("Context", () => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); context.attribute("country", "US"); - expect(context.treatment("exp_test_ab")).toEqual(1); + // Normal assignment would return 2, rules force variant 1 + expect(context.treatment("exp_test_abc")).toEqual(1); }); it("should return normal assignment when no rules match", () => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); context.attribute("country", "GB"); - expect(context.treatment("exp_test_ab")).toEqual(expectedVariants["exp_test_ab"]); + // No rule matches, should get normal assignment (2) + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); }); it("should skip rule when environment does not match", () => { client.getEnvironment = jest.fn().mockReturnValue("staging"); const context = new Context(sdk, contextOptions, contextParams, envScopedRulesContextResponse); context.attribute("country", "US"); - expect(context.treatment("exp_test_ab")).toEqual(expectedVariants["exp_test_ab"]); + // Rule scoped to production, SDK is staging — should get normal assignment (2) + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); }); it("should match rule when environment matches", () => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, envScopedRulesContextResponse); context.attribute("country", "US"); - expect(context.treatment("exp_test_ab")).toEqual(1); + // Rule scoped to production, SDK is production — should get rule variant (1) + expect(context.treatment("exp_test_abc")).toEqual(1); }); it("should override takes priority over rules", () => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); context.attribute("country", "US"); - context.override("exp_test_ab", 0); - expect(context.treatment("exp_test_ab")).toEqual(0); + context.override("exp_test_abc", 0); + expect(context.treatment("exp_test_abc")).toEqual(0); }); it("should set custom=true in exposure when rule matches", (done) => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); context.attribute("country", "US"); - expect(context.treatment("exp_test_ab")).toEqual(1); + expect(context.treatment("exp_test_abc")).toEqual(1); publisher.publish.mockReturnValue(Promise.resolve()); context.publish().then(() => { const publishCall = publisher.publish.mock.calls[0][0]; - const exposure = publishCall.exposures.find((e) => e.name === "exp_test_ab"); + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); expect(exposure.custom).toBe(true); expect(exposure.assigned).toBe(true); expect(exposure.variant).toBe(1); @@ -2168,14 +2174,17 @@ describe("Context", () => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesStrictContextResponse); context.attribute("country", "US"); - expect(context.treatment("exp_test_ab")).toEqual(1); + // audienceStrict is on, user doesn't match audience filter (no age set), + // but rule matches — should still get rule variant (1) + expect(context.treatment("exp_test_abc")).toEqual(1); }); it("should fall back to audienceStrict behavior when no rule matches", () => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesStrictContextResponse); context.attribute("country", "GB"); - expect(context.treatment("exp_test_ab")).toEqual(0); + // No rule matches, audienceStrict on, audience filter mismatch — variant 0 + expect(context.treatment("exp_test_abc")).toEqual(0); }); }); diff --git a/src/__tests__/matcher.test.js b/src/__tests__/matcher.test.js index c308536..729f02f 100644 --- a/src/__tests__/matcher.test.js +++ b/src/__tests__/matcher.test.js @@ -211,6 +211,41 @@ describe("AudienceMatcher", () => { expect(matcher.evaluateRules("", "production", {})).toBe(null); }); + it("should return null when rule has no variant property", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [], + environments: [], + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(null); + }); + + it("should return null when variant is not a number", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [], + environments: [], + variant: "bad", + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(null); + }); + it("should handle malformed rules gracefully", () => { expect(matcher.evaluateRules('{"rules":"not an array"}', "production", {})).toBe(null); expect(matcher.evaluateRules('{"rules":[{"or":"not an array"}]}', "production", {})).toBe(null); diff --git a/src/matcher.ts b/src/matcher.ts index 2e921fa..2d2fe39 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -35,12 +35,12 @@ export class AudienceMatcher { } const conditions = rule.and; if (!conditions || (Array.isArray(conditions) && conditions.length === 0)) { - return rule.variant; + return typeof rule.variant === "number" ? rule.variant : null; } if (Array.isArray(conditions)) { const result = this._jsonExpr.evaluateBooleanExpr({ and: conditions }, vars); if (result === true) { - return rule.variant; + return typeof rule.variant === "number" ? rule.variant : null; } } } From 76d1d6ee9b9ce0e82244ce6996683dd84f51deee Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 05:40:56 +0000 Subject: [PATCH 03/13] fix: use overridden flag instead of custom for rule-matched assignments Rule-matched assignments should be excluded from statistical analysis, same as overrides. The custom flag is for developer-driven randomization which is still included in analysis. --- src/__tests__/context.test.js | 4 ++-- src/context.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index b5346c6..ace1089 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -2152,7 +2152,7 @@ describe("Context", () => { expect(context.treatment("exp_test_abc")).toEqual(0); }); - it("should set custom=true in exposure when rule matches", (done) => { + it("should set overridden=true in exposure when rule matches", (done) => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); context.attribute("country", "US"); @@ -2163,7 +2163,7 @@ describe("Context", () => { context.publish().then(() => { const publishCall = publisher.publish.mock.calls[0][0]; const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); - expect(exposure.custom).toBe(true); + expect(exposure.overridden).toBe(true); expect(exposure.assigned).toBe(true); expect(exposure.variant).toBe(1); done(); diff --git a/src/context.ts b/src/context.ts index a481a4b..bc8fa3d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -550,7 +550,7 @@ export default class Context { if (ruleVariant !== null) { assignment.assigned = true; assignment.variant = ruleVariant; - assignment.custom = true; + assignment.overridden = true; } else if (experiment.data.audienceStrict && assignment.audienceMismatch) { assignment.variant = 0; } else if (experiment.data.fullOnVariant === 0) { From 296806bef5b6a5d5def31ae63c670b50c7347a14 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 06:29:16 +0000 Subject: [PATCH 04/13] fix: explicitly set eligible=true for rule-matched assignments Rules bypass the traffic split, so eligible must be explicitly set to true (same as fullOn) rather than relying on the default value. --- src/context.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/context.ts b/src/context.ts index bc8fa3d..5025459 100644 --- a/src/context.ts +++ b/src/context.ts @@ -549,6 +549,7 @@ export default class Context { if (ruleVariant !== null) { assignment.assigned = true; + assignment.eligible = true; assignment.variant = ruleVariant; assignment.overridden = true; } else if (experiment.data.audienceStrict && assignment.audienceMismatch) { From 6b1f1cc96859911e4bef0f5d792a462d0bc80b33 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 06:37:49 +0000 Subject: [PATCH 05/13] test: verify all exposure flags for rule-matched assignments Add tests checking the complete flag combination for each scenario: - Rule match: assigned=true, eligible=true, overridden=true - No match (normal): assigned=true, eligible=true, overridden=false - Rule match with audienceMismatch: overridden=true, audienceMismatch=true - Override priority over rule: assigned=false, overridden=true --- src/__tests__/context.test.js | 90 +++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index ace1089..c13635b 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -2152,7 +2152,7 @@ describe("Context", () => { expect(context.treatment("exp_test_abc")).toEqual(0); }); - it("should set overridden=true in exposure when rule matches", (done) => { + it("should set correct flags in exposure when rule matches", (done) => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); context.attribute("country", "US"); @@ -2163,9 +2163,91 @@ describe("Context", () => { context.publish().then(() => { const publishCall = publisher.publish.mock.calls[0][0]; const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); - expect(exposure.overridden).toBe(true); - expect(exposure.assigned).toBe(true); - expect(exposure.variant).toBe(1); + expect(exposure).toMatchObject({ + id: 2, + name: "exp_test_abc", + unit: "session_id", + variant: 1, + assigned: true, + eligible: true, + overridden: true, + fullOn: false, + custom: false, + }); + done(); + }); + }); + + it("should set correct flags in exposure when no rule matches (normal assignment)", (done) => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "GB"); + expect(context.treatment("exp_test_abc")).toEqual(2); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); + expect(exposure).toMatchObject({ + id: 2, + name: "exp_test_abc", + unit: "session_id", + variant: 2, + assigned: true, + eligible: true, + overridden: false, + fullOn: false, + custom: false, + }); + done(); + }); + }); + + it("should set correct flags when rule matches with audienceMismatch", (done) => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesStrictContextResponse); + context.attribute("country", "US"); + // audienceStrict on, no age set so audience filter mismatches, but rule matches + expect(context.treatment("exp_test_abc")).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); + expect(exposure).toMatchObject({ + variant: 1, + assigned: true, + eligible: true, + overridden: true, + fullOn: false, + custom: false, + audienceMismatch: true, + }); + done(); + }); + }); + + it("should set correct flags when override takes priority over rule", (done) => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + context.override("exp_test_abc", 0); + expect(context.treatment("exp_test_abc")).toEqual(0); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); + expect(exposure).toMatchObject({ + variant: 0, + assigned: false, + overridden: true, + fullOn: false, + custom: false, + }); done(); }); }); From 44ee3377645ca66672a01c3f0679eae8b15a520b Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 06:44:29 +0000 Subject: [PATCH 06/13] fix: invalidate cached assignment when rule evaluation result changes Re-evaluate rules in the audienceMatches cache check so that attribute changes affecting rule conditions properly invalidate the cached assignment. Store ruleVariant on the assignment for comparison. --- src/__tests__/context.test.js | 29 +++++++++++++++++++++++++++++ src/context.ts | 15 ++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index c13635b..6997db5 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -2252,6 +2252,35 @@ describe("Context", () => { }); }); + it("should invalidate cached assignment when rule result changes due to attribute change", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + + // First call: no country set, rule doesn't match → normal assignment (2) + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + + // Change attribute so rule now matches + context.attribute("country", "US"); + + // Second call: rule matches → should return rule variant (1), not cached (2) + expect(context.treatment("exp_test_abc")).toEqual(1); + }); + + it("should invalidate cached assignment when rule stops matching due to attribute change", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + + // First call: country=US, rule matches → variant 1 + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(1); + + // Change attribute so rule no longer matches + context.attribute("country", "GB"); + + // Second call: rule no longer matches → should return normal assignment (2) + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + it("rule should take priority over audienceStrict when rule matches", () => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesStrictContextResponse); diff --git a/src/context.ts b/src/context.ts index 5025459..9e632ff 100644 --- a/src/context.ts +++ b/src/context.ts @@ -60,6 +60,7 @@ type Assignment = { fullOn: boolean; custom: boolean; audienceMismatch: boolean; + ruleVariant?: number | null; trafficSplit?: number[]; variables?: Record; attrsSeq?: number; @@ -464,13 +465,23 @@ export default class Context { const audienceMatches = (experiment: ExperimentData, assignment: Assignment) => { if (experiment.audience && experiment.audience.length > 0) { if (this._attrsSeq > (assignment.attrsSeq ?? 0)) { - const result = this._audienceMatcher.evaluate(experiment.audience, this._getAttributesMap()); + const attrs = this._getAttributesMap(); + const result = this._audienceMatcher.evaluate(experiment.audience, attrs); const newAudienceMismatch = typeof result === "boolean" ? !result : false; if (newAudienceMismatch !== assignment.audienceMismatch) { return false; } + const ruleVariant = this._audienceMatcher.evaluateRules( + experiment.audience, + this._environmentName, + attrs + ); + if (ruleVariant !== (assignment.ruleVariant ?? null)) { + return false; + } + assignment.attrsSeq = this._attrsSeq; } } @@ -547,6 +558,8 @@ export default class Context { ) : null; + assignment.ruleVariant = ruleVariant; + if (ruleVariant !== null) { assignment.assigned = true; assignment.eligible = true; From 91a30fb00485463f228e322c635e2db1f31c2d47 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 07:08:07 +0000 Subject: [PATCH 07/13] fix: prevent rule-cached assignment from satisfying override cache check When a rule caches an assignment with overridden=true and assigned=true, a subsequent override() with the same variant would incorrectly reuse the cached rule assignment (which has assigned=true). Add !assigned check to the override cache path so it always recomputes, producing the correct override flags (assigned=false, overridden=true). --- src/__tests__/context.test.js | 29 +++++++++++++++++++++++++++++ src/context.ts | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index 6997db5..cf835e7 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -2252,6 +2252,35 @@ describe("Context", () => { }); }); + it("should set correct override flags even when override variant matches rule variant", (done) => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + + // First call: rule matches → variant 1 with assigned=true, overridden=true + expect(context.treatment("exp_test_abc")).toEqual(1); + + // Now override with the same variant the rule assigned + context.override("exp_test_abc", 1); + + // Second call: override should take over with assigned=false + expect(context.treatment("exp_test_abc")).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + const publishCall = publisher.publish.mock.calls[0][0]; + const exposures = publishCall.exposures.filter((e) => e.name === "exp_test_abc"); + const lastExposure = exposures[exposures.length - 1]; + expect(lastExposure).toMatchObject({ + variant: 1, + assigned: false, + overridden: true, + }); + done(); + }); + }); + it("should invalidate cached assignment when rule result changes due to attribute change", () => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); diff --git a/src/context.ts b/src/context.ts index 9e632ff..928cf3f 100644 --- a/src/context.ts +++ b/src/context.ts @@ -495,7 +495,7 @@ export default class Context { if (experimentName in this._assignments) { const assignment = this._assignments[experimentName]; if (hasOverride) { - if (assignment.overridden && assignment.variant === this._overrides[experimentName]) { + if (assignment.overridden && !assignment.assigned && assignment.variant === this._overrides[experimentName]) { // override up-to-date return assignment; } From ee2957d30b8625411e0b0cc7fb2727e1c0f2227c Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 07:32:49 +0000 Subject: [PATCH 08/13] fix: address PR review findings - Log errors in evaluateRules catch block (matching evaluate() pattern) - Extract _getAttributesMap() to avoid double call in assignment path - Add test: multiple rule groups (second group matches when first doesn't) - Add test: variableValue returns correct config for rule-assigned variant - Add test: cache invalidation when rule switches between matching variants --- src/__tests__/context.test.js | 55 +++++++++++++++++++++++++++++++++++ src/__tests__/matcher.test.js | 28 ++++++++++++++++++ src/context.ts | 20 ++++++------- src/matcher.ts | 2 +- 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index cf835e7..5535129 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -2326,6 +2326,61 @@ describe("Context", () => { // No rule matches, audienceStrict on, audience filter mismatch — variant 0 expect(context.treatment("exp_test_abc")).toEqual(0); }); + + it("should return correct variableValue when rule forces a different variant", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); + context.attribute("country", "US"); + // Rule forces variant 1 (B) which has config {"button.color":"blue"} + // Normal assignment would be variant 2 (C) with {"button.color":"red"} + expect(context.treatment("exp_test_abc")).toEqual(1); + expect(context.variableValue("button.color", "default")).toEqual("blue"); + }); + + it("should invalidate cache when rule switches to a different matching variant", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const twoRulesContextResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "US Users", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + { + name: "GB Users", + and: [{ eq: [{ var: "country" }, { value: "GB" }] }], + environments: [], + variant: 2, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + const context = new Context(sdk, contextOptions, contextParams, twoRulesContextResponse); + + // First: US → rule variant 1 + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(1); + + // Switch to GB → rule variant 2 + context.attribute("country", "GB"); + expect(context.treatment("exp_test_abc")).toEqual(2); + }); }); describe("variableValue()", () => { diff --git a/src/__tests__/matcher.test.js b/src/__tests__/matcher.test.js index 729f02f..9cc4a35 100644 --- a/src/__tests__/matcher.test.js +++ b/src/__tests__/matcher.test.js @@ -276,6 +276,34 @@ describe("AudienceMatcher", () => { expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(2); }); + it("should evaluate second rule group when first has no match", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "rule1", + and: [{ eq: [{ var: "country" }, { value: "GB" }] }], + environments: [], + variant: 1, + }, + ], + }, + { + or: [ + { + name: "rule2", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 2, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", { country: "US" })).toBe(2); + }); + it("should support variant 0", () => { const audience = JSON.stringify({ rules: [ diff --git a/src/context.ts b/src/context.ts index 928cf3f..a39db81 100644 --- a/src/context.ts +++ b/src/context.ts @@ -541,22 +541,22 @@ export default class Context { if (experiment != null) { const unitType = experiment.data.unitType; + let ruleVariant: number | null = null; + if (experiment.data.audience && experiment.data.audience.length > 0) { - const result = this._audienceMatcher.evaluate(experiment.data.audience, this._getAttributesMap()); + const attrs = this._getAttributesMap(); + const result = this._audienceMatcher.evaluate(experiment.data.audience, attrs); if (typeof result === "boolean") { assignment.audienceMismatch = !result; } - } - const ruleVariant = - experiment.data.audience && experiment.data.audience.length > 0 - ? this._audienceMatcher.evaluateRules( - experiment.data.audience, - this._environmentName, - this._getAttributesMap() - ) - : null; + ruleVariant = this._audienceMatcher.evaluateRules( + experiment.data.audience, + this._environmentName, + attrs + ); + } assignment.ruleVariant = ruleVariant; diff --git a/src/matcher.ts b/src/matcher.ts index 2d2fe39..039be54 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -47,7 +47,7 @@ export class AudienceMatcher { } } } catch (error) { - // parse error or evaluation error - fall through to normal assignment + console.error(error); } return null; } From 3b9ab8cee25f05000de56000ddbb1a51fbc61892 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 07:46:40 +0000 Subject: [PATCH 09/13] fix: remove optional chaining on getClient() and document flag semantics Remove unnecessary optional chaining on sdk.getClient() to fail fast consistently with the rest of the codebase. Document how rule-matched assignments (assigned=true, overridden=true) are distinguished from SDK overrides (assigned=false, overridden=true) in analytics. --- src/context.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/context.ts b/src/context.ts index a39db81..644fb09 100644 --- a/src/context.ts +++ b/src/context.ts @@ -168,7 +168,7 @@ export default class Context { this._units = {}; this._assigners = {}; this._audienceMatcher = new AudienceMatcher(); - this._environmentName = sdk.getClient()?.getEnvironment() ?? null; + this._environmentName = sdk.getClient().getEnvironment(); this._attrsSeq = 0; if (params.units) { @@ -561,6 +561,9 @@ export default class Context { assignment.ruleVariant = ruleVariant; if (ruleVariant !== null) { + // Rule-matched: assigned=true + overridden=true + // SDK overrides: assigned=false + overridden=true + // This distinction lets analytics differentiate the two cases assignment.assigned = true; assignment.eligible = true; assignment.variant = ruleVariant; From f32df8e30c7e4a7de2ea45c969a0c83505752779 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 08:24:17 +0000 Subject: [PATCH 10/13] fix: skip rules with invalid variant instead of aborting iteration Change from return null to continue when a matched rule has a non-numeric variant, so subsequent valid rules are still evaluated. Also persist ruleVariant in audienceMatches cache validation to maintain cache consistency across attribute changes. --- src/__tests__/matcher.test.js | 47 +++++++++++++++++++++++++++++++++++ src/context.ts | 13 +++------- src/matcher.ts | 11 +++----- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/__tests__/matcher.test.js b/src/__tests__/matcher.test.js index 9cc4a35..f18cff5 100644 --- a/src/__tests__/matcher.test.js +++ b/src/__tests__/matcher.test.js @@ -246,6 +246,53 @@ describe("AudienceMatcher", () => { expect(matcher.evaluateRules(audience, "production", {})).toBe(null); }); + it("should skip rule with invalid variant and continue to next valid rule", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "bad rule", + and: [], + environments: [], + variant: "not a number", + }, + { + name: "good rule", + and: [], + environments: [], + variant: 2, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(2); + }); + + it("should skip rule with missing variant and continue to next valid rule", () => { + const audience = JSON.stringify({ + rules: [ + { + or: [ + { + name: "no variant", + and: [], + environments: [], + }, + { + name: "good rule", + and: [], + environments: [], + variant: 1, + }, + ], + }, + ], + }); + expect(matcher.evaluateRules(audience, "production", {})).toBe(1); + }); + it("should handle malformed rules gracefully", () => { expect(matcher.evaluateRules('{"rules":"not an array"}', "production", {})).toBe(null); expect(matcher.evaluateRules('{"rules":[{"or":"not an array"}]}', "production", {})).toBe(null); diff --git a/src/context.ts b/src/context.ts index 644fb09..2c7ec3e 100644 --- a/src/context.ts +++ b/src/context.ts @@ -473,15 +473,12 @@ export default class Context { return false; } - const ruleVariant = this._audienceMatcher.evaluateRules( - experiment.audience, - this._environmentName, - attrs - ); + const ruleVariant = this._audienceMatcher.evaluateRules(experiment.audience, this._environmentName, attrs); if (ruleVariant !== (assignment.ruleVariant ?? null)) { return false; } + assignment.ruleVariant = ruleVariant; assignment.attrsSeq = this._attrsSeq; } } @@ -551,11 +548,7 @@ export default class Context { assignment.audienceMismatch = !result; } - ruleVariant = this._audienceMatcher.evaluateRules( - experiment.data.audience, - this._environmentName, - attrs - ); + ruleVariant = this._audienceMatcher.evaluateRules(experiment.data.audience, this._environmentName, attrs); } assignment.ruleVariant = ruleVariant; diff --git a/src/matcher.ts b/src/matcher.ts index 039be54..9a2c8f1 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -17,11 +17,7 @@ export class AudienceMatcher { return null; } - evaluateRules( - audienceString: string, - environmentName: string | null, - vars: Record - ): number | null { + evaluateRules(audienceString: string, environmentName: string | null, vars: Record): number | null { try { const audience = JSON.parse(audienceString); if (audience && Array.isArray(audience.rules)) { @@ -33,14 +29,15 @@ export class AudienceMatcher { continue; } } + if (typeof rule.variant !== "number") continue; const conditions = rule.and; if (!conditions || (Array.isArray(conditions) && conditions.length === 0)) { - return typeof rule.variant === "number" ? rule.variant : null; + return rule.variant; } if (Array.isArray(conditions)) { const result = this._jsonExpr.evaluateBooleanExpr({ and: conditions }, vars); if (result === true) { - return typeof rule.variant === "number" ? rule.variant : null; + return rule.variant; } } } From 8d6e73efd160319efcbd21d64d12dba74bd4aa46 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 23 Mar 2026 12:49:13 +0000 Subject: [PATCH 11/13] feat: add targetingRule flag to exposure events Add a dedicated targetingRule boolean flag (bit 8, value 256) to exposure events so analytics can explicitly identify rule-forced assignments without relying on the assigned+overridden combination. - Rule match: targetingRule=true, overridden=true, assigned=true - SDK override: targetingRule=false, overridden=true, assigned=false - Normal assignment: targetingRule=false, overridden=false --- src/__tests__/context.test.js | 52 ++++++++++++++++++++++++++++++----- src/context.ts | 9 ++++-- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index 5535129..1401cf1 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -758,6 +758,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -871,6 +872,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], attributes: [ @@ -1403,6 +1405,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 2, @@ -1416,6 +1419,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 3, @@ -1429,6 +1433,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 4, @@ -1442,6 +1447,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 5, @@ -1455,6 +1461,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -1523,6 +1530,7 @@ describe("Context", () => { fullOn: experiment.name === "exp_test_fullon", custom: false, audienceMismatch: false, + targetingRule: false, }); } @@ -1565,6 +1573,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -1613,6 +1622,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -1653,6 +1663,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + targetingRule: false, }, ], }, @@ -1693,6 +1704,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + targetingRule: false, }, ], }, @@ -1754,6 +1766,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 0, @@ -1767,6 +1780,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -1893,6 +1907,7 @@ describe("Context", () => { name: "exp_test_ab", variant: 0, audienceMismatch: true, + targetingRule: false, assigned: false, }), ], @@ -1918,6 +1933,7 @@ describe("Context", () => { name: "exp_test_ab", variant: 1, audienceMismatch: false, + targetingRule: false, assigned: true, }), ], @@ -2041,9 +2057,7 @@ describe("Context", () => { or: [ { name: "US Internal Users", - and: [ - { eq: [{ var: "country" }, { value: "US" }] }, - ], + and: [{ eq: [{ var: "country" }, { value: "US" }] }], environments: [], variant: 1, }, @@ -2173,6 +2187,7 @@ describe("Context", () => { overridden: true, fullOn: false, custom: false, + targetingRule: true, }); done(); }); @@ -2199,6 +2214,7 @@ describe("Context", () => { overridden: false, fullOn: false, custom: false, + targetingRule: false, }); done(); }); @@ -2208,7 +2224,6 @@ describe("Context", () => { client.getEnvironment = jest.fn().mockReturnValue("production"); const context = new Context(sdk, contextOptions, contextParams, rulesStrictContextResponse); context.attribute("country", "US"); - // audienceStrict on, no age set so audience filter mismatches, but rule matches expect(context.treatment("exp_test_abc")).toEqual(1); publisher.publish.mockReturnValue(Promise.resolve()); @@ -2224,6 +2239,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + targetingRule: true, }); done(); }); @@ -2247,6 +2263,7 @@ describe("Context", () => { overridden: true, fullOn: false, custom: false, + targetingRule: false, }); done(); }); @@ -2257,13 +2274,10 @@ describe("Context", () => { const context = new Context(sdk, contextOptions, contextParams, rulesContextResponse); context.attribute("country", "US"); - // First call: rule matches → variant 1 with assigned=true, overridden=true expect(context.treatment("exp_test_abc")).toEqual(1); - // Now override with the same variant the rule assigned context.override("exp_test_abc", 1); - // Second call: override should take over with assigned=false expect(context.treatment("exp_test_abc")).toEqual(1); publisher.publish.mockReturnValue(Promise.resolve()); @@ -2276,6 +2290,7 @@ describe("Context", () => { variant: 1, assigned: false, overridden: true, + targetingRule: false, }); done(); }); @@ -2463,6 +2478,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 2, @@ -2476,6 +2492,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 3, @@ -2489,6 +2506,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 4, @@ -2502,6 +2520,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 5, @@ -2515,6 +2534,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -2628,6 +2648,7 @@ describe("Context", () => { fullOn: experiment.name === "exp_test_fullon", custom: false, audienceMismatch: false, + targetingRule: false, }); } else { expect(SDK.defaultEventLogger).not.toHaveBeenCalled(); @@ -2693,6 +2714,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -2733,6 +2755,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + targetingRule: false, }, ], }, @@ -2773,6 +2796,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, + targetingRule: false, }, ], }, @@ -3210,6 +3234,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 3, @@ -3223,6 +3248,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], goals: [ @@ -3444,6 +3470,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 0, @@ -3457,6 +3484,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 2, @@ -3470,6 +3498,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + targetingRule: false, }, ], goals: [ @@ -3703,6 +3732,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -3753,6 +3783,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -3952,6 +3983,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 2, @@ -3965,6 +3997,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -4013,6 +4046,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -4060,6 +4094,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }, { id: 4, @@ -4073,6 +4108,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, + targetingRule: false, }, ], }, @@ -4129,6 +4165,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + targetingRule: false, }, { id: 2, @@ -4142,6 +4179,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, + targetingRule: false, }, ], }, diff --git a/src/context.ts b/src/context.ts index 2c7ec3e..c9f3ca5 100644 --- a/src/context.ts +++ b/src/context.ts @@ -60,6 +60,7 @@ type Assignment = { fullOn: boolean; custom: boolean; audienceMismatch: boolean; + targetingRule: boolean; ruleVariant?: number | null; trafficSplit?: number[]; variables?: Record; @@ -88,6 +89,7 @@ export type Exposure = { fullOn: boolean; custom: boolean; audienceMismatch: boolean; + targetingRule: boolean; }; export type Attribute = { @@ -522,6 +524,7 @@ export default class Context { fullOn: false, custom: false, audienceMismatch: false, + targetingRule: false, }; this._assignments[experimentName] = assignment; @@ -554,13 +557,11 @@ export default class Context { assignment.ruleVariant = ruleVariant; if (ruleVariant !== null) { - // Rule-matched: assigned=true + overridden=true - // SDK overrides: assigned=false + overridden=true - // This distinction lets analytics differentiate the two cases assignment.assigned = true; assignment.eligible = true; assignment.variant = ruleVariant; assignment.overridden = true; + assignment.targetingRule = true; } else if (experiment.data.audienceStrict && assignment.audienceMismatch) { assignment.variant = 0; } else if (experiment.data.fullOnVariant === 0) { @@ -652,6 +653,7 @@ export default class Context { fullOn: assignment.fullOn, custom: assignment.custom, audienceMismatch: assignment.audienceMismatch, + targetingRule: assignment.targetingRule, }; this._logEvent("exposure", exposureEvent); @@ -852,6 +854,7 @@ export default class Context { fullOn: x.fullOn, custom: x.custom, audienceMismatch: x.audienceMismatch, + targetingRule: x.targetingRule, })); } From 32b8da57c6f30c885a7847b96f85b5005ce17346 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Mar 2026 11:00:21 +0000 Subject: [PATCH 12/13] refactor: rename targetingRule to ruleOverride and address review feedback - Rename targetingRule flag to ruleOverride per Cal's suggestion - Reduce nesting in evaluateRules with early returns - Remove unnecessary comment in test fixtures - Add tests for multiple and conditions, multiple environments, and multiple or rules in context integration tests --- src/__tests__/context.test.js | 219 +++++++++++++++++++++++++++------- src/context.ts | 12 +- src/matcher.ts | 54 +++++---- 3 files changed, 210 insertions(+), 75 deletions(-) diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index 1401cf1..1af6d9c 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -758,7 +758,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -872,7 +872,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], attributes: [ @@ -1405,7 +1405,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 2, @@ -1419,7 +1419,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 3, @@ -1433,7 +1433,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 4, @@ -1447,7 +1447,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 5, @@ -1461,7 +1461,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -1530,7 +1530,7 @@ describe("Context", () => { fullOn: experiment.name === "exp_test_fullon", custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }); } @@ -1573,7 +1573,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -1622,7 +1622,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -1663,7 +1663,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -1704,7 +1704,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -1766,7 +1766,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 0, @@ -1780,7 +1780,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -1907,7 +1907,7 @@ describe("Context", () => { name: "exp_test_ab", variant: 0, audienceMismatch: true, - targetingRule: false, + ruleOverride: false, assigned: false, }), ], @@ -1933,7 +1933,7 @@ describe("Context", () => { name: "exp_test_ab", variant: 1, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, assigned: true, }), ], @@ -2042,8 +2042,6 @@ describe("Context", () => { }); describe("rules evaluation", () => { - // Uses exp_test_abc (3 variants, normal assignment = 2) with rules forcing variant 1 - // This ensures tests are meaningful: rule variant (1) differs from normal assignment (2) const rulesContextResponse = { ...getContextResponse, experiments: getContextResponse.experiments.map((x) => { @@ -2187,7 +2185,7 @@ describe("Context", () => { overridden: true, fullOn: false, custom: false, - targetingRule: true, + ruleOverride: true, }); done(); }); @@ -2214,7 +2212,7 @@ describe("Context", () => { overridden: false, fullOn: false, custom: false, - targetingRule: false, + ruleOverride: false, }); done(); }); @@ -2239,7 +2237,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, - targetingRule: true, + ruleOverride: true, }); done(); }); @@ -2263,7 +2261,7 @@ describe("Context", () => { overridden: true, fullOn: false, custom: false, - targetingRule: false, + ruleOverride: false, }); done(); }); @@ -2290,7 +2288,7 @@ describe("Context", () => { variant: 1, assigned: false, overridden: true, - targetingRule: false, + ruleOverride: false, }); done(); }); @@ -2396,6 +2394,135 @@ describe("Context", () => { context.attribute("country", "GB"); expect(context.treatment("exp_test_abc")).toEqual(2); }); + + it("should match rule with multiple and conditions", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const multiAndResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "US Internal", + and: [ + { eq: [{ var: "country" }, { value: "US" }] }, + { eq: [{ var: "user_type" }, { value: "internal" }] }, + ], + environments: [], + variant: 1, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + const context = new Context(sdk, contextOptions, contextParams, multiAndResponse); + + context.attribute("country", "US"); + context.attribute("user_type", "internal"); + expect(context.treatment("exp_test_abc")).toEqual(1); + + context.attribute("user_type", "external"); + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); + + it("should match rule scoped to multiple environments", () => { + client.getEnvironment = jest.fn().mockReturnValue("staging"); + const multiEnvResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "Prod and Staging", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: ["production", "staging"], + variant: 1, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + const context = new Context(sdk, contextOptions, contextParams, multiEnvResponse); + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(1); + }); + + it("should evaluate multiple or rules and match the first", () => { + client.getEnvironment = jest.fn().mockReturnValue("production"); + const multiOrResponse = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + audience: JSON.stringify({ + filter: [{ value: true }], + rules: [ + { + or: [ + { + name: "US Users", + and: [{ eq: [{ var: "country" }, { value: "US" }] }], + environments: [], + variant: 1, + }, + { + name: "GB Users", + and: [{ eq: [{ var: "country" }, { value: "GB" }] }], + environments: [], + variant: 2, + }, + { + name: "FR Users", + and: [{ eq: [{ var: "country" }, { value: "FR" }] }], + environments: [], + variant: 0, + }, + ], + }, + ], + }), + }; + } + return x; + }), + }; + const context = new Context(sdk, contextOptions, contextParams, multiOrResponse); + + context.attribute("country", "US"); + expect(context.treatment("exp_test_abc")).toEqual(1); + + context.attribute("country", "GB"); + expect(context.treatment("exp_test_abc")).toEqual(2); + + context.attribute("country", "FR"); + expect(context.treatment("exp_test_abc")).toEqual(0); + + context.attribute("country", "DE"); + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + }); }); describe("variableValue()", () => { @@ -2478,7 +2605,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 2, @@ -2492,7 +2619,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 3, @@ -2506,7 +2633,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 4, @@ -2520,7 +2647,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 5, @@ -2534,7 +2661,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -2648,7 +2775,7 @@ describe("Context", () => { fullOn: experiment.name === "exp_test_fullon", custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }); } else { expect(SDK.defaultEventLogger).not.toHaveBeenCalled(); @@ -2714,7 +2841,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -2755,7 +2882,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -2796,7 +2923,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: true, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -3234,7 +3361,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 3, @@ -3248,7 +3375,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], goals: [ @@ -3470,7 +3597,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 0, @@ -3484,7 +3611,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 2, @@ -3498,7 +3625,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], goals: [ @@ -3732,7 +3859,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -3783,7 +3910,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -3983,7 +4110,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 2, @@ -3997,7 +4124,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -4046,7 +4173,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -4094,7 +4221,7 @@ describe("Context", () => { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 4, @@ -4108,7 +4235,7 @@ describe("Context", () => { fullOn: true, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, @@ -4165,7 +4292,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, { id: 2, @@ -4179,7 +4306,7 @@ describe("Context", () => { fullOn: false, custom: true, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }, ], }, diff --git a/src/context.ts b/src/context.ts index c9f3ca5..6d77ebd 100644 --- a/src/context.ts +++ b/src/context.ts @@ -60,7 +60,7 @@ type Assignment = { fullOn: boolean; custom: boolean; audienceMismatch: boolean; - targetingRule: boolean; + ruleOverride: boolean; ruleVariant?: number | null; trafficSplit?: number[]; variables?: Record; @@ -89,7 +89,7 @@ export type Exposure = { fullOn: boolean; custom: boolean; audienceMismatch: boolean; - targetingRule: boolean; + ruleOverride: boolean; }; export type Attribute = { @@ -524,7 +524,7 @@ export default class Context { fullOn: false, custom: false, audienceMismatch: false, - targetingRule: false, + ruleOverride: false, }; this._assignments[experimentName] = assignment; @@ -561,7 +561,7 @@ export default class Context { assignment.eligible = true; assignment.variant = ruleVariant; assignment.overridden = true; - assignment.targetingRule = true; + assignment.ruleOverride = true; } else if (experiment.data.audienceStrict && assignment.audienceMismatch) { assignment.variant = 0; } else if (experiment.data.fullOnVariant === 0) { @@ -653,7 +653,7 @@ export default class Context { fullOn: assignment.fullOn, custom: assignment.custom, audienceMismatch: assignment.audienceMismatch, - targetingRule: assignment.targetingRule, + ruleOverride: assignment.ruleOverride, }; this._logEvent("exposure", exposureEvent); @@ -854,7 +854,7 @@ export default class Context { fullOn: x.fullOn, custom: x.custom, audienceMismatch: x.audienceMismatch, - targetingRule: x.targetingRule, + ruleOverride: x.ruleOverride, })); } diff --git a/src/matcher.ts b/src/matcher.ts index 9a2c8f1..8474ca1 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -18,34 +18,42 @@ export class AudienceMatcher { } evaluateRules(audienceString: string, environmentName: string | null, vars: Record): number | null { + let audience; try { - const audience = JSON.parse(audienceString); - if (audience && Array.isArray(audience.rules)) { - for (const ruleGroup of audience.rules) { - if (!ruleGroup || !Array.isArray(ruleGroup.or)) continue; - for (const rule of ruleGroup.or) { - if (Array.isArray(rule.environments) && rule.environments.length > 0) { - if (environmentName == null || !rule.environments.includes(environmentName)) { - continue; - } - } - if (typeof rule.variant !== "number") continue; - const conditions = rule.and; - if (!conditions || (Array.isArray(conditions) && conditions.length === 0)) { - return rule.variant; - } - if (Array.isArray(conditions)) { - const result = this._jsonExpr.evaluateBooleanExpr({ and: conditions }, vars); - if (result === true) { - return rule.variant; - } - } + audience = JSON.parse(audienceString); + } catch (error) { + console.error(error); + return null; + } + + if (!audience || !Array.isArray(audience.rules)) return null; + + for (const ruleGroup of audience.rules) { + if (!ruleGroup || !Array.isArray(ruleGroup.or)) continue; + for (const rule of ruleGroup.or) { + if (Array.isArray(rule.environments) && rule.environments.length > 0) { + if (environmentName == null || !rule.environments.includes(environmentName)) { + continue; } } + + if (typeof rule.variant !== "number") continue; + + const conditions = rule.and; + + if (!conditions || (Array.isArray(conditions) && conditions.length === 0)) { + return rule.variant; + } + + if (!Array.isArray(conditions)) continue; + + const result = this._jsonExpr.evaluateBooleanExpr({ and: conditions }, vars); + if (result === true) { + return rule.variant; + } } - } catch (error) { - console.error(error); } + return null; } From 126cc6753d123e07e48e7cf682ebd16588498334 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 25 Mar 2026 09:20:07 +0000 Subject: [PATCH 13/13] fix: rule-matched assignments should not be participants Rule-forced users are not real experiment participants. Set assigned=false and overridden=false, relying on ruleOverride=true to identify them. The assigned=false already excludes them from the engine filter (bitAnd(flags, 207) = 3 requires assigned=true). Update variableValue/peekVariable to check ruleOverride alongside assigned and overridden for returning variable configs. Remove the !assignment.assigned override cache workaround since rules no longer set overridden=true. --- src/__tests__/context.test.js | 8 ++++---- src/context.ts | 9 +++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index 1af6d9c..e17325f 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -2180,9 +2180,9 @@ describe("Context", () => { name: "exp_test_abc", unit: "session_id", variant: 1, - assigned: true, + assigned: false, eligible: true, - overridden: true, + overridden: false, fullOn: false, custom: false, ruleOverride: true, @@ -2231,9 +2231,9 @@ describe("Context", () => { const exposure = publishCall.exposures.find((e) => e.name === "exp_test_abc"); expect(exposure).toMatchObject({ variant: 1, - assigned: true, + assigned: false, eligible: true, - overridden: true, + overridden: false, fullOn: false, custom: false, audienceMismatch: true, diff --git a/src/context.ts b/src/context.ts index 6d77ebd..3a6f138 100644 --- a/src/context.ts +++ b/src/context.ts @@ -494,7 +494,7 @@ export default class Context { if (experimentName in this._assignments) { const assignment = this._assignments[experimentName]; if (hasOverride) { - if (assignment.overridden && !assignment.assigned && assignment.variant === this._overrides[experimentName]) { + if (assignment.overridden && assignment.variant === this._overrides[experimentName]) { // override up-to-date return assignment; } @@ -557,10 +557,7 @@ export default class Context { assignment.ruleVariant = ruleVariant; if (ruleVariant !== null) { - assignment.assigned = true; - assignment.eligible = true; assignment.variant = ruleVariant; - assignment.overridden = true; assignment.ruleOverride = true; } else if (experiment.data.audienceStrict && assignment.audienceMismatch) { assignment.variant = 0; @@ -756,7 +753,7 @@ export default class Context { this._queueExposure(experimentName, assignment); } - if (key in assignment.variables && (assignment.assigned || assignment.overridden)) { + if (key in assignment.variables && (assignment.assigned || assignment.overridden || assignment.ruleOverride)) { return assignment.variables[key] as string; } } @@ -770,7 +767,7 @@ export default class Context { const experimentName = this._indexVariables[key][i].data.name; const assignment = this._assign(experimentName); if (assignment.variables !== undefined) { - if (key in assignment.variables && (assignment.assigned || assignment.overridden)) { + if (key in assignment.variables && (assignment.assigned || assignment.overridden || assignment.ruleOverride)) { return assignment.variables[key] as string; } }