diff --git a/package.json b/package.json index f806544..1c259e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@absmartly/javascript-sdk", - "version": "1.13.2", + "version": "1.13.3", "description": "A/B Smartly Javascript SDK", "homepage": "https://github.com/absmartly/javascript-sdk#README.md", "bugs": "https://github.com/absmartly/javascript-sdk/issues", diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index 64503d8..0dc3f89 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -1309,6 +1309,60 @@ describe("Context", () => { done(); }); + + it("should re-evaluate audience expression when attributes change in strict mode", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); + + // First peek call without matching attribute should return control (0) + expect(context.peek("exp_test_ab")).toEqual(0); + + // Set attribute that matches the audience filter (age >= 20) + context.attribute("age", 25); + + // Second peek call should re-evaluate and return assigned variant (1) + expect(context.peek("exp_test_ab")).toEqual(1); + + // peek() should not queue exposures + expect(context.pending()).toEqual(0); + + done(); + }); + + it("should re-evaluate audience expression when attributes change in non-strict mode", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceContextResponse); + + // First peek call without matching attribute should return assigned variant (1) + expect(context.peek("exp_test_ab")).toEqual(1); + + // Set attribute that matches the audience filter (age >= 20) + context.attribute("age", 25); + + // Second peek call should re-evaluate and still return 1 + expect(context.peek("exp_test_ab")).toEqual(1); + + // peek() should not queue exposures + expect(context.pending()).toEqual(0); + + done(); + }); + + it("should not re-evaluate audience when no new attributes set", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); + + // Set attribute first + context.attribute("age", 15); + + // First peek call with non-matching attribute should return control (0) + expect(context.peek("exp_test_ab")).toEqual(0); + + // Second peek call without adding new attributes should use cached assignment + expect(context.peek("exp_test_ab")).toEqual(0); + + // peek() should not queue exposures + expect(context.pending()).toEqual(0); + + done(); + }); }); describe("treatment()", () => { @@ -1742,6 +1796,233 @@ describe("Context", () => { expect(context.isFinalizing()).toEqual(true); expect(() => context.treatment("exp_test_ab")).toThrow(); }); + + it("should re-evaluate audience expression when attributes change in strict mode", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); + + // First treatment call without matching attribute should return control (0) + expect(context.treatment("exp_test_ab")).toEqual(0); + expect(context.pending()).toEqual(1); + + // Set attribute that matches the audience filter (age >= 20) + context.attribute("age", 25); + + // Second treatment call should re-evaluate and return assigned variant (1) + expect(context.treatment("exp_test_ab")).toEqual(1); + + // Should queue another exposure + expect(context.pending()).toEqual(2); + + done(); + }); + + it("should re-evaluate audience expression when attributes change in non-strict mode", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceContextResponse); + + // First treatment call without matching attribute should return assigned variant (1) + // but with audienceMismatch = true + expect(context.treatment("exp_test_ab")).toEqual(1); + expect(context.pending()).toEqual(1); + + // Set attribute that matches the audience filter (age >= 20) + context.attribute("age", 25); + + // Second treatment call should re-evaluate + // The variant stays 1, but audienceMismatch should now be false + expect(context.treatment("exp_test_ab")).toEqual(1); + + // Should queue another exposure since audience result changed + expect(context.pending()).toEqual(2); + + done(); + }); + + it("should not re-evaluate audience when no new attributes set", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); + + // Set attribute first + context.attribute("age", 15); + + // First treatment call with non-matching attribute should return control (0) + expect(context.treatment("exp_test_ab")).toEqual(0); + expect(context.pending()).toEqual(1); + + // Second treatment call without adding new attributes should use cached assignment + expect(context.treatment("exp_test_ab")).toEqual(0); + + // Should not queue another exposure (uses cached assignment) + expect(context.pending()).toEqual(1); + + done(); + }); + + it("should not re-evaluate audience for experiments without audience filter", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + // First treatment call + expect(context.treatment("exp_test_abc")).toEqual(2); + expect(context.pending()).toEqual(1); + + // Set an attribute + context.attribute("age", 25); + + // Second treatment call should use cached assignment since no audience filter + expect(context.treatment("exp_test_abc")).toEqual(2); + + // Should not queue another exposure + expect(context.pending()).toEqual(1); + + done(); + }); + + it("should re-evaluate from audience mismatch to match in strict mode", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); + + // First treatment call without attribute - audience mismatch, returns 0 + expect(context.treatment("exp_test_ab")).toEqual(0); + expect(context.pending()).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + // Verify first exposure had audienceMismatch = true + expect(publisher.publish).toHaveBeenCalledWith( + expect.objectContaining({ + exposures: [ + expect.objectContaining({ + name: "exp_test_ab", + variant: 0, + audienceMismatch: true, + assigned: false, + }), + ], + }), + sdk, + context, + undefined + ); + + // Set matching attribute + context.attribute("age", 30); + + // Second treatment should re-evaluate and return 1 + expect(context.treatment("exp_test_ab")).toEqual(1); + expect(context.pending()).toEqual(1); + + context.publish().then(() => { + // Verify second exposure had audienceMismatch = false + expect(publisher.publish).toHaveBeenCalledWith( + expect.objectContaining({ + exposures: [ + expect.objectContaining({ + name: "exp_test_ab", + variant: 1, + audienceMismatch: false, + assigned: true, + }), + ], + }), + sdk, + context, + undefined + ); + + done(); + }); + }); + }); + + it("should not re-evaluate when attribute set before assignment", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); + + // Set attribute before treatment call + context.attribute("age", 25); + + // First treatment call - attribute was already set, included in assignment + expect(context.treatment("exp_test_ab")).toEqual(1); + expect(context.pending()).toEqual(1); + + // Second treatment call should use cached assignment + expect(context.treatment("exp_test_ab")).toEqual(1); + + // Should not queue another exposure + expect(context.pending()).toEqual(1); + + done(); + }); + + it("should re-evaluate when attribute set after assignment", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); + + // First treatment call without attribute - audience mismatch, returns 0 + expect(context.treatment("exp_test_ab")).toEqual(0); + expect(context.pending()).toEqual(1); + + // Set attribute AFTER assignment was computed + context.attribute("age", 25); + + // Second treatment call should re-evaluate because attrsSeq increased + expect(context.treatment("exp_test_ab")).toEqual(1); + + // Should queue another exposure + expect(context.pending()).toEqual(2); + + done(); + }); + + it("should not invalidate cache when audience result unchanged after attribute change", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); + + // Set attribute that doesn't match (age < 20) + context.attribute("age", 15); + + // First treatment call - audience mismatch, returns 0 + expect(context.treatment("exp_test_ab")).toEqual(0); + expect(context.pending()).toEqual(1); + + // Set another attribute that still doesn't match (age < 20) + context.attribute("age", 18); + + // Second treatment call - audience result unchanged (still mismatch), should use cache + expect(context.treatment("exp_test_ab")).toEqual(0); + + // Should NOT queue another exposure since audience result didn't change + expect(context.pending()).toEqual(1); + + done(); + }); + + it("should update attrsSeq after checking unchanged audience to avoid repeated evaluation", (done) => { + const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); + + // Set attribute that doesn't match (age < 20) + context.attribute("age", 15); + + // First treatment call - audience mismatch, returns 0 + expect(context.treatment("exp_test_ab")).toEqual(0); + expect(context.pending()).toEqual(1); + + // Set another attribute that still doesn't match + context.attribute("age", 16); + + // Second treatment - evaluates audience but result unchanged, updates attrsSeq + expect(context.treatment("exp_test_ab")).toEqual(0); + expect(context.pending()).toEqual(1); + + // Set yet another attribute that still doesn't match + context.attribute("age", 17); + + // Third treatment - evaluates audience but result unchanged, updates attrsSeq + expect(context.treatment("exp_test_ab")).toEqual(0); + expect(context.pending()).toEqual(1); + + // No new attributes set + // Fourth treatment - should use cache without re-evaluating + expect(context.treatment("exp_test_ab")).toEqual(0); + expect(context.pending()).toEqual(1); + + done(); + }); }); describe("variableValue()", () => { diff --git a/src/context.ts b/src/context.ts index 8731069..424a0b4 100644 --- a/src/context.ts +++ b/src/context.ts @@ -62,6 +62,7 @@ type Assignment = { audienceMismatch: boolean; trafficSplit?: number[]; variables?: Record; + attrsSeq?: number; }; export type Experiment = { @@ -142,6 +143,7 @@ export default class Context { private _indexVariables: Record; private _overrides: Record; private _pending: number; + private _attrsSeq: number; private _hashes?: Record; private _promise?: Promise; private _publishTimeout?: ReturnType; @@ -164,6 +166,7 @@ export default class Context { this._units = {}; this._assigners = {}; this._audienceMatcher = new AudienceMatcher(); + this._attrsSeq = 0; if (params.units) { this.units(params.units); @@ -323,6 +326,7 @@ export default class Context { this._checkNotFinalized(); this._attrs.push({ name: attrName, value: value, setAt: Date.now() }); + this._attrsSeq++; } getAttributes() { @@ -436,6 +440,14 @@ export default class Context { } } + private _getAttributesMap(): Record { + const attrs: Record = {}; + this._attrs.forEach((attr) => { + attrs[attr.name] = attr.value; + }); + return attrs; + } + private _assign(experimentName: string) { const experimentMatches = (experiment: ExperimentData, assignment: Assignment) => { return ( @@ -447,6 +459,22 @@ 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 newAudienceMismatch = typeof result === "boolean" ? !result : false; + + if (newAudienceMismatch !== assignment.audienceMismatch) { + return false; + } + + assignment.attrsSeq = this._attrsSeq; + } + } + return true; + }; + const hasCustom = experimentName in this._cassignments; const hasOverride = experimentName in this._overrides; const experiment = experimentName in this._index ? this._index[experimentName] : null; @@ -464,7 +492,7 @@ export default class Context { return assignment; } } else if (!hasCustom || this._cassignments[experimentName] === assignment.variant) { - if (experimentMatches(experiment.data, assignment)) { + if (experimentMatches(experiment.data, assignment) && audienceMatches(experiment.data, assignment)) { // assignment up-to-date return assignment; } @@ -501,12 +529,7 @@ export default class Context { const unitType = experiment.data.unitType; if (experiment.data.audience && experiment.data.audience.length > 0) { - const attrs: Record = {}; - this._attrs.forEach((attr) => { - attrs[attr.name] = attr.value; - }); - - const result = this._audienceMatcher.evaluate(experiment.data.audience, attrs); + const result = this._audienceMatcher.evaluate(experiment.data.audience, this._getAttributesMap()); if (typeof result === "boolean") { assignment.audienceMismatch = !result; @@ -564,6 +587,7 @@ export default class Context { assignment.iteration = experiment.data.iteration; assignment.trafficSplit = experiment.data.trafficSplit; assignment.fullOnVariant = experiment.data.fullOnVariant; + assignment.attrsSeq = this._attrsSeq; } }