From 8018c77e654910cbedb6ec5dacaf9dfb3b8e1092 Mon Sep 17 00:00:00 2001 From: Chris Hannon Date: Sun, 15 Dec 2024 14:34:28 -0700 Subject: [PATCH 1/3] Added sticky bucketing, inGroup --- GrowthBook.Tests/Json/standard-cases.json | 5705 +++++++++++------ .../GrowthBookTests/StickyBucketTests.cs | 37 + .../GrowthBookTests/UrlRedirectTests.cs | 30 + .../ProviderTests/EvalConditionTests.cs | 4 +- .../UtilitiesTests/VersionCompareTests.cs | 68 - GrowthBook.Tests/UnitTest.cs | 19 +- GrowthBook/Context.cs | 16 + GrowthBook/Experiment.cs | 35 + GrowthBook/ExperimentResult.cs | 5 + GrowthBook/Extensions/JsonExtensions.cs | 14 +- .../Extensions/StickyAssignmentExtensions.cs | 29 + GrowthBook/FeatureResult.cs | 4 +- GrowthBook/FeatureRule.cs | 30 + GrowthBook/GrowthBook.cs | 239 +- GrowthBook/ParentCondition.cs | 28 + .../Providers/ConditionEvaluationProvider.cs | 107 +- .../Providers/IConditionEvaluationProvider.cs | 2 +- GrowthBook/Services/IStickyBucketService.cs | 13 + .../Services/InMemoryStickyBucketService.cs | 33 + GrowthBook/StickyAssignmentsDocument.cs | 26 + GrowthBook/StickyBucketVariation.cs | 18 + GrowthBook/UrlPattern.cs | 13 + GrowthBook/Utilities/ExperimentUtilities.cs | 190 + 23 files changed, 4721 insertions(+), 1944 deletions(-) create mode 100644 GrowthBook.Tests/StandardTests/GrowthBookTests/StickyBucketTests.cs create mode 100644 GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs delete mode 100644 GrowthBook.Tests/StandardTests/UtilitiesTests/VersionCompareTests.cs create mode 100644 GrowthBook/Extensions/StickyAssignmentExtensions.cs create mode 100644 GrowthBook/ParentCondition.cs create mode 100644 GrowthBook/Services/IStickyBucketService.cs create mode 100644 GrowthBook/Services/InMemoryStickyBucketService.cs create mode 100644 GrowthBook/StickyAssignmentsDocument.cs create mode 100644 GrowthBook/StickyBucketVariation.cs create mode 100644 GrowthBook/UrlPattern.cs diff --git a/GrowthBook.Tests/Json/standard-cases.json b/GrowthBook.Tests/Json/standard-cases.json index e14a9f2..ffea4eb 100644 --- a/GrowthBook.Tests/Json/standard-cases.json +++ b/GrowthBook.Tests/Json/standard-cases.json @@ -1,5 +1,5 @@ { - "specVersion": "0.5.2", + "specVersion": "0.7.0", "evalCondition": [ [ "$not - pass", @@ -2019,2274 +2019,3663 @@ "v": "1.2.3-alpha" }, true - ] - ], - "versionCompare": { - "lt": [ - [ "0.9.99", "1.0.0", true ], - [ "0.9.0", "0.10.0", true ], - [ "1.0.0-0.0", "1.0.0-0.0.0", true ], - [ "1.0.0-9999", "1.0.0--", true ], - [ "1.0.0-99", "1.0.0-100", true ], - [ "1.0.0-alpha", "1.0.0-alpha.1", true ], - [ "1.0.0-alpha.1", "1.0.0-alpha.beta", true ], - [ "1.0.0-alpha.beta", "1.0.0-beta", true ], - [ "1.0.0-beta", "1.0.0-beta.2", true ], - [ "1.0.0-beta.2", "1.0.0-beta.11", true ], - [ "1.0.0-beta.11", "1.0.0-rc.1", true ], - [ "1.0.0-rc.1", "1.0.0", true ], - [ "1.0.0-0", "1.0.0--1", true ], - [ "1.0.0-0", "1.0.0-1", true ], - [ "1.0.0-1.0", "1.0.0-1.-1", true ] - ], - "gt": [ - [ "0.0.0", "0.0.0-foo", true ], - [ "0.0.1", "0.0.0", true ], - [ "1.0.0", "0.9.9", true ], - [ "0.10.0", "0.9.0", true ], - [ "0.99.0", "0.10.0", true ], - [ "2.0.0", "1.2.3", true ], - [ "v0.0.0", "0.0.0-foo", true ], - [ "v0.0.1", "0.0.0", true ], - [ "v1.0.0", "0.9.9", true ], - [ "v0.10.0", "0.9.0", true ], - [ "v0.99.0", "0.10.0", true ], - [ "v2.0.0", "1.2.3", true ], - [ "0.0.0", "v0.0.0-foo", true ], - [ "0.0.1", "v0.0.0", true ], - [ "1.0.0", "v0.9.9", true ], - [ "0.10.0", "v0.9.0", true ], - [ "0.99.0", "v0.10.0", true ], - [ "2.0.0", "v1.2.3", true ], - [ "1.2.3", "1.2.3-asdf", true ], - [ "1.2.3", "1.2.3-4", true ], - [ "1.2.3", "1.2.3-4-foo", true ], - [ "1.2.3-5-foo", "1.2.3-5", true ], - [ "1.2.3-5", "1.2.3-4", true ], - [ "1.2.3-5-foo", "1.2.3-5-Foo", true ], - [ "3.0.0", "2.7.2+asdf", true ], - [ "1.2.3-a.10", "1.2.3-a.5", true ], - [ "1.2.3-a.b", "1.2.3-a.5", true ], - [ "1.2.3-a.b", "1.2.3-a", true ], - [ "1.2.3-a.b.c", "1.2.3-a.b.c.d", false ], - [ "1.2.3-a.b.c.10.d.5", "1.2.3-a.b.c.5.d.100", true ], - [ "1.2.3-r2", "1.2.3-r100", true ], - [ "1.2.3-r100", "1.2.3-R2", true ], - [ "a.b.c.d.e.f", "1.2.3", true ], - [ "10.0.0", "9.0.0", true ], - [ "10000.0.0", "9999.0.0", true ] - ], - "eq": [ - [ "1.2.3", "1.2.3", true ], - [ "1.2.3", "v1.2.3", true ], - [ "1.2.3-0", "v1.2.3-0", true ], - [ "1.2.3-1", "1.2.3-1", true ], - [ "1.2.3-1", "v1.2.3-1", true ], - [ "1.2.3-beta", "1.2.3-beta", true ], - [ "1.2.3-beta", "v1.2.3-beta", true ], - [ "1.2.3-beta+build", "1.2.3-beta+otherbuild", true ], - [ "1.2.3-beta+build", "v1.2.3-beta+otherbuild", true ], - [ "1-2-3", "1.2.3", true ], - [ "1-2-3", "1-2.3+build99", true ], - [ "1-2-3", "v1.2.3", true ], - [ "1.2.3.4", "1.2.3-4", true ] - ] - }, - "hash": [ - [ "", "a", 1, 0.22 ], - [ "", "b", 1, 0.077 ], - [ "b", "a", 1, 0.946 ], - [ "ef", "d", 1, 0.652 ], - [ "asdf", "8952klfjas09ujk", 1, 0.549 ], - [ "", "123", 1, 0.011 ], - [ "", "___)((*\":&", 1, 0.563 ], - [ "seed", "a", 2, 0.0505 ], - [ "seed", "b", 2, 0.2696 ], - [ "foo", "ab", 2, 0.2575 ], - [ "foo", "def", 2, 0.2019 ], - [ "89123klj", "8952klfjas09ujkasdf", 2, 0.124 ], - [ "90850943850283058242805", "123", 2, 0.7516 ], - [ "()**(%$##$%#$#", "___)((*\":&", 2, 0.0128 ], - [ "abc", "def", 99, null ] - ], - "getBucketRange": [ - [ - "normal 50/50", - [ 2, 1, null ], - [ - [ 0, 0.5 ], - [ 0.5, 1 ] - ] - ], - [ - "reduced coverage", - [ 2, 0.5, null ], - [ - [ 0, 0.25 ], - [ 0.5, 0.75 ] - ] ], [ - "zero coverage", - [ 2, 0, null ], - [ - [ 0, 0 ], - [ 0.5, 0.5 ] - ] + "version 0.9.99 < 1.0.0", + { + "version": { + "$vlt": "1.0.0" + } + }, + { + "version": "0.9.99" + }, + true ], [ - "4 variations", - [ 4, 1, null ], - [ - [ 0, 0.25 ], - [ 0.25, 0.5 ], - [ 0.5, 0.75 ], - [ 0.75, 1 ] - ] + "version 0.9.0 < 0.10.0", + { + "version": { + "$vlt": "0.10.0" + } + }, + { + "version": "0.9.0" + }, + true ], [ - "uneven weights", - [ - 2, - 1, - [ 0.4, 0.6 ] - ], - [ - [ 0, 0.4 ], - [ 0.4, 1 ] - ] + "version 1.0.0-0.0 < 1.0.0-0.0.0", + { + "version": { + "$vlt": "1.0.0-0.0.0" + } + }, + { + "version": "1.0.0-0.0" + }, + true ], [ - "uneven weights, 3 variations", - [ - 3, - 1, - [ 0.2, 0.3, 0.5 ] - ], - [ - [ 0, 0.2 ], - [ 0.2, 0.5 ], - [ 0.5, 1 ] - ] + "version 1.0.0-9999 < 1.0.0--", + { + "version": { + "$vlt": "1.0.0--" + } + }, + { + "version": "1.0.0-9999" + }, + true ], [ - "uneven weights, reduced coverage, 3 variations", - [ - 3, - 0.2, - [ 0.2, 0.3, 0.5 ] - ], - [ - [ 0, 0.04 ], - [ 0.2, 0.26 ], - [ 0.5, 0.6 ] - ] + "version 1.0.0-99 < 1.0.0-100", + { + "version": { + "$vlt": "1.0.0-100" + } + }, + { + "version": "1.0.0-99" + }, + true ], [ - "negative coverage", - [ 2, -0.2, null ], - [ - [ 0, 0 ], - [ 0.5, 0.5 ] - ] + "version 1.0.0-alpha < 1.0.0-alpha.1", + { + "version": { + "$vlt": "1.0.0-alpha.1" + } + }, + { + "version": "1.0.0-alpha" + }, + true ], [ - "coverage above 1", - [ 2, 1.5, null ], - [ - [ 0, 0.5 ], - [ 0.5, 1 ] - ] + "version 1.0.0-alpha.1 < 1.0.0-alpha.beta", + { + "version": { + "$vlt": "1.0.0-alpha.beta" + } + }, + { + "version": "1.0.0-alpha.1" + }, + true ], [ - "weights sum below 1", - [ - 2, - 1, - [ 0.4, 0.1 ] - ], - [ - [ 0, 0.5 ], - [ 0.5, 1 ] - ] + "version 1.0.0-alpha.beta < 1.0.0-beta", + { + "version": { + "$vlt": "1.0.0-beta" + } + }, + { + "version": "1.0.0-alpha.beta" + }, + true ], [ - "weights sum above 1", - [ - 2, - 1, - [ 0.7, 0.6 ] - ], - [ - [ 0, 0.5 ], - [ 0.5, 1 ] - ] + "version 1.0.0-beta < 1.0.0-beta.2", + { + "version": { + "$vlt": "1.0.0-beta.2" + } + }, + { + "version": "1.0.0-beta" + }, + true ], [ - "weights.length not equal to num variations", - [ - 4, - 1, - [ 0.4, 0.4, 0.2 ] - ], - [ - [ 0, 0.25 ], - [ 0.25, 0.5 ], - [ 0.5, 0.75 ], - [ 0.75, 1 ] - ] + "version 1.0.0-beta.2 < 1.0.0-beta.11", + { + "version": { + "$vlt": "1.0.0-beta.11" + } + }, + { + "version": "1.0.0-beta.2" + }, + true ], [ - "weights sum almost equals 1", - [ - 2, - 1, - [ 0.4, 0.5999 ] - ], - [ - [ 0, 0.4 ], - [ 0.4, 0.9999 ] - ] - ] - ], - "feature": [ - [ - "unknown feature key", - {}, - "my-feature", + "version 1.0.0-beta.11 < 1.0.0-rc.1", { - "value": null, - "on": false, - "off": true, - "source": "unknownFeature" - } + "version": { + "$vlt": "1.0.0-rc.1" + } + }, + { + "version": "1.0.0-beta.11" + }, + true ], [ - "defaults when empty", - { "features": { "feature": {} } }, - "feature", + "version 1.0.0-rc.1 < 1.0.0", { - "value": null, - "on": false, - "off": true, - "source": "defaultValue" - } + "version": { + "$vlt": "1.0.0" + } + }, + { + "version": "1.0.0-rc.1" + }, + true ], [ - "uses defaultValue - number", - { "features": { "feature": { "defaultValue": 1 } } }, - "feature", + "version 1.0.0-0 < 1.0.0--1", { - "value": 1, - "on": true, - "off": false, - "source": "defaultValue" - } + "version": { + "$vlt": "1.0.0--1" + } + }, + { + "version": "1.0.0-0" + }, + true ], [ - "uses custom values - string", - { "features": { "feature": { "defaultValue": "yes" } } }, - "feature", + "version 1.0.0-0 < 1.0.0-1", { - "value": "yes", - "on": true, - "off": false, - "source": "defaultValue" - } + "version": { + "$vlt": "1.0.0-1" + } + }, + { + "version": "1.0.0-0" + }, + true ], [ - "force rules", + "version 1.0.0-1.0 < 1.0.0-1.-1", { - "features": { - "feature": { - "defaultValue": 2, - "rules": [ - { - "force": 1 - } - ] - } + "version": { + "$vlt": "1.0.0-1.-1" } }, - "feature", { - "value": 1, - "on": true, - "off": false, - "source": "force" - } + "version": "1.0.0-1.0" + }, + true ], [ - "force rules - force false", + "version 1.2.3-a.b.c < 1.2.3-a.b.c.d", { - "features": { - "feature": { - "defaultValue": true, - "rules": [ - { - "force": false - } - ] - } + "version": { + "$vlt": "1.2.3-a.b.c.d" } }, - "feature", { - "value": false, - "on": false, - "off": true, - "source": "force" - } + "version": "1.2.3-a.b.c" + }, + true ], [ - "force rules - coverage included", + "version 0.0.0 > 0.0.0-foo", { - "attributes": { - "id": "3" - }, - "features": { - "feature": { - "defaultValue": 2, - "rules": [ - { - "force": 1, - "coverage": 0.5 - } - ] - } + "version": { + "$vgt": "0.0.0-foo" } }, - "feature", { - "value": 1, - "on": true, - "off": false, - "source": "force" - } + "version": "0.0.0" + }, + true ], [ - "force rule - coverage with integer hash attribute", + "version 0.0.1 > 0.0.0", { - "attributes": { - "id": 3 - }, - "features": { - "feature": { - "defaultValue": 2, - "rules": [ - { - "force": 1, - "coverage": 0.5 - } - ] - } + "version": { + "$vgt": "0.0.0" } }, - "feature", { - "value": 1, - "on": true, - "off": false, - "source": "force" - } + "version": "0.0.1" + }, + true ], [ - "force rules - coverage excluded", + "version 1.0.0 > 0.9.9", { - "attributes": { - "id": "1" - }, - "features": { - "feature": { - "defaultValue": 2, - "rules": [ - { - "force": 1, - "coverage": 0.5 - } - ] - } + "version": { + "$vgt": "0.9.9" } }, - "feature", { - "value": 2, - "on": true, - "off": false, - "source": "defaultValue" - } + "version": "1.0.0" + }, + true ], [ - "force rules - coverage missing hashAttribute", + "version 0.10.0 > 0.9.0", { - "attributes": {}, - "features": { - "feature": { - "defaultValue": 2, - "rules": [ - { - "force": 1, - "coverage": 0.5 - } - ] - } + "version": { + "$vgt": "0.9.0" } }, - "feature", { - "value": 2, - "on": true, - "off": false, - "source": "defaultValue" - } + "version": "0.10.0" + }, + true ], [ - "force rules - condition pass", + "version 0.99.0 > 0.10.0", { - "attributes": { - "country": "US", - "browser": "firefox" - }, - "features": { - "feature": { - "defaultValue": 2, - "rules": [ - { - "force": 1, - "condition": { - "country": { "$in": [ "US", "CA" ] }, - "browser": "firefox" - } - } - ] - } + "version": { + "$vgt": "0.10.0" } }, - "feature", { - "value": 1, - "on": true, - "off": false, - "source": "force" - } + "version": "0.99.0" + }, + true ], [ - "force rules - condition fail", + "version 2.0.0 > 1.2.3", { - "attributes": { - "country": "US", - "browser": "chrome" - }, - "features": { - "feature": { - "defaultValue": 2, - "rules": [ - { - "force": 1, - "condition": { - "country": { "$in": [ "US", "CA" ] }, - "browser": "firefox" - } - } - ] - } + "version": { + "$vgt": "1.2.3" } }, - "feature", { - "value": 2, - "on": true, - "off": false, - "source": "defaultValue" - } + "version": "2.0.0" + }, + true ], [ - "force rules - coverage with bad hash version", + "version v0.0.0 > 0.0.0-foo", { - "attributes": { - "id": "1" - }, - "features": { - "feature": { - "defaultValue": 2, - "rules": [ - { - "force": 1, - "coverage": 1.0, - "hashVersion": 99 - } - ] - } + "version": { + "$vgt": "0.0.0-foo" } }, - "feature", { - "value": 2, - "on": true, - "off": false, - "source": "defaultValue" - } + "version": "v0.0.0" + }, + true ], [ - "ignores empty rules", + "version v0.0.1 > 0.0.0", { - "features": { - "feature": { - "rules": [ {} ] - } + "version": { + "$vgt": "0.0.0" } }, - "feature", { - "value": null, - "on": false, - "off": true, - "source": "defaultValue" - } + "version": "v0.0.1" + }, + true ], [ - "empty experiment rule - c", + "version v1.0.0 > 0.9.9", { - "attributes": { - "id": "123" - }, - "features": { - "feature": { - "rules": [ - { - "variations": [ "a", "b", "c" ] - } - ] - } + "version": { + "$vgt": "0.9.9" } }, - "feature", { - "value": "c", - "on": true, - "off": false, - "experiment": { - "key": "feature", - "variations": [ "a", "b", "c" ] - }, - "experimentResult": { - "featureId": "feature", - "value": "c", - "variationId": 2, - "inExperiment": true, - "hashUsed": true, - "hashAttribute": "id", - "hashValue": "123", - "bucket": 0.863, - "key": "2" - }, - "source": "experiment" - } + "version": "v1.0.0" + }, + true ], [ - "empty experiment rule - a", + "version v0.10.0 > 0.9.0", { - "attributes": { - "id": "456" - }, - "features": { - "feature": { - "rules": [ - { - "variations": [ "a", "b", "c" ] - } - ] - } + "version": { + "$vgt": "0.9.0" } }, - "feature", { - "value": "a", - "on": true, - "off": false, - "experiment": { - "key": "feature", - "variations": [ "a", "b", "c" ] - }, - "experimentResult": { - "featureId": "feature", - "value": "a", - "variationId": 0, - "inExperiment": true, - "hashUsed": true, - "hashAttribute": "id", - "hashValue": "456", - "bucket": 0.178, - "key": "0" - }, - "source": "experiment" - } + "version": "v0.10.0" + }, + true ], [ - "empty experiment rule - b", + "version v0.99.0 > 0.10.0", { - "attributes": { - "id": "fds" - }, - "features": { - "feature": { - "rules": [ - { - "variations": [ "a", "b", "c" ] - } - ] - } + "version": { + "$vgt": "0.10.0" } }, - "feature", { - "value": "b", - "on": true, - "off": false, - "experiment": { - "key": "feature", - "variations": [ "a", "b", "c" ] - }, - "experimentResult": { - "featureId": "feature", - "value": "b", - "variationId": 1, - "inExperiment": true, - "hashUsed": true, - "hashAttribute": "id", - "hashValue": "fds", - "bucket": 0.514, - "key": "1" - }, - "source": "experiment" - } + "version": "v0.99.0" + }, + true ], [ - "creates experiments properly", + "version v2.0.0 > 1.2.3", { - "attributes": { - "anonId": "123", - "premium": true - }, - "features": { - "feature": { - "rules": [ - { - "coverage": 0.99, - "hashAttribute": "anonId", - "seed": "feature", - "hashVersion": 2, - "name": "Test", - "phase": "1", - "ranges": [ - [ 0, 0.1 ], - [ 0.1, 1.0 ] - ], - "meta": [ - { - "key": "v0", - "name": "variation 0" - }, - { - "key": "v1", - "name": "variation 1" - } - ], - "filters": [ - { - "attribute": "anonId", - "seed": "pricing", - "ranges": [ [ 0, 1 ] ] - } - ], - "namespace": [ "pricing", 0, 1 ], - "key": "hello", - "variations": [ true, false ], - "weights": [ 0.1, 0.9 ], - "condition": { "premium": true } - } - ] - } + "version": { + "$vgt": "1.2.3" } }, - "feature", { - "value": false, - "on": false, - "off": true, - "source": "experiment", - "experiment": { - "coverage": 0.99, - "ranges": [ - [ 0, 0.1 ], - [ 0.1, 1.0 ] - ], - "meta": [ - { - "key": "v0", - "name": "variation 0" - }, - { - "key": "v1", - "name": "variation 1" - } - ], - "filters": [ - { - "attribute": "anonId", - "seed": "pricing", - "ranges": [ [ 0, 1 ] ] - } - ], - "name": "Test", - "phase": "1", - "seed": "feature", - "hashVersion": 2, - "hashAttribute": "anonId", - "namespace": [ "pricing", 0, 1 ], - "key": "hello", - "variations": [ true, false ], - "weights": [ 0.1, 0.9 ] - }, - "experimentResult": { - "featureId": "feature", - "value": false, - "variationId": 1, - "inExperiment": true, - "hashUsed": true, - "hashAttribute": "anonId", - "hashValue": "123", - "bucket": 0.5231, - "key": "v1", - "name": "variation 1" + "version": "v2.0.0" + }, + true + ], + [ + "version 0.0.0 > v0.0.0-foo", + { + "version": { + "$vgt": "v0.0.0-foo" } - } + }, + { + "version": "0.0.0" + }, + true ], [ - "rule orders - skip 1", + "version 0.0.1 > v0.0.0", { - "attributes": { - "browser": "firefox" - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "force": 1, - "condition": { "browser": "chrome" } - }, - { - "force": 2, - "condition": { "browser": "firefox" } - }, - { - "force": 3, - "condition": { "browser": "safari" } - } - ] - } + "version": { + "$vgt": "v0.0.0" } }, - "feature", { - "value": 2, - "on": true, - "off": false, - "source": "force" - } + "version": "0.0.1" + }, + true ], [ - "rule orders - skip 1,2", + "version 1.0.0 > v0.9.9", { - "attributes": { - "browser": "safari" - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "force": 1, - "condition": { "browser": "chrome" } - }, - { - "force": 2, - "condition": { "browser": "firefox" } - }, - { - "force": 3, - "condition": { "browser": "safari" } - } - ] - } + "version": { + "$vgt": "v0.9.9" } }, - "feature", { - "value": 3, - "on": true, - "off": false, - "source": "force" - } + "version": "1.0.0" + }, + true ], [ - "rule orders - skip all", + "version 0.10.0 > v0.9.0", { - "attributes": { - "browser": "ie" - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "force": 1, - "condition": { "browser": "chrome" } - }, - { - "force": 2, - "condition": { "browser": "firefox" } - }, - { - "force": 3, - "condition": { "browser": "safari" } - } - ] - } + "version": { + "$vgt": "v0.9.0" } }, - "feature", { - "value": 0, - "on": false, - "off": true, - "source": "defaultValue" - } + "version": "0.10.0" + }, + true ], [ - "skips experiment on coverage", + "version 0.99.0 > v0.10.0", { - "attributes": { "id": "123" }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "variations": [ 0, 1, 2, 3 ], - "coverage": 0.01 - }, - { - "force": 3 - } - ] - } + "version": { + "$vgt": "v0.10.0" } }, - "feature", { - "value": 3, - "on": true, - "off": false, - "source": "force" - } + "version": "0.99.0" + }, + true ], [ - "skips experiment on namespace", + "version 2.0.0 > v1.2.3", { - "attributes": { "id": "123" }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "variations": [ 0, 1, 2, 3 ], - "namespace": [ "pricing", 0, 0.01 ] - }, - { - "force": 3 - } - ] - } + "version": { + "$vgt": "v1.2.3" } }, - "feature", { - "value": 3, - "on": true, - "off": false, - "source": "force" - } + "version": "2.0.0" + }, + true ], [ - "handles integer hashAttribute", + "version 1.2.3 > 1.2.3-asdf", { - "attributes": { "id": 123 }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "variations": [ 0, 1 ] - } - ] - } + "version": { + "$vgt": "1.2.3-asdf" } }, - "feature", { - "value": 1, - "on": true, - "off": false, - "source": "experiment", - "experiment": { - "key": "feature", - "variations": [ 0, 1 ] - }, - "experimentResult": { - "featureId": "feature", - "hashAttribute": "id", - "hashValue": 123, - "hashUsed": true, - "inExperiment": true, - "value": 1, - "variationId": 1, - "key": "1", - "bucket": 0.863 - } - } + "version": "1.2.3" + }, + true ], [ - "skip experiment on missing hashAttribute", + "version 1.2.3 > 1.2.3-4", { - "attributes": { "id": "123" }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "variations": [ 0, 1, 2, 3 ], - "hashAttribute": "company" - }, - { - "force": 3 - } - ] - } + "version": { + "$vgt": "1.2.3-4" } }, - "feature", { - "value": 3, - "on": true, - "off": false, - "source": "force" - } + "version": "1.2.3" + }, + true ], [ - "include experiments when forced", + "version 1.2.3 > 1.2.3-4-foo", { - "attributes": { "id": "123" }, - "forcedVariations": { - "feature": 1 - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "variations": [ 0, 1, 2, 3 ] - }, - { - "force": 3 - } - ] - } + "version": { + "$vgt": "1.2.3-4-foo" } }, - "feature", { - "value": 1, - "on": true, - "off": false, - "source": "experiment", - "experiment": { - "key": "feature", - "variations": [ 0, 1, 2, 3 ] - }, - "experimentResult": { - "featureId": "feature", - "value": 1, - "variationId": 1, - "inExperiment": true, - "hashUsed": false, - "hashAttribute": "id", - "hashValue": "123", - "key": "1" - } - } + "version": "1.2.3" + }, + true ], [ - "Force rule with range, ignores coverage", + "version 1.2.3-5-foo > 1.2.3-5", { - "attributes": { - "id": "1" - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "force": 2, - "coverage": 0.01, - "range": [ 0, 0.99 ] - } - ] - } + "version": { + "$vgt": "1.2.3-5" } }, - "feature", { - "value": 2, - "on": true, - "off": false, - "source": "force" - } + "version": "1.2.3-5-foo" + }, + true ], [ - "Force rule, hash version 2", + "version 1.2.3-5 > 1.2.3-4", { - "attributes": { - "id": "1" - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "force": 2, - "hashVersion": 2, - "range": [ 0.96, 0.97 ] - } - ] - } + "version": { + "$vgt": "1.2.3-4" } }, - "feature", { - "value": 2, - "on": true, - "off": false, - "source": "force" - } + "version": "1.2.3-5" + }, + true ], [ - "Force rule, skip due to range", + "version 1.2.3-5-foo > 1.2.3-5-Foo", { - "attributes": { - "id": "1" - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "force": 2, - "range": [ 0, 0.01 ] - } - ] - } + "version": { + "$vgt": "1.2.3-5-Foo" } }, - "feature", { - "value": 0, - "on": false, - "off": true, - "source": "defaultValue" - } + "version": "1.2.3-5-foo" + }, + true ], [ - "Force rule, skip due to filter", + "version 3.0.0 > 2.7.2+asdf", { - "attributes": { - "id": "1" - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "force": 2, - "filters": [ - { - "seed": "seed", - "ranges": [ [ 0, 0.01 ] ] - } - ] - } - ] - } + "version": { + "$vgt": "2.7.2+asdf" } }, - "feature", { - "value": 0, - "on": false, - "off": true, - "source": "defaultValue" - } + "version": "3.0.0" + }, + true ], [ - "Force rule, use seed with range", + "version 1.2.3-a.10 > 1.2.3-a.5", { - "attributes": { - "id": "1" - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "force": 2, - "range": [ 0, 0.5 ], - "seed": "fjdslafdsa", - "hashVersion": 2 - } - ] - } + "version": { + "$vgt": "1.2.3-a.5" } }, - "feature", { - "value": 2, - "on": true, - "off": false, - "source": "force" - } + "version": "1.2.3-a.10" + }, + true ], [ - "Support passthrough variations", + "version 1.2.3-a.b > 1.2.3-a.5", { - "attributes": { - "id": "1" - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "key": "holdout", - "variations": [ 1, 2 ], - "hashVersion": 2, - "ranges": [ - [ 0, 0.01 ], - [ 0.01, 1.0 ] - ], - "meta": [ - {}, - { - "passthrough": true - } - ] - }, - { - "key": "experiment", - "variations": [ 3, 4 ], - "hashVersion": 2, - "ranges": [ - [ 0, 0.5 ], - [ 0.5, 1.0 ] - ] - } - ] - } + "version": { + "$vgt": "1.2.3-a.5" } }, - "feature", { - "value": 3, - "on": true, - "off": false, - "source": "experiment", - "experiment": { - "key": "experiment", - "hashVersion": 2, - "variations": [ 3, 4 ], - "ranges": [ - [ 0, 0.5 ], - [ 0.5, 1.0 ] - ] - }, - "experimentResult": { - "featureId": "feature", - "hashAttribute": "id", - "hashUsed": true, - "hashValue": "1", - "inExperiment": true, - "key": "0", - "value": 3, - "variationId": 0, - "bucket": 0.4413 - } - } + "version": "1.2.3-a.b" + }, + true ], [ - "Support holdout groups", + "version 1.2.3-a.b > 1.2.3-a", { - "attributes": { - "id": "1" - }, - "features": { - "feature": { - "defaultValue": 0, - "rules": [ - { - "key": "holdout", - "hashVersion": 2, - "variations": [ 1, 2 ], - "ranges": [ - [ 0, 0.99 ], - [ 0.99, 1.0 ] - ], - "meta": [ - {}, - { - "passthrough": true - } - ] - }, - { - "key": "experiment", - "hashVersion": 2, - "variations": [ 3, 4 ], - "ranges": [ - [ 0, 0.5 ], - [ 0.5, 1.0 ] - ] - } - ] - } + "version": { + "$vgt": "1.2.3-a" } }, - "feature", - { - "value": 1, - "on": true, - "off": false, - "source": "experiment", - "experiment": { - "hashVersion": 2, - "ranges": [ - [ 0, 0.99 ], - [ 0.99, 1.0 ] - ], - "meta": [ - {}, - { - "passthrough": true - } - ], - "key": "holdout", - "variations": [ 1, 2 ] - }, - "experimentResult": { - "featureId": "feature", - "hashAttribute": "id", - "hashUsed": true, - "hashValue": "1", - "inExperiment": true, - "key": "0", - "value": 1, - "variationId": 0, - "bucket": 0.8043 - } - } - ] - ], - "run": [ - [ - "default weights - 1", - { "attributes": { "id": "1" } }, { - "key": "my-test", - "variations": [ 0, 1 ] + "version": "1.2.3-a.b" }, - 1, - true, true ], [ - "default weights - 2", - { "attributes": { "id": "2" } }, + "version 1.2.3-a.b.c.10.d.5 > 1.2.3-a.b.c.5.d.100", { - "key": "my-test", - "variations": [ 0, 1 ] + "version": { + "$vgt": "1.2.3-a.b.c.5.d.100" + } + }, + { + "version": "1.2.3-a.b.c.10.d.5" }, - 0, - true, true ], [ - "default weights - 3", - { "attributes": { "id": "3" } }, + "version 1.2.3-r2 > 1.2.3-r100", { - "key": "my-test", - "variations": [ 0, 1 ] + "version": { + "$vgt": "1.2.3-r100" + } + }, + { + "version": "1.2.3-r2" }, - 0, - true, true ], [ - "default weights - 4", - { "attributes": { "id": "4" } }, + "version 1.2.3-r100 > 1.2.3-R2", { - "key": "my-test", - "variations": [ 0, 1 ] + "version": { + "$vgt": "1.2.3-R2" + } + }, + { + "version": "1.2.3-r100" }, - 1, - true, true ], [ - "default weights - 5", - { "attributes": { "id": "5" } }, + "version a.b.c.d.e.f > 1.2.3", { - "key": "my-test", - "variations": [ 0, 1 ] + "version": { + "$vgt": "1.2.3" + } + }, + { + "version": "a.b.c.d.e.f" }, - 1, - true, true ], [ - "default weights - 6", - { "attributes": { "id": "6" } }, + "version 10.0.0 > 9.0.0", { - "key": "my-test", - "variations": [ 0, 1 ] + "version": { + "$vgt": "9.0.0" + } + }, + { + "version": "10.0.0" }, - 1, - true, true ], [ - "default weights - 7", - { "attributes": { "id": "7" } }, + "version 10000.0.0 > 9999.0.0", { - "key": "my-test", - "variations": [ 0, 1 ] + "version": { + "$vgt": "9999.0.0" + } + }, + { + "version": "10000.0.0" }, - 0, - true, true ], [ - "default weights - 8", - { "attributes": { "id": "8" } }, + "version 1.2.3 == 1.2.3", { - "key": "my-test", - "variations": [ 0, 1 ] + "version": { + "$veq": "1.2.3" + } + }, + { + "version": "1.2.3" }, - 1, - true, true ], [ - "default weights - 9", - { "attributes": { "id": "9" } }, + "version 1.2.3 == v1.2.3", { - "key": "my-test", - "variations": [ 0, 1 ] + "version": { + "$veq": "v1.2.3" + } + }, + { + "version": "1.2.3" }, - 0, - true, true ], [ - "uneven weights - 1", - { "attributes": { "id": "1" } }, + "version 1.2.3-0 == v1.2.3-0", { - "key": "my-test", - "variations": [ 0, 1 ], - "weights": [ 0.1, 0.9 ] + "version": { + "$veq": "v1.2.3-0" + } }, - 1, - true, - true - ], - [ - "uneven weights - 2", - { "attributes": { "id": "2" } }, { - "key": "my-test", - "variations": [ 0, 1 ], - "weights": [ 0.1, 0.9 ] + "version": "1.2.3-0" }, - 1, - true, true ], [ - "uneven weights - 3", - { "attributes": { "id": "3" } }, + "version 1.2.3-1 == 1.2.3-1", { - "key": "my-test", - "variations": [ 0, 1 ], - "weights": [ 0.1, 0.9 ] + "version": { + "$veq": "1.2.3-1" + } + }, + { + "version": "1.2.3-1" }, - 0, - true, true ], [ - "uneven weights - 4", - { "attributes": { "id": "4" } }, + "version 1.2.3-1 == v1.2.3-1", { - "key": "my-test", - "variations": [ 0, 1 ], - "weights": [ 0.1, 0.9 ] + "version": { + "$veq": "v1.2.3-1" + } + }, + { + "version": "1.2.3-1" }, - 1, - true, true ], [ - "uneven weights - 5", - { "attributes": { "id": "5" } }, + "version 1.2.3-beta == 1.2.3-beta", { - "key": "my-test", - "variations": [ 0, 1 ], - "weights": [ 0.1, 0.9 ] + "version": { + "$veq": "1.2.3-beta" + } + }, + { + "version": "1.2.3-beta" }, - 1, - true, true ], [ - "uneven weights - 6", - { "attributes": { "id": "6" } }, + "version 1.2.3-beta == v1.2.3-beta", { - "key": "my-test", - "variations": [ 0, 1 ], - "weights": [ 0.1, 0.9 ] + "version": { + "$veq": "v1.2.3-beta" + } + }, + { + "version": "1.2.3-beta" }, - 1, - true, true ], [ - "uneven weights - 7", - { "attributes": { "id": "7" } }, + "version 1.2.3-beta+build == 1.2.3-beta+otherbuild", { - "key": "my-test", - "variations": [ 0, 1 ], - "weights": [ 0.1, 0.9 ] + "version": { + "$veq": "1.2.3-beta+otherbuild" + } + }, + { + "version": "1.2.3-beta+build" }, - 0, - true, true ], [ - "uneven weights - 8", - { "attributes": { "id": "8" } }, + "version 1.2.3-beta+build == v1.2.3-beta+otherbuild", { - "key": "my-test", - "variations": [ 0, 1 ], - "weights": [ 0.1, 0.9 ] + "version": { + "$veq": "v1.2.3-beta+otherbuild" + } + }, + { + "version": "1.2.3-beta+build" }, - 1, - true, true ], [ - "uneven weights - 9", - { "attributes": { "id": "9" } }, + "version 1-2-3 == 1.2.3", { - "key": "my-test", - "variations": [ 0, 1 ], - "weights": [ 0.1, 0.9 ] + "version": { + "$veq": "1.2.3" + } + }, + { + "version": "1-2-3" }, - 1, - true, true ], [ - "coverage - 1", - { "attributes": { "id": "1" } }, + "version 1-2-3 == 1-2.3+build99", { - "key": "my-test", - "variations": [ 0, 1 ], - "coverage": 0.4 + "version": { + "$veq": "1-2.3+build99" + } }, - 0, - false, - false - ], - [ - "coverage - 2", - { "attributes": { "id": "2" } }, { - "key": "my-test", - "variations": [ 0, 1 ], - "coverage": 0.4 + "version": "1-2-3" }, - 0, - true, true ], [ - "coverage - 3", - { "attributes": { "id": "3" } }, + "version 1-2-3 == v1.2.3", { - "key": "my-test", - "variations": [ 0, 1 ], - "coverage": 0.4 + "version": { + "$veq": "v1.2.3" + } + }, + { + "version": "1-2-3" }, - 0, - true, true ], [ - "coverage - 4", - { "attributes": { "id": "4" } }, + "version 1.2.3.4 == 1.2.3-4", { - "key": "my-test", - "variations": [ 0, 1 ], - "coverage": 0.4 + "version": { + "$veq": "1.2.3-4" + } }, - 0, - false, - false - ], - [ - "coverage - 5", - { "attributes": { "id": "5" } }, { - "key": "my-test", - "variations": [ 0, 1 ], - "coverage": 0.4 + "version": "1.2.3.4" }, - 1, - true, true ], [ - "coverage - 6", - { "attributes": { "id": "6" } }, + "$or pass but second condition fail", { - "key": "my-test", - "variations": [ 0, 1 ], - "coverage": 0.4 + "$or": [ + { "foo": 1 }, + { "bar": 1 } + ], + "baz": 2 + }, + { + "foo": 1, + "bar": 2, + "baz": 1 }, - 0, - false, false ], [ - "coverage - 7", - { "attributes": { "id": "7" } }, + "$or and second condition both pass", { - "key": "my-test", - "variations": [ 0, 1 ], - "coverage": 0.4 + "$or": [ + { "foo": 1 }, + { "bar": 1 } + ], + "baz": 2 }, - 0, - true, - true - ], - [ - "coverage - 8", - { "attributes": { "id": "8" } }, { - "key": "my-test", - "variations": [ 0, 1 ], - "coverage": 0.4 + "foo": 1, + "bar": 2, + "baz": 2 }, - 1, - true, true ], [ - "coverage - 9", - { "attributes": { "id": "9" } }, + "$and condition pass but $or fail", { - "key": "my-test", - "variations": [ 0, 1 ], - "coverage": 0.4 + "$and": [ + { "foo": 1 }, + { "bar": 1 } + ], + "$or": [ + { "baz": 1 }, + { "empty": 1 } + ] + }, + { + "foo": 1, + "bar": 1, + "baz": 2 }, - 0, - false, false ], [ - "three way test - 1", - { "attributes": { "id": "1" } }, + "$and and $or both pass", { - "key": "my-test", - "variations": [ 0, 1, 2 ] + "$and": [ + { "foo": 1 }, + { "bar": 1 } + ], + "$or": [ + { "baz": 1 }, + { "empty": 1 } + ] }, - 2, - true, - true - ], - [ - "three way test - 2", - { "attributes": { "id": "2" } }, { - "key": "my-test", - "variations": [ 0, 1, 2 ] + "foo": 1, + "bar": 1, + "baz": 2, + "empty": 1 }, - 0, - true, true ], [ - "three way test - 3", - { "attributes": { "id": "3" } }, + "$inGroup passes for member of known group id", { - "key": "my-test", - "variations": [ 0, 1, 2 ] + "id": { "$inGroup": "group_id" } }, - 0, + { "id": 1 }, true, - true + { "group_id": [ 1, 2, 3 ] } ], [ - "three way test - 4", - { "attributes": { "id": "4" } }, + "$inGroup fails for non-member of known group id", { - "key": "my-test", - "variations": [ 0, 1, 2 ] + "id": { "$inGroup": "group_id" } }, - 2, - true, - true + { "id": 5 }, + false, + { "group_id": [ 1, 2, 3 ] } ], [ - "three way test - 5", - { "attributes": { "id": "5" } }, + "$inGroup fails for unknown group id", { - "key": "my-test", - "variations": [ 0, 1, 2 ] + "id": { "$inGroup": "unknowngroup_id" } }, - 1, - true, - true + { "id": 1 }, + false, + { "group_id": [ 1, 2, 3 ] } ], [ - "three way test - 6", - { "attributes": { "id": "6" } }, + "$notInGroup fails for member of known group id", { - "key": "my-test", - "variations": [ 0, 1, 2 ] + "id": { "$notInGroup": "group_id" } }, - 2, - true, - true + { "id": 1 }, + false, + { "group_id": [ 1, 2, 3 ] } ], [ - "three way test - 7", - { "attributes": { "id": "7" } }, + "$notInGroup passes for non-member of known group id", { - "key": "my-test", - "variations": [ 0, 1, 2 ] + "id": { "$notInGroup": "group_id" } }, - 0, + { "id": 5 }, true, - true + { "group_id": [ 1, 2, 3 ] } ], [ - "three way test - 8", - { "attributes": { "id": "8" } }, + "$notInGroup passes for unknown group id", { - "key": "my-test", - "variations": [ 0, 1, 2 ] + "id": { "$notInGroup": "unknowngroup_id" } }, - 1, + { "id": 1 }, true, - true + { "group_id": [ 1, 2, 3 ] } ], [ - "three way test - 9", - { "attributes": { "id": "9" } }, + "$inGroup passes for properly typed data", { - "key": "my-test", - "variations": [ 0, 1, 2 ] + "id": { "$inGroup": "group_id" } }, - 0, + { "id": "2" }, true, - true + { "group_id": [ 1, "2", 3 ] } ], [ - "test name - my-test", - { "attributes": { "id": "1" } }, + "$inGroup fails for improperly typed data", { - "key": "my-test", - "variations": [ 0, 1 ] + "id": { "$inGroup": "group_id" } }, - 1, - true, - true + { "id": "3" }, + false, + { "group_id": [ 1, "2", 3 ] } + ] + ], + "hash": [ + [ "", "a", 1, 0.22 ], + [ "", "b", 1, 0.077 ], + [ "b", "a", 1, 0.946 ], + [ "ef", "d", 1, 0.652 ], + [ "asdf", "8952klfjas09ujk", 1, 0.549 ], + [ "", "123", 1, 0.011 ], + [ "", "___)((*\":&", 1, 0.563 ], + [ "seed", "a", 2, 0.0505 ], + [ "seed", "b", 2, 0.2696 ], + [ "foo", "ab", 2, 0.2575 ], + [ "foo", "def", 2, 0.2019 ], + [ "89123klj", "8952klfjas09ujkasdf", 2, 0.124 ], + [ "90850943850283058242805", "123", 2, 0.7516 ], + [ "()**(%$##$%#$#", "___)((*\":&", 2, 0.0128 ], + [ "abc", "def", 99, null ] + ], + "getBucketRange": [ + [ + "normal 50/50", + [ 2, 1, null ], + [ + [ 0, 0.5 ], + [ 0.5, 1 ] + ] ], [ - "test name - my-test-3", - { "attributes": { "id": "1" } }, - { - "key": "my-test-3", - "variations": [ 0, 1 ] - }, - 0, - true, - true + "reduced coverage", + [ 2, 0.5, null ], + [ + [ 0, 0.25 ], + [ 0.5, 0.75 ] + ] ], [ - "empty id", - { "attributes": { "id": "" } }, - { - "key": "my-test", - "variations": [ 0, 1 ] - }, - 0, - false, - false + "zero coverage", + [ 2, 0, null ], + [ + [ 0, 0 ], + [ 0.5, 0.5 ] + ] ], [ - "null id", - { "attributes": { "id": null } }, - { - "key": "my-test", - "variations": [ 0, 1 ] - }, - 0, - false, - false + "4 variations", + [ 4, 1, null ], + [ + [ 0, 0.25 ], + [ 0.25, 0.5 ], + [ 0.5, 0.75 ], + [ 0.75, 1 ] + ] ], [ - "missing id", - { "attributes": {} }, - { - "key": "my-test", - "variations": [ 0, 1 ] - }, - 0, - false, - false + "uneven weights", + [ + 2, + 1, + [ 0.4, 0.6 ] + ], + [ + [ 0, 0.4 ], + [ 0.4, 1 ] + ] ], [ - "missing attributes", - {}, - { - "key": "my-test", - "variations": [ 0, 1 ] - }, - 0, - false, - false + "uneven weights, 3 variations", + [ + 3, + 1, + [ 0.2, 0.3, 0.5 ] + ], + [ + [ 0, 0.2 ], + [ 0.2, 0.5 ], + [ 0.5, 1 ] + ] ], [ - "single variation", - { "attributes": { "id": "1" } }, - { - "key": "my-test", - "variations": [ 0 ] - }, - 0, - false, - false + "uneven weights, reduced coverage, 3 variations", + [ + 3, + 0.2, + [ 0.2, 0.3, 0.5 ] + ], + [ + [ 0, 0.04 ], + [ 0.2, 0.26 ], + [ 0.5, 0.6 ] + ] ], [ - "negative forced variation", - { "attributes": { "id": "1" } }, - { - "key": "my-test", - "variations": [ 0, 1 ], - "force": -8 - }, - 0, - false, - false + "negative coverage", + [ 2, -0.2, null ], + [ + [ 0, 0 ], + [ 0.5, 0.5 ] + ] ], [ - "high forced variation", - { "attributes": { "id": "1" } }, - { - "key": "my-test", - "variations": [ 0, 1 ], - "force": 25 - }, - 0, - false, - false + "coverage above 1", + [ 2, 1.5, null ], + [ + [ 0, 0.5 ], + [ 0.5, 1 ] + ] ], [ - "evaluates conditions - pass", - { - "attributes": { - "id": "1", - "browser": "firefox" - } - }, - { - "key": "my-test", - "variations": [ 0, 1 ], - "condition": { - "browser": "firefox" - } - }, - 1, - true, - true + "weights sum below 1", + [ + 2, + 1, + [ 0.4, 0.1 ] + ], + [ + [ 0, 0.5 ], + [ 0.5, 1 ] + ] ], [ - "evaluates conditions - fail", - { - "attributes": { - "id": "1", - "browser": "chrome" - } - }, - { - "key": "my-test", - "variations": [ 0, 1 ], - "condition": { - "browser": "firefox" - } - }, - 0, - false, - false + "weights sum above 1", + [ + 2, + 1, + [ 0.7, 0.6 ] + ], + [ + [ 0, 0.5 ], + [ 0.5, 1 ] + ] ], [ - "custom hashAttribute", - { - "attributes": { - "id": "2", - "companyId": "1" - } - }, - { - "key": "my-test", - "variations": [ 0, 1 ], - "hashAttribute": "companyId" - }, - 1, - true, - true + "weights.length not equal to num variations", + [ + 4, + 1, + [ 0.4, 0.4, 0.2 ] + ], + [ + [ 0, 0.25 ], + [ 0.25, 0.5 ], + [ 0.5, 0.75 ], + [ 0.75, 1 ] + ] ], [ - "globally disabled", - { - "attributes": { - "id": "1" - }, - "enabled": false - }, - { - "key": "my-test", - "variations": [ 0, 1 ] - }, - 0, - false, - false + "weights sum almost equals 1", + [ + 2, + 1, + [ 0.4, 0.5999 ] + ], + [ + [ 0, 0.4 ], + [ 0.4, 0.9999 ] + ] + ] + ], + "feature": [ + [ + "unknown feature key", + {}, + "my-feature", + { + "value": null, + "on": false, + "off": true, + "source": "unknownFeature" + } ], [ - "querystring force", + "defaults when empty", + { "features": { "feature": {} } }, + "feature", { - "attributes": { - "id": "1" - }, - "url": "http://example.com?forced-test-qs=1#someanchor" - }, + "value": null, + "on": false, + "off": true, + "source": "defaultValue" + } + ], + [ + "uses defaultValue - number", + { "features": { "feature": { "defaultValue": 1 } } }, + "feature", { - "key": "forced-test-qs", - "variations": [ 0, 1 ] - }, - 1, - true, - false + "value": 1, + "on": true, + "off": false, + "source": "defaultValue" + } ], [ - "run active experiments", + "uses custom values - string", + { "features": { "feature": { "defaultValue": "yes" } } }, + "feature", { - "attributes": { - "id": "1" + "value": "yes", + "on": true, + "off": false, + "source": "defaultValue" + } + ], + [ + "force rules", + { + "features": { + "feature": { + "defaultValue": 2, + "rules": [ + { + "force": 1 + } + ] + } } }, + "feature", { - "key": "my-test", - "active": true, - "variations": [ 0, 1 ] - }, - 1, - true, - true + "value": 1, + "on": true, + "off": false, + "source": "force" + } ], [ - "skip inactive experiments", + "force rules - force false", { - "attributes": { - "id": "1" + "features": { + "feature": { + "defaultValue": true, + "rules": [ + { + "force": false + } + ] + } } }, + "feature", { - "key": "my-test", - "active": false, - "variations": [ 0, 1 ] - }, - 0, - false, - false + "value": false, + "on": false, + "off": true, + "source": "force" + } ], [ - "querystring force with inactive", + "force rules - coverage included", { "attributes": { - "id": "1" + "id": "3" }, - "url": "http://example.com/?my-test=1" + "features": { + "feature": { + "defaultValue": 2, + "rules": [ + { + "force": 1, + "coverage": 0.5 + } + ] + } + } }, + "feature", { - "key": "my-test", - "active": false, - "variations": [ 0, 1 ] - }, - 1, - true, - false + "value": 1, + "on": true, + "off": false, + "source": "force" + } ], [ - "coverage take precendence over forced", + "force rule - coverage with integer hash attribute", { "attributes": { - "id": "1" + "id": 3 + }, + "features": { + "feature": { + "defaultValue": 2, + "rules": [ + { + "force": 1, + "coverage": 0.5 + } + ] + } } }, + "feature", { - "key": "my-test", - "force": 1, - "coverage": 0.01, - "variations": [ 0, 1 ] - }, - 0, - false, - false + "value": 1, + "on": true, + "off": false, + "source": "force" + } ], [ - "JSON values for experiments", + "force rules - coverage excluded", { "attributes": { "id": "1" - } - }, - { - "key": "my-test", - "variations": [ - { - "color": "blue", - "size": "small" - }, - { - "color": "green", - "size": "large" + }, + "features": { + "feature": { + "defaultValue": 2, + "rules": [ + { + "force": 1, + "coverage": 0.5 + } + ] } - ] + } }, + "feature", { - "color": "green", - "size": "large" - }, - true, - true + "value": 2, + "on": true, + "off": false, + "source": "defaultValue" + } ], [ - "Force variation from context", + "force rules - coverage missing hashAttribute", { - "attributes": { "id": "1" }, - "forcedVariations": { "my-test": 0 } + "attributes": {}, + "features": { + "feature": { + "defaultValue": 2, + "rules": [ + { + "force": 1, + "coverage": 0.5 + } + ] + } + } }, + "feature", { - "key": "my-test", - "variations": [ 0, 1 ] - }, - 0, - true, - false + "value": 2, + "on": true, + "off": false, + "source": "defaultValue" + } ], [ - "Skips experiments in QA mode", - { - "attributes": { "id": "1" }, - "qaMode": true - }, + "force rules - coverage 0", { - "key": "my-test", - "variations": [ 0, 1 ] - }, - 0, - false, - false - ], - [ - "Works in QA mode if forced in context", - { - "attributes": { "id": "1" }, - "qaMode": true, - "forcedVariations": { "my-test": 1 } + "attributes": { + "id": "d0bc0a5a" + }, + "features": { + "8d156": { + "defaultValue": 0, + "rules": [ + { + "force": 1, + "coverage": 0, + "hashVersion": 2 + } + ] + } + } }, + "8d156", { - "key": "my-test", - "variations": [ 0, 1 ] - }, - 1, - true, - false + "value": 0, + "on": false, + "off": true, + "source": "defaultValue" + } ], [ - "Works in QA mode if forced in experiment", + "force rules - condition pass", { - "attributes": { "id": "1" }, - "qaMode": true + "attributes": { + "country": "US", + "browser": "firefox" + }, + "features": { + "feature": { + "defaultValue": 2, + "rules": [ + { + "force": 1, + "condition": { + "country": { "$in": [ "US", "CA" ] }, + "browser": "firefox" + } + } + ] + } + } }, + "feature", { - "key": "my-test", - "variations": [ 0, 1 ], - "force": 1 - }, - 1, - true, - false + "value": 1, + "on": true, + "off": false, + "source": "force" + } ], [ - "Experiment namespace - pass", + "force rules - condition fail", { "attributes": { - "id": "1" + "country": "US", + "browser": "chrome" + }, + "features": { + "feature": { + "defaultValue": 2, + "rules": [ + { + "force": 1, + "condition": { + "country": { "$in": [ "US", "CA" ] }, + "browser": "firefox" + } + } + ] + } } }, + "feature", { - "key": "my-test", - "variations": [ 0, 1 ], - "namespace": [ "namespace", 0.1, 1 ] - }, - 1, - true, - true + "value": 2, + "on": true, + "off": false, + "source": "defaultValue" + } ], [ - "Experiment namespace - fail", + "force rules - coverage with bad hash version", { "attributes": { "id": "1" + }, + "features": { + "feature": { + "defaultValue": 2, + "rules": [ + { + "force": 1, + "coverage": 1.0, + "hashVersion": 99 + } + ] + } } }, + "feature", { - "key": "my-test", - "variations": [ 0, 1 ], - "namespace": [ "namespace", 0, 0.1 ] - }, - 0, - false, - false + "value": 2, + "on": true, + "off": false, + "source": "defaultValue" + } ], [ - "Experiment coverage - Works when 0", + "ignores empty rules", { - "attributes": { - "id": "1" + "features": { + "feature": { + "rules": [ {} ] + } } }, + "feature", { - "key": "no-coverage", - "variations": [ 0, 1 ], - "coverage": 0 - }, - 0, - false, - false + "value": null, + "on": false, + "off": true, + "source": "defaultValue" + } ], [ - "Filtered, included", + "empty experiment rule - c", { "attributes": { - "id": "1", - "anonId": "fsdafsda" - } - }, - { - "key": "filtered", - "variations": [ 0, 1 ], - "filters": [ - { - "seed": "seed", - "ranges": [ - [ 0, 0.1 ], - [ 0.2, 0.4 ] + "id": "123" + }, + "features": { + "feature": { + "rules": [ + { + "variations": [ "a", "b", "c" ] + } ] - }, - { - "seed": "seed", - "attribute": "anonId", - "ranges": [ [ 0.8, 1.0 ] ] } - ] + } }, - 1, - true, - true + "feature", + { + "value": "c", + "on": true, + "off": false, + "experiment": { + "key": "feature", + "variations": [ "a", "b", "c" ] + }, + "experimentResult": { + "featureId": "feature", + "value": "c", + "variationId": 2, + "inExperiment": true, + "hashUsed": true, + "hashAttribute": "id", + "hashValue": "123", + "bucket": 0.863, + "key": "2", + "stickyBucketUsed": false + }, + "source": "experiment" + } ], [ - "Filtered, excluded", + "empty experiment rule - a", { "attributes": { - "id": "1", - "anonId": "fsdafsda" - } - }, - { - "key": "filtered", - "variations": [ 0, 1 ], - "filters": [ - { - "seed": "seed", - "ranges": [ - [ 0, 0.1 ], - [ 0.2, 0.4 ] + "id": "456" + }, + "features": { + "feature": { + "rules": [ + { + "variations": [ "a", "b", "c" ] + } ] - }, - { - "seed": "seed", - "attribute": "anonId", - "ranges": [ [ 0.6, 0.8 ] ] } - ] + } }, - 0, - false, - false + "feature", + { + "value": "a", + "on": true, + "off": false, + "experiment": { + "key": "feature", + "variations": [ "a", "b", "c" ] + }, + "experimentResult": { + "featureId": "feature", + "value": "a", + "variationId": 0, + "inExperiment": true, + "hashUsed": true, + "hashAttribute": "id", + "hashValue": "456", + "bucket": 0.178, + "key": "0", + "stickyBucketUsed": false + }, + "source": "experiment" + } ], [ - "Filtered, ignore namespace", + "empty experiment rule - b", { "attributes": { - "id": "1" - } - }, - { - "key": "filtered", - "variations": [ 0, 1 ], - "filters": [ - { - "seed": "seed", - "ranges": [ - [ 0, 0.1 ], - [ 0.2, 0.4 ] + "id": "fds" + }, + "features": { + "feature": { + "rules": [ + { + "variations": [ "a", "b", "c" ] + } ] } - ], - "namespace": [ "test", 0, 0.001 ] - }, - 1, - true, - true - ], - [ - "Ranges, ignore coverage and weights", - { - "attributes": { - "id": "1" } }, + "feature", { - "key": "ranges", - "variations": [ 0, 1 ], - "ranges": [ - [ 0.99, 1.0 ], - [ 0.0, 0.99 ] - ], - "coverage": 0.01, - "weights": [ 0.99, 0.01 ] - }, - 1, - true, - true + "value": "b", + "on": true, + "off": false, + "experiment": { + "key": "feature", + "variations": [ "a", "b", "c" ] + }, + "experimentResult": { + "featureId": "feature", + "value": "b", + "variationId": 1, + "inExperiment": true, + "hashUsed": true, + "hashAttribute": "id", + "hashValue": "fds", + "bucket": 0.514, + "key": "1", + "stickyBucketUsed": false + }, + "source": "experiment" + } ], [ - "Ranges, partial coverage", + "creates experiments properly", { "attributes": { - "id": "1" + "anonId": "123", + "premium": true + }, + "features": { + "feature": { + "rules": [ + { + "coverage": 0.99, + "hashAttribute": "anonId", + "seed": "feature", + "hashVersion": 2, + "name": "Test", + "phase": "1", + "ranges": [ + [ 0, 0.1 ], + [ 0.1, 1.0 ] + ], + "meta": [ + { + "key": "v0", + "name": "variation 0" + }, + { + "key": "v1", + "name": "variation 1" + } + ], + "filters": [ + { + "attribute": "anonId", + "seed": "pricing", + "ranges": [ [ 0, 1 ] ] + } + ], + "namespace": [ "pricing", 0, 1 ], + "key": "hello", + "variations": [ true, false ], + "weights": [ 0.1, 0.9 ], + "condition": { "premium": true } + } + ] + } } }, + "feature", { - "key": "configs", - "variations": [ 0, 1 ], - "ranges": [ - [ 0, 0.1 ], - [ 0.9, 1.0 ] - ] - }, - 0, - false, - false + "value": false, + "on": false, + "off": true, + "source": "experiment", + "experiment": { + "coverage": 0.99, + "ranges": [ + [ 0, 0.1 ], + [ 0.1, 1.0 ] + ], + "meta": [ + { + "key": "v0", + "name": "variation 0" + }, + { + "key": "v1", + "name": "variation 1" + } + ], + "filters": [ + { + "attribute": "anonId", + "seed": "pricing", + "ranges": [ [ 0, 1 ] ] + } + ], + "name": "Test", + "phase": "1", + "seed": "feature", + "hashVersion": 2, + "hashAttribute": "anonId", + "namespace": [ "pricing", 0, 1 ], + "key": "hello", + "variations": [ true, false ], + "weights": [ 0.1, 0.9 ], + "condition": { "premium": true } + }, + "experimentResult": { + "featureId": "feature", + "value": false, + "variationId": 1, + "inExperiment": true, + "hashUsed": true, + "hashAttribute": "anonId", + "hashValue": "123", + "bucket": 0.5231, + "key": "v1", + "name": "variation 1", + "stickyBucketUsed": false + } + } ], [ - "Uses seed and hash version", + "rule orders - skip 1", { "attributes": { - "id": "1" + "browser": "firefox" + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "force": 1, + "condition": { "browser": "chrome" } + }, + { + "force": 2, + "condition": { "browser": "firefox" } + }, + { + "force": 3, + "condition": { "browser": "safari" } + } + ] + } } }, + "feature", { - "key": "key", - "seed": "foo", - "hashVersion": 2, - "variations": [ 0, 1 ], - "ranges": [ - [ 0, 0.5 ], - [ 0.5, 1.0 ] - ] - }, - 1, - true, - true + "value": 2, + "on": true, + "off": false, + "source": "force" + } ], [ - "Uses seed with default weights/coverage", + "rule orders - skip 1,2", { "attributes": { - "id": "1" + "browser": "safari" + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "force": 1, + "condition": { "browser": "chrome" } + }, + { + "force": 2, + "condition": { "browser": "firefox" } + }, + { + "force": 3, + "condition": { "browser": "safari" } + } + ] + } } }, + "feature", { - "key": "key", - "seed": "foo", - "hashVersion": 2, - "variations": [ 0, 1 ] - }, - 1, - true, - true + "value": 3, + "on": true, + "off": false, + "source": "force" + } ], [ - "Uses seed with weights/coverage", + "rule orders - skip all", { "attributes": { - "id": "1" - } - }, - { - "key": "key", - "seed": "foo", - "hashVersion": 2, - "variations": [ 0, 1 ], + "browser": "ie" + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "force": 1, + "condition": { "browser": "chrome" } + }, + { + "force": 2, + "condition": { "browser": "firefox" } + }, + { + "force": 3, + "condition": { "browser": "safari" } + } + ] + } + } + }, + "feature", + { + "value": 0, + "on": false, + "off": true, + "source": "defaultValue" + } + ], + [ + "skips experiment on coverage", + { + "attributes": { "id": "123" }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "variations": [ 0, 1, 2, 3 ], + "coverage": 0.01 + }, + { + "force": 3 + } + ] + } + } + }, + "feature", + { + "value": 3, + "on": true, + "off": false, + "source": "force" + } + ], + [ + "skips experiment on namespace", + { + "attributes": { "id": "123" }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "variations": [ 0, 1, 2, 3 ], + "namespace": [ "pricing", 0, 0.01 ] + }, + { + "force": 3 + } + ] + } + } + }, + "feature", + { + "value": 3, + "on": true, + "off": false, + "source": "force" + } + ], + [ + "handles integer hashAttribute", + { + "attributes": { "id": 123 }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "variations": [ 0, 1 ] + } + ] + } + } + }, + "feature", + { + "value": 1, + "on": true, + "off": false, + "source": "experiment", + "experiment": { + "key": "feature", + "variations": [ 0, 1 ] + }, + "experimentResult": { + "featureId": "feature", + "hashAttribute": "id", + "hashValue": 123, + "hashUsed": true, + "inExperiment": true, + "value": 1, + "variationId": 1, + "key": "1", + "bucket": 0.863, + "stickyBucketUsed": false + } + } + ], + [ + "skip experiment on missing hashAttribute", + { + "attributes": { "id": "123" }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "variations": [ 0, 1, 2, 3 ], + "hashAttribute": "company" + }, + { + "force": 3 + } + ] + } + } + }, + "feature", + { + "value": 3, + "on": true, + "off": false, + "source": "force" + } + ], + [ + "include experiments when forced", + { + "attributes": { "id": "123" }, + "forcedVariations": { + "feature": 1 + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "variations": [ 0, 1, 2, 3 ] + }, + { + "force": 3 + } + ] + } + } + }, + "feature", + { + "value": 1, + "on": true, + "off": false, + "source": "experiment", + "experiment": { + "key": "feature", + "variations": [ 0, 1, 2, 3 ] + }, + "experimentResult": { + "featureId": "feature", + "value": 1, + "variationId": 1, + "inExperiment": true, + "hashUsed": false, + "hashAttribute": "id", + "hashValue": "123", + "key": "1", + "stickyBucketUsed": false + } + } + ], + [ + "Force rule with range, ignores coverage", + { + "attributes": { + "id": "1" + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "force": 2, + "coverage": 0.01, + "range": [ 0, 0.99 ] + } + ] + } + } + }, + "feature", + { + "value": 2, + "on": true, + "off": false, + "source": "force" + } + ], + [ + "Force rule, hash version 2", + { + "attributes": { + "id": "1" + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "force": 2, + "hashVersion": 2, + "range": [ 0.96, 0.97 ] + } + ] + } + } + }, + "feature", + { + "value": 2, + "on": true, + "off": false, + "source": "force" + } + ], + [ + "Force rule, skip due to range", + { + "attributes": { + "id": "1" + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "force": 2, + "range": [ 0, 0.01 ] + } + ] + } + } + }, + "feature", + { + "value": 0, + "on": false, + "off": true, + "source": "defaultValue" + } + ], + [ + "Force rule, skip due to filter", + { + "attributes": { + "id": "1" + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "force": 2, + "filters": [ + { + "seed": "seed", + "ranges": [ [ 0, 0.01 ] ] + } + ] + } + ] + } + } + }, + "feature", + { + "value": 0, + "on": false, + "off": true, + "source": "defaultValue" + } + ], + [ + "Force rule, use seed with range", + { + "attributes": { + "id": "1" + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "force": 2, + "range": [ 0, 0.5 ], + "seed": "fjdslafdsa", + "hashVersion": 2 + } + ] + } + } + }, + "feature", + { + "value": 2, + "on": true, + "off": false, + "source": "force" + } + ], + [ + "Support passthrough variations", + { + "attributes": { + "id": "1" + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "key": "holdout", + "variations": [ 1, 2 ], + "hashVersion": 2, + "ranges": [ + [ 0, 0.01 ], + [ 0.01, 1.0 ] + ], + "meta": [ + {}, + { + "passthrough": true + } + ] + }, + { + "key": "experiment", + "variations": [ 3, 4 ], + "hashVersion": 2, + "ranges": [ + [ 0, 0.5 ], + [ 0.5, 1.0 ] + ] + } + ] + } + } + }, + "feature", + { + "value": 3, + "on": true, + "off": false, + "source": "experiment", + "experiment": { + "key": "experiment", + "hashVersion": 2, + "variations": [ 3, 4 ], + "ranges": [ + [ 0, 0.5 ], + [ 0.5, 1.0 ] + ] + }, + "experimentResult": { + "featureId": "feature", + "hashAttribute": "id", + "hashUsed": true, + "hashValue": "1", + "inExperiment": true, + "key": "0", + "value": 3, + "variationId": 0, + "bucket": 0.4413, + "stickyBucketUsed": false + } + } + ], + [ + "Support holdout groups", + { + "attributes": { + "id": "1" + }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "key": "holdout", + "hashVersion": 2, + "variations": [ 1, 2 ], + "ranges": [ + [ 0, 0.99 ], + [ 0.99, 1.0 ] + ], + "meta": [ + {}, + { + "passthrough": true + } + ] + }, + { + "key": "experiment", + "hashVersion": 2, + "variations": [ 3, 4 ], + "ranges": [ + [ 0, 0.5 ], + [ 0.5, 1.0 ] + ] + } + ] + } + } + }, + "feature", + { + "value": 1, + "on": true, + "off": false, + "source": "experiment", + "experiment": { + "hashVersion": 2, + "ranges": [ + [ 0, 0.99 ], + [ 0.99, 1.0 ] + ], + "meta": [ + {}, + { + "passthrough": true + } + ], + "key": "holdout", + "variations": [ 1, 2 ] + }, + "experimentResult": { + "featureId": "feature", + "hashAttribute": "id", + "hashUsed": true, + "hashValue": "1", + "inExperiment": true, + "key": "0", + "value": 1, + "variationId": 0, + "bucket": 0.8043, + "stickyBucketUsed": false + } + } + ], + [ + "Prerequisite flag off, block dependent flag", + { + "attributes": { + "id": "123", + "memberType": "basic", + "country": "Canada" + }, + "features": { + "parentFlag": { + "defaultValue": "silver", + "rules": [ + { + "condition": { "country": "Canada" }, + "force": "red" + }, + { + "condition": { "country": { "$in": [ "USA", "Mexico" ] } }, + "force": "green" + } + ] + }, + "childFlag": { + "defaultValue": "default", + "rules": [ + { + "parentConditions": [ + { + "id": "parentFlag", + "condition": { "value": "green" }, + "gate": true + } + ] + }, + { + "condition": { "memberType": "basic" }, + "force": "success" + } + ] + } + } + }, + "childFlag", + { + "value": null, + "on": false, + "off": true, + "source": "prerequisite" + } + ], + [ + "Prerequisite flag missing, block dependent flag", + { + "attributes": { + "id": "123", + "memberType": "basic", + "country": "Canada" + }, + "features": { + "childFlag": { + "defaultValue": "default", + "rules": [ + { + "parentConditions": [ + { + "id": "parentFlag", + "condition": { "value": "green" }, + "gate": true + } + ] + }, + { + "condition": { "memberType": "basic" }, + "force": "success" + } + ] + } + } + }, + "childFlag", + { + "value": null, + "on": false, + "off": true, + "source": "prerequisite" + } + ], + [ + "Prerequisite flag on, evaluate dependent flag", + { + "attributes": { + "id": "123", + "memberType": "basic", + "country": "USA" + }, + "features": { + "parentFlag": { + "defaultValue": "silver", + "rules": [ + { + "condition": { "country": "Canada" }, + "force": "red" + }, + { + "condition": { "country": { "$in": [ "USA", "Mexico" ] } }, + "force": "green" + } + ] + }, + "childFlag": { + "defaultValue": "default", + "rules": [ + { + "parentConditions": [ + { + "id": "parentFlag", + "condition": { "value": "green" }, + "gate": true + } + ] + }, + { + "condition": { "memberType": "basic" }, + "force": "success" + } + ] + } + } + }, + "childFlag", + { + "value": "success", + "on": true, + "off": false, + "source": "force" + } + ], + [ + "Multiple parallel prerequisite flags on, evaluate dependent flag", + { + "attributes": { + "id": "123", + "memberType": "basic", + "country": "USA" + }, + "features": { + "parentFlag1": { + "defaultValue": "silver", + "rules": [ + { + "condition": { "country": "Canada" }, + "force": "red" + }, + { + "condition": { "country": { "$in": [ "USA", "Mexico" ] } }, + "force": "green" + } + ] + }, + "parentFlag2": { + "defaultValue": 0, + "rules": [ + { + "condition": { "id": "123" }, + "force": 2 + } + ] + }, + "childFlag": { + "defaultValue": "default", + "rules": [ + { + "parentConditions": [ + { + "id": "parentFlag1", + "condition": { "value": "green" }, + "gate": true + } + ] + }, + { + "parentConditions": [ + { + "id": "parentFlag2", + "condition": { "value": { "$gt": 1 } }, + "gate": true + } + ] + }, + { + "condition": { "memberType": "basic" }, + "force": "success" + } + ] + } + } + }, + "childFlag", + { + "value": "success", + "on": true, + "off": false, + "source": "force" + } + ], + [ + "Multiple nested prerequisite flags on, evaluate dependent flag", + { + "attributes": { + "id": "123", + "memberType": "basic", + "country": "USA" + }, + "features": { + "parentFlag1": { + "defaultValue": "silver", + "rules": [ + { + "parentConditions": [ + { + "id": "parentFlag2", + "condition": { "value": { "$gt": 1 } }, + "gate": true + } + ] + }, + { + "condition": { "country": "Canada" }, + "force": "red" + }, + { + "condition": { "country": { "$in": [ "USA", "Mexico" ] } }, + "force": "green" + } + ] + }, + "parentFlag2": { + "defaultValue": 0, + "rules": [ + { + "condition": { "id": "123" }, + "force": 2 + } + ] + }, + "childFlag": { + "defaultValue": "default", + "rules": [ + { + "parentConditions": [ + { + "id": "parentFlag1", + "condition": { "value": "green" }, + "gate": true + } + ] + }, + { + "condition": { "memberType": "basic" }, + "force": "success" + } + ] + } + } + }, + "childFlag", + { + "value": "success", + "on": true, + "off": false, + "source": "force" + } + ], + [ + "Prerequisite experiment flag in target bucket, evaluate dependent flag", + { + "attributes": { + "id": "1234", + "memberType": "basic", + "country": "USA" + }, + "features": { + "parentExperimentFlag": { + "defaultValue": 0, + "rules": [ + { + "key": "experiment", + "variations": [ 0, 1 ], + "hashAttribute": "id", + "hashVersion": 2, + "ranges": [ + [ 0, 0.5 ], + [ 0.5, 1.0 ] + ] + } + ] + }, + "childFlag": { + "defaultValue": "default", + "rules": [ + { + "parentConditions": [ + { + "id": "parentExperimentFlag", + "condition": { "value": 1 }, + "gate": true + } + ] + }, + { + "condition": { "memberType": "basic" }, + "force": "success" + } + ] + } + } + }, + "childFlag", + { + "value": "success", + "on": true, + "off": false, + "source": "force" + } + ], + [ + "Prerequisite cycle detected, break", + { + "attributes": { + "id": "123" + }, + "features": { + "flag1": { + "defaultValue": true, + "rules": [ + { + "parentConditions": [ + { + "id": "flag2", + "condition": { "value": true }, + "gate": true + } + ] + } + ] + }, + "flag2": { + "defaultValue": true, + "rules": [ + { + "parentConditions": [ + { + "id": "flag1", + "condition": { "value": true }, + "gate": true + } + ] + } + ] + } + } + }, + "flag1", + { + "value": null, + "on": false, + "off": true, + "source": "cyclicPrerequisite" + } + ], + [ + "SavedGroups correctly pulled from context for force rule", + { + "attributes": { + "id": 123 + }, + "features": { + "inGroup_force_rule": { + "defaultValue": false, + "rules": [ + { + "force": true, + "condition": { "id": { "$inGroup": "group_id" } } + } + ] + } + }, + "savedGroups": { + "group_id": [ 123, 456 ] + } + }, + "inGroup_force_rule", + { + "value": true, + "on": true, + "off": false, + "source": "force" + } + ], + [ + "SavedGroups correctly pulled from context for experiment rule", + { + "attributes": { + "id": 123 + }, + "features": { + "inGroup_experiment_rule": { + "defaultValue": 0, + "rules": [ + { + "key": "experiment", + "condition": { "id": { "$inGroup": "group_id" } }, + "hashVersion": 2, + "variations": [ 1, 2 ], + "ranges": [ + [ 0, 0.5 ], + [ 0.5, 1.0 ] + ] + } + ] + } + }, + "savedGroups": { + "group_id": [ 123, 456 ] + } + }, + "inGroup_experiment_rule", + { + "value": 1, + "on": true, + "off": false, + "source": "experiment", + "experiment": { + "hashVersion": 2, + "condition": { "id": { "$inGroup": "group_id" } }, + "variations": [ 1, 2 ], + "ranges": [ + [ 0, 0.5 ], + [ 0.5, 1.0 ] + ], + "key": "experiment" + }, + "experimentResult": { + "featureId": "inGroup_experiment_rule", + "hashAttribute": "id", + "hashUsed": true, + "hashValue": 123, + "inExperiment": true, + "key": "0", + "value": 1, + "variationId": 0, + "bucket": 0.1736, + "stickyBucketUsed": false + } + } + ] + ], + "run": [ + [ + "default weights - 1", + { "attributes": { "id": "1" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 1, + true, + true + ], + [ + "default weights - 2", + { "attributes": { "id": "2" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + true, + true + ], + [ + "default weights - 3", + { "attributes": { "id": "3" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + true, + true + ], + [ + "default weights - 4", + { "attributes": { "id": "4" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 1, + true, + true + ], + [ + "default weights - 5", + { "attributes": { "id": "5" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 1, + true, + true + ], + [ + "default weights - 6", + { "attributes": { "id": "6" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 1, + true, + true + ], + [ + "default weights - 7", + { "attributes": { "id": "7" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + true, + true + ], + [ + "default weights - 8", + { "attributes": { "id": "8" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 1, + true, + true + ], + [ + "default weights - 9", + { "attributes": { "id": "9" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + true, + true + ], + [ + "uneven weights - 1", + { "attributes": { "id": "1" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "weights": [ 0.1, 0.9 ] + }, + 1, + true, + true + ], + [ + "uneven weights - 2", + { "attributes": { "id": "2" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "weights": [ 0.1, 0.9 ] + }, + 1, + true, + true + ], + [ + "uneven weights - 3", + { "attributes": { "id": "3" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "weights": [ 0.1, 0.9 ] + }, + 0, + true, + true + ], + [ + "uneven weights - 4", + { "attributes": { "id": "4" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "weights": [ 0.1, 0.9 ] + }, + 1, + true, + true + ], + [ + "uneven weights - 5", + { "attributes": { "id": "5" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "weights": [ 0.1, 0.9 ] + }, + 1, + true, + true + ], + [ + "uneven weights - 6", + { "attributes": { "id": "6" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "weights": [ 0.1, 0.9 ] + }, + 1, + true, + true + ], + [ + "uneven weights - 7", + { "attributes": { "id": "7" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "weights": [ 0.1, 0.9 ] + }, + 0, + true, + true + ], + [ + "uneven weights - 8", + { "attributes": { "id": "8" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "weights": [ 0.1, 0.9 ] + }, + 1, + true, + true + ], + [ + "uneven weights - 9", + { "attributes": { "id": "9" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "weights": [ 0.1, 0.9 ] + }, + 1, + true, + true + ], + [ + "coverage - 1", + { "attributes": { "id": "1" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "coverage": 0.4 + }, + 0, + false, + false + ], + [ + "coverage - 2", + { "attributes": { "id": "2" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "coverage": 0.4 + }, + 0, + true, + true + ], + [ + "coverage - 3", + { "attributes": { "id": "3" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "coverage": 0.4 + }, + 0, + true, + true + ], + [ + "coverage - 4", + { "attributes": { "id": "4" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "coverage": 0.4 + }, + 0, + false, + false + ], + [ + "coverage - 5", + { "attributes": { "id": "5" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "coverage": 0.4 + }, + 1, + true, + true + ], + [ + "coverage - 6", + { "attributes": { "id": "6" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "coverage": 0.4 + }, + 0, + false, + false + ], + [ + "coverage - 7", + { "attributes": { "id": "7" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "coverage": 0.4 + }, + 0, + true, + true + ], + [ + "coverage - 8", + { "attributes": { "id": "8" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "coverage": 0.4 + }, + 1, + true, + true + ], + [ + "coverage - 9", + { "attributes": { "id": "9" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "coverage": 0.4 + }, + 0, + false, + false + ], + [ + "three way test - 1", + { "attributes": { "id": "1" } }, + { + "key": "my-test", + "variations": [ 0, 1, 2 ] + }, + 2, + true, + true + ], + [ + "three way test - 2", + { "attributes": { "id": "2" } }, + { + "key": "my-test", + "variations": [ 0, 1, 2 ] + }, + 0, + true, + true + ], + [ + "three way test - 3", + { "attributes": { "id": "3" } }, + { + "key": "my-test", + "variations": [ 0, 1, 2 ] + }, + 0, + true, + true + ], + [ + "three way test - 4", + { "attributes": { "id": "4" } }, + { + "key": "my-test", + "variations": [ 0, 1, 2 ] + }, + 2, + true, + true + ], + [ + "three way test - 5", + { "attributes": { "id": "5" } }, + { + "key": "my-test", + "variations": [ 0, 1, 2 ] + }, + 1, + true, + true + ], + [ + "three way test - 6", + { "attributes": { "id": "6" } }, + { + "key": "my-test", + "variations": [ 0, 1, 2 ] + }, + 2, + true, + true + ], + [ + "three way test - 7", + { "attributes": { "id": "7" } }, + { + "key": "my-test", + "variations": [ 0, 1, 2 ] + }, + 0, + true, + true + ], + [ + "three way test - 8", + { "attributes": { "id": "8" } }, + { + "key": "my-test", + "variations": [ 0, 1, 2 ] + }, + 1, + true, + true + ], + [ + "three way test - 9", + { "attributes": { "id": "9" } }, + { + "key": "my-test", + "variations": [ 0, 1, 2 ] + }, + 0, + true, + true + ], + [ + "test name - my-test", + { "attributes": { "id": "1" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 1, + true, + true + ], + [ + "test name - my-test-3", + { "attributes": { "id": "1" } }, + { + "key": "my-test-3", + "variations": [ 0, 1 ] + }, + 0, + true, + true + ], + [ + "empty id", + { "attributes": { "id": "" } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + false, + false + ], + [ + "null id", + { "attributes": { "id": null } }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + false, + false + ], + [ + "missing id", + { "attributes": {} }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + false, + false + ], + [ + "missing attributes", + {}, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + false, + false + ], + [ + "single variation", + { "attributes": { "id": "1" } }, + { + "key": "my-test", + "variations": [ 0 ] + }, + 0, + false, + false + ], + [ + "negative forced variation", + { "attributes": { "id": "1" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "force": -8 + }, + 0, + false, + false + ], + [ + "high forced variation", + { "attributes": { "id": "1" } }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "force": 25 + }, + 0, + false, + false + ], + [ + "evaluates conditions - pass", + { + "attributes": { + "id": "1", + "browser": "firefox" + } + }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "condition": { + "browser": "firefox" + } + }, + 1, + true, + true + ], + [ + "evaluates conditions - fail", + { + "attributes": { + "id": "1", + "browser": "chrome" + } + }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "condition": { + "browser": "firefox" + } + }, + 0, + false, + false + ], + [ + "custom hashAttribute", + { + "attributes": { + "id": "2", + "companyId": "1" + } + }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "hashAttribute": "companyId" + }, + 1, + true, + true + ], + [ + "globally disabled", + { + "attributes": { + "id": "1" + }, + "enabled": false + }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + false, + false + ], + [ + "querystring force", + { + "attributes": { + "id": "1" + }, + "url": "http://example.com?forced-test-qs=1#someanchor" + }, + { + "key": "forced-test-qs", + "variations": [ 0, 1 ] + }, + 1, + true, + false + ], + [ + "run active experiments", + { + "attributes": { + "id": "1" + } + }, + { + "key": "my-test", + "active": true, + "variations": [ 0, 1 ] + }, + 1, + true, + true + ], + [ + "skip inactive experiments", + { + "attributes": { + "id": "1" + } + }, + { + "key": "my-test", + "active": false, + "variations": [ 0, 1 ] + }, + 0, + false, + false + ], + [ + "querystring force with inactive", + { + "attributes": { + "id": "1" + }, + "url": "http://example.com/?my-test=1" + }, + { + "key": "my-test", + "active": false, + "variations": [ 0, 1 ] + }, + 1, + true, + false + ], + [ + "coverage take precendence over forced", + { + "attributes": { + "id": "1" + } + }, + { + "key": "my-test", + "force": 1, + "coverage": 0.01, + "variations": [ 0, 1 ] + }, + 0, + false, + false + ], + [ + "JSON values for experiments", + { + "attributes": { + "id": "1" + } + }, + { + "key": "my-test", + "variations": [ + { + "color": "blue", + "size": "small" + }, + { + "color": "green", + "size": "large" + } + ] + }, + { + "color": "green", + "size": "large" + }, + true, + true + ], + [ + "Force variation from context", + { + "attributes": { "id": "1" }, + "forcedVariations": { "my-test": 0 } + }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + true, + false + ], + [ + "Skips experiments in QA mode", + { + "attributes": { "id": "1" }, + "qaMode": true + }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 0, + false, + false + ], + [ + "Works in QA mode if forced in context", + { + "attributes": { "id": "1" }, + "qaMode": true, + "forcedVariations": { "my-test": 1 } + }, + { + "key": "my-test", + "variations": [ 0, 1 ] + }, + 1, + true, + false + ], + [ + "Works in QA mode if forced in experiment", + { + "attributes": { "id": "1" }, + "qaMode": true + }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "force": 1 + }, + 1, + true, + false + ], + [ + "Experiment namespace - pass", + { + "attributes": { + "id": "1" + } + }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "namespace": [ "namespace", 0.1, 1 ] + }, + 1, + true, + true + ], + [ + "Experiment namespace - fail", + { + "attributes": { + "id": "1" + } + }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "namespace": [ "namespace", 0, 0.1 ] + }, + 0, + false, + false + ], + [ + "Experiment coverage - Works when 0", + { + "attributes": { + "id": "1" + } + }, + { + "key": "no-coverage", + "variations": [ 0, 1 ], + "coverage": 0 + }, + 0, + false, + false + ], + [ + "Filtered, included", + { + "attributes": { + "id": "1", + "anonId": "fsdafsda" + } + }, + { + "key": "filtered", + "variations": [ 0, 1 ], + "filters": [ + { + "seed": "seed", + "ranges": [ + [ 0, 0.1 ], + [ 0.2, 0.4 ] + ] + }, + { + "seed": "seed", + "attribute": "anonId", + "ranges": [ [ 0.8, 1.0 ] ] + } + ] + }, + 1, + true, + true + ], + [ + "Filtered, excluded", + { + "attributes": { + "id": "1", + "anonId": "fsdafsda" + } + }, + { + "key": "filtered", + "variations": [ 0, 1 ], + "filters": [ + { + "seed": "seed", + "ranges": [ + [ 0, 0.1 ], + [ 0.2, 0.4 ] + ] + }, + { + "seed": "seed", + "attribute": "anonId", + "ranges": [ [ 0.6, 0.8 ] ] + } + ] + }, + 0, + false, + false + ], + [ + "Filtered, ignore namespace", + { + "attributes": { + "id": "1" + } + }, + { + "key": "filtered", + "variations": [ 0, 1 ], + "filters": [ + { + "seed": "seed", + "ranges": [ + [ 0, 0.1 ], + [ 0.2, 0.4 ] + ] + } + ], + "namespace": [ "test", 0, 0.001 ] + }, + 1, + true, + true + ], + [ + "Ranges, ignore coverage and weights", + { + "attributes": { + "id": "1" + } + }, + { + "key": "ranges", + "variations": [ 0, 1 ], + "ranges": [ + [ 0.99, 1.0 ], + [ 0.0, 0.99 ] + ], + "coverage": 0.01, + "weights": [ 0.99, 0.01 ] + }, + 1, + true, + true + ], + [ + "Ranges, partial coverage", + { + "attributes": { + "id": "1" + } + }, + { + "key": "configs", + "variations": [ 0, 1 ], + "ranges": [ + [ 0, 0.1 ], + [ 0.9, 1.0 ] + ] + }, + 0, + false, + false + ], + [ + "Uses seed and hash version", + { + "attributes": { + "id": "1" + } + }, + { + "key": "key", + "seed": "foo", + "hashVersion": 2, + "variations": [ 0, 1 ], + "ranges": [ + [ 0, 0.5 ], + [ 0.5, 1.0 ] + ] + }, + 1, + true, + true + ], + [ + "Uses seed with default weights/coverage", + { + "attributes": { + "id": "1" + } + }, + { + "key": "key", + "seed": "foo", + "hashVersion": 2, + "variations": [ 0, 1 ] + }, + 1, + true, + true + ], + [ + "Uses seed with weights/coverage", + { + "attributes": { + "id": "1" + } + }, + { + "key": "key", + "seed": "foo", + "hashVersion": 2, + "variations": [ 0, 1 ], "weights": [ 0.5, 0.5 ], "coverage": 0.99 }, 1, true, true + ], + [ + "Prerequisite condition passes", + { + "attributes": { "id": "1" }, + "features": { + "parentFlag": { + "defaultValue": true + } + } + }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "parentConditions": [ + { + "id": "parentFlag", + "condition": { + "value": true + } + } + ] + }, + 1, + true, + true + ], + [ + "Prerequisite condition fails", + { + "attributes": { "id": "1" }, + "features": { + "parentFlag": { + "defaultValue": false + } + } + }, + { + "key": "my-test", + "variations": [ 0, 1 ], + "parentConditions": [ + { + "id": "parentFlag", + "condition": { + "value": true + } + } + ] + }, + 0, + false, + false + ], + [ + "SavedGroups correctly pulled from context for experiment", + { + "attributes": { "id": "4" }, + "savedGroups": { "group_id": [ "4", "5", "6" ] } + }, + { + "key": "group-filtered-test", + "condition": { + "id": { "$inGroup": "group_id" } + }, + "variations": [ 0, 1, 2 ] + }, + 0, + true, + true ] ], "chooseVariation": [ @@ -4571,70 +5960,798 @@ [ 0.33333333, 0.33333333, 0.33333333 ] ], [ - 4, - [ 0.25, 0.25, 0.25, 0.25 ] - ] - ], - "decrypt": [ - [ - "Valid feature", - "m5ylFM6ndyOJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", - "Zvwv/+uhpFDznZ6SX28Yjg==", - "{\"feature\":{\"defaultValue\":true}}" + 4, + [ 0.25, 0.25, 0.25, 0.25 ] + ] + ], + "decrypt": [ + [ + "Valid feature", + "m5ylFM6ndyOJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", + "Zvwv/+uhpFDznZ6SX28Yjg==", + "{\"feature\":{\"defaultValue\":true}}" + ], + [ + "Broken JSON", + "SVZIM2oKD1JoHNIeeoW3Uw==.AGbRiGAHf2f6/ziVr9UTIy+bVFmVli6+bHZ2jnCm9N991ITv1ROvOEjxjLSmgEpv", + "UQD0Qqw7fM1bhfKKPH8TGw==", + "{\"feature\":{\"defaultValue\":true?5%" + ], + [ + "Wrong key", + "m5ylFM6ndyOJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", + "Zvwv/+uhpFDznZ6SX39Yjg==", + null + ], + [ + "Invalid key length", + "m5ylFM6ndyOJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", + "Zvwv/+uhpFDznSX39Yjg==", + null + ], + [ + "Invalid key characters", + "m5ylFM6ndyOJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", + "Zvwv/%!(pFDznZ6SX39Yjg==", + null + ], + [ + "Invalid body", + "m5ylFM6ndyOJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q0!*&()f3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", + "Zvwv/+uhpFDznZ6SX28Yjg==", + null + ], + [ + "Invalid iv length", + "m5ylFM6ndyOPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", + "Zvwv/+uhpFDznZ6SX28Yjg==", + null + ], + [ + "Invalid iv", + "m5ylFM6*&(OJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", + "Zvwv/+uhpFDznZ6SX28Yjg==", + null + ], + [ + "Missing delimiter", + "m5ylFM6ndyOJA2OPadubkw==Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", + "Zvwv/+uhpFDznZ6SX28Yjg==", + null + ], + [ + "Corrupted payload", + "fsa*(&(SF*&F&SF^SD&*FS&*6fsdkajfd", + "Zvwv/+uhpFDznZ6SX28Yjg==", + null + ] + ], + "stickyBucket": [ + [ + "use fallbackAttribute when missing hashAttribute", + { + "attributes": { "anonymousId": "123" }, + "features": { + "feature": { + "defaultValue": 0, + "rules": [ + { + "variations": [ 0, 1, 2, 3 ], + "hashAttribute": "id", + "fallbackAttribute": "anonymousId" + } + ] + } + } + }, + [], + "feature", + { + "bucket": 0.863, + "featureId": "feature", + "hashAttribute": "anonymousId", + "hashUsed": true, + "hashValue": "123", + "inExperiment": true, + "key": "3", + "stickyBucketUsed": false, + "value": 3, + "variationId": 3 + }, + { + "anonymousId||123": { + "assignments": { "feature__0": "3" }, + "attributeName": "anonymousId", + "attributeValue": "123" + } + } + ], + [ + "performs evaluation without sticky bucket", + { + "attributes": { + "deviceId": "d123", + "anonymousId": "ses123", + "foo": "bar", + "country": "USA" + }, + "features": { + "exp1": { + "defaultValue": "control", + "rules": [ + { + "key": "feature-exp", + "seed": "feature-exp", + "hashAttribute": "id", + "fallbackAttribute": "deviceId", + "hashVersion": 2, + "bucketVersion": 0, + "condition": { "country": "USA" }, + "variations": [ "control", "red", "blue" ], + "meta": [ + { "key": "0" }, + { "key": "1" }, + { "key": "2" } + ], + "coverage": 1, + "weights": [ 0.3334, 0.3333, 0.3333 ], + "phase": "0" + } + ] + } + }, + "stickyBucketAssignmentDocs": {} + }, + [], + "exp1", + { + "bucket": 0.6468, + "featureId": "exp1", + "hashAttribute": "deviceId", + "hashUsed": true, + "hashValue": "d123", + "inExperiment": true, + "key": "1", + "stickyBucketUsed": false, + "value": "red", + "variationId": 1 + }, + { + "deviceId||d123": { + "assignments": { "feature-exp__0": "1" }, + "attributeName": "deviceId", + "attributeValue": "d123" + } + } + ], + [ + "evaluates based on stored sticky bucket", + { + "attributes": { + "deviceId": "d123", + "anonymousId": "ses123", + "foo": "bar", + "country": "USA" + }, + "features": { + "exp1": { + "defaultValue": "control", + "rules": [ + { + "key": "feature-exp", + "seed": "feature-exp", + "hashAttribute": "id", + "fallbackAttribute": "deviceId", + "hashVersion": 2, + "bucketVersion": 0, + "condition": { "country": "USA" }, + "variations": [ "control", "red", "blue" ], + "meta": [ + { "key": "0" }, + { "key": "1" }, + { "key": "2" } + ], + "coverage": 1, + "weights": [ 0.3334, 0.3333, 0.3333 ], + "phase": "0" + } + ] + } + } + }, + [ + { + "attributeName": "deviceId", + "attributeValue": "d123", + "assignments": { + "feature-exp__0": "2" + } + } + ], + "exp1", + { + "bucket": 0.6468, + "featureId": "exp1", + "hashAttribute": "deviceId", + "hashUsed": true, + "hashValue": "d123", + "inExperiment": true, + "key": "2", + "stickyBucketUsed": true, + "value": "blue", + "variationId": 2 + }, + { + "deviceId||d123": { + "assignments": { "feature-exp__0": "2" }, + "attributeName": "deviceId", + "attributeValue": "d123" + } + } + ], + [ + "does not consume a sticky bucket not belonging to the user", + { + "attributes": { + "deviceId": "d123", + "anonymousId": "ses123", + "foo": "bar", + "country": "USA" + }, + "features": { + "exp1": { + "defaultValue": "control", + "rules": [ + { + "key": "feature-exp", + "seed": "feature-exp", + "hashAttribute": "id", + "fallbackAttribute": "deviceId", + "hashVersion": 2, + "bucketVersion": 0, + "condition": { "country": "USA" }, + "variations": [ "control", "red", "blue" ], + "meta": [ + { "key": "0" }, + { "key": "1" }, + { "key": "2" } + ], + "coverage": 1, + "weights": [ 0.3334, 0.3333, 0.3333 ], + "phase": "0" + } + ] + } + } + }, + [ + { + "attributeName": "deviceId", + "attributeValue": "d456", + "assignments": { + "feature-exp__0": "2" + } + } + ], + "exp1", + { + "bucket": 0.6468, + "featureId": "exp1", + "hashAttribute": "deviceId", + "hashUsed": true, + "hashValue": "d123", + "inExperiment": true, + "key": "1", + "stickyBucketUsed": false, + "value": "red", + "variationId": 1 + }, + { + "deviceId||d123": { + "assignments": { "feature-exp__0": "1" }, + "attributeName": "deviceId", + "attributeValue": "d123" + } + } ], [ - "Broken JSON", - "SVZIM2oKD1JoHNIeeoW3Uw==.AGbRiGAHf2f6/ziVr9UTIy+bVFmVli6+bHZ2jnCm9N991ITv1ROvOEjxjLSmgEpv", - "UQD0Qqw7fM1bhfKKPH8TGw==", - "{\"feature\":{\"defaultValue\":true?5%" + "upgrades a sticky bucket doc from a fallbackAttribute to a hashAttribute", + { + "attributes": { + "id": "i123", + "anonymousId": "ses123", + "foo": "bar", + "country": "USA" + }, + "features": { + "exp1": { + "defaultValue": "control", + "rules": [ + { + "key": "feature-exp", + "seed": "feature-exp", + "hashAttribute": "id", + "fallbackAttribute": "anonymousId", + "hashVersion": 2, + "bucketVersion": 0, + "condition": { "country": "USA" }, + "variations": [ "control", "red", "blue" ], + "meta": [ + { "key": "0" }, + { "key": "1" }, + { "key": "2" } + ], + "coverage": 1, + "weights": [ 0.3334, 0.3333, 0.3333 ], + "phase": "0" + } + ] + } + } + }, + [ + { + "attributeName": "anonymousId", + "attributeValue": "ses123", + "assignments": { + "feature-exp__0": "1" + } + } + ], + "exp1", + { + "bucket": 0.9943, + "featureId": "exp1", + "hashAttribute": "id", + "hashUsed": true, + "hashValue": "i123", + "inExperiment": true, + "key": "1", + "stickyBucketUsed": true, + "value": "red", + "variationId": 1 + }, + { + "anonymousId||ses123": { + "assignments": { "feature-exp__0": "1" }, + "attributeName": "anonymousId", + "attributeValue": "ses123" + }, + "id||i123": { + "assignments": { "feature-exp__0": "1" }, + "attributeName": "id", + "attributeValue": "i123" + } + } ], [ - "Wrong key", - "m5ylFM6ndyOJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", - "Zvwv/+uhpFDznZ6SX39Yjg==", - null + "favors a sticky bucket doc based on hashAttribute over fallbackAttribute", + { + "attributes": { + "id": "i123", + "anonymousId": "ses123", + "foo": "bar", + "country": "USA" + }, + "features": { + "exp1": { + "defaultValue": "control", + "rules": [ + { + "key": "feature-exp", + "seed": "feature-exp", + "hashAttribute": "id", + "fallbackAttribute": "anonymousId", + "hashVersion": 2, + "bucketVersion": 0, + "condition": { "country": "USA" }, + "variations": [ "control", "red", "blue" ], + "meta": [ + { "key": "0" }, + { "key": "1" }, + { "key": "2" } + ], + "coverage": 1, + "weights": [ 0.3334, 0.3333, 0.3333 ], + "phase": "0" + } + ] + } + } + }, + [ + { + "attributeName": "anonymousId", + "attributeValue": "ses123", + "assignments": { + "feature-exp__0": "2" + } + }, + { + "attributeName": "id", + "attributeValue": "i123", + "assignments": { + "feature-exp__0": "1" + } + } + ], + "exp1", + { + "bucket": 0.9943, + "featureId": "exp1", + "hashAttribute": "id", + "hashUsed": true, + "hashValue": "i123", + "inExperiment": true, + "key": "1", + "stickyBucketUsed": true, + "value": "red", + "variationId": 1 + }, + { + "anonymousId||ses123": { + "assignments": { "feature-exp__0": "2" }, + "attributeName": "anonymousId", + "attributeValue": "ses123" + }, + "id||i123": { + "assignments": { "feature-exp__0": "1" }, + "attributeName": "id", + "attributeValue": "i123" + } + } ], [ - "Invalid key length", - "m5ylFM6ndyOJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", - "Zvwv/+uhpFDznSX39Yjg==", - null + "resets sticky bucketing when the bucketVersion changes", + { + "attributes": { + "id": "i123", + "foo": "bar", + "country": "USA" + }, + "features": { + "exp1": { + "defaultValue": "control", + "rules": [ + { + "key": "feature-exp", + "seed": "feature-exp", + "hashAttribute": "id", + "fallbackAttribute": "deviceId", + "hashVersion": 2, + "bucketVersion": 3, + "condition": { "country": "USA" }, + "variations": [ "control", "red", "blue" ], + "meta": [ + { "key": "0" }, + { "key": "1" }, + { "key": "2" } + ], + "coverage": 1, + "weights": [ 0.3334, 0.3333, 0.3333 ], + "phase": "0" + } + ] + } + } + }, + [ + { + "assignments": { "feature-exp__0": "1" }, + "attributeName": "id", + "attributeValue": "i123" + } + ], + "exp1", + { + "bucket": 0.9943, + "featureId": "exp1", + "hashAttribute": "id", + "hashUsed": true, + "hashValue": "i123", + "inExperiment": true, + "key": "2", + "stickyBucketUsed": false, + "value": "blue", + "variationId": 2 + }, + { + "id||i123": { + "assignments": { + "feature-exp__0": "1", + "feature-exp__3": "2" + }, + "attributeName": "id", + "attributeValue": "i123" + } + } ], [ - "Invalid key characters", - "m5ylFM6ndyOJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", - "Zvwv/%!(pFDznZ6SX39Yjg==", - null + "stops test enrollment when and existing sticky bucket is blocked by version", + { + "attributes": { + "id": "i123", + "foo": "bar", + "country": "USA" + }, + "features": { + "exp1": { + "defaultValue": "control", + "rules": [ + { + "key": "feature-exp", + "seed": "feature-exp", + "hashAttribute": "id", + "fallbackAttribute": "deviceId", + "hashVersion": 2, + "bucketVersion": 3, + "minBucketVersion": 3, + "condition": { "country": "USA" }, + "variations": [ "control", "red", "blue" ], + "meta": [ + { "key": "0" }, + { "key": "1" }, + { "key": "2" } + ], + "coverage": 1, + "weights": [ 0.3334, 0.3333, 0.3333 ], + "phase": "0" + } + ] + } + } + }, + [ + { + "assignments": { "feature-exp__0": "1" }, + "attributeName": "id", + "attributeValue": "i123" + } + ], + "exp1", + null, + { + "id||i123": { + "assignments": { + "feature-exp__0": "1" + }, + "attributeName": "id", + "attributeValue": "i123" + } + } ], [ - "Invalid body", - "m5ylFM6ndyOJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q0!*&()f3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", - "Zvwv/+uhpFDznZ6SX28Yjg==", - null - ], + "disables sticky bucketing when disabled by experiment", + { + "attributes": { + "id": "i123", + "foo": "bar", + "country": "USA" + }, + "features": { + "exp1": { + "defaultValue": "control", + "rules": [ + { + "key": "feature-exp", + "seed": "feature-exp", + "hashAttribute": "id", + "fallbackAttribute": "deviceId", + "hashVersion": 2, + "bucketVersion": 1, + "disableStickyBucketing": true, + "condition": { "country": "USA" }, + "variations": [ "control", "red", "blue" ], + "meta": [ + { "key": "0" }, + { "key": "1" }, + { "key": "2" } + ], + "coverage": 1, + "weights": [ 0.3334, 0.3333, 0.3333 ], + "phase": "0" + } + ] + } + } + }, + [ + { + "attributeName": "id", + "attributeValue": "i123", + "assignments": { "feature-exp__0": "1" } + } + ], + "exp1", + { + "bucket": 0.9943, + "featureId": "exp1", + "hashAttribute": "id", + "hashUsed": true, + "hashValue": "i123", + "inExperiment": true, + "key": "2", + "stickyBucketUsed": false, + "value": "blue", + "variationId": 2 + }, + { + "id||i123": { + "attributeName": "id", + "attributeValue": "i123", + "assignments": { "feature-exp__0": "1" } + } + } + ] + ], + "urlRedirect": [ [ - "Invalid iv length", - "m5ylFM6ndyOPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", - "Zvwv/+uhpFDznZ6SX28Yjg==", - null + "redirects correctly without query strings", + { + "attributes": { "id": "1" }, + "url": "http://www.example.com/home", + "experiments": [ + { + "key": "my-experiment", + "urlPatterns": [ + { + "type": "simple", + "include": true, + "pattern": "http://www.example.com/home" + } + ], + "weights": [ 0.1, 0.9 ], + "variations": [ + {}, + { + "urlRedirect": "http://www.example.com/home-new" + } + ] + } + ] + }, + [ + { + "inExperiment": true, + "urlRedirect": "http://www.example.com/home-new", + "urlWithParams": "http://www.example.com/home-new" + } + ] ], [ - "Invalid iv", - "m5ylFM6*&(OJA2OPadubkw==.Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", - "Zvwv/+uhpFDznZ6SX28Yjg==", - null + "redirects with query string on original url and persistQueryString enabled", + { + "attributes": { "id": "1" }, + "url": "http://www.example.com/home?color=blue&food=sushi", + "experiments": [ + { + "key": "my-experiment", + "urlPatterns": [ + { + "type": "simple", + "include": true, + "pattern": "http://www.example.com/home" + } + ], + "weights": [ 0.1, 0.9 ], + "variations": [ + {}, + { + "urlRedirect": "http://www.example.com/home-new" + } + ], + "persistQueryString": true + } + ] + }, + [ + { + "inExperiment": true, + "urlRedirect": "http://www.example.com/home-new", + "urlWithParams": "http://www.example.com/home-new?color=blue&food=sushi" + } + ] ], [ - "Missing delimiter", - "m5ylFM6ndyOJA2OPadubkw==Uu7ViqgKEt/dWvCyhI46q088PkAEJbnXKf3KPZjf9IEQQ+A8fojNoxw4wIbPX3aj", - "Zvwv/+uhpFDznZ6SX28Yjg==", - null + "merges query strings on original url & redirect url with param conflicts correctly when persistQueryString enabled", + { + "attributes": { "id": "1" }, + "url": "http://www.example.com/home?color=blue&food=sushi&title=original", + "experiments": [ + { + "key": "my-experiment", + "urlPatterns": [ + { + "type": "simple", + "include": true, + "pattern": "http://www.example.com/home" + } + ], + "weights": [ 0.1, 0.9 ], + "variations": [ + {}, + { + "urlRedirect": "http://www.example.com/home-new?name=test&color=red&food=lasagna" + } + ], + "persistQueryString": true + } + ] + }, + [ + { + "inExperiment": true, + "urlRedirect": "http://www.example.com/home-new?name=test&color=red&food=lasagna", + "urlWithParams": "http://www.example.com/home-new?name=test&color=red&food=lasagna&title=original" + } + ] ], [ - "Corrupted payload", - "fsa*(&(SF*&F&SF^SD&*FS&*6fsdkajfd", - "Zvwv/+uhpFDznZ6SX28Yjg==", - null + "only performs a redirect for first eligible experiment when there are multiple eligible experiments", + { + "attributes": { "id": "1" }, + "url": "http://www.example.com/home", + "experiments": [ + { + "key": "my-experiment", + "urlPatterns": [ + { + "type": "simple", + "include": true, + "pattern": "http://www.example.com/" + } + ], + "weights": [ 0.1, 0.9 ], + "variations": [ + {}, + { + "urlRedirect": "http://www.example.com/home-new" + } + ] + }, + { + "key": "my-experiment-2", + "urlPatterns": [ + { + "type": "simple", + "include": true, + "pattern": "http://www.example.com/home" + } + ], + "weights": [ 0.1, 0.9 ], + "variations": [ + {}, + { + "urlRedirect": "http://www.example.com/home-new-new" + } + ] + }, + { + "key": "my-experiment-3", + "urlPatterns": [ + { + "type": "simple", + "include": true, + "pattern": "http://www.example.com/home" + } + ], + "weights": [ 0.1, 0.9 ], + "variations": [ + {}, + { + "urlRedirect": "http://www.example.com/home-es" + } + ] + } + ] + }, + [ + { + "inExperiment": true, + "urlRedirect": "http://www.example.com/home-new-new", + "urlWithParams": "http://www.example.com/home-new-new" + } + ] ] ] } diff --git a/GrowthBook.Tests/StandardTests/GrowthBookTests/StickyBucketTests.cs b/GrowthBook.Tests/StandardTests/GrowthBookTests/StickyBucketTests.cs new file mode 100644 index 0000000..7b5dc00 --- /dev/null +++ b/GrowthBook.Tests/StandardTests/GrowthBookTests/StickyBucketTests.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace GrowthBook.Tests.StandardTests.GrowthBookTests; + +public class StickyBucketTests : UnitTest +{ + [StandardCaseTestCategory("stickyBucket")] + public class StickyBucketTestCase + { + [TestPropertyIndex(0)] + public string TestName { get; set; } + [TestPropertyIndex(1)] + public Context Context { get; set; } + [TestPropertyIndex(2)] + public StickyAssignmentsDocument[] PreExistingAssignmentDocs { get; set; } = []; + [TestPropertyIndex(3)] + public string FeatureName { get; set; } + [TestPropertyIndex(4)] + public JToken ExpectedResult { get; set; } + [TestPropertyIndex(5)] + public StickyAssignmentsDocument[] ExpectedAssignmentDocs { get; set; } = []; + } + + [Theory] + [MemberData(nameof(GetMappedTestsInCategory), typeof(StickyBucketTestCase))] + public void Run(StickyBucketTestCase testCase) + { + var gb = new GrowthBook(testCase.Context); +#warning Complete this. + } +} diff --git a/GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs b/GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs new file mode 100644 index 0000000..a17a316 --- /dev/null +++ b/GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace GrowthBook.Tests.StandardTests.GrowthBookTests; + +public class UrlRedirectTests : UnitTest +{ + [StandardCaseTestCategory("urlRedirect")] + public class UrlRedirectTestCase + { + [TestPropertyIndex(0)] + public string TestName { get; set; } + [TestPropertyIndex(1)] + public Context Context { get; set; } + [TestPropertyIndex(2)] + public JToken[] ExpectedResults { get; set; } + } + + [Theory] + [MemberData(nameof(GetMappedTestsInCategory), typeof(UrlRedirectTestCase))] + public void Run(UrlRedirectTestCase testCase) + { + var gb = new GrowthBook(testCase.Context); + } +} diff --git a/GrowthBook.Tests/StandardTests/ProviderTests/EvalConditionTests.cs b/GrowthBook.Tests/StandardTests/ProviderTests/EvalConditionTests.cs index 91818e7..96f5808 100644 --- a/GrowthBook.Tests/StandardTests/ProviderTests/EvalConditionTests.cs +++ b/GrowthBook.Tests/StandardTests/ProviderTests/EvalConditionTests.cs @@ -25,6 +25,8 @@ public class EvalConditionTestCase public JObject Attributes { get; set; } [TestPropertyIndex(3)] public bool ExpectedValue { get; set; } + [TestPropertyIndex(4, isOptional: true)] + public Dictionary Groups { get; set; } = []; } [Theory] @@ -32,7 +34,7 @@ public class EvalConditionTestCase public void EvalCondition(EvalConditionTestCase testCase) { var logger = new NullLogger(); - var actualResult = new ConditionEvaluationProvider(logger).EvalCondition(testCase.Attributes, testCase.Condition); + var actualResult = new ConditionEvaluationProvider(logger).EvalCondition(testCase.Attributes, testCase.Condition, JObject.FromObject(testCase.Groups)); actualResult.Should().Be(testCase.ExpectedValue, "because the condition should evaluate correctly"); } diff --git a/GrowthBook.Tests/StandardTests/UtilitiesTests/VersionCompareTests.cs b/GrowthBook.Tests/StandardTests/UtilitiesTests/VersionCompareTests.cs deleted file mode 100644 index 73c845e..0000000 --- a/GrowthBook.Tests/StandardTests/UtilitiesTests/VersionCompareTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using FluentAssertions; -using Xunit; -using GrowthBook.Extensions; - -namespace GrowthBook.Tests.StandardTests.UtilitiesTests; - -public class VersionCompareTests : UnitTest -{ - public class VersionCompareTestCase - { - [TestPropertyIndex(0)] - public string Version { get; set; } - [TestPropertyIndex(1)] - public string OtherVersion { get; set; } - [TestPropertyIndex(2)] - public bool ExpectedResult { get; set; } - } - - [StandardCaseTestCategory("versionCompare.lt")] - public class VersionCompareLessThanTestCase : VersionCompareTestCase { } - - [StandardCaseTestCategory("versionCompare.gt")] - public class VersionCompareGreaterThanTestCase : VersionCompareTestCase { } - - [StandardCaseTestCategory("versionCompare.eq")] - public class VersionCompareEqualTestCase : VersionCompareTestCase { } - - [Theory] - [MemberData(nameof(GetMappedTestsInCategory), typeof(VersionCompareLessThanTestCase))] - public void VersionCompareLessThan(VersionCompareLessThanTestCase testCase) - { - var versionString = testCase.Version.ToPaddedVersionString(); - var otherVersionString = testCase.OtherVersion.ToPaddedVersionString(); - - var actualResult = string.CompareOrdinal(versionString, otherVersionString) < 0; - - actualResult.Should().Be(testCase.ExpectedResult, $"because '{versionString}' should be less than '{otherVersionString}'"); - } - - [Theory] - [MemberData(nameof(GetMappedTestsInCategory), typeof(VersionCompareGreaterThanTestCase))] - public void VersionCompareGreaterThan(VersionCompareGreaterThanTestCase testCase) - { - var versionString = testCase.Version.ToPaddedVersionString(); - var otherVersionString = testCase.OtherVersion.ToPaddedVersionString(); - - var actualResult = string.CompareOrdinal(versionString, otherVersionString) > 0; - - actualResult.Should().Be(testCase.ExpectedResult, $"because '{versionString}' should be greater than '{otherVersionString}'"); - } - - [Theory] - [MemberData(nameof(GetMappedTestsInCategory), typeof(VersionCompareEqualTestCase))] - public void VersionCompareEqual(VersionCompareEqualTestCase testCase) - { - var versionString = testCase.Version.ToPaddedVersionString(); - var otherVersionString = testCase.OtherVersion.ToPaddedVersionString(); - - var actualResult = string.CompareOrdinal(versionString, otherVersionString) == 0; - - actualResult.Should().Be(testCase.ExpectedResult, $"because '{versionString}' should be less than '{otherVersionString}'"); - } -} diff --git a/GrowthBook.Tests/UnitTest.cs b/GrowthBook.Tests/UnitTest.cs index 178e9ef..bce4f6c 100644 --- a/GrowthBook.Tests/UnitTest.cs +++ b/GrowthBook.Tests/UnitTest.cs @@ -75,7 +75,13 @@ protected sealed class TestPropertyIndexAttribute : Attribute /// public int Index { get; } + /// + /// Gets whether this value might be omitted in the test JSON. + /// + public bool IsOptional { get; } + public TestPropertyIndexAttribute(int index) => Index = index; + public TestPropertyIndexAttribute(int index, bool isOptional) : this(index) => IsOptional = isOptional; } /// @@ -196,7 +202,10 @@ public static IEnumerable GetMappedTestsInCategory(Type categoryType) foreach(var property in instanceType.GetProperties(BindingFlags.Instance | BindingFlags.Public)) { - var testIndex = property.GetCustomAttribute()?.Index; + var indexAttribute = property.GetCustomAttribute(); + + var testIndex = indexAttribute?.Index; + var isOptional = indexAttribute?.IsOptional; if (testIndex is null) { @@ -208,6 +217,14 @@ public static IEnumerable GetMappedTestsInCategory(Type categoryType) throw new InvalidOperationException($"Unable to deserialize type '{instanceType}', property '{property.Name}' has an index of '{testIndex}' that is out of range"); } + if (testIndex == array.Count && isOptional == true) + { + // Some of the JSON tests may omit the last property, in which case + // we should just fail gracefully and keep going here. + + continue; + } + var jsonInstance = array[testIndex]; if (jsonInstance.Type == JTokenType.Array) diff --git a/GrowthBook/Context.cs b/GrowthBook/Context.cs index a25a8f0..39c60d9 100644 --- a/GrowthBook/Context.cs +++ b/GrowthBook/Context.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using GrowthBook.Services; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -48,6 +49,16 @@ public class Context /// public IDictionary Features { get; set; } = new Dictionary(); + /// + /// Service for using sticky buckets. + /// + public IStickyBucketService StickyBucketService { get; set; } + + /// + /// The assignment docs for sticky bucket usage. Optional. + /// + public IDictionary StickyBucketAssignmentDocs { get; set; } = new Dictionary(); + /// /// Feature definitions that have been encrypted. Requires that the property /// be set in order for the class to decrypt them for use. @@ -59,6 +70,11 @@ public class Context /// public IDictionary ForcedVariations { get; set; } = new Dictionary(); + /// + /// Gets groups that have been saved, if any. + /// + public JObject SavedGroups { get; set; } + /// /// If true, random assignment is disabled and only explicitly forced variations are used. /// diff --git a/GrowthBook/Experiment.cs b/GrowthBook/Experiment.cs index f7586d2..f4dfcaf 100644 --- a/GrowthBook/Experiment.cs +++ b/GrowthBook/Experiment.cs @@ -48,6 +48,11 @@ public class Experiment /// public JObject Condition { get; set; } + /// + /// Each item defines a prerequisite where a condition must evaluate against a parent feature's value (identified by id). If gate is true, then this is a blocking feature-level prerequisite; otherwise it applies to the current rule only. + /// + public IList ParentConditions { get; set; } + /// /// Adds the experiment to a namespace. /// @@ -63,6 +68,11 @@ public class Experiment /// public string HashAttribute { get; set; } = "id"; + /// + /// When using sticky bucketing, can be used as a fallback to assign variations. + /// + public string FallbackAttribute { get; set; } + /// /// The hash version to use (defaults to 1). /// @@ -93,6 +103,31 @@ public class Experiment /// public string Phase { get; set; } + /// + /// If true, sticky bucketing will be disabled for this experiment. (Note: sticky bucketing is only available if a StickyBucketingService is provided in the Context). + /// + public bool DisableStickyBucketing { get; set; } + + /// + /// A sticky bucket version number that can be used to force a re-bucketing of users (default to 0). + /// + public int BucketVersion { get; set; } = 0; + + /// + /// Any users with a sticky bucket version less than this will be excluded from the experiment. + /// + public int MinBucketVersion { get; set; } = 0; + + /// + /// Any URL patterns associated with this experiment. + /// + public IList UrlPatterns { get; set; } + + /// + /// Determines whether to persist the query string. + /// + public bool PersistQueryString { get; set; } + /// /// Returns the experiment variations cast to the specified type. /// diff --git a/GrowthBook/ExperimentResult.cs b/GrowthBook/ExperimentResult.cs index 8d9b715..29bd927 100644 --- a/GrowthBook/ExperimentResult.cs +++ b/GrowthBook/ExperimentResult.cs @@ -66,6 +66,11 @@ public class ExperimentResult /// public bool Passthrough { get; set; } + /// + /// If sticky bucketing was used to assign a variation. + /// + public bool StickyBucketUsed { get; set; } + /// /// Returns the value of the assigned variation cast to the specified type. /// diff --git a/GrowthBook/Extensions/JsonExtensions.cs b/GrowthBook/Extensions/JsonExtensions.cs index 972491f..d90ebbf 100644 --- a/GrowthBook/Extensions/JsonExtensions.cs +++ b/GrowthBook/Extensions/JsonExtensions.cs @@ -35,18 +35,24 @@ internal static class JsonExtensions /// The JSON object to look up the key from. /// The key of the attribute value in the JSON object. Defaults to "id" when not provided. /// The value associated with the requested attribute, or null if the value is null or . - public static string GetHashAttributeValue(this JObject json, string attributeKey = null) + public static (string Attribute, string Value) GetHashAttributeAndValue(this JObject json, string attributeKey = null, string fallbackAttributeKey = null) { var attribute = attributeKey ?? "id"; var attributeValue = json[attribute]; - if (attributeValue.IsNull()) + if (attributeValue.IsNull() && fallbackAttributeKey != null) { - return null; + return (fallbackAttributeKey, json[fallbackAttributeKey]?.ToString()); } - return attributeValue.ToString(); + return (attribute, attributeValue?.ToString()); } + + public static JArray AsArray(this JToken token) => (JArray)token; + + public static JArray AsArray(this JProperty property) => (JArray)property.Value; + + public static JObject AsObject(this JProperty property) => (JObject)property.Value; } } diff --git a/GrowthBook/Extensions/StickyAssignmentExtensions.cs b/GrowthBook/Extensions/StickyAssignmentExtensions.cs new file mode 100644 index 0000000..e85f347 --- /dev/null +++ b/GrowthBook/Extensions/StickyAssignmentExtensions.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GrowthBook.Extensions +{ + public static class StickyAssignmentExtensions + { + public static IDictionary MergeWith(this IDictionary mergedData, IDictionary additionalData) + { + foreach (var pair in additionalData) + { + mergedData[pair.Key] = pair.Value; + } + + return mergedData; + } + + public static IDictionary MergeWith(this IDictionary mergedData, IEnumerable> additionalData) + { + foreach(var dictionary in additionalData) + { + mergedData = mergedData.MergeWith(dictionary); + } + + return mergedData; + } + } +} diff --git a/GrowthBook/FeatureResult.cs b/GrowthBook/FeatureResult.cs index 8fd5536..8515b41 100644 --- a/GrowthBook/FeatureResult.cs +++ b/GrowthBook/FeatureResult.cs @@ -18,6 +18,8 @@ public static class SourceId public const string DefaultValue = "defaultValue"; public const string Force = "force"; public const string Experiment = "experiment"; + public const string CyclicPrerequisite = "cyclicPrerequisite"; + public const string Prerequisite = "prerequisite"; } /// @@ -48,7 +50,7 @@ public bool On public bool Off { get { return !On; } } /// - /// One of "unknownFeature", "defaultValue", "force", or "experiment". + /// One of "unknownFeature", "defaultValue", "force", "experiment", or "cyclicPrerequisite". /// public string Source { get; set; } diff --git a/GrowthBook/FeatureRule.cs b/GrowthBook/FeatureRule.cs index 51099b8..b505017 100644 --- a/GrowthBook/FeatureRule.cs +++ b/GrowthBook/FeatureRule.cs @@ -12,11 +12,21 @@ namespace GrowthBook [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] public class FeatureRule { + /// + /// Optional rule id, reserved for future use. + /// + public string Id { get; set; } + /// /// Optional targeting condition. /// public JObject Condition { get; set; } + /// + /// Each item defines a prerequisite where a condition must evaluate against a parent feature's value (identified by id). If gate is true, then this is a blocking feature-level prerequisite; otherwise it applies to the current rule only. + /// + public IList ParentConditions { get; set; } + /// /// What percent of users should be included in the experiment (between 0 and 1, inclusive). /// @@ -52,6 +62,11 @@ public class FeatureRule /// public string HashAttribute { get; set; } = "id"; + /// + /// When using sticky bucketing, can be used as a fallback to assign variations. + /// + public string FallbackAttribute { get; set; } + /// /// The hash version to use (defaults to 1). /// @@ -62,6 +77,21 @@ public class FeatureRule /// public BucketRange Range { get; set; } + /// + /// If true, sticky bucketing will be disabled for this experiment. (Note: sticky bucketing is only available if a StickyBucketingService is provided in the Context). + /// + public bool DisableStickyBucketing { get; set; } + + /// + /// A sticky bucket version number that can be used to force a re-bucketing of users (default to 0). + /// + public int BucketVersion { get; set; } + + /// + /// Any users with a sticky bucket version less than this will be excluded from the experiment. + /// + public int MinBucketVersion { get; set; } + /// /// Ranges for experiment variations. /// diff --git a/GrowthBook/GrowthBook.cs b/GrowthBook/GrowthBook.cs index 68db10b..75ca6b9 100644 --- a/GrowthBook/GrowthBook.cs +++ b/GrowthBook/GrowthBook.cs @@ -8,6 +8,7 @@ using GrowthBook.Api; using GrowthBook.Extensions; using GrowthBook.Providers; +using GrowthBook.Services; using GrowthBook.Utilities; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -30,7 +31,10 @@ public class GrowthBook : IGrowthBook, IDisposable private bool _disposedValue; private readonly IConditionEvaluationProvider _conditionEvaluator; private readonly IGrowthBookFeatureRepository _featureRepository; + private readonly IStickyBucketService _stickyBucketService; + private readonly IDictionary _stickyBucketAssignmentDocs; private readonly ILogger _logger; + private readonly JObject _savedGroups; /// /// Creates a new GrowthBook instance from the passed context. @@ -49,6 +53,9 @@ public GrowthBook(Context context) _tracked = new HashSet(); _assigned = new Dictionary(); _subscriptions = new List>(); + _stickyBucketService = context.StickyBucketService; + _stickyBucketAssignmentDocs = context.StickyBucketAssignmentDocs ?? new Dictionary(); + _savedGroups = context.SavedGroups; var config = new GrowthBookConfigurationOptions { @@ -219,10 +226,19 @@ public async Task EvalFeatureAsync(string featureId, Cancellation return EvaluateFeature(featureId); } - private FeatureResult EvaluateFeature(string featureId) + private FeatureResult EvaluateFeature(string featureId, ISet evaluatedFeatures = default) { try { + evaluatedFeatures = evaluatedFeatures ?? new HashSet(); + + if (evaluatedFeatures.Contains(featureId)) + { + return GetFeatureResult(default, FeatureResult.SourceId.CyclicPrerequisite); + } + + evaluatedFeatures.Add(featureId); + if (!Features.TryGetValue(featureId, out Feature feature)) { return GetFeatureResult(null, FeatureResult.SourceId.UnknownFeature); @@ -230,12 +246,51 @@ private FeatureResult EvaluateFeature(string featureId) foreach (FeatureRule rule in feature?.Rules ?? Enumerable.Empty()) { - if (!rule.Condition.IsNull() && !_conditionEvaluator.EvalCondition(Attributes, rule.Condition)) + if (rule.ParentConditions != null) + { + var passedPrerequisiteEvaluations = true; + + foreach (var parentCondition in rule.ParentConditions) + { + var parentResult = EvaluateFeature(parentCondition.Id, evaluatedFeatures); + + // Don't continue evaluating if the prerequisite conditions have cycles. + + if (parentResult.Source == FeatureResult.SourceId.CyclicPrerequisite) + { + return GetFeatureResult(default, FeatureResult.SourceId.CyclicPrerequisite); + } + + var evaluationObject = new JObject { ["value"] = parentResult.Value }; + + var isSuccess = _conditionEvaluator.EvalCondition(evaluationObject, parentCondition.Condition ?? new JObject(), _savedGroups); + + if (!isSuccess) + { + // When the parent evaluation is gated we'll treat that as a complete failure. + + if (parentCondition.Gate) + { + return GetFeatureResult(default, FeatureResult.SourceId.Prerequisite); + } + + passedPrerequisiteEvaluations = false; + break; + } + } + + if (!passedPrerequisiteEvaluations) + { + continue; + } + } + + if (rule.Filters?.Any() == true && IsFilteredOut(rule.Filters)) { continue; } - if (rule.Filters?.Any() == true && IsFilteredOut(rule.Filters)) + if (!rule.Condition.IsNull() && !_conditionEvaluator.EvalCondition(Attributes, rule.Condition, _savedGroups)) { continue; } @@ -272,6 +327,10 @@ private FeatureResult EvaluateFeature(string featureId) Coverage = rule.Coverage, Weights = rule.Weights, HashAttribute = rule.HashAttribute, + FallbackAttribute = rule.FallbackAttribute, + DisableStickyBucketing = rule.DisableStickyBucketing, + BucketVersion = rule.BucketVersion, + MinBucketVersion = rule.MinBucketVersion, Namespace = rule.Namespace, Meta = rule.Meta, Ranges = rule.Ranges, @@ -279,7 +338,8 @@ private FeatureResult EvaluateFeature(string featureId) Phase = rule.Phase, Seed = rule.Seed, Filters = rule.Filters, - HashVersion = rule.HashVersion + HashVersion = rule.HashVersion, + Condition = rule.Condition }; var result = RunExperiment(experiment, featureId); @@ -416,54 +476,132 @@ private ExperimentResult RunExperiment(Experiment experiment, string featureId) // 6. Abort if we're unable to generate a hash identifying this run. - var hashValue = Attributes.GetHashAttributeValue(experiment.HashAttribute); + (var hashAttribute, var hashValue) = Attributes.GetHashAttributeAndValue(experiment.HashAttribute); if (hashValue.IsNullOrWhitespace()) { - _logger.LogDebug("Aborting experiment, unable to locate a value for the experiment hash attribute \'{ExperimentHashAttribute}\'", experiment.HashAttribute); - return GetExperimentResult(experiment, featureId: featureId); + // Check if a fallback attribute for sticky bucketing exists and use it if possible. + + var hasFallback = !experiment.FallbackAttribute.IsNullOrWhitespace(); + + if (hasFallback) + { + (_, hashValue) = Attributes.GetHashAttributeAndValue(experiment.FallbackAttribute); + } + else + { + _logger.LogDebug("Aborting experiment, unable to locate a value for the experiment hash attribute \'{ExperimentHashAttribute}\'", experiment.HashAttribute); + return GetExperimentResult(experiment, featureId: featureId); + } } - // 7. Abort if this run is ineligible to be included in the experiment. + // 6.5 When sticky bucketing is permitted, determine if they already have a value and use it if possible. + + var assignedBucket = -1; + var foundStickyBucket = false; + var stickyBucketVersionIsBlocked = false; - if (experiment.Filters?.Any() == true) + if (_stickyBucketService != null && !experiment.DisableStickyBucketing) { - if (IsFilteredOut(experiment.Filters)) + var bucketVersion = experiment.BucketVersion; + var minBucketVersion = experiment.MinBucketVersion; + var meta = experiment.Meta ?? new List(); + + var stickyBucketVariation = ExperimentUtilities.GetStickyBucketVariation( + experiment, + bucketVersion, + minBucketVersion, + meta, + Attributes, + _stickyBucketAssignmentDocs + ); + + foundStickyBucket = stickyBucketVariation.VariationIndex >= 0; + assignedBucket = stickyBucketVariation.VariationIndex; + stickyBucketVersionIsBlocked = stickyBucketVariation.IsVersionBlocked; + } + + if (!foundStickyBucket) + { + // 7. Abort if this run is ineligible to be included in the experiment. + + if (experiment.Filters?.Any() == true) { - _logger.LogDebug("Aborting experiment, filters have been applied and matched this run"); + if (IsFilteredOut(experiment.Filters)) + { + _logger.LogDebug("Aborting experiment, filters have been applied and matched this run"); + return GetExperimentResult(experiment, featureId: featureId); + } + } + else if (experiment.Namespace != null && !ExperimentUtilities.InNamespace(hashValue, experiment.Namespace)) + { + _logger.LogDebug("Aborting experiment, not within the specified namespace \'{ExperimentNamespace}\'", experiment.Namespace); return GetExperimentResult(experiment, featureId: featureId); } + + // 8. Abort if the conditions for the experiment prohibit this. + + if (!experiment.Condition.IsNull()) + { + if (!_conditionEvaluator.EvalCondition(Attributes, experiment.Condition, _savedGroups)) + { + _logger.LogDebug("Aborting experiment, associated conditions have prohibited participation"); + return GetExperimentResult(experiment, featureId: featureId); + } + + if (experiment.ParentConditions != null) + { + foreach (var parentCondition in experiment.ParentConditions) + { + var parentResult = EvaluateFeature(featureId); + + if (parentResult.Source == FeatureResult.SourceId.CyclicPrerequisite) + { + return GetExperimentResult(experiment, featureId: featureId); + } + + var evaluationObject = new JObject { ["value"] = parentResult.Value }; + + if (!_conditionEvaluator.EvalCondition(evaluationObject, parentCondition.Condition ?? new JObject(), _savedGroups)) + { + return GetExperimentResult(experiment, featureId: featureId); + } + } + } + } } - else if (experiment.Namespace != null && !ExperimentUtilities.InNamespace(hashValue, experiment.Namespace)) + + // 9. Attempt to assign this run to an experiment variation. + + var hash = HashUtilities.Hash(experiment.Seed ?? experiment.Key, hashValue, experiment.HashVersion); + + if (hash is null) { - _logger.LogDebug("Aborting experiment, not within the specified namespace \'{ExperimentNamespace}\'", experiment.Namespace); return GetExperimentResult(experiment, featureId: featureId); } - // 8. Abort if the conditions for the experiment prohibit this. - - if (!experiment.Condition.IsNull()) + if (!foundStickyBucket) { - if (!_conditionEvaluator.EvalCondition(Attributes, experiment.Condition)) + var ranges = experiment.Ranges?.Count > 0 ? experiment.Ranges : ExperimentUtilities.GetBucketRanges(experiment.Variations?.Count ?? 0, experiment.Coverage ?? 1, experiment.Weights ?? new List()); + assignedBucket = ExperimentUtilities.ChooseVariation(hash.Value, ranges.ToList()); + + // 10. Abort if a variation could not be assigned. + + if (assignedBucket == -1) { - _logger.LogDebug("Aborting experiment, associated conditions have prohibited participation"); + _logger.LogDebug("Aborting experiment, unable to assign this run to an experiment variation"); return GetExperimentResult(experiment, featureId: featureId); } } - // 9. Attempt to assign this run to an experiment variation and abort if that can't be done. - - var ranges = experiment.Ranges?.Count > 0 ? experiment.Ranges : ExperimentUtilities.GetBucketRanges(experiment.Variations?.Count ?? 0, experiment.Coverage ?? 1, experiment.Weights ?? new List()); - var variationHash = HashUtilities.Hash(experiment.Seed ?? experiment.Key, hashValue, experiment.HashVersion); - var assigned = ExperimentUtilities.ChooseVariation(variationHash.Value, ranges.ToList()); + // 9.5 Unenroll if any prior sticky buckets are blocked by version. - if (assigned == -1) + if (stickyBucketVersionIsBlocked) { - _logger.LogDebug("Aborting experiment, unable to assign this run to an experiment variation"); - return GetExperimentResult(experiment, featureId: featureId); + return GetExperimentResult(experiment, featureId: featureId, wasStickyBucketUsed: true); } - // 10. Use the forced value for the experiment if one is specified. + // 11. Use the forced value for the experiment if one is specified. if (experiment.Force != null) { @@ -471,7 +609,7 @@ private ExperimentResult RunExperiment(Experiment experiment, string featureId) return GetExperimentResult(experiment, experiment.Force.Value, featureId: featureId); } - // 11. Abort if we're currently operating in QA mode. + // 12. Abort if we're currently operating in QA mode. if (_qaMode) { @@ -479,10 +617,29 @@ private ExperimentResult RunExperiment(Experiment experiment, string featureId) return GetExperimentResult(experiment, featureId: featureId); } - // 12. Run the experiment and track the result if we haven't seen this one before. + // 13. Run the experiment and track the result if we haven't seen this one before. _logger.LogInformation("Participation in experiment with key \'{ExperimentKey}\' is allowed, running the experiment", experiment.Key); - var result = GetExperimentResult(experiment, assigned, true, featureId, variationHash); + var result = GetExperimentResult(experiment, assignedBucket, true, featureId, hash, foundStickyBucket); + + // 13.5 Store the value for later if sticky bucketing is enabled. + + if (_stickyBucketService != null && !experiment.DisableStickyBucketing) + { + var experimentKey = ExperimentUtilities.GetStickyBucketExperimentKey(experiment.Key, experiment.BucketVersion); + + var assignments = new Dictionary + { + [experimentKey] = result.Key + }; + + (var document, var isChanged) = ExperimentUtilities.GenerateStickyBucketAssignment(_stickyBucketService, hashAttribute, hashValue, assignments); + + if (isChanged) + { + _stickyBucketService.SaveAssignments(document); + } + } TryToTrack(experiment, result); @@ -504,7 +661,7 @@ private bool IsFilteredOut(IEnumerable filters) { foreach(var filter in filters) { - var hashValue = Attributes.GetHashAttributeValue(filter.Attribute); + (_, var hashValue) = Attributes.GetHashAttributeAndValue(filter.Attribute); if (hashValue.IsNullOrWhitespace()) { @@ -534,7 +691,13 @@ private bool IsIncludedInRollout(string seed, string hashAttribute = null, Bucke return true; } - var hashValue = Attributes.GetHashAttributeValue(hashAttribute); + if (range is null && coverage == 0) + { + _logger.LogDebug("Range and coverage were not set, marking as not included in rollout"); + return false; + } + + (_, var hashValue) = Attributes.GetHashAttributeAndValue(hashAttribute); if (hashValue is null) { @@ -564,10 +727,8 @@ private bool IsIncludedInRollout(string seed, string hashAttribute = null, Bucke /// The variation id, if specified. /// Whether or not a hash was used in assignment. /// The experiment result. - private ExperimentResult GetExperimentResult(Experiment experiment, int variationIndex = -1, bool hashUsed = false, string featureId = null, double? bucket = null) + private ExperimentResult GetExperimentResult(Experiment experiment, int variationIndex = -1, bool hashUsed = false, string featureId = null, double? bucketHash = null, bool wasStickyBucketUsed = false) { - string hashAttribute = experiment.HashAttribute ?? "id"; - var inExperiment = true; if (variationIndex < 0 || variationIndex >= experiment.Variations.Count) @@ -576,7 +737,11 @@ private ExperimentResult GetExperimentResult(Experiment experiment, int variatio inExperiment = false; } - var hashValue = Attributes.GetHashAttributeValue(hashAttribute); + var canUseStickyBucketing = _stickyBucketService != null && !experiment.DisableStickyBucketing; + var fallbackAttribute = canUseStickyBucketing ? experiment.FallbackAttribute : default; + + (var hashAttribute, var hashValue) = Attributes.GetHashAttributeAndValue(experiment.HashAttribute, fallbackAttributeKey: fallbackAttribute); + var meta = experiment.Meta?.Count > 0 ? experiment.Meta[variationIndex] : null; var result = new ExperimentResult @@ -593,7 +758,7 @@ private ExperimentResult GetExperimentResult(Experiment experiment, int variatio result.Name = meta?.Name; result.Passthrough = meta?.Passthrough ?? false; - result.Bucket = bucket ?? 0f; + result.Bucket = bucketHash ?? 0d; return result; } diff --git a/GrowthBook/ParentCondition.cs b/GrowthBook/ParentCondition.cs new file mode 100644 index 0000000..c992436 --- /dev/null +++ b/GrowthBook/ParentCondition.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Newtonsoft.Json.Linq; + +namespace GrowthBook +{ + /// + /// Represents a prerequisite for a condition. + /// + public class ParentCondition + { + /// + /// The feature ID. + /// + public string Id { get; set; } + + /// + /// The condition to evaluate. + /// + public JObject Condition { get; set; } + + /// + /// Requires that the parent feature must be set to on if true. + /// + public bool Gate { get; set; } + } +} diff --git a/GrowthBook/Providers/ConditionEvaluationProvider.cs b/GrowthBook/Providers/ConditionEvaluationProvider.cs index f4af7d0..fa9a397 100644 --- a/GrowthBook/Providers/ConditionEvaluationProvider.cs +++ b/GrowthBook/Providers/ConditionEvaluationProvider.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; +using System.Xml.Linq; using GrowthBook.Extensions; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; @@ -17,35 +18,45 @@ internal sealed class ConditionEvaluationProvider : IConditionEvaluationProvider public ConditionEvaluationProvider(ILogger logger) => _logger = logger; /// - public bool EvalCondition(JToken attributes, JObject condition) + public bool EvalCondition(JToken attributes, JObject condition, JObject savedGroups = default) { _logger.LogInformation("Beginning to evaluate attributes based on the provided JSON condition"); _logger.LogDebug("Attribute evaluation is based on the JSON condition \'{Condition}\'", condition); - if (condition.ContainsKey("$or")) + foreach (var innerCondition in condition.Properties()) { - return EvalOr(attributes, (JArray)condition["$or"]); - } - if (condition.ContainsKey("$nor")) - { - return !EvalOr(attributes, (JArray)condition["$nor"]); - } - if (condition.ContainsKey("$and")) - { - return EvalAnd(attributes, (JArray)condition["$and"]); - } - if (condition.ContainsKey("$not")) - { - return !EvalCondition(attributes, (JObject)condition["$not"]); - } - - _logger.LogDebug("No overarching condition found, evaluating condition values separately"); - - foreach (JProperty property in condition.Properties()) - { - if (!EvalConditionValue(property.Value, GetPath(attributes, property.Name))) + switch(innerCondition.Name) { - return false; + case "$or": + if (!EvalOr(attributes, innerCondition.AsArray(), savedGroups)) + { + return false; + } + break; + case "$nor": + if (EvalOr(attributes, innerCondition.AsArray(), savedGroups)) + { + return false; + } + break; + case "$and": + if (!EvalAnd(attributes, innerCondition.AsArray(), savedGroups)) + { + return false; + } + break; + case "$not": + if (EvalCondition(attributes, innerCondition.AsObject(), savedGroups)) + { + return false; + } + break; + default: + if (!EvalConditionValue(innerCondition.Value, GetPath(attributes, innerCondition.Name), savedGroups)) + { + return false; + } + break; } } @@ -58,7 +69,7 @@ public bool EvalCondition(JToken attributes, JObject condition) /// The attributes to compare against. /// The condition to evaluate. /// True if the attributes satisfy any of the conditions. - private bool EvalOr(JToken attributes, JArray conditions) + private bool EvalOr(JToken attributes, JArray conditions, JObject savedGroups) { if (conditions.Count == 0) { @@ -85,7 +96,7 @@ private bool EvalOr(JToken attributes, JArray conditions) /// The attributes to compare against. /// The condition to evaluate. /// True if the attributes satisfy all of the conditions. - private bool EvalAnd(JToken attributes, JArray conditions) + private bool EvalAnd(JToken attributes, JArray conditions, JObject savedGroups) { _logger.LogDebug("Evaluating all conditions within an 'and' context"); @@ -106,7 +117,7 @@ private bool EvalAnd(JToken attributes, JArray conditions) /// The condition value to check. /// The attribute value to check. /// True if the condition value matches the attribute value. - private bool EvalConditionValue(JToken conditionValue, JToken attributeValue) + private bool EvalConditionValue(JToken conditionValue, JToken attributeValue, JObject savedGroups) { _logger.LogDebug("Evaluating condition value \'{ConditionValue}\'", conditionValue); @@ -120,7 +131,7 @@ private bool EvalConditionValue(JToken conditionValue, JToken attributeValue) foreach (JProperty property in conditionObj.Properties()) { - if (!EvalOperatorCondition(property.Name, attributeValue, property.Value)) + if (!EvalOperatorCondition(property.Name, attributeValue, property.Value, savedGroups)) { return false; } @@ -139,7 +150,7 @@ private bool EvalConditionValue(JToken conditionValue, JToken attributeValue) /// The condition to check. /// The attribute value to check. /// True if attributeValue is an array and at least one of the array items matches the condition. - private bool ElemMatch(JObject condition, JToken attributeValue) + private bool ElemMatch(JObject condition, JToken attributeValue, JObject savedGroups) { if (attributeValue?.Type != JTokenType.Array) { @@ -149,7 +160,7 @@ private bool ElemMatch(JObject condition, JToken attributeValue) foreach (JToken elem in (JArray)attributeValue) { - if (IsOperatorObject(condition) && EvalConditionValue(condition, elem)) + if (IsOperatorObject(condition) && EvalConditionValue(condition, elem, savedGroups)) { return true; } @@ -170,7 +181,7 @@ private bool ElemMatch(JObject condition, JToken attributeValue) /// The attribute value to check. /// The condition value to check. /// - private bool EvalOperatorCondition(string op, JToken attributeValue, JToken conditionValue) + private bool EvalOperatorCondition(string op, JToken attributeValue, JToken conditionValue, JObject savedGroups) { _logger.LogDebug("Evaluating operator condition \'{Op}\'", op); @@ -219,7 +230,7 @@ private bool EvalOperatorCondition(string op, JToken attributeValue, JToken cond { return false; } - return IsIn(conditionValue, attributeValue); + return IsIn(conditionValue, attributeValue, savedGroups); } if (op == "$nin") { @@ -227,7 +238,7 @@ private bool EvalOperatorCondition(string op, JToken attributeValue, JToken cond { return false; } - return !IsIn(conditionValue, attributeValue); + return !IsIn(conditionValue, attributeValue, savedGroups); } if (op == "$all") { @@ -245,7 +256,7 @@ private bool EvalOperatorCondition(string op, JToken attributeValue, JToken cond foreach (JToken condition in conditionList) { - if (!attributeList.Any(x => EvalConditionValue(condition, x))) + if (!attributeList.Any(x => EvalConditionValue(condition, x, savedGroups))) { return false; } @@ -256,7 +267,7 @@ private bool EvalOperatorCondition(string op, JToken attributeValue, JToken cond if (op == "$elemMatch") { - return ElemMatch((JObject)conditionValue, attributeValue); + return ElemMatch((JObject)conditionValue, attributeValue, savedGroups); } if (op == "$size") { @@ -265,7 +276,7 @@ private bool EvalOperatorCondition(string op, JToken attributeValue, JToken cond return false; } - return EvalConditionValue(conditionValue, ((JArray)attributeValue).Count); + return EvalConditionValue(conditionValue, ((JArray)attributeValue).Count, savedGroups); } if (op == "$exists") { @@ -284,7 +295,7 @@ private bool EvalOperatorCondition(string op, JToken attributeValue, JToken cond } if (op == "$not") { - return !EvalConditionValue(conditionValue, attributeValue); + return !EvalConditionValue(conditionValue, attributeValue, savedGroups); } if (op == "$veq") { @@ -310,6 +321,24 @@ private bool EvalOperatorCondition(string op, JToken attributeValue, JToken cond { return CompareVersions(attributeValue, conditionValue, x => x >= 0); } + if (op == "$inGroup") + { + if (attributeValue != null && conditionValue != null) + { + var array = savedGroups[conditionValue.ToString()]?.AsArray() ?? new JArray(); + + return IsIn(array, attributeValue, savedGroups); + } + } + if (op == "$notInGroup") + { + if (attributeValue != null && conditionValue != null) + { + var array = savedGroups[conditionValue.ToString()]?.AsArray() ?? new JArray(); + + return !IsIn(array, attributeValue, savedGroups); + } + } _logger.LogWarning("Unable to handle unsupported operator condition \'{Op}\', failing the condition", op); @@ -362,7 +391,7 @@ private bool IsOperatorObject(JObject obj) return true; } - private bool IsIn(JToken conditionValue, JToken actualValue) + private bool IsIn(JToken conditionValue, JToken actualValue, JObject savedGroups) { if (actualValue?.Type == JTokenType.Array) { @@ -375,6 +404,10 @@ private bool IsIn(JToken conditionValue, JToken actualValue) return conditionValues.Any(); } + else if (conditionValue is JArray array) + { + return array.Any(x => x.Equals(actualValue)); + } else { _logger.LogDebug("Evaluating whether the specified value is equal to or contained within the actual value"); diff --git a/GrowthBook/Providers/IConditionEvaluationProvider.cs b/GrowthBook/Providers/IConditionEvaluationProvider.cs index 590c9b3..0d698ee 100644 --- a/GrowthBook/Providers/IConditionEvaluationProvider.cs +++ b/GrowthBook/Providers/IConditionEvaluationProvider.cs @@ -16,6 +16,6 @@ public interface IConditionEvaluationProvider /// The attributes to compare against. /// The condition to evaluate. /// True if the attributes satisfy the condition. - bool EvalCondition(JToken attributes, JObject condition); + bool EvalCondition(JToken attributes, JObject condition, JObject savedGroups = default); } } diff --git a/GrowthBook/Services/IStickyBucketService.cs b/GrowthBook/Services/IStickyBucketService.cs new file mode 100644 index 0000000..99c3284 --- /dev/null +++ b/GrowthBook/Services/IStickyBucketService.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GrowthBook.Services +{ + public interface IStickyBucketService + { + StickyAssignmentsDocument GetAssignments(string attributeName, string attributeValue); + void SaveAssignments(StickyAssignmentsDocument document); + IDictionary GetAllAssignments(IDictionary attributes); + } +} diff --git a/GrowthBook/Services/InMemoryStickyBucketService.cs b/GrowthBook/Services/InMemoryStickyBucketService.cs new file mode 100644 index 0000000..aa4402d --- /dev/null +++ b/GrowthBook/Services/InMemoryStickyBucketService.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace GrowthBook.Services +{ + public class InMemoryStickyBucketService : IStickyBucketService + { + private readonly IDictionary _cachedDocuments = new Dictionary(); + + public IDictionary GetAllAssignments(IDictionary attributes) + { + var assignments = from pair in attributes + let existingDoc = _cachedDocuments.TryGetValue(pair.Key, out var doc) ? doc : null + where existingDoc != null + select (Attribute: existingDoc.FormattedAttribute, Document: existingDoc); + + return assignments.ToDictionary(x => x.Attribute, x => x.Document); + } + + public StickyAssignmentsDocument GetAssignments(string attributeName, string attributeValue) + { + var attribute = FormatAttribute(attributeName, attributeValue); + + return _cachedDocuments.TryGetValue(attribute, out var document) ? document : null; + } + + public void SaveAssignments(StickyAssignmentsDocument document) => _cachedDocuments[document.FormattedAttribute] = document; + + private static string FormatAttribute(string attributeName, string attributeValue) => $"{attributeName}||{attributeValue}"; + } +} diff --git a/GrowthBook/StickyAssignmentsDocument.cs b/GrowthBook/StickyAssignmentsDocument.cs new file mode 100644 index 0000000..036e155 --- /dev/null +++ b/GrowthBook/StickyAssignmentsDocument.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GrowthBook +{ + public class StickyAssignmentsDocument + { + public bool HasValue => AttributeValue != null; + + public string FormattedAttribute => $"{AttributeName}||{AttributeValue}"; + + public string AttributeName { get; set; } + public string AttributeValue { get; set; } + public IDictionary StickyAssignments { get; set; } + + public StickyAssignmentsDocument() { } + + public StickyAssignmentsDocument(string attributeName, string attributeValue, IDictionary stickyAssignments = default) + { + AttributeName = attributeName; + AttributeValue = attributeValue; + StickyAssignments = stickyAssignments ?? new Dictionary(); + } + } +} diff --git a/GrowthBook/StickyBucketVariation.cs b/GrowthBook/StickyBucketVariation.cs new file mode 100644 index 0000000..eb21ced --- /dev/null +++ b/GrowthBook/StickyBucketVariation.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GrowthBook +{ + public class StickyBucketVariation + { + public int VariationIndex { get; set; } + public bool IsVersionBlocked { get; set; } + + public StickyBucketVariation(int variationIndex, bool isVersionBlocked) + { + VariationIndex = variationIndex; + IsVersionBlocked = isVersionBlocked; + } + } +} diff --git a/GrowthBook/UrlPattern.cs b/GrowthBook/UrlPattern.cs new file mode 100644 index 0000000..d411b05 --- /dev/null +++ b/GrowthBook/UrlPattern.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GrowthBook +{ + public class UrlPattern + { + public string Type { get; set; } + public bool Include { get; set; } + public string Pattern { get; set; } + } +} diff --git a/GrowthBook/Utilities/ExperimentUtilities.cs b/GrowthBook/Utilities/ExperimentUtilities.cs index f1ca3c7..b7659c8 100644 --- a/GrowthBook/Utilities/ExperimentUtilities.cs +++ b/GrowthBook/Utilities/ExperimentUtilities.cs @@ -3,8 +3,12 @@ using System.Collections.Specialized; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Web; using GrowthBook.Extensions; +using GrowthBook.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace GrowthBook.Utilities { @@ -148,5 +152,191 @@ public static int ChooseVariation(double n, IList ranges) /// The bucket range. /// True if the value is in the range, false otherwise. public static bool InRange(double number, BucketRange range) => number >= range.Start && number < range.End; + + public static bool IsUrlTargeted(string url, IEnumerable urlPatterns) + { + var allPatterns = urlPatterns.ToArray(); + + if (!allPatterns.Any()) + { + return false; + } + + var hasIncludeRules = false; + var isIncluded = false; + + foreach (var pattern in allPatterns) + { + var isMatch = EvaluateUrlTarget(url, pattern); + + if (!pattern.Include) + { + if (isMatch) + { + return false; + } + } + else + { + hasIncludeRules = true; + + if (isMatch) + { + return isIncluded; + } + } + } + + return isIncluded || !hasIncludeRules; + } + + private static bool EvaluateUrlTarget(string url, UrlPattern pattern) + { + var parsed = new Uri(url); + + if (pattern.Type == "regex") + { + var regex = GetUrlRegex(pattern); + + if (regex is null) + { + return false; + } + + return + regex.IsMatch(parsed.AbsolutePath) || + regex.IsMatch(parsed.AbsolutePath.Substring(parsed.Host.Length)); + } + else if (pattern.Type == "simple") + { + return EvaluateSimpleUrlTarget(parsed, pattern); + } + + return false; + } + + private static bool EvaluateSimpleUrlTarget(Uri actual, UrlPattern pattern) + { + // If a protocol is missing, but a host is specified, add `https://` to the front + // Use "_____" as the wildcard since `*` is not a valid hostname in some browsers + + var expected = Regex.Replace(pattern.Pattern, "^([^:/?]*)\\.", "https://$1."); + expected = Regex.Replace(expected, "/*", "_____"); + var expectedUri = new Uri($"https://{expected}"); + + // Compare each part of the URL separately + + var comparisons = new[] { (actual.Host, expectedUri.Host, false), (actual.AbsolutePath, expectedUri.AbsolutePath, true) }; + + // We only want to compare hashes if it's explicitly being targeted + +#warning Check hash codes? +#warning Comparisons and finish implementation + + return false; + } + + private static Regex GetUrlRegex(UrlPattern pattern) + { + try + { + var escaped = Regex.Replace(pattern.Pattern, "([^\\\\])\\/", "$1\\/"); + + return new Regex(escaped); + } + catch + { + return default; + } + } + + public static (StickyAssignmentsDocument Document, bool IsChanged) GenerateStickyBucketAssignment(IStickyBucketService stickyBucketService, string attributeName, string attributeValue, IDictionary assignments) + { + var existingDocument = stickyBucketService is null ? new StickyAssignmentsDocument(attributeName, attributeValue) : stickyBucketService.GetAssignments(attributeName, attributeValue); + var newAssignments = new Dictionary(existingDocument.StickyAssignments); + + newAssignments.MergeWith(new[] { assignments }); + + var isChanged = JsonConvert.SerializeObject(existingDocument) != JsonConvert.SerializeObject(newAssignments); + + existingDocument.StickyAssignments = newAssignments; + + return (existingDocument, isChanged); + } + + public static StickyBucketVariation GetStickyBucketVariation(Experiment experiment, int bucketVersion, int minBucketVersion, IList meta, JObject attributes, IDictionary document) + { + var id = GetStickyBucketExperimentKey(experiment.Key, experiment.BucketVersion); + var assignments = GetStickyBucketAssignments(attributes, document, experiment.HashAttribute, experiment.FallbackAttribute); + + if (experiment.MinBucketVersion > 0) + { + for(var i = 0; i <= experiment.MinBucketVersion; i++) + { + var blockedKey = GetStickyBucketExperimentKey(experiment.Key, i); + + if (assignments.ContainsKey(blockedKey)) + { + return new StickyBucketVariation(-1, isVersionBlocked: true); + } + } + } + + if (!assignments.TryGetValue(id, out var variationKey)) + { + return new StickyBucketVariation(-1, isVersionBlocked: false); + } + + var variationIndex = FindVariationIndex(meta, variationKey); + + return new StickyBucketVariation(variationIndex, isVersionBlocked: false); + } + + private static IDictionary GetStickyBucketAssignments(JObject attributes, IDictionary stickyAssignmentDocs, string hashAttribute, string fallbackAttribute) + { + var mergedAssignments = new Dictionary(); + + if (stickyAssignmentDocs is null) + { + return mergedAssignments; + } + + (var hashAttributeWithoutFallback, var hashValueWithoutFallback) = attributes.GetHashAttributeAndValue(hashAttribute, default); + var hashKey = new StickyAssignmentsDocument(hashAttributeWithoutFallback, hashValueWithoutFallback); + + (var hashAttributeWithFallback, var hashValueWithFallback) = attributes.GetHashAttributeAndValue(default, fallbackAttribute); + var fallbackKey = new StickyAssignmentsDocument(hashAttributeWithFallback, hashValueWithFallback); + + var pendingAssignments = new List>(); + + // We're grabbing any fallback values first so that the original can override them if present as well. + + if (fallbackKey.HasValue && stickyAssignmentDocs.TryGetValue(fallbackKey.FormattedAttribute, out var fallbackDocument)) + { + pendingAssignments.Add(fallbackDocument.StickyAssignments); + } + + if (stickyAssignmentDocs.TryGetValue(hashKey.FormattedAttribute, out var document)) + { + pendingAssignments.Add(document.StickyAssignments); + } + + return mergedAssignments.MergeWith(pendingAssignments); + } + + private static int FindVariationIndex(IList meta, string key) + { + for(var i = 0; i < meta.Count; i++) + { + if (meta[i].Key == key) + { + return i; + } + } + + return -1; + } + + public static string GetStickyBucketExperimentKey(string key, int bucketVersion) => $"{key}__{bucketVersion}"; } } From 0abda2d8554133276b220640c45f324b78791b76 Mon Sep 17 00:00:00 2001 From: Chris Hannon Date: Sun, 15 Dec 2024 17:21:17 -0700 Subject: [PATCH 2/3] Fixed JSON tests for existing and sticky bucket --- .../GrowthBookTests/StickyBucketTests.cs | 40 ++++++++++++++++++- GrowthBook/GrowthBook.cs | 34 +++++++++------- GrowthBook/Services/IStickyBucketService.cs | 2 +- .../Services/InMemoryStickyBucketService.cs | 6 +-- GrowthBook/StickyAssignmentsDocument.cs | 4 +- GrowthBook/Utilities/ExperimentUtilities.cs | 13 +++--- 6 files changed, 69 insertions(+), 30 deletions(-) diff --git a/GrowthBook.Tests/StandardTests/GrowthBookTests/StickyBucketTests.cs b/GrowthBook.Tests/StandardTests/GrowthBookTests/StickyBucketTests.cs index 7b5dc00..56fc0d3 100644 --- a/GrowthBook.Tests/StandardTests/GrowthBookTests/StickyBucketTests.cs +++ b/GrowthBook.Tests/StandardTests/GrowthBookTests/StickyBucketTests.cs @@ -3,6 +3,9 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using FluentAssertions; +using GrowthBook.Services; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Xunit; @@ -24,14 +27,47 @@ public class StickyBucketTestCase [TestPropertyIndex(4)] public JToken ExpectedResult { get; set; } [TestPropertyIndex(5)] - public StickyAssignmentsDocument[] ExpectedAssignmentDocs { get; set; } = []; + public Dictionary ExpectedAssignmentDocs { get; set; } = []; } [Theory] [MemberData(nameof(GetMappedTestsInCategory), typeof(StickyBucketTestCase))] public void Run(StickyBucketTestCase testCase) { + var service = new InMemoryStickyBucketService(); + + testCase.Context.StickyBucketService = service; + testCase.Context.StickyBucketAssignmentDocs = testCase.PreExistingAssignmentDocs.ToDictionary(x => x.FormattedAttribute); + + // NOTE: Existing sticky bucket JSON tests in the JS SDK load this into the service up front + // but I wonder if that's correct because without that any assignment doc that exists + // other than those will not be stored and some of these test cases will fail. + + foreach (var document in testCase.PreExistingAssignmentDocs) + { + service.SaveAssignments(document); + } + var gb = new GrowthBook(testCase.Context); -#warning Complete this. + + var result = gb.EvalFeature(testCase.FeatureName); + + var actualResult = JToken.Parse(JsonConvert.SerializeObject(result.ExperimentResult)); + + if (testCase.ExpectedResult is JObject obj) + { + foreach (var property in obj.Properties()) + { + actualResult[property.Name].ToString().Should().Be(property.Value.ToString()); + } + } + else + { + actualResult.ToString().Should().Be(testCase.ExpectedResult.ToString()); + } + + var storedDocuments = service.GetAllAssignments(testCase.ExpectedAssignmentDocs.Keys); + + storedDocuments.Should().BeEquivalentTo(testCase.ExpectedAssignmentDocs, "because those should have been stored correctly"); } } diff --git a/GrowthBook/GrowthBook.cs b/GrowthBook/GrowthBook.cs index 75ca6b9..6ffc7c2 100644 --- a/GrowthBook/GrowthBook.cs +++ b/GrowthBook/GrowthBook.cs @@ -486,7 +486,7 @@ private ExperimentResult RunExperiment(Experiment experiment, string featureId) if (hasFallback) { - (_, hashValue) = Attributes.GetHashAttributeAndValue(experiment.FallbackAttribute); + (hashAttribute, hashValue) = Attributes.GetHashAttributeAndValue(experiment.FallbackAttribute); } else { @@ -548,24 +548,24 @@ private ExperimentResult RunExperiment(Experiment experiment, string featureId) _logger.LogDebug("Aborting experiment, associated conditions have prohibited participation"); return GetExperimentResult(experiment, featureId: featureId); } + } - if (experiment.ParentConditions != null) + if (experiment.ParentConditions != null) + { + foreach (var parentCondition in experiment.ParentConditions) { - foreach (var parentCondition in experiment.ParentConditions) - { - var parentResult = EvaluateFeature(featureId); + var parentResult = EvaluateFeature(parentCondition.Id); - if (parentResult.Source == FeatureResult.SourceId.CyclicPrerequisite) - { - return GetExperimentResult(experiment, featureId: featureId); - } + if (parentResult.Source == FeatureResult.SourceId.CyclicPrerequisite) + { + return GetExperimentResult(experiment, featureId: featureId); + } - var evaluationObject = new JObject { ["value"] = parentResult.Value }; + var evaluationObject = new JObject { ["value"] = parentResult.Value }; - if (!_conditionEvaluator.EvalCondition(evaluationObject, parentCondition.Condition ?? new JObject(), _savedGroups)) - { - return GetExperimentResult(experiment, featureId: featureId); - } + if (!_conditionEvaluator.EvalCondition(evaluationObject, parentCondition.Condition ?? new JObject(), _savedGroups)) + { + return GetExperimentResult(experiment, featureId: featureId); } } } @@ -753,7 +753,11 @@ private ExperimentResult GetExperimentResult(Experiment experiment, int variatio HashUsed = hashUsed, HashValue = hashValue, Value = experiment.Variations is null ? null : experiment.Variations[variationIndex], - VariationId = variationIndex + VariationId = variationIndex, + Name = meta?.Name, + Passthrough = meta?.Passthrough ?? false, + Bucket = bucketHash ?? 0d, + StickyBucketUsed = wasStickyBucketUsed }; result.Name = meta?.Name; diff --git a/GrowthBook/Services/IStickyBucketService.cs b/GrowthBook/Services/IStickyBucketService.cs index 99c3284..d3e3a7f 100644 --- a/GrowthBook/Services/IStickyBucketService.cs +++ b/GrowthBook/Services/IStickyBucketService.cs @@ -8,6 +8,6 @@ public interface IStickyBucketService { StickyAssignmentsDocument GetAssignments(string attributeName, string attributeValue); void SaveAssignments(StickyAssignmentsDocument document); - IDictionary GetAllAssignments(IDictionary attributes); + IDictionary GetAllAssignments(IEnumerable attributes); } } diff --git a/GrowthBook/Services/InMemoryStickyBucketService.cs b/GrowthBook/Services/InMemoryStickyBucketService.cs index aa4402d..aa199f1 100644 --- a/GrowthBook/Services/InMemoryStickyBucketService.cs +++ b/GrowthBook/Services/InMemoryStickyBucketService.cs @@ -9,10 +9,10 @@ public class InMemoryStickyBucketService : IStickyBucketService { private readonly IDictionary _cachedDocuments = new Dictionary(); - public IDictionary GetAllAssignments(IDictionary attributes) + public IDictionary GetAllAssignments(IEnumerable attributes) { - var assignments = from pair in attributes - let existingDoc = _cachedDocuments.TryGetValue(pair.Key, out var doc) ? doc : null + var assignments = from name in attributes + let existingDoc = _cachedDocuments.TryGetValue(name, out var doc) ? doc : null where existingDoc != null select (Attribute: existingDoc.FormattedAttribute, Document: existingDoc); diff --git a/GrowthBook/StickyAssignmentsDocument.cs b/GrowthBook/StickyAssignmentsDocument.cs index 036e155..adb9397 100644 --- a/GrowthBook/StickyAssignmentsDocument.cs +++ b/GrowthBook/StickyAssignmentsDocument.cs @@ -12,7 +12,7 @@ public class StickyAssignmentsDocument public string AttributeName { get; set; } public string AttributeValue { get; set; } - public IDictionary StickyAssignments { get; set; } + public IDictionary Assignments { get; set; } = new Dictionary(); public StickyAssignmentsDocument() { } @@ -20,7 +20,7 @@ public StickyAssignmentsDocument(string attributeName, string attributeValue, ID { AttributeName = attributeName; AttributeValue = attributeValue; - StickyAssignments = stickyAssignments ?? new Dictionary(); + Assignments = stickyAssignments ?? new Dictionary(); } } } diff --git a/GrowthBook/Utilities/ExperimentUtilities.cs b/GrowthBook/Utilities/ExperimentUtilities.cs index b7659c8..a678395 100644 --- a/GrowthBook/Utilities/ExperimentUtilities.cs +++ b/GrowthBook/Utilities/ExperimentUtilities.cs @@ -253,15 +253,14 @@ private static Regex GetUrlRegex(UrlPattern pattern) public static (StickyAssignmentsDocument Document, bool IsChanged) GenerateStickyBucketAssignment(IStickyBucketService stickyBucketService, string attributeName, string attributeValue, IDictionary assignments) { var existingDocument = stickyBucketService is null ? new StickyAssignmentsDocument(attributeName, attributeValue) : stickyBucketService.GetAssignments(attributeName, attributeValue); - var newAssignments = new Dictionary(existingDocument.StickyAssignments); + var newAssignments = new Dictionary(existingDocument?.Assignments ?? new Dictionary()); newAssignments.MergeWith(new[] { assignments }); var isChanged = JsonConvert.SerializeObject(existingDocument) != JsonConvert.SerializeObject(newAssignments); + var document = new StickyAssignmentsDocument(attributeName, attributeValue, newAssignments); - existingDocument.StickyAssignments = newAssignments; - - return (existingDocument, isChanged); + return (document, isChanged); } public static StickyBucketVariation GetStickyBucketVariation(Experiment experiment, int bucketVersion, int minBucketVersion, IList meta, JObject attributes, IDictionary document) @@ -304,7 +303,7 @@ private static IDictionary GetStickyBucketAssignments(JObject at (var hashAttributeWithoutFallback, var hashValueWithoutFallback) = attributes.GetHashAttributeAndValue(hashAttribute, default); var hashKey = new StickyAssignmentsDocument(hashAttributeWithoutFallback, hashValueWithoutFallback); - (var hashAttributeWithFallback, var hashValueWithFallback) = attributes.GetHashAttributeAndValue(default, fallbackAttribute); + (var hashAttributeWithFallback, var hashValueWithFallback) = attributes.GetHashAttributeAndValue(fallbackAttribute, default); var fallbackKey = new StickyAssignmentsDocument(hashAttributeWithFallback, hashValueWithFallback); var pendingAssignments = new List>(); @@ -313,12 +312,12 @@ private static IDictionary GetStickyBucketAssignments(JObject at if (fallbackKey.HasValue && stickyAssignmentDocs.TryGetValue(fallbackKey.FormattedAttribute, out var fallbackDocument)) { - pendingAssignments.Add(fallbackDocument.StickyAssignments); + pendingAssignments.Add(fallbackDocument.Assignments); } if (stickyAssignmentDocs.TryGetValue(hashKey.FormattedAttribute, out var document)) { - pendingAssignments.Add(document.StickyAssignments); + pendingAssignments.Add(document.Assignments); } return mergedAssignments.MergeWith(pendingAssignments); From fe164651ddac6f679fc5b066cff90dd1d59530fc Mon Sep 17 00:00:00 2001 From: Chris Hannon Date: Sun, 22 Dec 2024 17:08:26 -0700 Subject: [PATCH 3/3] Added initial port of URL targeting, still need clarity around this --- .../GrowthBookTests/UrlRedirectTests.cs | 23 +++++++- GrowthBook/Context.cs | 5 ++ GrowthBook/Extensions/UriExtensions.cs | 23 ++++++++ GrowthBook/GrowthBook.cs | 14 +++++ GrowthBook/Utilities/ExperimentUtilities.cs | 57 ++++++++++++++++--- 5 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 GrowthBook/Extensions/UriExtensions.cs diff --git a/GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs b/GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs index a17a316..3ec1e38 100644 --- a/GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs +++ b/GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using FluentAssertions; using Newtonsoft.Json.Linq; using Xunit; @@ -10,6 +11,13 @@ namespace GrowthBook.Tests.StandardTests.GrowthBookTests; public class UrlRedirectTests : UnitTest { + public sealed class TestResult + { + public bool InExperiment { get; set; } + public string UrlRedirect { get; set; } + public string UrlWithParams { get; set; } + } + [StandardCaseTestCategory("urlRedirect")] public class UrlRedirectTestCase { @@ -18,7 +26,7 @@ public class UrlRedirectTestCase [TestPropertyIndex(1)] public Context Context { get; set; } [TestPropertyIndex(2)] - public JToken[] ExpectedResults { get; set; } + public TestResult[] ExpectedResults { get; set; } } [Theory] @@ -26,5 +34,18 @@ public class UrlRedirectTestCase public void Run(UrlRedirectTestCase testCase) { var gb = new GrowthBook(testCase.Context); + +#warning Is this only applicable for auto experiments? Need more clarity around usage/logic as well. + + //for(var i = 0; i < gb.Experiments.Count; i++) + //{ + // var experiment = gb.Experiments[i]; + // var expectedResult = testCase.ExpectedResults[i]; + + // var result = gb.Run(experiment); + + // result.InExperiment.Should().Be(expectedResult.InExperiment); + // result.Value["urlRedirect"]?.ToString().Should().Be(expectedResult.UrlRedirect); + //} } } diff --git a/GrowthBook/Context.cs b/GrowthBook/Context.cs index 39c60d9..ebe9d4f 100644 --- a/GrowthBook/Context.cs +++ b/GrowthBook/Context.cs @@ -49,6 +49,11 @@ public class Context /// public IDictionary Features { get; set; } = new Dictionary(); + /// + /// Experiment definitions. + /// + public IList Experiments { get; set; } + /// /// Service for using sticky buckets. /// diff --git a/GrowthBook/Extensions/UriExtensions.cs b/GrowthBook/Extensions/UriExtensions.cs new file mode 100644 index 0000000..6a9e349 --- /dev/null +++ b/GrowthBook/Extensions/UriExtensions.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GrowthBook.Extensions +{ + public static class UriExtensions + { + public static bool ContainsHashInPath(this Uri uri) => uri.AbsolutePath.Contains("#"); + + public static string GetHashContents(this Uri uri) + { + if (!uri.ContainsHashInPath()) + { + return default; + } + + var hashIndex = uri.AbsolutePath.IndexOf("#"); + + return uri.AbsolutePath.Substring(hashIndex); + } + } +} diff --git a/GrowthBook/GrowthBook.cs b/GrowthBook/GrowthBook.cs index 6ffc7c2..3c55b16 100644 --- a/GrowthBook/GrowthBook.cs +++ b/GrowthBook/GrowthBook.cs @@ -46,6 +46,7 @@ public GrowthBook(Context context) Attributes = context.Attributes; Url = context.Url; Features = context.Features?.ToDictionary(k => k.Key, v => v.Value) ?? new Dictionary(); + Experiments = context.Experiments ?? new List(); ForcedVariations = context.ForcedVariations; _qaMode = context.QaMode; @@ -104,6 +105,11 @@ public GrowthBook(Context context) /// public IDictionary Features { get; set; } + /// + /// The currently loaded experiments (separate from features). + /// + public IList Experiments { get; set; } + /// /// Listing of specific experiments to always assign a specific variation (used for QA). /// @@ -445,6 +451,14 @@ private ExperimentResult RunExperiment(Experiment experiment, string featureId) return GetExperimentResult(experiment, featureId: featureId); } + // 2.6 Use improved URL targeting if specified. + + if (experiment.UrlPatterns?.Count > 0 && !ExperimentUtilities.IsUrlTargeted(Url ?? string.Empty, experiment.UrlPatterns)) + { + _logger.LogDebug("Skipping due to URL targeting"); + return GetExperimentResult(experiment, featureId: featureId); + } + // 3. Use the override value from the query string if one is specified. if (!Url.IsNullOrWhitespace()) diff --git a/GrowthBook/Utilities/ExperimentUtilities.cs b/GrowthBook/Utilities/ExperimentUtilities.cs index a678395..3500c9f 100644 --- a/GrowthBook/Utilities/ExperimentUtilities.cs +++ b/GrowthBook/Utilities/ExperimentUtilities.cs @@ -220,27 +220,70 @@ private static bool EvaluateSimpleUrlTarget(Uri actual, UrlPattern pattern) // If a protocol is missing, but a host is specified, add `https://` to the front // Use "_____" as the wildcard since `*` is not a valid hostname in some browsers - var expected = Regex.Replace(pattern.Pattern, "^([^:/?]*)\\.", "https://$1."); - expected = Regex.Replace(expected, "/*", "_____"); + var currentPattern = pattern.Pattern; + + var match = Regex.Match(currentPattern, "^([^:/?]*)\\."); + + if (match.Success) + { + currentPattern = $"https://{currentPattern}"; + } + + var expected = currentPattern.Replace("*", "_____"); var expectedUri = new Uri($"https://{expected}"); // Compare each part of the URL separately - var comparisons = new[] { (actual.Host, expectedUri.Host, false), (actual.AbsolutePath, expectedUri.AbsolutePath, true) }; + var comparisons = new List<(string Actual, string Expected, bool IsPath)> + { + (actual.Host, expectedUri.Host, false), + (actual.AbsolutePath, expectedUri.AbsolutePath, true) + }; // We only want to compare hashes if it's explicitly being targeted -#warning Check hash codes? -#warning Comparisons and finish implementation + if (expectedUri.ContainsHashInPath()) + { + comparisons.Add((actual.GetHashContents(), expectedUri.GetHashContents(), false)); + } - return false; + var actualQueryParameters = HttpUtility.ParseQueryString(actual.Query); + var expectedQueryParameters = HttpUtility.ParseQueryString(expectedUri.Query); + + for(var i = 0; i < expectedQueryParameters.Count; i++) + { + comparisons.Add((actualQueryParameters[i] ?? string.Empty, expectedQueryParameters[i], false)); + } + + // Any failure means the whole thing fails. + + return comparisons.Any(x => !EvaluateSimpleUrlPart(x.Actual, x.Expected, x.IsPath)); + } + + private static bool EvaluateSimpleUrlPart(string actual, string expected, bool isPath) + { + var escaped = Regex.Replace(expected, @"[*.+?^${}()|[\]\\]", @"\$&"); + var escapedWithWildcards = escaped.Replace("_____", ".*"); + + if (isPath) + { + // When matching path name, make leading/trailing slashes optional + + escapedWithWildcards = Regex.Replace(escapedWithWildcards, @"(^\/|\/$)", string.Empty); + escapedWithWildcards = $@"\/?{escapedWithWildcards}\/?"; + } + + var regex = new Regex($"^{escapedWithWildcards}$"); + + return regex.IsMatch(actual); } private static Regex GetUrlRegex(UrlPattern pattern) { try { - var escaped = Regex.Replace(pattern.Pattern, "([^\\\\])\\/", "$1\\/"); + var match = Regex.IsMatch(pattern.Pattern, @"([^\\])\/"); + var escaped = Regex.Replace(pattern.Pattern, @"([^\\])\/", @"$1\/"); return new Regex(escaped); }