diff --git a/CLAUDE.md b/CLAUDE.md index 1f2ddb2..4076505 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,8 +66,9 @@ These features exist in the Roku SDK but were removed from JS SDK v2. Keep them | `refresh()` manual refresh | Caller-driven refresh on app resume | | `onReady` / `onRefresh` / `onUpdate` callbacks (SGNode events `ready` / `refreshed` / `updated`) | Roku event model uses field observers, not Promises | | `activate()` method | Experiment activation tracking is useful for analytics | -| `interceptContext` / `configureBucketKey` / `configureBucketValue` callbacks | Needed until hooks system is implemented | +| `interceptContext` / `configureBucketKey` / `configureBucketValue` callbacks | Legacy callbacks kept alongside the new hooks system for backward compat | | `initialFeatures` option | Before datafile loads, return known defaults | +| `spawn()` / child instances | No concrete Roku use case; deferred — requires OverrideOptions per-call sticky to be useful | --- @@ -77,6 +78,7 @@ These features exist in the Roku SDK but were removed from JS SDK v2. Keep them |------|---------------| | `FeaturevisorSDK.brs` | Public API factory — thin wrapper over the SGNode instance | | `Featurevisor.instance.brs` | Core evaluation engine (`evaluateFlag`, `evaluateVariation`, `evaluateVariable`, context, sticky, bucketing) | +| `FeaturevisorHooks.brs` | Hooks manager — `add`, `remove`, `getAll` for `before`/`after`/`bucketKey`/`bucketValue` hooks | | `Featurevisor.instance.xml` | SGNode definition (fields: `ready`, `activated`, `refreshed`, `updated`) | | `Featurevisor.request.brs` | HTTP task for datafile fetch (uses createRequest) | | `FeaturevisorDatafileReader.brs` | Datafile accessor — handles both v1 (array) and v2 (dict) for features/segments | diff --git a/README.md b/README.md index 48ebd5f..34a0abd 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Visit [https://featurevisor.com/docs/sdks/roku](https://featurevisor.com/docs/sd - [`f.startRefreshing`](#fstartrefreshing) - [`f.stopRefreshing`](#fstoprefreshing) - [Evaluation object](#evaluation-object) +- [Differences from the JS SDK](#differences-from-the-js-sdk) ## Installation @@ -737,18 +738,34 @@ The `evaluateFlag`, `evaluateVariation`, and `evaluateVariable` methods return a | `"initial"` | Instance not yet ready; using `initialFeatures` value | | `"no_match"` | No traffic rule matched | | `"no_variations"` | Feature has no variations defined | -| `"not_found"` | Feature not found in the datafile | +| `"feature_not_found"` | Feature not found in the datafile | | `"variable_not_found"` | Variable key not found in the feature's schema | | `"out_of_range"` | Feature is in a mutually-exclusive group but bucket is out of range| | `"override"` | Variable overridden by a condition inside a variation (v1 format) | | `"required"` | A required feature is not enabled | | `"rule"` | Matched a traffic rule | | `"sticky"` | Using a sticky feature override | +| `"variable_default"` | Variable falls back to its `defaultValue` | | `"variable_disabled"` | Feature is disabled; variable `disabledValue` is returned | | `"variable_override_rule"` | Variable overridden directly by a traffic rule | | `"variable_override_variation"` | Variable overridden by a condition inside a variation | | `"variation_disabled"` | Feature is disabled; `disabledVariationValue` is returned | +## Differences from the JS SDK + +The Roku SDK intentionally diverges from the JavaScript SDK in the following ways: + +| Feature | Why | +|---------|-----| +| `datafileUrl` + HTTP fetch | Roku apps fetch the datafile themselves; there is no built-in fetch API outside the SDK | +| `refreshInterval` + `startRefreshing()` / `stopRefreshing()` | TV apps need periodic polling; no background threads available | +| `refresh()` manual method | Caller-driven refresh on app resume | +| `onReady` / `onRefresh` / `onUpdate` / `onDatafileChanged` / `onContextChanged` / `onStickyChanged` callbacks | Roku uses SGNode field observers, not Promises or EventEmitter | +| `activate()` method | Experiment activation tracking for analytics | +| `interceptContext` / `configureBucketKey` / `configureBucketValue` callbacks | Legacy callback API; kept alongside the hooks system | +| `initialFeatures` option | Return known defaults before the datafile loads | +| `spawn()` / child instances | No concrete Roku use case identified; deferred — no equivalent implementation planned | + ## License MIT © Błażej Chełkowski diff --git a/src/components/libs/featurevisor/Featurevisor.instance.brs b/src/components/libs/featurevisor/Featurevisor.instance.brs index 456eba1..90e56f1 100644 --- a/src/components/libs/featurevisor/Featurevisor.instance.brs +++ b/src/components/libs/featurevisor/Featurevisor.instance.brs @@ -10,6 +10,7 @@ ' @import /components/libs/featurevisor/FeaturevisorDatafileReader.brs ' @import /components/libs/featurevisor/FeaturevisorEvaluationReason.const.brs ' @import /components/libs/featurevisor/FeaturevisorFeature.brs +' @import /components/libs/featurevisor/FeaturevisorHooks.brs ' @import /components/libs/featurevisor/FeaturevisorLogger.brs ' @import /components/libs/featurevisor/FeaturevisorSegments.brs @@ -18,6 +19,7 @@ sub init() m._arrayUtils = ArrayUtils() m._featurevisorEvaluationReason = FeaturevisorEvaluationReason() + m._hooksManager = FeaturevisorHooks() m._logger = FeaturevisorLogger() m._statuses = { ready: false, @@ -50,6 +52,12 @@ sub initialize(options = {} as Object) m._stickyFeatures = getProperty(options, ["stickyFeatures"], m._stickyFeatures) m._instanceContext = getProperty(options, ["context"], m._instanceContext) + if (getType(getProperty(options, ["hooks"])) = "roArray") + for each hook in options.hooks + m._hooksManager.add(hook) + end for + end if + if (NOT m._statuses.ready AND m._datafileUrl <> "") m._datafileReader = Invalid datafile = getProperty(options, ["datafile"], { @@ -95,6 +103,7 @@ sub clear() m._configureBucketValue = Invalid m._datafileReader = Invalid m._datafileUrl = "" + m._hooksManager = FeaturevisorHooks() m._initialFeatures = Invalid m._instanceContext = {} m._interceptContext = Invalid @@ -106,9 +115,22 @@ sub clear() m._stickyFeatures = Invalid end sub -function activate(feature as Dynamic, context = {} as Object) as Object +sub addHook(hook as Object) + m._hooksManager.add(hook) +end sub + +sub close() + stopRefreshing() + clear() +end sub + +sub setLogLevel(level as String) + m._logger.setLevel(level) +end sub + +function activate(feature as Dynamic, context = {} as Object, options = {} as Object) as Object try - evaluation = evaluateVariation(feature, context) + evaluation = evaluateVariation(feature, context, options) variationValue = getProperty(evaluation, ["variation", "value"], evaluation.variationValue) if (variationValue = Invalid) then return Invalid @@ -140,7 +162,38 @@ function activate(feature as Dynamic, context = {} as Object) as Object end try end function -function evaluateFlag(featureKey as String, context = {} as Object) as Object +function evaluateFlag(featureKey as String, context = {} as Object, options = {} as Object) as Object + try + previousSticky = m._stickyFeatures + hasStickyOverride = options.sticky <> Invalid + if (hasStickyOverride) + m._stickyFeatures = _mergeSticky(m._stickyFeatures, options.sticky) + end if + + evaluateOptions = _applyBeforeHooks(featureKey, context) + result = _evaluateFlagInternal(evaluateOptions.featureKey, evaluateOptions.context) + result = _applyAfterHooks(result, evaluateOptions) + + if (hasStickyOverride) + m._stickyFeatures = previousSticky + end if + + return result + catch error + if (options.sticky <> Invalid) + m._stickyFeatures = previousSticky + end if + m._logger.error("evaluateFlag error", { featureKey: featureKey, error: error.message }) + + return { + error: error, + featureKey: featureKey, + reason: m._featurevisorEvaluationReason.ERROR, + } + end try +end function + +function _evaluateFlagInternal(featureKey as String, context as Object) as Object try ' sticky if (getProperty(m._stickyFeatures, [featureKey, "enabled"]) <> Invalid) @@ -168,7 +221,7 @@ function evaluateFlag(featureKey as String, context = {} as Object) as Object if (feature = Invalid) return { featureKey: featureKey, - reason: m._featurevisorEvaluationReason.NOT_FOUND, + reason: m._featurevisorEvaluationReason.FEATURE_NOT_FOUND, } end if @@ -309,7 +362,40 @@ function evaluateFlag(featureKey as String, context = {} as Object) as Object end try end function -function evaluateVariable(featureV as Dynamic, variableKey as String, context = {} as Object) as Object +function evaluateVariable(featureV as Dynamic, variableKey as String, context = {} as Object, options = {} as Object) as Object + previousSticky = m._stickyFeatures + hasStickyOverride = options.sticky <> Invalid + + try + if (hasStickyOverride) + m._stickyFeatures = _mergeSticky(m._stickyFeatures, options.sticky) + end if + + if (getType(featureV) = "roString") + featureKey = featureV + else + featureKey = featureV.key + end if + + evaluateOptions = _applyBeforeHooks(featureKey, context) + result = _evaluateVariableInternal(evaluateOptions.featureKey, variableKey, evaluateOptions.context) + result = _applyAfterHooks(result, evaluateOptions) + catch error + m._logger.error("evaluateVariable error", { variableKey: variableKey, error: error.message }) + result = { + error: error, + reason: m._featurevisorEvaluationReason.ERROR, + } + end try + + if (hasStickyOverride) + m._stickyFeatures = previousSticky + end if + + return result +end function + +function _evaluateVariableInternal(featureV as Dynamic, variableKey as String, context as Object) as Object try if (getType(featureV) = "roString") featureKey = featureV @@ -344,7 +430,7 @@ function evaluateVariable(featureV as Dynamic, variableKey as String, context = return { enabled: false, featureKey: featureKey, - reason: m._featurevisorEvaluationReason.DEFAULTED, + reason: m._featurevisorEvaluationReason.VARIABLE_DEFAULT, variableKey: variableKey, variableSchema: variableSchema, variableValue: variableSchema.defaultValue, @@ -383,7 +469,7 @@ function evaluateVariable(featureV as Dynamic, variableKey as String, context = if (feature = Invalid) return { featureKey: featureKey, - reason: m._featurevisorEvaluationReason.NOT_FOUND, + reason: m._featurevisorEvaluationReason.FEATURE_NOT_FOUND, variableKey: variableKey, } end if @@ -447,7 +533,7 @@ function evaluateVariable(featureV as Dynamic, variableKey as String, context = return featurevisorAllGroupSegmentsAreMatched(parsed, context.finalContext, context.datafileReader) end if - return true + return false end function, { datafileReader: m._datafileReader, finalContext: finalContext }) if (ruleOverride <> Invalid) @@ -505,7 +591,7 @@ function evaluateVariable(featureV as Dynamic, variableKey as String, context = return featurevisorAllGroupSegmentsAreMatched(parsed, context.finalContext, context.datafileReader) end if - return true + return false end function, { datafileReader: m._datafileReader, finalContext: finalContext }) if (varOverride <> Invalid) @@ -592,7 +678,7 @@ function evaluateVariable(featureV as Dynamic, variableKey as String, context = return { bucketValue: bucketValue, featureKey: featureKey, - reason: m._featurevisorEvaluationReason.DEFAULTED, + reason: m._featurevisorEvaluationReason.VARIABLE_DEFAULT, variableKey: variableKey, variableSchema: variableSchema, variableValue: variableSchema.defaultValue, @@ -609,7 +695,40 @@ function evaluateVariable(featureV as Dynamic, variableKey as String, context = end try end function -function evaluateVariation(featureV as Dynamic, context = {} as Object) as Object +function evaluateVariation(featureV as Dynamic, context = {} as Object, options = {} as Object) as Object + previousSticky = m._stickyFeatures + hasStickyOverride = options.sticky <> Invalid + + try + if (hasStickyOverride) + m._stickyFeatures = _mergeSticky(m._stickyFeatures, options.sticky) + end if + + if (getType(featureV) = "roString") + featureKey = featureV + else + featureKey = featureV.key + end if + + evaluateOptions = _applyBeforeHooks(featureKey, context) + result = _evaluateVariationInternal(evaluateOptions.featureKey, evaluateOptions.context) + result = _applyAfterHooks(result, evaluateOptions) + catch error + m._logger.error("evaluateVariation error", { error: error.message }) + result = { + error: error, + reason: m._featurevisorEvaluationReason.ERROR, + } + end try + + if (hasStickyOverride) + m._stickyFeatures = previousSticky + end if + + return result +end function + +function _evaluateVariationInternal(featureV as Dynamic, context as Object) as Object try if (getType(featureV) = "roString") featureKey = featureV @@ -661,7 +780,7 @@ function evaluateVariation(featureV as Dynamic, context = {} as Object) as Objec if (feature = Invalid) return { featureKey: featureKey, - reason: m._featurevisorEvaluationReason.NOT_FOUND, + reason: m._featurevisorEvaluationReason.FEATURE_NOT_FOUND, } end if @@ -754,9 +873,9 @@ function getRevision() as String return m._datafileReader.getRevision() end function -function getVariable(feature as Dynamic, variableKey as String, context = {} as Object) as Dynamic +function getVariable(feature as Dynamic, variableKey as String, context = {} as Object, options = {} as Object) as Dynamic try - evaluation = evaluateVariable(feature, variableKey, context) + evaluation = evaluateVariable(feature, variableKey, context, options) if (evaluation.variableValue <> Invalid) if (getType(evaluation.variableValue) = "roString" AND getProperty(evaluation, ["variableSchema", "type"], "") = "json") @@ -766,6 +885,10 @@ function getVariable(feature as Dynamic, variableKey as String, context = {} as return evaluation.variableValue end if + if (options.defaultVariableValue <> Invalid) + return options.defaultVariableValue + end if + return Invalid catch error m._logger.error("getVariable error", { feature: feature, error: error.message }) @@ -816,14 +939,22 @@ function getVariableString(feature as Dynamic, variableKey as String, context = return _getValueByType(variableValue, "string") end function -function getVariation(feature as Dynamic, context = {} as Object) as Dynamic +function getVariation(feature as Dynamic, context = {} as Object, options = {} as Object) as Dynamic try - evaluation = evaluateVariation(feature, context) + evaluation = evaluateVariation(feature, context, options) + result = Invalid - if (evaluation.variationValue <> Invalid) then return evaluation.variationValue - if (evaluation.variation <> Invalid) then return evaluation.variation.value + if (evaluation.variationValue <> Invalid) + result = evaluation.variationValue + else if (evaluation.variation <> Invalid) + result = evaluation.variation.value + end if - return Invalid + if (result = Invalid AND options.defaultVariationValue <> Invalid) + result = options.defaultVariationValue + end if + + return result catch error m._logger.error("getVariation error", { feature: feature, error: error.message }) @@ -831,9 +962,9 @@ function getVariation(feature as Dynamic, context = {} as Object) as Dynamic end try end function -function isEnabled(featureKey as String, context = {} as Object) as Boolean +function isEnabled(featureKey as String, context = {} as Object, options = {} as Object) as Boolean try - evaluation = getProperty(evaluateFlag(featureKey, context), ["enabled"], false) + evaluation = getProperty(evaluateFlag(featureKey, context, options), ["enabled"], false) if (getType(evaluation) = "roBoolean") then return evaluation @@ -848,10 +979,14 @@ end function sub refresh() if (m._statuses.refreshInProgress) m._logger.warn("refresh already in progress, skipping") + + return end if - if (m._datafileUrl = Invalid) + if (m._datafileUrl = Invalid OR m._datafileUrl = "") m._logger.warn("cannot refresh since `datafileUrl` is not provided") + + return end if m._statuses.refreshInProgress = true @@ -889,7 +1024,68 @@ sub setDatafile(datafile as Dynamic) datafileScoped = ParseJson(datafileScoped) end if - m._datafileReader = FeaturevisorDatafileReader(datafileScoped) + previousRevision = "" + previousFeatureKeys = [] + oldReader = m._datafileReader + if (oldReader <> Invalid) + previousRevision = oldReader.getRevision() + previousFeatureKeys = oldReader.getFeatureKeys() + end if + + newReader = FeaturevisorDatafileReader(datafileScoped) + m._datafileReader = newReader + + newRevision = newReader.getRevision() + newFeatureKeys = newReader.getFeatureKeys() + + addedFeatures = m._arrayUtils.filter(newFeatureKeys, function (featureKey as String, context as Object) as Boolean + return NOT m._arrayUtils.contains(context.previousFeatureKeys, function (existing as String, innerContext as Object) as Boolean + return existing = innerContext.featureKey + end function, { featureKey: featureKey }) + end function, { previousFeatureKeys: previousFeatureKeys }) + + removedFeatures = m._arrayUtils.filter(previousFeatureKeys, function (featureKey as String, context as Object) as Boolean + return NOT m._arrayUtils.contains(context.newFeatureKeys, function (existing as String, innerContext as Object) as Boolean + return existing = innerContext.featureKey + end function, { featureKey: featureKey }) + end function, { newFeatureKeys: newFeatureKeys }) + + changedFeatures = m._arrayUtils.filter(newFeatureKeys, function (featureKey as String, context as Object) as Boolean + if (context.oldReader = Invalid) then return false + + existsInPrevious = m._arrayUtils.contains(context.previousFeatureKeys, function (existing as String, innerContext as Object) as Boolean + return existing = innerContext.featureKey + end function, { featureKey: featureKey }) + if (NOT existsInPrevious) then return false + + newFeature = context.newReader.getFeature(featureKey) + oldFeature = context.oldReader.getFeature(featureKey) + if (newFeature = Invalid OR oldFeature = Invalid) then return false + + newHash = getProperty(newFeature, ["hash"], Invalid) + oldHash = getProperty(oldFeature, ["hash"], Invalid) + if (newHash = Invalid OR oldHash = Invalid) then return false + + return newHash <> oldHash + end function, { previousFeatureKeys: previousFeatureKeys, newReader: newReader, oldReader: oldReader }) + + allChangedFeatures = [] + for each featureKey in addedFeatures + allChangedFeatures.push(featureKey) + end for + for each featureKey in removedFeatures + allChangedFeatures.push(featureKey) + end for + for each featureKey in changedFeatures + allChangedFeatures.push(featureKey) + end for + + m.top.datafileChange = { + features: allChangedFeatures, + previousRevision: previousRevision, + revision: newRevision, + revisionChanged: previousRevision <> newRevision, + } catch error m._logger.error("could not parse datafile", { error: error.message }) end try @@ -907,6 +1103,11 @@ sub setContext(context as Object, replace = false as Boolean) m._instanceContext[key] = context[key] end for end if + + m.top.contextChange = { + context: m._instanceContext, + replaced: replace, + } end sub function getContext(context = {} as Object) as Object @@ -988,6 +1189,8 @@ sub setSticky(stickyFeatures as Object, replace = false as Boolean) m._stickyFeatures[key] = stickyFeatures[key] end for end if + + m.top.stickyChange = { replaced: replace } end sub ' @deprecated Use setSticky instead @@ -1063,11 +1266,81 @@ function _getValueByType(value as Dynamic, fieldType as String) as Dynamic end try end function +function _callHook(hook as Object, key as String, arg1 = Invalid as Dynamic, arg2 = Invalid as Dynamic) as Dynamic + func = hook[key] + + if (func = Invalid OR getType(func) <> "roFunction") then return Invalid + + if (hook.context <> Invalid) + hook.context["$$hook"] = func + if (arg2 <> Invalid) + result = hook.context["$$hook"](arg1, arg2) + else + result = hook.context["$$hook"](arg1) + end if + hook.context.delete("$$hook") + else + m["$$hook"] = func + if (arg2 <> Invalid) + result = m["$$hook"](arg1, arg2) + else + result = m["$$hook"](arg1) + end if + m.delete("$$hook") + end if + + return result +end function + +function _applyBeforeHooks(featureKey as String, context as Object) as Object + evaluateOptions = { featureKey: featureKey, context: context } + hooks = m._hooksManager.getAll() + + for each hookName in hooks + result = _callHook(hooks[hookName], "before", evaluateOptions) + if (result <> Invalid) then evaluateOptions = result + end for + + return evaluateOptions +end function + +function _applyAfterHooks(result as Object, evaluateOptions as Object) as Object + hooks = m._hooksManager.getAll() + + for each hookName in hooks + updated = _callHook(hooks[hookName], "after", result, evaluateOptions) + if (updated <> Invalid) then result = updated + end for + + return result +end function + +function _mergeSticky(base as Object, overrides as Object) as Object + merged = {} + if (base <> Invalid) + for each key in base + merged[key] = base[key] + end for + end if + for each key in overrides + merged[key] = overrides[key] + end for + + return merged +end function + function _getBucketValue(feature as Object, finalContext as Object) as Integer bucketKey = _getBucketKey(feature, finalContext) bucketValue = featurevisorGetBucketedNumber(bucketKey) + bucketValue = _configureBucketValue(feature, finalContext, bucketValue) + hooks = m._hooksManager.getAll() + + for each hookName in hooks + updated = _callHook(hooks[hookName], "bucketValue", { feature: feature, context: finalContext, bucketValue: bucketValue }) + if (updated <> Invalid) then bucketValue = updated + end for - return _configureBucketValue(feature, finalContext, bucketValue) + return bucketValue end function function _getBucketKey(feature as Object, context as Object) as String @@ -1109,7 +1382,15 @@ function _getBucketKey(feature as Object, context as Object) as String bucketKey.push(featureKey) - return _configureBucketKey(feature, context, bucketKey.join(m._bucketKeySeparator)) + bucketKeyResult = _configureBucketKey(feature, context, bucketKey.join(m._bucketKeySeparator)) + hooks = m._hooksManager.getAll() + + for each hookName in hooks + updated = _callHook(hooks[hookName], "bucketKey", { feature: feature, context: context, bucketKey: bucketKeyResult }) + if (updated <> Invalid) then bucketKeyResult = updated + end for + + return bucketKeyResult end function function _configureBucketKey(feature as Dynamic, context as Object, bucketKey as String) as String diff --git a/src/components/libs/featurevisor/Featurevisor.instance.xml b/src/components/libs/featurevisor/Featurevisor.instance.xml index b9de4f4..6e60b2d 100644 --- a/src/components/libs/featurevisor/Featurevisor.instance.xml +++ b/src/components/libs/featurevisor/Featurevisor.instance.xml @@ -4,13 +4,18 @@ + + + + + @@ -32,6 +37,7 @@ + diff --git a/src/components/libs/featurevisor/FeaturevisorConditions.brs b/src/components/libs/featurevisor/FeaturevisorConditions.brs index 6192232..40b6165 100644 --- a/src/components/libs/featurevisor/FeaturevisorConditions.brs +++ b/src/components/libs/featurevisor/FeaturevisorConditions.brs @@ -1,4 +1,5 @@ ' @import /components/ArrayUtils.brs from @dazn/kopytko-utils +' @import /components/getProperty.brs from @dazn/kopytko-utils ' @import /components/getType.brs from @dazn/kopytko-utils ' @import /components/libs/compareVersions.brs from compare-versions-roku @@ -51,14 +52,28 @@ function featurevisorAllConditionsAreMatched(condition as Object, context as Obj return false end function + functionScope._attributeExistsInContext = function (context as Object, path as String) as Boolean + if (path.instr(0, ".") = -1) then return context.doesExist(path) + + parts = path.split(".") + lastKey = parts.pop() + parent = getProperty(context, parts) + + if (parent = Invalid OR getType(parent) <> "roAssociativeArray") then return false + + return parent.doesExist(lastKey) + end function + functionScope._conditionIsMatched = function (condition as Object, context as Object) as Boolean if (condition = Invalid) then return true if (condition.doesExist("operator") AND condition.doesExist("attribute") AND condition.doesExist("value")) + attributeValue = getProperty(context, condition.attribute) + if (condition["operator"] = "before" OR condition["operator"] = "after") - if (getType(context[condition.attribute]) = "roString") - dateInContext = m._toDateTime(context[condition.attribute]) + if (getType(attributeValue) = "roString") + dateInContext = m._toDateTime(attributeValue) else - dateInContext = context[condition.attribute] + dateInContext = attributeValue end if if (getType(condition.value) = "roString") @@ -72,85 +87,85 @@ function featurevisorAllConditionsAreMatched(condition as Object, context as Obj end if return dateInContext.asSeconds() > dateInCondition.asSeconds() - else if (getType(condition.value) = "roArray" AND context.doesExist(condition.attribute) AND (context[condition.attribute] = Invalid OR getType(context[condition.attribute]) = "roString" OR m._isNumber(context[condition.attribute]))) + else if (getType(condition.value) = "roArray" AND m._attributeExistsInContext(context, condition.attribute) AND (attributeValue = Invalid OR getType(attributeValue) = "roString" OR m._isNumber(attributeValue))) if (condition["operator"] = "in") return m._arrayUtils.contains(condition.value, function (item as String, context as Object) as Boolean return context.compareValue <> Invalid AND item.toStr() = context.compareValue.toStr() - end function, { compareValue: context[condition.attribute] }) + end function, { compareValue: attributeValue }) else if (condition["operator"] = "notIn") return NOT m._arrayUtils.contains(condition.value, function (item as String, context as Object) as Boolean return context.compareValue <> Invalid AND item.toStr() = context.compareValue.toStr() - end function, { compareValue: context[condition.attribute] }) + end function, { compareValue: attributeValue }) end if - else if (context.doesExist(condition.attribute) AND getType(context[condition.attribute]) = "roArray") + else if (getType(attributeValue) = "roArray") if (condition["operator"] = "includes") - return m._arrayUtils.contains(context[condition.attribute], function (item as Object, ctx as Object) as Boolean - return item.toStr() = ctx.compareValue.toStr() + return m._arrayUtils.contains(attributeValue, function (item as Object, context as Object) as Boolean + return item.toStr() = context.compareValue.toStr() end function, { compareValue: condition.value }) else if (condition["operator"] = "notIncludes") - return NOT m._arrayUtils.contains(context[condition.attribute], function (item as Object, ctx as Object) as Boolean - return item.toStr() = ctx.compareValue.toStr() + return NOT m._arrayUtils.contains(attributeValue, function (item as Object, context as Object) as Boolean + return item.toStr() = context.compareValue.toStr() end function, { compareValue: condition.value }) end if - else if (getType(context[condition.attribute]) = "roString" AND getType(condition.value) = "roString") + else if (getType(attributeValue) = "roString" AND getType(condition.value) = "roString") if (condition["operator"] = "equals") - return context[condition.attribute] = condition.value + return attributeValue = condition.value else if (condition["operator"] = "notEquals") - return context[condition.attribute] <> condition.value + return attributeValue <> condition.value else if (condition["operator"] = "contains") - return context[condition.attribute].instr(0, condition.value) <> -1 + return attributeValue.instr(0, condition.value) <> -1 else if (condition["operator"] = "notContains") - return context[condition.attribute].instr(0, condition.value) = -1 + return attributeValue.instr(0, condition.value) = -1 else if (condition["operator"] = "startsWith") - return condition.value = context[condition.attribute].left(condition.value.len()) + return condition.value = attributeValue.left(condition.value.len()) else if (condition["operator"] = "endsWith") - return condition.value = context[condition.attribute].right(condition.value.len()) + return condition.value = attributeValue.right(condition.value.len()) else if (condition["operator"] = "semverEquals") - return compareVersions(context[condition.attribute], condition.value) = 0 + return compareVersions(attributeValue, condition.value) = 0 else if (condition["operator"] = "semverNotEquals") - return compareVersions(context[condition.attribute], condition.value) <> 0 + return compareVersions(attributeValue, condition.value) <> 0 else if (condition["operator"] = "semverGreaterThan") - return compareVersions(context[condition.attribute], condition.value) = 1 + return compareVersions(attributeValue, condition.value) = 1 else if (condition["operator"] = "semverGreaterThanOrEquals") - return compareVersions(context[condition.attribute], condition.value) >= 0 + return compareVersions(attributeValue, condition.value) >= 0 else if (condition["operator"] = "semverLessThan") - return compareVersions(context[condition.attribute], condition.value) = -1 + return compareVersions(attributeValue, condition.value) = -1 else if (condition["operator"] = "semverLessThanOrEquals") - return compareVersions(context[condition.attribute], condition.value) <= 0 + return compareVersions(attributeValue, condition.value) <= 0 else if (condition["operator"] = "matches") flags = "" if (condition.doesExist("regexFlags") AND getType(condition.regexFlags) = "roString") then flags = condition.regexFlags regex = CreateObject("roRegex", condition.value, flags) - return regex.isMatch(context[condition.attribute]) + return regex.isMatch(attributeValue) else if (condition["operator"] = "notMatches") flags = "" if (condition.doesExist("regexFlags") AND getType(condition.regexFlags) = "roString") then flags = condition.regexFlags regex = CreateObject("roRegex", condition.value, flags) - return NOT regex.isMatch(context[condition.attribute]) + return NOT regex.isMatch(attributeValue) end if - else if (m._isNumber(context[condition.attribute]) AND m._isNumber(condition.value)) + else if (m._isNumber(attributeValue) AND m._isNumber(condition.value)) if (condition["operator"] = "equals") - return context[condition.attribute] = condition.value + return attributeValue = condition.value else if (condition["operator"] = "notEquals") - return context[condition.attribute] <> condition.value + return attributeValue <> condition.value else if (condition["operator"] = "greaterThan") - return context[condition.attribute] > condition.value + return attributeValue > condition.value else if (condition["operator"] = "greaterThanOrEquals") - return context[condition.attribute] >= condition.value + return attributeValue >= condition.value else if (condition["operator"] = "lessThan") - return context[condition.attribute] < condition.value + return attributeValue < condition.value else if (condition["operator"] = "lessThanOrEquals") - return context[condition.attribute] <= condition.value + return attributeValue <= condition.value end if - else if (getType(context[condition.attribute]) = "roBoolean" AND getType(condition.value) = "roBoolean") + else if (getType(attributeValue) = "roBoolean" AND getType(condition.value) = "roBoolean") if (condition["operator"] = "equals") - return context[condition.attribute] = condition.value + return attributeValue = condition.value else if (condition["operator"] = "notEquals") - return context[condition.attribute] <> condition.value + return attributeValue <> condition.value end if - else if (getType(context[condition.attribute]) = "roInvalid" AND getType(condition.value) = "roInvalid") + else if (getType(attributeValue) = "roInvalid" AND getType(condition.value) = "roInvalid") if (condition["operator"] = "equals") return true else if (condition["operator"] = "notEquals") @@ -158,10 +173,12 @@ function featurevisorAllConditionsAreMatched(condition as Object, context as Obj end if end if else if (condition.doesExist("operator") AND condition.doesExist("attribute")) + attributeValue = getProperty(context, condition.attribute) + if (condition["operator"] = "exists") - return context.doesExist(condition.attribute) + return attributeValue <> Invalid else if (condition["operator"] = "notExists") - return NOT context.doesExist(condition.attribute) + return attributeValue = Invalid end if end if diff --git a/src/components/libs/featurevisor/FeaturevisorDatafileReader.brs b/src/components/libs/featurevisor/FeaturevisorDatafileReader.brs index f57b3f6..5e95b60 100644 --- a/src/components/libs/featurevisor/FeaturevisorDatafileReader.brs +++ b/src/components/libs/featurevisor/FeaturevisorDatafileReader.brs @@ -76,6 +76,33 @@ function FeaturevisorDatafileReader(datafile as Object) as Object return keys end function + prototype.getVariableKeys = function (featureKey as String) as Object + feature = m.getFeature(featureKey) + if (feature = Invalid) then return [] + + if (getType(feature.variablesSchema) = "roAssociativeArray") + keys = [] + for each key in feature.variablesSchema + keys.push(key) + end for + + return keys + else if (getType(feature.variablesSchema) = "roArray") + return m._arrayUtils.map(feature.variablesSchema, function (schema as Object, _context as Object) as String + return schema.key + end function, {}) + end if + + return [] + end function + + prototype.hasVariations = function (featureKey as String) as Boolean + feature = m.getFeature(featureKey) + if (feature = Invalid) then return false + + return feature.variations <> Invalid AND getType(feature.variations) = "roArray" AND feature.variations.count() > 0 + end function + prototype.getRevision = function () as String return m._revision end function diff --git a/src/components/libs/featurevisor/FeaturevisorEvaluationReason.const.brs b/src/components/libs/featurevisor/FeaturevisorEvaluationReason.const.brs index fd6c4a4..813745e 100644 --- a/src/components/libs/featurevisor/FeaturevisorEvaluationReason.const.brs +++ b/src/components/libs/featurevisor/FeaturevisorEvaluationReason.const.brs @@ -1,19 +1,19 @@ function FeaturevisorEvaluationReason() as Object return { ALLOCATED: "allocated", - DEFAULTED: "defaulted", DISABLED: "disabled", ERROR: "error", + FEATURE_NOT_FOUND: "feature_not_found", FORCED: "forced", INITIAL: "initial", NO_MATCH: "no_match", NO_VARIATIONS: "no_variations", - NOT_FOUND: "not_found", OUT_OF_RANGE: "out_of_range", OVERRIDE: "override", REQUIRED: "required", RULE: "rule", STICKY: "sticky", + VARIABLE_DEFAULT: "variable_default", VARIABLE_DISABLED: "variable_disabled", VARIABLE_NOT_FOUND: "variable_not_found", VARIABLE_OVERRIDE_RULE: "variable_override_rule", diff --git a/src/components/libs/featurevisor/FeaturevisorHooks.brs b/src/components/libs/featurevisor/FeaturevisorHooks.brs new file mode 100644 index 0000000..263fdd0 --- /dev/null +++ b/src/components/libs/featurevisor/FeaturevisorHooks.brs @@ -0,0 +1,21 @@ +function FeaturevisorHooks() as Object + prototype = {} + + prototype._hooks = {} + + prototype.add = sub (hook as Object) + if (m._hooks.doesExist(hook.name)) then return + + m._hooks[hook.name] = hook + end sub + + prototype.remove = sub (name as String) + m._hooks.delete(name) + end sub + + prototype.getAll = function () as Object + return m._hooks + end function + + return prototype +end function diff --git a/src/components/libs/featurevisor/FeaturevisorLogger.brs b/src/components/libs/featurevisor/FeaturevisorLogger.brs index ca2f4be..ca7cb21 100644 --- a/src/components/libs/featurevisor/FeaturevisorLogger.brs +++ b/src/components/libs/featurevisor/FeaturevisorLogger.brs @@ -1,9 +1,12 @@ +' @import /components/ArrayUtils.brs from @dazn/kopytko-utils ' @import /components/getType.brs from @dazn/kopytko-utils function FeaturevisorLogger(options = {} as Object) as Object prototype = {} prototype._LEVELS = ["fatal", "error", "warn", "info", "debug"] + + prototype._arrayUtils = ArrayUtils() prototype._level = "info" prototype._handler = Invalid @@ -24,8 +27,8 @@ function FeaturevisorLogger(options = {} as Object) as Object end sub prototype.log = sub (level as String, message as String, details = {} as Object) - levelIndex = m._LEVELS.indexOf(m._level) - msgLevelIndex = m._LEVELS.indexOf(level) + levelIndex = m._arrayUtils.findIndex(m._LEVELS, m._level) + msgLevelIndex = m._arrayUtils.findIndex(m._LEVELS, level) if (levelIndex < 0 OR msgLevelIndex < 0) then return if (msgLevelIndex > levelIndex) then return diff --git a/src/components/libs/featurevisor/FeaturevisorSDK.brs b/src/components/libs/featurevisor/FeaturevisorSDK.brs index bbf8c49..36bfc61 100644 --- a/src/components/libs/featurevisor/FeaturevisorSDK.brs +++ b/src/components/libs/featurevisor/FeaturevisorSDK.brs @@ -7,8 +7,11 @@ function FeaturevisorSDK() as Object prototype._featurevisorInstance = Invalid prototype._isReady = false prototype._onActivation = { callback: Invalid, context: Invalid } + prototype._onContextChanged = { callback: Invalid, context: Invalid } + prototype._onDatafileChanged = { callback: Invalid, context: Invalid } prototype._onReady = { callback: Invalid, context: Invalid } prototype._onRefresh = { callback: Invalid, context: Invalid } + prototype._onStickyChanged = { callback: Invalid, context: Invalid } prototype._onUpdate = { callback: Invalid, context: Invalid } prototype.createInstance = function (options as Object, featurevisorInstance = Invalid as Object) as Object @@ -26,6 +29,18 @@ function FeaturevisorSDK() as Object m.onActivation(m._onActivation.callback, m._onActivation.context) end if + if (getType(getProperty(options, ["onContextChanged", "callback"])) = "roFunction") + m.onContextChanged(options.onContextChanged.callback, options.onContextChanged.context) + else if (getType(m._onContextChanged.callback) = "roFunction") + m.onContextChanged(m._onContextChanged.callback, m._onContextChanged.context) + end if + + if (getType(getProperty(options, ["onDatafileChanged", "callback"])) = "roFunction") + m.onDatafileChanged(options.onDatafileChanged.callback, options.onDatafileChanged.context) + else if (getType(m._onDatafileChanged.callback) = "roFunction") + m.onDatafileChanged(m._onDatafileChanged.callback, m._onDatafileChanged.context) + end if + if (getType(getProperty(options, ["onReady", "callback"])) = "roFunction") m.onReady(options.onReady.callback, options.onReady.context) else if (getType(m._onReady.callback) = "roFunction") @@ -38,6 +53,12 @@ function FeaturevisorSDK() as Object m.onRefresh(m._onRefresh.callback, m._onRefresh.context) end if + if (getType(getProperty(options, ["onStickyChanged", "callback"])) = "roFunction") + m.onStickyChanged(options.onStickyChanged.callback, options.onStickyChanged.context) + else if (getType(m._onStickyChanged.callback) = "roFunction") + m.onStickyChanged(m._onStickyChanged.callback, m._onStickyChanged.context) + end if + if (getType(getProperty(options, ["onUpdate", "callback"])) = "roFunction") m.onUpdate(options.onUpdate.callback, options.onUpdate.context) else if (getType(m._onUpdate.callback) = "roFunction") @@ -49,48 +70,66 @@ function FeaturevisorSDK() as Object return featurevisorInstance end function - prototype.activate = function (feature as Dynamic, context = {} as Object) as Object + prototype.activate = function (feature as Dynamic, context = {} as Object, options = {} as Object) as Object if (m._featurevisorInstance = Invalid) then return Invalid - return m._featurevisorInstance.callFunc("activate", feature, context) + return m._featurevisorInstance.callFunc("activate", feature, context, options) end function + prototype.addHook = sub (hook as Object) + if (m._featurevisorInstance = Invalid) then return + + m._featurevisorInstance.callFunc("addHook", hook) + end sub + prototype.clear = sub (options = {} as Object) if (m._featurevisorInstance = Invalid) then return m._isReady = false m._featurevisorInstance.unobserveFieldScoped("activated") + m._featurevisorInstance.unobserveFieldScoped("contextChange") + m._featurevisorInstance.unobserveFieldScoped("datafileChange") m._featurevisorInstance.unobserveFieldScoped("ready") m._featurevisorInstance.unobserveFieldScoped("refreshed") + m._featurevisorInstance.unobserveFieldScoped("stickyChange") m._featurevisorInstance.unobserveFieldScoped("updated") if (getProperty(options, "clearCallbackDefinitions", false)) m._onActivation = { callback: Invalid, context: Invalid } + m._onContextChanged = { callback: Invalid, context: Invalid } + m._onDatafileChanged = { callback: Invalid, context: Invalid } m._onReady = { callback: Invalid, context: Invalid } m._onRefresh = { callback: Invalid, context: Invalid } + m._onStickyChanged = { callback: Invalid, context: Invalid } m._onUpdate = { callback: Invalid, context: Invalid } end if m._featurevisorInstance.callFunc("clear") end sub - prototype.evaluateFlag = function (featureKey as String, context = {} as Object) as Object + prototype.close = sub () + if (m._featurevisorInstance = Invalid) then return + + m.clear() + end sub + + prototype.evaluateFlag = function (featureKey as String, context = {} as Object, options = {} as Object) as Object if (m._featurevisorInstance = Invalid) then return Invalid - return m._featurevisorInstance.callFunc("evaluateFlag", featureKey, context) + return m._featurevisorInstance.callFunc("evaluateFlag", featureKey, context, options) end function - prototype.evaluateVariable = function (feature as Dynamic, variableKey as String, context = {} as Object) as Object + prototype.evaluateVariable = function (feature as Dynamic, variableKey as String, context = {} as Object, options = {} as Object) as Object if (m._featurevisorInstance = Invalid) then return Invalid - return m._featurevisorInstance.callFunc("evaluateVariable", feature, variableKey, context) + return m._featurevisorInstance.callFunc("evaluateVariable", feature, variableKey, context, options) end function - prototype.evaluateVariation = function (feature as Dynamic, context = {} as Object) as Object + prototype.evaluateVariation = function (feature as Dynamic, context = {} as Object, options = {} as Object) as Object if (m._featurevisorInstance = Invalid) then return Invalid - return m._featurevisorInstance.callFunc("evaluateVariation", feature, context) + return m._featurevisorInstance.callFunc("evaluateVariation", feature, context, options) end function prototype.getFeature = function (feature as Dynamic) as Object @@ -105,10 +144,10 @@ function FeaturevisorSDK() as Object return m._featurevisorInstance.callFunc("getRevision") end function - prototype.getVariable = function (feature as Dynamic, variableKey as String, context = {} as Object) as Dynamic + prototype.getVariable = function (feature as Dynamic, variableKey as String, context = {} as Object, options = {} as Object) as Dynamic if (m._featurevisorInstance = Invalid) then return Invalid - return m._featurevisorInstance.callFunc("getVariable", feature, variableKey, context) + return m._featurevisorInstance.callFunc("getVariable", feature, variableKey, context, options) end function prototype.getVariableArray = function (feature as Dynamic, variableKey as String, context = {} as Object) as Dynamic @@ -153,16 +192,16 @@ function FeaturevisorSDK() as Object return m._featurevisorInstance.callFunc("getVariableString", feature, variableKey, context) end function - prototype.getVariation = function (feature as Dynamic, context = {} as Object) as Dynamic + prototype.getVariation = function (feature as Dynamic, context = {} as Object, options = {} as Object) as Dynamic if (m._featurevisorInstance = Invalid) then return Invalid - return m._featurevisorInstance.callFunc("getVariation", feature, context) + return m._featurevisorInstance.callFunc("getVariation", feature, context, options) end function - prototype.isEnabled = function (featureKey as String, context = {} as Object) as Boolean + prototype.isEnabled = function (featureKey as String, context = {} as Object, options = {} as Object) as Boolean if (m._featurevisorInstance = Invalid) then return false - return m._featurevisorInstance.callFunc("isEnabled", featureKey, context) + return m._featurevisorInstance.callFunc("isEnabled", featureKey, context, options) end function prototype.isReady = function () as Boolean @@ -178,6 +217,24 @@ function FeaturevisorSDK() as Object end if end sub + prototype.onContextChanged = sub (func as Function, context = Invalid as Object) + m._onContextChanged = { callback: func, context: context } + + if (m._featurevisorInstance <> Invalid) + m._featurevisorInstance.unobserveFieldScoped("contextChange") + m._featurevisorInstance.observeFieldScoped("contextChange", "Featurevisor_onContextChanged") + end if + end sub + + prototype.onDatafileChanged = sub (func as Function, context = Invalid as Object) + m._onDatafileChanged = { callback: func, context: context } + + if (m._featurevisorInstance <> Invalid) + m._featurevisorInstance.unobserveFieldScoped("datafileChange") + m._featurevisorInstance.observeFieldScoped("datafileChange", "Featurevisor_onDatafileChanged") + end if + end sub + prototype.onReady = sub (func as Function, context = Invalid as Object) m._onReady = { callback: func, context: context } @@ -196,6 +253,15 @@ function FeaturevisorSDK() as Object end if end sub + prototype.onStickyChanged = sub (func as Function, context = Invalid as Object) + m._onStickyChanged = { callback: func, context: context } + + if (m._featurevisorInstance <> Invalid) + m._featurevisorInstance.unobserveFieldScoped("stickyChange") + m._featurevisorInstance.observeFieldScoped("stickyChange", "Featurevisor_onStickyChanged") + end if + end sub + prototype.onUpdate = sub (func as Function, context = Invalid as Object) m._onUpdate = { callback: func, context: context } @@ -254,6 +320,12 @@ function FeaturevisorSDK() as Object m._featurevisorInstance.callFunc("startRefreshing") end sub + prototype.setLogLevel = sub (level as String) + if (m._featurevisorInstance = Invalid) then return + + m._featurevisorInstance.callFunc("setLogLevel", level) + end sub + prototype.stopRefreshing = sub () if (m._featurevisorInstance = Invalid) then return @@ -269,6 +341,14 @@ sub Featurevisor_onActivation(event as Object) Featurevisor_callback("$$FeaturevisorSDK_onActivation", m["$$FeaturevisorSDK"]._onActivation, event.getData()) end sub +sub Featurevisor_onContextChanged(event as Object) + Featurevisor_callback("$$FeaturevisorSDK_onContextChanged", m["$$FeaturevisorSDK"]._onContextChanged, event.getData()) +end sub + +sub Featurevisor_onDatafileChanged(event as Object) + Featurevisor_callback("$$FeaturevisorSDK_onDatafileChanged", m["$$FeaturevisorSDK"]._onDatafileChanged, event.getData()) +end sub + sub Featurevisor_onReady(_event as Object) m["$$FeaturevisorSDK"]._isReady = true Featurevisor_callback("$$FeaturevisorSDK_onReady", m["$$FeaturevisorSDK"]._onReady) @@ -278,6 +358,10 @@ sub Featurevisor_onRefresh(_event as Object) Featurevisor_callback("$$FeaturevisorSDK_onRefresh", m["$$FeaturevisorSDK"]._onRefresh) end sub +sub Featurevisor_onStickyChanged(event as Object) + Featurevisor_callback("$$FeaturevisorSDK_onStickyChanged", m["$$FeaturevisorSDK"]._onStickyChanged, event.getData()) +end sub + sub Featurevisor_onUpdate(_event as Object) Featurevisor_callback("$$FeaturevisorSDK_onUpdate", m["$$FeaturevisorSDK"]._onUpdate) end sub diff --git a/src/components/libs/featurevisor/_tests/FeaturevisorConditions.test.brs b/src/components/libs/featurevisor/_tests/FeaturevisorConditions.test.brs index fd85b10..d686896 100644 --- a/src/components/libs/featurevisor/_tests/FeaturevisorConditions.test.brs +++ b/src/components/libs/featurevisor/_tests/FeaturevisorConditions.test.brs @@ -451,5 +451,43 @@ function TestSuite__FeaturevisorConditions() as Object return expect(result).toBeTrue() end function) + itEach([ + { + conditions: [{ attribute: "user.profile.country", operator: "equals", value: "nl" }], + context: { user: { profile: { country: "nl" } } }, + expected: true, + }, + { + conditions: [{ attribute: "user.profile.country", operator: "equals", value: "nl" }], + context: { user: { profile: { country: "de" } } }, + expected: false, + }, + { + conditions: [{ attribute: "user.profile.country", operator: "equals", value: "nl" }], + context: { user: {} }, + expected: false, + }, + { + conditions: [{ attribute: "user.age", operator: "greaterThan", value: 18 }], + context: { user: { age: 25 } }, + expected: true, + }, + { + conditions: [{ attribute: "user.age", operator: "greaterThan", value: 18 }], + context: { user: { age: 10 } }, + expected: false, + }, + ], "dot-path attribute access - ${operator}", function (_ts as Object, params as Object) as String + return expect(featurevisorAllConditionsAreMatched(params.conditions, params.context)).toBe(params.expected) + end function) + + it("should handle exists operator with dot-path", function (_ts as Object) as Object + return [ + expect(featurevisorAllConditionsAreMatched([{ attribute: "user.id", operator: "exists" }], { user: { id: "abc" } })).toBeTrue(), + expect(featurevisorAllConditionsAreMatched([{ attribute: "user.id", operator: "exists" }], { user: {} })).toBeFalse(), + expect(featurevisorAllConditionsAreMatched([{ attribute: "user.id", operator: "notExists" }], { user: {} })).toBeTrue(), + ] + end function) + return ts end function diff --git a/src/components/libs/featurevisor/_tests/FeaturevisorDatafileReader.test.brs b/src/components/libs/featurevisor/_tests/FeaturevisorDatafileReader.test.brs index 2a7134f..b779f22 100644 --- a/src/components/libs/featurevisor/_tests/FeaturevisorDatafileReader.test.brs +++ b/src/components/libs/featurevisor/_tests/FeaturevisorDatafileReader.test.brs @@ -146,6 +146,102 @@ function TestSuite__FeaturevisorDatafileReader() as Object ] end function) + it("should return variable keys for a feature (v1 array schema)", function (_ts as Object) as Object + ' Given + datafile = { + schemaVersion: "1", + revision: "1", + attributes: [], + segments: [], + features: [ + { + key: "test", + bucketBy: "userId", + variablesSchema: [ + { key: "color", type: "string", defaultValue: "red" }, + { key: "count", type: "integer", defaultValue: 0 }, + ], + traffic: [], + }, + ], + } + reader = FeaturevisorDatafileReader(datafile) + + ' When + keys = reader.getVariableKeys("test") + + ' Then + return [ + expect(keys).toHaveLength(2), + expect(keys).toContain("color"), + expect(keys).toContain("count"), + expect(reader.getVariableKeys("missing")).toEqual([]), + ] + end function) + + it("should return variable keys for a feature (v2 dict schema)", function (_ts as Object) as Object + ' Given + datafile = { + schemaVersion: "2", + revision: "1", + attributes: [], + segments: {}, + features: { + test: { + key: "test", + bucketBy: "userId", + variablesSchema: { + color: { type: "string", defaultValue: "red" }, + size: { type: "string", defaultValue: "medium" }, + }, + traffic: [], + }, + }, + } + reader = FeaturevisorDatafileReader(datafile) + + ' When + keys = reader.getVariableKeys("test") + + ' Then + return [ + expect(keys).toHaveLength(2), + expect(keys).toContain("color"), + expect(keys).toContain("size"), + ] + end function) + + it("should check hasVariations", function (_ts as Object) as Object + ' Given + datafile = { + schemaVersion: "1", + revision: "1", + attributes: [], + segments: [], + features: [ + { + key: "withVariations", + bucketBy: "userId", + variations: [{ value: "control" }, { value: "treatment" }], + traffic: [], + }, + { + key: "noVariations", + bucketBy: "userId", + traffic: [], + }, + ], + } + reader = FeaturevisorDatafileReader(datafile) + + ' Then + return [ + expect(reader.hasVariations("withVariations")).toBeTrue(), + expect(reader.hasVariations("noVariations")).toBeFalse(), + expect(reader.hasVariations("missing")).toBeFalse(), + ] + end function) + ' F9: v1 array-based datafile backward compatibility it("should still work with v1 array-based features and segments", function (_ts as Object) as Object ' Given diff --git a/src/components/libs/featurevisor/_tests/FeaturevisorInstance.test.brs b/src/components/libs/featurevisor/_tests/FeaturevisorInstance.test.brs index 09ce116..9453b07 100644 --- a/src/components/libs/featurevisor/_tests/FeaturevisorInstance.test.brs +++ b/src/components/libs/featurevisor/_tests/FeaturevisorInstance.test.brs @@ -1369,7 +1369,7 @@ function TestSuite__FeaturevisorInstance() as Object { value: "treatment", variableOverrides: { - color: [{ value: "blue" }], + color: [{ value: "blue", segments: "*" }], }, }, ], @@ -1486,6 +1486,334 @@ function TestSuite__FeaturevisorInstance() as Object return expect(evaluation.reason).toBe("variable_not_found") end function) + it("should use feature_not_found reason when feature is missing", function (_ts as Object) as String + ' Given + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + ' Then + return expect(evaluateFlag("missing").reason).toBe("feature_not_found") + end function) + + it("should use variable_default reason when useDefaultWhenDisabled is set", function (_ts as Object) as String + ' Given + datafile = { + schemaVersion: "1", + revision: "1", + features: [ + { + key: "test", + bucketBy: "userId", + variablesSchema: [ + { key: "color", type: "string", defaultValue: "red", useDefaultWhenDisabled: true }, + ], + traffic: [], + }, + ], + attributes: [], + segments: [], + } + initialize({ datafile: datafile }) + + ' Then + return expect(evaluateVariable("test", "color", { userId: "123" }).reason).toBe("variable_default") + end function) + + it("should not match variableOverride with no conditions or segments", function (_ts as Object) as String + ' v2 variableOverrides entry with neither conditions nor segments must be skipped (return false) + datafile = { + schemaVersion: "2", + revision: "1", + features: { + test: { + key: "test", + bucketBy: "userId", + variablesSchema: { + color: { type: "string", defaultValue: "red" }, + }, + variations: [ + { value: "control" }, + { + value: "treatment", + variableOverrides: { + color: [{ value: "blue" }], + }, + }, + ], + traffic: [ + { + key: "1", + segments: "*", + percentage: 100000, + allocation: [ + { variation: "control", range: [0, 0] }, + { variation: "treatment", range: [0, 100000] }, + ], + }, + ], + }, + }, + attributes: [], + segments: {}, + } + initialize({ datafile: datafile }) + + return expect(getVariable("test", "color", { userId: "user-a" })).toBe("red") + end function) + + it("should match variableOverride with segments wildcard", function (_ts as Object) as String + ' An override with segments: "*" matches unconditionally + datafile = { + schemaVersion: "2", + revision: "1", + features: { + test: { + key: "test", + bucketBy: "userId", + variablesSchema: { + color: { type: "string", defaultValue: "red" }, + }, + variations: [ + { value: "control" }, + { + value: "treatment", + variableOverrides: { + color: [{ value: "blue", segments: "*" }], + }, + }, + ], + traffic: [ + { + key: "1", + segments: "*", + percentage: 100000, + allocation: [ + { variation: "control", range: [0, 0] }, + { variation: "treatment", range: [0, 100000] }, + ], + }, + ], + }, + }, + attributes: [], + segments: {}, + } + initialize({ datafile: datafile }) + + return expect(getVariable("test", "color", { userId: "user-a" })).toBe("blue") + end function) + + it("should not execute refresh twice when already in progress", function (_ts as Object) as Object + ' Given + mockFunction("createRequest").returnValue(Promise()) + + initialize({ datafileUrl: "http://localhost:3000/datafile.json" }) + m._statuses.refreshInProgress = true + + ' When + refresh() + + ' Then — createRequest was only called once (for initialize), not again for the blocked refresh + return expect("createRequest").toHaveBeenCalledTimes(1) + end function) + + it("should emit datafileChange event when datafile is set", function (_ts as Object) as Object + ' Given + m.__datafileChangedPayload = Invalid + m.top.observeFieldScoped("datafileChange", "__callback_onDatafileChanged") + + datafile = { + schemaVersion: "1", + revision: "2.0", + features: [ + { key: "newFeature", bucketBy: "userId", traffic: [] }, + ], + attributes: [], + segments: [], + } + + ' When + initialize({ datafile: datafile }) + + ' Then + m.top.unobserveFieldScoped("datafileChange") + + return [ + expect(m.__datafileChangedPayload).toBeValid(), + expect(m.__datafileChangedPayload.revision).toBe("2.0"), + ] + end function) + + it("should emit contextChange event when context is set", function (_ts as Object) as Object + ' Given + m.__contextChangedPayload = Invalid + m.top.observeFieldScoped("contextChange", "__callback_onContextChanged") + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + ' When + setContext({ userId: "abc" }) + + ' Then + m.top.unobserveFieldScoped("contextChange") + + return [ + expect(m.__contextChangedPayload).toBeValid(), + expect(m.__contextChangedPayload.context.userId).toBe("abc"), + ] + end function) + + it("should emit stickyChange event when sticky is set", function (_ts as Object) as Object + ' Given + m.__stickyChangedPayload = Invalid + m.top.observeFieldScoped("stickyChange", "__callback_onStickyChanged") + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + ' When + setSticky({ myFeature: { enabled: true } }) + + ' Then + m.top.unobserveFieldScoped("stickyChange") + + return expect(m.__stickyChangedPayload).toBeValid() + end function) + + it("should apply before hook (no context) to modify featureKey", function (_ts as Object) as String + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + addHook({ + name: "redirectHook", + before: function (options as Object) as Object + options.featureKey = "redirectedByHook" + + return options + end function, + }) + + return expect(evaluateFlag("originalKey", {}).featureKey).toBe("redirectedByHook") + end function) + + it("should apply before hook (with context) so m inside the hook is that context", function (_ts as Object) as String + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + hookContext = { redirectTarget: "fromContext" } + + addHook({ + name: "contextHook", + context: hookContext, + before: function (options as Object) as Object + options.featureKey = m.redirectTarget + + return options + end function, + }) + + return expect(evaluateFlag("originalKey", {}).featureKey).toBe("fromContext") + end function) + + it("should apply after hook (no context) to modify evaluation result", function (_ts as Object) as String + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + addHook({ + name: "overrideHook", + after: function (evaluation as Object, _options as Object) as Object + evaluation.reason = "overridden_by_hook" + + return evaluation + end function, + }) + + return expect(evaluateFlag("anyKey", {}).reason).toBe("overridden_by_hook") + end function) + + it("should apply after hook (with context) so m inside the hook is that context", function (_ts as Object) as String + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + hookContext = { customReason: "from_context_after" } + + addHook({ + name: "contextAfterHook", + context: hookContext, + after: function (evaluation as Object, _options as Object) as Object + evaluation.reason = m.customReason + + return evaluation + end function, + }) + + return expect(evaluateFlag("anyKey", {}).reason).toBe("from_context_after") + end function) + + it("should reject duplicate hook names", function (_ts as Object) as String + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + addHook({ name: "myHook", before: function (options as Object) as Object : return options : end function }) + addHook({ name: "myHook", before: function (options as Object) as Object : return options : end function }) + + return expect(m._hooksManager.getAll().count()).toBe(1) + end function) + + it("should apply OverrideOptions sticky per call", function (_ts as Object) as Object + ' Given + datafile = { + schemaVersion: "1", + revision: "1", + features: [ + { + key: "test", + bucketBy: "userId", + traffic: [ + { + key: "1", + segments: "*", + percentage: 0, + allocation: [], + }, + ], + }, + ], + attributes: [], + segments: [], + } + initialize({ datafile: datafile }) + + ' The feature is disabled (percentage: 0) + ' But with per-call sticky, it should be enabled + callSticky = { test: { enabled: true } } + + return [ + expect(isEnabled("test", {})).toBeFalse(), + expect(isEnabled("test", {}, { sticky: callSticky })).toBeTrue(), + expect(isEnabled("test", {})).toBeFalse(), + ] + end function) + + it("should return defaultVariationValue from options when no variation matched", function (_ts as Object) as String + ' Given + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + ' When / Then + return expect(getVariation("missingFeature", {}, { defaultVariationValue: "fallback" })).toBe("fallback") + end function) + + it("should return defaultVariableValue from options when variable not found", function (_ts as Object) as String + ' Given + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + ' When / Then + return expect(getVariable("missingFeature", "color", {}, { defaultVariableValue: "orange" })).toBe("orange") + end function) + + it("should support setLogLevel", function (_ts as Object) as Object + ' Given + initialize({ datafile: { schemaVersion: "1", revision: "1", features: [], attributes: [], segments: [] } }) + + ' When — should not throw + setLogLevel("debug") + setLogLevel("warn") + + ' Then — no assertion needed; the test passes if no error is thrown + return expect(true).toBeTrue() + end function) + return ts end function @@ -1493,6 +1821,14 @@ sub __callback_onActivation() m.__onActivation() end sub +sub __callback_onContextChanged() + m.__contextChangedPayload = m.top.contextChange +end sub + +sub __callback_onDatafileChanged() + m.__datafileChangedPayload = m.top.datafileChange +end sub + sub __callback_onReady() m.__onReady() end sub @@ -1501,6 +1837,10 @@ sub __callback_onRefresh() m.__onRefresh() end sub +sub __callback_onStickyChanged() + m.__stickyChangedPayload = m.top.stickyChange +end sub + sub __callback_onUpdate() m.__onUpdate() end sub