diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fc93516 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# Composer installs the archive Git exports. Keep tests, fixtures and +# contributor tooling out of it. The featurevisor CLI and cli/ ship. +/.editorconfig export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.nvmrc export-ignore +/.prettierrc export-ignore +/CODE_OF_CONDUCT.md export-ignore +/CONTRIBUTING.md export-ignore +/Makefile export-ignore +/conformance export-ignore +/phpstan.neon export-ignore +/phpunit.xml export-ignore +/scripts export-ignore +/tests export-ignore diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 6df45c4..3782508 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -137,7 +137,7 @@ jobs: composer config minimum-stability dev composer config prefer-stable true composer config repositories.featurevisor "{\"type\":\"path\",\"url\":\"$GITHUB_WORKSPACE\",\"options\":{\"symlink\":false}}" - composer require featurevisor/featurevisor-php:@dev open-feature/sdk:^2.2 --no-interaction --prefer-dist --no-progress + composer require featurevisor/featurevisor-php:@dev open-feature/sdk:^2.3 --no-interaction --prefer-dist --no-progress php -r 'require "vendor/autoload.php"; $p = new Featurevisor\OpenFeatureProvider(["logLevel" => "fatal"]); if ($p->getMetadata()->getName() !== "Featurevisor") { exit(1); } $p->shutdown();' example-project: @@ -167,7 +167,7 @@ jobs: - name: Set up Featurevisor example-1 project run: | mkdir example-1 - (cd example-1 && npx --yes @featurevisor/cli@3 init --example=1) + (cd example-1 && npx --yes @featurevisor/cli@latest init --example=1) (cd example-1 && npm install) (cd example-1 && npx featurevisor build) (cd example-1 && npx featurevisor test --onlyFailures) diff --git a/.github/workflows/packagist.yml b/.github/workflows/packagist.yml index 8a7bfba..9912b42 100644 --- a/.github/workflows/packagist.yml +++ b/.github/workflows/packagist.yml @@ -31,8 +31,8 @@ jobs: - name: Validate release tag shell: bash run: | - if [[ ! "$GITHUB_REF_NAME" =~ ^v3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Expected a Featurevisor PHP v3 semantic version tag such as v3.0.0" + if [[ ! "$GITHUB_REF_NAME" =~ ^v4\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Expected a Featurevisor PHP v4 semantic version tag such as v4.0.0" exit 1 fi diff --git a/README.md b/README.md index 54dd189..f81128d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,13 @@ # Featurevisor PHP SDK -This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to PHP, providing a way to evaluate feature flags, variations, and variables in your PHP applications. - -This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 projects and v2 datafiles. +Use the Featurevisor PHP SDK to evaluate flags, variations and variables in PHP applications. ## Table of contents - [Installation](#installation) -- [Public API](#public-api) - [Initialization](#initialization) -- [Evaluation types](#evaluation-types) + - [Public API](#public-api) + - [Evaluation types](#evaluation-types) - [Context](#context) - [Setting initial context](#setting-initial-context) - [Setting after initialization](#setting-after-initialization) @@ -18,9 +16,12 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [Check if enabled](#check-if-enabled) - [Getting variation](#getting-variation) - [Getting variables](#getting-variables) + - [Getting feature variables](#getting-feature-variables) - [Type specific methods](#type-specific-methods) -- [Getting all evaluations](#getting-all-evaluations) -- [Sticky features and variables](#sticky-features-and-variables) + - [Getting global variables](#getting-global-variables) + - [Conditions and composition](#conditions-and-composition) +- [Getting evaluations](#getting-evaluations) +- [Sticky](#sticky) - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) @@ -28,7 +29,6 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [Replacing](#replacing) - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) -- [Evaluation details](#evaluation-details) - [Diagnostics](#diagnostics) - [Levels](#levels) - [Handler](#handler) @@ -37,6 +37,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [`context_set`](#context_set) - [`sticky_features_set` and `sticky_variables_set`](#sticky_features_set-and-sticky_variables_set) - [`error`](#error) +- [Evaluation details](#evaluation-details) - [Modules](#modules) - [Defining a module](#defining-a-module) - [Registering modules](#registering-modules) @@ -54,6 +55,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [Test](#test) - [Benchmark](#benchmark) - [Assess distribution](#assess-distribution) +- [GitHub repositories](#github-repositories) - [Development of this package](#development-of-this-package) - [Setting up](#setting-up) - [Running tests](#running-tests) @@ -64,62 +66,85 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje ## Installation +Upgrading an existing application? Follow the [PHP migration guide](https://featurevisor.com/docs/migrations/v4/php/). + The Featurevisor PHP SDK requires PHP 8.0 or newer. Install it using [Composer](https://getcomposer.org/): -``` -$ composer require featurevisor/featurevisor-php +```bash +composer require featurevisor/featurevisor-php:^4.0 ``` -## Public API +## Initialization -The main runtime API is `Featurevisor::createFeaturevisor()`: +The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: ```php + $datafileContent, + "datafile" => $datafileContent ]); +try { + echo $f->isEnabled("checkout", ["userId" => "123"]) ? "true\n" : "false\n"; +} finally { + $f->close(); +} ``` -Most applications only need this factory and the returned `Featurevisor` instance. Public extension and observability APIs include modules, diagnostics, events, and the datafile arrays accepted by the factory. +The HTTP stream requires `allow_url_fopen` and rejects HTTP error responses by default. The explicit check turns a failed read into an exception. Invalid JSON also throws. Handle these exceptions at your application's startup boundary. For a local datafile, pass its path to `file_get_contents` instead. -Treat an instance as request-owned in normal PHP applications. If a long-running parallel runtime shares an instance, serialize calls that mutate or close it. Module, event, and diagnostic callbacks are responsible for synchronizing mutable state that they capture. +Passing the raw JSON string is recommended. It preserves the distinction between JSON objects and arrays, including empty objects and objects whose only key is numeric. This lets compiled mutation and variable condition paths follow JavaScript container semantics exactly. Detailed evaluations retain preserved JSON objects as `stdClass` values. Public getters and the OpenFeature provider recursively adapt them to PHP arrays for compatibility. -## Initialization +The SDK also accepts decoded associative arrays. Non-list associative arrays are treated as objects and lists are treated as arrays, regardless of how a partial datafile was loaded. PHP cannot distinguish `{}` from `[]`, or `{"0": "value"}` from `["value"]`, after `json_decode($json, true)`. The same limitation applies to sticky values and other runtime values supplied as PHP arrays. Pass the raw JSON datafile or use `stdClass`, such as `(object) []`, for ambiguous native object values. This avoids evaluation behaviour changing when JSON and array datafile patches are mixed. -The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: +### Public API -```php - $datafileContent + "datafile" => $datafileContent, ]); ``` -## Evaluation types +Most applications only need this factory and the returned `Featurevisor` instance. Public extension and observability APIs include modules, diagnostics, events, and the datafile arrays accepted by the factory. + +Treat an instance as request-owned in normal PHP applications. If a long-running parallel runtime shares an instance, serialize calls that mutate or close it. Module, event, and diagnostic callbacks are responsible for synchronizing mutable state that they capture. -We can evaluate 3 types of values against a particular [feature](https://featurevisor.com/docs/features/): +### Evaluation types -- [**Flag**](#check-if-enabled) (`boolean`): whether the feature is enabled or not +Featurevisor evaluates four kinds of values: + +- [**Flag**](#check-if-enabled) (`bool`): whether the feature is enabled or not - [**Variation**](#getting-variation) (`string`): the variation of the feature (if any) -- [**Variables**](#getting-variables): variable values of the feature (if any) +- [**Feature variable**](#getting-feature-variables): a configuration value owned by a feature +- [**Global variable**](#getting-global-variables): a configuration value evaluated independently of a feature These evaluations are run against the provided context. ## Context -Contexts are [attribute](https://featurevisor.com/docs/attributes) values that we pass to SDK for evaluating [features](https://featurevisor.com/docs/features) against. +Contexts are [attribute](https://featurevisor.com/docs/attributes) values you pass to the SDK for evaluating [features](https://featurevisor.com/docs/features) against. + +Conditions compare these attributes using the [operators](https://featurevisor.com/docs/operators/), including `empty` and `notEmpty`. -Think of the conditions that you define in your [segments](https://featurevisor.com/docs/segments/), which are used in your feature's [rules](https://featurevisor.com/docs/features/#rules). +Date comparisons accept portable timestamp strings and `DateTimeInterface` values. Offsets must stay within 14 hours and both local and UTC years within 0001 to 9999. Fractions are truncated to milliseconds. Invalid values make both `before` and `after` return `false`. They are plain objects: @@ -131,11 +156,9 @@ $context = [ ]; ``` -Context can be passed to SDK instance in various different ways, depending on your needs: - ### Setting initial context -You can set context at the time of initialization: +Set context when you create the instance: ```php use Featurevisor\Featurevisor; @@ -152,7 +175,7 @@ This is useful for values that don't change too frequently and available at the ### Setting after initialization -You can also set more context after the SDK has been initialized: +Add context later: ```php $f->setContext([ @@ -165,7 +188,7 @@ This will merge the new context with the existing one (if already set). ### Replacing existing context -If you wish to fully replace the existing context, you can pass `true` in second argument: +Replace the context by passing `true` as the second argument: ```php $f->setContext( @@ -181,7 +204,7 @@ $f->setContext( ### Manually passing context -You can optionally pass additional context manually for each and every evaluation separately, without needing to set it to the SDK instance affecting all evaluations: +Pass context to one evaluation without changing the instance context: ```php $context = [ @@ -189,22 +212,20 @@ $context = [ "country" => "nl", ]; -$isEnabled = $f->isEnabled('my_feature', $context); -$variation = $f->getVariation('my_feature', $context); -$featureVariable = $f->getVariable('my_feature', 'my_variable', $context); +$isEnabled = $f->isEnabled('checkout', $context); +$variation = $f->getVariation('checkout', $context); +$featureVariable = $f->getVariable('checkout', 'bgColor', $context); $globalVariable = $f->getVariable('supportEmail', $context); ``` When manually passing context, it will merge with existing context set to the SDK instance before evaluating the specific value. -Further details for each evaluation types are described below. - ## Check if enabled -Once the SDK is initialized, you can check if a feature is enabled or not: +Check whether a feature is enabled: ```php -$featureKey = 'my_feature'; +$featureKey = 'checkout'; $isEnabled = $f->isEnabled($featureKey); @@ -213,7 +234,7 @@ if ($isEnabled) { } ``` -You can also pass additional context per evaluation: +Pass additional context to one evaluation: ```php $isEnabled = $f->isEnabled($featureKey, [ @@ -223,10 +244,10 @@ $isEnabled = $f->isEnabled($featureKey, [ ## Getting variation -If your feature has any [variations](https://featurevisor.com/docs/features/#variations) defined, you can evaluate them as follows: +Evaluate a feature's [variations](https://featurevisor.com/docs/features/#variations): ```php -$featureKey = 'my_feature'; +$featureKey = 'checkout'; $variation = $f->getVariation($featureKey); @@ -237,7 +258,7 @@ if ($variation === "treatment") { } ``` -Additional context per evaluation can also be passed: +Pass additional context to one evaluation: ```php $variation = $f->getVariation($featureKey, [ @@ -247,7 +268,9 @@ $variation = $f->getVariation($featureKey, [ ## Getting variables -Your features may also include [variables](https://featurevisor.com/docs/features/#variables), which can be evaluated as follows: +### Getting feature variables + +Evaluate a feature's [variables](https://featurevisor.com/docs/features/#variables): ```php $variableKey = 'bgColor'; @@ -255,7 +278,7 @@ $variableKey = 'bgColor'; $bgColorValue = $f->getVariable($featureKey, $variableKey); ``` -Additional context per evaluation can also be passed: +Pass additional context to one evaluation: ```php $bgColorValue = $f->getVariable($featureKey, $variableKey, [ @@ -271,13 +294,17 @@ Next to generic `getVariable()` methods, there are also type specific methods av $f->getVariableBoolean($featureKey, $variableKey, $context = []); $f->getVariableString($featureKey, $variableKey, $context = []); $f->getVariableInteger($featureKey, $variableKey, $context = []); -$f->getVariableDouble($featureKey, $variableKey, $context = []); +$f->getVariableNumber($featureKey, $variableKey, $context = []); $f->getVariableArray($featureKey, $variableKey, $context = []); $f->getVariableObject($featureKey, $variableKey, $context = []); $f->getVariableJSON($featureKey, $variableKey, $context = []); ``` -Type specific methods do not coerce values. `getVariableInteger()` returns `null` for the string `"1"`, and boolean getters return `null` for non-boolean values. +Type specific methods do not coerce values. `getVariableInteger()` returns `null` for the string `"1"`, and boolean getters return `null` for non-boolean values. Object and JSON type specific getters recursively adapt preserved JSON objects to associative PHP arrays. + +`getVariableNumber()` reads a variable of type `number` as a `float`, whether it holds `3` or `4.5`. Datafiles built before v4 call that type `double`, and it reads the same way. The v3 name `getVariableDouble()` was removed in v4. + +### Getting global variables Global variables use the same methods without a feature key: @@ -287,11 +314,71 @@ $evaluation = $f->evaluateVariable('supportEmail', ['country' => 'nl']); $settings = $f->getVariableObject('checkoutSettings'); ``` -Global variables honour sticky values, required features, and the first matching override. When an override contains both conditions and segments, both must match. +Conditions, composition and overrides are applied for you. See [Variables](https://featurevisor.com/docs/variables/#global-variables), [Conditions](https://featurevisor.com/docs/conditions/) and [Composition](https://featurevisor.com/docs/variables/#compose-values) for authoring guidance. + +When an override uses continuation, the SDK applies its mutations in order without changing the stored default. Supported operations are `set`, `append`, `prepend`, `appendUnique`, `removeValue`, `before`, `after`, and `remove`. Detailed evaluations expose every matched path in `variableOverridePaths`: + +```php +$evaluation = $f->evaluateVariable('paymentPolicy', ['country' => 'nl']); + +$value = $evaluation['variableValue']; +$matchedPaths = $evaluation['variableOverridePaths'] ?? []; +``` + +Module callbacks wrap the complete accumulated evaluation once. Sticky values and caller defaults continue to bypass or follow normal evaluation precedence. + +Treat sticky feature and variable maps, including nested values passed to the SDK or returned by modules, as read only during evaluation. Use the SDK setters to change owned sticky state. PHP module callbacks compare sticky arrays by value: a newly built array equal to the incoming one is treated as unchanged. If a setter then changes the owner's sticky state during that evaluation, nested prerequisites read the new owner state, not the equal array returned by the module. A different valued module replacement remains authoritative. + +Modules should return the same result for the same input during one evaluation. Pass values that change through context. + +### Conditions and composition + +Schema version `"4"` uses one condition language for segments, feature and global variable root gates, force entries, traffic and variable overrides. Leaves compare attributes, reference a literal segment key, check a feature's enabled state and optional variation, or compare an ordinary variable getter result. Variable leaves may name their feature owner and a compiled path such as `['items', 0, 'enabled']`. + +Arrays imply AND. Groups use `and`, `or` and `not`. NOT negates the AND of its direct children. Empty expressions are false. Authored order, short circuiting and explicit null values are preserved. + +Features evaluate sticky state, force, the root gate, traffic and variable overrides in that order. Global variables evaluate sticky state, the root gate, composition sources when present, then overrides. Unmet root gates report `conditions_unmet` and expose the normalized expression in `conditions`. Variation and feature variable disabled reasons are unchanged. + +A compiled global variable may assemble ordinary getter results or alias one source: + +```php +$datafileContent = [ + 'schemaVersion' => '4', + 'revision' => 'composition', + 'segments' => [], + 'features' => [], + 'variables' => [ + 'policy' => ['type' => 'object', 'defaultValue' => (object) ['limit' => 50]], + 'screen' => ['type' => 'object', 'compose' => [ + ['key' => 'policy', 'variable' => 'policy'], + ]], + 'policyAlias' => ['type' => 'object', 'compose' => 'policy'], + ], +]; +$f = Featurevisor::createFeaturevisor(['datafile' => $datafileContent]); +try { + $screen = $f->getVariable('screen'); + $policy = $f->getVariable('policyAlias'); +} finally { + $f->close(); +} +``` + +The list form returns a fresh outer object with literal property names. References can read global variables, feature variables, flags or variations. The string form aliases one literal global variable key without wrapping its value. False, zero, empty strings, arrays and null remain source values. JSON is decoded exactly once, including alias chains and strings resembling JSON. + +Compositions have no `defaultValue` or `useDefaultWhenDisabled`. Optional root conditions and `disabledValue` are supported. A missed gate skips every source and override, returning the disabled value or null. Whole sticky state also bypasses source reads. Otherwise all sources are read before the existing override program runs. Evaluations report `variable_composed`, or `variable_override_rule` when an override matches. -## Getting all evaluations +Sources use effective context, modules and child sticky state without inheriting an outer explicit default. The SDK preserves JSON object and array containers internally, including empty and numeric property `stdClass` objects. Detailed composition evaluations retain those objects. Ordinary public getters and the provider adapt them to PHP arrays as usual. Mutations copy changed paths without changing source, sibling or sticky values. -You can get evaluations of all features available in the SDK instance: +Features, segments, variables and composition share request scoped cycle detection and a combined bound of 256 dependency handoffs. Dependency errors fail the whole evaluation, never publishing a partial composition. Completed ordinary getter null and false fallbacks remain ordinary values. + +Schema version `"2"` selectors normalize eagerly before merging, retaining informational version metadata and leaving caller data unchanged. Schema version `"4"` entities normalize lazily on their first read. Partial updates invalidate only incoming entities. Replacement clears all normalized entity caches. + +Malformed stringified selector JSON fails the whole selector, including through NOT and segment aliases, without rejecting the accepted datafile. The `conditions_parse_error` error diagnostic is reported once per selector per incoming entity load. It is eager for schema version `"2"` and lazy for schema version `"4"`. Details contain exactly `conditions`, `entityType` and `entityKey`. Accepted data is stored before eager diagnostics, and lazy results are cached before diagnostic callbacks, allowing safe reentry. + +## Getting evaluations + +Evaluate all features available in the instance: ```php $allEvaluations = $f->getFeatureEvaluations($context = []); @@ -299,7 +386,7 @@ $globalVariables = $f->getVariableEvaluations($context = []); print_r($allEvaluations); // [ -// myFeature: [ +// checkout: [ // enabled: true, // variation: "control", // variables: [ @@ -316,9 +403,9 @@ print_r($allEvaluations); This is handy especially when you want to pass all evaluations from a backend application to the frontend. -## Sticky features and variables +## Sticky -For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): +Set sticky values to take precedence over feature evaluation against the [datafile](https://featurevisor.com/docs/building-datafiles/): Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides. Pass `stickyFeatures` or `stickyVariables` when a child needs its own state. @@ -329,7 +416,7 @@ use Featurevisor\Featurevisor; $f = Featurevisor::createFeaturevisor([ "stickyFeatures" => [ - "myFeatureKey" => [ + "checkout" => [ "enabled" => true, // optional @@ -353,12 +440,12 @@ Once initialized with sticky features, the SDK will look for values there first ### Set sticky afterwards -You can also set sticky features after the SDK is initialized: +Set sticky features after initialization: ```php $f->setStickyFeatures( [ - "myFeatureKey" => [ + "checkout" => [ "enabled" => true, "variation" => 'treatment', "variables" => [ @@ -383,50 +470,40 @@ You may also initialize the SDK without passing `datafile`, and set it later on: $f->setDatafile($datafileContent); ``` +`setDatafile` returns `true` when the datafile is accepted, including unchanged content. It returns `false` when the datafile is rejected or the instance is closed. Rejected datafiles leave the last accepted datafile in place. Diagnostics and events keep their existing behaviour. + ### Merging by default By default, `setDatafile($datafileContent)` merges the incoming datafile with the SDK instance's existing datafile: -- incoming `features` and `segments` override matching keys -- existing `features` and `segments` that are missing from the incoming datafile are kept +- incoming `features`, `segments`, and global `variables` override matching keys +- existing `features`, `segments`, and global `variables` that are missing from the incoming datafile are kept - `revision`, `schemaVersion`, and `featurevisorVersion` are taken from the incoming datafile -This means you can call `setDatafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. +Call `setDatafile` with different datafiles to merge their features, segments, and global variables. ### Replacing To replace the stored datafile completely, pass `true` as the second argument: ```php -$f->setDatafile($datafileContent, true); +$f->setDatafile($datafileContent, true); // replace ``` ### Loading datafiles on demand -Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront. - -This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: +Merge datafiles for different [targets](https://featurevisor.com/docs/targets/) into the same instance. Here, the two datafile values have already been loaded and validated by your application from the same published release: ```php -$f = Featurevisor::createFeaturevisor([]); - -function loadDatafile($f, string $target): void { - $url = "https://cdn.yoursite.com/production/featurevisor-$target.json"; - $datafile = json_decode(file_get_contents($url), true); - - // merges into whatever was loaded before - $f->setDatafile($datafile); -} - -loadDatafile($f, 'products'); - -// later, when the user reaches checkout -loadDatafile($f, 'checkout'); +$f->setDatafile($productsDatafile); +$f->setDatafile($checkoutDatafile); ``` +Use immutable release URLs, check HTTP responses and keep shared dependencies consistent. See [Loading datafiles on demand](https://featurevisor.com/docs/use-cases/on-demand-datafiles/) for the complete loading and refresh flow. + ### Updating datafile -You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile-set) event that you can listen and react to accordingly. +Each accepted datafile update emits a [`datafile_set`](#datafile-set) event. Subscribe to react to updates. The triggers for setting the datafile again can be: @@ -470,12 +547,11 @@ Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` Diagnostic handlers are isolated from SDK behaviour. An exception in a handler does not stop other handlers or evaluations. - ## Events -Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. +Subscribe to SDK events to respond to context, sticky state and datafile changes. -You can listen to these events that can occur at various stages in your application: +Subscribe to instance lifecycle events: ### `datafile_set` @@ -543,7 +619,7 @@ The `error` event is emitted for diagnostics whose level is `error`. ## Evaluation details -Besides logging with debug level enabled, you can also get more details about how the feature variations and variables are evaluated in the runtime against given context: +Inspect how variations and variables are evaluated against a context: ```php // flag @@ -580,10 +656,17 @@ And optionally these properties depending on whether you are evaluating a featur - `variableSchema`: the variable schema - `variableOverrideKey`: the matched override key - `variableOverridePath`: the full path of a matched nested override +- `variableOverridePaths`: all matched paths in an accumulating global variable program - `variableOverrideIndex`: the flattened override index +- `conditions`: the normalized root expression when its gate prevents evaluation +- `variable`: the global variable definition when available ## Modules +The before evaluation and after evaluation phases each use the module membership at the start of that phase. Where an evaluation uses bucketing, the bucket key and bucket value phases each use their own starting membership. Adding or removing a module during a phase does not change the iteration already in progress. The next phase uses the membership then installed. A module added before the after phase can receive after callbacks for evaluations already in progress, even if it did not receive their before callbacks. A module removed before the after phase receives no after callback. + +Module options expose `dependencyOf` on prerequisite reads. It identifies the immediate requesting evaluation with `type`, optional `featureKey` and optional `variableKey`. Application calls have no requester marker. + Modules allow you to intercept the evaluation process, report diagnostics, and customize behaviour further as per your needs. ### Defining a module @@ -669,13 +752,13 @@ $myCustomModule = [ ]; ``` -For feature evaluations, all `before` callbacks run in registration order, followed by all `beforeEvaluation` callbacks. After evaluation and caller defaults, all `afterEvaluation` callbacks run, followed by all `after` callbacks. Global variable evaluations use only `beforeEvaluation` and `afterEvaluation`. Required feature checks run through the complete module pipeline, and transformed defaults are preserved. +All `beforeEvaluation` callbacks run in registration order. After evaluation and caller defaults, all `afterEvaluation` callbacks run in registration order. Condition and composition dependencies use the same module pipeline. A global accumulation program still invokes each callback only once for its final result. -`before` and `after` remain available as deprecated feature-only compatibility callbacks. Use `beforeEvaluation` and `afterEvaluation` for new modules so the same callbacks can handle feature and global variable evaluations. +Internal reads carry `dependencyOf` identifying the immediate requester and `dependencyPurpose` set to `condition` or `composition`. Direct application calls carry neither. Memoization separates purposes, requesters, context and state changes. Condition snapshots are owned. Composition memoization follows ordinary getter ownership without an additional deep copy. Tracking callbacks should normally skip condition reads, while composition reads consume real values. Private request state is never exposed to callbacks. ### Registering modules -You can register modules at the time of SDK initialization: +Register modules when you create the instance: ```php use Featurevisor\Featurevisor; @@ -715,12 +798,12 @@ $childF = $f->spawn([ ]); ``` -Now you can pass the child instance where your individual request is being handled, and you can continue to evaluate features targeting that specific user alone: +Pass the child instance to the request handler to evaluate features for that user: ```php -$isEnabled = $childF->isEnabled('my_feature'); -$variation = $childF->getVariation('my_feature'); -$variableValue = $childF->getVariable('my_feature', 'my_variable'); +$isEnabled = $childF->isEnabled('checkout'); +$variation = $childF->getVariation('checkout'); +$variableValue = $childF->getVariable('checkout', 'bgColor'); $globalVariableValue = $childF->getVariable('supportEmail'); ``` @@ -738,7 +821,7 @@ Similar to parent SDK, child instances also support several additional methods: - `getVariableBoolean` - `getVariableString` - `getVariableInteger` -- `getVariableDouble` +- `getVariableNumber` - `getVariableArray` - `getVariableObject` - `getVariableJSON` @@ -755,70 +838,14 @@ Both primary and child instances support a `.close()` method. The primary instan $f->close(); ``` -## CLI usage - -This package also provides a CLI tool for running your Featurevisor project's test specs and benchmarking against this PHP SDK: - -All three commands accept repeatable `--target=` options. `test` builds only the selected Target datafiles and runs untargeted assertions plus assertions for those targets. `benchmark` and `assess-distribution` run independently against every selected Target datafile. Without `--target`, existing project-wide behaviour is preserved. Project definitions, test specs, Target discovery, and datafile generation continue to come from the Node.js CLI. - -### Test - -Learn more about testing [here](https://featurevisor.com/docs/testing/). - -``` -$ vendor/bin/featurevisor test --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" -``` - -Additional options that are available: - -``` -$ vendor/bin/featurevisor test \ - --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \ - --quiet|verbose \ - --onlyFailures \ - --keyPattern="myFeatureKey" \ - --assertionPattern="#1" -``` - -If assertions include `target`, the runner builds and selects the corresponding Target datafile automatically via `npx featurevisor build --target= --environment= --json`. - -### Benchmark - -Learn more about benchmarking [here](https://featurevisor.com/docs/cli/#benchmarking). - -``` -$ vendor/bin/featurevisor benchmark \ - --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \ - --environment="production" \ - --feature="myFeatureKey" \ - --context='{"country": "nl"}' \ - --n=1000 -``` - -### Assess distribution - -Learn more about assessing distribution [here](https://featurevisor.com/docs/cli/#assess-distribution). - -``` -$ vendor/bin/featurevisor assess-distribution \ - --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \ - --environment=production \ - --feature=foo \ - --variation \ - --context='{"country": "nl"}' \ - --populateUuid=userId \ - --populateUuid=deviceId \ - --n=1000 -``` - ## OpenFeature -The provider targets OpenFeature PHP SDK `2.x`. OpenFeature remains optional and is not installed or loaded by the base Featurevisor SDK. +The provider is compatible with OpenFeature PHP SDK `2.x`, tested with `2.0.0` and `2.3.0`. OpenFeature remains optional and is not installed or loaded by the base Featurevisor SDK. ### Installation ```bash -composer require featurevisor/featurevisor-php open-feature/sdk:^2.2 +composer require featurevisor/featurevisor-php:^4.0 open-feature/sdk:^2.3 ``` ### Provider setup @@ -895,15 +922,17 @@ $provider = new OpenFeatureProvider( OpenFeature context attributes are copied without mutating the incoming context. Nested arrays are preserved. Dates are normalized to UTC ISO strings with millisecond precision, matching the JavaScript provider. +The provider distinguishes an absent targeting key from an explicitly empty one when called directly. The upstream OpenFeature PHP client merges contexts using only nonempty targeting keys, so it drops an empty key before the provider receives it. To override an existing targeting field with an empty string through that client, set the configured field itself as an attribute, such as `['userId' => '']`. Clear any inherited nonempty targeting key at its source first, since it would overwrite that attribute again. A narrower empty key may not clear it. + ### Resolution details The provider maps Featurevisor evaluation results to OpenFeature details: | Featurevisor result | OpenFeature result | | --- | --- | -| Required feature rule, forced, sticky, or rule match | `TARGETING_MATCH` | +| Composed variable, forced, sticky or rule match | `TARGETING_MATCH` | | Traffic allocation | `SPLIT` | -| Unmet global variable requirements, disabled variation, or disabled variable | `DISABLED` | +| Unmet root conditions, disabled variation or disabled variable | `DISABLED` | | No match or variable default | `DEFAULT` | | Missing feature, variable, or variations | `ERROR` with `FLAG_NOT_FOUND` | | Wrong resolver type | `ERROR` with `TYPE_MISMATCH` | @@ -929,7 +958,7 @@ $provider = new OpenFeatureProvider( ### Using an existing Featurevisor instance -You can reuse an existing Featurevisor instance: +Reuse an existing Featurevisor instance: ```php $featurevisor = Featurevisor::createFeaturevisor(['datafile' => $datafileContent]); @@ -938,14 +967,98 @@ $provider = new OpenFeatureProvider(featurevisor: $featurevisor); The caller owns an instance passed this way. Calling `$provider->shutdown()` does not close it. Call `$featurevisor->close()` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `$featurevisor` takes precedence over `$options`. Shutdown is safe to call more than once. +An invalid initial datafile returns `PARSE_ERROR` during evaluation, independently of `logLevel`. The upstream PHP provider interface has no registration lifecycle to fail. A valid datafile update clears the error. Shutdown removes provider subscriptions and closes only an owned instance. + See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, lifecycle, and providers for other languages. -## Example application +## CLI usage + +Install the package with Composer: -See a complete application using this SDK at [featurevisor-example-php](https://github.com/featurevisor/featurevisor-example-php). +```bash +composer require featurevisor/featurevisor-php:^4.0 +``` + +Run the commands below from the directory containing `vendor`. + +The native test runner requires `@featurevisor/cli` 4.0.0 or later and Node.js 24 or later. Install the Node.js CLI in your Featurevisor project: + +```bash +npm install --save-dev @featurevisor/cli@^4.0.0 +``` + +Project parsing and datafile generation use `npx featurevisor`. Evaluations run through this PHP SDK. The SDK runtime has no Node.js dependency. All three commands accept repeated `--target=` options. + +Use the Node.js CLI directly for authoring commands such as `init`, `lint`, `build` and `list`. Native commands delegate project parsing, test expansion and datafile generation to Node.js, then evaluate the results with this SDK. + +Options use kebab case, such as `--project-directory-path`. The camelCase forms from earlier releases still work, and `--root-directory-path` is an alias for `--project-directory-path`. Pass values as `--name=value` or `--name value`. Repeat `--target` to select several targets in order. Run ` --help` to list every option. + +`--n` and `-n` accept both value forms. Repeat `--populate-uuid` for multiple context attributes. Quote JSON and paths containing spaces. + +### Test + +```bash +vendor/bin/featurevisor test \ + --project-directory-path=/path/to/featurevisor-project \ + --only-failures +``` + +See [Testing](https://featurevisor.com/docs/testing/) for authoring specs and [Checks](https://featurevisor.com/docs/testing/#checks) for check syntax and operators. + +Useful test options: + +- `--key-pattern=` and `--assertion-pattern=` filter tests. +- `--entity-type=scenario` selects scenarios. `--set=` selects one project set. Omitting `--set` runs all sets. +- `--case=` reruns one expanded case. Copy the opaque ID or rerun command from a failure report. +- `--json --pretty` produces an indented JSON report. +- `--coverage` reports observed rule and override selections. `--uncovered` lists features and global variables without associated specs. +- `--only-failures`, `--quiet`, `--verbose` and `--show-datafile` control diagnostic output. + +Bootstrap, argument and project discovery failures may produce no JSON report. Check the exit status and standard error as well as any report. Reruns retain the PHP executable and CLI script. Without a project path, rerun from the same project directory. + +Printed rerun commands use POSIX shell quoting. JSON reports also include raw argument arrays. + +### Benchmark + +The examples below use a project without environments. Add `--environment=` if your project configures environments. In a sets project, omit `--set` to run every set or pass `--set=`. Repeat `--target=` to evaluate specific target datafiles. The Node.js CLI validates these options and builds the datafiles. PHP evaluates them. Replace `checkout` with a feature defined in your project. + +```bash +vendor/bin/featurevisor benchmark \ + --project-directory-path=/path/to/featurevisor-project \ + --feature=checkout \ + --context='{"country":"nl"}' \ + --n=1000 +``` + +Use `--variation` for a variation or `--variable=` for a feature variable. Omit `--feature` to benchmark a global variable. Do not combine `--variation` with `--variable`. See [Benchmarking](https://featurevisor.com/docs/cli/#benchmarking). + +### Assess distribution + +```bash +vendor/bin/featurevisor assess-distribution \ + --project-directory-path=/path/to/featurevisor-project \ + --feature=checkout \ + --populate-uuid=userId \ + --n=1000 +``` + +The PHP CLI also accepts `--variation` or `--variable=` to assess a variation or feature variable instead of the enabled state. These selectors are specific to this runner and are not supported by the Node.js `assess-distribution` command. Repeat `--populate-uuid=` to generate UUID values for multiple context attributes. See [Assess distribution](https://featurevisor.com/docs/cli/#assess-distribution). + +## GitHub repositories + +- SDK: [featurevisor/featurevisor-php](https://github.com/featurevisor/featurevisor-php) +- Example application: [featurevisor/featurevisor-example-php](https://github.com/featurevisor/featurevisor-example-php) + + +The aggregate evaluation reference has moved to [Getting evaluations](#getting-evaluations). + + + +The sticky state reference has moved to [Sticky](#sticky). + ## Development of this package ### Setting up @@ -962,6 +1075,10 @@ $ composer install $ composer test ``` +The native tests cover runtime conformance and project integration. + +Run `vendor/bin/phpunit tests/ReadmeInitializationTest.php` to execute the actual initialization snippet against a local HTTP fixture. This check also runs in the ordinary SDK suite and CI. + Run the complete local release check with: ``` @@ -978,8 +1095,10 @@ $ make test-example-1 ### Releasing +The next breaking release is `4.0.0`. Composer derives the package version from the Git tag, so `composer.json` deliberately has no version field. The Packagist workflow accepts v4 semantic version tags. Run `composer validate --strict`, `composer sast`, `make test-base` and `make test-openfeature` before tagging. OpenFeature remains optional for base consumers. Provider consumers also install `open-feature/sdk:^2.3`. + - Manually create a new release on [GitHub](https://github.com/featurevisor/featurevisor-php/releases) -- Tag it with a prefix of `v`, like `v3.0.0` +- Tag it with a prefix of `v`, like `vX.Y.Z` - The Packagist workflow notifies [Packagist](https://packagist.org/packages/featurevisor/featurevisor-php) after the tag is pushed ## License diff --git a/cli/Options.php b/cli/Options.php new file mode 100644 index 0000000..b7bc1da --- /dev/null +++ b/cli/Options.php @@ -0,0 +1,141 @@ + 'string', 'entityType' => 'string', 'context' => 'string', + 'environment' => 'string', 'feature' => 'string', 'keyPattern' => 'string', + 'n' => 'string', 'onlyFailures' => 'boolean', 'quiet' => 'boolean', + 'showDatafile' => 'boolean', 'variable' => 'string', 'variation' => 'boolean', + 'verbose' => 'boolean', 'inflate' => 'string', 'withScopes' => 'boolean', + 'withTags' => 'boolean', 'schemaVersion' => 'string', + 'projectDirectoryPath' => 'string', 'populateUuid' => 'array', 'target' => 'array', + 'set' => 'string', 'json' => 'boolean', 'pretty' => 'boolean', 'case' => 'string', + 'coverage' => 'boolean', 'uncovered' => 'boolean', 'help' => 'boolean', + ]; +} + +function canonicalCliOption(string $name): string { + $name = preg_replace_callback('/-([a-z])/', static fn(array $match): string => strtoupper($match[1]), $name); + if ($name === 'rootDirectoryPath') return 'projectDirectoryPath'; + return $name === 'h' ? 'help' : $name; +} + +function parseCliArguments(array $argv): array { + $types = cliOptionTypes(); + $values = []; + foreach ($types as $key => $type) $values[$key] = $type === 'array' ? [] : null; + $command = null; + $seen = []; + for ($index = 1; $index < count($argv); $index++) { + $argument = $argv[$index]; + if (substr($argument, 0, 1) !== '-') { + if ($command !== null) throw new InvalidArgumentException('Unexpected argument: '.$argument); + $command = $argument; + continue; + } + if (!preg_match('/^(--[a-zA-Z][a-zA-Z-]*|-(n|h))(=|$)/', $argument)) { + throw new InvalidArgumentException('Unknown option: '.$argument); + } + $parts = explode('=', ltrim($argument, '-'), 2); + $key = canonicalCliOption($parts[0]); + if (!isset($types[$key])) throw new InvalidArgumentException('Unknown option: '.$parts[0]); + $type = $types[$key]; + if ($type !== 'array' && isset($seen[$key])) { + throw new InvalidArgumentException('Option supplied more than once: --'.$parts[0]); + } + $seen[$key] = true; + if ($type === 'boolean') { + $value = $parts[1] ?? null; + if ($value === null && isset($argv[$index + 1]) && in_array($argv[$index + 1], ['true', 'false'], true)) { + $value = $argv[++$index]; + } + if ($value !== null && !in_array($value, ['true', 'false'], true)) { + throw new InvalidArgumentException('--'.$parts[0].' must be true or false'); + } + $values[$key] = $value !== 'false'; + continue; + } + if (count($parts) === 2) { + $value = $parts[1]; + } else { + $value = $argv[$index + 1] ?? null; + if ($value === null || (substr($value, 0, 1) === '-' && !in_array($key, ['n', 'inflate'], true))) { + throw new InvalidArgumentException('--'.$parts[0].' requires a value'); + } + if (substr($value, 0, 2) === '--') throw new InvalidArgumentException('--'.$parts[0].' requires a value'); + $index++; + } + if ($value === '' && !in_array($key, ['keyPattern', 'assertionPattern', 'schemaVersion'], true)) { + throw new InvalidArgumentException('--'.$parts[0].' requires a nonempty value'); + } + if ($type === 'array') $values[$key][] = $value; + else $values[$key] = $value; + } + $values['0'] = $command; + $values['targets'] = array_values(array_unique($values['target'])); + unset($values['target']); + $values['rootDirectoryPath'] = $values['projectDirectoryPath'] ?? getcwd(); + return $values; +} + +function parseCliOption(array $argv, string $key) { + $values = parseCliArguments($argv); + $key = canonicalCliOption($key); + return $values[$key === 'target' ? 'targets' : $key] ?? null; +} + +function parseCliOptions(array $argv, string $key): array { + $value = parseCliOption($argv, $key); + return is_array($value) ? $value : ($value === null ? [] : [$value]); +} + +function validateCliArguments(array $options): void { + if (!in_array($options['0'], ['test', 'benchmark', 'assess-distribution'], true)) { + throw new InvalidArgumentException('Expected test, benchmark or assess-distribution. Use --help for usage.'); + } + if ($options['n'] !== null && (filter_var($options['n'], FILTER_VALIDATE_INT) === false || (int) $options['n'] < 1)) { + throw new InvalidArgumentException('--n must be a positive integer'); + } + validateInflateOption($options['inflate']); + if ($options['entityType'] !== null && !in_array($options['entityType'], ['feature', 'segment', 'variable', 'scenario'], true)) { + throw new InvalidArgumentException('--entity-type must be feature, segment, variable or scenario'); + } + if ($options['context'] !== null) { + $context = json_decode($options['context'], false, 512, JSON_THROW_ON_ERROR); + if (!$context instanceof stdClass) throw new InvalidArgumentException('--context must be a JSON object'); + } +} + +function printCliHelp(): void { + echo <<<'HELP' +Usage: featurevisor [options] + +Node.js owns project parsing, test expansion and datafile building. +The PHP SDK evaluates the generated inputs. + + --project-directory-path PATH Project directory (also --root-directory-path) + --set NAME Select one Set + --target KEY Select a Target, repeat for multiple Targets + --key-pattern PATTERN Filter test keys + --assertion-pattern PATTERN Filter test assertions + --entity-type TYPE feature, segment, variable or scenario + --case ID Select an exact expanded test case + --json --pretty Test report output + --coverage --uncovered Test coverage output + --only-failures --show-datafile Test diagnostics + --quiet --verbose Diagnostic verbosity + --feature KEY --variable KEY Evaluation selectors + --variation Select a variation, not a variable + --environment NAME Required when environments are configured + --context JSON Context object + --populate-uuid KEY Generate a UUID per distribution context attribute, repeatable + --n N, -n N Positive evaluation count (default 1000) + --inflate N Positive datafile inflation factor + --help, -h Show this help + +CamelCase option names remain supported. Values accept --name=value or --name value. +Distribution also supports --variation and feature --variable as PHP extensions. + +HELP; +} diff --git a/cli/Segments.php b/cli/Segments.php new file mode 100644 index 0000000..9aa6b09 --- /dev/null +++ b/cli/Segments.php @@ -0,0 +1,70 @@ + $_) { + $helper = '__featurevisor_segment_test__'.$key; + while (array_key_exists($helper, $datafile['features'] ?? [])) $helper .= '_'; + $datafile['_segmentTestKeys'][$key] = $helper; + $datafile['features'][$helper] = ['bucketBy' => '__segment_test__', 'conditions' => (string) $key, + 'traffic' => [['key' => 'segment', 'conditions' => '*', 'percentage' => 100000]]]; + } + return $datafile; + } + + /** Inspect authored reference closure without invoking SDK getters. */ + public static function preflight(string $key, array $datafile, array $segments): void + { + $pending = [['segment', $key, null]]; + $seen = []; + $expression = static function ($value) use (&$expression, &$pending): void { + if (is_string($value)) { + if (preg_match('/^\s*[\{\[\"]/', $value) === 1) { + try { $expression(json_decode($value, true, 512, JSON_THROW_ON_ERROR)); } catch (\JsonException $error) {} + } elseif ($value !== '*') $pending[] = ['segment', $value, null]; + } elseif (is_array($value)) { + if (isset($value['variable'])) $pending[] = isset($value['feature']) + ? ['feature', $value['feature'], $value['variable']] : ['variable', $value['variable'], null]; + elseif (isset($value['feature'])) $pending[] = ['feature', $value['feature'], null]; + elseif (Normalize::isList($value)) foreach ($value as $child) $expression($child); + else foreach (['and', 'or', 'not'] as $operator) if (isset($value[$operator])) $expression($value[$operator]); + } + }; + $selector = static function (array $entry) use ($expression): void { $expression($entry['conditions'] ?? null); }; + for ($cursor = 0; $cursor < count($pending); $cursor++) { + [$kind, $name, $variable] = $pending[$cursor]; + $identity = json_encode($pending[$cursor], JSON_THROW_ON_ERROR); + if (isset($seen[$identity])) continue; + $seen[$identity] = true; + $definition = $kind === 'segment' ? ($segments[$name] ?? $datafile['segments'][$name] ?? null) + : ($datafile[$kind === 'feature' ? 'features' : 'variables'][$name] ?? null); + if (!is_array($definition)) throw new \RuntimeException($kind.' "'.$name.'" is absent from the selected datafile and test definitions'); + if ($variable !== null && !array_key_exists($variable, $definition['variablesSchema'] ?? [])) { + throw new \RuntimeException('variable "'.$variable.'" is absent from feature "'.$name.'" in the selected datafile'); + } + [$normalized] = Normalize::entity($kind, $name, $definition); + $selector($normalized); + if ($kind === 'feature') { + foreach (array_merge($normalized['force'] ?? [], $normalized['traffic'] ?? []) as $entry) $selector($entry); + if ($variable !== null) foreach (array_merge($normalized['traffic'] ?? [], $normalized['variations'] ?? []) as $entry) { + foreach ($entry['variableOverrides'][$variable] ?? [] as $override) $selector($override); + } + } elseif ($kind === 'variable') { + foreach (array_merge($normalized['overrides'] ?? [], $normalized['overrideProgram'] ?? []) as $entry) $selector($entry); + $compose = $normalized['compose'] ?? null; + if (is_string($compose)) $pending[] = ['variable', $compose, null]; + elseif (is_array($compose)) foreach ($compose as $entry) $expression($entry); + } + } + } +} diff --git a/cli/TestChecks.php b/cli/TestChecks.php new file mode 100644 index 0000000..e968480 --- /dev/null +++ b/cli/TestChecks.php @@ -0,0 +1,847 @@ +getMessage(), 0, $cause); + $this->expected = $expected; + $this->path = $path; + } +} + +final class TestChecks +{ + private const VALUELESS = ['exists', 'notExists', 'isNull', 'notNull', 'empty', 'notEmpty']; + private const OPERATORS = [ + 'exists', 'notExists', 'isNull', 'notNull', 'empty', 'notEmpty', 'equals', 'notEquals', + 'greaterThan', 'greaterThanOrEquals', 'lessThan', 'lessThanOrEquals', + 'contains', 'notContains', 'startsWith', 'endsWith', 'matches', 'notMatches', + 'semverEquals', 'semverNotEquals', 'semverGreaterThan', + 'semverGreaterThanOrEquals', 'semverLessThan', 'semverLessThanOrEquals', + 'before', 'after', 'in', 'notIn', 'includes', 'notIncludes', 'includesAny', 'notIncludesAny', + ]; + private const UNSAFE_PATH_PARTS = ['__proto__', 'prototype', 'constructor']; + private const MAX_SAFE_INTEGER = 9007199254740991; + private static ?object $missing = null; + + /** @return list */ + public static function evaluate($evaluator, $datafile, $checks, ?string $ownerVariable, array &$diagnostics, string $prefix = 'checks', array $segments = [], array $context = []): array + { + return self::evaluateResult($evaluator, $datafile, $checks, $ownerVariable, $diagnostics, $prefix, null, $segments, $context)['failures']; + } + + /** @return array{passed:bool,error:bool,failures:list,errors:list>} */ + public static function evaluateResult($evaluator, $datafile, $checks, ?string $ownerVariable, array &$diagnostics, string $prefix = 'checks', ?array $authoredBase = null, array $segments = [], array $context = []): array + { + $authoredBase = $authoredBase ?? self::authoredPrefix($prefix); + if (!is_array($checks) || count($checks) === 0 || !self::isList($checks)) { + $failures = ["$prefix: checks must be a nonempty array"]; + return [ + 'passed' => false, + 'error' => true, + 'failures' => $failures, + 'errors' => [[ + 'type' => 'check', + 'message' => $failures[0], + 'path' => $authoredBase, + 'expected' => 'valid checks', + 'actual' => $checks, + 'details' => ['evaluated' => []], + ]], + ]; + } + $decoded = is_string($datafile) ? json_decode($datafile, true, 512, JSON_THROW_ON_ERROR) : $datafile; + $validationErrors = []; + try { + self::validateSiblingKeys(array_values($checks), $prefix); + } catch (Throwable $error) { + $validationErrors[] = $error->getMessage(); + } + foreach (array_values($checks) as $index => $check) { + $path = "{$prefix}[$index]"; + try { + self::validateNode($check, $ownerVariable, $path); + } catch (Throwable $error) { + $message = $error->getMessage(); + $validationErrors[] = strpos($message, $path) === 0 ? $message : "$path: $message"; + } + } + if (count($validationErrors) > 0) { + return [ + 'passed' => false, + 'error' => true, + 'failures' => $validationErrors, + 'errors' => array_map(static fn(string $message): array => [ + 'type' => 'check', + 'message' => $message, + 'path' => $authoredBase, + 'expected' => 'valid checks', + 'actual' => $checks, + 'details' => ['evaluated' => []], + ], $validationErrors), + ]; + } + try { + foreach (array_values($checks) as $index => $check) { + self::preflightNode($check, $decoded, $ownerVariable, $segments, "{$prefix}[$index]"); + } + } catch (Throwable $error) { + return [ + 'passed' => false, + 'error' => true, + 'failures' => [$error->getMessage()], + 'errors' => [[ + 'type' => 'check', + 'message' => $error->getMessage(), + 'path' => $authoredBase, + 'expected' => 'valid references', + 'actual' => $checks, + 'details' => ['evaluated' => []], + ]], + ]; + } + $errors = []; + $errorObjects = []; + $hasError = false; + foreach (array_values($checks) as $index => $check) { + $path = "{$prefix}[$index]"; + $authoredPath = array_merge($authoredBase, [$index]); + $evaluated = []; + $details = [ + 'checkIndex' => $index, + ]; + if (array_key_exists('key', $check)) $details['checkKey'] = $check['key']; + if (array_key_exists('description', $check)) $details['checkDescription'] = $check['description']; + try { + [$matched, $detail] = self::evaluateNode($check, $evaluator, $decoded, $ownerVariable, $diagnostics, $path, $authoredPath, $evaluated, $segments, $context); + if (!$matched) { + $errors[] = $detail; + $details['evaluated'] = $evaluated; + $errorObjects[] = [ + 'type' => 'check', + 'message' => $detail, + 'path' => $authoredPath, + 'expected' => true, + 'actual' => false, + 'details' => $details, + ]; + } + } catch (Throwable $error) { + $hasError = true; + $message = $error->getMessage(); + $message = strpos($message, $path) === 0 ? $message : "$path: $message"; + $errors[] = $message; + $details['evaluated'] = $evaluated; + $errorObjects[] = [ + 'type' => 'check', + 'message' => $message, + 'path' => $authoredPath, + 'expected' => 'successful comparison', + 'actual' => $error->getMessage(), + 'details' => $details, + ]; + } + } + return ['passed' => count($errors) === 0, 'error' => $hasError, 'failures' => $errors, 'errors' => $errorObjects]; + } + + public static function preflight($datafile, $checks, ?string $ownerVariable, array $segments = [], array $pathBase = ['checks']): void + { + try { + if (!is_array($checks) || count($checks) === 0 || !self::isList($checks)) { + throw new RuntimeException('checks must be a nonempty array'); + } + self::validateSiblingKeys(array_values($checks), 'checks'); + } catch (Throwable $error) { + throw new CheckPreflightError($error, 'valid checks', $pathBase); + } + foreach (array_values($checks) as $index => $check) { + try { + self::validateNode($check, $ownerVariable, "checks[$index]"); + } catch (Throwable $error) { + throw new CheckPreflightError($error, 'valid checks', array_merge($pathBase, [$index])); + } + } + $decoded = is_string($datafile) ? json_decode($datafile, true, 512, JSON_THROW_ON_ERROR) : $datafile; + foreach (array_values($checks) as $index => $check) { + try { + self::preflightNode($check, $decoded, $ownerVariable, $segments, "checks[$index]"); + } catch (Throwable $error) { + throw new CheckPreflightError($error, 'valid references', array_merge($pathBase, [$index])); + } + } + } + + private static function authoredPrefix(string $prefix): array + { + if (preg_match('/^children\[(\d+)\]\.checks$/', $prefix, $matches)) { + return ['children', (int) $matches[1], 'checks']; + } + return [$prefix]; + } + + /** @param list $nodes */ + private static function validateSiblingKeys(array $nodes, string $path): void + { + $seen = []; + foreach ($nodes as $index => $node) { + if (!is_array($node) || !array_key_exists('key', $node)) continue; + $key = $node['key']; + if (!is_string($key) || $key === '') throw new RuntimeException("{$path}[$index].key: key must be a nonempty string"); + if (array_key_exists($key, $seen)) { + throw new RuntimeException("{$path}[$index].key: duplicate sibling key \"$key\", first used at {$path}[{$seen[$key]}]"); + } + $seen[$key] = $index; + } + } + + private static function missing(): object + { + if (self::$missing === null) self::$missing = new \stdClass(); + return self::$missing; + } + + private static function validateNode($node, ?string $ownerVariable, string $path): void + { + if (!is_array($node)) throw new RuntimeException("$path: check must be an object"); + if (array_key_exists('key', $node) && (!is_string($node['key']) || $node['key'] === '')) { + throw new RuntimeException("$path.key: key must be a nonempty string"); + } + if (array_key_exists('description', $node) && !is_string($node['description'])) { + throw new RuntimeException("$path.description: description must be a string"); + } + if (self::isSegmentCheck($node)) { + self::validateSegmentCheck($node, $path); + return; + } + $groups = array_values(array_filter(['and', 'or', 'not'], static fn($key) => array_key_exists($key, $node))); + if (count($groups) > 0) { + $allowed = [$groups[0], 'key', 'description']; + if (count($groups) !== 1 || count(array_diff(array_keys($node), $allowed)) > 0) { + throw new RuntimeException("$path: group must contain exactly one of and, or or not plus optional key and description"); + } + $group = $groups[0]; + $children = $node[$group]; + if (!is_array($children) || count($children) === 0 || !self::isList($children)) throw new RuntimeException("$path.$group: group must be a nonempty array"); + self::validateSiblingKeys(array_values($children), "$path.$group"); + foreach (array_values($children) as $index => $child) self::validateNode($child, $ownerVariable, "{$path}.{$group}[$index]"); + return; + } + if (self::isFeatureCheck($node)) { + self::validateFeatureCheck($node, $path); + return; + } + $operator = $node['operator'] ?? null; + $allowed = ['variable', 'feature', 'path', 'operator', 'value', 'compareTo', 'regexFlags', 'key', 'description']; + $unknown = current(array_values(array_diff(array_keys($node), $allowed))); + if ($unknown !== false) throw new RuntimeException("$path: unknown check property \"$unknown\""); + if (!is_string($operator) || !in_array($operator, self::OPERATORS, true)) throw new RuntimeException("$path: unknown check operator ".self::json($operator)); + self::validateReference($node, $ownerVariable, $path); + $supplied = (array_key_exists('value', $node) ? 1 : 0) + (array_key_exists('compareTo', $node) ? 1 : 0); + if (in_array($operator, self::VALUELESS, true)) { + if ($supplied !== 0) throw new RuntimeException("$path: operator \"$operator\" accepts neither value nor compareTo"); + } elseif ($supplied !== 1) { + throw new RuntimeException("$path: exactly one of value or compareTo is required"); + } + if (array_key_exists('regexFlags', $node)) { + if (!in_array($operator, ['matches', 'notMatches'], true)) throw new RuntimeException("$path: regexFlags are only allowed with matches or notMatches"); + self::regexFlags($node['regexFlags'], false); + } + if (array_key_exists('compareTo', $node)) { + $other = $node['compareTo']; + if (!is_array($other)) throw new RuntimeException("$path.compareTo: reference must be an object"); + $unknown = current(array_values(array_diff(array_keys($other), ['variable', 'feature', 'path']))); + if ($unknown !== false) throw new RuntimeException("$path.compareTo: unknown reference property \"$unknown\""); + self::validateReference($other, $ownerVariable, "$path.compareTo"); + } elseif (array_key_exists('value', $node)) { + self::validateLiteral($operator, $node['value'], $node['regexFlags'] ?? null, $path); + } + } + + private static function isFeatureCheck(array $node): bool + { + return array_key_exists('enabled', $node) + || array_key_exists('variation', $node) + || (array_key_exists('feature', $node) && !array_key_exists('variable', $node) && !array_key_exists('operator', $node)); + } + + private static function isSegmentCheck(array $node): bool + { + return array_key_exists('segment', $node) || array_key_exists('matches', $node); + } + + private static function validateSegmentCheck(array $node, string $path): void + { + $unknown = current(array_values(array_diff(array_keys($node), ['segment', 'matches', 'key', 'description']))); + if ($unknown !== false) throw new RuntimeException("$path: segment check cannot contain property \"$unknown\""); + if (!is_string($node['segment'] ?? null) || $node['segment'] === '') { + throw new RuntimeException("$path: segment check requires a nonempty segment"); + } + if (!array_key_exists('matches', $node) || !is_bool($node['matches'])) { + throw new RuntimeException("$path: segment \"{$node['segment']}\" matches must be a boolean"); + } + } + + private static function validateFeatureCheck(array $node, string $path): void + { + $unknown = current(array_values(array_diff(array_keys($node), ['feature', 'enabled', 'variation', 'key', 'description']))); + if ($unknown !== false) throw new RuntimeException("$path: feature check cannot contain property \"$unknown\""); + if (!is_string($node['feature'] ?? null) || $node['feature'] === '') throw new RuntimeException("$path: feature must be a nonempty string"); + if (!array_key_exists('enabled', $node) && !array_key_exists('variation', $node)) { + throw new RuntimeException("$path: feature check requires enabled or variation"); + } + if (array_key_exists('enabled', $node) && !is_bool($node['enabled'])) throw new RuntimeException("$path: enabled must be a boolean"); + if (array_key_exists('variation', $node) && !is_string($node['variation']) && $node['variation'] !== null) { + throw new RuntimeException("$path: variation must be a string or null"); + } + } + + private static function validateReference(array $reference, ?string $ownerVariable, string $path): void + { + if (array_key_exists('variable', $reference) && (!is_string($reference['variable']) || $reference['variable'] === '')) { + throw new RuntimeException("$path: variable must be a nonempty string"); + } + if (array_key_exists('feature', $reference) && (!is_string($reference['feature']) || $reference['feature'] === '')) { + throw new RuntimeException("$path: feature must be a nonempty string"); + } + if (array_key_exists('feature', $reference) && !array_key_exists('variable', $reference)) { + throw new RuntimeException("$path: a feature reference requires an explicit variable"); + } + if (!array_key_exists('variable', $reference) && $ownerVariable === null) { + throw new RuntimeException("$path: variable reference requires an explicit variable"); + } + if (array_key_exists('path', $reference) && $reference['path'] === null) { + throw new RuntimeException("$path: path must not be null"); + } + self::pathParts($reference['path'] ?? null); + } + + private static function validateReferenceMembership(array $reference, array $datafile, ?string $ownerVariable, string $path): void + { + $variable = $reference['variable'] ?? $ownerVariable; + $feature = $reference['feature'] ?? null; + if ($feature !== null) { + $definition = $datafile['features'][$feature] ?? null; + if (!is_array($definition)) throw new RuntimeException("$path: feature \"$feature\" is absent from the selected datafile"); + if (!array_key_exists($variable, $definition['variablesSchema'] ?? [])) { + throw new RuntimeException("$path: variable \"$variable\" is absent from feature \"$feature\" in the selected datafile"); + } + } elseif (!array_key_exists($variable, $datafile['variables'] ?? [])) { + throw new RuntimeException("$path: global variable \"$variable\" is absent from the selected datafile"); + } + } + + private static function preflightNode(array $node, array $datafile, ?string $ownerVariable, array $segments, string $path): void + { + if (self::isSegmentCheck($node)) { + $definition = $segments[$node['segment']] ?? null; + if (!is_array($definition) || !array_key_exists('conditions', $definition)) { + throw new RuntimeException("$path: segment \"{$node['segment']}\" definition is missing from the normalized test spec"); + } + Segments::preflight($node['segment'], $datafile, $segments); + return; + } + foreach (['and', 'or', 'not'] as $group) { + if (!array_key_exists($group, $node)) continue; + foreach (array_values($node[$group]) as $index => $child) { + self::preflightNode($child, $datafile, $ownerVariable, $segments, "{$path}.{$group}[$index]"); + } + return; + } + if (self::isFeatureCheck($node)) { + if (!is_array($datafile['features'][$node['feature']] ?? null)) { + throw new RuntimeException("$path: feature \"{$node['feature']}\" is absent from the selected datafile"); + } + return; + } + self::validateReferenceMembership($node, $datafile, $ownerVariable, $path); + if (array_key_exists('compareTo', $node)) { + self::validateReferenceMembership($node['compareTo'], $datafile, $ownerVariable, "$path.compareTo"); + } + } + + private static function validateLiteral(string $operator, $value, $regexFlags, string $path): void + { + if (in_array($operator, ['equals', 'notEquals'], true)) { + if (!self::primitive($value)) throw new RuntimeException("$path: operator \"$operator\" requires a scalar literal"); + return; + } + if (in_array($operator, ['greaterThan', 'greaterThanOrEquals', 'lessThan', 'lessThanOrEquals'], true)) { + if (!self::finiteNumber($value)) throw new RuntimeException("$path: operator \"$operator\" requires a numeric literal"); + return; + } + if (in_array($operator, ['contains', 'notContains', 'startsWith', 'endsWith'], true)) { + if (!is_string($value)) throw new RuntimeException("$path: operator \"$operator\" requires a string literal"); + return; + } + if (in_array($operator, ['matches', 'notMatches'], true)) { + if (!is_string($value)) throw new RuntimeException("$path: operator \"$operator\" requires a string literal"); + self::regex($value, $regexFlags); + return; + } + if (strpos($operator, 'semver') === 0) { + if (!is_string($value)) throw new RuntimeException("$path: operator \"$operator\" requires a string literal"); + CompareVersions::compare($value, $value); + return; + } + if ($operator === 'before' || $operator === 'after') { + self::date($value); + return; + } + if ($operator === 'in' || $operator === 'notIn') { + if (!is_array($value) || !self::isList($value) || count(array_filter($value, static fn($item) => !self::membershipPrimitive($item))) > 0) { + throw new RuntimeException("$path: operator \"$operator\" requires a scalar array literal"); + } + return; + } + if ($operator === 'includesAny' || $operator === 'notIncludesAny') { + if (!is_array($value) || !self::isList($value)) throw new RuntimeException("$path: operator \"$operator\" requires a primitive array literal"); + foreach ($value as $candidate) { + if (!self::primitive($candidate)) throw new RuntimeException("$path: operator \"$operator\" requires a primitive array literal"); + } + return; + } + if ($operator === 'includes' || $operator === 'notIncludes') { + if (!self::membershipPrimitive($value)) throw new RuntimeException("$path: operator \"$operator\" requires a scalar literal"); + } + } + + /** @param list> $evaluated @return array{bool,string} */ + private static function evaluateNode(array $node, $evaluator, array $datafile, ?string $ownerVariable, array &$diagnostics, string $path, array $authoredPath, array &$evaluated, array $segments, array $context): array + { + $labelledPath = self::checkLabel($path, $node); + foreach (['and', 'or', 'not'] as $group) { + if (!array_key_exists($group, $node)) continue; + $evidence = []; + if ($group === 'and' || $group === 'not') { + foreach (array_values($node[$group]) as $index => $child) { + [$matched, $detail] = self::evaluateNode($child, $evaluator, $datafile, $ownerVariable, $diagnostics, "{$path}.{$group}[$index]", array_merge($authoredPath, [$group, $index]), $evaluated, $segments, $context); + $evidence[] = $detail; + if (!$matched) return [$group === 'not', "$labelledPath: $detail"]; + } + return [$group !== 'not', $labelledPath.': '.($evidence[count($evidence) - 1] ?? 'group did not match')]; + } + foreach (array_values($node[$group]) as $index => $child) { + [$matched, $detail] = self::evaluateNode($child, $evaluator, $datafile, $ownerVariable, $diagnostics, "{$path}.{$group}[$index]", array_merge($authoredPath, [$group, $index]), $evaluated, $segments, $context); + $evidence[] = $detail; + if ($matched) return [true, $detail]; + } + return [false, $labelledPath.': '.implode('; ', $evidence)]; + } + if (self::isSegmentCheck($node)) { + return self::evaluateSegmentCheck($node, $evaluator, $datafile, $diagnostics, $segments, $context, $labelledPath, $authoredPath, $evaluated); + } + if (self::isFeatureCheck($node)) { + return self::evaluateFeatureCheck($node, $evaluator, $datafile, $diagnostics, $labelledPath, $authoredPath, $evaluated); + } + $leftReference = array_intersect_key($node, array_flip(['variable', 'feature', 'path'])); + if (!array_key_exists('variable', $leftReference) && $ownerVariable !== null) $leftReference['variable'] = $ownerVariable; + $traceIndex = count($evaluated); + $trace = [ + 'path' => $authoredPath, + 'reference' => $leftReference, + 'operator' => $node['operator'], + 'actual' => '', + 'expected' => array_key_exists('value', $node) ? $node['value'] : ['$type' => 'missing'], + ]; + if (!array_key_exists('value', $node) && !array_key_exists('compareTo', $node)) $trace['expectedMissing'] = true; + if (array_key_exists('key', $node)) $trace['key'] = $node['key']; + if (array_key_exists('description', $node)) $trace['description'] = $node['description']; + if (array_key_exists('compareTo', $node)) { + $trace['compareTo'] = $node['compareTo']; + if (!array_key_exists('variable', $trace['compareTo']) && $ownerVariable !== null) $trace['compareTo']['variable'] = $ownerVariable; + } + $evaluated[] = $trace; + try { + $left = self::resolve($evaluator, $datafile, $leftReference, $ownerVariable, $diagnostics); + $evaluated[$traceIndex]['actual'] = self::reportValue($left); + if ($left === self::missing()) $evaluated[$traceIndex]['actualMissing'] = true; + } catch (Throwable $error) { + throw new RuntimeException("$path.left(".self::referenceLabel($leftReference, $ownerVariable).'): '.$error->getMessage(), 0, $error); + } + if (array_key_exists('compareTo', $node)) { + $rightReference = $node['compareTo']; + try { + $right = self::resolve($evaluator, $datafile, $rightReference, $ownerVariable, $diagnostics); + $evaluated[$traceIndex]['expected'] = self::reportValue($right); + if ($right === self::missing()) $evaluated[$traceIndex]['expectedMissing'] = true; + } catch (Throwable $error) { + throw new RuntimeException("$path.compareTo(".self::referenceLabel($rightReference, $ownerVariable).'): '.$error->getMessage(), 0, $error); + } + $rightLabel = self::referenceLabel($rightReference, $ownerVariable); + } else { + $right = array_key_exists('value', $node) ? $node['value'] : self::missing(); + $rightLabel = 'literal'; + } + $matched = self::compare($node['operator'], $left, $right, $node['regexFlags'] ?? null); + return [$matched, "$labelledPath: ".self::referenceLabel($leftReference, $ownerVariable)." {$node['operator']} $rightLabel failed (left=".self::json($left).", right=".self::json($right).')']; + } + + private static function checkLabel(string $path, array $node): string + { + $metadata = []; + if (array_key_exists('key', $node)) $metadata[] = 'key='.self::json($node['key']); + if (array_key_exists('description', $node)) $metadata[] = 'description='.self::json($node['description']); + return count($metadata) > 0 ? $path.' ['.implode(', ', $metadata).']' : $path; + } + + /** @param list> $evaluated @return array{bool,string} */ + private static function evaluateSegmentCheck(array $node, $evaluator, array $datafile, array &$diagnostics, array $segments, array $context, string $path, array $authoredPath, array &$evaluated): array + { + $segment = $node['segment']; + $definition = $segments[$segment] ?? null; + if (!is_array($definition) || !array_key_exists('conditions', $definition)) { + throw new RuntimeException("segment \"$segment\" definition is missing from the normalized test spec"); + } + $diagnosticStart = count($diagnostics); + $helper = $datafile['_segmentTestKeys'][$segment] ?? null; + if ($helper === null) throw new RuntimeException('segment evaluations were not prepared'); + $evaluation = $evaluator->evaluateFlag($helper); + if (($evaluation['reason'] ?? null) === 'error') throw new RuntimeException('segment evaluation failed'); + foreach (array_slice($diagnostics, $diagnosticStart) as $diagnostic) if (($diagnostic['level'] ?? null) === 'error') throw new RuntimeException($diagnostic['message'] ?? 'segment evaluation failed'); + $actual = ($evaluation['enabled'] ?? false) === true; + $trace = [ + 'path' => $authoredPath, + 'reference' => ['segment' => $segment], + 'property' => 'matches', + 'operator' => 'equals', + 'expected' => $node['matches'], + 'actual' => $actual, + ]; + if (array_key_exists('key', $node)) $trace['key'] = $node['key']; + if (array_key_exists('description', $node)) $trace['description'] = $node['description']; + $evaluated[] = $trace; + return [$actual === $node['matches'], "$path: segment \"$segment\" expected matches=".self::json($node['matches']).', actual matches='.self::json($actual)]; + } + + /** @param list> $evaluated @return array{bool,string} */ + private static function evaluateFeatureCheck(array $node, $evaluator, array $datafile, array &$diagnostics, string $path, array $authoredPath, array &$evaluated): array + { + $feature = $node['feature']; + if (!is_array($datafile['features'][$feature] ?? null)) { + throw new RuntimeException("feature \"$feature\" is absent from the selected datafile"); + } + $expected = []; + $actual = []; + if (array_key_exists('enabled', $node)) { + $expected['enabled'] = $node['enabled']; + $traceIndex = count($evaluated); + $evaluated[] = self::featureTrace($node, $authoredPath, 'enabled'); + $actual['enabled'] = self::featureGetter($evaluator, $feature, 'enabled', $diagnostics); + $evaluated[$traceIndex]['actual'] = $actual['enabled']; + } + if (array_key_exists('variation', $node)) { + $expected['variation'] = $node['variation']; + $traceIndex = count($evaluated); + $evaluated[] = self::featureTrace($node, $authoredPath, 'variation'); + $actual['variation'] = self::featureGetter($evaluator, $feature, 'variation', $diagnostics); + $evaluated[$traceIndex]['actual'] = $actual['variation']; + } + $matched = true; + $details = []; + foreach ($expected as $key => $value) { + $matched = $matched && self::strictEqual($actual[$key], $value); + $details[] = 'expected '.$key.'='.self::json($value).', actual '.$key.'='.self::json($actual[$key]); + } + return [$matched, "$path: feature \"$feature\" expectations failed (".implode(', ', $details).')']; + } + + /** @return array */ + private static function featureTrace(array $node, array $path, string $property): array + { + $trace = [ + 'path' => $path, + 'reference' => ['feature' => $node['feature']], + 'property' => $property, + 'operator' => 'equals', + 'actual' => '', + 'expected' => $node[$property], + ]; + if (array_key_exists('key', $node)) $trace['key'] = $node['key']; + if (array_key_exists('description', $node)) $trace['description'] = $node['description']; + return $trace; + } + + /** @return mixed */ + private static function reportValue($value) + { + return $value === self::missing() ? ['$type' => 'missing'] : $value; + } + + private static function featureGetter($evaluator, string $feature, string $getter, array &$diagnostics) + { + $diagnosticStart = count($diagnostics); + try { + $value = $getter === 'enabled' ? $evaluator->isEnabled($feature, []) : $evaluator->getVariation($feature, []); + } catch (Throwable $error) { + throw new RuntimeException("feature \"$feature\" $getter getter failed: ".$error->getMessage(), 0, $error); + } + foreach (array_slice($diagnostics, $diagnosticStart) as $diagnostic) { + if (($diagnostic['level'] ?? null) === 'error') { + throw new RuntimeException("feature \"$feature\" $getter getter reported ".($diagnostic['code'] ?? 'an error').': '.($diagnostic['message'] ?? 'evaluation failed')); + } + } + return $value; + } + + private static function resolve($evaluator, array $datafile, array $reference, ?string $ownerVariable, array &$diagnostics) + { + $variable = $reference['variable'] ?? $ownerVariable; + $feature = $reference['feature'] ?? null; + if (!is_string($variable) || $variable === '') throw new RuntimeException('variable reference requires a nonempty variable'); + if ($feature !== null) { + if (!is_string($feature) || $feature === '') throw new RuntimeException('feature reference requires a nonempty feature'); + $definition = $datafile['features'][$feature] ?? null; + if (!is_array($definition)) throw new RuntimeException("feature \"$feature\" is absent from the selected datafile"); + if (!array_key_exists($variable, $definition['variablesSchema'] ?? [])) throw new RuntimeException("variable \"$variable\" is absent from feature \"$feature\" in the selected datafile"); + } elseif (!array_key_exists($variable, $datafile['variables'] ?? [])) { + throw new RuntimeException("global variable \"$variable\" is absent from the selected datafile"); + } + $diagnosticStart = count($diagnostics); + try { + $options = ['__featurevisorPreserveJsonObjects' => true]; + $value = $feature !== null ? $evaluator->getVariable($feature, $variable, [], $options) : $evaluator->getVariable($variable, [], $options); + } catch (Throwable $error) { + throw new RuntimeException('variable getter failed: '.$error->getMessage(), 0, $error); + } + foreach (array_slice($diagnostics, $diagnosticStart) as $diagnostic) { + if (($diagnostic['level'] ?? null) === 'error') { + throw new RuntimeException('variable getter reported '.($diagnostic['code'] ?? 'an error').': '.($diagnostic['message'] ?? 'evaluation failed')); + } + } + return self::atPath($value, self::pathParts($reference['path'] ?? null)); + } + + /** @return list */ + private static function pathParts($path): array + { + if ($path === null) return []; + if (is_string($path)) { + $parts = explode('.', $path); + if (in_array('', $parts, true)) throw new RuntimeException('path must not contain empty dot components'); + foreach ($parts as $index => $part) { + if (preg_match('/^(0|[1-9]\d*)$/', $part)) { + if (strlen($part) > 16 || (strlen($part) === 16 && strcmp($part, (string) self::MAX_SAFE_INTEGER) > 0)) { + throw new RuntimeException('path indices must be safe nonnegative integers'); + } + $parts[$index] = (int) $part; + } + } + } elseif (is_array($path)) { + $parts = array_values($path); + } else { + throw new RuntimeException('path must be a dot separated string or an array'); + } + foreach ($parts as $part) { + if ((!is_string($part) && !is_int($part)) || (is_int($part) && ($part < 0 || $part > self::MAX_SAFE_INTEGER))) throw new RuntimeException('path entries must be string keys or safe nonnegative integer indices'); + if (is_string($part) && in_array($part, self::UNSAFE_PATH_PARTS, true)) throw new RuntimeException("unsafe path segment \"$part\" is not allowed"); + if ($part === '*') throw new RuntimeException('wildcards are not allowed in paths'); + } + return $parts; + } + + private static function atPath($value, array $parts) + { + $current = $value; + foreach ($parts as $part) { + if ($current instanceof \stdClass) { + if (!is_string($part) || !property_exists($current, $part)) return self::missing(); + $current = $current->{$part}; + continue; + } + if (is_int($part)) { + if (!is_array($current) || !self::isList($current) || !array_key_exists($part, $current)) return self::missing(); + } elseif (is_array($current) && self::isList($current)) { + if ($part === 'length') { + $current = count($current); + continue; + } + if (!preg_match('/^(0|[1-9]\d*)$/', $part) || !array_key_exists((int) $part, $current)) return self::missing(); + $part = (int) $part; + } elseif (!is_array($current) || !array_key_exists($part, $current)) { + return self::missing(); + } + $current = $current[$part]; + } + return $current; + } + + private static function primitive($value): bool + { + return $value === null || is_string($value) || is_bool($value) || ((is_int($value) || is_float($value)) && is_finite((float) $value)); + } + + private static function membershipPrimitive($value): bool + { + return $value === null || is_string($value) || self::finiteNumber($value); + } + + private static function finiteNumber($value): bool + { + return !is_bool($value) && (is_int($value) || is_float($value)) && is_finite((float) $value); + } + + private static function strictEqual($left, $right): bool + { + if ((is_int($left) || is_float($left)) && (is_int($right) || is_float($right))) return (float) $left === (float) $right; + return gettype($left) === gettype($right) && $left === $right; + } + + private static function compare(string $operator, $left, $right, $regexFlags): bool + { + if ($operator === 'exists') return $left !== self::missing(); + if ($operator === 'notExists') return $left === self::missing(); + if ($operator === 'isNull') return $left === null; + if ($operator === 'notNull') return $left !== self::missing() && $left !== null; + if ($operator === 'empty' || $operator === 'notEmpty') { + if ($left === self::missing() || $left === null) return false; + if (is_string($left) || is_array($left)) { + $size = is_string($left) ? strlen($left) : count($left); + } elseif ($left instanceof \stdClass) { + $size = count(get_object_vars($left)); + } else { + throw new RuntimeException("operator \"$operator\" requires a string, array or object operand"); + } + return $operator === 'empty' ? $size === 0 : $size > 0; + } + if ($left === self::missing() || $right === self::missing()) return false; + if ($operator === 'equals' || $operator === 'notEquals') { + if (!self::primitive($left) || !self::primitive($right)) throw new RuntimeException("operator \"$operator\" only supports scalar values"); + $matched = self::strictEqual($left, $right); + return $operator === 'equals' ? $matched : !$matched; + } + if (in_array($operator, ['greaterThan', 'greaterThanOrEquals', 'lessThan', 'lessThanOrEquals'], true)) { + if ((!is_int($left) && !is_float($left)) || (!is_int($right) && !is_float($right)) || !is_finite((float) $left) || !is_finite((float) $right)) throw new RuntimeException("operator \"$operator\" requires numeric operands"); + return ['greaterThan' => $left > $right, 'greaterThanOrEquals' => $left >= $right, 'lessThan' => $left < $right, 'lessThanOrEquals' => $left <= $right][$operator]; + } + if (in_array($operator, ['contains', 'notContains', 'startsWith', 'endsWith', 'matches', 'notMatches'], true)) { + if (!is_string($left) || !is_string($right)) throw new RuntimeException("operator \"$operator\" requires string operands"); + if ($operator === 'contains') return strpos($left, $right) !== false; + if ($operator === 'notContains') return strpos($left, $right) === false; + if ($operator === 'startsWith') return strpos($left, $right) === 0; + if ($operator === 'endsWith') return $right === '' || substr($left, -strlen($right)) === $right; + $matched = preg_match(self::regex($right, $regexFlags), $left) === 1; + return $operator === 'matches' ? $matched : !$matched; + } + if (strpos($operator, 'semver') === 0) { + if (!is_string($left) || !is_string($right)) throw new RuntimeException("operator \"$operator\" requires string operands"); + $compared = CompareVersions::compare($left, $right); + return ['semverEquals' => $compared === 0, 'semverNotEquals' => $compared !== 0, 'semverGreaterThan' => $compared === 1, 'semverGreaterThanOrEquals' => $compared >= 0, 'semverLessThan' => $compared === -1, 'semverLessThanOrEquals' => $compared <= 0][$operator]; + } + if ($operator === 'before' || $operator === 'after') { + $leftDate = self::date($left); $rightDate = self::date($right); + return $operator === 'before' ? $leftDate < $rightDate : $leftDate > $rightDate; + } + if ($operator === 'in' || $operator === 'notIn') { + if (!self::membershipPrimitive($left) || !is_array($right) || !self::isList($right) || count(array_filter($right, static fn($value) => !self::membershipPrimitive($value))) > 0) throw new RuntimeException("operator \"$operator\" requires a scalar left operand and scalar array right operand"); + $matched = count(array_filter($right, static fn($value) => self::strictEqual($left, $value))) > 0; + return $operator === 'in' ? $matched : !$matched; + } + if ($operator === 'includesAny' || $operator === 'notIncludesAny') { + if (!is_array($left) || !self::isList($left) || !is_array($right) || !self::isList($right)) throw new RuntimeException("operator \"$operator\" requires an array left operand and primitive array right operand"); + foreach ($right as $candidate) { + if (!self::primitive($candidate)) throw new RuntimeException("operator \"$operator\" requires an array left operand and primitive array right operand"); + } + foreach ($right as $candidate) { + foreach ($left as $actual) { + if (self::strictEqual($actual, $candidate)) return $operator === 'includesAny'; + } + } + return $operator === 'notIncludesAny'; + } + if ($operator === 'includes' || $operator === 'notIncludes') { + if (!is_array($left) || !self::isList($left) || !self::membershipPrimitive($right)) throw new RuntimeException("operator \"$operator\" requires an array left operand and scalar right operand"); + $matched = count(array_filter($left, static fn($value) => self::strictEqual($right, $value))) > 0; + return $operator === 'includes' ? $matched : !$matched; + } + throw new RuntimeException("unknown check operator \"$operator\""); + } + + private static function regex(string $pattern, $flags): string + { + $flags = self::regexFlags($flags); + if (preg_match('/\(\?/', $pattern) || preg_match('/\\\\(?:[1-9]|k<|k\'|g<|g\')/', $pattern) || preg_match('/(?:[?*+]|\{\d+(?:,\d*)?\})\+/', $pattern)) throw new RuntimeException('regular expression uses syntax outside the portable subset'); + $delimiter = '~'; + $expression = $delimiter.str_replace($delimiter, '\\'.$delimiter, $pattern).$delimiter.str_replace('g', '', $flags); + if (@preg_match($expression, '') === false) throw new RuntimeException('invalid regular expression'); + return $expression; + } + + private static function regexFlags($flags, bool $allowNull = true): string + { + if ($flags === null && $allowNull) $flags = ''; + if (!is_string($flags) || !preg_match('/^[gims]*$/', $flags) || strlen(count_chars($flags, 3)) !== strlen($flags)) throw new RuntimeException('regexFlags must contain unique characters from g, i, m and s'); + return $flags; + } + + private static function date($value): int + { + if (!is_string($value) || !preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/', $value, $parts)) { + throw new RuntimeException('date comparisons require valid portable timestamps'); + } + $year = (int) $parts[1]; $month = (int) $parts[2]; $day = (int) $parts[3]; + $hour = (int) $parts[4]; $minute = (int) $parts[5]; $second = (int) $parts[6]; + if ($year < 1 || $year > 9999 || !checkdate($month, $day, $year) || $hour > 23 || $minute > 59 || $second > 59) { + throw new RuntimeException('date comparisons require valid portable timestamps'); + } + $zone = $parts[8]; + $offset = 0; + if ($zone !== 'Z') { + $offsetHour = (int) substr($zone, 1, 2); $offsetMinute = (int) substr($zone, 4, 2); + if ($offsetHour > 14 || $offsetMinute > 59 || ($offsetHour === 14 && $offsetMinute !== 0)) { + throw new RuntimeException('date comparisons require valid portable timestamps'); + } + $offset = $offsetHour * 3600 + $offsetMinute * 60; + if ($zone[0] === '-') $offset = -$offset; + } + $days = self::daysBeforeYear($year) + self::daysBeforeMonth($year, $month) + $day - 1; + $seconds = $days * 86400 + $hour * 3600 + $minute * 60 + $second - $offset; + $maximum = (self::daysBeforeYear(10000) * 86400) - 1; + if ($seconds < 0 || $seconds > $maximum) throw new RuntimeException('date comparisons require valid portable timestamps'); + $fraction = $parts[7] !== '' ? str_pad(substr($parts[7], 0, 3), 3, '0') : '000'; + return $seconds * 1000 + (int) $fraction; + } + + private static function daysBeforeYear(int $year): int + { + $prior = $year - 1; + return 365 * $prior + intdiv($prior, 4) - intdiv($prior, 100) + intdiv($prior, 400); + } + + private static function daysBeforeMonth(int $year, int $month): int + { + $days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334][$month - 1]; + $leap = $year % 4 === 0 && ($year % 100 !== 0 || $year % 400 === 0); + return $days + ($leap && $month > 2 ? 1 : 0); + } + + private static function referenceLabel(array $reference, ?string $ownerVariable): string + { + $variable = $reference['variable'] ?? $ownerVariable ?? ''; + $root = isset($reference['feature']) ? $reference['feature'].'.'.$variable : $variable; + return !array_key_exists('path', $reference) || $reference['path'] === [] ? $root : $root.':'.self::json($reference['path']); + } + + private static function isList(array $value): bool + { + return array_keys($value) === range(0, count($value) - 1) || $value === []; + } + + private static function json($value): string + { + return $value === self::missing() ? '' : json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } +} diff --git a/composer.json b/composer.json index 7c3289f..31ef5d7 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,7 @@ "ext-json": "*" }, "require-dev": { - "open-feature/sdk": "^2.2", + "open-feature/sdk": "^2.3", "phpunit/phpunit": "^9.6.33", "phpstan/phpstan": "^2.2" }, diff --git a/composer.lock b/composer.lock index d8ebee6..5a692bb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5d2d50293138c70b49be2aaf0541d9bc", + "content-hash": "dee899d87c175cacc33ed7f01600f689", "packages": [], "packages-dev": [ { @@ -259,16 +259,16 @@ }, { "name": "open-feature/sdk", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/open-feature/php-sdk.git", - "reference": "40638875cb9050aec75126d7f714290eb034ca24" + "reference": "eacce4e1465e098223d46dd71029928d9a52f57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/open-feature/php-sdk/zipball/40638875cb9050aec75126d7f714290eb034ca24", - "reference": "40638875cb9050aec75126d7f714290eb034ca24", + "url": "https://api.github.com/repos/open-feature/php-sdk/zipball/eacce4e1465e098223d46dd71029928d9a52f57e", + "reference": "eacce4e1465e098223d46dd71029928d9a52f57e", "shasum": "" }, "require": { @@ -332,9 +332,9 @@ ], "support": { "issues": "https://github.com/open-feature/php-sdk/issues", - "source": "https://github.com/open-feature/php-sdk/tree/2.2.0" + "source": "https://github.com/open-feature/php-sdk/tree/2.3.0" }, - "time": "2026-06-08T13:01:00+00:00" + "time": "2026-07-26T03:45:46+00:00" }, { "name": "phar-io/manifest", diff --git a/conformance/openfeature-provider.json b/conformance/openfeature-provider.json new file mode 100644 index 0000000..ef8c834 --- /dev/null +++ b/conformance/openfeature-provider.json @@ -0,0 +1,264 @@ +{ + "kind": "featurevisor.openfeature-provider", + "schemaVersion": 1, + "datafile": { + "schemaVersion": "2", + "revision": "provider-conformance", + "segments": {}, + "features": { + "checkout": { + "bucketBy": "userId", + "variations": [ + { "value": "on", "variables": { "config": "{\"enabled\":true}", "nullable": "null" } } + ], + "variablesSchema": { + "config": { "type": "json", "defaultValue": "{}" }, + "nullable": { "type": "json", "defaultValue": "null" } + }, + "force": [ + { + "conditions": { "attribute": "userId", "operator": "equals", "value": "forced" }, + "enabled": true, + "variation": "on" + } + ], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000, "variation": "on" }] + }, + "disabled": { + "bucketBy": "userId", + "variations": [], + "force": [ + { + "conditions": { "attribute": "blocked", "operator": "equals", "value": true }, + "enabled": false + } + ], + "traffic": [] + }, + "accountTargeted": { + "bucketBy": "accountId", + "variations": [], + "force": [ + { + "conditions": { "attribute": "accountId", "operator": "equals", "value": "" }, + "enabled": true + } + ], + "traffic": [] + }, + "targetOnly": { + "bucketBy": "userId", + "variations": [], + "force": [ + { + "conditions": { "attribute": "userId", "operator": "equals", "value": "forced" }, + "enabled": true + } + ], + "traffic": [] + }, + "conditional": { + "bucketBy": "userId", + "conditions": { "feature": "checkout", "enabled": false }, + "traffic": [{ "key": "all", "conditions": "*", "percentage": 100000 }] + } + }, + "variables": { + "settings": { "type": "json", "defaultValue": "{\"region\":\"eu\"}" }, + "nullable": { "type": "json", "defaultValue": "null" }, + "settingsAlias": { "type": "json", "compose": "settings" }, + "jsonText": { "type": "json", "defaultValue": "\"null\"" }, + "jsonTextAlias": { "type": "json", "compose": "jsonText" }, + "screen": { + "type": "object", + "compose": [ + { "key": "enabled", "feature": "checkout" }, + { "key": "settings", "variable": "settings" } + ] + }, + "restricted": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": { "attribute": "allowed", "operator": "equals", "value": true } + }, + "screenOverride": { + "type": "object", + "compose": "screen", + "overrideProgram": [ + { + "key": "regional", + "conditions": "*", + "operations": [{ "op": "set", "path": ["settings", "region"], "value": "us" }], + "onMatch": 1, + "onMiss": 1 + } + ] + }, + "brokenAlias": { "type": "object", "compose": "brokenAlias" } + } + }, + "cases": [ + { + "name": "forced flag", + "flagKey": "checkout", + "type": "boolean", + "default": false, + "context": { "targetingKey": "forced" }, + "expected": { "value": true, "reason": "TARGETING_MATCH" } + }, + { + "name": "variation selector", + "flagKey": "checkout:variation", + "type": "string", + "default": "fallback", + "context": { "targetingKey": "forced" }, + "expected": { "value": "on", "reason": "TARGETING_MATCH", "variant": "on" } + }, + { + "name": "feature JSON", + "flagKey": "checkout:config", + "type": "object", + "default": {}, + "context": { "targetingKey": "forced" }, + "expected": { "value": { "enabled": true } } + }, + { + "name": "global JSON", + "flagKey": "variable:settings", + "type": "object", + "default": {}, + "context": {}, + "expected": { "value": { "region": "eu" }, "reason": "DEFAULT" } + }, + { + "name": "feature JSON null", + "flagKey": "checkout:nullable", + "type": "object", + "default": { "fallback": true }, + "context": { "targetingKey": "forced" }, + "expected": { "value": { "fallback": true } } + }, + { + "name": "global JSON null", + "flagKey": "variable:nullable", + "type": "object", + "default": { "fallback": true }, + "context": {}, + "expected": { "value": { "fallback": true }, "reason": "DEFAULT" } + }, + { + "name": "disabled flag", + "flagKey": "disabled", + "type": "boolean", + "default": true, + "context": { "blocked": true }, + "expected": { "value": false, "reason": "TARGETING_MATCH" } + }, + { + "name": "missing flag", + "flagKey": "missing", + "type": "boolean", + "default": false, + "context": {}, + "expected": { "value": false, "reason": "ERROR", "errorCode": "FLAG_NOT_FOUND" } + }, + { + "name": "type mismatch", + "flagKey": "checkout:config", + "type": "string", + "default": "fallback", + "context": { "targetingKey": "forced" }, + "expected": { "value": "fallback", "reason": "ERROR", "errorCode": "TYPE_MISMATCH" } + }, + { + "name": "composed object", + "flagKey": "variable:screen", + "type": "object", + "default": {}, + "context": { "targetingKey": "forced" }, + "expected": { + "value": { "enabled": true, "settings": { "region": "eu" } }, + "reason": "TARGETING_MATCH" + } + }, + { + "name": "string alias to JSON object", + "flagKey": "variable:settingsAlias", + "type": "object", + "default": {}, + "context": {}, + "expected": { "value": { "region": "eu" }, "reason": "TARGETING_MATCH" } + }, + { + "name": "string alias decodes JSON exactly once", + "flagKey": "variable:jsonTextAlias", + "type": "string", + "default": "fallback", + "context": {}, + "expected": { "value": "null", "reason": "TARGETING_MATCH" } + }, + { + "name": "composed override", + "flagKey": "variable:screenOverride", + "type": "object", + "default": {}, + "context": { "targetingKey": "forced" }, + "expected": { + "value": { "enabled": true, "settings": { "region": "us" } }, + "reason": "TARGETING_MATCH" + } + }, + { + "name": "unmet variable root conditions", + "flagKey": "variable:restricted", + "type": "boolean", + "default": true, + "context": {}, + "expected": { "value": false, "reason": "DISABLED" } + }, + { + "name": "unmet feature root conditions", + "flagKey": "conditional", + "type": "boolean", + "default": true, + "context": { "targetingKey": "forced" }, + "expected": { "value": false, "reason": "DISABLED" } + }, + { + "name": "composition dependency error", + "flagKey": "variable:brokenAlias", + "type": "object", + "default": { "fallback": true }, + "context": {}, + "expected": { "value": { "fallback": true }, "reason": "ERROR", "errorCode": "GENERAL" } + } + ], + "targetingCases": [ + { + "name": "absent preserves field", + "context": { "userId": "forced" }, + "expectedUserId": "forced", + "expectedValue": true + }, + { + "name": "empty overrides field", + "context": { "targetingKey": "", "userId": "forced" }, + "expectedUserId": "", + "expectedValue": false + }, + { + "name": "nonempty overrides field", + "context": { "targetingKey": "other", "userId": "forced" }, + "expectedUserId": "other", + "expectedValue": false + } + ], + "customTargetingCase": { + "flagKey": "accountTargeted", + "context": { "targetingKey": "", "accountId": "old" }, + "targetingKeyField": "accountId", + "expectedValue": true, + "expectedReason": "TARGETING_MATCH" + } +} diff --git a/conformance/sdk-composition.json b/conformance/sdk-composition.json new file mode 100644 index 0000000..7cf7b7f --- /dev/null +++ b/conformance/sdk-composition.json @@ -0,0 +1,436 @@ +{ + "version": 2, + "capability": "composition-and-overrides", + "datafile": { + "schemaVersion": "2", + "revision": "composition-conformance", + "segments": {}, + "features": { + "checkout": { + "bucketBy": "userId", + "disabledVariationValue": "off", + "variations": [{ "value": "control" }], + "variablesSchema": { + "label": { "type": "string", "defaultValue": "Buy", "disabledValue": "Closed" } + }, + "traffic": [ + { + "key": "all", + "segments": "*", + "percentage": 100000, + "allocation": [{ "range": [0, 100000], "variation": "control" }] + } + ] + } + }, + "variables": { + "policy": { "type": "object", "defaultValue": { "limit": 50, "methods": ["card"] } }, + "raw": { "type": "json", "defaultValue": "{\"decoded\":true}" }, + "gate": { "type": "boolean", "defaultValue": true }, + "policyAlias": { + "type": "object", + "compose": "policy", + "overrideProgram": [ + { + "key": "regional", + "keyPath": ["regional"], + "conditions": { "attribute": "country", "operator": "equals", "value": "nl" }, + "operations": [{ "op": "set", "path": ["limit"], "value": 80 }], + "onMatch": 1, + "onMiss": 1 + } + ] + }, + "policyChain": { "type": "object", "compose": "policyAlias" }, + "booleanAlias": { "type": "boolean", "compose": "gate" }, + "numberSource": { "type": "integer", "defaultValue": 0 }, + "numberAlias": { "type": "integer", "compose": "numberSource" }, + "stringSource": { "type": "string", "defaultValue": "" }, + "stringAlias": { "type": "string", "compose": "stringSource" }, + "jsonText": { "type": "json", "defaultValue": "\"null\"" }, + "jsonAlias": { "type": "json", "compose": "jsonText" }, + "jsonChain": { "type": "json", "compose": "jsonAlias" }, + "arraySource": { "type": "array", "defaultValue": ["card"] }, + "arrayAlias": { + "type": "array", + "compose": "arraySource", + "overrideProgram": [ + { + "key": "extra", + "keyPath": ["extra"], + "segments": "*", + "operations": [{ "op": "appendUnique", "path": [], "value": "ideal" }], + "onMatch": 1, + "onMiss": 1 + } + ] + }, + "screenAlias": { "type": "object", "compose": "screen" }, + "aliasMembers": { + "type": "object", + "compose": [ + { "key": "policy", "variable": "policyChain" }, + { "key": "raw", "variable": "jsonChain" } + ] + }, + "aliasCycle": { "type": "object", "compose": "aliasCycle" }, + "missingAlias": { "type": "object", "compose": "missingSource" }, + "screen": { + "type": "object", + "compose": [ + { "key": "enabled", "feature": "checkout" }, + { "key": "label", "feature": "checkout", "variable": "label" }, + { "key": "policy", "variable": "policy" }, + { "key": "raw", "variable": "raw" }, + { "key": "variation", "feature": "checkout", "variation": true } + ], + "overrideProgram": [ + { + "key": "netherlands", + "keyPath": ["netherlands"], + "conditions": { "attribute": "country", "operator": "equals", "value": "nl" }, + "operations": [{ "op": "set", "path": ["policy", "limit"], "value": 90 }], + "onMatch": 1, + "onMiss": 2 + }, + { + "key": "amsterdam", + "keyPath": ["netherlands", "amsterdam"], + "conditions": { "attribute": "city", "operator": "equals", "value": "Amsterdam" }, + "operations": [{ "op": "set", "path": ["label"], "value": "Collect" }], + "onMatch": 2, + "onMiss": 2 + }, + { + "key": "eligible", + "keyPath": ["eligible"], + "requiredFeatures": ["checkout"], + "conditions": {"and":[{"attribute":"country","operator":"equals","value":"nl"},{"variable":"gate","operator":"equals","value":true}]}, + "operations": [{ "op": "set", "path": ["policy", "limit"], "value": 100 }], + "onMatch": 3, + "onMiss": 3 + } + ] + }, + "nested": { "type": "object", "compose": [{ "key": "screen", "variable": "screen" }] }, + "replacement": { + "type": "object", + "compose": [ + { "key": "enabled", "feature": "checkout" }, + { "key": "label", "feature": "checkout", "variable": "label" }, + { "key": "policy", "variable": "policy" } + ], + "overrideProgram": [ + { + "key": "literal", + "keyPath": ["literal"], + "segments": "*", + "operations": [ + { + "op": "set", + "path": [], + "value": { + "enabled": false, + "label": null, + "policy": { "limit": 25, "methods": ["cash"] } + } + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + }, + "cycle": { "type": "object", "compose": [{ "key": "self", "variable": "cycle" }] }, + "invalidProgram": { + "type": "object", + "compose": [{ "key": "policy", "variable": "policy" }], + "overrideProgram": [ + { + "key": "bad", + "segments": "*", + "operations": [{ "op": "set", "path": ["policy", "limit"], "value": 1 }], + "onMatch": 0, + "onMiss": 1 + } + ] + } + } + }, + "cases": [ + { + "name": "string-composition-reads-overridden-map", + "variable": "screenAlias", + "context": { "country": "nl", "city": "Amsterdam" }, + "expectedValue": { + "enabled": true, + "label": "Collect", + "policy": { "limit": 100, "methods": ["card"] }, + "raw": { "decoded": true }, + "variation": "control" + }, + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "map-composition-reads-final-string-sources", + "variable": "aliasMembers", + "context": { "country": "nl" }, + "expectedValue": { "policy": { "limit": 80, "methods": ["card"] }, "raw": "null" }, + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "string-composition-preserves-source-object", + "variable": "policyAlias", + "context": {}, + "expectedValue": { "limit": 50, "methods": ["card"] }, + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "string-composition-mutates-copy-of-source", + "variable": "policyAlias", + "context": { "country": "nl" }, + "expectedValue": { "limit": 80, "methods": ["card"] }, + "expectedEvaluation": { + "reason": "variable_override_rule", + "variableOverridePaths": [["regional"]] + } + }, + { + "name": "string-composition-chain-uses-final-overrides", + "variable": "policyChain", + "context": { "country": "nl" }, + "expectedValue": { "limit": 80, "methods": ["card"] }, + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "string-composition-preserves-false", + "variable": "booleanAlias", + "context": {}, + "stickyVariables": { "gate": false }, + "expectedValue": false, + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "string-composition-preserves-zero", + "variable": "numberAlias", + "context": {}, + "expectedValue": 0, + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "string-composition-preserves-empty-string", + "variable": "stringAlias", + "context": {}, + "expectedValue": "", + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "string-composition-json-is-decoded-once", + "variable": "jsonChain", + "context": {}, + "expectedValue": "null", + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "string-composition-preserves-source-null", + "variable": "policyAlias", + "context": { "country": "nl" }, + "stickyVariables": { "policy": null }, + "expectedValue": null, + "expectedEvaluation": { "reason": "variable_override_rule" } + }, + { + "name": "string-composition-array-root-operation", + "variable": "arrayAlias", + "context": {}, + "expectedValue": ["card", "ideal"], + "expectedEvaluation": { "reason": "variable_override_rule" } + }, + { + "name": "string-composition-sticky-bypasses-source", + "variable": "policyChain", + "context": {}, + "stickyVariables": { "policyChain": null }, + "expectedValue": null, + "expectedEvaluation": { "reason": "sticky" } + }, + { + "name": "string-composition-missing-source-is-null", + "variable": "missingAlias", + "context": {}, + "expectedValue": null, + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "string-composition-cycle-fails-completely", + "variable": "aliasCycle", + "context": {}, + "expectedValue": null, + "expectedEvaluation": { "reason": "error" } + }, + { + "name": "unmatched-assembled-value", + "variable": "screen", + "context": { "userId": "example-user", "country": "us" }, + "expectedValue": { + "enabled": true, + "label": "Buy", + "policy": { "limit": 50, "methods": ["card"] }, + "raw": { "decoded": true }, + "variation": "control" + }, + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "nested-and-continuing-overrides", + "variable": "screen", + "context": { "userId": "example-user", "country": "nl", "city": "Amsterdam" }, + "expectedValue": { + "enabled": true, + "label": "Collect", + "policy": { "limit": 100, "methods": ["card"] }, + "raw": { "decoded": true }, + "variation": "control" + }, + "expectedEvaluation": { + "reason": "variable_override_rule", + "variableOverrideKey": "eligible", + "variableOverridePath": ["eligible"], + "variableOverridePaths": [["netherlands"], ["netherlands", "amsterdam"], ["eligible"]] + } + }, + { + "name": "child-miss-continues-parent", + "variable": "screen", + "context": { "country": "nl", "city": "Rotterdam" }, + "expectedValue": { + "enabled": true, + "label": "Buy", + "policy": { "limit": 100, "methods": ["card"] }, + "raw": { "decoded": true }, + "variation": "control" + }, + "expectedEvaluation": { + "reason": "variable_override_rule", + "variableOverridePaths": [["netherlands"], ["eligible"]] + } + }, + { + "name": "disabled-feature-source-and-prerequisite", + "variable": "screen", + "context": { "country": "nl", "city": "Amsterdam" }, + "stickyFeatures": { "checkout": { "enabled": false } }, + "expectedValue": { + "enabled": false, + "label": "Collect", + "policy": { "limit": 90, "methods": ["card"] }, + "raw": { "decoded": true }, + "variation": "off" + }, + "expectedEvaluation": { + "reason": "variable_override_rule", + "variableOverrideKey": "amsterdam", + "variableOverridePaths": [["netherlands"], ["netherlands", "amsterdam"]] + } + }, + { + "name": "disabled-sources-with-no-override", + "variable": "screen", + "context": { "country": "us" }, + "stickyFeatures": { "checkout": { "enabled": false } }, + "expectedValue": { + "enabled": false, + "label": "Closed", + "policy": { "limit": 50, "methods": ["card"] }, + "raw": { "decoded": true }, + "variation": "off" + }, + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "variable-prerequisite-gates-only-its-override", + "variable": "screen", + "context": { "country": "nl" }, + "stickyVariables": { "gate": false }, + "expectedValue": { + "enabled": true, + "label": "Buy", + "policy": { "limit": 90, "methods": ["card"] }, + "raw": { "decoded": true }, + "variation": "control" + }, + "expectedEvaluation": { + "reason": "variable_override_rule", + "variableOverridePaths": [["netherlands"]] + } + }, + { + "name": "null-source-remains-null", + "variable": "screen", + "context": { "country": "nl" }, + "stickyVariables": { "policy": null }, + "expectedValue": { + "enabled": true, + "label": "Buy", + "policy": null, + "raw": { "decoded": true }, + "variation": "control" + }, + "expectedEvaluation": { + "reason": "variable_override_rule", + "variableOverridePaths": [["netherlands"], ["eligible"]] + } + }, + { + "name": "whole-variable-sticky-bypasses-both-stages", + "variable": "screen", + "context": { "country": "nl" }, + "stickyVariables": { "screen": { "pinned": true } }, + "expectedValue": { "pinned": true }, + "expectedEvaluation": { "reason": "sticky" } + }, + { + "name": "nested-composition-consumes-final-overridden-source", + "variable": "nested", + "context": { "country": "nl", "city": "Amsterdam" }, + "expectedValue": { + "screen": { + "enabled": true, + "label": "Collect", + "policy": { "limit": 100, "methods": ["card"] }, + "raw": { "decoded": true }, + "variation": "control" + } + }, + "expectedEvaluation": { "reason": "variable_composed" } + }, + { + "name": "literal-replacement-keeps-null-members", + "variable": "replacement", + "context": {}, + "expectedValue": { + "enabled": false, + "label": null, + "policy": { "limit": 25, "methods": ["cash"] } + }, + "expectedEvaluation": { + "reason": "variable_override_rule", + "variableOverridePaths": [["literal"]] + } + }, + { + "name": "composition-cycle-fails-whole-result", + "variable": "cycle", + "context": {}, + "expectedValue": null, + "expectedEvaluation": { "reason": "error" } + }, + { + "name": "bad-program-fails-whole-result", + "variable": "invalidProgram", + "context": {}, + "expectedValue": null, + "expectedEvaluation": { "reason": "error" } + } + ] +} diff --git a/conformance/sdk-conditions.json b/conformance/sdk-conditions.json new file mode 100644 index 0000000..339a41b --- /dev/null +++ b/conformance/sdk-conditions.json @@ -0,0 +1,10990 @@ +{ + "version": 1, + "capability": "unified-conditions", + "cases": [ + { + "name": "wildcard", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": "*" + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "attribute leaf", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "segment leaf", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": "dutch" + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "feature leaf", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "global variable leaf", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "enabled" + ], + "operator": "equals", + "value": true + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "mixed group", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + "dutch", + { + "feature": "gate" + }, + { + "variable": "policy", + "path": [ + "enabled" + ], + "operator": "equals", + "value": true + }, + { + "or": [ + { + "feature": "missing" + }, + { + "not": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ] + } + ] + } + ] + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "feature variable leaf", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "variablesSchema": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "enabled" + ], + "operator": "equals", + "value": true, + "feature": "gate" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "segment with segment reference", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "dependent": { + "conditions": "dutch" + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "dependent", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "segment with feature reference", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "dependent": { + "conditions": { + "feature": "gate" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "dependent", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "segment with variable reference", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "dependent": { + "conditions": { + "variable": "policy", + "path": [ + "enabled" + ], + "operator": "equals", + "value": true + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "dependent", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "malformed segment cycle fails the evaluation", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "first": { + "conditions": "second" + }, + "second": { + "conditions": { + "not": [ + "first" + ] + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "first", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "reason": "error" + } + }, + { + "name": "root gate reports its unmet conditions", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "country": "us" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + { + "name": "force bypasses the root gate", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + "force": [ + { + "conditions": "*", + "enabled": true + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "country": "us" + }, + "expected": { + "enabled": true, + "reason": "forced" + } + }, + { + "name": "sticky flag bypasses the root gate", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "country": "us" + }, + "expected": { + "enabled": true, + "reason": "sticky" + }, + "options": { + "stickyFeatures": { + "subject": { + "enabled": true + } + } + } + }, + { + "name": "rule fall through after an unmet mixed condition", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "miss", + "conditions": [ + "dutch", + { + "feature": "gate", + "enabled": false + } + ], + "percentage": 0 + }, + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "bare string always means segment", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": "gate" + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": "gate" + } + }, + { + "name": "empty expression []", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": [] + } + }, + { + "name": "empty expression {\"and\":[]}", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "and": [] + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "and": [] + } + } + }, + { + "name": "empty expression {\"or\":[]}", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "or": [] + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "or": [] + } + } + }, + { + "name": "empty expression {\"not\":[]}", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "not": [] + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "not": [] + } + } + }, + { + "name": "explicit null feature gate is not absent in schema 2", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": null + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": null + } + }, + { + "name": "explicit null feature gate is not absent in schema 4", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": null + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": null + } + }, + { + "name": "NOT with a null child still negates its ordinary false result", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "not": [ + null + ] + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "legacy v3 fields retain their meaning", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": "dutch", + "percentage": 100000 + } + ], + "requiredFeatures": [ + "gate" + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "legacy empty segment selector [] keeps v3 wildcard matching", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": [], + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "legacy empty segment selector {\"and\":[]} keeps v3 wildcard matching", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": { + "and": [] + }, + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "schema 3 legacy input remains readable", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "3", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": "dutch", + "percentage": 100000 + } + ], + "requiredFeatures": [ + "gate" + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "legacy root string feature is exposed as a normalized object leaf", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "requiredFeatures": [ + "missing" + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": [ + { + "feature": "missing" + } + ] + } + }, + { + "name": "stringified mixed conditions retain all leaf kinds", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": "[\"dutch\",{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"feature\":\"gate\"},{\"variable\":\"policy\",\"path\":[\"enabled\"],\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "mixed legacy and new root gates are ANDed", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": "*", + "requiredFeatures": [ + { + "feature": "gate", + "enabled": false + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet" + } + }, + { + "name": "mixed legacy and new rule selectors are ANDed", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "segments": "missing" + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "unreleased requiredVariables is not read", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "requiredVariables": [ + { + "variable": "missing", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "explicit null variable gate is not absent in schema 2", + "type": "variable", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "conditions": null + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "disabled", + "reason": "conditions_unmet", + "conditions": null + }, + "variable": "subject" + }, + { + "name": "explicit null variable gate is not absent in schema 4", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "conditions": null + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "disabled", + "reason": "conditions_unmet", + "conditions": null + }, + "variable": "subject" + }, + { + "name": "global root gate returns disabledValue", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "conditions": { + "feature": "gate", + "enabled": false + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "disabled", + "reason": "conditions_unmet" + }, + "variable": "subject" + }, + { + "name": "global root gate preserves useDefaultWhenDisabled", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "conditions": { + "feature": "gate", + "enabled": false + }, + "useDefaultWhenDisabled": true + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "default", + "reason": "conditions_unmet" + }, + "variable": "subject" + }, + { + "name": "mixed legacy and new override selectors are ANDed", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrides": [ + { + "key": "miss", + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + "segments": "dutch", + "requiredFeatures": [ + { + "feature": "gate", + "enabled": false + } + ], + "value": "incorrect" + }, + { + "key": "all", + "conditions": "*", + "value": "matched" + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "matched", + "reason": "variable_override_rule", + "variableOverrideKey": "all" + }, + "variable": "subject" + }, + { + "name": "composed root gate returns null before sources", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "object", + "compose": "policy", + "conditions": { + "feature": "gate", + "enabled": false + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": null, + "reason": "conditions_unmet" + }, + "variable": "subject", + "dependencies": [ + { + "feature": "gate", + "dependencyPurpose": "condition" + } + ] + }, + { + "name": "composed root gate uses disabledValue before sources", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "object", + "compose": "policy", + "conditions": { + "feature": "gate", + "enabled": false + }, + "disabledValue": { + "enabled": false + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": { + "enabled": false + }, + "reason": "conditions_unmet" + }, + "variable": "subject", + "dependencies": [ + { + "feature": "gate", + "dependencyPurpose": "condition" + } + ] + }, + { + "name": "override ancestors AND with child nl", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + "operations": [], + "onMatch": 1, + "onMiss": 2 + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "conditions": { + "feature": "gate" + }, + "operations": [ + { + "op": "set", + "path": [], + "value": "child" + } + ], + "onMatch": 2, + "onMiss": 2 + } + ] + } + } + }, + "context": { + "country": "nl" + }, + "expected": { + "value": "child", + "reason": "variable_override_rule" + }, + "variable": "subject" + }, + { + "name": "override ancestors AND with child us", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + "operations": [], + "onMatch": 1, + "onMiss": 2 + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "conditions": { + "feature": "gate" + }, + "operations": [ + { + "op": "set", + "path": [], + "value": "child" + } + ], + "onMatch": 2, + "onMiss": 2 + } + ] + } + } + }, + "context": { + "country": "us" + }, + "expected": { + "value": "default", + "reason": "variable_default" + }, + "variable": "subject" + }, + { + "name": "module dependencies are ordered and tagged condition", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": [ + "dutch", + { + "feature": "gate" + }, + { + "variable": "policy", + "path": [ + "enabled" + ], + "operator": "equals", + "value": true + }, + { + "feature": "gate" + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + }, + "dependencies": [ + { + "feature": "gate", + "dependencyPurpose": "condition" + }, + { + "variable": "policy", + "dependencyPurpose": "condition" + } + ] + }, + { + "name": "short circuit avoids later dependency callbacks", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "feature": "gate" + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "country": "us" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet" + }, + "dependencies": [] + }, + { + "name": "malformed rule selector falls through", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "broken", + "conditions": "[\"dutch\"", + "percentage": 100000 + }, + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "malformed root gate reports normalized unmet conditions", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": "{\"feature\":" + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "or": [] + } + } + }, + { + "name": "NOT over a malformed segment cannot match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "broken": { + "conditions": "{\"attribute\":\"country\"" + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": { + "not": [ + "broken" + ] + }, + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "malformed force selector is skipped", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "force": [ + { + "conditions": "[[", + "enabled": false + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "malformed global override selector is skipped", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrides": [ + { + "key": "broken", + "conditions": "{\"not\":[", + "value": "incorrect" + }, + { + "key": "all", + "conditions": "*", + "value": "matched" + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "matched", + "reason": "variable_override_rule", + "variableOverrideKey": "all" + }, + "variable": "subject" + }, + { + "name": "schema 2 malformed segments string falls through", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "broken", + "segments": "[\"dutch\",", + "percentage": 100000 + }, + { + "key": "all", + "segments": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 empty [] at root", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": [] + } + }, + { + "name": "schema 2 empty [] at rule", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": [], + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "schema 2 empty [] at force", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "force": [ + { + "conditions": [], + "enabled": false + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "forced" + } + }, + { + "name": "schema 2 empty [] at segment", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "empty": { + "conditions": [] + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "empty", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 empty [] at variable root", + "type": "variable", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "conditions": [] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "disabled", + "reason": "conditions_unmet", + "conditions": [] + }, + "variable": "subject" + }, + { + "name": "schema 2 empty [] at override", + "type": "variable", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrides": [ + { + "key": "empty", + "conditions": [], + "value": "matched" + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "matched", + "reason": "variable_override_rule", + "variableOverrideKey": "empty" + }, + "variable": "subject" + }, + { + "name": "schema 2 empty [] at override program", + "type": "variable", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrideProgram": [ + { + "key": "empty", + "keyPath": [ + "empty" + ], + "conditions": [], + "operations": [ + { + "op": "set", + "path": [], + "value": "incorrect" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "default", + "reason": "variable_default" + }, + "variable": "subject" + }, + { + "name": "schema 2 empty {\"and\":[]} at root", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "and": [] + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "and": [] + } + } + }, + { + "name": "schema 2 empty {\"and\":[]} at rule", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": { + "and": [] + }, + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "schema 2 empty {\"and\":[]} at force", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "force": [ + { + "conditions": { + "and": [] + }, + "enabled": false + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "forced" + } + }, + { + "name": "schema 2 empty {\"and\":[]} at segment", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "empty": { + "conditions": { + "and": [] + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "empty", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 empty {\"and\":[]} at variable root", + "type": "variable", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "conditions": { + "and": [] + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "disabled", + "reason": "conditions_unmet", + "conditions": { + "and": [] + } + }, + "variable": "subject" + }, + { + "name": "schema 2 empty {\"and\":[]} at override", + "type": "variable", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrides": [ + { + "key": "empty", + "conditions": { + "and": [] + }, + "value": "matched" + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "matched", + "reason": "variable_override_rule", + "variableOverrideKey": "empty" + }, + "variable": "subject" + }, + { + "name": "schema 2 empty {\"and\":[]} at override program", + "type": "variable", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrideProgram": [ + { + "key": "empty", + "keyPath": [ + "empty" + ], + "conditions": { + "and": [] + }, + "operations": [ + { + "op": "set", + "path": [], + "value": "incorrect" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "default", + "reason": "variable_default" + }, + "variable": "subject" + }, + { + "name": "schema 2 segment empty AND inside OR", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "nested": { + "conditions": { + "or": [ + { + "and": [] + }, + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ] + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "nested", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 segment NOT of empty AND", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "nested": { + "conditions": { + "not": [ + { + "and": [] + } + ] + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "nested", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "schema 2 segment empty OR", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "nested": { + "conditions": { + "or": [] + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "nested", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "schema 4 empty [] at root", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": [] + } + }, + { + "name": "schema 4 empty [] at rule", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": [], + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "schema 4 empty [] at force", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "force": [ + { + "conditions": [], + "enabled": false + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 4 empty [] at segment", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "empty": { + "conditions": [] + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "empty", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "schema 4 empty [] at variable root", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "conditions": [] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "disabled", + "reason": "conditions_unmet", + "conditions": [] + }, + "variable": "subject" + }, + { + "name": "schema 4 empty [] at override", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrides": [ + { + "key": "empty", + "conditions": [], + "value": "matched" + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "default", + "reason": "variable_default" + }, + "variable": "subject" + }, + { + "name": "schema 4 empty [] at override program", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrideProgram": [ + { + "key": "empty", + "keyPath": [ + "empty" + ], + "conditions": [], + "operations": [ + { + "op": "set", + "path": [], + "value": "incorrect" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "default", + "reason": "variable_default" + }, + "variable": "subject" + }, + { + "name": "schema 4 empty {\"and\":[]} at root", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "and": [] + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "and": [] + } + } + }, + { + "name": "schema 4 empty {\"and\":[]} at rule", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": { + "and": [] + }, + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "schema 4 empty {\"and\":[]} at force", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "force": [ + { + "conditions": { + "and": [] + }, + "enabled": false + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 4 empty {\"and\":[]} at segment", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "empty": { + "conditions": { + "and": [] + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "empty", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "schema 4 empty {\"and\":[]} at variable root", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "conditions": { + "and": [] + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "disabled", + "reason": "conditions_unmet", + "conditions": { + "and": [] + } + }, + "variable": "subject" + }, + { + "name": "schema 4 empty {\"and\":[]} at override", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrides": [ + { + "key": "empty", + "conditions": { + "and": [] + }, + "value": "matched" + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "default", + "reason": "variable_default" + }, + "variable": "subject" + }, + { + "name": "schema 4 empty {\"and\":[]} at override program", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrideProgram": [ + { + "key": "empty", + "keyPath": [ + "empty" + ], + "conditions": { + "and": [] + }, + "operations": [ + { + "op": "set", + "path": [], + "value": "incorrect" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "value": "default", + "reason": "variable_default" + }, + "variable": "subject" + }, + { + "name": "schema 4 segment empty AND inside OR", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "nested": { + "conditions": { + "or": [ + { + "and": [] + }, + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ] + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "nested", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "schema 4 segment NOT of empty AND", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "nested": { + "conditions": { + "not": [ + { + "and": [] + } + ] + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "nested", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 4 segment empty OR", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "nested": { + "conditions": { + "or": [] + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "nested", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "no_match" + } + }, + { + "name": "schema 2 parsed legacy segments [] match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": [], + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 stringified legacy segments [] match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": "[]", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 parsed legacy segments {\"and\":[]} match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": { + "and": [] + }, + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 stringified legacy segments {\"and\":[]} match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": "{\"and\":[]}", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 3 parsed legacy segments [] match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "3", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": [], + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 3 stringified legacy segments [] match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "3", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": "[]", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 3 parsed legacy segments {\"and\":[]} match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "3", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": { + "and": [] + }, + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 3 stringified legacy segments {\"and\":[]} match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "3", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": "{\"and\":[]}", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 4 parsed legacy segments [] match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": [], + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 4 stringified legacy segments [] match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": "[]", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 4 parsed legacy segments {\"and\":[]} match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": { + "and": [] + }, + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 4 stringified legacy segments {\"and\":[]} match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "segments": "{\"and\":[]}", + "percentage": 100000 + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "numeric 1.0 equals integer at attribute", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "attribute": "count", + "operator": "equals", + "value": 1.0 + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "count": { + "type": "integer", + "defaultValue": 1 + } + } + }, + "context": { + "count": 1 + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "numeric 1.0 equals integer at variable", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "count", + "operator": "equals", + "value": 1.0 + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "count": { + "type": "integer", + "defaultValue": 1 + } + } + }, + "context": { + "count": 1 + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "numeric 1.0 equals integer at stringified attribute", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": "{\"attribute\":\"count\",\"operator\":\"equals\",\"value\":1.0}" + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + }, + "count": { + "type": "integer", + "defaultValue": 1 + } + } + }, + "context": { + "count": 1 + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "compiled path numeric array index", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "items", + 1, + "count" + ], + "operator": "equals", + "value": 5 + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "items": [ + { + "count": 0 + }, + { + "count": 5 + } + ], + "a.b": { + "count": 1 + }, + "a": { + "b": { + "count": 99 + } + }, + "nothing": null + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "compiled path numeric 1.0 array index", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "items", + 1.0, + "count" + ], + "operator": "equals", + "value": 5 + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "items": [ + { + "count": 0 + }, + { + "count": 5 + } + ], + "a.b": { + "count": 1 + }, + "a": { + "b": { + "count": 99 + } + }, + "nothing": null + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "compiled path string array index", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "items", + "1", + "count" + ], + "operator": "equals", + "value": 5 + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "items": [ + { + "count": 0 + }, + { + "count": 5 + } + ], + "a.b": { + "count": 1 + }, + "a": { + "b": { + "count": 99 + } + }, + "nothing": null + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "compiled path array length", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "items", + "length" + ], + "operator": "equals", + "value": 2 + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "items": [ + { + "count": 0 + }, + { + "count": 5 + } + ], + "a.b": { + "count": 1 + }, + "a": { + "b": { + "count": 99 + } + }, + "nothing": null + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "compiled path literal dotted key", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "a.b", + "count" + ], + "operator": "equals", + "value": 1 + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "items": [ + { + "count": 0 + }, + { + "count": 5 + } + ], + "a.b": { + "count": 1 + }, + "a": { + "b": { + "count": 99 + } + }, + "nothing": null + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "compiled path missing path isNull", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "absent" + ], + "operator": "isNull" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "items": [ + { + "count": 0 + }, + { + "count": 5 + } + ], + "a.b": { + "count": 1 + }, + "a": { + "b": { + "count": 99 + } + }, + "nothing": null + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "variable": "policy", + "path": [ + "absent" + ], + "operator": "isNull" + } + } + }, + { + "name": "compiled path missing path notNull", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "absent" + ], + "operator": "notNull" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "items": [ + { + "count": 0 + }, + { + "count": 5 + } + ], + "a.b": { + "count": 1 + }, + "a": { + "b": { + "count": 99 + } + }, + "nothing": null + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "variable": "policy", + "path": [ + "absent" + ], + "operator": "notNull" + } + } + }, + { + "name": "compiled path explicit null isNull", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "nothing" + ], + "operator": "isNull" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "items": [ + { + "count": 0 + }, + { + "count": 5 + } + ], + "a.b": { + "count": 1 + }, + "a": { + "b": { + "count": 99 + } + }, + "nothing": null + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "compiled path explicit null notNull", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "nothing" + ], + "operator": "notNull" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "items": [ + { + "count": 0 + }, + { + "count": 5 + } + ], + "a.b": { + "count": 1 + }, + "a": { + "b": { + "count": 99 + } + }, + "nothing": null + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "variable": "policy", + "path": [ + "nothing" + ], + "operator": "notNull" + } + } + }, + { + "name": "compiled path nonnull path notNull", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "policy", + "path": [ + "items", + 1, + "count" + ], + "operator": "notNull" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "items": [ + { + "count": 0 + }, + { + "count": 5 + } + ], + "a.b": { + "count": 1 + }, + "a": { + "b": { + "count": 99 + } + }, + "nothing": null + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "compiled missing path NOT isNull matches", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "not": [ + { + "variable": "policy", + "path": [ + "absent" + ], + "operator": "isNull" + } + ] + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "compiled missing path NOT notNull matches", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "not": [ + { + "variable": "policy", + "path": [ + "absent" + ], + "operator": "notNull" + } + ] + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "missing global variable getter is null before path traversal", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "missing", + "operator": "isNull" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "missing feature variable getter is null before path traversal", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "missing", + "operator": "isNull" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "expected": { + "enabled": true, + "reason": "rule" + } + }, + { + "name": "schema 2 force mixes empty conditions with segments", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "force": [ + { + "conditions": [], + "segments": "dutch", + "enabled": false + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [], + "expected": { + "enabled": false, + "reason": "forced" + } + }, + { + "name": "schema 4 force mixes empty conditions with segments", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "force": [ + { + "conditions": [], + "segments": "dutch", + "enabled": false + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 requiredFeatures empty list is no gate", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "requiredFeatures": [] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 requiredFeatures nested AND OR NOT", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "requiredFeatures": [ + { + "or": [ + "missing", + { + "and": [ + "gate", + { + "not": [ + "off" + ] + } + ] + } + ] + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "missing", + "dependencyPurpose": "condition" + }, + { + "feature": "gate", + "dependencyPurpose": "condition" + }, + { + "feature": "off", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 requiredFeatures empty OR is unmet", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "requiredFeatures": [ + { + "or": [] + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": [ + { + "or": [] + } + ] + } + }, + { + "name": "schema 2 override requiredFeatures strings and segments", + "type": "variable", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrides": [ + { + "key": "dep", + "segments": "dutch", + "requiredFeatures": [ + "gate", + { + "feature": "off", + "enabled": false + } + ], + "value": "matched" + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "variable": "subject", + "dependencies": [ + { + "feature": "gate", + "dependencyPurpose": "condition" + }, + { + "feature": "off", + "dependencyPurpose": "condition" + } + ], + "expected": { + "reason": "variable_override_rule", + "variableOverrideKey": "dep", + "value": "matched" + } + }, + { + "name": "string variable never equals a number", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "text", + "operator": "equals", + "value": 3 + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "text": { + "type": "string", + "defaultValue": "3" + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "variable": "text", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "variable": "text", + "operator": "equals", + "value": 3 + } + } + }, + { + "name": "variable greaterThan with integer actual and floating expected", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "count", + "operator": "greaterThan", + "value": 2.5 + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "count": { + "type": "integer", + "defaultValue": 3 + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "variable": "count", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "variable greaterThan with double actual and integer expected", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "count", + "operator": "greaterThan", + "value": 3 + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "count": { + "type": "double", + "defaultValue": 3.5 + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "variable": "count", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "numeric getter integer equals expected 1.0 lexeme", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "subject": { + "type": "integer", + "defaultValue": 1 + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "variable": "subject", + "dependencies": [], + "expected": { + "reason": "variable_default", + "value": 1.0 + } + }, + { + "name": "absent feature satisfies enabled false", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "missing", + "enabled": false + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "missing", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "disabled feature matches its disabled variation", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "off", + "enabled": false, + "variation": "control" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [], + "variations": [ + { + "value": "control" + }, + { + "value": "treatment" + } + ], + "disabledVariationValue": "control" + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "off", + "dependencyPurpose": "condition" + }, + { + "feature": "off", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "variation leaf without variations does not match", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variation": "control" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "dependencyPurpose": "condition" + }, + { + "feature": "gate", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "feature": "gate", + "variation": "control" + } + } + }, + { + "name": "sticky dependency feature drives the gate", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "off" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "options": { + "stickyFeatures": { + "off": { + "enabled": true + } + } + }, + "dependencies": [ + { + "feature": "off", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "cycle under NOT fails the evaluation", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "not": [ + { + "feature": "loop" + } + ] + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + }, + "loop": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "loop" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "loop", + "dependencyPurpose": "condition" + } + ], + "expected": { + "reason": "error" + } + }, + { + "name": "short circuit OR never reaches a cycle", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "or": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "feature": "loop" + } + ] + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + }, + "loop": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "loop" + } + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "global override referencing itself is a cycle", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "overrides": [ + { + "key": "self", + "conditions": { + "variable": "subject", + "operator": "equals", + "value": "default" + }, + "value": "incorrect" + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "variable": "subject", + "dependencies": [], + "expected": { + "reason": "error", + "value": null + } + }, + { + "name": "segment and direct feature leaf memoize one dependency", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + }, + "needsGate": { + "conditions": { + "feature": "gate" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": [ + "needsGate", + { + "feature": "gate" + }, + "needsGate" + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "variable leaf over an alias reports condition then composition", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "variable": "alias", + "path": [ + "limit" + ], + "operator": "equals", + "value": 3 + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "alias": { + "type": "object", + "compose": "policy" + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "variable": "alias", + "dependencyPurpose": "condition" + }, + { + "variable": "policy", + "dependencyPurpose": "composition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "composition override never mutates its source in one evaluation", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": [ + { + "variable": "alias", + "path": [ + "limit" + ], + "operator": "equals", + "value": 90 + }, + { + "variable": "policy", + "path": [ + "limit" + ], + "operator": "equals", + "value": 3 + }, + { + "variable": "alias", + "path": [ + "limit" + ], + "operator": "equals", + "value": 90 + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "alias": { + "type": "object", + "compose": "policy", + "overrideProgram": [ + { + "key": "all", + "keyPath": [ + "all" + ], + "conditions": "*", + "operations": [ + { + "op": "set", + "path": [ + "limit" + ], + "value": 90 + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "variable": "alias", + "dependencyPurpose": "condition" + }, + { + "variable": "policy", + "dependencyPurpose": "composition" + }, + { + "variable": "policy", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "malformed composition gate returns disabledValue and empty OR metadata", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "subject": { + "type": "object", + "compose": "policy", + "conditions": "{\"or\":[", + "disabledValue": { + "blocked": true + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "variable": "subject", + "dependencies": [], + "expected": { + "reason": "conditions_unmet", + "conditions": { + "or": [] + }, + "value": { + "blocked": true + } + } + }, + { + "name": "alias chain gates at each hop in nl", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "subject": { + "type": "object", + "compose": "hop2" + }, + "hop1": { + "type": "object", + "compose": "policy", + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + "disabledValue": { + "hop": 1 + } + }, + "hop2": { + "type": "object", + "compose": "hop1", + "conditions": { + "not": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ] + } + } + } + }, + "context": { + "country": "nl" + }, + "variable": "subject", + "dependencies": [ + { + "variable": "hop2", + "dependencyPurpose": "composition" + }, + { + "variable": "hop1", + "dependencyPurpose": "composition" + }, + { + "variable": "policy", + "dependencyPurpose": "composition" + } + ], + "expected": { + "reason": "variable_composed", + "value": { + "enabled": true, + "limit": 3 + } + } + }, + { + "name": "alias chain gates at each hop in de", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "subject": { + "type": "object", + "compose": "hop2" + }, + "hop1": { + "type": "object", + "compose": "policy", + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + "disabledValue": { + "hop": 1 + } + }, + "hop2": { + "type": "object", + "compose": "hop1", + "conditions": { + "not": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ] + } + } + } + }, + "context": { + "country": "de" + }, + "variable": "subject", + "dependencies": [ + { + "variable": "hop2", + "dependencyPurpose": "composition" + }, + { + "variable": "hop1", + "dependencyPurpose": "composition" + } + ], + "expected": { + "reason": "variable_composed", + "value": { + "hop": 1 + } + } + }, + { + "name": "alias chain gates at each hop in us", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "subject": { + "type": "object", + "compose": "hop2" + }, + "hop1": { + "type": "object", + "compose": "policy", + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + "disabledValue": { + "hop": 1 + } + }, + "hop2": { + "type": "object", + "compose": "hop1", + "conditions": { + "not": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ] + } + } + } + }, + "context": { + "country": "us" + }, + "variable": "subject", + "dependencies": [ + { + "variable": "hop2", + "dependencyPurpose": "composition" + } + ], + "expected": { + "reason": "variable_composed", + "value": null + } + }, + { + "name": "root gate reads a composed alias", + "type": "variable", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + }, + "subject": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "conditions": { + "variable": "alias", + "operator": "equals", + "value": true + } + }, + "source": { + "type": "boolean", + "defaultValue": true + }, + "alias": { + "type": "boolean", + "compose": "source" + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "variable": "subject", + "dependencies": [ + { + "variable": "alias", + "dependencyPurpose": "condition" + }, + { + "variable": "source", + "dependencyPurpose": "composition" + } + ], + "expected": { + "reason": "variable_default", + "value": "default" + } + }, + { + "name": "schema 2 parsed empty [] at feature variable rule override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variableOverrides": { + "label": [ + { + "conditions": [], + "value": "matched" + } + ] + } + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + } + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 parsed empty [] at feature variable variation override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variation": "control" + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + }, + "variations": [ + { + "value": "control", + "variableOverrides": { + "label": [ + { + "conditions": [], + "value": "matched" + } + ] + } + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 stringified empty [] at feature variable rule override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variableOverrides": { + "label": [ + { + "conditions": "[]", + "value": "matched" + } + ] + } + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + } + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 stringified empty [] at feature variable variation override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variation": "control" + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + }, + "variations": [ + { + "value": "control", + "variableOverrides": { + "label": [ + { + "conditions": "[]", + "value": "matched" + } + ] + } + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 parsed empty {\"and\":[]} at feature variable rule override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variableOverrides": { + "label": [ + { + "conditions": { + "and": [] + }, + "value": "matched" + } + ] + } + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + } + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 parsed empty {\"and\":[]} at feature variable variation override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variation": "control" + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + }, + "variations": [ + { + "value": "control", + "variableOverrides": { + "label": [ + { + "conditions": { + "and": [] + }, + "value": "matched" + } + ] + } + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 stringified empty {\"and\":[]} at feature variable rule override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variableOverrides": { + "label": [ + { + "conditions": "{\"and\":[]}", + "value": "matched" + } + ] + } + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + } + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 2 stringified empty {\"and\":[]} at feature variable variation override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "2", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variation": "control" + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + }, + "variations": [ + { + "value": "control", + "variableOverrides": { + "label": [ + { + "conditions": "{\"and\":[]}", + "value": "matched" + } + ] + } + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": true, + "reason": "rule", + "ruleKey": "all" + } + }, + { + "name": "schema 4 parsed empty [] at feature variable rule override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variableOverrides": { + "label": [ + { + "conditions": [], + "value": "matched" + } + ] + } + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + } + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + } + }, + { + "name": "schema 4 parsed empty [] at feature variable variation override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variation": "control" + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + }, + "variations": [ + { + "value": "control", + "variableOverrides": { + "label": [ + { + "conditions": [], + "value": "matched" + } + ] + } + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + } + }, + { + "name": "schema 4 stringified empty [] at feature variable rule override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variableOverrides": { + "label": [ + { + "conditions": "[]", + "value": "matched" + } + ] + } + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + } + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + } + }, + { + "name": "schema 4 stringified empty [] at feature variable variation override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variation": "control" + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + }, + "variations": [ + { + "value": "control", + "variableOverrides": { + "label": [ + { + "conditions": "[]", + "value": "matched" + } + ] + } + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + } + }, + { + "name": "schema 4 parsed empty {\"and\":[]} at feature variable rule override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variableOverrides": { + "label": [ + { + "conditions": { + "and": [] + }, + "value": "matched" + } + ] + } + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + } + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + } + }, + { + "name": "schema 4 parsed empty {\"and\":[]} at feature variable variation override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variation": "control" + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + }, + "variations": [ + { + "value": "control", + "variableOverrides": { + "label": [ + { + "conditions": { + "and": [] + }, + "value": "matched" + } + ] + } + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + } + }, + { + "name": "schema 4 stringified empty {\"and\":[]} at feature variable rule override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variableOverrides": { + "label": [ + { + "conditions": "{\"and\":[]}", + "value": "matched" + } + ] + } + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + } + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + } + }, + { + "name": "schema 4 stringified empty {\"and\":[]} at feature variable variation override", + "type": "flag", + "feature": "subject", + "datafile": { + "schemaVersion": "4", + "revision": "conditions-conformance", + "segments": { + "dutch": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000, + "variation": "control" + } + ], + "variablesSchema": { + "label": { + "type": "string", + "defaultValue": "default" + } + }, + "variations": [ + { + "value": "control", + "variableOverrides": { + "label": [ + { + "conditions": "{\"and\":[]}", + "value": "matched" + } + ] + } + } + ] + }, + "subject": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ], + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + }, + "off": { + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { + "enabled": true, + "limit": 3 + } + } + } + }, + "context": { + "userId": "conditions-user", + "country": "nl" + }, + "dependencies": [ + { + "feature": "gate", + "variable": "label", + "dependencyPurpose": "condition" + } + ], + "expected": { + "enabled": false, + "reason": "conditions_unmet", + "conditions": { + "feature": "gate", + "variable": "label", + "operator": "equals", + "value": "matched" + } + } + } + ] +} diff --git a/conformance/sdk-diagnostics.json b/conformance/sdk-diagnostics.json new file mode 100644 index 0000000..ec57d42 --- /dev/null +++ b/conformance/sdk-diagnostics.json @@ -0,0 +1,196 @@ +{ + "kind": "featurevisor.sdk-diagnostics", + "version": 1, + "cases": [ + { + "name": "eager and lazy diagnostics cache before reentry and reset only replaced entities", + "operations": [ + { + "action": "setDatafile", + "datafile": { + "schemaVersion": "2", + "revision": "r2", + "segments": { + "broken": { + "conditions": "[{\"attribute\":" + } + }, + "features": { + "a": { + "bucketBy": "userId", + "force": [ + { + "conditions": "{\"and\":[", + "enabled": false + } + ], + "traffic": [ + { + "key": "all", + "segments": "*", + "percentage": 100000 + } + ] + } + } + }, + "replace": true, + "expectedResult": true, + "diagnostics": [ + { + "code": "conditions_parse_error", + "level": "error", + "details": { + "conditions": "[{\"attribute\":", + "entityType": "segment", + "entityKey": "broken" + }, + "revision": "r2" + }, + { + "code": "conditions_parse_error", + "level": "error", + "details": { + "conditions": "{\"and\":[", + "entityType": "feature", + "entityKey": "a" + }, + "revision": "r2" + } + ] + }, + { + "action": "isEnabled", + "feature": "a", + "expectedResult": true, + "diagnostics": [] + }, + { + "action": "setDatafile", + "datafile": { + "schemaVersion": "4", + "revision": "r4", + "segments": {}, + "features": { + "b": { + "bucketBy": "userId", + "traffic": [ + { + "key": "broken", + "conditions": "[\"x\"", + "percentage": 100000 + }, + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + } + }, + "replace": true, + "expectedResult": true, + "diagnostics": [] + }, + { + "action": "isEnabled", + "feature": "b", + "expectedResult": true, + "diagnostics": [ + { + "code": "conditions_parse_error", + "level": "error", + "details": { + "conditions": "[\"x\"", + "entityType": "feature", + "entityKey": "b" + }, + "revision": "r4" + } + ] + }, + { + "action": "isEnabled", + "feature": "b", + "expectedResult": true, + "diagnostics": [] + }, + { + "action": "setDatafile", + "datafile": { + "schemaVersion": "4", + "revision": "r5", + "segments": {}, + "features": { + "c": { + "bucketBy": "userId", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + } + }, + "replace": false, + "expectedResult": true, + "diagnostics": [] + }, + { + "action": "isEnabled", + "feature": "b", + "expectedResult": true, + "diagnostics": [] + }, + { + "action": "setDatafile", + "datafile": { + "schemaVersion": "4", + "revision": "r6", + "segments": {}, + "features": { + "b": { + "bucketBy": "userId", + "traffic": [ + { + "key": "broken", + "conditions": "[\"x\"", + "percentage": 100000 + }, + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + } + }, + "replace": false, + "expectedResult": true, + "diagnostics": [] + }, + { + "action": "isEnabled", + "feature": "b", + "expectedResult": true, + "diagnostics": [ + { + "code": "conditions_parse_error", + "level": "error", + "details": { + "conditions": "[\"x\"", + "entityType": "feature", + "entityKey": "b" + }, + "revision": "r6" + } + ] + } + ] + } + ] +} diff --git a/conformance/sdk-stateful.json b/conformance/sdk-stateful.json new file mode 100644 index 0000000..fdacf88 --- /dev/null +++ b/conformance/sdk-stateful.json @@ -0,0 +1,4023 @@ +{ + "kind": "featurevisor.sdk-stateful", + "schemaVersion": 1, + "cases": [ + { + "name": "partial and full variable updates propagate through same hash dependents", + "initial": { + "datafile": { + "schemaVersion": "2", + "revision": "initial", + "segments": {}, + "features": { + "dependent": { + "hash": "dependent", + "bucketBy": "userId", + "conditions": [{ "variable": "gate", "operator": "equals", "value": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "transitive": { + "hash": "transitive", + "bucketBy": "userId", + "requiredFeatures": ["dependent"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + }, + "variables": { + "gate": { "hash": "gate-1", "type": "boolean", "defaultValue": false }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [{ "variable": "gate", "operator": "equals", "value": true }] + } + } + } + }, + "operations": [ + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "2", + "revision": "partial", + "segments": {}, + "features": {}, + "variables": { + "gate": { "hash": "gate-2", "type": "boolean", "defaultValue": true } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "partial", + "previousRevision": "initial", + "revisionChanged": true, + "features": ["dependent", "transitive"], + "variables": ["gate", "message"], + "replaced": false + } + } + ], + "results": [ + { "target": "root", "method": "isEnabled", "args": ["dependent"], "value": true }, + { "target": "root", "method": "isEnabled", "args": ["transitive"], "value": true }, + { "target": "root", "method": "getVariable", "args": ["message"], "value": "ready" } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "2", + "revision": "full", + "segments": {}, + "features": { + "dependent": { + "hash": "dependent", + "bucketBy": "userId", + "conditions": [{ "variable": "gate", "operator": "equals", "value": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "transitive": { + "hash": "transitive", + "bucketBy": "userId", + "requiredFeatures": ["dependent"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + }, + "variables": { + "gate": { "hash": "gate-3", "type": "boolean", "defaultValue": false }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [{ "variable": "gate", "operator": "equals", "value": true }] + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "full", + "previousRevision": "partial", + "revisionChanged": true, + "features": ["dependent", "transitive"], + "variables": ["gate", "message"], + "replaced": true + } + } + ], + "results": [ + { "target": "root", "method": "isEnabled", "args": ["dependent"], "value": false }, + { "target": "root", "method": "isEnabled", "args": ["transitive"], "value": false }, + { "target": "root", "method": "getVariable", "args": ["message"], "value": "blocked" } + ] + } + } + ] + }, + { + "name": "partial and full updates use new and old dependency graphs", + "initial": { + "datafile": { + "schemaVersion": "2", + "revision": "initial", + "segments": {}, + "features": { + "dependent": { + "hash": "dependent", + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "transitive": { + "hash": "transitive", + "bucketBy": "userId", + "requiredFeatures": ["dependent"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + }, + "variables": { + "gate": { "hash": "gate-1", "type": "boolean", "defaultValue": false }, + "message": { "hash": "message", "type": "string", "defaultValue": "ready" } + } + } + }, + "operations": [ + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "2", + "revision": "added", + "segments": {}, + "features": { + "dependent": { + "hash": "dependent", + "bucketBy": "userId", + "conditions": [{ "variable": "gate", "operator": "equals", "value": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + }, + "variables": { + "gate": { "hash": "gate-2", "type": "boolean", "defaultValue": false }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [{ "variable": "gate", "operator": "equals", "value": true }] + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "added", + "previousRevision": "initial", + "revisionChanged": true, + "features": ["dependent", "transitive"], + "variables": ["gate", "message"], + "replaced": false + } + } + ], + "results": [ + { "target": "root", "method": "isEnabled", "args": ["dependent"], "value": false }, + { "target": "root", "method": "getVariable", "args": ["message"], "value": "blocked" } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "2", + "revision": "removed", + "segments": {}, + "features": { + "dependent": { + "hash": "dependent", + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "transitive": { + "hash": "transitive", + "bucketBy": "userId", + "requiredFeatures": ["dependent"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + }, + "variables": { + "gate": { "hash": "gate-3", "type": "boolean", "defaultValue": false }, + "message": { "hash": "message", "type": "string", "defaultValue": "ready" } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "removed", + "previousRevision": "added", + "revisionChanged": true, + "features": ["dependent", "transitive"], + "variables": ["gate", "message"], + "replaced": true + } + } + ], + "results": [ + { "target": "root", "method": "isEnabled", "args": ["dependent"], "value": true }, + { "target": "root", "method": "getVariable", "args": ["message"], "value": "ready" } + ] + } + } + ] + }, + { + "name": "rejected JSON preserves previous state", + "initial": { + "datafile": { + "schemaVersion": "2", + "revision": "stable", + "segments": {}, + "features": { + "stable": { + "hash": "stable", + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + } + } + }, + "operations": [ + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": "{not JSON", + "expect": { + "error": { "code": "invalid_datafile" }, + "events": [], + "results": [ + { "target": "root", "method": "getRevision", "args": [], "value": "stable" }, + { "target": "root", "method": "isEnabled", "args": ["stable"], "value": true } + ] + } + } + ] + }, + { + "name": "root and child sticky payloads stay isolated", + "initial": { + "datafile": { + "schemaVersion": "2", + "revision": "sticky", + "segments": {}, + "features": { + "flag": { + "hash": "flag", + "bucketBy": "userId", + "variablesSchema": { + "message": { "type": "string", "defaultValue": "default-feature" } + }, + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + }, + "variables": { + "message": { "hash": "message", "type": "string", "defaultValue": "default-global" } + } + } + }, + "operations": [ + { + "action": "spawn", + "parent": "root", + "target": "child", + "context": { "tenant": "child" }, + "options": { + "stickyFeatures": { "flag": { "enabled": false } }, + "stickyVariables": { "message": "child-global" } + }, + "expect": { + "events": [], + "results": [ + { "target": "root", "method": "isEnabled", "args": ["flag"], "value": true }, + { "target": "child", "method": "isEnabled", "args": ["flag"], "value": false }, + { + "target": "root", + "method": "getVariable", + "args": ["message"], + "value": "default-global" + }, + { + "target": "child", + "method": "getVariable", + "args": ["message"], + "value": "child-global" + } + ] + } + }, + { + "action": "setStickyFeatures", + "target": "root", + "replace": true, + "stickyFeatures": { + "flag": { "enabled": true, "variables": { "message": "root-feature" } } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "sticky_features_set", + "details": { "features": ["flag"], "replaced": true } + } + ], + "results": [ + { + "target": "root", + "method": "getVariable", + "args": ["flag", "message"], + "value": "root-feature" + }, + { "target": "child", "method": "isEnabled", "args": ["flag"], "value": false } + ] + } + }, + { + "action": "setStickyFeatures", + "target": "child", + "replace": true, + "stickyFeatures": { + "flag": { "enabled": true, "variables": { "message": "child-feature" } } + }, + "expect": { + "events": [ + { + "target": "child", + "name": "sticky_features_set", + "details": { "features": ["flag"], "replaced": true } + } + ], + "results": [ + { + "target": "root", + "method": "getVariable", + "args": ["flag", "message"], + "value": "root-feature" + }, + { + "target": "child", + "method": "getVariable", + "args": ["flag", "message"], + "value": "child-feature" + }, + { + "target": "root", + "method": "getVariable", + "args": ["message"], + "value": "default-global" + }, + { + "target": "child", + "method": "getVariable", + "args": ["message"], + "value": "child-global" + } + ] + } + }, + { + "action": "setStickyVariables", + "target": "child", + "replace": true, + "stickyVariables": { "message": "child-global-updated" }, + "expect": { + "events": [ + { + "target": "child", + "name": "sticky_variables_set", + "details": { "variables": ["message"], "replaced": true } + } + ], + "results": [ + { + "target": "root", + "method": "getVariable", + "args": ["message"], + "value": "default-global" + }, + { + "target": "child", + "method": "getVariable", + "args": ["message"], + "value": "child-global-updated" + } + ] + } + } + ] + }, + { + "name": "cached graph refreshes after segment only partial updates", + "initial": { + "datafile": { + "schemaVersion": "4", + "revision": "0", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + "s4": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "g": { + "hash": "g1", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "context": { + "userId": "subject", + "country": "nl" + } + }, + "operations": [ + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "1", + "segments": {}, + "features": { + "g": { + "hash": "g2", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "1", + "previousRevision": "0", + "revisionChanged": true, + "features": [ + "g" + ], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "isEnabled", + "args": [ + "g" + ], + "value": true + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "2", + "segments": { + "s4": { + "conditions": [ + "s0" + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "2", + "previousRevision": "1", + "revisionChanged": true, + "features": [ + "f3" + ], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "isEnabled", + "args": [ + "g" + ], + "value": true + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "3", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "de" + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "3", + "previousRevision": "2", + "revisionChanged": true, + "features": [ + "f3" + ], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": false + }, + { + "target": "root", + "method": "isEnabled", + "args": [ + "g" + ], + "value": true + } + ] + } + } + ] + }, + { + "name": "cache free graph history seed 5", + "initial": { + "datafile": { + "schemaVersion": "4", + "revision": "0", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + "s4": { + "conditions": [ + "s0" + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "g": { + "hash": "g0", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "hash": "gate0", + "type": "boolean", + "defaultValue": true + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "context": { + "userId": "subject", + "country": "nl" + } + }, + "operations": [ + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "1", + "segments": {}, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "1", + "previousRevision": "0", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "2", + "segments": {}, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "2", + "previousRevision": "1", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "3", + "segments": {}, + "features": {}, + "variables": { + "gate": { + "type": "boolean", + "defaultValue": false + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "3", + "previousRevision": "2", + "revisionChanged": true, + "features": [], + "variables": [ + "gate" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "4", + "revision": "4", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + "s4": { + "conditions": [ + "s0" + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "g": { + "hash": "g4", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "type": "boolean", + "defaultValue": false + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "4", + "previousRevision": "3", + "revisionChanged": true, + "features": [ + "g" + ], + "variables": [ + "gate" + ], + "replaced": true + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "5", + "segments": { + "s4": { + "conditions": [ + "s0" + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "5", + "previousRevision": "4", + "revisionChanged": true, + "features": [], + "variables": [ + "gate" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "6", + "segments": {}, + "features": {}, + "variables": { + "gate": { + "type": "boolean", + "defaultValue": true + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "6", + "previousRevision": "5", + "revisionChanged": true, + "features": [], + "variables": [ + "gate" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "7", + "segments": { + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "7", + "previousRevision": "6", + "revisionChanged": true, + "features": [ + "f3" + ], + "variables": [ + "alias", + "gate", + "message" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "4", + "revision": "8", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "hash": "gate8", + "type": "boolean", + "defaultValue": true + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "8", + "previousRevision": "7", + "revisionChanged": true, + "features": [ + "f3", + "g" + ], + "variables": [ + "alias", + "gate", + "message" + ], + "replaced": true + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "9", + "segments": {}, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "9", + "previousRevision": "8", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "10", + "segments": { + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "10", + "previousRevision": "9", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "11", + "segments": {}, + "features": {}, + "variables": { + "gate": { + "hash": "gate11", + "type": "boolean", + "defaultValue": false + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "11", + "previousRevision": "10", + "revisionChanged": true, + "features": [ + "f3" + ], + "variables": [ + "alias", + "gate", + "message" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "blocked" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "blocked" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "4", + "revision": "12", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "de" + } + ] + }, + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "hash": "gate11", + "type": "boolean", + "defaultValue": false + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "12", + "previousRevision": "11", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": true + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "blocked" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "blocked" + } + ] + } + } + ] + }, + { + "name": "cache free graph history seed 11", + "initial": { + "datafile": { + "schemaVersion": "4", + "revision": "0", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + "s4": { + "conditions": [ + "s0" + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "g": { + "hash": "g0", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "hash": "gate0", + "type": "boolean", + "defaultValue": true + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "context": { + "userId": "subject", + "country": "nl" + } + }, + "operations": [ + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "1", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "de" + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "1", + "previousRevision": "0", + "revisionChanged": true, + "features": [ + "f3" + ], + "variables": [ + "alias", + "message" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "blocked" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "blocked" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "2", + "segments": { + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "2", + "previousRevision": "1", + "revisionChanged": true, + "features": [ + "f3" + ], + "variables": [ + "alias", + "message" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "3", + "segments": {}, + "features": { + "g": { + "hash": "g3", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "3", + "previousRevision": "2", + "revisionChanged": true, + "features": [ + "g" + ], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "4", + "revision": "4", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "de" + } + ] + }, + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "g": { + "hash": "g4", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "hash": "gate0", + "type": "boolean", + "defaultValue": true + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "4", + "previousRevision": "3", + "revisionChanged": true, + "features": [ + "g" + ], + "variables": [], + "replaced": true + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "5", + "segments": {}, + "features": { + "g": { + "hash": "g5", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "5", + "previousRevision": "4", + "revisionChanged": true, + "features": [ + "g" + ], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "6", + "segments": {}, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "6", + "previousRevision": "5", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "7", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "de" + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "7", + "previousRevision": "6", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "4", + "revision": "8", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "de" + } + ] + }, + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "hash": "gate8", + "type": "boolean", + "defaultValue": false + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "8", + "previousRevision": "7", + "revisionChanged": true, + "features": [ + "f3", + "g" + ], + "variables": [ + "alias", + "gate", + "message" + ], + "replaced": true + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "blocked" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "blocked" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "9", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "9", + "previousRevision": "8", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "blocked" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "blocked" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "10", + "segments": {}, + "features": {}, + "variables": { + "gate": { + "hash": "gate10", + "type": "boolean", + "defaultValue": true + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "10", + "previousRevision": "9", + "revisionChanged": true, + "features": [ + "f3" + ], + "variables": [ + "alias", + "gate", + "message" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "11", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "11", + "previousRevision": "10", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "4", + "revision": "12", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "hash": "gate10", + "type": "boolean", + "defaultValue": true + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "12", + "previousRevision": "11", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": true + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + } + ] + }, + { + "name": "cache free graph history seed 29", + "initial": { + "datafile": { + "schemaVersion": "4", + "revision": "0", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + "s4": { + "conditions": [ + "s0" + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": [ + "s4" + ], + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "g": { + "hash": "g0", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "hash": "gate0", + "type": "boolean", + "defaultValue": true + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "context": { + "userId": "subject", + "country": "nl" + } + }, + "operations": [ + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "1", + "segments": {}, + "features": {}, + "variables": { + "gate": { + "hash": "gate1", + "type": "boolean", + "defaultValue": false + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "1", + "previousRevision": "0", + "revisionChanged": true, + "features": [], + "variables": [ + "gate" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "2", + "segments": {}, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "2", + "previousRevision": "1", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": false + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "3", + "segments": {}, + "features": {}, + "variables": { + "gate": { + "type": "boolean", + "defaultValue": true + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "3", + "previousRevision": "2", + "revisionChanged": true, + "features": [], + "variables": [ + "gate" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "4", + "revision": "4", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + "s4": { + "conditions": [ + "s0" + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + }, + "g": { + "hash": "g0", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "hash": "gate4", + "type": "boolean", + "defaultValue": true + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "4", + "previousRevision": "3", + "revisionChanged": true, + "features": [], + "variables": [ + "gate" + ], + "replaced": true + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "5", + "segments": {}, + "features": { + "g": { + "hash": "g5", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "5", + "previousRevision": "4", + "revisionChanged": true, + "features": [ + "g" + ], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "6", + "segments": {}, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "6", + "previousRevision": "5", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "7", + "segments": { + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "7", + "previousRevision": "6", + "revisionChanged": true, + "features": [], + "variables": [], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "4", + "revision": "8", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "de" + } + ] + }, + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "hash": "gate4", + "type": "boolean", + "defaultValue": true + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "8", + "previousRevision": "7", + "revisionChanged": true, + "features": [ + "g" + ], + "variables": [], + "replaced": true + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "9", + "segments": {}, + "features": {}, + "variables": { + "gate": { + "type": "boolean", + "defaultValue": true + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "9", + "previousRevision": "8", + "revisionChanged": true, + "features": [], + "variables": [ + "gate" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "10", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "de" + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "10", + "previousRevision": "9", + "revisionChanged": true, + "features": [], + "variables": [ + "gate" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": false, + "datafile": { + "schemaVersion": "4", + "revision": "11", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "de" + } + ] + } + }, + "features": {}, + "variables": {} + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "11", + "previousRevision": "10", + "revisionChanged": true, + "features": [], + "variables": [ + "gate" + ], + "replaced": false + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + }, + { + "action": "setDatafile", + "target": "root", + "replace": true, + "datafile": { + "schemaVersion": "4", + "revision": "12", + "segments": { + "s0": { + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "de" + } + ] + }, + "s4": { + "conditions": [ + { + "variable": "gate", + "operator": "equals", + "value": true + } + ] + } + }, + "features": { + "f3": { + "hash": "f3", + "bucketBy": "userId", + "conditions": "*", + "traffic": [ + { + "key": "all", + "conditions": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "gate": { + "type": "boolean", + "defaultValue": true + }, + "message": { + "hash": "message", + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [ + { + "feature": "f3" + } + ] + }, + "alias": { + "hash": "alias", + "type": "string", + "compose": "message" + } + } + }, + "expect": { + "events": [ + { + "target": "root", + "name": "datafile_set", + "details": { + "revision": "12", + "previousRevision": "11", + "revisionChanged": true, + "features": [], + "variables": [ + "gate" + ], + "replaced": true + } + } + ], + "results": [ + { + "target": "root", + "method": "isEnabled", + "args": [ + "f3" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "gate" + ], + "value": true + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "message" + ], + "value": "ready" + }, + { + "target": "root", + "method": "getVariable", + "args": [ + "alias" + ], + "value": "ready" + } + ] + } + } + ] + } + ] +} diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index d5870a5..61bc432 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,6 +1,41 @@ { - "version": 6, - "description": "Featurevisor v3 cross SDK compatibility contracts", + "version": 15, + "description": "Featurevisor SDK compatibility contracts, including evaluated variable requirements", + "requiredFeatureGroupInputs": [ + { "a": false, "b": false }, + { "a": false, "b": true }, + { "a": true, "b": false }, + { "a": true, "b": true } + ], + "requiredFeatureGroups": [ + { "name": "implicit AND", "requirements": ["a", "b"], "results": [false, false, false, true] }, + { + "name": "explicit AND", + "requirements": [{ "and": ["a", "b"] }], + "results": [false, false, false, true] + }, + { "name": "OR", "requirements": [{ "or": ["a", "b"] }], "results": [false, true, true, true] }, + { + "name": "NOT means not all", + "requirements": [{ "not": ["a", "b"] }], + "results": [true, true, true, false] + }, + { + "name": "NOT OR means neither", + "requirements": [{ "not": [{ "or": ["a", "b"] }] }], + "results": [true, false, false, false] + }, + { + "name": "disabled leaf in OR", + "requirements": [{ "or": [{ "feature": "a", "enabled": false }, "b"] }], + "results": [true, true, false, true] + }, + { + "name": "empty NOT defensive fallback", + "requirements": [{ "not": [] }], + "results": [false, false, false, false] + } + ], "bucketing": { "minimum": 0, "maximum": 100000, @@ -237,25 +272,25 @@ "name": "required unmet with disabled value", "key": "requiredDisabled", "expectedValue": "disabled", - "expectedReason": "required_features_unmet" + "expectedReason": "conditions_unmet" }, { "name": "required unmet without value", "key": "requiredMissingValue", - "expectedReason": "required_features_unmet" + "expectedReason": "conditions_unmet" }, { "name": "required unmet with caller default", "key": "requiredMissingValue", "defaultVariableValue": "caller", "expectedValue": "caller", - "expectedReason": "required_features_unmet" + "expectedReason": "conditions_unmet" }, { "name": "required unmet using variable default", "key": "requiredUsesDefault", "expectedValue": "default", - "expectedReason": "required_features_unmet" + "expectedReason": "conditions_unmet" }, { "name": "required variation matched", @@ -583,11 +618,30 @@ "unmetMultipleRequirements": { "bucketBy": "userId", "requiredFeatures": ["enabledFeature", { "feature": "disabledFeature", "enabled": true }], + "variablesSchema": { "message": { "type": "string", "defaultValue": "ready" } }, + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "unmetWithDisabledValues": { + "bucketBy": "userId", + "requiredFeatures": ["disabledFeature"], + "disabledVariationValue": "off", + "variablesSchema": { + "message": { "type": "string", "defaultValue": "ready", "disabledValue": "blocked" }, + "fallback": { + "type": "string", + "defaultValue": "ready", + "useDefaultWhenDisabled": true + } + }, + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "unmetVariableRequirement": { + "bucketBy": "userId", + "conditions": [{ "variable": "gate", "operator": "equals", "value": true }], "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] }, - "canonicalPrecedence": { + "canonicalRequirement": { "bucketBy": "userId", - "required": ["disabledFeature"], "requiredFeatures": ["enabledFeature"], "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] }, @@ -618,6 +672,21 @@ } ] } + }, + "variables": { + "gate": { "type": "boolean", "defaultValue": false }, + "featureGatedMessage": { + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "requiredFeatures": ["disabledFeature"] + }, + "variableGatedMessage": { + "type": "string", + "defaultValue": "ready", + "disabledValue": "blocked", + "conditions": [{ "variable": "gate", "operator": "equals", "value": true }] + } } }, "cases": [ @@ -657,11 +726,70 @@ "expectedEnabled": false }, { - "name": "requiredFeatures takes precedence over required", - "feature": "canonicalPrecedence", + "name": "canonical requiredFeatures", + "feature": "canonicalRequirement", "expectedEnabled": true } ], + "evaluationCases": [ + { + "name": "feature flag uses the canonical unmet feature reason", + "type": "flag", + "feature": "unmetMultipleRequirements", + "expected": { "reason": "conditions_unmet", "enabled": false } + }, + { + "name": "variation retains its disabled reason", + "type": "variation", + "feature": "unmetMultipleRequirements", + "expected": { "reason": "disabled" } + }, + { + "name": "feature variable retains its disabled reason", + "type": "variable", + "feature": "unmetMultipleRequirements", + "variable": "message", + "expected": { "reason": "disabled" } + }, + { + "name": "variation retains its disabled value policy", + "type": "variation", + "feature": "unmetWithDisabledValues", + "expected": { "reason": "variation_disabled", "variationValue": "off", "enabled": false } + }, + { + "name": "feature variable retains its explicit disabled value", + "type": "variable", + "feature": "unmetWithDisabledValues", + "variable": "message", + "expected": { "reason": "variable_disabled", "variableValue": "blocked", "enabled": false } + }, + { + "name": "feature variable retains its disabled default policy", + "type": "variable", + "feature": "unmetWithDisabledValues", + "variable": "fallback", + "expected": { "reason": "variable_default", "variableValue": "ready", "enabled": false } + }, + { + "name": "global variable uses the canonical unmet feature reason", + "type": "variable", + "variable": "featureGatedMessage", + "expected": { "reason": "conditions_unmet", "variableValue": "blocked" } + }, + { + "name": "feature flag keeps the distinct unmet variable reason", + "type": "flag", + "feature": "unmetVariableRequirement", + "expected": { "reason": "conditions_unmet", "enabled": false } + }, + { + "name": "global variable keeps the distinct unmet variable reason", + "type": "variable", + "variable": "variableGatedMessage", + "expected": { "reason": "conditions_unmet", "variableValue": "blocked" } + } + ], "featureVariableCase": { "feature": "featureVariableOverride", "variable": "message", @@ -688,6 +816,7 @@ "portableConditions": { "regexFlags": ["g", "i", "m", "s"], "rejectedRegexFlags": ["d", "u", "v", "y"], + "regexEngineLimits": "Portable shorthand cases cover ASCII digit and word classes, their negations on BMP characters, escaped literals, and case insensitive word shorthand outside character classes. Astral and other UTF-16 code unit distinctions, case insensitive word shorthand inside character classes, and broader regular expression engine equivalence are not guaranteed. Whitespace shorthand retains each native engine's current behaviour.", "dateFormat": "ISO 8601 with an explicit timezone", "dates": [ "2024-01-01T00:00:00Z", @@ -701,96 +830,1844 @@ }, "conditionCases": [ { - "name": "strict primitive equality", + "name": "notEquals: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "notEquals", "value": 1.0 }, + "context": { "values": 1 }, + "expected": false + }, + { + "name": "notEquals: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "notEquals", "value": 1 }, + "context": { "values": 1.0 }, + "expected": false + }, + { + "name": "in: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "in", "value": [1.0] }, + "context": { "values": 1 }, + "expected": true + }, + { + "name": "in: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "in", "value": [1] }, + "context": { "values": 1.0 }, + "expected": true + }, + { + "name": "notIn: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "notIn", "value": [1.0] }, + "context": { "values": 1 }, + "expected": false + }, + { + "name": "notIn: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "notIn", "value": [1] }, + "context": { "values": 1.0 }, + "expected": false + }, + { + "name": "greaterThan: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "greaterThan", "value": 1.0 }, + "context": { "values": 1 }, + "expected": false + }, + { + "name": "greaterThan: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "greaterThan", "value": 1 }, + "context": { "values": 1.0 }, + "expected": false + }, + { + "name": "greaterThanOrEquals: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "greaterThanOrEquals", "value": 1.0 }, + "context": { "values": 1 }, + "expected": true + }, + { + "name": "greaterThanOrEquals: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "greaterThanOrEquals", "value": 1 }, + "context": { "values": 1.0 }, + "expected": true + }, + { + "name": "lessThan: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "lessThan", "value": 1.0 }, + "context": { "values": 1 }, + "expected": false + }, + { + "name": "lessThan: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "lessThan", "value": 1 }, + "context": { "values": 1.0 }, + "expected": false + }, + { + "name": "lessThanOrEquals: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "lessThanOrEquals", "value": 1.0 }, + "context": { "values": 1 }, + "expected": true + }, + { + "name": "lessThanOrEquals: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "lessThanOrEquals", "value": 1 }, + "context": { "values": 1.0 }, + "expected": true + }, + { + "name": "equals: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "equals", "value": 1.0 }, + "context": { "values": 1 }, + "expected": true + }, + { + "name": "equals: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "equals", "value": 1 }, + "context": { "values": 1.0 }, + "expected": true + }, + { + "name": "includes: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "includes", "value": 1.0 }, + "context": { "values": [1] }, + "expected": true + }, + { + "name": "includes: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "includes", "value": 1 }, + "context": { "values": [1.0] }, + "expected": true + }, + { + "name": "includesAny: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "includesAny", "value": [1.0] }, + "context": { "values": [1] }, + "expected": true + }, + { + "name": "includesAny: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "includesAny", "value": [1] }, + "context": { "values": [1.0] }, + "expected": true + }, + { + "name": "notIncludesAny: integer actual and floating candidate", + "condition": { "attribute": "values", "operator": "notIncludesAny", "value": [1.0] }, + "context": { "values": [1] }, + "expected": false + }, + { + "name": "notIncludesAny: floating actual and integer candidate", + "condition": { "attribute": "values", "operator": "notIncludesAny", "value": [1] }, + "context": { "values": [1.0] }, + "expected": false + }, + { + "name": "equals: large finite numbers", + "condition": { "attribute": "values", "operator": "equals", "value": 1e30 }, + "context": { "values": 1e30 }, + "expected": true + }, + { + "name": "includes: large finite numbers", + "condition": { "attribute": "values", "operator": "includes", "value": 1e30 }, + "context": { "values": [1e30] }, + "expected": true + }, + { + "name": "includesAny: large finite numbers", + "condition": { "attribute": "values", "operator": "includesAny", "value": [1e30] }, + "context": { "values": [1e30] }, + "expected": true + }, + { + "name": "notIncludesAny: large finite numbers", + "condition": { "attribute": "values", "operator": "notIncludesAny", "value": [1e30] }, + "context": { "values": [1e30] }, + "expected": false + }, + { + "name": "includes: large actual before a string match", + "condition": { "attribute": "values", "operator": "includes", "value": "write" }, + "context": { "values": [1e30, "write"] }, + "expected": true + }, + { + "name": "includesAny: large actual before a string match", + "condition": { "attribute": "values", "operator": "includesAny", "value": ["write"] }, + "context": { "values": [1e30, "write"] }, + "expected": true + }, + { + "name": "notIncludesAny: large actual before a string match", + "condition": { "attribute": "values", "operator": "notIncludesAny", "value": ["write"] }, + "context": { "values": [1e30, "write"] }, + "expected": false + }, + { + "name": "includesAny: large candidate before a string match", + "condition": { "attribute": "values", "operator": "includesAny", "value": [1e30, "write"] }, + "context": { "values": ["write"] }, + "expected": true + }, + { + "name": "equals: tiny finite values do not equal zero", + "condition": { "attribute": "values", "operator": "equals", "value": 1e-100 }, + "context": { "values": 0 }, + "expected": false + }, + { + "name": "equals: tiny finite values", + "condition": { "attribute": "values", "operator": "equals", "value": 1e-100 }, + "context": { "values": 1e-100 }, + "expected": true + }, + { + "name": "includes: tiny finite values do not equal zero", + "condition": { "attribute": "values", "operator": "includes", "value": 1e-100 }, + "context": { "values": [0] }, + "expected": false + }, + { + "name": "includesAny: tiny finite values do not equal zero", + "condition": { "attribute": "values", "operator": "includesAny", "value": [1e-100] }, + "context": { "values": [0] }, + "expected": false + }, + { + "name": "notIncludesAny: tiny finite values do not equal zero", + "condition": { "attribute": "values", "operator": "notIncludesAny", "value": [1e-100] }, + "context": { "values": [0] }, + "expected": true + }, + { + "name": "lessThan: zero is less than a tiny finite value", + "condition": { "attribute": "values", "operator": "lessThan", "value": 1e-100 }, + "context": { "values": 0 }, + "expected": true + }, + { + "name": "greaterThan: tiny finite values exceed zero", + "condition": { "attribute": "values", "operator": "greaterThan", "value": 0 }, + "context": { "values": 1e-100 }, + "expected": true + }, + { + "name": "includesAny: roles hit", "condition": { - "attribute": "value", - "operator": "equals", - "value": 1 + "attribute": "values", + "operator": "includesAny", + "value": ["admin", "owner"] + }, + "context": { + "values": ["member", "owner"] + }, + "expected": true + }, + { + "name": "notIncludesAny: roles hit", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["admin", "owner"] + }, + "context": { + "values": ["member", "owner"] }, - "context": { "value": "1" }, "expected": false }, { - "name": "not negates implicit and", + "name": "includesAny: roles miss", "condition": { - "not": [ - { "attribute": "country", "operator": "equals", "value": "us" }, - { "attribute": "device", "operator": "equals", "value": "mobile" } - ] + "attribute": "values", + "operator": "includesAny", + "value": ["admin", "owner"] + }, + "context": { + "values": ["viewer"] + }, + "expected": false + }, + { + "name": "notIncludesAny: roles miss", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["admin", "owner"] + }, + "context": { + "values": ["viewer"] }, - "context": { "country": "us", "device": "desktop" }, "expected": true }, { - "name": "not with nested or means none match", + "name": "includesAny: empty actual", "condition": { - "not": [ - { - "or": [ - { "attribute": "country", "operator": "equals", "value": "us" }, - { "attribute": "country", "operator": "equals", "value": "nl" } - ] - } - ] + "attribute": "values", + "operator": "includesAny", + "value": ["owner"] + }, + "context": { + "values": [] + }, + "expected": false + }, + { + "name": "notIncludesAny: empty actual", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["owner"] + }, + "context": { + "values": [] }, - "context": { "country": "de" }, "expected": true }, { - "name": "empty not fails defensively", - "condition": { "not": [] }, - "context": {}, + "name": "includesAny: empty candidates", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [] + }, + "context": { + "values": ["owner"] + }, "expected": false }, { - "name": "fractional ISO date with offset", + "name": "notIncludesAny: empty candidates", "condition": { - "attribute": "date", - "operator": "before", - "value": "2024-01-01T00:00:00.500Z" + "attribute": "values", + "operator": "notIncludesAny", + "value": [] + }, + "context": { + "values": ["owner"] }, - "context": { "date": "2024-01-01T01:00:00.250+01:00" }, "expected": true - } - ], - "childInstances": { - "contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win", - "stickyStateModel": "child sticky features and variables replace parent sticky state; omitted child sticky options mean empty sticky state", - "closeRemovesLocalAndDelegatedSubscriptions": true, - "detailedEvaluationMethods": ["flag", "variation", "variable"], - "contextCase": { - "parentAtSpawn": { "country": "nl", "plan": "free" }, - "child": { "country": "de" }, - "parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" }, - "expected": { "country": "de", "plan": "free", "region": "eu" } }, - "stickyCase": { - "datafile": { - "schemaVersion": "2", - "revision": "child-sticky", - "segments": {}, - "features": { - "flag": { - "key": "flag", - "bucketBy": "userId", - "traffic": [] - } - }, - "variables": { - "setting": { - "type": "string", - "defaultValue": "datafile" - } - } + { + "name": "includesAny: both arrays empty", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [] }, - "parentStickyFeatures": { "flag": { "enabled": true } }, - "parentStickyVariables": { "setting": "parent-sticky" }, - "expectedParent": { "flag": true, "setting": "parent-sticky" }, - "expectedChildWithoutStickyOptions": { "flag": false, "setting": "datafile" } + "context": { + "values": [] + }, + "expected": false }, - "globalJsonCase": { - "datafile": { - "schemaVersion": "2", + { + "name": "notIncludesAny: both arrays empty", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [] + }, + "context": { + "values": [] + }, + "expected": true + }, + { + "name": "includesAny: numeric fractional match", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [1.5] + }, + "context": { + "values": [0, 1.5] + }, + "expected": true + }, + { + "name": "notIncludesAny: numeric fractional match", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [1.5] + }, + "context": { + "values": [0, 1.5] + }, + "expected": false + }, + { + "name": "includesAny: numeric string distinction", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["1"] + }, + "context": { + "values": [1] + }, + "expected": false + }, + { + "name": "notIncludesAny: numeric string distinction", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["1"] + }, + "context": { + "values": [1] + }, + "expected": true + }, + { + "name": "includesAny: boolean match", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [false] + }, + "context": { + "values": [true, false] + }, + "expected": true + }, + { + "name": "notIncludesAny: boolean match", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [false] + }, + "context": { + "values": [true, false] + }, + "expected": false + }, + { + "name": "includesAny: boolean is not one", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [1] + }, + "context": { + "values": [true] + }, + "expected": false + }, + { + "name": "notIncludesAny: boolean is not one", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [1] + }, + "context": { + "values": [true] + }, + "expected": true + }, + { + "name": "includesAny: false is not zero", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [0] + }, + "context": { + "values": [false] + }, + "expected": false + }, + { + "name": "notIncludesAny: false is not zero", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [0] + }, + "context": { + "values": [false] + }, + "expected": true + }, + { + "name": "includesAny: null element", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [null] + }, + "context": { + "values": [null, "owner"] + }, + "expected": true + }, + { + "name": "notIncludesAny: null element", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [null] + }, + "context": { + "values": [null, "owner"] + }, + "expected": false + }, + { + "name": "includesAny: null differs from empty text", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [""] + }, + "context": { + "values": [null] + }, + "expected": false + }, + { + "name": "notIncludesAny: null differs from empty text", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [""] + }, + "context": { + "values": [null] + }, + "expected": true + }, + { + "name": "includesAny: duplicates and order", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["absent", "owner", "owner"] + }, + "context": { + "values": ["owner", "owner"] + }, + "expected": true + }, + { + "name": "notIncludesAny: duplicates and order", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["absent", "owner", "owner"] + }, + "context": { + "values": ["owner", "owner"] + }, + "expected": false + }, + { + "name": "includesAny: structured actual elements ignored", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["owner"] + }, + "context": { + "values": [ + { + "role": "owner" + } + ] + }, + "expected": false + }, + { + "name": "notIncludesAny: structured actual elements ignored", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["owner"] + }, + "context": { + "values": [ + { + "role": "owner" + } + ] + }, + "expected": true + }, + { + "name": "includesAny: structured and primitive actual elements", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["owner"] + }, + "context": { + "values": [ + { + "role": "owner" + }, + "owner" + ] + }, + "expected": true + }, + { + "name": "notIncludesAny: structured and primitive actual elements", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["owner"] + }, + "context": { + "values": [ + { + "role": "owner" + }, + "owner" + ] + }, + "expected": false + }, + { + "name": "includesAny: mixed primitive arrays", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["missing", null] + }, + "context": { + "values": [true, 1, "1", null] + }, + "expected": true + }, + { + "name": "notIncludesAny: mixed primitive arrays", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["missing", null] + }, + "context": { + "values": [true, 1, "1", null] + }, + "expected": false + }, + { + "name": "includesAny: case sensitive", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["owner"] + }, + "context": { + "values": ["Owner"] + }, + "expected": false + }, + { + "name": "notIncludesAny: case sensitive", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["owner"] + }, + "context": { + "values": ["Owner"] + }, + "expected": true + }, + { + "name": "includesAny: null actual", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["owner"] + }, + "context": { + "values": null + }, + "expected": false + }, + { + "name": "notIncludesAny: null actual", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["owner"] + }, + "context": { + "values": null + }, + "expected": false + }, + { + "name": "includesAny: missing actual", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["owner"] + }, + "context": {}, + "expected": false + }, + { + "name": "notIncludesAny: missing actual", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["owner"] + }, + "context": {}, + "expected": false + }, + { + "name": "includesAny: string actual", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["owner"] + }, + "context": { + "values": "owner" + }, + "expected": false + }, + { + "name": "notIncludesAny: string actual", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["owner"] + }, + "context": { + "values": "owner" + }, + "expected": false + }, + { + "name": "includesAny: number actual", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [1] + }, + "context": { + "values": 1 + }, + "expected": false + }, + { + "name": "notIncludesAny: number actual", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [1] + }, + "context": { + "values": 1 + }, + "expected": false + }, + { + "name": "includesAny: boolean actual", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [false] + }, + "context": { + "values": false + }, + "expected": false + }, + { + "name": "notIncludesAny: boolean actual", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [false] + }, + "context": { + "values": false + }, + "expected": false + }, + { + "name": "includesAny: object actual", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["owner"] + }, + "context": { + "values": { + "owner": true + } + }, + "expected": false + }, + { + "name": "notIncludesAny: object actual", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["owner"] + }, + "context": { + "values": { + "owner": true + } + }, + "expected": false + }, + { + "name": "includesAny: null candidates", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": null + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "notIncludesAny: null candidates", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": null + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "includesAny: missing candidates", + "condition": { + "attribute": "values", + "operator": "includesAny" + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "notIncludesAny: missing candidates", + "condition": { + "attribute": "values", + "operator": "notIncludesAny" + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "includesAny: scalar candidates", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": "owner" + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "notIncludesAny: scalar candidates", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": "owner" + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "includesAny: object candidates", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": { + "owner": true + } + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "notIncludesAny: object candidates", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": { + "owner": true + } + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "includesAny: nested array candidates", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [["owner"]] + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "notIncludesAny: nested array candidates", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [["owner"]] + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "includesAny: malformed candidates after a match", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": ["owner", {}] + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "notIncludesAny: malformed candidates after a match", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": ["owner", {}] + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "includesAny: malformed candidates before a match", + "condition": { + "attribute": "values", + "operator": "includesAny", + "value": [[], "owner"] + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "notIncludesAny: malformed candidates before a match", + "condition": { + "attribute": "values", + "operator": "notIncludesAny", + "value": [[], "owner"] + }, + "context": { + "values": ["owner"] + }, + "expected": false + }, + { + "name": "includesAny: nested object path", + "condition": { + "attribute": "account.permissions", + "operator": "includesAny", + "value": ["owner"] + }, + "context": { + "account": { + "permissions": ["owner"] + } + }, + "expected": true + }, + { + "name": "notIncludesAny: nested object path", + "condition": { + "attribute": "account.permissions", + "operator": "notIncludesAny", + "value": ["owner"] + }, + "context": { + "account": { + "permissions": ["owner"] + } + }, + "expected": false + }, + { + "name": "empty: empty string", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": "" + }, + "expected": true + }, + { + "name": "notEmpty: empty string", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": "" + }, + "expected": false + }, + { + "name": "empty: whitespace", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": " \t" + }, + "expected": false + }, + { + "name": "notEmpty: whitespace", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": " \t" + }, + "expected": true + }, + { + "name": "empty: text", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": "hello" + }, + "expected": false + }, + { + "name": "notEmpty: text", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": "hello" + }, + "expected": true + }, + { + "name": "empty: empty array", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": [] + }, + "expected": true + }, + { + "name": "notEmpty: empty array", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": [] + }, + "expected": false + }, + { + "name": "empty: array containing null", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": [null] + }, + "expected": false + }, + { + "name": "notEmpty: array containing null", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": [null] + }, + "expected": true + }, + { + "name": "empty: array containing empty array", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": [[]] + }, + "expected": false + }, + { + "name": "notEmpty: array containing empty array", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": [[]] + }, + "expected": true + }, + { + "name": "empty: empty object", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": {} + }, + "expected": true + }, + { + "name": "notEmpty: empty object", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": {} + }, + "expected": false + }, + { + "name": "empty: object containing null", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": { + "value": null + } + }, + "expected": false + }, + { + "name": "notEmpty: object containing null", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": { + "value": null + } + }, + "expected": true + }, + { + "name": "empty: object containing empty object", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": { + "nested": {} + } + }, + "expected": false + }, + { + "name": "notEmpty: object containing empty object", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": { + "nested": {} + } + }, + "expected": true + }, + { + "name": "empty: null", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": null + }, + "expected": false + }, + { + "name": "notEmpty: null", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": null + }, + "expected": false + }, + { + "name": "empty: zero", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": 0 + }, + "expected": false + }, + { + "name": "notEmpty: zero", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": 0 + }, + "expected": false + }, + { + "name": "empty: false", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": { + "value": false + }, + "expected": false + }, + { + "name": "notEmpty: false", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": { + "value": false + }, + "expected": false + }, + { + "name": "empty: missing", + "condition": { + "attribute": "value", + "operator": "empty" + }, + "context": {}, + "expected": false + }, + { + "name": "notEmpty: missing", + "condition": { + "attribute": "value", + "operator": "notEmpty" + }, + "context": {}, + "expected": false + }, + { + "name": "strict primitive equality", + "condition": { + "attribute": "value", + "operator": "equals", + "value": 1 + }, + "context": { "value": "1" }, + "expected": false + }, + { + "name": "not negates implicit and", + "condition": { + "not": [ + { "attribute": "country", "operator": "equals", "value": "us" }, + { "attribute": "device", "operator": "equals", "value": "mobile" } + ] + }, + "context": { "country": "us", "device": "desktop" }, + "expected": true + }, + { + "name": "not with nested or means none match", + "condition": { + "not": [ + { + "or": [ + { "attribute": "country", "operator": "equals", "value": "us" }, + { "attribute": "country", "operator": "equals", "value": "nl" } + ] + } + ] + }, + "context": { "country": "de" }, + "expected": true + }, + { + "name": "empty not fails defensively", + "condition": { "not": [] }, + "context": {}, + "expected": false + }, + { + "name": "fractional ISO date with offset", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.500Z" + }, + "context": { "date": "2024-01-01T01:00:00.250+01:00" }, + "expected": true + }, + { + "name": "date rejects February 30", + "condition": { "attribute": "date", "operator": "before", "value": "2024-03-01T00:00:00Z" }, + "context": { "date": "2024-02-30T00:00:00Z" }, + "expected": false + }, + { + "name": "date rejects non leap century", + "condition": { "attribute": "date", "operator": "before", "value": "1900-03-01T00:00:00Z" }, + "context": { "date": "1900-02-29T00:00:00Z" }, + "expected": false + }, + { + "name": "date accepts leap century", + "condition": { "attribute": "date", "operator": "before", "value": "2000-03-01T00:00:00Z" }, + "context": { "date": "2000-02-29T23:59:59Z" }, + "expected": true + }, + { + "name": "date rejects non ISO form", + "condition": { "attribute": "date", "operator": "before", "value": "2024-03-01T00:00:00Z" }, + "context": { "date": "2024-02-29 23:59:59Z" }, + "expected": false + }, + { + "name": "date rejects date only", + "condition": { "attribute": "date", "operator": "before", "value": "2024-03-01T00:00:00Z" }, + "context": { "date": "2024-02-29" }, + "expected": false + }, + { + "name": "date rejects trailing text", + "condition": { "attribute": "date", "operator": "before", "value": "2024-03-01T00:00:00Z" }, + "context": { "date": "2024-02-29T00:00:00Zextra" }, + "expected": false + }, + { + "name": "date rejects 24 hour rollover", + "condition": { "attribute": "date", "operator": "before", "value": "2024-03-01T00:00:01Z" }, + "context": { "date": "2024-02-29T24:00:00Z" }, + "expected": false + }, + { + "name": "date rejects leap second", + "condition": { "attribute": "date", "operator": "before", "value": "2024-03-01T00:00:01Z" }, + "context": { "date": "2024-02-29T23:59:60Z" }, + "expected": false + }, + { + "name": "date accepts four fractional digits", + "condition": { "attribute": "date", "operator": "before", "value": "2024-01-01T00:00:01Z" }, + "context": { "date": "2024-01-01T00:00:00.1234Z" }, + "expected": true + }, + { + "name": "date truncates 4 digits before", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.124Z" + }, + "context": { + "date": "2024-01-01T00:00:00.1234+00:00" + }, + "expected": true + }, + { + "name": "date truncates 4 digits after", + "condition": { + "attribute": "date", + "operator": "after", + "value": "2024-01-01T00:00:00.123Z" + }, + "context": { + "date": "2024-01-01T00:00:00.1234+00:00" + }, + "expected": false + }, + { + "name": "date accepts 4 digit expected operand", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.1234Z" + }, + "context": { + "date": "2024-01-01T00:00:00.122Z" + }, + "expected": true + }, + { + "name": "date truncates 6 digits before", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.124Z" + }, + "context": { + "date": "2024-01-01T00:00:00.123456+00:00" + }, + "expected": true + }, + { + "name": "date truncates 6 digits after", + "condition": { + "attribute": "date", + "operator": "after", + "value": "2024-01-01T00:00:00.123Z" + }, + "context": { + "date": "2024-01-01T00:00:00.123456+00:00" + }, + "expected": false + }, + { + "name": "date accepts 6 digit expected operand", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.123456Z" + }, + "context": { + "date": "2024-01-01T00:00:00.122Z" + }, + "expected": true + }, + { + "name": "date truncates 7 digits before", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.124Z" + }, + "context": { + "date": "2024-01-01T00:00:00.1234567+00:00" + }, + "expected": true + }, + { + "name": "date truncates 7 digits after", + "condition": { + "attribute": "date", + "operator": "after", + "value": "2024-01-01T00:00:00.123Z" + }, + "context": { + "date": "2024-01-01T00:00:00.1234567+00:00" + }, + "expected": false + }, + { + "name": "date accepts 7 digit expected operand", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.1234567Z" + }, + "context": { + "date": "2024-01-01T00:00:00.122Z" + }, + "expected": true + }, + { + "name": "date truncates 9 digits before", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.124Z" + }, + "context": { + "date": "2024-01-01T00:00:00.123456789+00:00" + }, + "expected": true + }, + { + "name": "date truncates 9 digits after", + "condition": { + "attribute": "date", + "operator": "after", + "value": "2024-01-01T00:00:00.123Z" + }, + "context": { + "date": "2024-01-01T00:00:00.123456789+00:00" + }, + "expected": false + }, + { + "name": "date accepts 9 digit expected operand", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.123456789Z" + }, + "context": { + "date": "2024-01-01T00:00:00.122Z" + }, + "expected": true + }, + { + "name": "date truncates pre epoch fractions without rounding", + "condition": { + "attribute": "date", + "operator": "after", + "value": "1969-12-31T23:59:59.999Z" + }, + "context": { + "date": "1969-12-31T23:59:59.999999999Z" + }, + "expected": false + }, + { + "name": "date rejects ten fractional digits", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:01Z" + }, + "context": { + "date": "2024-01-01T00:00:00.1234567890Z" + }, + "expected": false + }, + { + "name": "date rejects ten digit expected fraction", + "condition": { + "attribute": "date", + "operator": "after", + "value": "2024-01-01T00:00:00.1234567890Z" + }, + "context": { + "date": "2024-01-01T00:00:01Z" + }, + "expected": false + }, + { + "name": "date rejects Unicode digits", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2025-01-01T00:00:00Z" + }, + "context": { + "date": "٢٠٢٤-01-01T00:00:00Z" + }, + "expected": false + }, + { + "name": "date accepts one fractional digit", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.101Z" + }, + "context": { "date": "2024-01-01T00:00:00.1Z" }, + "expected": true + }, + { + "name": "date compares at millisecond precision", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.100Z" + }, + "context": { "date": "2024-01-01T00:00:00.1Z" }, + "expected": false + }, + { + "name": "date rejects offset above fourteen hours", + "condition": { "attribute": "date", "operator": "before", "value": "2024-03-01T00:00:00Z" }, + "context": { "date": "2024-02-29T00:00:00+14:01" }, + "expected": false + }, + { + "name": "date accepts positive fourteen hour offset", + "condition": { "attribute": "date", "operator": "before", "value": "2024-01-01T00:00:01Z" }, + "context": { "date": "2024-01-01T14:00:00+14:00" }, + "expected": true + }, + { + "name": "date accepts negative fourteen hour offset", + "condition": { "attribute": "date", "operator": "after", "value": "2024-01-01T13:59:59Z" }, + "context": { "date": "2024-01-01T00:00:00-14:00" }, + "expected": true + }, + { + "name": "date rejects offset minute rollover", + "condition": { "attribute": "date", "operator": "before", "value": "2024-03-01T00:00:00Z" }, + "context": { "date": "2024-02-29T00:00:00+01:60" }, + "expected": false + }, + { + "name": "date rejects year zero", + "condition": { "attribute": "date", "operator": "before", "value": "0001-01-01T00:00:01Z" }, + "context": { "date": "0000-12-31T23:59:59Z" }, + "expected": false + }, + { + "name": "date rejects year zero even when UTC is year one", + "condition": { "attribute": "date", "operator": "before", "value": "0001-01-01T00:00:01Z" }, + "context": { "date": "0000-12-31T23:00:00-01:00" }, + "expected": false + }, + { + "name": "date rejects offset crossing minimum UTC year", + "condition": { "attribute": "date", "operator": "before", "value": "0001-01-01T00:00:01Z" }, + "context": { "date": "0001-01-01T00:00:00+01:00" }, + "expected": false + }, + { + "name": "date accepts maximum UTC year", + "condition": { "attribute": "date", "operator": "after", "value": "9999-12-31T23:59:58Z" }, + "context": { "date": "9999-12-31T23:59:59.999Z" }, + "expected": true + }, + { + "name": "date rejects offset crossing maximum UTC year", + "condition": { "attribute": "date", "operator": "after", "value": "9999-12-31T23:59:58Z" }, + "context": { "date": "9999-12-31T23:59:59-00:01" }, + "expected": false + }, + { + "name": "date rejects trailing newline", + "condition": { "attribute": "date", "operator": "before", "value": "2024-03-01T00:00:00Z" }, + "context": { "date": "2024-02-29T00:00:00Z\n" }, + "expected": false + }, + { + "name": "regex digit is ASCII only", + "condition": { "attribute": "value", "operator": "matches", "value": "^\\d+$" }, + "context": { "value": "٣" }, + "expected": false + }, + { + "name": "regex nondigit accepts Arabic digit", + "condition": { "attribute": "value", "operator": "matches", "value": "^\\D+$" }, + "context": { "value": "٣" }, + "expected": true + }, + { + "name": "regex word is ASCII only", + "condition": { "attribute": "value", "operator": "matches", "value": "^\\w+$" }, + "context": { "value": "é" }, + "expected": false + }, + { + "name": "regex nonword accepts accented letter", + "condition": { "attribute": "value", "operator": "matches", "value": "^\\W+$" }, + "context": { "value": "é" }, + "expected": true + }, + { + "name": "regex digit class is ASCII only", + "condition": { "attribute": "value", "operator": "matches", "value": "^[\\d]+$" }, + "context": { "value": "٣" }, + "expected": false + }, + { + "name": "regex word class is ASCII only", + "condition": { "attribute": "value", "operator": "matches", "value": "^[\\w]+$" }, + "context": { "value": "é" }, + "expected": false + }, + { + "name": "regex nondigit class accepts Arabic digit", + "condition": { "attribute": "value", "operator": "matches", "value": "^[\\D]+$" }, + "context": { "value": "٣" }, + "expected": true + }, + { + "name": "regex nonword class accepts accented letter", + "condition": { "attribute": "value", "operator": "matches", "value": "^[\\W]+$" }, + "context": { "value": "é" }, + "expected": true + }, + { + "name": "regex single nondigit accepts Arabic digit", + "condition": { "attribute": "value", "operator": "matches", "value": "^\\D$" }, + "context": { "value": "٣" }, + "expected": true + }, + { + "name": "regex single nonword accepts accented letter", + "condition": { "attribute": "value", "operator": "matches", "value": "^\\W$" }, + "context": { "value": "é" }, + "expected": true + }, + { + "name": "regex single nondigit class accepts Arabic digit", + "condition": { "attribute": "value", "operator": "matches", "value": "^[\\D]$" }, + "context": { "value": "٣" }, + "expected": true + }, + { + "name": "regex single nonword class accepts accented letter", + "condition": { "attribute": "value", "operator": "matches", "value": "^[\\W]$" }, + "context": { "value": "é" }, + "expected": true + }, + { + "name": "regex negated nondigit class accepts ASCII digit", + "condition": { "attribute": "value", "operator": "matches", "value": "^[^\\D]$" }, + "context": { "value": "5" }, + "expected": true + }, + { + "name": "regex negated nondigit class rejects Arabic digit", + "condition": { "attribute": "value", "operator": "matches", "value": "^[^\\D]$" }, + "context": { "value": "٣" }, + "expected": false + }, + { + "name": "regex negated nonword class accepts ASCII letter", + "condition": { "attribute": "value", "operator": "matches", "value": "^[^\\W]$" }, + "context": { "value": "A" }, + "expected": true + }, + { + "name": "regex negated nonword class rejects accented letter", + "condition": { "attribute": "value", "operator": "matches", "value": "^[^\\W]$" }, + "context": { "value": "é" }, + "expected": false + }, + { + "name": "regex escaped backslash inside class is not digit shorthand", + "condition": { "attribute": "value", "operator": "matches", "value": "^[\\\\d]$" }, + "context": { "value": "3" }, + "expected": false + }, + { + "name": "regex escaped backslash inside class remains literal", + "condition": { "attribute": "value", "operator": "matches", "value": "^[\\\\d]$" }, + "context": { "value": "\\" }, + "expected": true + }, + { + "name": "regex escaped literal backslash remains literal", + "condition": { "attribute": "value", "operator": "matches", "value": "^\\\\d$" }, + "context": { "value": "\\d" }, + "expected": true + }, + { + "name": "regex notMatches respects ASCII word", + "condition": { "attribute": "value", "operator": "notMatches", "value": "^\\w+$" }, + "context": { "value": "é" }, + "expected": true + }, + { + "name": "regex word ignores Kelvin sign under i", + "condition": { + "attribute": "value", + "operator": "matches", + "value": "^\\w+$", + "regexFlags": "i" + }, + "context": { "value": "K" }, + "expected": false + }, + { + "name": "regex word ignores long s under i", + "condition": { + "attribute": "value", + "operator": "matches", + "value": "^\\w+$", + "regexFlags": "i" + }, + "context": { "value": "ſ" }, + "expected": false + } + ], + "childInstances": { + "contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win", + "stickyStateModel": "child sticky features and variables replace parent sticky state; omitted child sticky options mean empty sticky state", + "closeRemovesLocalAndDelegatedSubscriptions": true, + "detailedEvaluationMethods": ["flag", "variation", "variable"], + "contextCase": { + "parentAtSpawn": { "country": "nl", "plan": "free" }, + "child": { "country": "de" }, + "parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" }, + "expected": { "country": "de", "plan": "free", "region": "eu" } + }, + "stickyCase": { + "datafile": { + "schemaVersion": "2", + "revision": "child-sticky", + "segments": {}, + "features": { + "flag": { + "key": "flag", + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "setting": { + "type": "string", + "defaultValue": "datafile" + } + } + }, + "parentStickyFeatures": { "flag": { "enabled": true } }, + "parentStickyVariables": { "setting": "parent-sticky" }, + "expectedParent": { "flag": true, "setting": "parent-sticky" }, + "expectedChildWithoutStickyOptions": { "flag": false, "setting": "datafile" } + }, + "globalJsonCase": { + "datafile": { + "schemaVersion": "2", "revision": "child-global-json", "segments": {}, "features": {}, @@ -833,14 +2710,10 @@ }, "modulePipeline": { "featureOrder": [ - "before:first", - "before:second", "beforeEvaluation:first", "beforeEvaluation:second", "afterEvaluation:first", - "afterEvaluation:second", - "after:first", - "after:second" + "afterEvaluation:second" ], "globalOrder": [ "beforeEvaluation:first", @@ -858,9 +2731,264 @@ "stickyVariableDiagnostic": "sticky_variables_set", "diagnosticBeforeEvent": true }, + "requiredVariables": { + "datafile": { + "schemaVersion": "2", + "revision": "required-variables", + "segments": {}, + "features": { + "sourceFeature": { + "bucketBy": "userId", + "variablesSchema": { + "profile": { + "type": "object", + "defaultValue": { "plan": { "name": "free" } } + } + }, + "force": [{ "segments": "*", "enabled": true }], + "traffic": [] + }, + "gated": { + "bucketBy": "userId", + "conditions": [ + { + "variable": "policy", + "path": ["access", "enabled"], + "operator": "equals", + "value": true + }, + { + "feature": "sourceFeature", + "variable": "profile", + "path": ["plan", "name"], + "operator": "equals", + "value": "pro" + } + ], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "featureOverride": { + "bucketBy": "userId", + "variablesSchema": { + "message": { "type": "string", "defaultValue": "default" } + }, + "traffic": [ + { + "key": "all", + "segments": "*", + "percentage": 100000, + "variableOverrides": { + "message": [ + { + "key": "required", + "conditions": [ + { + "variable": "policy", + "path": ["access", "enabled"], + "operator": "equals", + "value": true + }, + { + "feature": "sourceFeature", + "variable": "profile", + "path": ["plan", "name"], + "operator": "equals", + "value": "pro" + } + ], + "value": "matched" + } + ] + } + } + ] + } + }, + "variables": { + "policy": { + "type": "object", + "defaultValue": { "access": { "enabled": false } } + }, + "malformed": { "type": "json", "defaultValue": "{" }, + "nullGate": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "default", + "conditions": [{ "variable": "malformed", "operator": "isNull" }] + }, + "root": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "default", + "conditions": [ + { + "variable": "policy", + "path": ["access", "enabled"], + "operator": "equals", + "value": true + }, + { + "feature": "sourceFeature", + "variable": "profile", + "path": ["plan", "name"], + "operator": "equals", + "value": "pro" + } + ] + }, + "override": { + "type": "string", + "defaultValue": "default", + "overrides": [ + { + "key": "required", + "conditions": [ + { + "variable": "policy", + "path": ["access", "enabled"], + "operator": "equals", + "value": true + }, + { + "feature": "sourceFeature", + "variable": "profile", + "path": ["plan", "name"], + "operator": "equals", + "value": "pro" + } + ], + "value": "matched" + } + ] + }, + "accumulated": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "global", + "keyPath": ["global"], + "conditions": [ + { + "variable": "policy", + "path": ["access", "enabled"], + "operator": "equals", + "value": true + } + ], + "operations": [{ "op": "set", "path": ["global"], "value": true }], + "onMatch": 1, + "onMiss": 1 + }, + { + "key": "feature", + "keyPath": ["feature"], + "conditions": [ + { + "feature": "sourceFeature", + "variable": "profile", + "path": ["plan", "name"], + "operator": "equals", + "value": "pro" + } + ], + "operations": [{ "op": "set", "path": ["feature"], "value": true }], + "onMatch": 2, + "onMiss": 2 + } + ] + } + } + }, + "stickyCases": [ + { + "name": "no sticky sources", + "stickyVariables": {}, + "stickyFeatures": {}, + "matched": false, + "accumulated": {} + }, + { + "name": "only global source", + "stickyVariables": { "policy": { "access": { "enabled": true } } }, + "stickyFeatures": {}, + "matched": false, + "accumulated": { "global": true } + }, + { + "name": "only feature source", + "stickyVariables": {}, + "stickyFeatures": { + "sourceFeature": { + "enabled": true, + "variables": { "profile": { "plan": { "name": "pro" } } } + } + }, + "matched": false, + "accumulated": { "feature": true } + }, + { + "name": "mixed global and feature sources", + "stickyVariables": { "policy": { "access": { "enabled": true } } }, + "stickyFeatures": { + "sourceFeature": { + "enabled": true, + "variables": { "profile": { "plan": { "name": "pro" } } } + } + }, + "matched": true, + "accumulated": { "global": true, "feature": true } + } + ], + "groupInputs": [ + { "a": false, "b": false }, + { "a": false, "b": true }, + { "a": true, "b": false }, + { "a": true, "b": true } + ], + "groups": [ + { + "name": "implicit AND", + "requirements": ["a", "b"], + "results": [false, false, false, true] + }, + { + "name": "explicit AND", + "requirements": [{ "and": ["a", "b"] }], + "results": [false, false, false, true] + }, + { + "name": "OR", + "requirements": [{ "or": ["a", "b"] }], + "results": [false, true, true, true] + }, + { + "name": "NOT means not all", + "requirements": [{ "not": ["a", "b"] }], + "results": [true, true, true, false] + }, + { + "name": "NOT OR means neither", + "requirements": [{ "not": [{ "or": ["a", "b"] }] }], + "results": [true, false, false, false] + }, + { + "name": "empty NOT defensive fallback", + "requirements": [{ "not": [] }], + "results": [false, false, false, false] + } + ], + "expected": { + "matchedValue": "matched", + "defaultValue": "default", + "accumulated": { "global": true, "feature": true }, + "unmetReason": "conditions_unmet", + "nullGetterValue": null + } + }, "openFeature": { "reasonMappings": { - "required_features_unmet": "DISABLED" + "conditions_unmet": "DISABLED" } }, "diagnosticCase": { diff --git a/conformance/sdk-v4-cases.json b/conformance/sdk-v4-cases.json new file mode 100644 index 0000000..424a283 --- /dev/null +++ b/conformance/sdk-v4-cases.json @@ -0,0 +1,21153 @@ +{ + "version": 1, + "description": "Portable v4 accumulation, mutation, path and error cases supplementing sdk-v3.json version 11. Run each case twice through the ordinary global variable getter and detailed evaluator. Do not mutate fixture datafiles. Extra evaluation metadata is allowed; compare expected fields only.", + "cases": [ + { + "name": "empty global requirement: empty string", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": "" + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "empty feature requirement: empty string", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": "" + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "empty program condition: empty string", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": "" + }, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_override_rule" + } + }, + { + "name": "notEmpty global requirement: empty string", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": "" + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty feature requirement: empty string", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": "" + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty program condition: empty string", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": "" + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "empty global requirement: whitespace", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": " \t" + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty feature requirement: whitespace", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": " \t" + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty program condition: whitespace", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": " \t" + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "notEmpty global requirement: whitespace", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": " \t" + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty feature requirement: whitespace", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": " \t" + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty program condition: whitespace", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": " \t" + }, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_override_rule" + } + }, + { + "name": "empty global requirement: text", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": "hello" + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty feature requirement: text", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": "hello" + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty program condition: text", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": "hello" + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "notEmpty global requirement: text", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": "hello" + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty feature requirement: text", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": "hello" + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty program condition: text", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": "hello" + }, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_override_rule" + } + }, + { + "name": "empty global requirement: empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": [] + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "empty feature requirement: empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": [] + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "empty program condition: empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": [] + }, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_override_rule" + } + }, + { + "name": "notEmpty global requirement: empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": [] + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty feature requirement: empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": [] + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty program condition: empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": [] + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "empty global requirement: array containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": [ + null + ] + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty feature requirement: array containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": [ + null + ] + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty program condition: array containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": [ + null + ] + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "notEmpty global requirement: array containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": [ + null + ] + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty feature requirement: array containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": [ + null + ] + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty program condition: array containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": [ + null + ] + }, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_override_rule" + } + }, + { + "name": "empty global requirement: array containing empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": [ + [] + ] + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty feature requirement: array containing empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": [ + [] + ] + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty program condition: array containing empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": [ + [] + ] + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "notEmpty global requirement: array containing empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": [ + [] + ] + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty feature requirement: array containing empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": [ + [] + ] + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty program condition: array containing empty array", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": [ + [] + ] + }, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_override_rule" + } + }, + { + "name": "empty global requirement: empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": {} + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "empty feature requirement: empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": {} + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "empty program condition: empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": {} + }, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_override_rule" + } + }, + { + "name": "notEmpty global requirement: empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": {} + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty feature requirement: empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": {} + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty program condition: empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": {} + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "empty global requirement: object containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": { + "value": null + } + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty feature requirement: object containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": { + "value": null + } + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty program condition: object containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": { + "value": null + } + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "notEmpty global requirement: object containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": { + "value": null + } + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty feature requirement: object containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": { + "value": null + } + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty program condition: object containing null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": { + "value": null + } + }, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_override_rule" + } + }, + { + "name": "empty global requirement: object containing empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": { + "nested": {} + } + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty feature requirement: object containing empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": { + "nested": {} + } + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty program condition: object containing empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": { + "nested": {} + } + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "notEmpty global requirement: object containing empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": { + "nested": {} + } + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty feature requirement: object containing empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": { + "nested": {} + } + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": true, + "reason": "variable_default" + } + }, + { + "name": "notEmpty program condition: object containing empty object", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": { + "nested": {} + } + }, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_override_rule" + } + }, + { + "name": "empty global requirement: null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": null + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty feature requirement: null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": null + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty program condition: null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": null + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "notEmpty global requirement: null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": null + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty feature requirement: null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": null + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty program condition: null", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": null + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "empty global requirement: zero", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": 0 + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty feature requirement: zero", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": 0 + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty program condition: zero", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": 0 + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "notEmpty global requirement: zero", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": 0 + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty feature requirement: zero", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": 0 + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty program condition: zero", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": 0 + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "empty global requirement: false", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": false + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty feature requirement: false", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": false + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty program condition: false", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": false + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "notEmpty global requirement: false", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "value": false + } + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty feature requirement: false", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": { + "value": false + } + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty program condition: false", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": { + "value": false + }, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "empty global requirement: missing", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": {} + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty feature requirement: missing", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": {} + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "empty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "empty program condition: missing", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "empty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "notEmpty global requirement: missing", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": {} + }, + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty feature requirement: missing", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": { + "sourceFeature": { + "bucketBy": [ + "userId" + ], + "force": [ + { + "segments": "*", + "enabled": true + } + ], + "traffic": [], + "variablesSchema": { + "source": { + "type": "object", + "defaultValue": {} + } + } + } + }, + "segments": {}, + "variables": { + "subject": { + "type": "boolean", + "defaultValue": true, + "disabledValue": false, + "conditions": [ + { + "feature": "sourceFeature", + "variable": "source", + "path": [ + "value" + ], + "operator": "notEmpty" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": false, + "reason": "conditions_unmet" + } + }, + { + "name": "notEmpty program condition: missing", + "datafile": { + "schemaVersion": "2", + "revision": "emptiness", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [ + { + "key": "content", + "keyPath": [ + "content" + ], + "conditions": [ + { + "attribute": "value", + "operator": "notEmpty" + } + ], + "operations": [ + { + "op": "set", + "path": [], + "value": "matched" + } + ], + "onMatch": 1, + "onMiss": 1 + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "set root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "old": true + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [], + "value": { + "next": true + } + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "next": true + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "set missing own property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "enabled" + ], + "value": false + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "enabled": false + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "literal dotted property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "a.b": { + "value": 1 + } + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "a.b", + "value" + ], + "value": 2 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "a.b": { + "value": 2 + } + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "numeric string object property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "0": 1 + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "0" + ], + "value": 2 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "0": 2 + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "set at array length", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + 1 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + 1 + ], + "value": 2 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + 1, + 2 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "set beyond array length", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + 1 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + 2 + ], + "value": 2 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + 1 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "set selected item", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "id": 1 + }, + { + "id": 2 + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + { + "select": { + "property": "id", + "values": [ + 2 + ] + } + } + ], + "value": { + "id": 3 + } + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "id": 1 + }, + { + "id": 3 + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "remove own property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "one": 1, + "two": 2 + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "remove", + "path": [ + "one" + ] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "two": 2 + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "remove array item", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + 1, + 2, + 3 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "remove", + "path": [ + 1 + ] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + 1, + 3 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "remove missing property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "remove", + "path": [ + "one" + ] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": {}, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "a" + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "a", + "x" + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": [ + "a" + ] + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": [ + "a", + "x" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append array index", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + [ + "a" + ] + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + [ + "a", + "x" + ] + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append selected nested array", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "id": "a", + "tags": [ + "a" + ] + }, + { + "id": "a", + "tags": [ + "a" + ] + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [ + { + "select": { + "property": "id", + "values": [ + "a" + ] + } + }, + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "id": "a", + "tags": [ + "a", + "x" + ] + }, + { + "id": "a", + "tags": [ + "a" + ] + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append null root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": null, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append null array element", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + null + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + null + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append missing final property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": [ + "x" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append null final property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": null + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": [ + "x" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append missing intermediate", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [ + "missing", + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": {}, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append unavailable array index", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append wrong container", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": false + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "prepend root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "a" + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "x", + "a" + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": [ + "a" + ] + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": [ + "x", + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend array index", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + [ + "a" + ] + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + [ + "x", + "a" + ] + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend selected nested array", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "id": "a", + "tags": [ + "a" + ] + }, + { + "id": "a", + "tags": [ + "a" + ] + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [ + { + "select": { + "property": "id", + "values": [ + "a" + ] + } + }, + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "id": "a", + "tags": [ + "x", + "a" + ] + }, + { + "id": "a", + "tags": [ + "a" + ] + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend null root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": null, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend null array element", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + null + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + null + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend missing final property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": [ + "x" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend null final property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": null + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": [ + "x" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend missing intermediate", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [ + "missing", + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": {}, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend unavailable array index", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend wrong container", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": false + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "appendUnique root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "a" + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "a", + "x" + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": [ + "a" + ] + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": [ + "a", + "x" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique array index", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + [ + "a" + ] + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + [ + "a", + "x" + ] + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique selected nested array", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "id": "a", + "tags": [ + "a" + ] + }, + { + "id": "a", + "tags": [ + "a" + ] + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + { + "select": { + "property": "id", + "values": [ + "a" + ] + } + }, + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "id": "a", + "tags": [ + "a", + "x" + ] + }, + { + "id": "a", + "tags": [ + "a" + ] + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique null root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": null, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique null array element", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + null + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + null + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique missing final property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": [ + "x" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique null final property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": null + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": [ + "x" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique missing intermediate", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + "missing", + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": {}, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique unavailable array index", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique wrong container", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": false + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "removeValue root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "a", + "x", + "x" + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "a" + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": [ + "a", + "x", + "x" + ] + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue array index", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + [ + "a", + "x", + "x" + ] + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + [ + "a" + ] + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue selected nested array", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "id": "a", + "tags": [ + "a", + "x", + "x" + ] + }, + { + "id": "a", + "tags": [ + "a", + "x", + "x" + ] + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + { + "select": { + "property": "id", + "values": [ + "a" + ] + } + }, + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "id": "a", + "tags": [ + "a" + ] + }, + { + "id": "a", + "tags": [ + "a", + "x", + "x" + ] + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue null root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": null, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue null array element", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + null + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + null + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue missing final property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": {}, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue null final property", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": null + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "tags": null + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue missing intermediate", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + "missing", + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": {}, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue unavailable array index", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + 0 + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue wrong container", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "tags": false + }, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + "tags" + ], + "value": "x" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "appendUnique strict \"1\"", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + "1" + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [], + "value": "1" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + 1, + true, + false, + null, + 0, + "1" + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue strict \"1\"", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + "1" + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [], + "value": "1" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + 1, + true, + false, + null, + 0 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique strict 1", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + 1 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [], + "value": 1 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + 1, + true, + false, + null, + 0, + 1 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue strict 1", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + 1 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [], + "value": 1 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + true, + false, + null, + 0 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique strict true", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + true + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + 1, + true, + false, + null, + 0, + true + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue strict true", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + true + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + 1, + false, + null, + 0 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique strict false", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + false + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [], + "value": false + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + 1, + true, + false, + null, + 0, + false + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue strict false", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + false + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [], + "value": false + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + 1, + true, + null, + 0 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique strict null", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + null + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [], + "value": null + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + 1, + true, + false, + null, + 0, + null + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue strict null", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + null + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [], + "value": null + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + 1, + true, + false, + 0 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique strict 0", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + 0 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [], + "value": 0 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + 1, + true, + false, + null, + 0, + 0 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "removeValue strict 0", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + 1, + true, + false, + null, + 0, + 0 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [], + "value": 0 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + 1, + true, + false, + null + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "unique append preserves existing duplicates", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "1", + "1" + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [], + "value": 1 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "1", + "1", + 1 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "zero and negative zero compare equally", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + 0 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [], + "value": 0 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + 0 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "append inserts an array as one item", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + 1 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [], + "value": [ + 2, + 3 + ] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + 1, + [ + 2, + 3 + ] + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "prepend inserts an array as one item", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + 1 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "prepend", + "path": [], + "value": [ + 2, + 3 + ] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + [ + 2, + 3 + ], + 1 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "before indexed item", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + 1, + 3 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "before", + "path": [ + 1 + ], + "value": 2 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + 1, + 2, + 3 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "before selected item", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "id": "a" + }, + { + "id": "b" + }, + { + "id": "b" + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "before", + "path": [ + { + "select": { + "property": "id", + "values": [ + "b" + ] + } + } + ], + "value": { + "id": "new" + } + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "id": "a" + }, + { + "id": "new" + }, + { + "id": "b" + }, + { + "id": "b" + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "before unavailable index", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + 1 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "before", + "path": [ + 1 + ], + "value": 2 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + 1 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "before invalid root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "before", + "path": [], + "value": 2 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "after indexed item", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + 1, + 3 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "after", + "path": [ + 1 + ], + "value": 2 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + 1, + 3, + 2 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "after selected item", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "id": "a" + }, + { + "id": "b" + }, + { + "id": "b" + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "after", + "path": [ + { + "select": { + "property": "id", + "values": [ + "b" + ] + } + } + ], + "value": { + "id": "new" + } + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "id": "a" + }, + { + "id": "b" + }, + { + "id": "new" + }, + { + "id": "b" + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "after unavailable index", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + 1 + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "after", + "path": [ + 1 + ], + "value": 2 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + 1 + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "after invalid root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "after", + "path": [], + "value": 2 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "selector distinguishes null from missing", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "other": 1 + }, + { + "id": null + }, + { + "id": null + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "remove", + "path": [ + { + "select": { + "property": "id", + "values": [ + null + ] + } + } + ] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "other": 1 + }, + { + "id": null + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "selector distinguishes booleans from numbers", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "id": 1 + }, + { + "id": true + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "remove", + "path": [ + { + "select": { + "property": "id", + "values": [ + true + ] + } + } + ] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "id": 1 + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "selector candidates can include text and number", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "id": "01" + }, + { + "id": 1 + }, + { + "id": "1" + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "remove", + "path": [ + { + "select": { + "property": "id", + "values": [ + "1", + 1 + ] + } + } + ] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "id": "01" + }, + { + "id": "1" + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "selectors see previous mutations", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + { + "id": "old" + } + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "append", + "path": [], + "value": { + "id": "new", + "tags": [] + } + }, + { + "op": "append", + "path": [ + { + "select": { + "property": "id", + "values": [ + "new" + ] + } + }, + "tags" + ], + "value": "seen" + }, + { + "op": "remove", + "path": [ + { + "select": { + "property": "id", + "values": [ + "old" + ] + } + } + ] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + { + "id": "new", + "tags": [ + "seen" + ] + } + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "ordered idempotent primitive operations", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [ + "a", + "b", + "b" + ], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [], + "value": "b" + }, + { + "op": "appendUnique", + "path": [], + "value": "b" + }, + { + "op": "appendUnique", + "path": [], + "value": "b" + }, + { + "op": "removeValue", + "path": [], + "value": "a" + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": [ + "b" + ], + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "case" + ] + ], + "variableOverrideKey": "case", + "variableOverrideIndex": 0, + "variableOverridePath": [ + "case" + ] + } + }, + { + "name": "appendUnique invalid operand {} even on a miss", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + "missing", + "tags" + ], + "value": {} + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "appendUnique invalid operand [] even on a miss", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "appendUnique", + "path": [ + "missing", + "tags" + ], + "value": [] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "removeValue invalid operand {} even on a miss", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + "missing", + "tags" + ], + "value": {} + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "removeValue invalid operand [] even on a miss", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "removeValue", + "path": [ + "missing", + "tags" + ], + "value": [] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "unsafe property __proto__", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "__proto__" + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "unsafe selector __proto__ even on a miss", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + { + "select": { + "property": "__proto__", + "values": [ + "missing" + ] + } + } + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "unsafe property prototype", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "prototype" + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "unsafe selector prototype even on a miss", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + { + "select": { + "property": "prototype", + "values": [ + "missing" + ] + } + } + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "unsafe property constructor", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "constructor" + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "unsafe selector constructor even on a miss", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + { + "select": { + "property": "constructor", + "values": [ + "missing" + ] + } + } + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid path [-1]", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + -1 + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid path [0.5]", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + 0.5 + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid path [true]", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + true + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid path [null]", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + null + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid path [{\"select\":{\"property\":\"id\",\"values\":[{}]}}]", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "array", + "defaultValue": [], + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + { + "select": { + "property": "id", + "values": [ + {} + ] + } + } + ], + "value": true + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "missing operand", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "a" + ] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "unknown operation", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "unknown", + "path": [], + "value": 1 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "cannot remove root", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "remove", + "path": [] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "string paths are not parsed by SDK", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": "a.b", + "value": 1 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "late operation error never publishes partial result", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "a" + ], + "value": true + }, + { + "op": "append", + "path": [ + "a" + ], + "value": 1 + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "nested continuation eu nl", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "trace": [] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "parent" + } + ], + "onMatch": 1, + "onMiss": 3, + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + { + "key": "nl", + "keyPath": [ + "parent", + "nl" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "nl" + } + ], + "onMatch": 2, + "onMiss": 2, + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "child" + } + ], + "onMatch": 3, + "onMiss": 3, + "segments": "*" + }, + { + "key": "tail", + "keyPath": [ + "tail" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "tail" + } + ], + "onMatch": 4, + "onMiss": 4, + "segments": "*" + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl" + }, + "options": {}, + "expected": { + "value": { + "trace": [ + "parent", + "nl", + "child", + "tail" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "nl" + ], + [ + "parent", + "child" + ], + [ + "tail" + ] + ], + "variableOverrideIndex": 3 + } + }, + { + "name": "nested continuation eu be", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "trace": [] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "parent" + } + ], + "onMatch": 1, + "onMiss": 3, + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + { + "key": "nl", + "keyPath": [ + "parent", + "nl" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "nl" + } + ], + "onMatch": 2, + "onMiss": 2, + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "child" + } + ], + "onMatch": 3, + "onMiss": 3, + "segments": "*" + }, + { + "key": "tail", + "keyPath": [ + "tail" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "tail" + } + ], + "onMatch": 4, + "onMiss": 4, + "segments": "*" + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "be" + }, + "options": {}, + "expected": { + "value": { + "trace": [ + "parent", + "child", + "tail" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "child" + ], + [ + "tail" + ] + ], + "variableOverrideIndex": 3 + } + }, + { + "name": "nested continuation us nl", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "trace": [] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "parent" + } + ], + "onMatch": 1, + "onMiss": 3, + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + { + "key": "nl", + "keyPath": [ + "parent", + "nl" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "nl" + } + ], + "onMatch": 2, + "onMiss": 2, + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "child" + } + ], + "onMatch": 3, + "onMiss": 3, + "segments": "*" + }, + { + "key": "tail", + "keyPath": [ + "tail" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "tail" + } + ], + "onMatch": 4, + "onMiss": 4, + "segments": "*" + } + ] + } + } + }, + "context": { + "region": "us", + "country": "nl" + }, + "options": {}, + "expected": { + "value": { + "trace": [ + "tail" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "tail" + ] + ], + "variableOverrideIndex": 3 + } + }, + { + "name": "nested continuation us be", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": { + "trace": [] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "parent" + } + ], + "onMatch": 1, + "onMiss": 3, + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + { + "key": "nl", + "keyPath": [ + "parent", + "nl" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "nl" + } + ], + "onMatch": 2, + "onMiss": 2, + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "child" + } + ], + "onMatch": 3, + "onMiss": 3, + "segments": "*" + }, + { + "key": "tail", + "keyPath": [ + "tail" + ], + "operations": [ + { + "op": "append", + "path": [ + "trace" + ], + "value": "tail" + } + ], + "onMatch": 4, + "onMiss": 4, + "segments": "*" + } + ] + } + } + }, + "context": { + "region": "us", + "country": "be" + }, + "options": {}, + "expected": { + "value": { + "trace": [ + "tail" + ] + }, + "reason": "variable_override_rule", + "variableOverridePaths": [ + [ + "tail" + ] + ], + "variableOverrideIndex": 3 + } + }, + { + "name": "invalid forward jump 0", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "partial" + ], + "value": true + } + ], + "onMatch": 0, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid forward jump -1", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "partial" + ], + "value": true + } + ], + "onMatch": -1, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid forward jump 2", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "partial" + ], + "value": true + } + ], + "onMatch": 2, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid forward jump 0.5", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "partial" + ], + "value": true + } + ], + "onMatch": 0.5, + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid forward jump \"1\"", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "set", + "path": [ + "partial" + ], + "value": true + } + ], + "onMatch": "1", + "onMiss": 1, + "segments": "*" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "empty program", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "empty program ignores ordinary overrides", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "subject": { + "type": "string", + "defaultValue": "default", + "overrideProgram": [], + "overrides": [ + { + "key": "unused", + "segments": "*", + "value": "incorrect" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "default", + "reason": "variable_default" + } + }, + { + "name": "required path [\"missing\"] isNull", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "missing" + ], + "operator": "isNull" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "miss", + "reason": "conditions_unmet" + } + }, + { + "name": "required path [\"missing\"] notNull", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "missing" + ], + "operator": "notNull" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "miss", + "reason": "conditions_unmet" + } + }, + { + "name": "required path [\"missing\"] exists", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "missing" + ], + "operator": "exists" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "miss", + "reason": "conditions_unmet" + } + }, + { + "name": "required path [\"null\"] isNull", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "null" + ], + "operator": "isNull" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "required path [\"null\"] exists", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "null" + ], + "operator": "exists" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "required path [\"null\"] notNull", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "null" + ], + "operator": "notNull" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "miss", + "reason": "conditions_unmet" + } + }, + { + "name": "required path [\"false\"] exists", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "false" + ], + "operator": "exists" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "required path [\"zero\"] exists", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "zero" + ], + "operator": "exists" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "required path [\"empty\"] exists", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "empty" + ], + "operator": "exists" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "required path [0] exists", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + 0 + ], + "operator": "exists" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "miss", + "reason": "conditions_unmet" + } + }, + { + "name": "required path [\"0\"] exists", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": { + "0": true, + "null": null, + "false": false, + "zero": 0, + "empty": "" + } + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "0" + ], + "operator": "exists" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "required array path [\"0\"] equals", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "array", + "defaultValue": [ + "first" + ] + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "0" + ], + "operator": "equals", + "value": "first" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "required array path [0] equals", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "array", + "defaultValue": [ + "first" + ] + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + 0 + ], + "operator": "equals", + "value": "first" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "required array path [\"length\"] equals", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "array", + "defaultValue": [ + "first" + ] + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "length" + ], + "operator": "equals", + "value": 1 + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "required array path [\"00\"] exists", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "array", + "defaultValue": [ + "first" + ] + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "00" + ], + "operator": "exists" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "miss", + "reason": "conditions_unmet" + } + }, + { + "name": "required array path [\"-1\"] exists", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "array", + "defaultValue": [ + "first" + ] + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "-1" + ], + "operator": "exists" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "miss", + "reason": "conditions_unmet" + } + }, + { + "name": "getter null after source program error remains a legitimate comparison", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": {}, + "overrideProgram": [ + { + "key": "case", + "keyPath": [ + "case" + ], + "operations": [ + { + "op": "remove", + "path": [] + } + ], + "onMatch": 1, + "onMiss": 1, + "segments": "*" + } + ] + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "operator": "isNull" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "JSON getter null remains a legitimate comparison", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "json", + "defaultValue": "not JSON" + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "operator": "isNull" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": "matched", + "reason": "variable_default" + } + }, + { + "name": "invalid matches cannot succeed through NOT", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "string", + "defaultValue": "text" + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "not": [ + { + "variable": "source", + "operator": "matches", + "value": "[" + } + ] + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid semverGreaterThan cannot succeed through NOT", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "string", + "defaultValue": "also-broken" + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "not": [ + { + "variable": "source", + "operator": "semverGreaterThan", + "value": "broken" + } + ] + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid required path [\"constructor\"]", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": {} + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": [ + "constructor" + ], + "operator": "notNull" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "invalid required path \"a.b\"", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "object", + "defaultValue": {} + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "source", + "path": "a.b", + "operator": "notNull" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "variable cycle cannot pass an isNull check", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": {}, + "segments": {}, + "variables": { + "source": { + "type": "boolean", + "defaultValue": true + }, + "subject": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "miss", + "conditions": [ + { + "variable": "subject", + "operator": "isNull" + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": null, + "reason": "error" + } + }, + { + "name": "first match precomputed parsed: child", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "attribute": "reset", + "operator": "equals", + "value": true + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": [ + "europe" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "child", + "variableOverridePath": [ + "parent", + "child" + ], + "variableOverrideIndex": 1 + } + }, + { + "name": "first match precomputed parsed: literal reset", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "attribute": "reset", + "operator": "equals", + "value": true + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": [ + "europe" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "reset": true + }, + "options": {}, + "expected": { + "value": { + "stage": "reset", + "rows": [], + "flags": [] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "reset", + "variableOverridePath": [ + "parent", + "child", + "reset" + ], + "variableOverrideIndex": 0 + } + }, + { + "name": "first match precomputed parsed: parent fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "attribute": "reset", + "operator": "equals", + "value": true + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": [ + "europe" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "be", + "eligible": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "parent", + "variableOverridePath": [ + "parent" + ], + "variableOverrideIndex": 3 + } + }, + { + "name": "first match precomputed parsed: sibling fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "attribute": "reset", + "operator": "equals", + "value": true + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": [ + "europe" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "be", + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "fallback", + "variableOverridePath": [ + "parent", + "fallback" + ], + "variableOverrideIndex": 2 + } + }, + { + "name": "first match precomputed parsed: unmet feature", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "attribute": "reset", + "operator": "equals", + "value": true + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": [ + "europe" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": false, + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "fallback", + "variableOverridePath": [ + "parent", + "fallback" + ], + "variableOverrideIndex": 2 + } + }, + { + "name": "first match precomputed parsed: unmet variable", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "attribute": "reset", + "operator": "equals", + "value": true + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": [ + "europe" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "denied": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "parent", + "variableOverridePath": [ + "parent" + ], + "variableOverrideIndex": 3 + } + }, + { + "name": "first match precomputed parsed: parent miss skips matching child", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "attribute": "reset", + "operator": "equals", + "value": true + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": [ + "europe" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "us", + "country": "nl", + "eligible": true, + "reset": true + }, + "options": {}, + "expected": { + "value": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_default" + } + }, + { + "name": "first match precomputed parsed: root fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "attribute": "reset", + "operator": "equals", + "value": true + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": [ + "europe" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "us", + "country": "us" + }, + "options": {}, + "expected": { + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "us", + "variableOverridePath": [ + "us" + ], + "variableOverrideIndex": 4 + } + }, + { + "name": "first match precomputed parsed: missing context", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "attribute": "reset", + "operator": "equals", + "value": true + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": [ + "europe" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_default" + } + }, + { + "name": "first match precomputed parsed: first child prevents later matching sibling", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "attribute": "reset", + "operator": "equals", + "value": true + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": [ + "europe" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": [ + "europe" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "child", + "variableOverridePath": [ + "parent", + "child" + ], + "variableOverrideIndex": 1 + } + }, + { + "name": "first match precomputed stringified: child", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": "[\"europe\"]", + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "child", + "variableOverridePath": [ + "parent", + "child" + ], + "variableOverrideIndex": 1 + } + }, + { + "name": "first match precomputed stringified: literal reset", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": "[\"europe\"]", + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "reset": true + }, + "options": {}, + "expected": { + "value": { + "stage": "reset", + "rows": [], + "flags": [] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "reset", + "variableOverridePath": [ + "parent", + "child", + "reset" + ], + "variableOverrideIndex": 0 + } + }, + { + "name": "first match precomputed stringified: parent fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": "[\"europe\"]", + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "be", + "eligible": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "parent", + "variableOverridePath": [ + "parent" + ], + "variableOverrideIndex": 3 + } + }, + { + "name": "first match precomputed stringified: sibling fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": "[\"europe\"]", + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "be", + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "fallback", + "variableOverridePath": [ + "parent", + "fallback" + ], + "variableOverrideIndex": 2 + } + }, + { + "name": "first match precomputed stringified: unmet feature", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": "[\"europe\"]", + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": false, + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "fallback", + "variableOverridePath": [ + "parent", + "fallback" + ], + "variableOverrideIndex": 2 + } + }, + { + "name": "first match precomputed stringified: unmet variable", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": "[\"europe\"]", + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "denied": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "parent", + "variableOverridePath": [ + "parent" + ], + "variableOverrideIndex": 3 + } + }, + { + "name": "first match precomputed stringified: parent miss skips matching child", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": "[\"europe\"]", + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "us", + "country": "nl", + "eligible": true, + "reset": true + }, + "options": {}, + "expected": { + "value": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_default" + } + }, + { + "name": "first match precomputed stringified: root fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": "[\"europe\"]", + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "us", + "country": "us" + }, + "options": {}, + "expected": { + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "us", + "variableOverridePath": [ + "us" + ], + "variableOverrideIndex": 4 + } + }, + { + "name": "first match precomputed stringified: missing context", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": "[\"europe\"]", + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_default" + } + }, + { + "name": "first match precomputed stringified: first child prevents later matching sibling", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrides": [ + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "segments": "[\"europe\"]", + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "segments": "[\"europe\"]", + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + } + }, + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + } + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + } + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "child", + "variableOverridePath": [ + "parent", + "child" + ], + "variableOverrideIndex": 1 + } + }, + { + "name": "first match runtime parsed: child", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": [ + { + "attribute": "reset", + "operator": "equals", + "value": true + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "child", + "variableOverridePath": [ + "parent", + "child" + ], + "variableOverrideIndex": 1, + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "child" + ] + ] + } + }, + { + "name": "first match runtime parsed: literal reset", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": [ + { + "attribute": "reset", + "operator": "equals", + "value": true + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "reset": true + }, + "options": {}, + "expected": { + "value": { + "stage": "reset", + "rows": [], + "flags": [] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "reset", + "variableOverridePath": [ + "parent", + "child", + "reset" + ], + "variableOverrideIndex": 2, + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "child" + ], + [ + "parent", + "child", + "reset" + ] + ] + } + }, + { + "name": "first match runtime parsed: parent fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": [ + { + "attribute": "reset", + "operator": "equals", + "value": true + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "be", + "eligible": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "parent", + "variableOverridePath": [ + "parent" + ], + "variableOverrideIndex": 0, + "variableOverridePaths": [ + [ + "parent" + ] + ] + } + }, + { + "name": "first match runtime parsed: sibling fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": [ + { + "attribute": "reset", + "operator": "equals", + "value": true + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "be", + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "fallback", + "variableOverridePath": [ + "parent", + "fallback" + ], + "variableOverrideIndex": 3, + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "fallback" + ] + ] + } + }, + { + "name": "first match runtime parsed: unmet feature", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": [ + { + "attribute": "reset", + "operator": "equals", + "value": true + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": false, + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "fallback", + "variableOverridePath": [ + "parent", + "fallback" + ], + "variableOverrideIndex": 3, + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "fallback" + ] + ] + } + }, + { + "name": "first match runtime parsed: unmet variable", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": [ + { + "attribute": "reset", + "operator": "equals", + "value": true + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "denied": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "parent", + "variableOverridePath": [ + "parent" + ], + "variableOverrideIndex": 0, + "variableOverridePaths": [ + [ + "parent" + ] + ] + } + }, + { + "name": "first match runtime parsed: parent miss skips matching child", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": [ + { + "attribute": "reset", + "operator": "equals", + "value": true + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "us", + "country": "nl", + "eligible": true, + "reset": true + }, + "options": {}, + "expected": { + "value": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_default" + } + }, + { + "name": "first match runtime parsed: root fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": [ + { + "attribute": "reset", + "operator": "equals", + "value": true + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "us", + "country": "us" + }, + "options": {}, + "expected": { + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "us", + "variableOverridePath": [ + "us" + ], + "variableOverrideIndex": 4, + "variableOverridePaths": [ + [ + "us" + ] + ] + } + }, + { + "name": "first match runtime parsed: missing context", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": [ + { + "attribute": "reset", + "operator": "equals", + "value": true + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_default" + } + }, + { + "name": "first match runtime parsed: first child prevents later matching sibling", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": [ + { + "attribute": "region", + "operator": "equals", + "value": "eu" + } + ] + }, + "eligible": { + "conditions": [ + { + "attribute": "eligible", + "operator": "equals", + "value": true + } + ] + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": [ + { + "attribute": "denied", + "operator": "equals", + "value": true + } + ], + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": [ + "europe" + ], + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": { + "and": [ + { + "attribute": "country", + "operator": "equals", + "value": "nl" + }, + { + "variable": "permission", + "operator": "equals", + "value": true + } + ] + }, + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": [ + { + "attribute": "reset", + "operator": "equals", + "value": true + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": [ + { + "attribute": "city", + "operator": "equals", + "value": "rotterdam" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": [ + { + "attribute": "country", + "operator": "equals", + "value": "us" + } + ], + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "child", + "variableOverridePath": [ + "parent", + "child" + ], + "variableOverrideIndex": 1, + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "child" + ] + ] + } + }, + { + "name": "first match runtime stringified: child", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": "[{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "child", + "variableOverridePath": [ + "parent", + "child" + ], + "variableOverrideIndex": 1, + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "child" + ] + ] + } + }, + { + "name": "first match runtime stringified: literal reset", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": "[{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "reset": true + }, + "options": {}, + "expected": { + "value": { + "stage": "reset", + "rows": [], + "flags": [] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "reset", + "variableOverridePath": [ + "parent", + "child", + "reset" + ], + "variableOverrideIndex": 2, + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "child" + ], + [ + "parent", + "child", + "reset" + ] + ] + } + }, + { + "name": "first match runtime stringified: parent fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": "[{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "be", + "eligible": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "parent", + "variableOverridePath": [ + "parent" + ], + "variableOverrideIndex": 0, + "variableOverridePaths": [ + [ + "parent" + ] + ] + } + }, + { + "name": "first match runtime stringified: sibling fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": "[{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "be", + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "fallback", + "variableOverridePath": [ + "parent", + "fallback" + ], + "variableOverrideIndex": 3, + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "fallback" + ] + ] + } + }, + { + "name": "first match runtime stringified: unmet feature", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": "[{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": false, + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "fallback", + "rows": [], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "fallback", + "variableOverridePath": [ + "parent", + "fallback" + ], + "variableOverrideIndex": 3, + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "fallback" + ] + ] + } + }, + { + "name": "first match runtime stringified: unmet variable", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": "[{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "denied": true + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 1, + "tags": [ + "parent" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "parent", + "variableOverridePath": [ + "parent" + ], + "variableOverrideIndex": 0, + "variableOverridePaths": [ + [ + "parent" + ] + ] + } + }, + { + "name": "first match runtime stringified: parent miss skips matching child", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": "[{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "us", + "country": "nl", + "eligible": true, + "reset": true + }, + "options": {}, + "expected": { + "value": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_default" + } + }, + { + "name": "first match runtime stringified: root fallback", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": "[{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "us", + "country": "us" + }, + "options": {}, + "expected": { + "value": { + "stage": "us", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "us", + "variableOverridePath": [ + "us" + ], + "variableOverrideIndex": 4, + "variableOverridePaths": [ + [ + "us" + ] + ] + } + }, + { + "name": "first match runtime stringified: missing context", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": "[{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": {}, + "options": {}, + "expected": { + "value": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "reason": "variable_default" + } + }, + { + "name": "first match runtime stringified: first child prevents later matching sibling", + "datafile": { + "schemaVersion": "2", + "revision": "portable-v4", + "features": { + "gate": { + "bucketBy": "userId", + "traffic": [ + { + "key": "eligible", + "segments": [ + "eligible" + ], + "percentage": 100000 + } + ] + } + }, + "segments": { + "europe": { + "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"eu\"}]" + }, + "eligible": { + "conditions": "[{\"attribute\":\"eligible\",\"operator\":\"equals\",\"value\":true}]" + } + }, + "variables": { + "permission": { + "type": "boolean", + "defaultValue": true, + "overrides": [ + { + "key": "denied", + "conditions": "[{\"attribute\":\"denied\",\"operator\":\"equals\",\"value\":true}]", + "value": false + } + ] + }, + "subject": { + "type": "object", + "defaultValue": { + "stage": "default", + "rows": [ + { + "id": 1, + "tags": [] + } + ], + "flags": [ + null, + "a" + ] + }, + "overrideProgram": [ + { + "key": "parent", + "keyPath": [ + "parent" + ], + "segments": "[\"europe\"]", + "onMatch": 1, + "onMiss": 4, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "parent" + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "parent" + }, + { + "op": "removeValue", + "path": [ + "flags" + ], + "value": null + }, + { + "op": "appendUnique", + "path": [ + "flags" + ], + "value": "a" + } + ] + }, + { + "key": "child", + "keyPath": [ + "parent", + "child" + ], + "requiredFeatures": [ + "gate" + ], + "conditions": "{\"and\":[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"nl\"},{\"variable\":\"permission\",\"operator\":\"equals\",\"value\":true}]}", + "onMatch": 2, + "onMiss": 3, + "operations": [ + { + "op": "before", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + } + ], + "value": { + "id": 0, + "tags": [] + } + }, + { + "op": "append", + "path": [ + "rows", + { + "select": { + "property": "id", + "values": [ + 1 + ] + } + }, + "tags" + ], + "value": "child" + } + ] + }, + { + "key": "reset", + "keyPath": [ + "parent", + "child", + "reset" + ], + "conditions": "[{\"attribute\":\"reset\",\"operator\":\"equals\",\"value\":true}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [], + "value": { + "stage": "reset", + "rows": [], + "flags": [] + } + } + ] + }, + { + "key": "fallback", + "keyPath": [ + "parent", + "fallback" + ], + "conditions": "[{\"attribute\":\"city\",\"operator\":\"equals\",\"value\":\"rotterdam\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "fallback" + }, + { + "op": "remove", + "path": [ + "rows", + 0 + ] + } + ] + }, + { + "key": "us", + "keyPath": [ + "us" + ], + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"us\"}]", + "onMatch": 5, + "onMiss": 5, + "operations": [ + { + "op": "set", + "path": [ + "stage" + ], + "value": "us" + } + ] + } + ] + } + } + }, + "context": { + "region": "eu", + "country": "nl", + "eligible": true, + "city": "rotterdam" + }, + "options": {}, + "expected": { + "value": { + "stage": "parent", + "rows": [ + { + "id": 0, + "tags": [] + }, + { + "id": 1, + "tags": [ + "parent", + "child" + ] + } + ], + "flags": [ + "a" + ] + }, + "reason": "variable_override_rule", + "variableOverrideKey": "child", + "variableOverridePath": [ + "parent", + "child" + ], + "variableOverrideIndex": 1, + "variableOverridePaths": [ + [ + "parent" + ], + [ + "parent", + "child" + ] + ] + } + } + ] +} diff --git a/conformance/semver.json b/conformance/semver.json new file mode 100644 index 0000000..56e96fb --- /dev/null +++ b/conformance/semver.json @@ -0,0 +1,76 @@ +{ + "kind": "featurevisor.semver", + "version": 1, + "description": "Expected ordering is -1, 0 or 1, or null for invalid input. Exercise all six operators in both operand directions through conditions, required variables and CLI checks. Invalid conditions are false; invalid requirements and checks are errors.", + "cases": [ + { "name": "equal", "left": "1.2.3", "right": "1.2.3", "order": 0 }, + { "name": "major", "left": "2.0.0", "right": "1.9.9", "order": 1 }, + { "name": "minor", "left": "1.10.0", "right": "1.9.9", "order": 1 }, + { "name": "patch", "left": "1.0.10", "right": "1.0.9", "order": 1 }, + { "name": "short major", "left": "1", "right": "1.0.0", "order": 0 }, + { "name": "short minor", "left": "1.0", "right": "1.0.0", "order": 0 }, + { "name": "fourth component", "left": "1.2.3.4", "right": "1.2.3.5", "order": -1 }, + { "name": "missing fourth", "left": "1.2.3", "right": "1.2.3.0", "order": 0 }, + { "name": "prefix", "left": "v1.2.3", "right": "1.2.3", "order": 0 }, + { "name": "comparison prefix", "left": ">=1.2.3", "right": "1.2.3", "order": 0 }, + { "name": "tilde prefix", "left": "~1.2.3", "right": "1.2.3", "order": 0 }, + { "name": "caret prefix", "left": "^1.2.3", "right": "1.2.3", "order": 0 }, + { "name": "core wildcard", "left": "1.x.3", "right": "1.9.3", "order": 0 }, + { "name": "uppercase wildcard", "left": "1.X.3", "right": "1.9.3", "order": 0 }, + { "name": "star wildcard", "left": "1.2.*", "right": "1.2.42", "order": 0 }, + { "name": "wildcard keeps later comparison", "left": "1.x.3", "right": "1.9.4", "order": -1 }, + { "name": "metadata", "left": "1.2.3+build.1", "right": "1.2.3+other.2", "order": 0 }, + { "name": "leading zeroes", "left": "01.002.0003", "right": "1.2.3", "order": 0 }, + { + "name": "large integers", + "left": "9007199254740993.0.0", + "right": "9007199254740992.0.0", + "order": 1 + }, + { + "name": "integer overflow", + "left": "999999999999999999999.0.0", + "right": "1000000000000000000000.0.0", + "order": -1 + }, + { "name": "release precedence", "left": "1.0.0-alpha", "right": "1.0.0", "order": -1 }, + { "name": "zero prerelease", "left": "1.0.0-0", "right": "1.0.0", "order": -1 }, + { "name": "numeric leading identifiers", "left": "1.0.0-1a", "right": "1.0.0-1b", "order": -1 }, + { "name": "numeric prerelease", "left": "1.0.0-10", "right": "1.0.0-2", "order": 1 }, + { + "name": "numeric before textual prerelease", + "left": "1.0.0-10", + "right": "1.0.0-1a", + "order": -1 + }, + { "name": "prerelease length", "left": "1.0.0-alpha", "right": "1.0.0-alpha.0", "order": -1 }, + { "name": "prerelease zero normalization", "left": "1.0.0-01", "right": "1.0.0-1", "order": 0 }, + { + "name": "prerelease integer overflow", + "left": "1.0.0-999999999999999999999", + "right": "1.0.0-1000000000000000000000", + "order": -1 + }, + { "name": "prerelease x is literal", "left": "1.0.0-x", "right": "1.0.0-alpha", "order": 1 }, + { "name": "prerelease case", "left": "1.0.0-A", "right": "1.0.0-a", "order": -1 }, + { "name": "fourth with prerelease", "left": "1.2.3.4-alpha", "right": "1.2.3.4", "order": -1 }, + { "name": "empty", "left": "", "right": "1.0.0", "order": null }, + { "name": "unicode digit", "left": "٢.0.0", "right": "1.0.0", "order": null }, + { "name": "unicode prerelease digit", "left": "1.0.0-٢", "right": "1.0.0", "order": null }, + { "name": "unicode case folding", "left": "1.0.0-K", "right": "1.0.0-k", "order": null }, + { "name": "multiline", "left": "junk\n1.0.0\njunk", "right": "1.0.0", "order": null }, + { "name": "trailing newline", "left": "1.0.0\n", "right": "1.0.0", "order": null }, + { "name": "trailing carriage return", "left": "1.0.0\r\n", "right": "1.0.0", "order": null }, + { "name": "leading whitespace", "left": " 1.0.0", "right": "1.0.0", "order": null }, + { "name": "trailing whitespace", "left": "1.0.0 ", "right": "1.0.0", "order": null }, + { "name": "fifth component", "left": "1.2.3.4.5", "right": "1.0.0", "order": null }, + { "name": "empty prerelease", "left": "1.0.0-", "right": "1.0.0", "order": null }, + { + "name": "empty prerelease identifier", + "left": "1.0.0-a..b", + "right": "1.0.0", + "order": null + }, + { "name": "short prerelease", "left": "1.0-alpha", "right": "1.0.0", "order": null } + ] +} diff --git a/conformance/test-checks.json b/conformance/test-checks.json new file mode 100644 index 0000000..8c29242 --- /dev/null +++ b/conformance/test-checks.json @@ -0,0 +1,3495 @@ +{ + "version": 3, + "description": "CLI only. Resolve references with native SDK getters against datafile. Each case uses a fresh instance. error means invalid check or resolution/comparison error, not an ordinary failed predicate.", + "datafile": { + "schemaVersion": "2", + "revision": "cli-checks-v2", + "segments": {}, + "features": { + "membershipFeature": { + "bucketBy": ["userId"], + "variablesSchema": { "roles": { "type": "array", "defaultValue": ["member", "owner"] } }, + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledCheckout": { + "bucketBy": ["userId"], + "disabledVariationValue": "control", + "traffic": [] + }, + "disabledNoVariation": { + "bucketBy": ["userId"], + "traffic": [] + }, + "experiment": { + "bucketBy": ["userId"], + "variations": [{ "value": "treatment" }], + "traffic": [ + { + "key": "all", + "segments": "*", + "percentage": 100000, + "allocation": [{ "variation": "treatment", "range": [0, 100000] }] + } + ] + }, + "checkout": { + "bucketBy": ["userId"], + "variablesSchema": { + "limit": { + "type": "integer", + "defaultValue": 5 + }, + "label": { + "type": "string", + "defaultValue": "standard" + } + }, + "traffic": [ + { + "key": "everyone", + "segments": "*", + "percentage": 100000 + } + ] + } + }, + "variables": { + "membershipFixture": { + "type": "object", + "defaultValue": { + "roles": ["member", "owner"], + "empty": [], + "booleans": [true, false], + "nulls": [null], + "structured": [{ "role": "owner" }], + "scalar": "owner", + "candidates": ["absent", "owner"], + "badCandidates": [{}, "owner"] + } + }, + "unicodeTimestamps": { + "type": "object", + "defaultValue": { + "arabic": "٢٠٢٦-٠١-٠١T00:00:00Z", + "fullwidth": "2026-01-01T00:00:00Z", + "fraction": "2026-01-01T00:00:00.١٢٣Z", + "offset": "2026-01-01T00:00:00+٠١:00" + } + }, + "emptiness": { + "type": "object", + "defaultValue": { + "empty string": "", + "whitespace": " \t", + "text": "hello", + "empty array": [], + "array containing null": [null], + "array containing empty array": [[]], + "empty object": {}, + "object containing null": { + "value": null + }, + "object containing empty object": { + "nested": {} + }, + "null": null, + "zero": 0, + "false": false + } + }, + "settings": { + "type": "object", + "defaultValue": { + "min": 2, + "max": 10, + "regions": ["nl", "gb"], + "nested": { + "nullable": null, + "dot.key": 7 + }, + "items": [ + { + "limit": 4 + } + ], + "active": true + } + }, + "limit": { + "type": "integer", + "defaultValue": 12 + }, + "label": { + "type": "string", + "defaultValue": "Hello world" + }, + "version": { + "type": "string", + "defaultValue": "2.3.1" + }, + "timestamp": { + "type": "string", + "defaultValue": "2026-09-13T12:00:00Z" + }, + "leapTimestamp": { + "type": "string", + "defaultValue": "2024-02-29T12:34:56Z" + }, + "equivalentTimestamp": { + "type": "string", + "defaultValue": "2026-09-13T14:00:00+02:00" + }, + "millisecondTimestamp": { + "type": "string", + "defaultValue": "2026-09-13T12:00:00.123Z" + }, + "invalidCalendarTimestamp": { + "type": "string", + "defaultValue": "2026-02-30T12:00:00Z" + }, + "invalidFractionTimestamp": { + "type": "string", + "defaultValue": "2026-09-13T12:00:00.1234567890Z" + }, + "preciseTimestamp": { + "type": "string", + "defaultValue": "2026-09-13T12:00:00.123999999Z" + }, + "invalidOffsetTimestamp": { + "type": "string", + "defaultValue": "2026-09-13T12:00:00+14:01" + }, + "nil": { + "type": "object", + "defaultValue": null + }, + "regions": { + "type": "array", + "defaultValue": ["nl", "gb"] + }, + "pattern": { + "type": "string", + "defaultValue": "^Hello" + }, + "badPattern": { + "type": "string", + "defaultValue": "[" + } + } + }, + "cases": [ + { + "name": "includesAny: hit", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "includesAny", + "value": ["owner"] + } + ], + "passed": true, + "error": false + }, + { + "name": "includesAny: miss", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "includesAny", + "value": ["admin"] + } + ], + "passed": false, + "error": false + }, + { + "name": "includesAny: empty actual", + "checks": [ + { + "variable": "membershipFixture", + "path": "empty", + "operator": "includesAny", + "value": ["owner"] + } + ], + "passed": false, + "error": false + }, + { + "name": "includesAny: empty candidates", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "includesAny", + "value": [] + } + ], + "passed": false, + "error": false + }, + { + "name": "includesAny: boolean is not zero", + "checks": [ + { + "variable": "membershipFixture", + "path": "booleans", + "operator": "includesAny", + "value": [0] + } + ], + "passed": false, + "error": false + }, + { + "name": "includesAny: null element", + "checks": [ + { + "variable": "membershipFixture", + "path": "nulls", + "operator": "includesAny", + "value": [null] + } + ], + "passed": true, + "error": false + }, + { + "name": "includesAny: object element ignored", + "checks": [ + { + "variable": "membershipFixture", + "path": "structured", + "operator": "includesAny", + "value": ["owner"] + } + ], + "passed": false, + "error": false + }, + { + "name": "includesAny: malformed candidate", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "includesAny", + "value": [{}] + } + ], + "passed": false, + "error": true + }, + { + "name": "includesAny: malformed after a match", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "includesAny", + "value": ["owner", {}] + } + ], + "passed": false, + "error": true + }, + { + "name": "includesAny: nonarray candidates", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "includesAny", + "value": "owner" + } + ], + "passed": false, + "error": true + }, + { + "name": "includesAny: nonarray actual", + "checks": [ + { + "variable": "membershipFixture", + "path": "scalar", + "operator": "includesAny", + "value": ["owner"] + } + ], + "passed": false, + "error": true + }, + { + "name": "includesAny: null actual is a check error", + "checks": [ + { + "variable": "nil", + "operator": "includesAny", + "value": ["owner"] + } + ], + "passed": false, + "error": true + }, + { + "name": "includesAny: missing path remains false", + "checks": [ + { + "variable": "membershipFixture", + "path": "missing", + "operator": "includesAny", + "value": ["owner"] + } + ], + "passed": false, + "error": false + }, + { + "name": "includesAny: resolved array candidates", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "includesAny", + "compareTo": { + "variable": "membershipFixture", + "path": "candidates" + } + } + ], + "passed": true, + "error": false + }, + { + "name": "includesAny: resolved invalid candidates", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "includesAny", + "compareTo": { + "variable": "membershipFixture", + "path": "badCandidates" + } + } + ], + "passed": false, + "error": true + }, + { + "name": "includesAny: feature variable", + "checks": [ + { + "feature": "membershipFeature", + "variable": "roles", + "operator": "includesAny", + "value": ["owner"] + } + ], + "passed": true, + "error": false + }, + { + "name": "includesAny: comparison errors are not inverted", + "checks": [ + { + "not": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "includesAny", + "value": [{}] + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "includesAny: short circuit skips a bad operand", + "checks": [ + { + "or": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "includesAny", + "value": ["owner"] + }, + { + "variable": "membershipFixture", + "path": "scalar", + "operator": "includesAny", + "value": ["owner"] + } + ] + } + ], + "passed": true, + "error": false + }, + { + "name": "notIncludesAny: hit", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "notIncludesAny", + "value": ["owner"] + } + ], + "passed": false, + "error": false + }, + { + "name": "notIncludesAny: miss", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "notIncludesAny", + "value": ["admin"] + } + ], + "passed": true, + "error": false + }, + { + "name": "notIncludesAny: empty actual", + "checks": [ + { + "variable": "membershipFixture", + "path": "empty", + "operator": "notIncludesAny", + "value": ["owner"] + } + ], + "passed": true, + "error": false + }, + { + "name": "notIncludesAny: empty candidates", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "notIncludesAny", + "value": [] + } + ], + "passed": true, + "error": false + }, + { + "name": "notIncludesAny: boolean is not zero", + "checks": [ + { + "variable": "membershipFixture", + "path": "booleans", + "operator": "notIncludesAny", + "value": [0] + } + ], + "passed": true, + "error": false + }, + { + "name": "notIncludesAny: null element", + "checks": [ + { + "variable": "membershipFixture", + "path": "nulls", + "operator": "notIncludesAny", + "value": [null] + } + ], + "passed": false, + "error": false + }, + { + "name": "notIncludesAny: object element ignored", + "checks": [ + { + "variable": "membershipFixture", + "path": "structured", + "operator": "notIncludesAny", + "value": ["owner"] + } + ], + "passed": true, + "error": false + }, + { + "name": "notIncludesAny: malformed candidate", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "notIncludesAny", + "value": [{}] + } + ], + "passed": false, + "error": true + }, + { + "name": "notIncludesAny: malformed after a match", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "notIncludesAny", + "value": ["owner", {}] + } + ], + "passed": false, + "error": true + }, + { + "name": "notIncludesAny: nonarray candidates", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "notIncludesAny", + "value": "owner" + } + ], + "passed": false, + "error": true + }, + { + "name": "notIncludesAny: nonarray actual", + "checks": [ + { + "variable": "membershipFixture", + "path": "scalar", + "operator": "notIncludesAny", + "value": ["owner"] + } + ], + "passed": false, + "error": true + }, + { + "name": "notIncludesAny: null actual is a check error", + "checks": [ + { + "variable": "nil", + "operator": "notIncludesAny", + "value": ["owner"] + } + ], + "passed": false, + "error": true + }, + { + "name": "notIncludesAny: missing path remains false", + "checks": [ + { + "variable": "membershipFixture", + "path": "missing", + "operator": "notIncludesAny", + "value": ["owner"] + } + ], + "passed": false, + "error": false + }, + { + "name": "notIncludesAny: resolved array candidates", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "notIncludesAny", + "compareTo": { + "variable": "membershipFixture", + "path": "candidates" + } + } + ], + "passed": false, + "error": false + }, + { + "name": "notIncludesAny: resolved invalid candidates", + "checks": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "notIncludesAny", + "compareTo": { + "variable": "membershipFixture", + "path": "badCandidates" + } + } + ], + "passed": false, + "error": true + }, + { + "name": "notIncludesAny: feature variable", + "checks": [ + { + "feature": "membershipFeature", + "variable": "roles", + "operator": "notIncludesAny", + "value": ["owner"] + } + ], + "passed": false, + "error": false + }, + { + "name": "notIncludesAny: comparison errors are not inverted", + "checks": [ + { + "not": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "notIncludesAny", + "value": [{}] + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "notIncludesAny: short circuit skips a bad operand", + "checks": [ + { + "or": [ + { + "variable": "membershipFixture", + "path": "roles", + "operator": "notIncludesAny", + "value": ["admin"] + }, + { + "variable": "membershipFixture", + "path": "scalar", + "operator": "notIncludesAny", + "value": ["owner"] + } + ] + } + ], + "passed": true, + "error": false + }, + { + "name": "resolved Unicode timestamp arabic is rejected", + "checks": [ + { + "variable": "unicodeTimestamps", + "path": "arabic", + "operator": "before", + "value": "2027-01-01T00:00:00Z" + } + ], + "passed": false, + "error": true + }, + { + "name": "resolved Unicode timestamp fullwidth is rejected", + "checks": [ + { + "variable": "unicodeTimestamps", + "path": "fullwidth", + "operator": "before", + "value": "2027-01-01T00:00:00Z" + } + ], + "passed": false, + "error": true + }, + { + "name": "resolved Unicode timestamp fraction is rejected", + "checks": [ + { + "variable": "unicodeTimestamps", + "path": "fraction", + "operator": "before", + "value": "2027-01-01T00:00:00Z" + } + ], + "passed": false, + "error": true + }, + { + "name": "resolved Unicode timestamp offset is rejected", + "checks": [ + { + "variable": "unicodeTimestamps", + "path": "offset", + "operator": "before", + "value": "2027-01-01T00:00:00Z" + } + ], + "passed": false, + "error": true + }, + { + "name": "resolved Unicode timestamp comparison operand is rejected", + "checks": [ + { + "variable": "timestamp", + "operator": "before", + "compareTo": { + "variable": "unicodeTimestamps", + "path": "arabic" + } + } + ], + "passed": false, + "error": true + }, + { + "name": "Unicode timestamp error propagates through not", + "checks": [ + { + "not": [ + { + "variable": "unicodeTimestamps", + "path": "arabic", + "operator": "before", + "value": "2027-01-01T00:00:00Z" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "Unicode timestamp error propagates through and", + "checks": [ + { + "and": [ + { + "variable": "unicodeTimestamps", + "path": "arabic", + "operator": "before", + "value": "2027-01-01T00:00:00Z" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "Unicode timestamp error propagates through or", + "checks": [ + { + "or": [ + { + "variable": "unicodeTimestamps", + "path": "arabic", + "operator": "before", + "value": "2027-01-01T00:00:00Z" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "empty rejects empty regex flags", + "checks": [ + { + "variable": "emptiness", + "operator": "empty", + "regexFlags": "" + } + ], + "passed": false, + "error": true + }, + { + "name": "empty rejects null regex flags", + "checks": [ + { + "variable": "emptiness", + "operator": "empty", + "regexFlags": null + } + ], + "passed": false, + "error": true + }, + { + "name": "empty rejects null value", + "checks": [ + { + "variable": "emptiness", + "operator": "empty", + "value": null + } + ], + "passed": false, + "error": true + }, + { + "name": "empty rejects false value", + "checks": [ + { + "variable": "emptiness", + "operator": "empty", + "value": false + } + ], + "passed": false, + "error": true + }, + { + "name": "empty rejects null comparison", + "checks": [ + { + "variable": "emptiness", + "operator": "empty", + "compareTo": null + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty rejects empty regex flags", + "checks": [ + { + "variable": "emptiness", + "operator": "notEmpty", + "regexFlags": "" + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty rejects null regex flags", + "checks": [ + { + "variable": "emptiness", + "operator": "notEmpty", + "regexFlags": null + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty rejects null value", + "checks": [ + { + "variable": "emptiness", + "operator": "notEmpty", + "value": null + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty rejects false value", + "checks": [ + { + "variable": "emptiness", + "operator": "notEmpty", + "value": false + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty rejects null comparison", + "checks": [ + { + "variable": "emptiness", + "operator": "notEmpty", + "compareTo": null + } + ], + "passed": false, + "error": true + }, + { + "name": "empty: empty string", + "checks": [ + { + "variable": "emptiness", + "path": ["empty string"], + "operator": "empty" + } + ], + "passed": true + }, + { + "name": "notEmpty: empty string", + "checks": [ + { + "variable": "emptiness", + "path": ["empty string"], + "operator": "notEmpty" + } + ], + "passed": false + }, + { + "name": "empty: whitespace", + "checks": [ + { + "variable": "emptiness", + "path": ["whitespace"], + "operator": "empty" + } + ], + "passed": false + }, + { + "name": "notEmpty: whitespace", + "checks": [ + { + "variable": "emptiness", + "path": ["whitespace"], + "operator": "notEmpty" + } + ], + "passed": true + }, + { + "name": "empty: text", + "checks": [ + { + "variable": "emptiness", + "path": ["text"], + "operator": "empty" + } + ], + "passed": false + }, + { + "name": "notEmpty: text", + "checks": [ + { + "variable": "emptiness", + "path": ["text"], + "operator": "notEmpty" + } + ], + "passed": true + }, + { + "name": "empty: empty array", + "checks": [ + { + "variable": "emptiness", + "path": ["empty array"], + "operator": "empty" + } + ], + "passed": true + }, + { + "name": "notEmpty: empty array", + "checks": [ + { + "variable": "emptiness", + "path": ["empty array"], + "operator": "notEmpty" + } + ], + "passed": false + }, + { + "name": "empty: array containing null", + "checks": [ + { + "variable": "emptiness", + "path": ["array containing null"], + "operator": "empty" + } + ], + "passed": false + }, + { + "name": "notEmpty: array containing null", + "checks": [ + { + "variable": "emptiness", + "path": ["array containing null"], + "operator": "notEmpty" + } + ], + "passed": true + }, + { + "name": "empty: array containing empty array", + "checks": [ + { + "variable": "emptiness", + "path": ["array containing empty array"], + "operator": "empty" + } + ], + "passed": false + }, + { + "name": "notEmpty: array containing empty array", + "checks": [ + { + "variable": "emptiness", + "path": ["array containing empty array"], + "operator": "notEmpty" + } + ], + "passed": true + }, + { + "name": "empty: empty object", + "checks": [ + { + "variable": "emptiness", + "path": ["empty object"], + "operator": "empty" + } + ], + "passed": true + }, + { + "name": "notEmpty: empty object", + "checks": [ + { + "variable": "emptiness", + "path": ["empty object"], + "operator": "notEmpty" + } + ], + "passed": false + }, + { + "name": "empty: object containing null", + "checks": [ + { + "variable": "emptiness", + "path": ["object containing null"], + "operator": "empty" + } + ], + "passed": false + }, + { + "name": "notEmpty: object containing null", + "checks": [ + { + "variable": "emptiness", + "path": ["object containing null"], + "operator": "notEmpty" + } + ], + "passed": true + }, + { + "name": "empty: object containing empty object", + "checks": [ + { + "variable": "emptiness", + "path": ["object containing empty object"], + "operator": "empty" + } + ], + "passed": false + }, + { + "name": "notEmpty: object containing empty object", + "checks": [ + { + "variable": "emptiness", + "path": ["object containing empty object"], + "operator": "notEmpty" + } + ], + "passed": true + }, + { + "name": "empty: null", + "checks": [ + { + "variable": "emptiness", + "path": ["null"], + "operator": "empty" + } + ], + "passed": false + }, + { + "name": "notEmpty: null", + "checks": [ + { + "variable": "emptiness", + "path": ["null"], + "operator": "notEmpty" + } + ], + "passed": false + }, + { + "name": "empty: zero", + "checks": [ + { + "variable": "emptiness", + "path": ["zero"], + "operator": "empty" + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty: zero", + "checks": [ + { + "variable": "emptiness", + "path": ["zero"], + "operator": "notEmpty" + } + ], + "passed": false, + "error": true + }, + { + "name": "empty: false", + "checks": [ + { + "variable": "emptiness", + "path": ["false"], + "operator": "empty" + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty: false", + "checks": [ + { + "variable": "emptiness", + "path": ["false"], + "operator": "notEmpty" + } + ], + "passed": false, + "error": true + }, + { + "name": "empty: missing", + "checks": [ + { + "variable": "emptiness", + "path": ["missing"], + "operator": "empty" + } + ], + "passed": false + }, + { + "name": "notEmpty: missing", + "checks": [ + { + "variable": "emptiness", + "path": ["missing"], + "operator": "notEmpty" + } + ], + "passed": false + }, + { + "name": "empty rejects value", + "checks": [ + { + "variable": "emptiness", + "operator": "empty", + "value": "" + } + ], + "passed": false, + "error": true + }, + { + "name": "empty rejects compareTo", + "checks": [ + { + "variable": "emptiness", + "operator": "empty", + "compareTo": { + "variable": "emptiness" + } + } + ], + "passed": false, + "error": true + }, + { + "name": "empty rejects regexFlags", + "checks": [ + { + "variable": "emptiness", + "operator": "empty", + "regexFlags": "i" + } + ], + "passed": false, + "error": true + }, + { + "name": "empty validates skipped operand", + "checks": [ + { + "or": [ + { + "variable": "emptiness", + "operator": "exists" + }, + { + "variable": "emptiness", + "operator": "empty", + "value": [] + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "empty error cannot be inverted", + "checks": [ + { + "not": [ + { + "variable": "emptiness", + "path": ["zero"], + "operator": "empty" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty rejects value", + "checks": [ + { + "variable": "emptiness", + "operator": "notEmpty", + "value": "" + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty rejects compareTo", + "checks": [ + { + "variable": "emptiness", + "operator": "notEmpty", + "compareTo": { + "variable": "emptiness" + } + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty rejects regexFlags", + "checks": [ + { + "variable": "emptiness", + "operator": "notEmpty", + "regexFlags": "i" + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty validates skipped operand", + "checks": [ + { + "or": [ + { + "variable": "emptiness", + "operator": "exists" + }, + { + "variable": "emptiness", + "operator": "notEmpty", + "value": [] + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "notEmpty error cannot be inverted", + "checks": [ + { + "not": [ + { + "variable": "emptiness", + "path": ["zero"], + "operator": "notEmpty" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "feature flag enabled", + "checks": [{ "feature": "checkout", "enabled": true }], + "passed": true + }, + { + "name": "feature flag disabled", + "checks": [{ "feature": "disabledCheckout", "enabled": false }], + "passed": true + }, + { + "name": "flag mismatch is ordinary failure", + "checks": [{ "feature": "checkout", "enabled": false }], + "passed": false + }, + { + "name": "variation only enabled", + "checks": [{ "feature": "experiment", "variation": "treatment" }], + "passed": true + }, + { + "name": "variation only does not imply enabled", + "checks": [{ "feature": "disabledCheckout", "variation": "control" }], + "passed": true + }, + { + "name": "enabled and variation both match", + "checks": [{ "feature": "experiment", "enabled": true, "variation": "treatment" }], + "passed": true + }, + { + "name": "disabled and disabled variation both match", + "checks": [{ "feature": "disabledCheckout", "enabled": false, "variation": "control" }], + "passed": true + }, + { + "name": "enabled mismatch is not rescued by matching variation", + "checks": [{ "feature": "disabledCheckout", "enabled": true, "variation": "control" }], + "passed": false + }, + { + "name": "variation mismatch is not rescued by matching enabled", + "checks": [{ "feature": "experiment", "enabled": true, "variation": "control" }], + "passed": false + }, + { + "name": "null variation on enabled feature", + "checks": [{ "feature": "checkout", "variation": null }], + "passed": true + }, + { + "name": "null variation on disabled feature", + "checks": [{ "feature": "disabledNoVariation", "enabled": false, "variation": null }], + "passed": true + }, + { + "name": "null variation mismatch", + "checks": [{ "feature": "experiment", "variation": null }], + "passed": false + }, + { + "name": "empty variation string is valid expectation", + "checks": [{ "feature": "experiment", "variation": "" }], + "passed": false + }, + { + "name": "missing feature cannot satisfy disabled", + "checks": [{ "feature": "absent", "enabled": false }], + "passed": false, + "error": true + }, + { + "name": "missing feature cannot satisfy null variation", + "checks": [{ "feature": "absent", "variation": null }], + "passed": false, + "error": true + }, + { + "name": "not cannot hide missing feature", + "checks": [{ "not": [{ "feature": "absent", "enabled": true }] }], + "passed": false, + "error": true + }, + { + "name": "or cannot hide reached missing feature", + "checks": [ + { + "or": [ + { "feature": "absent", "enabled": true }, + { "feature": "checkout", "enabled": true } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "or skips unreached missing feature", + "checks": [ + { + "or": [ + { "feature": "checkout", "enabled": true }, + { "feature": "absent", "enabled": true } + ] + } + ], + "passed": true + }, + { + "name": "and skips unreached missing feature", + "checks": [ + { + "and": [ + { "feature": "checkout", "enabled": false }, + { "feature": "absent", "enabled": true } + ] + } + ], + "passed": false + }, + { + "name": "not negates feature implicit and", + "checks": [ + { + "not": [ + { "feature": "checkout", "enabled": true }, + { "feature": "disabledCheckout", "enabled": true } + ] + } + ], + "passed": true + }, + { + "name": "mixed feature and variable checks", + "checks": [ + { + "and": [ + { "feature": "checkout", "enabled": true }, + { "feature": "checkout", "variable": "limit", "operator": "equals", "value": 5 }, + { "variable": "limit", "operator": "equals", "value": 12 } + ] + } + ], + "passed": true + }, + { + "name": "owner does not turn flag into feature variable", + "owner": "settings", + "checks": [ + { "feature": "checkout", "enabled": true }, + { "path": "min", "operator": "equals", "value": 2 } + ], + "passed": true + }, + { + "name": "bare feature check is invalid", + "checks": [{ "feature": "checkout" }], + "passed": false, + "error": true + }, + { + "name": "feature check requires feature key", + "owner": "settings", + "checks": [{ "enabled": true }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects empty feature key", + "checks": [{ "feature": "", "enabled": true }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects numeric feature key", + "checks": [{ "feature": 7, "enabled": true }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects string enabled", + "checks": [{ "feature": "checkout", "enabled": "true" }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects null enabled", + "checks": [{ "feature": "checkout", "enabled": null }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects numeric variation", + "checks": [{ "feature": "checkout", "variation": 1 }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects boolean variation", + "checks": [{ "feature": "checkout", "variation": false }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects array variation", + "checks": [{ "feature": "checkout", "variation": ["control"] }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects variable even with owner", + "owner": "settings", + "checks": [{ "feature": "checkout", "variable": "limit", "enabled": true }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects operator", + "checks": [{ "feature": "checkout", "enabled": true, "operator": "equals", "value": true }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects path", + "checks": [{ "feature": "checkout", "variation": null, "path": [] }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects compareTo", + "checks": [{ "feature": "checkout", "enabled": true, "compareTo": { "variable": "limit" } }], + "passed": false, + "error": true + }, + { + "name": "feature check rejects unknown field", + "checks": [{ "feature": "checkout", "enabled": true, "extra": true }], + "passed": false, + "error": true + }, + { + "name": "invalid skipped feature leaf still errors", + "checks": [ + { + "or": [ + { "feature": "checkout", "enabled": true }, + { "feature": "checkout", "enabled": "true" } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "compareTo cannot resolve a flag", + "checks": [ + { + "variable": "limit", + "operator": "equals", + "compareTo": { "feature": "checkout", "enabled": true } + } + ], + "passed": false, + "error": true + }, + { + "name": "mixed feature and group properties are invalid", + "checks": [ + { + "feature": "checkout", + "enabled": true, + "and": [{ "feature": "checkout", "enabled": true }] + } + ], + "passed": false, + "error": true + }, + { + "name": "explicit null regex flags", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "label", + "operator": "matches", + "value": "Hello", + "regexFlags": null + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "structured membership literal", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "limit", + "operator": "in", + "value": [{}] + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "null path component", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "settings", + "path": ["items", null], + "operator": "exists" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "unsafe integer string path", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "settings", + "path": "items.9007199254740992", + "operator": "exists" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "nonstring operator", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "limit", + "operator": true, + "value": 12 + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "noncapturing regex is outside portable subset", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "label", + "operator": "matches", + "value": "(?:Hello)" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "regex backreference is outside portable subset", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "label", + "operator": "matches", + "value": "(Hello)\\1" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "possessive regex is outside portable subset", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "label", + "operator": "matches", + "value": "Hello++" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "inline regex flags are outside portable subset", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "label", + "operator": "matches", + "value": "(?i)Hello" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "large safe array index is missing rather than invalid", + "checks": [ + { + "variable": "settings", + "path": ["items", 4294967296], + "operator": "notExists" + } + ], + "passed": true + }, + { + "name": "explicit null variable", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": null, + "operator": "exists" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "explicit null feature", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "limit", + "feature": null, + "operator": "exists" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "explicit null path", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "limit", + "path": null, + "operator": "exists" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "numeric variable name", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": 12, + "operator": "exists" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "empty feature name", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "feature": "", + "variable": "limit", + "operator": "exists" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "numeric compareTo name", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "limit", + "operator": "equals", + "compareTo": { + "variable": 12 + } + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "invalid numeric literal in skipped OR", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "limit", + "operator": "greaterThan", + "value": "12" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "bad regex in skipped OR", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "label", + "operator": "matches", + "value": "[" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "invalid flags in skipped OR", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "label", + "operator": "matches", + "value": "Hello", + "regexFlags": false + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "duplicate flags in skipped OR", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "label", + "operator": "matches", + "value": "Hello", + "regexFlags": "ii" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "structured literal in skipped OR", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "limit", + "operator": "equals", + "value": {} + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "malformed membership literal in skipped OR", + "owner": "settings", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "limit", + "operator": "in", + "value": "12" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "boolean in is invalid", + "checks": [ + { + "variable": "settings", + "path": "active", + "operator": "in", + "value": [true] + } + ], + "passed": false, + "error": true + }, + { + "name": "objects in array do not match primitive needle", + "checks": [ + { + "variable": "settings", + "path": "items", + "operator": "notIncludes", + "value": 4 + } + ], + "passed": true + }, + { + "name": "mixed primitive types use strict equality", + "checks": [ + { + "variable": "settings", + "path": "active", + "operator": "notEquals", + "value": 1 + } + ], + "passed": true + }, + { + "name": "literal integer equality", + "checks": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + } + ], + "passed": true + }, + { + "name": "failed literal comparison", + "checks": [ + { + "variable": "limit", + "operator": "lessThan", + "value": 10 + } + ], + "passed": false + }, + { + "name": "notEquals", + "checks": [ + { + "variable": "limit", + "operator": "notEquals", + "value": 13 + } + ], + "passed": true + }, + { + "name": "greaterThan", + "checks": [ + { + "variable": "limit", + "operator": "greaterThan", + "value": 11 + } + ], + "passed": true + }, + { + "name": "greaterThanOrEquals", + "checks": [ + { + "variable": "limit", + "operator": "greaterThanOrEquals", + "value": 12 + } + ], + "passed": true + }, + { + "name": "lessThan", + "checks": [ + { + "variable": "limit", + "operator": "lessThan", + "value": 13 + } + ], + "passed": true + }, + { + "name": "lessThanOrEquals", + "checks": [ + { + "variable": "limit", + "operator": "lessThanOrEquals", + "value": 12 + } + ], + "passed": true + }, + { + "name": "in", + "checks": [ + { + "variable": "limit", + "operator": "in", + "value": [10, 12] + } + ], + "passed": true + }, + { + "name": "notIn", + "checks": [ + { + "variable": "limit", + "operator": "notIn", + "value": [9] + } + ], + "passed": true + }, + { + "name": "global reference", + "checks": [ + { + "variable": "limit", + "operator": "greaterThan", + "compareTo": { + "variable": "settings", + "path": "max" + } + } + ], + "passed": true + }, + { + "name": "feature reference", + "checks": [ + { + "variable": "limit", + "operator": "greaterThan", + "compareTo": { + "feature": "checkout", + "variable": "limit" + } + } + ], + "passed": true + }, + { + "name": "feature left operand", + "checks": [ + { + "feature": "checkout", + "variable": "limit", + "operator": "equals", + "value": 5 + } + ], + "passed": true + }, + { + "name": "owner reference", + "owner": "settings", + "checks": [ + { + "path": "min", + "operator": "lessThan", + "compareTo": { + "path": "max" + } + } + ], + "passed": true + }, + { + "name": "compareTo inherits owner not left operand", + "owner": "settings", + "checks": [ + { + "variable": "limit", + "operator": "greaterThan", + "compareTo": { + "path": "max" + } + } + ], + "passed": true + }, + { + "name": "dot numeric index", + "checks": [ + { + "variable": "settings", + "path": "items.0.limit", + "operator": "equals", + "value": 4 + } + ], + "passed": true + }, + { + "name": "array path numeric index", + "checks": [ + { + "variable": "settings", + "path": ["items", 0, "limit"], + "operator": "equals", + "value": 4 + } + ], + "passed": true + }, + { + "name": "literal dot key", + "checks": [ + { + "variable": "settings", + "path": ["nested", "dot.key"], + "operator": "equals", + "value": 7 + } + ], + "passed": true + }, + { + "name": "array string index matches requiredVariables", + "checks": [ + { + "variable": "settings", + "path": ["items", "0", "limit"], + "operator": "equals", + "value": 4 + } + ], + "passed": true + }, + { + "name": "array length matches requiredVariables", + "checks": [ + { "variable": "settings", "path": ["items", "length"], "operator": "equals", "value": 1 } + ], + "passed": true + }, + { + "name": "padded array string index stays missing", + "checks": [{ "variable": "settings", "path": ["items", "00"], "operator": "notExists" }], + "passed": true + }, + { + "name": "empty array path is whole value", + "checks": [ + { + "variable": "limit", + "path": [], + "operator": "equals", + "value": 12 + } + ], + "passed": true + }, + { + "name": "null path", + "checks": [ + { + "variable": "settings", + "path": "nested.nullable", + "operator": "isNull" + } + ], + "passed": true + }, + { + "name": "null getter", + "checks": [ + { + "variable": "nil", + "operator": "isNull" + } + ], + "passed": true + }, + { + "name": "null exists", + "checks": [ + { + "variable": "nil", + "operator": "exists" + } + ], + "passed": true + }, + { + "name": "missing nested path", + "checks": [ + { + "variable": "settings", + "path": "noSuch", + "operator": "notExists" + } + ], + "passed": true + }, + { + "name": "missing is not null", + "checks": [ + { + "variable": "settings", + "path": "noSuch", + "operator": "isNull" + } + ], + "passed": false + }, + { + "name": "missing notNull is false", + "checks": [ + { + "variable": "settings", + "path": "noSuch", + "operator": "notNull" + } + ], + "passed": false + }, + { + "name": "missing notEquals is false", + "checks": [ + { + "variable": "settings", + "path": "noSuch", + "operator": "notEquals", + "value": 3 + } + ], + "passed": false + }, + { + "name": "missing right operand false", + "checks": [ + { + "variable": "limit", + "operator": "notEquals", + "compareTo": { + "variable": "settings", + "path": "noSuch" + } + } + ], + "passed": false + }, + { + "name": "boolean literal", + "checks": [ + { + "variable": "settings", + "path": "active", + "operator": "equals", + "value": true + } + ], + "passed": true + }, + { + "name": "contains", + "checks": [ + { + "variable": "label", + "operator": "contains", + "value": "world" + } + ], + "passed": true + }, + { + "name": "notContains", + "checks": [ + { + "variable": "label", + "operator": "notContains", + "value": "absent" + } + ], + "passed": true + }, + { + "name": "startsWith", + "checks": [ + { + "variable": "label", + "operator": "startsWith", + "value": "Hello" + } + ], + "passed": true + }, + { + "name": "endsWith", + "checks": [ + { + "variable": "label", + "operator": "endsWith", + "value": "world" + } + ], + "passed": true + }, + { + "name": "matches", + "checks": [ + { + "variable": "label", + "operator": "matches", + "value": "^Hello" + } + ], + "passed": true + }, + { + "name": "notMatches", + "checks": [ + { + "variable": "label", + "operator": "notMatches", + "value": "^Goodbye" + } + ], + "passed": true + }, + { + "name": "regex flags", + "checks": [ + { + "variable": "label", + "operator": "matches", + "value": "^hello", + "regexFlags": "i" + } + ], + "passed": true + }, + { + "name": "regex from variable", + "checks": [ + { + "variable": "label", + "operator": "matches", + "compareTo": { + "variable": "pattern" + } + } + ], + "passed": true + }, + { + "name": "semverEquals", + "checks": [ + { + "variable": "version", + "operator": "semverEquals", + "value": "2.3.1" + } + ], + "passed": true + }, + { + "name": "semverNotEquals", + "checks": [ + { + "variable": "version", + "operator": "semverNotEquals", + "value": "2.3.2" + } + ], + "passed": true + }, + { + "name": "semverGreaterThan", + "checks": [ + { + "variable": "version", + "operator": "semverGreaterThan", + "value": "2.3.0" + } + ], + "passed": true + }, + { + "name": "semverGreaterThanOrEquals", + "checks": [ + { + "variable": "version", + "operator": "semverGreaterThanOrEquals", + "value": "2.3.1" + } + ], + "passed": true + }, + { + "name": "semverLessThan", + "checks": [ + { + "variable": "version", + "operator": "semverLessThan", + "value": "3.0.0" + } + ], + "passed": true + }, + { + "name": "semverLessThanOrEquals", + "checks": [ + { + "variable": "version", + "operator": "semverLessThanOrEquals", + "value": "2.3.1" + } + ], + "passed": true + }, + { + "name": "before", + "checks": [ + { + "variable": "timestamp", + "operator": "before", + "value": "2026-09-14T00:00:00Z" + } + ], + "passed": true + }, + { + "name": "after", + "checks": [ + { + "variable": "timestamp", + "operator": "after", + "value": "2026-09-12T00:00:00Z" + } + ], + "passed": true + }, + { + "name": "includes", + "checks": [ + { + "variable": "regions", + "operator": "includes", + "value": "nl" + } + ], + "passed": true + }, + { + "name": "notIncludes", + "checks": [ + { + "variable": "regions", + "operator": "notIncludes", + "value": "fr" + } + ], + "passed": true + }, + { + "name": "array reference membership", + "checks": [ + { + "variable": "settings", + "path": ["regions", 0], + "operator": "in", + "compareTo": { + "variable": "regions" + } + } + ], + "passed": true + }, + { + "name": "and group", + "checks": [ + { + "and": [ + { + "variable": "limit", + "operator": "greaterThan", + "value": 5 + }, + { + "variable": "limit", + "operator": "lessThan", + "value": 20 + } + ] + } + ], + "passed": true + }, + { + "name": "or group", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "lessThan", + "value": 5 + }, + { + "variable": "limit", + "operator": "equals", + "value": 12 + } + ] + } + ], + "passed": true + }, + { + "name": "not negates AND", + "checks": [ + { + "not": [ + { + "variable": "limit", + "operator": "greaterThan", + "value": 5 + }, + { + "variable": "limit", + "operator": "lessThan", + "value": 10 + } + ] + } + ], + "passed": true + }, + { + "name": "not all true fails", + "checks": [ + { + "not": [ + { + "variable": "limit", + "operator": "greaterThan", + "value": 5 + }, + { + "variable": "limit", + "operator": "lessThan", + "value": 20 + } + ] + } + ], + "passed": false + }, + { + "name": "not of or none match", + "checks": [ + { + "not": [ + { + "or": [ + { + "variable": "limit", + "operator": "lessThan", + "value": 5 + }, + { + "variable": "limit", + "operator": "greaterThan", + "value": 20 + } + ] + } + ] + } + ], + "passed": true + }, + { + "name": "outer array AND", + "checks": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "limit", + "operator": "lessThan", + "value": 10 + } + ], + "passed": false + }, + { + "name": "short circuit skips unresolved ref", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "absent", + "operator": "isNull" + } + ] + } + ], + "passed": true + }, + { + "name": "AND short circuit", + "checks": [ + { + "and": [ + { + "variable": "limit", + "operator": "equals", + "value": 13 + }, + { + "variable": "absent", + "operator": "isNull" + } + ] + } + ], + "passed": false + }, + { + "name": "missing global is error", + "checks": [ + { + "variable": "absent", + "operator": "isNull" + } + ], + "passed": false, + "error": true + }, + { + "name": "missing feature variable is error", + "checks": [ + { + "feature": "checkout", + "variable": "absent", + "operator": "isNull" + } + ], + "passed": false, + "error": true + }, + { + "name": "unresolved ref cannot invert", + "checks": [ + { + "not": [ + { + "variable": "absent", + "operator": "isNull" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "numeric type error cannot invert", + "checks": [ + { + "not": [ + { + "variable": "limit", + "operator": "greaterThan", + "compareTo": { + "variable": "label" + } + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "OR cannot hide error", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "greaterThan", + "compareTo": { + "variable": "label" + } + }, + { + "variable": "limit", + "operator": "equals", + "value": 12 + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "null numeric error", + "checks": [ + { + "variable": "nil", + "operator": "greaterThan", + "value": 1 + } + ], + "passed": false, + "error": true + }, + { + "name": "structured equality error", + "checks": [ + { + "variable": "settings", + "operator": "equals", + "compareTo": { + "variable": "settings" + } + } + ], + "passed": false, + "error": true + }, + { + "name": "regex reference error", + "checks": [ + { + "variable": "label", + "operator": "matches", + "compareTo": { + "variable": "badPattern" + } + } + ], + "passed": false, + "error": true + }, + { + "name": "empty checks", + "checks": [], + "passed": false, + "error": true + }, + { + "name": "empty group", + "checks": [ + { + "not": [] + } + ], + "passed": false, + "error": true + }, + { + "name": "mixed group", + "checks": [ + { + "and": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + } + ], + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "scenario needs variable", + "checks": [ + { + "operator": "equals", + "value": 12 + } + ], + "passed": false, + "error": true + }, + { + "name": "feature reference needs variable", + "checks": [ + { + "feature": "checkout", + "operator": "equals", + "value": 12 + } + ], + "passed": false, + "error": true + }, + { + "name": "unsafe path", + "checks": [ + { + "variable": "settings", + "path": "__proto__.x", + "operator": "exists" + } + ], + "passed": false, + "error": true + }, + { + "name": "wildcard path", + "checks": [ + { + "variable": "settings", + "path": "items.*.limit", + "operator": "exists" + } + ], + "passed": false, + "error": true + }, + { + "name": "negative path", + "checks": [ + { + "variable": "settings", + "path": ["items", -1], + "operator": "exists" + } + ], + "passed": false, + "error": true + }, + { + "name": "both operands", + "checks": [ + { + "variable": "limit", + "operator": "equals", + "value": 12, + "compareTo": { + "variable": "limit" + } + } + ], + "passed": false, + "error": true + }, + { + "name": "neither operand", + "checks": [ + { + "variable": "limit", + "operator": "equals" + } + ], + "passed": false, + "error": true + }, + { + "name": "unary operand", + "checks": [ + { + "variable": "limit", + "operator": "exists", + "value": 12 + } + ], + "passed": false, + "error": true + }, + { + "name": "invalid operator", + "checks": [ + { + "variable": "limit", + "operator": "futureOperator", + "value": 12 + } + ], + "passed": false, + "error": true + }, + { + "name": "unknown property", + "checks": [ + { + "variable": "limit", + "operator": "equals", + "value": 12, + "wat": true + } + ], + "passed": false, + "error": true + }, + { + "name": "bad literal regex", + "checks": [ + { + "variable": "label", + "operator": "matches", + "value": "[" + } + ], + "passed": false, + "error": true + }, + { + "name": "lookahead not portable", + "checks": [ + { + "variable": "label", + "operator": "matches", + "value": "(?=Hello)" + } + ], + "passed": false, + "error": true + }, + { + "name": "invalid flags", + "checks": [ + { + "variable": "label", + "operator": "matches", + "value": "Hello", + "regexFlags": "u" + } + ], + "passed": false, + "error": true + }, + { + "name": "flags nonregex", + "checks": [ + { + "variable": "limit", + "operator": "equals", + "value": 12, + "regexFlags": "i" + } + ], + "passed": false, + "error": true + }, + { + "name": "date check truncates 4 digits", + "checks": [ + { + "not": [ + { + "variable": "millisecondTimestamp", + "operator": "before", + "value": "2026-09-13T12:00:00.1234Z" + } + ] + }, + { + "not": [ + { + "variable": "millisecondTimestamp", + "operator": "after", + "value": "2026-09-13T12:00:00.1234Z" + } + ] + } + ], + "passed": true + }, + { + "name": "date check truncates 6 digits", + "checks": [ + { + "not": [ + { + "variable": "millisecondTimestamp", + "operator": "before", + "value": "2026-09-13T12:00:00.123456Z" + } + ] + }, + { + "not": [ + { + "variable": "millisecondTimestamp", + "operator": "after", + "value": "2026-09-13T12:00:00.123456Z" + } + ] + } + ], + "passed": true + }, + { + "name": "date check truncates 7 digits", + "checks": [ + { + "not": [ + { + "variable": "millisecondTimestamp", + "operator": "before", + "value": "2026-09-13T12:00:00.1234567Z" + } + ] + }, + { + "not": [ + { + "variable": "millisecondTimestamp", + "operator": "after", + "value": "2026-09-13T12:00:00.1234567Z" + } + ] + } + ], + "passed": true + }, + { + "name": "date check truncates 9 digits", + "checks": [ + { + "not": [ + { + "variable": "millisecondTimestamp", + "operator": "before", + "value": "2026-09-13T12:00:00.123456789Z" + } + ] + }, + { + "not": [ + { + "variable": "millisecondTimestamp", + "operator": "after", + "value": "2026-09-13T12:00:00.123456789Z" + } + ] + } + ], + "passed": true + }, + { + "name": "date check truncates resolved nanoseconds", + "checks": [ + { + "not": [ + { + "variable": "preciseTimestamp", + "operator": "after", + "compareTo": { + "variable": "millisecondTimestamp" + } + } + ] + }, + { + "variable": "preciseTimestamp", + "operator": "before", + "value": "2026-09-13T12:00:00.124Z" + } + ], + "passed": true + }, + { + "name": "portable leap day", + "checks": [ + { + "variable": "leapTimestamp", + "operator": "before", + "value": "2024-03-01T00:00:00Z" + } + ], + "passed": true + }, + { + "name": "timezone equivalent instants", + "checks": [ + { + "not": [ + { + "variable": "timestamp", + "operator": "before", + "compareTo": { + "variable": "equivalentTimestamp" + } + } + ] + } + ], + "passed": true + }, + { + "name": "portable milliseconds", + "checks": [ + { + "variable": "millisecondTimestamp", + "operator": "after", + "value": "2026-09-13T12:00:00.122Z" + } + ], + "passed": true + }, + { + "name": "minimum portable UTC instant", + "checks": [ + { + "not": [ + { + "variable": "timestamp", + "operator": "before", + "value": "0001-01-01T00:00:00Z" + } + ] + } + ], + "passed": true + }, + { + "name": "maximum portable UTC instant", + "checks": [ + { + "variable": "timestamp", + "operator": "before", + "value": "9999-12-31T23:59:59.999Z" + } + ], + "passed": true + }, + { + "name": "invalid calendar literal in skipped OR", + "checks": [ + { + "or": [ + { + "variable": "limit", + "operator": "equals", + "value": 12 + }, + { + "variable": "timestamp", + "operator": "before", + "value": "2026-02-30T12:00:00Z" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "invalid resolved calendar cannot invert", + "checks": [ + { + "not": [ + { + "variable": "invalidCalendarTimestamp", + "operator": "before", + "value": "2026-03-01T00:00:00Z" + } + ] + } + ], + "passed": false, + "error": true + }, + { + "name": "24 hour timestamp is invalid", + "checks": [ + { + "variable": "timestamp", + "operator": "before", + "value": "2026-09-13T24:00:00Z" + } + ], + "passed": false, + "error": true + }, + { + "name": "leap second timestamp is invalid", + "checks": [ + { + "variable": "timestamp", + "operator": "before", + "value": "2026-09-13T23:59:60Z" + } + ], + "passed": false, + "error": true + }, + { + "name": "year zero timestamp is invalid", + "checks": [ + { + "variable": "timestamp", + "operator": "after", + "value": "0000-12-31T23:59:59Z" + } + ], + "passed": false, + "error": true + }, + { + "name": "resolved excessive fractional precision is invalid", + "checks": [ + { + "variable": "timestamp", + "operator": "before", + "compareTo": { + "variable": "invalidFractionTimestamp" + } + } + ], + "passed": false, + "error": true + }, + { + "name": "resolved excessive offset is invalid", + "checks": [ + { + "variable": "invalidOffsetTimestamp", + "operator": "before", + "value": "2026-09-14T00:00:00Z" + } + ], + "passed": false, + "error": true + }, + { + "name": "UTC instant below year one is invalid", + "checks": [ + { + "variable": "timestamp", + "operator": "after", + "value": "0001-01-01T00:00:00+00:01" + } + ], + "passed": false, + "error": true + }, + { + "name": "UTC instant above year 9999 is invalid", + "checks": [ + { + "variable": "timestamp", + "operator": "before", + "value": "9999-12-31T23:59:59.999-00:01" + } + ], + "passed": false, + "error": true + } + ] +} diff --git a/featurevisor b/featurevisor index 1509655..6ddbb6c 100755 --- a/featurevisor +++ b/featurevisor @@ -2,104 +2,103 @@ ['pipe', 'w'], 2 => ['file', $stderrPath, 'w']], $pipes, $cwd); + if (!is_resource($process)) { + throw new RuntimeException('Could not start command'); + } + try { + $output = stream_get_contents($pipes[1]); + } finally { + fclose($pipes[1]); + $exitCode = proc_close($process); + } + $stderr = file_get_contents($stderrPath); + if ($output === false || $stderr === false) { + throw new RuntimeException('Could not read command output'); } + } finally { + unlink($stderrPath); } - return $values; -} - -$cwd = getcwd(); -$argv = $_SERVER['argv'] ?? []; - -$cliOptions = [ - '0' => $argv[1] ?? null, - 'assertionPattern' => parseCliOption($argv, 'assertionPattern'), - 'context' => parseCliOption($argv, 'context'), - 'environment' => parseCliOption($argv, 'environment'), - 'feature' => parseCliOption($argv, 'feature'), - 'keyPattern' => parseCliOption($argv, 'keyPattern'), - 'n' => parseCliOption($argv, 'n'), - 'onlyFailures' => parseCliOption($argv, 'onlyFailures'), - 'quiet' => parseCliOption($argv, 'quiet'), - 'showDatafile' => parseCliOption($argv, 'showDatafile'), - 'variable' => parseCliOption($argv, 'variable'), - 'variation' => parseCliOption($argv, 'variation'), - 'verbose' => parseCliOption($argv, 'verbose'), - 'inflate' => parseCliOption($argv, 'inflate'), - // Accepted for compatibility with older SDK runners, intentionally ignored in v3. - 'withScopes' => parseCliOption($argv, 'withScopes'), - 'withTags' => parseCliOption($argv, 'withTags'), - 'schemaVersion' => parseCliOption($argv, 'schemaVersion') ?? parseCliOption($argv, 'schema-version'), - 'rootDirectoryPath' => $cwd, - 'populateUuid' => array_reduce($argv, function($acc, $arg) { - if (strpos($arg, '--populateUuid=') === 0) { - $acc[] = substr($arg, strlen('--populateUuid=')); - } - return $acc; - }, []), - 'targets' => parseCliOptions($argv, 'target'), -]; - -if (parseCliOption($argv, 'rootDirectoryPath')) { - $cliOptions['rootDirectoryPath'] = parseCliOption($argv, 'rootDirectoryPath'); -} else if (parseCliOption($argv, 'projectDirectoryPath')) { - $cliOptions['rootDirectoryPath'] = parseCliOption($argv, 'projectDirectoryPath'); + if ($exitCode !== 0) { + throw new RuntimeException("Command failed with exit code $exitCode: " . trim($stderr . "\n" . $output)); + } + if ($stderr !== '') fwrite(STDERR, $stderr); + return $output; } -/** - * Common functions - */ -function executeCommand(string $command): string { - $output = []; - $exitCode = 0; - exec($command . ' 2>&1', $output, $exitCode); - if ($exitCode !== 0) { - throw new RuntimeException("Command failed with exit code $exitCode: " . implode("\n", $output)); +function cliProgress(string $message): void { + global $cliOptions; + if (($cliOptions['json'] ?? null) === true) { + fwrite(STDERR, $message.PHP_EOL); + } else { + echo $message.PHP_EOL; } - return implode("\n", $output); +} + +function validateInflateOption($value): ?int { + if ($value === null) return null; + $parsed = (is_string($value) || is_int($value)) ? filter_var($value, FILTER_VALIDATE_INT) : false; + if ($parsed === false || $parsed < 1) { + throw new InvalidArgumentException('--inflate must be a positive integer'); + } + return $parsed; } function getConfig(string $featurevisorProjectPath): array { - echo "Getting config..." . PHP_EOL; - $configOutput = executeCommand("(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor config --json)"); + cliProgress("Getting config..."); + $configOutput = executeCommand(nodeCommand($featurevisorProjectPath, ['config', '--json']), $featurevisorProjectPath); return json_decode($configOutput, true); } -function getSegments(string $featurevisorProjectPath): array { - echo "Getting segments..." . PHP_EOL; - $segmentsOutput = executeCommand("(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor list --segments --json)"); +function getSegments(string $featurevisorProjectPath, ?string $set = null): array { + cliProgress("Getting segments..."); + $segmentsOutput = executeCommand(nodeCommand($featurevisorProjectPath, array_merge(['list', '--segments', '--json'], setArguments($set))), $featurevisorProjectPath); $segments = json_decode($segmentsOutput, true); $segmentsByKey = []; foreach ($segments as $segment) { @@ -108,9 +107,9 @@ function getSegments(string $featurevisorProjectPath): array { return $segmentsByKey; } -function getTargets(string $featurevisorProjectPath): array { - echo "Getting targets..." . PHP_EOL; - $targetsOutput = executeCommand("(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor list --targets --json)"); +function getTargets(string $featurevisorProjectPath, ?string $set = null): array { + cliProgress("Getting targets..."); + $targetsOutput = executeCommand(nodeCommand($featurevisorProjectPath, array_merge(['list', '--targets', '--json'], setArguments($set))), $featurevisorProjectPath); $targets = json_decode($targetsOutput, true); if (!is_array($targets)) { @@ -136,34 +135,41 @@ function buildSingleDatafile( string $featurevisorProjectPath, $environment, ?string $target = null, - $inflate = null -): array { - $command = "(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor build --json"; + $inflate = null, + ?string $set = null +): string { + $inflate = validateInflateOption($inflate); + $arguments = ['build', '--json']; if ($environment !== false && $environment !== null && $environment !== '') { - $command .= " --environment=" . escapeshellarg($environment); + $arguments[] = '--environment='.$environment; } if ($target) { - $command .= " --target=" . escapeshellarg($target); + $arguments[] = '--target='.$target; } - if ($inflate !== null && (int) $inflate > 0) { - $command .= " --inflate=" . escapeshellarg((string) $inflate); + if ($inflate !== null) { + $arguments[] = '--inflate='.$inflate; } - $command .= ")"; - $output = executeCommand($command); + $output = executeCommand(nodeCommand($featurevisorProjectPath, array_merge($arguments, setArguments($set))), $featurevisorProjectPath); - $decoded = json_decode($output, true); + return validateDatafileJson($output); +} - return is_array($decoded) ? $decoded : []; +function validateDatafileJson(string $output): string { + $decoded = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + throw new RuntimeException('Featurevisor CLI returned an invalid datafile'); + } + return $output; } function getEnvironmentList(array $config): array { $environments = $config['environments'] ?? false; - if ($environments === false || $environments === null) { + if ($environments === false) { return [false]; } @@ -174,49 +180,66 @@ function getEnvironmentList(array $config): array { return [false]; } -function getBaseDatafileKey($environment) { - return ($environment === false || $environment === null || $environment === '') - ? false - : (string) $environment; +function getDatafileCacheKey($environment, ?string $target): string { + return json_encode([ + ($environment === false || $environment === null) ? null : (string) $environment, + $target, + ], JSON_THROW_ON_ERROR); } -function getTargetDatafileKey($environment, string $target): string { - $baseDatafileKey = getBaseDatafileKey($environment); - $environmentKey = $baseDatafileKey === false - ? 'false' - : (string) $baseDatafileKey; +function getBaseDatafileKey($environment): string { + return getDatafileCacheKey($environment, null); +} - return $environmentKey . '-target-' . $target; +function getTargetDatafileKey($environment, string $target): string { + return getDatafileCacheKey($environment, $target); } -function getDatafileForAssertion(array $assertion, array $datafilesByKey): ?array { +/** @return string|array|null */ +function getDatafileForAssertion(array $assertion, array $datafilesByKey) { $baseDatafileKey = getBaseDatafileKey($assertion['environment'] ?? false); $target = $assertion['target'] ?? null; if (is_string($target)) { - $targetDatafileKey = getTargetDatafileKey($baseDatafileKey, $target); + $targetDatafileKey = getTargetDatafileKey($assertion['environment'] ?? null, $target); return $datafilesByKey[$targetDatafileKey] ?? null; } return $datafilesByKey[$baseDatafileKey] ?? null; } -function buildDatafiles(string $featurevisorProjectPath, array $config, array $targets = []): array { +/** @param mixed $value @return mixed */ +function normalizeCliValue($value) { + return Helpers::objectValueToArray($value); +} + +/** @param string|array $datafile */ +function formatDatafile($datafile): string { + $value = is_string($datafile) + ? json_decode($datafile, false, 512, JSON_THROW_ON_ERROR) + : $datafile; + return json_encode($value, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); +} + +function buildDatafiles(string $featurevisorProjectPath, array $config, array $targets = [], ?string $set = null, ?int $inflate = null): array { $datafilesByKey = []; $environments = getEnvironmentList($config); foreach ($environments as $environment) { $baseDatafileKey = getBaseDatafileKey($environment); - echo "Building datafile for environment: " . ($baseDatafileKey === false ? 'default' : $baseDatafileKey) . "..." . PHP_EOL; - $datafilesByKey[$baseDatafileKey] = buildSingleDatafile($featurevisorProjectPath, $environment); + $environmentLabel = ($environment === false || $environment === null || $environment === '') ? 'default' : $environment; + cliProgress("Building datafile for environment: " . $environmentLabel . "..."); + $datafilesByKey[$baseDatafileKey] = buildSingleDatafile($featurevisorProjectPath, $environment, null, $inflate, $set); foreach ($targets as $target) { - echo "Building target datafile for environment: " . ($baseDatafileKey === false ? 'default' : $baseDatafileKey) . ", target: $target..." . PHP_EOL; - $datafilesByKey[getTargetDatafileKey($baseDatafileKey, $target)] = buildSingleDatafile( + cliProgress("Building target datafile for environment: " . $environmentLabel . ", target: $target..."); + $datafilesByKey[getTargetDatafileKey($environment, $target)] = buildSingleDatafile( $featurevisorProjectPath, $environment, - $target + $target, + $inflate, + $set ); } } @@ -250,53 +273,202 @@ function stringEndsWith(string $value, string $suffix): bool { return substr($value, -strlen($suffix)) === $suffix; } +// array_is_list() needs PHP 8.1, and the CLI supports PHP 8.0. +function arrayIsList(array $value): bool { + return $value === [] || array_keys($value) === range(0, count($value) - 1); +} + /** * Test */ -function getTests(string $featurevisorProjectPath, array $cliOptions): array { - $testsSuffix = ""; - if ($cliOptions['keyPattern']) { - $testsSuffix = " --keyPattern=" . $cliOptions['keyPattern']; +function getTests(string $featurevisorProjectPath, array $cliOptions, ?string $set = null, bool $includeCase = true): array { + $arguments = ['list', '--tests', '--execution-protocol', '--json']; + if ($cliOptions['keyPattern'] !== null) { + $arguments[] = '--keyPattern='.$cliOptions['keyPattern']; + } + if ($cliOptions['assertionPattern'] !== null) { + $arguments[] = '--assertionPattern='.$cliOptions['assertionPattern']; } - if ($cliOptions['assertionPattern']) { - $testsSuffix .= " --assertionPattern=" . $cliOptions['assertionPattern']; + if ($cliOptions['entityType']) { + $arguments[] = '--entityType='.$cliOptions['entityType']; } + if ($includeCase && $cliOptions['case'] !== null) $arguments[] = '--case='.$cliOptions['case']; - $testsOutput = executeCommand("(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor list --tests --applyMatrix --json" . $testsSuffix . ")"); - return json_decode($testsOutput, true); + $testsOutput = executeCommand(nodeCommand($featurevisorProjectPath, array_merge($arguments, setArguments($set))), $featurevisorProjectPath); + return decodeTestExecutionEnvelope($testsOutput); } -function testFeature(array $assertion, string $featureKey, $f, string $level): array { +function decodeTestExecutionEnvelope(string $output): array { + $payload = json_decode($output, false, 512, JSON_THROW_ON_ERROR); + if (!($payload instanceof stdClass)) { + throw new RuntimeException('Invalid test execution protocol: expected an envelope object'); + } + if (($payload->kind ?? null) !== 'featurevisor.test-execution') { + throw new RuntimeException('Invalid test execution protocol: kind must be "featurevisor.test-execution"'); + } + $version = $payload->schemaVersion ?? null; + if ((!is_int($version) && !is_float($version)) || $version != 1) { + throw new RuntimeException('Unsupported test execution protocol schemaVersion; expected numeric 1'); + } + if (!isset($payload->tests) || !is_array($payload->tests)) { + throw new RuntimeException('Invalid test execution protocol: tests must be an array of objects'); + } + foreach ($payload->tests as $test) { + if (!($test instanceof stdClass)) { + throw new RuntimeException('Invalid test execution protocol: tests must be an array of objects'); + } + } + $tests = decodeTestExecutionValue($payload->tests); + foreach ($payload->tests as $index => $test) { + if (property_exists($test, 'feature') && isset($test->assertions) && is_array($test->assertions)) { + $tests[$index]['assertions'] = array_map('decodeFeatureAssertion', $test->assertions); + } + } + return $tests; +} + +/** @param mixed $assertion @return mixed */ +function decodeFeatureAssertion($assertion) { + $decoded = decodeTestExecutionValue($assertion); + if (!$assertion instanceof stdClass) return $decoded; + if (isset($assertion->children) && is_array($assertion->children)) { + $decoded['children'] = array_map('decodeFeatureAssertion', $assertion->children); + } + return $decoded; +} + +/** @param mixed $value @return mixed */ +function decodeTestExecutionValue($value) { + if ($value instanceof stdClass) { + $result = []; + foreach (get_object_vars($value) as $key => $item) { + if ($key === 'value') { + $result[$key] = Helpers::copyValue($item); + } elseif ($key === 'context' || $key === 'stickyVariables') { + $result[$key] = runtimeMapToArray($item); + } elseif ($key === 'stickyFeatures') { + $result[$key] = decodeStickyFeatureStates($item); + } else { + $result[$key] = decodeTestExecutionValue($item); + } + } + return $result; + } + if (is_array($value)) return array_map('decodeTestExecutionValue', $value); + return $value; +} + +/** @param mixed $value @return mixed */ +function decodeStickyFeatureStates($value) { + if (!$value instanceof stdClass) return decodeTestExecutionValue($value); + $states = []; + foreach (get_object_vars($value) as $feature => $state) { + $states[$feature] = decodeTestExecutionValue($state); + if ($state instanceof stdClass && property_exists($state, 'variables')) { + $states[$feature]['variables'] = runtimeMapToArray($state->variables); + } + } + return $states; +} + +/** @param mixed $value @return mixed */ +function runtimeMapToArray($value) { + return $value instanceof stdClass ? get_object_vars($value) : $value; +} + +function testsContainCase(array $tests, string $caseId): bool { + foreach ($tests as $test) { + foreach (($test['assertions'] ?? []) as $assertion) { + if (($assertion['_case']['id'] ?? null) === $caseId) return true; + } + } + return false; +} + +/** @param list $sets @return list */ +function selectTestExecutions(string $projectPath, array $cliOptions, array $sets): array { + if (!$cliOptions['case'] || count($sets) === 1) { + return array_map(static fn(?string $set): array => [ + 'set' => $set, + 'tests' => getTests($projectPath, $cliOptions, $set), + ], $sets); + } + + foreach ($sets as $set) { + $tests = getTests($projectPath, $cliOptions, $set, false); + if (testsContainCase($tests, $cliOptions['case'])) { + return [['set' => $set, 'tests' => getTests($projectPath, $cliOptions, $set)]]; + } + } + + // Ask Node for the typed test_case_not_found failure after every set has been searched. + getTests($projectPath, $cliOptions, $sets[0]); + return []; +} + +/** @param mixed $expected @param mixed $actual @return array */ +function testFailure(string $type, array $path, $expected, $actual, array $details = [], ?string $message = null): array { + return [ + 'type' => $type, + 'message' => $message ?? "$type expectation did not match", + 'path' => $path, + 'expected' => $expected, + 'actual' => $actual, + 'details' => count($details) === 0 ? (object) [] : $details, + ]; +} + +/** @param mixed $value */ +function displayTestValue($value): string { + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); +} + +function testFeature(array $assertion, string $featureKey, $f, string $level, array $pathBase = ['assertions', 0], ?int $childIndex = null): array { $context = isset($assertion["context"]) ? $assertion["context"] : []; - $sticky = isset($assertion["sticky"]) ? $assertion["sticky"] : []; + $sticky = $assertion["stickyFeatures"] ?? []; // Update the SDK instance context and sticky values for this assertion $f->setContext($context, true); $f->setStickyFeatures($sticky, true); + $f->setStickyVariables($assertion['stickyVariables'] ?? [], true); - $hasError = false; - $errors = ""; + $errors = []; $startTime = microtime(true); + $childDetails = $childIndex === null ? [] : ['childIndex' => $childIndex]; // Test expectedToBeEnabled - if (isset($assertion["expectedToBeEnabled"])) { + if (array_key_exists("expectedToBeEnabled", $assertion)) { $isEnabled = $f->isEnabled($featureKey, $context); if ($isEnabled !== $assertion["expectedToBeEnabled"]) { - $hasError = true; - $errors .= " ✘ expectedToBeEnabled: expected " . json_encode($assertion["expectedToBeEnabled"]) . " but received " . json_encode($isEnabled) . PHP_EOL; + $expected = $assertion['expectedToBeEnabled']; + $errors[] = testFailure( + 'flag', + array_merge($pathBase, ['expectedToBeEnabled']), + $expected, + $isEnabled, + $childDetails, + 'expectedToBeEnabled: expected '.displayTestValue($expected).' but received '.displayTestValue($isEnabled) + ); } } // Test expectedVariation - if (isset($assertion["expectedVariation"])) { + if (array_key_exists("expectedVariation", $assertion)) { $variationOptions = []; if (array_key_exists("defaultVariationValue", $assertion)) { $variationOptions["defaultVariationValue"] = $assertion["defaultVariationValue"]; } $variation = $f->getVariation($featureKey, $context, $variationOptions); if ($variation !== $assertion["expectedVariation"]) { - $hasError = true; - $errors .= " ✘ expectedVariation: expected " . json_encode($assertion["expectedVariation"]) . " but received " . json_encode($variation) . PHP_EOL; + $expected = $assertion['expectedVariation']; + $errors[] = testFailure( + 'variation', + array_merge($pathBase, ['expectedVariation']), + $expected, + $variation, + $childDetails, + 'expectedVariation: expected '.displayTestValue($expected).' but received '.displayTestValue($variation) + ); } } @@ -314,9 +486,16 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a $variableOptions["defaultVariableValue"] = $assertion["defaultVariableValues"][$variableKey]; } $actualValue = $f->getVariable($featureKey, $variableKey, $context, $variableOptions); + $actualValue = normalizeCliValue($actualValue); if ($actualValue !== $expectedValue) { - $hasError = true; - $errors .= " ✘ expectedVariables.$variableKey: expected " . json_encode($expectedValue) . " but received " . json_encode($actualValue) . PHP_EOL; + $errors[] = testFailure( + 'variable', + array_merge($pathBase, ['expectedVariables', $variableKey]), + $expectedValue, + $actualValue, + array_merge($childDetails, ['variableKey' => $variableKey]), + "expectedVariables.$variableKey: expected ".displayTestValue($expectedValue).' but received '.displayTestValue($actualValue) + ); } } } @@ -328,9 +507,16 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a if (isset($expectedEvaluations["flag"])) { $actualEvaluation = $f->evaluateFlag($featureKey, $context); foreach ($expectedEvaluations["flag"] as $key => $expectedValue) { - if ($actualEvaluation[$key] !== $expectedValue) { - $hasError = true; - $errors .= " ✘ expectedEvaluations.flag.$key: expected " . json_encode($expectedValue) . " but received " . json_encode($actualEvaluation[$key] ?? null) . PHP_EOL; + $actualValue = normalizeCliValue($actualEvaluation[$key] ?? null); + if ($actualValue !== $expectedValue) { + $errors[] = testFailure( + 'evaluation', + array_merge($pathBase, ['expectedEvaluations', 'flag', $key]), + $expectedValue, + $actualValue, + array_merge($childDetails, ['evaluationType' => 'flag', 'evaluationKey' => $key]), + "expectedEvaluations.flag.$key: expected ".displayTestValue($expectedValue).' but received '.displayTestValue($actualValue) + ); } } } @@ -342,9 +528,16 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a } $actualEvaluation = $f->evaluateVariation($featureKey, $context, $variationOptions); foreach ($expectedEvaluations["variation"] as $key => $expectedValue) { - if ($actualEvaluation[$key] !== $expectedValue) { - $hasError = true; - $errors .= " ✘ expectedEvaluations.variation.$key: expected " . json_encode($expectedValue) . " but received " . json_encode($actualEvaluation[$key] ?? null) . PHP_EOL; + $actualValue = normalizeCliValue($actualEvaluation[$key] ?? null); + if ($actualValue !== $expectedValue) { + $errors[] = testFailure( + 'evaluation', + array_merge($pathBase, ['expectedEvaluations', 'variation', $key]), + $expectedValue, + $actualValue, + array_merge($childDetails, ['evaluationType' => 'variation', 'evaluationKey' => $key]), + "expectedEvaluations.variation.$key: expected ".displayTestValue($expectedValue).' but received '.displayTestValue($actualValue) + ); } } } @@ -357,9 +550,16 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a } $actualEvaluation = $f->evaluateVariable($featureKey, $variableKey, $context, $variableOptions); foreach ($expectedEvaluation as $key => $expectedValue) { - if ($actualEvaluation[$key] !== $expectedValue) { - $hasError = true; - $errors .= " ✘ expectedEvaluations.variables.$variableKey.$key: expected " . json_encode($expectedValue) . " but received " . json_encode($actualEvaluation[$key] ?? null) . PHP_EOL; + $actualValue = normalizeCliValue($actualEvaluation[$key] ?? null); + if ($actualValue !== $expectedValue) { + $errors[] = testFailure( + 'evaluation', + array_merge($pathBase, ['expectedEvaluations', 'variables', $variableKey, $key]), + $expectedValue, + $actualValue, + array_merge($childDetails, ['variableKey' => $variableKey, 'evaluationType' => 'variable', 'evaluationKey' => $key]), + "expectedEvaluations.variables.$variableKey.$key: expected ".displayTestValue($expectedValue).' but received '.displayTestValue($actualValue) + ); } } } @@ -370,249 +570,653 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a // children if (isset($assertion["children"])) { - foreach ($assertion["children"] as $child) { + foreach ($assertion["children"] as $index => $child) { $childF = $f->spawn($child["context"] ?? []); try { - $childResult = testFeature($child, $featureKey, $childF, $level); + $childResult = testFeature($child, $featureKey, $childF, $level, array_merge($pathBase, ['children', $index]), $index); } finally { $childF->close(); } $duration += $childResult['duration']; - $hasError = $hasError || $childResult['hasError']; - - if ($childResult['hasError']) { - $errors .= $childResult['errors']; - } + $errors = array_merge($errors, $childResult['errors']); } } return [ - 'hasError' => $hasError, + 'hasError' => count($errors) > 0, 'errors' => $errors, 'duration' => $duration ]; } -function testSegment(array $assertion, array $segment): array { - $context = isset($assertion["context"]) ? $assertion["context"] : []; - $conditions = $segment["conditions"]; - - $hasError = false; - $errors = ""; - $startTime = microtime(true); +function testSegment(array $assertion, array $segment, array $pathBase = ['assertions', 0]): array { + $segments = ['subject' => $segment]; + $datafile = Segments::prepare(['schemaVersion' => '4', 'revision' => 'segment-test', 'features' => [], 'segments' => []], $segments); + $diagnostics = []; + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'context' => $assertion['context'] ?? [], 'logLevel' => 'fatal']); + try { return testSegmentWithSdk($assertion, 'subject', $f, $datafile, $diagnostics, $pathBase, $segments); } + finally { $f->close(); } +} - if (isset($assertion["expectedToMatch"])) { - $actual = Conditions::allConditionsAreMatched( - $conditions, - $context, - static fn(string $pattern, string $flags): string => '~'.str_replace('~', '\\~', $pattern).'~'.str_replace(['g', 'y'], '', $flags) - ); - if ($actual !== $assertion["expectedToMatch"]) { - $hasError = true; - $errors .= " ✘ expectedToMatch: expected " . json_encode($assertion["expectedToMatch"]) . " but received " . json_encode($actual) . PHP_EOL; +function testSegmentWithSdk(array $assertion, string $segment, $f, array $datafile, array &$diagnostics, array $pathBase, array $segments): array { + $start = microtime(true); + $errors = []; + foreach (array_merge([$assertion], $assertion['children'] ?? []) as $index => $current) { + $evaluator = $index === 0 ? $f : $f->spawn($current['context'] ?? [], [ + 'stickyFeatures' => $current['stickyFeatures'] ?? [], + 'stickyVariables' => $current['stickyVariables'] ?? [], + ]); + $base = $index === 0 ? $pathBase : array_merge($pathBase, ['children', $index - 1]); + try { + $evaluation = $evaluator->evaluateFlag($datafile['_segmentTestKeys'][$segment]); + if (($evaluation['reason'] ?? null) === 'error') throw new RuntimeException('segment evaluation failed'); + $actual = ($evaluation['enabled'] ?? false) === true; + if (array_key_exists('expectedToMatch', $current) && $actual !== $current['expectedToMatch']) { + $errors[] = testFailure('segment', array_merge($base, ['expectedToMatch']), $current['expectedToMatch'], $actual); + } + if (array_key_exists('checks', $current)) { + $checks = TestChecks::evaluateResult($evaluator, $datafile, $current['checks'], null, $diagnostics, + 'checks', array_merge($base, ['checks']), $segments, $evaluator->getContext()); + $errors = array_merge($errors, $checks['errors']); + } + } catch (Throwable $error) { + $errors[] = testFailure('segment', $base, 'successful evaluation', $error->getMessage(), [], $error->getMessage()); + } finally { + if ($index !== 0) $evaluator->close(); } } + return ['hasError' => $errors !== [], 'errors' => $errors, 'duration' => (microtime(true) - $start) * 1000]; +} - $duration = (microtime(true) - $startTime) * 1000; - return [ - 'hasError' => $hasError, - 'errors' => $errors, - 'duration' => $duration - ]; +function testValuesEqual($left, $right): bool { + if ($left instanceof stdClass) $left = get_object_vars($left); + if ($right instanceof stdClass) $right = get_object_vars($right); + if (is_array($left) && is_array($right)) { + if (count($left) !== count($right)) return false; + $leftList = $left === [] || array_keys($left) === range(0, count($left) - 1); + $rightList = $right === [] || array_keys($right) === range(0, count($right) - 1); + if ($leftList !== $rightList) return false; + foreach ($left as $key => $value) { + if (!array_key_exists($key, $right) || !testValuesEqual($value, $right[$key])) return false; + } + return true; + } + if ((is_int($left) || is_float($left)) && (is_int($right) || is_float($right))) return (float) $left === (float) $right; + return $left === $right; } -function testVariable(array $assertion, string $variableKey, Featurevisor $f): array { +function testVariableExpectation(array $assertion, ?string $variableKey, $f, $datafile, array &$diagnostics, array $pathBase, string $checkPrefix, string $checkLabel, ?int $childIndex = null, array $segments = [], array $context = []): array { $options = []; if (array_key_exists('defaultVariableValue', $assertion)) $options['defaultVariableValue'] = $assertion['defaultVariableValue']; - $hasError = false; - $errors = ''; - $started = microtime(true); - $evaluation = $f->evaluateVariable($variableKey, [], $options); - if (array_key_exists('expectedValue', $assertion)) { - $actual = $evaluation['variableValue'] ?? null; - if ($actual !== $assertion['expectedValue']) { - $hasError = true; - $errors .= ' ✘ expectedValue: expected '.json_encode($assertion['expectedValue']).' but received '.json_encode($actual).PHP_EOL; + $errors = []; + $details = $childIndex === null ? [] : ['childIndex' => $childIndex]; + $needsEvaluation = $variableKey !== null && (array_key_exists('expectedValue', $assertion) || isset($assertion['expectedEvaluation'])); + $evaluation = $needsEvaluation ? $f->evaluateVariable($variableKey, [], $options) : null; + if ($variableKey !== null && array_key_exists('expectedValue', $assertion)) { + $present = is_array($evaluation) && array_key_exists('variableValue', $evaluation); + $actual = $present ? normalizeCliValue($evaluation['variableValue']) : ['$type' => 'missing']; + $expected = $assertion['expectedValue']; + if (!$present || !testValuesEqual($actual, $expected)) { + $errorDetails = array_merge($details, ['variableKey' => $variableKey]); + if (!$present) $errorDetails['actualMissing'] = true; + $errors[] = testFailure( + 'variable', + array_merge($pathBase, ['expectedValue']), + $expected, + $actual, + $errorDetails, + 'expectedValue: expected '.displayTestValue($expected).' but received '.displayTestValue($actual) + ); } } - if (isset($assertion['expectedEvaluation'])) { + if ($variableKey !== null && isset($assertion['expectedEvaluation'])) { foreach ($assertion['expectedEvaluation'] as $key => $expected) { - $actual = $evaluation[$key] ?? null; - if ($actual !== $expected) { - $hasError = true; - $errors .= " ✘ expectedEvaluation.$key: expected ".json_encode($expected).' but received '.json_encode($actual).PHP_EOL; + $present = is_array($evaluation) && array_key_exists($key, $evaluation); + $actual = $present ? normalizeCliValue($evaluation[$key]) : ['$type' => 'missing']; + if (!$present || !testValuesEqual($actual, $expected)) { + $errorDetails = array_merge($details, ['evaluationType' => 'variable', 'evaluationKey' => $key]); + if (!$present) $errorDetails['actualMissing'] = true; + $errors[] = testFailure( + 'evaluation', + array_merge($pathBase, ['expectedEvaluation', $key]), + $expected, + $actual, + $errorDetails, + "expectedEvaluation.$key: expected ".displayTestValue($expected).' but received '.displayTestValue($actual) + ); } } } + if (array_key_exists('checks', $assertion)) { + $checkResult = TestChecks::evaluateResult( + $f, + $datafile, + $assertion['checks'], + $variableKey, + $diagnostics, + $checkPrefix, + array_merge($pathBase, [$checkLabel]), + $segments, + $context + ); + $errors = array_merge($errors, $checkResult['errors']); + } + return $errors; +} + +function testVariable(array $assertion, string $variableKey, Featurevisor $f, $datafile, array &$diagnostics, array $pathBase = ['assertions', 0], array $segments = []): array { + $started = microtime(true); + $rootContext = is_array($assertion['context'] ?? null) ? $assertion['context'] : []; + $errors = testVariableExpectation($assertion, $variableKey, $f, $datafile, $diagnostics, $pathBase, 'checks', 'checks', null, $segments, $rootContext); foreach (($assertion['children'] ?? []) as $childIndex => $childAssertion) { $child = $f->spawn($childAssertion['context'] ?? [], [ 'stickyFeatures' => $childAssertion['stickyFeatures'] ?? [], 'stickyVariables' => $childAssertion['stickyVariables'] ?? [], ]); try { - $childOptions = []; - if (array_key_exists('defaultVariableValue', $childAssertion)) { - $childOptions['defaultVariableValue'] = $childAssertion['defaultVariableValue']; - } - $childEvaluation = $child->evaluateVariable($variableKey, [], $childOptions); $prefix = "children[$childIndex]."; - if (array_key_exists('expectedValue', $childAssertion)) { - $actual = $childEvaluation['variableValue'] ?? null; - if ($actual !== $childAssertion['expectedValue']) { - $hasError = true; - $errors .= " ✘ {$prefix}expectedValue: expected ".json_encode($childAssertion['expectedValue']).' but received '.json_encode($actual).PHP_EOL; - } - } - foreach (($childAssertion['expectedEvaluation'] ?? []) as $key => $expected) { - $actual = $childEvaluation[$key] ?? null; - if ($actual !== $expected) { - $hasError = true; - $errors .= " ✘ {$prefix}expectedEvaluation.$key: expected ".json_encode($expected).' but received '.json_encode($actual).PHP_EOL; - } - } + $errors = array_merge($errors, testVariableExpectation( + $childAssertion, + $variableKey, + $child, + $datafile, + $diagnostics, + array_merge($pathBase, ['children', $childIndex]), + $prefix.'checks', + 'checks', + $childIndex, + $segments, + array_merge($rootContext, is_array($childAssertion['context'] ?? null) ? $childAssertion['context'] : []) + )); } finally { $child->close(); } } - return ['hasError' => $hasError, 'errors' => $errors, 'duration' => microtime(true) - $started]; + return ['hasError' => count($errors) > 0, 'errors' => $errors, 'duration' => microtime(true) - $started]; } -function test(array $cliOptions) { - $featurevisorProjectPath = $cliOptions['rootDirectoryPath']; +function testScenario(array $assertion, Featurevisor $f, $datafile, array &$diagnostics, array $segments = []): array { + $started = microtime(true); + $errors = []; + $rootContext = is_array($assertion['context'] ?? null) ? $assertion['context'] : []; + if (!empty($assertion['__missingAssertions'])) { + $errors[] = testFailure('check', [], 'at least one assertion', [], [], 'scenario spec requires assertions'); + } elseif (array_key_exists('checks', $assertion)) { + $checkResult = TestChecks::evaluateResult($f, $datafile, $assertion['checks'], null, $diagnostics, 'checks', ['checks'], $segments, $rootContext); + $errors = array_merge($errors, $checkResult['errors']); + } elseif (count($assertion['children'] ?? []) === 0) { + $errors[] = testFailure('check', ['checks'], 'checks or children', ['$type' => 'missing'], ['actualMissing' => true], 'scenario assertion requires checks or children'); + } + foreach (($assertion['children'] ?? []) as $childIndex => $childAssertion) { + $child = $f->spawn($childAssertion['context'] ?? [], [ + 'stickyFeatures' => $childAssertion['stickyFeatures'] ?? [], + 'stickyVariables' => $childAssertion['stickyVariables'] ?? [], + ]); + try { + $checkResult = TestChecks::evaluateResult( + $child, + $datafile, + $childAssertion['checks'] ?? null, + null, + $diagnostics, + "children[$childIndex].checks", + ['children', $childIndex, 'checks'], + $segments, + array_merge($rootContext, is_array($childAssertion['context'] ?? null) ? $childAssertion['context'] : []) + ); + $errors = array_merge($errors, $checkResult['errors']); + } finally { + $child->close(); + } + } + return ['hasError' => count($errors) > 0, 'errors' => $errors, 'duration' => microtime(true) - $started]; +} - $config = getConfig($featurevisorProjectPath); - $segmentsByKey = getSegments($featurevisorProjectPath); - $availableTargets = getTargets($featurevisorProjectPath); - $targets = count($cliOptions['targets']) > 0 ? $cliOptions['targets'] : $availableTargets; - $datafilesByKey = buildDatafiles($featurevisorProjectPath, $config, $targets); +function emptyTestReport(): array { + return [ + 'schemaVersion' => 1, + 'runner' => 'php', + 'passed' => true, + 'summary' => [ + 'specs' => ['passed' => 0, 'failed' => 0], + 'assertions' => ['passed' => 0, 'failed' => 0], + 'entitySpecs' => ['passed' => 0, 'failed' => 0], + 'entityAssertions' => ['passed' => 0, 'failed' => 0], + 'scenarioSpecs' => ['passed' => 0, 'failed' => 0], + 'scenarioCases' => ['passed' => 0, 'failed' => 0], + ], + 'results' => [], + 'errors' => [], + ]; +} - echo PHP_EOL; +function selectedTestSets(string $projectPath, array $config, ?string $selected): array { + if ($selected !== null) return [$selected]; + if (empty($config['sets'])) return [null]; + $directory = $config['setsDirectoryPath'] ?? 'sets'; + $absolute = strpos($directory, DIRECTORY_SEPARATOR) === 0 || preg_match('/^[A-Za-z]:[\\\\\/]/', $directory) === 1; + $path = $absolute ? $directory : $projectPath.DIRECTORY_SEPARATOR.$directory; + $entries = array_values(array_filter(scandir($path) ?: [], static function (string $entry) use ($path): bool { + return $entry !== '.' && $entry !== '..' && is_dir($path.DIRECTORY_SEPARATOR.$entry); + })); + sort($entries, SORT_STRING); + if (count($entries) === 0) throw new RuntimeException("No sets found in $directory."); + return $entries; +} - $level = getDiagnosticLevel($cliOptions); - $tests = getTests($featurevisorProjectPath, $cliOptions); +function recordCoverageSelection(array $evaluation, array $assertion, ?string $set, array &$selections): void { + $ruleKey = is_string($evaluation['ruleKey'] ?? null) ? $evaluation['ruleKey'] : null; + $paths = $evaluation['variableOverridePaths'] ?? []; + if (count($paths) === 0 && array_key_exists('variableOverridePath', $evaluation)) $paths = [$evaluation['variableOverridePath']]; + if (count($paths) === 0 && is_string($evaluation['variableOverrideKey'] ?? null)) $paths = [[$evaluation['variableOverrideKey']]]; + if (count($paths) === 0 && array_key_exists('variableOverrideIndex', $evaluation)) $paths = [null]; + if (count($paths) === 0 && $ruleKey !== null) $paths = [null]; + foreach ($paths as $path) { + if ($ruleKey === null && $path === null && !array_key_exists('variableOverrideIndex', $evaluation)) continue; + $item = [ + 'set' => $set, + 'environment' => is_string($assertion['environment'] ?? null) ? $assertion['environment'] : null, + 'target' => is_string($assertion['target'] ?? null) ? $assertion['target'] : null, + ]; + foreach (['featureKey', 'variableKey', 'ruleKey'] as $key) { + if (is_string($evaluation[$key] ?? null)) $item[$key] = $evaluation[$key]; + } + if ($path !== null) $item['overridePath'] = $path; + if ($path === null && array_key_exists('variableOverrideIndex', $evaluation)) $item['overrideIndex'] = $evaluation['variableOverrideIndex']; + if (isset($evaluation['variation']['value'])) $item['variation'] = $evaluation['variation']['value']; + $selections[] = $item; + } +} - if (count($tests) === 0) { - echo "No tests found" . PHP_EOL; - return; +function coverageSelectionKey(array $selection): string { + return json_encode([ + $selection['set'], + $selection['environment'], + $selection['target'], + $selection['featureKey'] ?? null, + $selection['variableKey'] ?? null, + $selection['ruleKey'] ?? null, + $selection['variation'] ?? null, + $selection['overridePath'] ?? null, + $selection['overrideIndex'] ?? null, + ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); +} + +function executeTestAssertion(array $test, array $assertion, array $datafiles, array $segments, array $cliOptions, ?string $set, array &$coverageSelections): array { + $discriminators = array_values(array_filter(['feature', 'variable', 'segment', 'scenario'], static fn(string $key): bool => array_key_exists($key, $test))); + $type = count($discriminators) === 1 ? $discriminators[0] : 'test'; + $assertionIndex = (int) ($assertion['_case']['assertionIndex'] ?? 0); + $pathBase = $type === 'scenario' ? [] : ['assertions', $assertionIndex]; + if (!empty($assertion['__invalidDiscriminator'])) { + $actual = []; + foreach (['feature', 'variable', 'segment', 'scenario'] as $key) $actual[$key] = array_key_exists($key, $test); + return [ + 'hasError' => true, + 'errors' => [testFailure('check', [], 'exactly one discriminator', $actual, [], 'test spec requires exactly one of feature, variable, segment or scenario')], + 'duration' => 0, + ]; + } + $datafile = getDatafileForAssertion($assertion, $datafiles); + if (!$datafile && $type === 'segment' && !isset($assertion['environment']) && !isset($assertion['target'])) { + $datafile = ['schemaVersion' => '4', 'revision' => 'segment-test', 'segments' => [], 'features' => [], 'variables' => []]; + } + if (!$datafile) { + $errorType = $type === 'feature' ? 'flag' : ($type === 'variable' ? 'variable' : 'check'); + return [ + 'hasError' => true, + 'errors' => [testFailure($errorType, $pathBase, 'datafile', ['$type' => 'missing'], ['actualMissing' => true], 'no datafile found for assertion target/environment combination')], + 'duration' => 0, + ]; + } + $authoredSegments = is_array($test['_segments'] ?? null) ? $test['_segments'] : []; + if ($type === 'segment' && !isset($authoredSegments[$test['segment']]) && isset($segments[$test['segment']])) { + $authoredSegments[$test['segment']] = $segments[$test['segment']]; } + $datafile = Segments::prepare($datafile, $authoredSegments); + try { + if ($type === 'segment') Segments::preflight($test['segment'], $datafile, $authoredSegments); + preflightTestAssertion($test, $assertion, $datafile, $authoredSegments); + } catch (Throwable $error) { + $path = $error instanceof \FeaturevisorCLI\CheckPreflightError ? array_merge($pathBase, $error->path) : $pathBase; + $expected = $error instanceof \FeaturevisorCLI\CheckPreflightError ? $error->expected : 'valid references'; + return [ + 'hasError' => true, + 'errors' => [testFailure('check', $path, $expected, $error->getMessage(), [], $error->getMessage())], + 'duration' => 0, + ]; + } + if ($cliOptions['showDatafile'] === true) cliProgress(formatDatafile($datafile)); + $diagnostics = []; + $module = [ + 'name' => 'test-module', + 'bucketValue' => static function ($options) use ($assertion) { + return isset($assertion['at']) ? $assertion['at'] * 1000 : $options['bucketValue']; + }, + ]; + if ($cliOptions['coverage'] === true) { + $module['afterEvaluation'] = static function (array $evaluation, array $options) use ($assertion, $set, $datafile, &$coverageSelections): array { + if (!in_array($evaluation['featureKey'] ?? null, $datafile['_segmentTestKeys'] ?? [], true)) recordCoverageSelection($evaluation, $assertion, $set, $coverageSelections); + return $evaluation; + }; + } + $f = Featurevisor::createFeaturevisor([ + 'datafile' => $datafile, + 'logLevel' => (array_key_exists('checks', $assertion) || count($assertion['children'] ?? []) > 0) ? 'error' : getDiagnosticLevel($cliOptions), + 'context' => $assertion['context'] ?? [], + 'stickyFeatures' => $assertion['stickyFeatures'] ?? [], + 'stickyVariables' => $assertion['stickyVariables'] ?? [], + 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { $diagnostics[] = $diagnostic; }, + 'modules' => [$module], + ]); + try { + if ($type === 'segment') return testSegmentWithSdk($assertion, $test['segment'], $f, $datafile, $diagnostics, $pathBase, $authoredSegments); + if (isset($test['feature'])) return testFeature($assertion, $test['feature'], $f, getDiagnosticLevel($cliOptions), $pathBase); + if (isset($test['variable'])) return testVariable($assertion, $test['variable'], $f, $datafile, $diagnostics, $pathBase, $authoredSegments); + return testScenario($assertion, $f, $datafile, $diagnostics, $authoredSegments); + } finally { + $f->close(); + } +} + +function preflightTestAssertion(array $test, array $assertion, $datafile, array $segments): void { + $datafile = is_string($datafile) ? json_decode($datafile, true, 512, JSON_THROW_ON_ERROR) : $datafile; + if (!is_array($datafile)) throw new RuntimeException('selected datafile must be an object'); + $children = $assertion['children'] ?? []; + if (!is_array($children) || !arrayIsList($children)) throw new RuntimeException('children must be an array'); + foreach ($children as $index => $child) { + if (!is_array($child) || arrayIsList($child)) throw new RuntimeException("children[$index] must be an object"); + } + if (isset($test['feature'])) { + $feature = $datafile['features'][$test['feature']] ?? null; + $variables = $feature['variablesSchema'] ?? []; + foreach (array_merge([$assertion], $children) as $current) { + $expectedVariables = $current['expectedVariables'] ?? []; + if (!is_array($expectedVariables) || arrayIsList($expectedVariables) && count($expectedVariables) > 0) { + throw new RuntimeException('expectedVariables must be an object'); + } + foreach (array_keys($expectedVariables) as $variable) { + if (!array_key_exists($variable, $variables)) { + throw new RuntimeException('variable "'.$variable.'" is absent from feature "'.$test['feature'].'" in the selected datafile'); + } + } + } + } + $owner = $test['variable'] ?? null; + if (array_key_exists('checks', $assertion)) TestChecks::preflight($datafile, $assertion['checks'], $owner, $segments); + foreach ($children as $index => $child) { + if (array_key_exists('checks', $child) || isset($test['scenario'])) { + TestChecks::preflight($datafile, $child['checks'] ?? null, $owner, $segments, ['children', $index, 'checks']); + } + } +} - $passedTestsCount = 0; - $failedTestsCount = 0; - $passedAssertionsCount = 0; - $failedAssertionsCount = 0; +function testCaseReport(array $assertion, array $testResult, ?string $set, array $selectedTargets): array { + global $cliOptions; + $metadata = $assertion['_case'] ?? []; + $args = ['test']; + if (array_key_exists('id', $metadata)) $args[] = '--case='.$metadata['id']; + if ($cliOptions['projectDirectoryPath'] !== null) $args[] = '--projectDirectoryPath='.$cliOptions['projectDirectoryPath']; + if ($set !== null) $args[] = '--set='.$set; + foreach ($selectedTargets as $target) $args[] = '--target='.$target; + return [ + 'id' => $metadata['id'] ?? null, + 'description' => $assertion['description'] ?? null, + 'environment' => $assertion['environment'] ?? null, + 'target' => $assertion['target'] ?? null, + 'context' => count($assertion['context'] ?? []) === 0 ? (object) [] : $assertion['context'], + 'matrixValues' => count($metadata['matrixValues'] ?? []) === 0 ? (object) [] : $metadata['matrixValues'], + 'passed' => !$testResult['hasError'], + 'errors' => $testResult['errors'], + 'rerun' => [ + 'command' => escapeshellarg(PHP_BINARY).' '.escapeshellarg(__FILE__), + 'args' => $args, + ], + ]; +} +function runTestSet(string $projectPath, array $config, array $cliOptions, ?string $set, array $tests, array &$coverageSelections): array { + $segments = getSegments($projectPath, $set); + $availableTargets = getTargets($projectPath, $set); + foreach ($cliOptions['targets'] as $target) { + if (!in_array($target, $availableTargets, true)) { + throw new RuntimeException('Unknown target "'.$target.'". Available targets: '.(count($availableTargets) ? implode(', ', $availableTargets) : 'none').'.'); + } + } + $targetsToBuild = count($cliOptions['targets']) > 0 ? $cliOptions['targets'] : $availableTargets; + $datafiles = buildDatafiles($projectPath, $config, $targetsToBuild, $set, $cliOptions['inflate'] ?? null); + $results = []; foreach ($tests as $test) { - $testKey = $test['key']; - $assertions = $test["assertions"]; - if ((isset($test['feature']) || isset($test['variable'])) && count($cliOptions['targets']) > 0) { - $assertions = array_values(array_filter($assertions, function ($assertion) use ($cliOptions) { + $discriminators = array_values(array_filter(['feature', 'variable', 'segment', 'scenario'], static fn($key) => array_key_exists($key, $test))); + $type = $discriminators[0] ?? 'test'; + $invalid = count($discriminators) !== 1; + $assertions = $test['assertions'] ?? []; + if ($invalid) $assertions = [['description' => 'malformed test spec', '__invalidDiscriminator' => true]]; + elseif ($type === 'scenario' && count($assertions) === 0) $assertions = [['description' => 'malformed scenario spec', '__missingAssertions' => true]]; + if (!$invalid && in_array($type, ['feature', 'variable', 'scenario'], true) && count($cliOptions['targets']) > 0) { + $assertions = array_values(array_filter($assertions, static function (array $assertion) use ($cliOptions): bool { return !isset($assertion['target']) || in_array($assertion['target'], $cliOptions['targets'], true); })); - if (count($assertions) === 0) { - continue; - } + if (count($assertions) === 0) continue; } - $results = ""; - $testHasError = false; - $testDuration = 0; - + $metadata = $test['_test'] ?? []; + $resultSet = array_key_exists('set', $metadata) ? $metadata['set'] : $set; + $result = [ + 'type' => $type, + 'key' => $test[$type] ?? ($test['key'] ?? 'malformed'), + 'source' => $metadata['source'] ?? ($test['key'] ?? null), + 'set' => $resultSet, + 'passed' => true, + 'cases' => [], + ]; foreach ($assertions as $assertion) { - $testResult = []; - - if (isset($test["feature"]) || isset($test['variable'])) { - $environment = $assertion["environment"] ?? null; - $datafile = getDatafileForAssertion($assertion, $datafilesByKey); - - if (!$datafile) { - $testResult = [ - 'hasError' => true, - 'errors' => " ✘ missing datafile for environment: " . json_encode($environment) . PHP_EOL, - 'duration' => 0 - ]; - } else { - if ($cliOptions['showDatafile'] === true) { - echo PHP_EOL.json_encode($datafile, JSON_PRETTY_PRINT).PHP_EOL.PHP_EOL; - } - $f = Featurevisor::createFeaturevisor([ - 'datafile' => $datafile, - 'logLevel' => $level, - 'context' => $assertion['context'] ?? [], - 'stickyFeatures' => $assertion['stickyFeatures'] ?? ($assertion['sticky'] ?? []), - 'stickyVariables' => $assertion['stickyVariables'] ?? [], - 'modules' => [ - [ - 'name' => 'test-module', - 'bucketValue' => function ($options) use ($assertion) { - if (isset($assertion["at"])) { - return $assertion["at"] * 1000; - } - return $options["bucketValue"]; - } - ] - ] - ]); - - try { - $testResult = isset($test['feature']) - ? testFeature($assertion, $test["feature"], $f, $level) - : testVariable($assertion, $test['variable'], $f); - } finally { - $f->close(); - } - } - } else if (isset($test["segment"])) { - $testResult = testSegment($assertion, $segmentsByKey[$test["segment"]]); - } - - $testDuration += $testResult['duration']; - - if ($testResult['hasError']) { - $results .= " ✘ " . $assertion["description"] . " (" . number_format($testResult['duration'] * 1000, 2) . "ms)" . PHP_EOL; - $results .= $testResult['errors']; - $testHasError = true; - $failedAssertionsCount++; - } else { - $results .= " ✔ " . $assertion["description"] . " (" . number_format($testResult['duration'] * 1000, 2) . "ms)" . PHP_EOL; - $passedAssertionsCount++; - } + $testResult = executeTestAssertion($test, $assertion, $datafiles, $segments, $cliOptions, $resultSet, $coverageSelections); + $case = testCaseReport($assertion, $testResult, $resultSet, $cliOptions['targets']); + $result['cases'][] = $case; + if (!$case['passed']) $result['passed'] = false; } + if (count($result['cases']) === 0) continue; + $results[] = $result; + } + return $results; +} - if ($cliOptions['onlyFailures'] === null || ($cliOptions['onlyFailures'] === true && $testHasError)) { - echo "\nTesting: $testKey" . " (" . number_format($testDuration * 1000, 2) . "ms)" . PHP_EOL; - echo $results; - } +function getUncoveredEntities(string $projectPath, string $selector, ?string $set): array { + $command = nodeCommand($projectPath, array_merge(['list', "--$selector", '--without-tests', '--json'], setArguments($set))); + $items = json_decode(executeCommand($command, $projectPath), true, 512, JSON_THROW_ON_ERROR); + $keys = array_map(static fn($item): string => (string) (is_array($item) ? $item['key'] : $item), $items); + sort($keys, SORT_STRING); + return $keys; +} - if ($testHasError) { - $failedTestsCount++; - } else { - $passedTestsCount++; +function finalizeTestReport(array &$report): void { + $summary = emptyTestReport()['summary']; + foreach ($report['results'] as $result) { + $specBucket = $result['type'] === 'scenario' ? 'scenarioSpecs' : 'entitySpecs'; + $caseBucket = $result['type'] === 'scenario' ? 'scenarioCases' : 'entityAssertions'; + $specState = $result['passed'] ? 'passed' : 'failed'; + $summary['specs'][$specState]++; + $summary[$specBucket][$specState]++; + foreach ($result['cases'] as $case) { + $caseState = $case['passed'] ? 'passed' : 'failed'; + $summary['assertions'][$caseState]++; + $summary[$caseBucket][$caseState]++; } } + $report['summary'] = $summary; + $report['passed'] = count($report['errors']) === 0 && $summary['specs']['failed'] === 0 && $summary['assertions']['failed'] === 0; +} +function printTestReport(array $report, array $cliOptions): void { + if ($cliOptions['json'] === true) { + $flags = JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; + if ($cliOptions['pretty'] === true) $flags |= JSON_PRETTY_PRINT; + echo json_encode($report, $flags).PHP_EOL; + return; + } + if (count($report['results']) === 0 && count($report['errors']) === 0) echo "No tests found".PHP_EOL; + foreach ($report['results'] as $result) { + if ($cliOptions['onlyFailures'] === true && $result['passed']) continue; + echo "\nTesting: {$result['source']}".PHP_EOL; + foreach ($result['cases'] as $index => $case) { + if ($cliOptions['onlyFailures'] === true && $case['passed']) continue; + echo ' '.($case['passed'] ? '✔' : '✘').' '.humanCaseLabel($case, $index).PHP_EOL; + foreach ($case['errors'] as $error) echo ' ✘ '.humanCheckMessage($error['message']).PHP_EOL; + if (!$case['passed']) { + $args = implode(' ', array_map('escapeshellarg', $case['rerun']['args'])); + echo " Rerun (POSIX shell): {$case['rerun']['command']} $args".PHP_EOL; + } + } + } + foreach ($report['errors'] as $error) echo "Error: {$error['message']}".PHP_EOL; + $summary = $report['summary']; echo PHP_EOL; - echo "Test specs: " . $passedTestsCount . " passed, " . $failedTestsCount . " failed" . PHP_EOL; - echo "Assertions: " . $passedAssertionsCount . " passed, " . $failedAssertionsCount . " failed" . PHP_EOL; + echo "Entity specs: {$summary['entitySpecs']['passed']} passed, {$summary['entitySpecs']['failed']} failed".PHP_EOL; + echo "Entity assertions: {$summary['entityAssertions']['passed']} passed, {$summary['entityAssertions']['failed']} failed".PHP_EOL; + echo "Scenario specs: {$summary['scenarioSpecs']['passed']} passed, {$summary['scenarioSpecs']['failed']} failed".PHP_EOL; + echo "Scenario cases: {$summary['scenarioCases']['passed']} passed, {$summary['scenarioCases']['failed']} failed".PHP_EOL; + echo "Total cases: {$summary['assertions']['passed']} passed, {$summary['assertions']['failed']} failed".PHP_EOL; + if (isset($report['uncovered'])) echo "\nDefinitions without associated specs (not evaluation coverage):".PHP_EOL; + foreach ($report['uncovered'] ?? [] as $item) { + if ($item['set']) echo " Set: {$item['set']}".PHP_EOL; + echo ' Features: '.(count($item['features']) ? implode(', ', $item['features']) : 'none').PHP_EOL; + echo ' Global variables: '.(count($item['variables']) ? implode(', ', $item['variables']) : 'none').PHP_EOL; + } + if (isset($report['coverage'])) { + echo "\nObserved rule and override selections (not branch coverage):".PHP_EOL; + foreach ($report['coverage']['selections'] as $selection) echo ' '.humanCoverageSelection($selection).PHP_EOL; + if (count($report['coverage']['selections']) === 0) echo ' No selections observed'.PHP_EOL; + } echo PHP_EOL; +} - if ($failedTestsCount > 0) { - exit(1); +function humanCaseLabel(array $case, int $index): string { + $label = ($case['description'] ?? null) ?: 'assertion '.($index + 1); + $details = ['case '.($index + 1)]; + $matrix = (array) ($case['matrixValues'] ?? []); + if ($matrix !== []) { + $entries = []; + foreach ($matrix as $key => $value) $entries[] = $key.'='.json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + $details[] = 'matrix '.implode(', ', $entries); } + if (!empty($case['target'])) $details[] = 'target '.$case['target']; + return $label.' ('.implode('; ', $details).')'; +} + +function humanCheckMessage(string $message): string { + return preg_replace_callback('/\b(checks|children)\[(\d+)\]/', static function (array $match): string { + return ($match[1] === 'children' ? 'Child ' : 'check ').((int) $match[2] + 1); + }, $message); +} + +function humanCoverageSelection(array $selection): string { + $owner = isset($selection['featureKey']) + ? 'feature '.$selection['featureKey'].(isset($selection['variableKey']) ? ' variable '.$selection['variableKey'] : '') + : 'global variable '.($selection['variableKey'] ?? ''); + $scope = []; + foreach (['set', 'environment', 'target'] as $field) if (!empty($selection[$field])) $scope[] = $field.' '.$selection[$field]; + $picks = []; + if (isset($selection['ruleKey'])) $picks[] = 'rule '.$selection['ruleKey']; + if (array_key_exists('variation', $selection)) $picks[] = 'variation '.json_encode($selection['variation'], JSON_THROW_ON_ERROR); + if (isset($selection['overridePath'])) $picks[] = 'override '.implode(' > ', $selection['overridePath']); + if (isset($selection['overrideIndex'])) $picks[] = 'override '.($selection['overrideIndex'] + 1); + return $owner.($scope ? ' ('.implode(', ', $scope).')' : '').': '.implode(', ', $picks); +} + +function test(array $cliOptions): int { + $report = emptyTestReport(); + $coverageSelections = []; + try { + $cliOptions['inflate'] = validateInflateOption($cliOptions['inflate'] ?? null); + $projectPath = $cliOptions['rootDirectoryPath']; + $config = getConfig($projectPath); + $requestedSets = selectedTestSets($projectPath, $config, $cliOptions['set']); + if ($cliOptions['uncovered'] === true) { + foreach ($requestedSets as $set) { + $report['uncovered'][] = [ + 'set' => $set, + 'features' => getUncoveredEntities($projectPath, 'features', $set), + 'variables' => getUncoveredEntities($projectPath, 'variables', $set), + ]; + } + } + $executions = selectTestExecutions($projectPath, $cliOptions, $requestedSets); + foreach ($executions as $execution) { + $report['results'] = array_merge( + $report['results'], + runTestSet($projectPath, $config, $cliOptions, $execution['set'], $execution['tests'], $coverageSelections) + ); + } + if (count($report['results']) === 0) { + throw new RuntimeException('No test specs matched the requested filters'); + } + } catch (Throwable $error) { + $report['errors'][] = ['message' => $error->getMessage()]; + } + if ($cliOptions['coverage'] === true) { + $unique = []; + foreach ($coverageSelections as $selection) $unique[coverageSelectionKey($selection)] = $selection; + ksort($unique, SORT_STRING); + $report['coverage'] = ['mode' => 'observed-selections', 'selections' => array_values($unique)]; + } + if ($cliOptions['uncovered'] === true) { + $report['uncovered'] = $report['uncovered'] ?? []; + usort($report['uncovered'], static fn(array $left, array $right): int => strcmp($left['set'] ?? '', $right['set'] ?? '')); + } + finalizeTestReport($report); + printTestReport($report, $cliOptions); + return $report['passed'] ? 0 : 1; } /** * Benchmark */ +function runRuntimeCommand(string $command, array $options): void { + $options['inflate'] = validateInflateOption($options['inflate'] ?? null); + $n = $options['n'] ?? 1000; + if (filter_var($n, FILTER_VALIDATE_INT) === false || (int) $n <= 0) { + throw new InvalidArgumentException('--n must be a positive integer'); + } + $options['n'] = (int) $n; + if ($options['variation'] && $options['variable'] !== null) { + throw new InvalidArgumentException('--variation and --variable cannot be combined'); + } + if ($options['feature'] === null && ($command !== 'benchmark' || $options['variable'] === null || $options['variation'])) { + throw new InvalidArgumentException($command === 'benchmark' ? '--feature or --variable is required' : '--feature is required'); + } + if ($options['context'] !== null) { + $context = json_decode($options['context'], false, 512, JSON_THROW_ON_ERROR); + if (!$context instanceof stdClass) throw new InvalidArgumentException('--context must be a JSON object'); + } + $projectPath = $options['rootDirectoryPath']; + $config = getConfig($projectPath); + $environments = $config['environments'] ?? false; + if (is_array($environments)) { + if ($options['environment'] === null) throw new InvalidArgumentException('Pass --environment= when selecting one datafile.'); + if (!in_array($options['environment'], $environments, true)) { + throw new InvalidArgumentException('Unknown environment "'.$options['environment'].'". Available environments: '.implode(', ', $environments).'.'); + } + } elseif ($options['environment'] !== null) { + throw new InvalidArgumentException('This project does not configure environments. Omit --environment.'); + } + foreach (selectedTestSets($projectPath, $config, $options['set']) as $set) { + $options['set'] = $set; + if ($set) echo 'Set "'.$set.'"'.PHP_EOL; + if ($options['targets']) { + $available = getTargets($projectPath, $set); + foreach ($options['targets'] as $target) { + if (!in_array($target, $available, true)) throw new InvalidArgumentException('Unknown target "'.$target.'".'); + } + } + if ($command === 'benchmark') benchmark($options); + else assessDistribution($options); + } +} + function benchmark(array $cliOptions) { $featurevisorProjectPath = $cliOptions['rootDirectoryPath']; - if ($cliOptions['environment'] === null) { - echo "Environment is required" . PHP_EOL; - return; - } - if ($cliOptions['feature'] === null && $cliOptions['variable'] === null) { - echo "Feature or variable is required" . PHP_EOL; - return; + throw new InvalidArgumentException('Feature or variable is required'); } if (count($cliOptions['targets']) > 1) { @@ -624,14 +1228,15 @@ function benchmark(array $cliOptions) { return; } - $context = $cliOptions['context'] ? json_decode($cliOptions['context'], true) : []; + $context = $cliOptions['context'] ? runtimeMapToArray(json_decode($cliOptions['context'], false, 512, JSON_THROW_ON_ERROR)) : []; $level = getDiagnosticLevel($cliOptions); $datafile = buildSingleDatafile( $featurevisorProjectPath, $cliOptions['environment'], $cliOptions['targets'][0] ?? null, - $cliOptions['inflate'] + $cliOptions['inflate'], + $cliOptions['set'] ); $f = Featurevisor::createFeaturevisor([ @@ -695,14 +1300,8 @@ function benchmark(array $cliOptions) { function assessDistribution(array $cliOptions) { $featurevisorProjectPath = $cliOptions['rootDirectoryPath']; - if ($cliOptions['environment'] === null) { - echo "Environment is required" . PHP_EOL; - return; - } - if ($cliOptions['feature'] === null) { - echo "Feature is required" . PHP_EOL; - return; + throw new InvalidArgumentException('Feature is required'); } if (count($cliOptions['targets']) > 1) { @@ -714,10 +1313,10 @@ function assessDistribution(array $cliOptions) { return; } - $context = $cliOptions['context'] ? json_decode($cliOptions['context'], true) : []; + $context = $cliOptions['context'] ? runtimeMapToArray(json_decode($cliOptions['context'], false, 512, JSON_THROW_ON_ERROR)) : []; $populateUuid = $cliOptions['populateUuid']; - $datafile = buildSingleDatafile($featurevisorProjectPath, $cliOptions['environment'], $cliOptions['targets'][0] ?? null, $cliOptions['inflate']); + $datafile = buildSingleDatafile($featurevisorProjectPath, $cliOptions['environment'], $cliOptions['targets'][0] ?? null, $cliOptions['inflate'], $cliOptions['set']); $level = getDiagnosticLevel($cliOptions); $f = Featurevisor::createFeaturevisor([ @@ -780,13 +1379,17 @@ function assessDistribution(array $cliOptions) { * Main */ if (!defined('FEATUREVISOR_CLI_TEST')) { - if ($cliOptions['0'] === 'test') { - test($cliOptions); - } else if ($cliOptions['0'] === 'benchmark') { - benchmark($cliOptions); - } else if ($cliOptions['0'] === 'assess-distribution') { - assessDistribution($cliOptions); - } else { - echo "Learn more at https://featurevisor.com/docs/sdks/php/" . PHP_EOL; + try { + $cliOptions = parseCliArguments($_SERVER['argv'] ?? []); + if ($cliOptions['help']) { + printCliHelp(); + exit(0); + } + validateCliArguments($cliOptions); + if ($cliOptions['0'] === 'test') exit(test($cliOptions)); + runRuntimeCommand($cliOptions['0'], $cliOptions); + } catch (Throwable $error) { + fwrite(STDERR, 'Error: '.$error->getMessage().PHP_EOL); + exit(1); } } diff --git a/phpstan.neon b/phpstan.neon index 5a847af..4d25c66 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,6 +6,8 @@ parameters: paths: - src - tests + - cli + - featurevisor ignoreErrors: - message: '#^Call to function is_array\(\) with mixed will always evaluate to false\.$#' diff --git a/scripts/benchmark_sticky_prerequisites.php b/scripts/benchmark_sticky_prerequisites.php new file mode 100644 index 0000000..67cb7fe --- /dev/null +++ b/scripts/benchmark_sticky_prerequisites.php @@ -0,0 +1,60 @@ + 'all', 'segments' => '*', 'percentage' => 100000]]; +$datafile = $exported ?? ['schemaVersion' => '2', 'revision' => 'sticky-benchmark', 'segments' => [], + 'features' => [], 'variables' => []]; +$datafile['features'] = array_merge($datafile['features'], [ + 'leafFlag' => ['bucketBy' => 'userId', 'traffic' => $traffic], + ]); +$datafile['variables'] = array_merge($datafile['variables'] ?? [], [ + 'source' => ['type' => 'integer', 'defaultValue' => 1], + ]); +// Add the nonsticky global gate only after loading or constructing the exported shape. +$datafile['variables']['featureGated'] = + ['type' => 'integer', 'defaultValue' => 1, + 'requiredFeatures' => ['leafFlag', 'leafFlag']]; +$datafile['variables']['gated'] = + ['type' => 'integer', 'defaultValue' => 1, + 'conditions' => [ + ['variable' => 'source', 'operator' => 'equals', 'value' => 1], + ['variable' => 'source', 'operator' => 'equals', 'value' => 1], + ]]; + +echo "kind,size,owner,mean_microseconds_per_evaluation,iterations\n"; +foreach (['features', 'variables'] as $kind) { + foreach ([0, 100, 1000, 5000] as $size) { + $sticky = []; + for ($i = 0; $i < $size; $i++) { + $sticky['unrelated'.$i] = $kind === 'features' ? ['enabled' => true] : $i; + } + foreach (['instance', 'child'] as $ownerName) { + // The no op hook exercises the public sticky handoff and PHP array comparison path. + $options = ['datafile' => $datafile, 'logLevel' => 'fatal', + 'modules' => [['beforeEvaluation' => static fn(array $evaluation): array => $evaluation]]]; + $options[$kind === 'features' ? 'stickyFeatures' : 'stickyVariables'] = $sticky; + $sdk = Featurevisor::createFeaturevisor($options); + $owner = $ownerName === 'child' + ? $sdk->spawn([], [$kind === 'features' ? 'stickyFeatures' : 'stickyVariables' => $sticky]) + : $sdk; + $read = $kind === 'features' + ? static fn() => $owner->getVariable('featureGated', ['userId' => 'bench']) === 1 + : static fn() => $owner->getVariable('gated', ['userId' => 'bench']) === 1; + for ($i = 0; $i < 100; $i++) if (!$read()) throw new RuntimeException('Incorrect warmup result'); + $total = 0; + for ($i = 0; $i < $iterations; $i++) { + $start = hrtime(true); + if (!$read()) throw new RuntimeException('Incorrect benchmark result'); + $total += hrtime(true) - $start; + } + printf("%s,%d,%s,%.3f,%d\n", $kind, $size, $ownerName, $total / $iterations / 1000, $iterations); + if ($ownerName === 'child') $owner->close(); + $sdk->close(); + } + } +} diff --git a/src/Child.php b/src/Child.php index f1bd0a1..38dc375 100644 --- a/src/Child.php +++ b/src/Child.php @@ -9,6 +9,7 @@ class Child private array $stickyFeatures; private array $stickyVariables; private Emitter $emitter; + private int $evaluationGeneration = 0; /** @var array */ private array $parentUnsubscribers = []; @@ -51,6 +52,7 @@ public function close(): void public function setContext(array $context, bool $replace = false): void { $this->context = $replace ? $context : array_merge($this->context, $context); + $this->evaluationGeneration++; $this->emitter->trigger('context_set', ['context' => $this->context, 'replaced' => $replace]); } @@ -63,6 +65,7 @@ public function setStickyFeatures(array $sticky, bool $replace = false): void { $previous = $this->stickyFeatures; $this->stickyFeatures = $replace ? $sticky : array_merge($this->stickyFeatures, $sticky); + $this->evaluationGeneration++; $params = Events::getParamsForStickyFeaturesSetEvent($previous, $this->stickyFeatures, $replace); $this->emitter->trigger('sticky_features_set', $params); } @@ -70,6 +73,7 @@ public function setStickyVariables(array $sticky, bool $replace = false): void { $previous = $this->stickyVariables; $this->stickyVariables = $replace ? $sticky : array_merge($this->stickyVariables, $sticky); + $this->evaluationGeneration++; $this->emitter->trigger('sticky_variables_set', Events::getParamsForStickyVariablesSetEvent($previous, $this->stickyVariables, $replace)); } @@ -79,6 +83,9 @@ private function options(array $options): array return array_merge($options, [ '__featurevisorChildStickyFeatures' => $this->stickyFeatures, '__featurevisorChildStickyVariables' => $this->stickyVariables, + '__featurevisorStickyFeaturesSource' => fn(): array => $this->stickyFeatures, + '__featurevisorStickyVariablesSource' => fn(): array => $this->stickyVariables, + '__featurevisorChildGeneration' => fn(): int => $this->evaluationGeneration, ]); } @@ -97,7 +104,7 @@ public function evaluateVariable(string $first, string|array|null $second = null public function getVariableBoolean(string $first, string|array|null $second = null, array $third = [], array $fourth = []): ?bool { return $this->parent->getVariableBoolean(...$this->variableArguments($first, $second, $third, $fourth)); } public function getVariableString(string $first, string|array|null $second = null, array $third = [], array $fourth = []): ?string { return $this->parent->getVariableString(...$this->variableArguments($first, $second, $third, $fourth)); } public function getVariableInteger(string $first, string|array|null $second = null, array $third = [], array $fourth = []): ?int { return $this->parent->getVariableInteger(...$this->variableArguments($first, $second, $third, $fourth)); } - public function getVariableDouble(string $first, string|array|null $second = null, array $third = [], array $fourth = []): ?float { return $this->parent->getVariableDouble(...$this->variableArguments($first, $second, $third, $fourth)); } + public function getVariableNumber(string $first, string|array|null $second = null, array $third = [], array $fourth = []): ?float { return $this->parent->getVariableNumber(...$this->variableArguments($first, $second, $third, $fourth)); } public function getVariableArray(string $first, string|array|null $second = null, array $third = [], array $fourth = []): ?array { return $this->parent->getVariableArray(...$this->variableArguments($first, $second, $third, $fourth)); } public function getVariableObject(string $first, string|array|null $second = null, array $third = [], array $fourth = []) { return $this->parent->getVariableObject(...$this->variableArguments($first, $second, $third, $fourth)); } public function getVariableJSON(string $first, string|array|null $second = null, array $third = [], array $fourth = []) { return $this->parent->getVariableJSON(...$this->variableArguments($first, $second, $third, $fourth)); } diff --git a/src/CompareVersions.php b/src/CompareVersions.php index cb06bee..c9b8ffd 100644 --- a/src/CompareVersions.php +++ b/src/CompareVersions.php @@ -12,7 +12,7 @@ public static function compare(string $v1, string $v2): int $n1 = self::validateAndParse($v1); $n2 = self::validateAndParse($v2); - // pop off the patch + // Separate the prerelease from the core components $p1 = array_pop($n1); $p2 = array_pop($n2); @@ -21,10 +21,10 @@ public static function compare(string $v1, string $v2): int if ($r !== 0) return $r; // validate pre-release - if ($p1 && $p2) { - return self::compareSegments(explode('.', $p1), explode('.', $p2)); - } elseif ($p1 || $p2) { - return $p1 ? -1 : 1; + if ($p1 !== null && $p2 !== null) { + return self::compareSegments(explode('.', $p1), explode('.', $p2), true); + } elseif ($p1 !== null || $p2 !== null) { + return $p1 !== null ? -1 : 1; } return 0; @@ -32,7 +32,7 @@ public static function compare(string $v1, string $v2): int private static function validateAndParse(string $version): array { - if (!preg_match(self::$semver, $version, $match, PREG_UNMATCHED_AS_NULL)) { + if (!preg_match(self::$semver, $version, $match, PREG_UNMATCHED_AS_NULL) || $match[0] !== $version) { throw new \Exception("Invalid argument not valid semver ('$version' received)"); } @@ -45,33 +45,28 @@ private static function isWildcard(string $s): bool return $s === '*' || $s === 'x' || $s === 'X'; } - private static function forceType($a, $b): array + private static function compareStrings(string $a, string $b, bool $prerelease = false): int { - return gettype($a) !== gettype($b) ? [strval($a), strval($b)] : [$a, $b]; - } - - private static function tryParse(string $v) - { - return preg_match('/^\d+$/', $v) ? intval($v) : $v; - } - - private static function compareStrings(string $a, string $b): int - { - if (self::isWildcard($a) || self::isWildcard($b)) return 0; - - list($ap, $bp) = self::forceType(self::tryParse($a), self::tryParse($b)); - - if ($ap > $bp) return 1; - if ($ap < $bp) return -1; - return 0; + if (!$prerelease && (self::isWildcard($a) || self::isWildcard($b))) return 0; + $an = preg_match('/\A[0-9]+\z/', $a) === 1; + $bn = preg_match('/\A[0-9]+\z/', $b) === 1; + if ($an && $bn) { + $a = ltrim($a, '0'); + $b = ltrim($b, '0'); + if (strlen($a) !== strlen($b)) return strlen($a) <=> strlen($b); + } elseif ($prerelease && $an !== $bn) { + return $an ? -1 : 1; + } + return strcmp($a, $b) <=> 0; } - private static function compareSegments($a, $b): int + private static function compareSegments($a, $b, bool $prerelease = false): int { $maxLength = max(count($a), count($b)); for ($i = 0; $i < $maxLength; $i++) { - $r = self::compareStrings($a[$i] ?? '0', $b[$i] ?? '0'); + if ($prerelease && ($i === count($a) || $i === count($b))) return count($a) <=> count($b); + $r = self::compareStrings($a[$i] ?? '0', $b[$i] ?? '0', $prerelease); if ($r !== 0) return $r; } diff --git a/src/Conditions.php b/src/Conditions.php index 887892f..26f75fe 100644 --- a/src/Conditions.php +++ b/src/Conditions.php @@ -18,6 +18,18 @@ private static function primitiveEquals($left, $right): bool return false; } + /** @param array $values @param mixed $needle */ + private static function containsPrimitive(array $values, $needle): bool + { + foreach ($values as $value) { + if (self::primitiveEquals($value, $needle)) { + return true; + } + } + + return false; + } + /** @param array $value */ private static function isList(array $value): bool { @@ -33,6 +45,11 @@ public static function getValueFromContext(array $context, string $path) $current = $context; foreach (explode('.', $path) as $key) { + if ($current instanceof \stdClass) { + if (!property_exists($current, $key)) return null; + $current = $current->{$key}; + continue; + } if (!is_array($current) || !array_key_exists($key, $current)) { return null; } @@ -49,6 +66,11 @@ public static function pathExists(array $context, string $path): bool $current = $context; foreach (explode('.', $path) as $key) { + if ($current instanceof \stdClass) { + if (!property_exists($current, $key)) return false; + $current = $current->{$key}; + continue; + } if (!is_array($current) || !array_key_exists($key, $current)) { return false; } @@ -74,16 +96,66 @@ public static function conditionIsMatched($condition, array $context, callable $ } $attribute = $condition['attribute'] ?? ''; + if (strpos($attribute, '.') === false) { + $present = array_key_exists($attribute, $context); + + return self::valueIsMatched( + $present ? $context[$attribute] : null, + $condition, + $getRegex, + $present + ); + } + + $current = $context; + foreach (explode('.', $attribute) as $key) { + if ($current instanceof \stdClass) { + if (!property_exists($current, $key)) return self::valueIsMatched(null, $condition, $getRegex, false); + $current = $current->{$key}; + continue; + } + if (!is_array($current) || !array_key_exists($key, $current)) { + return self::valueIsMatched(null, $condition, $getRegex, false); + } + $current = $current[$key]; + } + + return self::valueIsMatched($current, $condition, $getRegex, true); + } + + /** + * Shared comparisons for context conditions and evaluated variable requirements. + * + * @param mixed $contextValue + * @param array $condition + */ + public static function valueIsMatched($contextValue, array $condition, callable $getRegex, bool $present = true): bool + { $operator = $condition['operator'] ?? ''; $value = $condition['value'] ?? null; - $contextValue = self::getValueFromContext($context, $attribute); + + if ($operator === 'isNull') return $present && $contextValue === null; + if ($operator === 'notNull') return $present && $contextValue !== null; + if ($operator === 'empty' || $operator === 'notEmpty') { + if (!$present || $contextValue === null) return false; + + if (is_string($contextValue) || is_array($contextValue)) { + $size = is_string($contextValue) ? strlen($contextValue) : count($contextValue); + } elseif ($contextValue instanceof \stdClass) { + $size = count(get_object_vars($contextValue)); + } else { + return false; + } + + return $operator === 'empty' ? $size === 0 : $size > 0; + } if ($operator === 'equals') { - return self::pathExists($context, $attribute) && self::primitiveEquals($contextValue, $value); + return $present && self::primitiveEquals($contextValue, $value); } if ($operator === 'notEquals') { - return !self::pathExists($context, $attribute) || !self::primitiveEquals($contextValue, $value); + return !$present || !self::primitiveEquals($contextValue, $value); } if ($operator === 'before' || $operator === 'after') { @@ -97,16 +169,16 @@ public static function conditionIsMatched($condition, array $context, callable $ } if (is_array($value) && (is_string($contextValue) || is_int($contextValue) || is_float($contextValue) || $contextValue === null)) { - if (!self::pathExists($context, $attribute)) { + if (!$present) { return false; } if ($operator === 'in') { - return count(array_filter($value, fn ($candidate) => self::primitiveEquals($candidate, $contextValue))) > 0; + return self::containsPrimitive($value, $contextValue); } if ($operator === 'notIn') { - return count(array_filter($value, fn ($candidate) => self::primitiveEquals($candidate, $contextValue))) === 0; + return !self::containsPrimitive($value, $contextValue); } } @@ -167,18 +239,29 @@ public static function conditionIsMatched($condition, array $context, callable $ } if ($operator === 'exists') { - return self::pathExists($context, $attribute); + return $present; } if ($operator === 'notExists') { - return !self::pathExists($context, $attribute); + return !$present; + } + + if ($operator === 'includesAny' || $operator === 'notIncludesAny') { + if (!is_array($contextValue) || !self::isList($contextValue) || !is_array($value) || !self::isList($value)) return false; + foreach ($value as $candidate) { + if ($candidate !== null && !is_string($candidate) && !is_int($candidate) && !is_float($candidate) && !is_bool($candidate)) return false; + } + foreach ($value as $candidate) { + if (self::containsPrimitive($contextValue, $candidate)) return $operator === 'includesAny'; + } + return $operator === 'notIncludesAny'; } if (is_array($contextValue) && (is_string($value) || is_int($value) || is_float($value) || is_bool($value) || $value === null)) { if ($operator === 'includes') { - return count(array_filter($contextValue, fn ($candidate) => self::primitiveEquals($candidate, $value))) > 0; + return self::containsPrimitive($contextValue, $value); } if ($operator === 'notIncludes') { - return count(array_filter($contextValue, fn ($candidate) => self::primitiveEquals($candidate, $value))) === 0; + return !self::containsPrimitive($contextValue, $value); } } @@ -187,15 +270,32 @@ public static function conditionIsMatched($condition, array $context, callable $ private static function portableDate($value): ?\DateTimeInterface { - if ($value instanceof \DateTimeInterface) { - return $value; - } - if (!is_string($value) || !preg_match('/T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})$/', $value)) { - return null; - } - try { - return new \DateTimeImmutable($value); + if ($value instanceof \DateTimeInterface) { + $year = (int) $value->format('Y'); + if ($year < 1 || $year > 9999 || abs($value->getOffset()) > 14 * 3600) { + return null; + } + $date = \DateTimeImmutable::createFromInterface($value); + } elseif (is_string($value) && preg_match('/\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?(?:Z|([+\-])([0-9]{2}):([0-9]{2}))\z/D', $value, $match)) { + if (substr($value, 0, 4) === '0000') return null; + if (isset($match[2]) && ((int) $match[2] > 14 || (int) $match[3] > 59 || ($match[2] === '14' && $match[3] !== '00'))) { + return null; + } + $normalized = preg_replace('/(\.[0-9]{3})[0-9]{1,6}(?=Z|[+\-][0-9]{2}:[0-9]{2}\z)/', '$1', $value); + $date = new \DateTimeImmutable($normalized ?? $value); + if ($date->format('Y-m-d\TH:i:s') !== substr($value, 0, 19)) { + return null; + } + } else { + return null; + } + $date = $date->setTimezone(new \DateTimeZone('UTC')); + $year = (int) $date->format('Y'); + if ($year < 1 || $year > 9999) { + return null; + } + return $date->setTime((int) $date->format('H'), (int) $date->format('i'), (int) $date->format('s'), intdiv((int) $date->format('u'), 1000) * 1000); } catch (\Exception $error) { return null; } @@ -282,7 +382,7 @@ public static function parseConditionsIfStringified($conditions, ?callable $repo } try { - return json_decode($conditions, true, 512, JSON_THROW_ON_ERROR); + return Internal\JsonValues::normalizeConditionsValue(json_decode($conditions, false, 512, JSON_THROW_ON_ERROR)); } catch (\Throwable $error) { if ($reportDiagnostic) { $reportDiagnostic([ diff --git a/src/Evaluate.php b/src/Evaluate.php index c1c0522..6e0024a 100644 --- a/src/Evaluate.php +++ b/src/Evaluate.php @@ -9,11 +9,30 @@ class Evaluate public static function evaluateWithModules(array $opts): array { try { + $session = $opts['__featurevisorSession'] ?? new EvaluationSession(); + $opts['__featurevisorSession'] = $session; + if (!empty($opts['__featurevisorPrerequisite'])) { + if (isset($opts['__featurevisorStickyFeaturesSource'])) $opts['stickyFeatures'] = $opts['__featurevisorStickyFeaturesSource'](); + if (isset($opts['__featurevisorStickyVariablesSource'])) $opts['stickyVariables'] = $opts['__featurevisorStickyVariablesSource'](); + $opts['dependencyOf'] = $session->dependencyOf(); + $opts['dependencyPurpose'] = $opts['dependencyPurpose'] ?? 'condition'; + } + return $session->run($opts, !empty($opts['__featurevisorPrerequisite']), function () use ($opts, $session): array { $modulesManager = $opts['modulesManager']; - // run before modules - $options = $modulesManager->runBeforeModules($opts); - $options = $modulesManager->runBeforeEvaluationModules($options); + $options = $modulesManager->runBeforeEvaluationModules($opts); + $options['__featurevisorSession'] = $session; + $session->updateCurrent($options); + foreach (['stickyFeatures', 'stickyVariables'] as $field) { + $sourceKey = '__featurevisor'.ucfirst($field).'Source'; + // Existing PHP value semantics treat an equal array as unchanged. + if (($options[$field] ?? null) !== ($opts[$field] ?? null)) { + $value = $options[$field]; + $options[$sourceKey] = static fn(): array => $value; + } elseif (isset($options[$sourceKey])) { + $options[$field] = $options[$sourceKey](); + } + } // evaluate $evaluation = self::evaluate($options); @@ -37,12 +56,16 @@ public static function evaluateWithModules(array $opts): array $evaluation['variableValue'] = $options['defaultVariableValue']; } - // run after modules + if ($evaluation['type'] === 'variable' && array_key_exists('variableValue', $evaluation)) { + $evaluation['variableValue'] = Helpers::copyValue($evaluation['variableValue']); + } + $evaluation = $modulesManager->runAfterEvaluationModules($evaluation, $options); - $evaluation = $modulesManager->runAfterModules($evaluation, $options); return $evaluation; + }); } catch (\Throwable $e) { + if ($e instanceof RequirementError && !empty($opts['__featurevisorPrerequisite'])) throw $e; $type = $opts['type']; $featureKey = $opts['featureKey']; $variableKey = $opts['variableKey'] ?? null; @@ -60,50 +83,15 @@ public static function evaluateWithModules(array $opts): array } } - private static function evaluateRequired(array $options, array $feature): ?array - { - $type = $options['type']; - $featureKey = $options['featureKey']; - $requirements = $feature['requiredFeatures'] ?? ($feature['required'] ?? null); - if ($type === 'flag' && $requirements && !self::requiredFeaturesAreMatched($requirements, $options)) { - $evaluation = [ - 'type' => $type, - 'featureKey' => $featureKey, - 'reason' => Evaluation::REQUIRED, - 'required' => $feature['required'] ?? null, - 'requiredFeatures' => $feature['requiredFeatures'] ?? null, - 'enabled' => false - ]; - - Diagnostics::reportEvaluation($options, $evaluation, 'required features not enabled'); - - return $evaluation; - } - - return null; - } - - /** @param mixed $requirements */ - public static function requiredFeaturesAreMatched($requirements, array $options): bool + private static function evaluateConditions(array $options, array $feature): ?array { - if ($requirements === null) return true; - $isList = is_array($requirements) && ($requirements === [] || array_keys($requirements) === range(0, count($requirements) - 1)); - $items = $isList ? $requirements : [$requirements]; - $cleanOptions = $options; - unset($cleanOptions['type'], $cleanOptions['featureKey'], $cleanOptions['variableKey'], $cleanOptions['defaultVariationValue'], $cleanOptions['defaultVariableValue']); - foreach ($items as $required) { - $key = is_string($required) ? $required : ($required['feature'] ?? $required['key']); - $expectedEnabled = is_string($required) ? true : ($required['enabled'] ?? true); - $expectedVariation = is_string($required) ? null : ($required['variation'] ?? null); - $flag = self::evaluateWithModules(array_merge($cleanOptions, ['type' => 'flag', 'featureKey' => $key])); - if ((($flag['enabled'] ?? false) === true) !== $expectedEnabled) return false; - if ($expectedVariation !== null) { - $variation = self::evaluateWithModules(array_merge($cleanOptions, ['type' => 'variation', 'featureKey' => $key])); - $actual = $variation['variationValue'] ?? ($variation['variation']['value'] ?? null); - if ($actual !== $expectedVariation) return false; - } - } - return true; + if ($options['type'] !== 'flag' || !array_key_exists('conditions', $feature) + || $options['datafile']['conditionsAreMatched']($feature['conditions'], $options)) return null; + $evaluation = ['type' => 'flag', 'featureKey' => $options['featureKey'], 'enabled' => false, + 'reason' => Evaluation::CONDITIONS_UNMET, + 'conditions' => Internal\Normalize::publicConditions($feature['conditions'])]; + Diagnostics::reportEvaluation($options, $evaluation, 'root conditions did not match'); + return $evaluation; } public static function evaluate(array $options): array @@ -154,7 +142,7 @@ public static function evaluate(array $options): array } // required - $requiredEvaluation = self::evaluateRequired($options, $feature); + $requiredEvaluation = self::evaluateConditions($options, $feature); if ($requiredEvaluation) { return $requiredEvaluation; } @@ -182,6 +170,7 @@ public static function evaluate(array $options): array return $evaluation; } catch (\Throwable $e) { + if ($e instanceof RequirementError) throw $e; $evaluation = [ 'type' => $type, 'featureKey' => $featureKey, diff --git a/src/EvaluateByBucketing.php b/src/EvaluateByBucketing.php index c9d3080..ae456ff 100644 --- a/src/EvaluateByBucketing.php +++ b/src/EvaluateByBucketing.php @@ -8,18 +8,8 @@ class EvaluateByBucketing { private static function overrideMatches(array $override, array $options, array $datafile, array $context): bool { - if (!Evaluate::requiredFeaturesAreMatched($override['requiredFeatures'] ?? null, $options)) return false; - $conditionsMatch = true; - $segmentsMatch = true; - if (isset($override['conditions'])) { - $conditions = Conditions::parseConditionsIfStringified($override['conditions'], $options['reportDiagnostic'] ?? null); - $conditionsMatch = $datafile['allConditionsAreMatched']($conditions, $context); - } - if (isset($override['segments'])) { - $segments = Conditions::parseSegmentsIfStringified($override['segments']); - $segmentsMatch = $datafile['allSegmentsAreMatched']($segments, $context); - } - return $conditionsMatch && $segmentsMatch; + return array_key_exists('conditions', $override) + && $datafile['conditionsAreMatched']($override['conditions'], $options); } public static function evaluate(array $options, array $feature, ?array $variableSchema = null, ?array $force = null): array @@ -66,13 +56,13 @@ public static function evaluate(array $options, array $feature, ?array $variable $matchedAllocation = null; if ($type !== 'flag') { - $matchedTraffic = $datafile['getMatchedTraffic']($feature['traffic'], $context); + $matchedTraffic = $datafile['getMatchedTraffic']($feature['traffic'], $context, $options); if ($matchedTraffic) { $matchedAllocation = $datafile['getMatchedAllocation']($matchedTraffic, $bucketValue); } } else { - $matchedTraffic = $datafile['getMatchedTraffic']($feature['traffic'], $context); + $matchedTraffic = $datafile['getMatchedTraffic']($feature['traffic'], $context, $options); } $result = [ @@ -103,49 +93,6 @@ public static function evaluate(array $options, array $feature, ?array $variable // flag if ($type === 'flag') { - // flag: check if mutually exclusive - if (isset($feature['ranges']) && count($feature['ranges']) > 0) { - $matchedRange = null; - foreach ($feature['ranges'] as $range) { - if ($bucketValue >= $range[0] && $bucketValue < $range[1]) { - $matchedRange = $range; - break; - } - } - - // matched - if ($matchedRange) { - $result['evaluation'] = [ - 'type' => $type, - 'featureKey' => $featureKey, - 'reason' => Evaluation::ALLOCATED, - 'bucketKey' => $bucketKey, - 'bucketValue' => $bucketValue, - 'ruleKey' => $matchedTraffic['key'], - 'traffic' => $matchedTraffic, - 'enabled' => isset($matchedTraffic['enabled']) ? $matchedTraffic['enabled'] : true - ]; - - Diagnostics::reportEvaluation($options, $result['evaluation'], 'matched'); - - return $result; - } - - // no match - $result['evaluation'] = [ - 'type' => $type, - 'featureKey' => $featureKey, - 'reason' => Evaluation::OUT_OF_RANGE, - 'bucketKey' => $bucketKey, - 'bucketValue' => $bucketValue, - 'enabled' => false - ]; - - Diagnostics::reportEvaluation($options, $result['evaluation'], 'not matched'); - - return $result; - } - // flag: override from rule if (isset($matchedTraffic['enabled'])) { $result['evaluation'] = [ diff --git a/src/EvaluateForced.php b/src/EvaluateForced.php index 14e7847..35c0d4f 100644 --- a/src/EvaluateForced.php +++ b/src/EvaluateForced.php @@ -14,7 +14,7 @@ public static function evaluate(array $options, array $feature, ?array $variable $context = $options['context']; $datafile = $options['datafile']; - $forceResult = $datafile['getMatchedForce']($feature, $context); + $forceResult = $datafile['getMatchedForce']($feature, $context, $options); $force = $forceResult['force'] ?? null; $forceIndex = $forceResult['forceIndex'] ?? null; diff --git a/src/EvaluateSticky.php b/src/EvaluateSticky.php index fd40bde..01fe609 100644 --- a/src/EvaluateSticky.php +++ b/src/EvaluateSticky.php @@ -11,7 +11,7 @@ public static function evaluate(array $options): ?array $type = $options['type']; $featureKey = $options['featureKey']; $variableKey = $options['variableKey'] ?? null; - $sticky = $options['sticky'] ?? null; + $sticky = $options['stickyFeatures'] ?? null; if ($sticky && array_key_exists($featureKey, $sticky)) { $evaluation = null; @@ -21,7 +21,7 @@ public static function evaluate(array $options): ?array 'type' => $type, 'featureKey' => $featureKey, 'reason' => Evaluation::STICKY, - 'sticky' => $sticky[$featureKey], + 'stickyFeature' => $sticky[$featureKey], 'enabled' => $sticky[$featureKey]['enabled'] ]; diff --git a/src/Evaluation.php b/src/Evaluation.php index d5472a9..87b607a 100644 --- a/src/Evaluation.php +++ b/src/Evaluation.php @@ -7,16 +7,15 @@ class Evaluation // Evaluation reasons public const FEATURE_NOT_FOUND = 'feature_not_found'; public const DISABLED = 'disabled'; - public const REQUIRED = 'required'; - public const OUT_OF_RANGE = 'out_of_range'; public const NO_VARIATIONS = 'no_variations'; public const VARIATION_DISABLED = 'variation_disabled'; public const VARIABLE_NOT_FOUND = 'variable_not_found'; public const VARIABLE_DEFAULT = 'variable_default'; public const VARIABLE_DISABLED = 'variable_disabled'; - public const VARIABLE_OVERRIDE = 'variable_override'; public const VARIABLE_OVERRIDE_VARIATION = 'variable_override_variation'; public const VARIABLE_OVERRIDE_RULE = 'variable_override_rule'; + public const CONDITIONS_UNMET = 'conditions_unmet'; + public const VARIABLE_COMPOSED = 'variable_composed'; public const NO_MATCH = 'no_match'; public const FORCED = 'forced'; public const STICKY = 'sticky'; @@ -40,8 +39,8 @@ class Evaluation public ?array $traffic = null; public ?int $forceIndex = null; public ?array $force = null; - public ?array $required = null; - public ?array $sticky = null; + public $conditions = null; + public ?array $stickyFeature = null; public ?array $variation = null; public $variationValue = null; public ?string $variableKey = null; @@ -62,8 +61,8 @@ public function toArray(): array 'traffic' => $this->traffic, 'forceIndex' => $this->forceIndex, 'force' => $this->force, - 'required' => $this->required, - 'sticky' => $this->sticky, + 'conditions' => $this->conditions, + 'stickyFeature' => $this->stickyFeature, 'variation' => $this->variation, 'variationValue' => $this->variationValue, 'variableKey' => $this->variableKey, diff --git a/src/EvaluationSession.php b/src/EvaluationSession.php new file mode 100644 index 0000000..e4cd64e --- /dev/null +++ b/src/EvaluationSession.php @@ -0,0 +1,186 @@ +active === [] ? null : $this->active[count($this->active) - 1]['current']; + } + + public function updateCurrent(array $options): void + { + $current = $this->identity($options); + $this->active[count($this->active) - 1]['current'] = $current; + } + + public function run(array $options, bool $prerequisite, callable $evaluate): array + { + $identity = $this->identity($options); + $this->assertCanEvaluate($identity); + $borrowed = ($options['dependencyPurpose'] ?? null) === 'composition'; + + $requester = $this->dependencyOf(); + $key = $prerequisite ? $this->fingerprint($identity, $requester, $options) : null; + if ($key !== null && array_key_exists($key, $this->memo)) { + return $borrowed ? $this->memo[$key] : Helpers::copyValue($this->memo[$key]); + } + + $this->active[] = ['identity' => $identity, 'current' => $identity]; + try { + $result = $evaluate(); + if ($key !== null && $key === $this->fingerprint($identity, $requester, $options)) { + $minimal = $this->minimal($result, $borrowed); + if ($minimal !== null) $this->memo[$key] = $borrowed ? $minimal : Helpers::copyValue($minimal); + } + return $result; + } catch (\Throwable $error) { + if (!($error instanceof RequirementError) && $key !== null + && $key === $this->fingerprint($identity, $requester, $options)) { + $this->memo[$key] = array_merge($identity, ['reason' => Evaluation::ERROR]); + } + throw $error; + } finally { + array_pop($this->active); + if ($this->active === []) { + $this->memo = []; + $this->stickySources = []; + } + } + } + + /** Memoize the completed ordinary getter, including JSON decoding and null fallbacks. */ + public function memoizeGetter(array $options, callable $evaluate) + { + $identity = $this->identity($options); + $this->assertCanEvaluate($identity); + $requester = $this->dependencyOf(); + $fingerprint = $this->fingerprint($identity, $requester, $options); + $key = $fingerprint === null ? null : 'getter:'.$fingerprint; + $borrowed = ($options['dependencyPurpose'] ?? null) === 'composition'; + if ($key !== null && array_key_exists($key, $this->memo)) { + return $borrowed ? $this->memo[$key]['variableValue'] : Helpers::copyValue($this->memo[$key]['variableValue']); + } + $value = $evaluate(); + if ($key !== null && $fingerprint === $this->fingerprint($identity, $requester, $options)) { + if ($borrowed) $this->memo[$key] = ['variableValue' => $value]; + else { + try { + self::canonical($value); + $this->memo[$key] = ['variableValue' => Helpers::copyValue($value)]; + } catch (\Throwable $error) { + // Unsupported host values keep ordinary getter behaviour without memoization. + } + } + } + return $value; + } + + private function assertCanEvaluate(array $identity): void + { + if (count($this->active) + count($this->segments) > 256) throw new RequirementError('Evaluation dependency depth exceeded'); + foreach ($this->active as $frame) { + if ($frame['identity'] === $identity || $frame['current'] === $identity) { + throw new RequirementError('Circular evaluation requirement'); + } + } + } + + public function segment(string $key, callable $evaluate): bool + { + if (isset($this->segments[$key])) throw new RequirementError('Circular dependency'); + if (count($this->active) - 1 + count($this->segments) >= 256) throw new RequirementError('Evaluation dependency depth exceeded'); + $this->segments[$key] = true; + try { return $evaluate(); } + finally { unset($this->segments[$key]); } + } + + private function identity(array $options): array + { + $identity = ['type' => $options['type']]; + if (isset($options['featureKey'])) $identity['featureKey'] = $options['featureKey']; + if (isset($options['variableKey'])) $identity['variableKey'] = $options['variableKey']; + return $identity; + } + + private function fingerprint(array $identity, ?array $requester, array $options): ?string + { + try { + foreach (['__featurevisorStickyFeaturesSource', '__featurevisorStickyVariablesSource'] as $field) { + if (isset($options[$field])) { + $source = $options[$field]; + $this->stickySources[spl_object_id($source)] = $source; + } + } + $generation = isset($options['__featurevisorGeneration']) + ? $options['__featurevisorGeneration']() : null; + $childGeneration = isset($options['__featurevisorChildGeneration']) + ? $options['__featurevisorChildGeneration']() : null; + return json_encode(self::canonical([ + $identity, $requester, $options['dependencyPurpose'] ?? 'condition', $options['context'] ?? null, + isset($options['__featurevisorStickyFeaturesSource']) + ? spl_object_id($options['__featurevisorStickyFeaturesSource']) : ($options['stickyFeatures'] ?? null), + isset($options['__featurevisorStickyVariablesSource']) + ? spl_object_id($options['__featurevisorStickyVariablesSource']) : ($options['stickyVariables'] ?? null), + $generation, $childGeneration + ]), JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + } catch (\Throwable $error) { + return null; + } + } + + private static function canonical($value, array $ancestors = [], int $depth = 0) + { + // PHP arrays can contain references to themselves without an object id. + // A conservative bound skips such host inputs rather than recursing forever. + if ($depth > 64) throw new \RuntimeException('Unsupported recursive context'); + if (is_array($value)) { + if ($value === [] || array_keys($value) === range(0, count($value) - 1)) { + return ['array', array_map(fn($item) => self::canonical($item, $ancestors, $depth + 1), $value)]; + } + ksort($value); + $entries = []; + foreach ($value as $key => $item) $entries[] = [(string) $key, self::canonical($item, $ancestors, $depth + 1)]; + return ['map', $entries]; + } + if ($value instanceof \stdClass) { + $id = spl_object_id($value); + if (in_array($id, $ancestors, true)) throw new \RuntimeException('Circular context'); + $properties = get_object_vars($value); + ksort($properties); + $entries = []; + foreach ($properties as $key => $item) $entries[] = [(string) $key, self::canonical($item, [...$ancestors, $id], $depth + 1)]; + return ['object', $entries]; + } + if (is_object($value) || is_resource($value) || (is_float($value) && !is_finite($value))) { + throw new \RuntimeException('Unsupported context'); + } + return $value; + } + + private function minimal(array $result, bool $borrowed = false): ?array + { + $keys = ['type', 'featureKey', 'variableKey', 'reason', 'enabled', 'variationValue', 'variableValue']; + $minimal = array_intersect_key($result, array_flip($keys)); + if (isset($result['variation']['value'])) $minimal['variation'] = ['value' => $result['variation']['value']]; + if (isset($result['variableSchema']['type'])) $minimal['variableSchema'] = ['type' => $result['variableSchema']['type']]; + if (isset($result['variable']['type'])) $minimal['variable'] = ['type' => $result['variable']['type']]; + if (isset($result['variable']['compose'])) $minimal['variable']['compose'] = true; + if ($borrowed) return $minimal; + try { + self::canonical($minimal); + } catch (\Throwable $error) { + return null; + } + return $minimal; + } +} diff --git a/src/Events.php b/src/Events.php index 1318a87..ff18d9a 100644 --- a/src/Events.php +++ b/src/Events.php @@ -2,7 +2,7 @@ namespace Featurevisor; -class Events +final class Events { public static function getParamsForStickyFeaturesSetEvent(array $previous = [], array $current = [], bool $replace = false): array { @@ -14,132 +14,216 @@ public static function getParamsForStickyVariablesSetEvent(array $previous = [], return ['variables' => array_values(array_unique(array_merge(array_keys($previous), array_keys($current)))), 'replaced' => $replace]; } - public static function getParamsForDatafileSetEvent(array $previous, array $current, bool $replace = false): array + public static function getParamsForDatafileSetEvent(array $previous, array $current, bool $replace = false, ?array &$cache = null, ?array $incoming = null): array { - $previousFeatures = array_keys($previous['features'] ?? []); - $currentFeatures = array_keys($current['features'] ?? []); - $features = []; - foreach (array_unique(array_merge($previousFeatures, $currentFeatures)) as $key) { - if (self::entityChanged($previous['features'][$key] ?? null, $current['features'][$key] ?? null)) $features[] = $key; - } - $previousVariables = array_keys($previous['variables'] ?? []); - $currentVariables = array_keys($current['variables'] ?? []); - $variables = []; - foreach (array_unique(array_merge($previousVariables, $currentVariables)) as $key) { - if (self::entityChanged($previous['variables'][$key] ?? null, $current['variables'][$key] ?? null)) $variables[] = $key; + if (empty($previous['features']) && empty($previous['variables']) && empty($previous['segments'])) { + // Every incoming entity is already announced, so no dependency traversal is needed. + return [ + 'revision' => $current['revision'], + 'previousRevision' => $previous['revision'], + 'revisionChanged' => $previous['revision'] !== $current['revision'], + 'features' => array_keys($current['features'] ?? []), + 'variables' => array_keys($current['variables'] ?? []), + 'replaced' => $replace, + ]; } + + $touched = $replace || $incoming === null ? null : $incoming; + $features = self::changedEntities($previous['features'] ?? [], $current['features'] ?? [], $touched['features'] ?? ($touched === null ? null : [])); + $variables = self::changedEntities($previous['variables'] ?? [], $current['variables'] ?? [], $touched['variables'] ?? ($touched === null ? null : [])); $changedSegments = []; - foreach (array_unique(array_merge(array_keys($previous['segments'] ?? []), array_keys($current['segments'] ?? []))) as $key) { + $segmentKeys = $touched === null + ? array_unique(array_merge(array_keys($previous['segments'] ?? []), array_keys($current['segments'] ?? []))) + : array_keys($touched['segments'] ?? []); + foreach ($segmentKeys as $key) { if (($previous['segments'][$key] ?? null) !== ($current['segments'][$key] ?? null)) $changedSegments[] = $key; } - $allFeatureKeys = array_unique(array_merge($previousFeatures, $currentFeatures)); - do { - $before = count($features); - foreach ($allFeatureKeys as $key) { - if (in_array($key, $features, true)) continue; - foreach (array_filter([$previous['features'][$key] ?? null, $current['features'][$key] ?? null]) as $feature) { - [$segments, $required] = self::featureDependencies($feature); - if (array_intersect($segments, $changedSegments) || array_intersect($required, $features)) { - $features[] = $key; - break; - } - } - } - } while ($before !== count($features)); - - foreach (array_unique(array_merge($previousVariables, $currentVariables)) as $key) { - if (in_array($key, $variables, true)) continue; - foreach (array_filter([$previous['variables'][$key] ?? null, $current['variables'][$key] ?? null]) as $variable) { - [$segments, $required] = self::globalVariableDependencies($variable); - if (array_intersect($segments, $changedSegments) || array_intersect($required, $features)) { - $variables[] = $key; - break; - } + if ($cache !== null || $features !== [] || $variables !== [] || $changedSegments !== []) { + if ($cache === null) { + $cache = ['reverse' => [], 'owners' => []]; + self::indexDatafile($cache, $previous); } + self::addDependencyChanges($features, $variables, $changedSegments, $cache['reverse']); + self::updateIndex($cache, $current, $touched, $previous); + self::addDependencyChanges($features, $variables, $changedSegments, $cache['reverse']); } return [ 'revision' => $current['revision'], 'previousRevision' => $previous['revision'], 'revisionChanged' => $previous['revision'] !== $current['revision'], - 'features' => array_values(array_unique($features)), - 'variables' => array_values(array_unique($variables)), + 'features' => $features, + 'variables' => $variables, 'replaced' => $replace, ]; } - private static function entityChanged(?array $previous, ?array $current): bool + /** @param array> $previous @param array> $current */ + private static function changedEntities(array $previous, array $current, ?array $incoming = null): array { - return $previous === null || $current === null || empty($previous['hash']) || empty($current['hash']) || $previous['hash'] !== $current['hash']; + $changed = []; + $keys = array_unique(array_merge(array_keys($previous), array_keys($incoming ?? $current))); + foreach ($keys as $key) { + if (!isset($previous[$key]) || !isset($current[$key]) || empty($previous[$key]['hash']) || empty($current[$key]['hash']) || $previous[$key]['hash'] !== $current[$key]['hash']) $changed[] = $key; + } + return $changed; } - /** @param mixed $values */ - private static function requiredFeatureKeys($values): array + private static function addEdge(array &$index, string $kind, string $key, string $dependent): void { - if ($values === null) return []; - $isList = is_array($values) && ($values === [] || array_keys($values) === range(0, count($values) - 1)); - $items = $isList ? $values : [$values]; - return array_values(array_filter(array_map(fn($item) => is_string($item) ? $item : ($item['feature'] ?? ($item['key'] ?? null)), $items))); + $identity = json_encode([$kind, $key], JSON_THROW_ON_ERROR); + $index[$identity][$dependent] = true; } - /** @param mixed $value */ - private static function segmentKeys($value): array + private static function visitConditions(array &$index, $conditions, string $dependent): void { - if ($value === null || $value === '*') return []; - if (is_string($value)) { - if (str_starts_with($value, '{') || str_starts_with($value, '[')) { - try { return self::segmentKeys(json_decode($value, true, 512, JSON_THROW_ON_ERROR)); } catch (\Throwable $e) { return []; } + if (is_string($conditions)) { + if (preg_match('/^\s*[\{\[\"]/', $conditions) === 1) { + try { self::visitConditions($index, json_decode($conditions, true, 512, JSON_THROW_ON_ERROR), $dependent); } + catch (\Throwable $error) {} + } elseif ($conditions !== '*') self::addEdge($index, 'segment', $conditions, $dependent); + return; + } + if (!is_array($conditions)) return; + if (isset($conditions['variable'])) { + self::addEdge($index, isset($conditions['feature']) ? 'feature' : 'variable', $conditions['feature'] ?? $conditions['variable'], $dependent); + } elseif (isset($conditions['feature'])) self::addEdge($index, 'feature', $conditions['feature'], $dependent); + elseif (!isset($conditions['attribute'])) { + if (Internal\Normalize::isList($conditions)) { + foreach ($conditions as $item) self::visitConditions($index, $item, $dependent); + } else foreach (['and', 'or', 'not'] as $operator) { + if (isset($conditions[$operator])) self::visitConditions($index, $conditions[$operator], $dependent); } - return [$value]; } - if (is_array($value)) { - $result = []; - foreach (['and', 'or', 'not'] as $operator) if (isset($value[$operator])) $result = array_merge($result, self::segmentKeys($value[$operator])); - if ($result === []) foreach ($value as $item) $result = array_merge($result, self::segmentKeys($item)); - return array_values(array_unique($result)); + } + + private static function visitSelectors(array &$index, array $entry, string $dependent): void + { + if (array_key_exists('conditions', $entry)) self::visitConditions($index, $entry['conditions'], $dependent); + foreach (['force', 'traffic', 'variations', 'overrides', 'overrideProgram'] as $field) { + foreach ($entry[$field] ?? [] as $child) self::visitSelectors($index, $child, $dependent); + } + foreach ($entry['variableOverrides'] ?? [] as $overrides) { + foreach ($overrides as $override) self::visitSelectors($index, $override, $dependent); + } + } + + private static function indexDatafile(array &$cache, array $datafile, array $reused = []): void + { + foreach (['segments' => 'segment', 'features' => 'feature', 'variables' => 'variable'] as $name => $kind) { + foreach ($datafile[$name] ?? [] as $key => $entity) { + $dependent = json_encode([$kind, (string) $key], JSON_THROW_ON_ERROR); + if (isset($reused[$dependent])) { + $edges = $reused[$dependent]; + } else { + $index = []; + self::visitSelectors($index, $entity, $dependent); + $compose = $entity['compose'] ?? null; + if (is_string($compose)) self::addEdge($index, 'variable', $compose, $dependent); + elseif (is_array($compose)) foreach ($compose as $entry) { + if (isset($entry['feature'])) self::addEdge($index, 'feature', $entry['feature'], $dependent); + elseif (isset($entry['variable'])) self::addEdge($index, 'variable', $entry['variable'], $dependent); + } + $edges = array_keys($index); + } + $cache['owners'][$dependent] = $edges; + foreach ($edges as $identity) $cache['reverse'][$identity][$dependent] = true; + } } - return []; } - private static function overrideDependencies(array $groups): array + private static function updateIndex(array &$cache, array $current, ?array $incoming, array $previous): void { - $segments = $required = []; - foreach ($groups as $overrides) foreach ($overrides as $override) { - $segments = array_merge($segments, self::segmentKeys($override['segments'] ?? null)); - $required = array_merge($required, self::requiredFeatureKeys($override['requiredFeatures'] ?? null)); + $rebuild = $incoming === null; + $incoming = $incoming ?? $current; + $reused = []; + foreach (['segments' => 'segment', 'features' => 'feature', 'variables' => 'variable'] as $name => $kind) { + foreach ($incoming[$name] ?? [] as $key => $entity) { + $owner = json_encode([$kind, (string) $key], JSON_THROW_ON_ERROR); + // Hash equality alone cannot establish unchanged dependency edges. + // Numeric keys retain array_merge's existing reindexing behaviour. + if (($rebuild || is_string($key)) && isset($previous[$name][$key])) { + try { + if (self::isComparable($previous[$name][$key]) && $previous[$name][$key] === $entity) $reused[$owner] = $cache['owners'][$owner] ?? []; + } catch (\Error $error) { + // Recursive native metadata need not be comparable to index selectors. + } + } + if (!$rebuild) self::removeOwner($cache, $owner); + } } - return [array_unique($segments), array_unique($required)]; + if ($rebuild) $cache = ['reverse' => [], 'owners' => []]; + // Reinsert even reused edges in incoming order to retain traversal order. + self::indexDatafile($cache, $incoming, $reused); } - private static function featureDependencies(array $feature): array + /** + * Before PHP 8.4, comparing an array that contains itself is a fatal error no catch can stop. + * Only the left operand needs checking. A conservative bound skips such host metadata. + */ + private static function isComparable($value, int $depth = 0): bool { - $segments = []; - $requirements = array_key_exists('requiredFeatures', $feature) ? $feature['requiredFeatures'] : ($feature['required'] ?? null); - $required = self::requiredFeatureKeys($requirements); - foreach ($feature['traffic'] ?? [] as $traffic) { - $segments = array_merge($segments, self::segmentKeys($traffic['segments'] ?? null)); - [$nestedSegments, $nestedRequired] = self::overrideDependencies($traffic['variableOverrides'] ?? []); - $segments = array_merge($segments, $nestedSegments); - $required = array_merge($required, $nestedRequired); + if (PHP_VERSION_ID >= 80400 || !is_array($value)) return true; + if ($depth > 64) return false; + foreach ($value as $item) { + if (!self::isComparable($item, $depth + 1)) return false; } - foreach ($feature['force'] ?? [] as $force) $segments = array_merge($segments, self::segmentKeys($force['segments'] ?? null)); - foreach ($feature['variations'] ?? [] as $variation) { - [$nestedSegments, $nestedRequired] = self::overrideDependencies($variation['variableOverrides'] ?? []); - $segments = array_merge($segments, $nestedSegments); - $required = array_merge($required, $nestedRequired); + return true; + } + + private static function removeOwner(array &$cache, string $owner): void + { + foreach ($cache['owners'][$owner] ?? [] as $identity) { + unset($cache['reverse'][$identity][$owner]); + if (empty($cache['reverse'][$identity])) unset($cache['reverse'][$identity]); } - return [array_unique($segments), array_unique($required)]; + unset($cache['owners'][$owner]); } - private static function globalVariableDependencies(array $variable): array + private static function addDependencyChanges(array &$features, array &$variables, array $changedSegments, array $index): void { - $segments = []; - $required = self::requiredFeatureKeys($variable['requiredFeatures'] ?? null); - foreach ($variable['overrides'] ?? [] as $override) { - $segments = array_merge($segments, self::segmentKeys($override['segments'] ?? null)); - $required = array_merge($required, self::requiredFeatureKeys($override['requiredFeatures'] ?? null)); + $queue = []; + $featureSet = []; + $variableSet = []; + // Only strings match the decoded identities strictly. Numeric array keys + // must retain their existing integer and string notification entries. + foreach ($features as $key) if (is_string($key)) $featureSet[$key] = true; + foreach ($variables as $key) if (is_string($key)) $variableSet[$key] = true; + $seeds = []; + foreach (['feature' => $features, 'variable' => $variables, 'segment' => $changedSegments] as $kind => $keys) { + foreach ($keys as $key) { + $identity = json_encode([$kind, (string) $key], JSON_THROW_ON_ERROR); + $queue[] = $identity; + $seeds[$identity] = true; + $key = (string) $key; + if ($kind === 'feature' && !isset($featureSet[$key])) { + $features[] = $key; + $featureSet[$key] = true; + } + if ($kind === 'variable' && !isset($variableSet[$key])) { + $variables[] = $key; + $variableSet[$key] = true; + } + } + } + $seen = []; + for ($cursor = 0; $cursor < count($queue); $cursor++) { + $identity = $queue[$cursor]; + if (isset($seen[$identity])) continue; + $seen[$identity] = true; + if (!isset($seeds[$identity])) { + [$kind, $key] = json_decode($identity, true, 512, JSON_THROW_ON_ERROR); + if ($kind === 'feature' && !isset($featureSet[$key])) { + $features[] = $key; + $featureSet[$key] = true; + } + if ($kind === 'variable' && !isset($variableSet[$key])) { + $variables[] = $key; + $variableSet[$key] = true; + } + } + foreach ($index[$identity] ?? [] as $dependent => $_) $queue[] = $dependent; } - return [array_unique($segments), array_unique($required)]; } } diff --git a/src/Featurevisor.php b/src/Featurevisor.php index 27b688f..d1bfee9 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -24,12 +24,22 @@ class Featurevisor private array $datafile; /** @var array */ private array $regexCache; + /** @var array */ + private array $expressionCache; + private int $evaluationGeneration = 0; + private array $entityCache = []; + private ?array $dependencyIndex = null; private ModulesManager $modulesManager; private Emitter $emitter; /** @var callable|null */ private $onDiagnostic; /** @var array> */ private array $moduleDiagnosticSubscriptions; + /** @var array */ + private array $evaluationDependencies; + private Closure $regexResolver; + private Closure $diagnosticReporter; + private Closure $segmentResolver; private bool $closed; /** @@ -40,8 +50,8 @@ class Featurevisor * stickyFeatures?: array, * modules?: array @@ -68,6 +78,7 @@ private function __construct(array $options = []) $this->closed = false; $this->datafile = self::EMPTY_DATAFILE; $this->regexCache = []; + $this->expressionCache = []; $this->modulesManager = ModulesManager::createFromOptions([ 'modules' => $options['modules'] ?? [], 'reportDiagnostic' => function(array $diagnostic, ?array $module = null): void { @@ -80,6 +91,51 @@ private function __construct(array $options = []) $this->clearModuleDiagnosticSubscriptions($module); }, ]); + $this->regexResolver = function (string $pattern, string $flags): string { + return $this->getRegex($pattern, $flags); + }; + $this->diagnosticReporter = function (array $diagnostic): void { + $this->reportDiagnostic($diagnostic); + }; + $this->segmentResolver = function (string $segmentKey): ?array { + return $this->getSegment($segmentKey); + }; + $this->evaluationDependencies = [ + 'reportDiagnostic' => $this->diagnosticReporter, + 'diagnosticIsObserved' => function (string $level): bool { + return $this->diagnosticIsObserved($level); + }, + 'modulesManager' => $this->modulesManager, + 'datafile' => [ + 'getFeature' => function (string $featureKey): ?array { + return $this->getFeature($featureKey); + }, + 'allConditionsAreMatched' => function ($conditions, array $resolvedContext): bool { + return $this->allConditionsAreMatched($conditions, $resolvedContext); + }, + 'allSegmentsAreMatched' => function ($segments, array $resolvedContext): bool { + return $this->allSegmentsAreMatched($segments, $resolvedContext); + }, + 'getMatchedTraffic' => function (array $traffic, array $resolvedContext, array $dependencies): ?array { + return $this->getMatchedTraffic($traffic, $resolvedContext, $dependencies); + }, + 'getMatchedAllocation' => function (array $traffic, int $bucketValue): ?array { + return $this->getMatchedAllocation($traffic, $bucketValue); + }, + 'getMatchedForce' => function ($featureKey, array $resolvedContext, array $dependencies): array { + return $this->getMatchedForce($featureKey, $resolvedContext, $dependencies); + }, + 'conditionsAreMatched' => function ($conditions, array $dependencies): bool { + return $this->conditionsAreMatched($conditions, $dependencies); + }, + 'parseConditionsIfStringified' => function ($conditions) { + return $this->parseConditionsIfStringified($conditions); + }, + 'parseSegmentsIfStringified' => function ($segments) { + return $this->parseSegmentsIfStringified($segments); + }, + ], + ]; if (isset($options['datafile'])) { $this->setDatafile($options['datafile'], true); @@ -94,16 +150,18 @@ private function __construct(array $options = []) /** * @param string|array $datafile + * @return bool Whether the datafile was accepted, even if unchanged */ - public function setDatafile($datafile, bool $replace = false): void + public function setDatafile($datafile, bool $replace = false): bool { if ($this->closed) { - return; + return false; } try { - $incomingDatafile = is_string($datafile) - ? json_decode($datafile, true, 512, JSON_THROW_ON_ERROR) + $jsonInput = is_string($datafile); + $incomingDatafile = $jsonInput + ? self::decodeJsonDatafile($datafile) : $datafile; if (!is_array($incomingDatafile) || !is_string($incomingDatafile['schemaVersion'] ?? null) @@ -113,13 +171,24 @@ public function setDatafile($datafile, bool $replace = false): void || (isset($incomingDatafile['variables']) && !is_array($incomingDatafile['variables']))) { throw new \InvalidArgumentException('Invalid datafile'); } + $errors = []; + if ($incomingDatafile['schemaVersion'] === '2') { + [$incomingDatafile, $errors] = Internal\Normalize::datafile($incomingDatafile); + } $storedDatafile = $replace ? $incomingDatafile : $this->mergeDatafiles($this->datafile, $incomingDatafile); - $details = Events::getParamsForDatafileSetEvent($this->datafile, $storedDatafile, $replace); + $details = Events::getParamsForDatafileSetEvent($this->datafile, $storedDatafile, $replace, $this->dependencyIndex, $incomingDatafile); + if ($replace) $this->entityCache = []; + else foreach (['features', 'segments', 'variables'] as $kind) { + foreach ($incomingDatafile[$kind] ?? [] as $key => $_) unset($this->entityCache[$kind][$key]); + } $this->datafile = $storedDatafile; + $this->evaluationGeneration++; $this->regexCache = []; + $this->expressionCache = []; + foreach ($errors as $diagnostic) $this->reportDiagnostic($diagnostic); $this->reportDiagnostic([ 'level' => 'info', @@ -128,6 +197,7 @@ public function setDatafile($datafile, bool $replace = false): void 'details' => $details, ]); $this->emitter->trigger('datafile_set', $details); + return true; } catch (\Throwable $e) { $this->reportDiagnostic([ 'level' => 'error', @@ -136,9 +206,19 @@ public function setDatafile($datafile, bool $replace = false): void 'originalError' => $e, 'details' => [], ]); + return false; } } + /** @return array */ + private static function decodeJsonDatafile(string $datafile): array + { + $decoded = json_decode($datafile, false, 512, JSON_THROW_ON_ERROR); + $normalized = Internal\JsonValues::normalizeDatafileValue($decoded); + if (!is_array($normalized)) throw new \InvalidArgumentException('Invalid datafile'); + return $normalized; + } + public function setStickyFeatures(array $sticky, bool $replace = false): void { if ($this->closed) { @@ -152,6 +232,7 @@ public function setStickyFeatures(array $sticky, bool $replace = false): void } else { $this->stickyFeatures = array_merge($this->stickyFeatures ?? [], $sticky); } + $this->evaluationGeneration++; $params = Events::getParamsForStickyFeaturesSetEvent($previousStickyFeatures, $this->stickyFeatures, $replace); @@ -169,6 +250,7 @@ public function setStickyVariables(array $sticky, bool $replace = false): void if ($this->closed) return; $previous = $this->stickyVariables ?? []; $this->stickyVariables = $replace ? $sticky : array_merge($previous, $sticky); + $this->evaluationGeneration++; $params = Events::getParamsForStickyVariablesSetEvent($previous, $this->stickyVariables, $replace); $this->reportDiagnostic(['level' => 'info', 'code' => 'sticky_variables_set', 'message' => 'Sticky variables set', 'details' => $params]); $this->emitter->trigger('sticky_variables_set', $params); @@ -186,19 +268,19 @@ public function getSchemaVersion(): string public function getSegment(string $segmentKey): ?array { - $segment = $this->datafile['segments'][$segmentKey] ?? null; - if (!is_array($segment)) { - return null; - } - - $segment['conditions'] = Conditions::parseConditionsIfStringified( - $segment['conditions'], - function (array $diagnostic): void { - $this->reportDiagnostic($diagnostic); - } - ); + return $this->readEntity('segments', $segmentKey); + } - return $segment; + private function readEntity(string $name, string $key): ?array + { + if (array_key_exists($key, $this->entityCache[$name] ?? [])) return $this->entityCache[$name][$key]; + $raw = $this->datafile[$name][$key] ?? null; + if (!is_array($raw)) return null; + $kind = ['segments' => 'segment', 'features' => 'feature', 'variables' => 'variable'][$name]; + [$value, $errors] = Internal\Normalize::entity($kind, $key, $raw); + $this->entityCache[$name][$key] = $value; + foreach ($errors as $diagnostic) $this->reportDiagnostic($diagnostic); + return $value; } public function getFeatureKeys(): array @@ -223,15 +305,12 @@ public function hasVariations(string $featureKey): bool public function getFeature(string $featureKey): ?array { - $feature = $this->datafile['features'][$featureKey] ?? null; - - return is_array($feature) ? $feature : null; + return $this->readEntity('features', $featureKey); } private function getGlobalVariable(string $variableKey): ?array { - $variable = $this->datafile['variables'][$variableKey] ?? null; - return is_array($variable) ? $variable : null; + return $this->readEntity('variables', $variableKey); } public function setLogLevel(string $level): void @@ -245,7 +324,13 @@ public function addModule(array $module): ?callable return null; } - return $this->modulesManager->add($module); + $remove = $this->modulesManager->add($module); + if ($remove === null) return null; + $this->evaluationGeneration++; + return function () use ($remove): void { + $this->evaluationGeneration++; + $remove(); + }; } public function removeModule(string $name): void @@ -254,6 +339,7 @@ public function removeModule(string $name): void return; } + $this->evaluationGeneration++; $this->modulesManager->remove($name); } @@ -273,8 +359,13 @@ public function close(): void } $this->closed = true; + $this->entityCache = []; + $this->dependencyIndex = null; + $this->evaluationGeneration++; $this->modulesManager->closeAll(); $this->moduleDiagnosticSubscriptions = []; + $this->expressionCache = []; + $this->regexCache = []; $this->emitter->clearAll(); } @@ -292,6 +383,7 @@ public function setContext(array $context, bool $replace = false): void } else { $this->context = array_merge($this->context, $context); } + $this->evaluationGeneration++; $this->emitter->trigger('context_set', [ 'context' => $this->context, @@ -360,6 +452,9 @@ private function clearModuleDiagnosticSubscriptions(array $module): void private function reportDiagnostic(array $diagnostic, ?array $sourceModule = null): void { $diagnostic['level'] = $diagnostic['level'] ?? 'info'; + if (!$this->diagnosticIsObserved($diagnostic['level'], $sourceModule)) { + return; + } if ($sourceModule && isset($sourceModule['name'])) { $diagnostic['module'] = $sourceModule['name']; } @@ -406,6 +501,24 @@ private function reportDiagnostic(array $diagnostic, ?array $sourceModule = null } } + /** @param array|null $sourceModule */ + private function diagnosticIsObserved(string $level, ?array $sourceModule = null): bool + { + if ($level === 'error') { + return true; + } + foreach ($this->moduleDiagnosticSubscriptions as $subscription) { + if ($sourceModule && ($subscription['moduleId'] ?? null) === ($sourceModule['id'] ?? null)) { + continue; + } + if ($this->levelAllows($level, $subscription['level'])) { + return true; + } + } + + return $this->levelAllows($level, $this->logLevel); + } + /** * @param array $previous * @param array $incoming @@ -493,7 +606,8 @@ private function getRegex(string $pattern, string $flags = ''): string } $pcreFlags = ''; - foreach (str_split($flags) as $flag) { + // Before PHP 8.2, str_split('') returns [''] rather than []. + foreach ($flags === '' ? [] : str_split($flags) as $flag) { if (strpos('imsu', $flag) !== false && strpos($pcreFlags, $flag) === false) { $pcreFlags .= $flag; } elseif ($flag !== 'g' && $flag !== 'y') { @@ -501,23 +615,79 @@ private function getRegex(string $pattern, string $flags = ''): string } } - $this->regexCache[$cacheKey] = '~'.str_replace('~', '\\~', $pattern).'~'.$pcreFlags; + $this->regexCache[$cacheKey] = '~(*UTF)'.str_replace('~', '\\~', self::normalizePortableRegex($pattern)).'~'.$pcreFlags; return $this->regexCache[$cacheKey]; } + private static function normalizePortableRegex(string $pattern): string + { + $inside = ['d' => '0-9', 'D' => '\\x{0}-\\x{2F}\\x{3A}-\\x{10FFFF}', 'w' => 'A-Za-z0-9_', 'W' => '\\x{0}-\\x{2F}\\x{3A}-\\x{40}\\x{5B}-\\x{5E}\\x{60}\\x{7B}-\\x{10FFFF}']; + $outside = ['d' => '[0-9]', 'D' => '[^0-9]', 'w' => '(?-i:[A-Za-z0-9_])', 'W' => '(?-i:[^A-Za-z0-9_])']; + $result = ''; + $inClass = false; + for ($index = 0, $length = strlen($pattern); $index < $length; ++$index) { + $char = $pattern[$index]; + if ($char === '\\' && $index + 1 < $length) { + $code = $pattern[++$index]; + $result .= ($inClass ? $inside : $outside)[$code] ?? '\\'.$code; + continue; + } + if ($char === '[' && !$inClass) $inClass = true; + elseif ($char === ']' && $inClass) $inClass = false; + $result .= $char; + } + return $result; + } + + /** @param mixed $conditions @return mixed */ + private function parseConditionsIfStringified($conditions) + { + if (!is_string($conditions) || $conditions === '*') { + return $conditions; + } + $cacheKey = 'json:'.$conditions; + if (array_key_exists($cacheKey, $this->expressionCache)) { + return $this->expressionCache[$cacheKey]; + } + + try { + $parsed = Internal\JsonValues::normalizeConditionsValue(json_decode($conditions, false, 512, JSON_THROW_ON_ERROR)); + $this->expressionCache[$cacheKey] = $parsed; + return $parsed; + } catch (\Throwable $error) { + $this->reportDiagnostic([ + 'level' => 'error', + 'code' => 'conditions_parse_error', + 'message' => 'Error parsing conditions', + 'originalError' => $error, + 'details' => ['conditions' => $conditions], + ]); + return $conditions; + } + } + + /** @param mixed $segments @return mixed */ + private function parseSegmentsIfStringified($segments) + { + if (!is_string($segments) || (($segments[0] ?? '') !== '{' && ($segments[0] ?? '') !== '[')) { + return $segments; + } + $cacheKey = 'json:'.$segments; + if (!array_key_exists($cacheKey, $this->expressionCache)) { + $this->expressionCache[$cacheKey] = json_decode($segments, true, 512, JSON_THROW_ON_ERROR); + } + return $this->expressionCache[$cacheKey]; + } + /** @param mixed $conditions @param array $context */ private function allConditionsAreMatched($conditions, array $context): bool { return Conditions::allConditionsAreMatched( $conditions, $context, - function (string $pattern, string $flags): string { - return $this->getRegex($pattern, $flags); - }, - function (array $diagnostic): void { - $this->reportDiagnostic($diagnostic); - } + $this->regexResolver, + $this->diagnosticReporter ); } @@ -527,23 +697,17 @@ private function allSegmentsAreMatched($segments, array $context): bool return Conditions::allSegmentsAreMatched( $segments, $context, - function (string $segmentKey): ?array { - return $this->getSegment($segmentKey); - }, - function (string $pattern, string $flags): string { - return $this->getRegex($pattern, $flags); - }, - function (array $diagnostic): void { - $this->reportDiagnostic($diagnostic); - } + $this->segmentResolver, + $this->regexResolver, + $this->diagnosticReporter ); } /** @param array> $traffic @param array $context */ - private function getMatchedTraffic(array $traffic, array $context): ?array + private function getMatchedTraffic(array $traffic, array $context, array $dependencies): ?array { foreach ($traffic as $trafficItem) { - if ($this->allSegmentsAreMatched(Conditions::parseSegmentsIfStringified($trafficItem['segments']), $context)) { + if (array_key_exists('conditions', $trafficItem) && $this->conditionsAreMatched($trafficItem['conditions'], $dependencies)) { return $trafficItem; } } @@ -564,7 +728,7 @@ private function getMatchedAllocation(array $traffic, int $bucketValue): ?array } /** @param string|array $featureKey @param array $context */ - private function getMatchedForce($featureKey, array $context): array + private function getMatchedForce($featureKey, array $context, array $dependencies): array { $feature = is_string($featureKey) ? $this->getFeature($featureKey) : $featureKey; if (!$feature) { @@ -572,22 +736,7 @@ private function getMatchedForce($featureKey, array $context): array } foreach ($feature['force'] ?? [] as $index => $force) { - if (array_key_exists('conditions', $force) && $this->allConditionsAreMatched( - Conditions::parseConditionsIfStringified( - $force['conditions'], - function (array $diagnostic): void { - $this->reportDiagnostic($diagnostic); - } - ), - $context - )) { - return ['force' => $force, 'forceIndex' => $index]; - } - - if (array_key_exists('segments', $force) && $this->allSegmentsAreMatched( - Conditions::parseSegmentsIfStringified($force['segments']), - $context - )) { + if (array_key_exists('conditions', $force) && $this->conditionsAreMatched($force['conditions'], $dependencies)) { return ['force' => $force, 'forceIndex' => $index]; } } @@ -600,46 +749,31 @@ function (array $diagnostic): void { * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * __featurevisorChildSticky?: array + * __featurevisorChildStickyFeatures?: array, + * __featurevisorChildStickyVariables?: array * } $options * @return array */ private function getEvaluationDependencies(array $context, array $options = []): array { - $sticky = array_key_exists('__featurevisorChildStickyFeatures', $options) - ? $options['__featurevisorChildStickyFeatures'] - : $this->stickyFeatures; - $datafile = [ - 'getFeature' => function (string $featureKey): ?array { - return $this->getFeature($featureKey); - }, - 'allConditionsAreMatched' => function ($conditions, array $resolvedContext): bool { - return $this->allConditionsAreMatched($conditions, $resolvedContext); - }, - 'allSegmentsAreMatched' => function ($segments, array $resolvedContext): bool { - return $this->allSegmentsAreMatched($segments, $resolvedContext); - }, - 'getMatchedTraffic' => function (array $traffic, array $resolvedContext): ?array { - return $this->getMatchedTraffic($traffic, $resolvedContext); - }, - 'getMatchedAllocation' => function (array $traffic, int $bucketValue): ?array { - return $this->getMatchedAllocation($traffic, $bucketValue); - }, - 'getMatchedForce' => function ($featureKey, array $resolvedContext): array { - return $this->getMatchedForce($featureKey, $resolvedContext); - }, - ]; - - $dependencies = [ - 'context' => $this->getContext($context), - 'reportDiagnostic' => function (array $diagnostic): void { - $this->reportDiagnostic($diagnostic); - }, - 'modulesManager' => $this->modulesManager, - 'datafile' => $datafile, - 'stickyFeatures' => $sticky, - 'sticky' => $sticky, - ]; + $stickySource = $options['__featurevisorStickyFeaturesSource'] ?? + fn(): array => $options['__featurevisorChildStickyFeatures'] ?? ($this->stickyFeatures ?? []); + $variableSource = $options['__featurevisorStickyVariablesSource'] ?? + fn(): array => $options['__featurevisorChildStickyVariables'] ?? ($this->stickyVariables ?? []); + $dependencies = $this->evaluationDependencies; + $dependencies['context'] = !empty($options['__featurevisorEffectiveContext']) ? $context : $this->getContext($context); + $dependencies['stickyFeatures'] = $stickySource(); + $dependencies['stickyVariables'] = $variableSource(); + $dependencies['__featurevisorStickyFeaturesSource'] = $stickySource; + $dependencies['__featurevisorStickyVariablesSource'] = $variableSource; + $dependencies['__featurevisorChildGeneration'] = $options['__featurevisorChildGeneration'] ?? null; + $dependencies['__featurevisorSession'] = $options['__featurevisorSession'] ?? null; + $dependencies['__featurevisorPrerequisite'] = $options['__featurevisorPrerequisite'] ?? false; + $dependencies['__featurevisorGeneration'] = fn(): int => $this->evaluationGeneration; + if (!empty($options['__featurevisorPrerequisite']) && isset($options['__featurevisorSession'])) { + $dependencies['dependencyOf'] = $options['__featurevisorSession']->dependencyOf(); + $dependencies['dependencyPurpose'] = $options['dependencyPurpose'] ?? 'condition'; + } if (array_key_exists('defaultVariationValue', $options)) { $dependencies['defaultVariationValue'] = $options['defaultVariationValue']; @@ -656,7 +790,8 @@ private function getEvaluationDependencies(array $context, array $options = []): * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * __featurevisorChildSticky?: array + * __featurevisorChildStickyFeatures?: array, + * __featurevisorChildStickyVariables?: array * } $options * @return array */ @@ -684,6 +819,7 @@ public function isEnabled(string $featureKey, array $context = [], array $option return ($evaluation['enabled'] ?? false) === true; } catch (\Throwable $error) { + if ($error instanceof RequirementError && !empty($options['__featurevisorPrerequisite'])) throw $error; $this->reportDiagnostic([ 'level' => 'error', 'code' => 'evaluation_error', @@ -737,6 +873,7 @@ public function getVariation(string $featureKey, array $context = [], array $opt return null; } catch (\Throwable $e) { + if ($e instanceof RequirementError && !empty($options['__featurevisorPrerequisite'])) throw $e; $this->reportDiagnostic([ 'level' => 'error', 'code' => 'evaluation_error', @@ -774,86 +911,309 @@ public function evaluateVariable(string $featureOrVariableKey, string|array|null ])); } - /** @param mixed $requirements */ - private function requiredFeaturesAreMatched($requirements, array $context, array $options): bool + private function conditionsAreMatched($conditions, array $dependencies): bool { - if ($requirements === null) return true; - $isList = is_array($requirements) && ($requirements === [] || array_keys($requirements) === range(0, count($requirements) - 1)); - $items = $isList ? $requirements : [$requirements]; - unset($options['defaultVariationValue'], $options['defaultVariableValue']); - foreach ($items as $required) { - $key = is_string($required) ? $required : $required['feature']; - $enabled = is_string($required) ? true : ($required['enabled'] ?? true); - $variation = is_string($required) ? null : ($required['variation'] ?? null); - if ($this->isEnabled($key, $context, $options) !== $enabled) return false; - if ($variation !== null && $this->getVariation($key, $context, $options) !== $variation) return false; + try { return $this->matchConditions($conditions, $dependencies); } + catch (Internal\ConditionsParseFailure $error) { return false; } + } + + private static function attributeOnlyConditions($conditions): bool + { + if (!is_array($conditions)) return false; + if (array_key_exists('variable', $conditions) || array_key_exists('feature', $conditions)) return false; + if (array_key_exists('attribute', $conditions)) return true; + if (!Internal\Normalize::isList($conditions)) { + foreach (['and', 'or', 'not'] as $operator) { + if (array_key_exists($operator, $conditions)) { + $conditions = $conditions[$operator]; + break; + } + } } + if (!Internal\Normalize::isList($conditions)) return false; + foreach ($conditions as $child) if (!self::attributeOnlyConditions($child)) return false; return true; } + private function matchConditions($conditions, array $dependencies): bool + { + if ($conditions instanceof Internal\InvalidConditions) throw new Internal\ConditionsParseFailure(); + if ($conditions === '*') return true; + if (is_string($conditions)) { + $segment = $this->getSegment($conditions); + if ($segment !== null && self::attributeOnlyConditions($segment['conditions'] ?? null)) { + return $this->matchConditions($segment['conditions'] ?? null, $dependencies); + } + return $segment !== null && $dependencies['__featurevisorSession']->segment( + $conditions, fn(): bool => $this->matchConditions($segment['conditions'] ?? null, $dependencies) + ); + } + if (!is_array($conditions)) return false; + if (array_key_exists('variable', $conditions)) return $this->variableConditionIsMatched($conditions, $dependencies); + if (isset($conditions['feature'])) { + $flag = $this->evaluateDependencyFeature('flag', $conditions['feature'], $dependencies); + if ((($flag['enabled'] ?? false) === true) !== ($conditions['enabled'] ?? true)) return false; + if (!array_key_exists('variation', $conditions)) return true; + $variation = $this->evaluateDependencyFeature('variation', $conditions['feature'], $dependencies); + $actual = array_key_exists('variationValue', $variation) ? $variation['variationValue'] : ($variation['variation']['value'] ?? null); + return $actual === $conditions['variation']; + } + if (isset($conditions['attribute'])) return $this->allConditionsAreMatched($conditions, $dependencies['context']); + foreach (['and', 'or', 'not'] as $operator) { + if (!isset($conditions[$operator]) || !Internal\Normalize::isList($conditions[$operator])) continue; + $items = $conditions[$operator]; + if ($items === []) return false; + if ($operator === 'or') { + foreach ($items as $item) if ($this->matchConditions($item, $dependencies)) return true; + return false; + } + $matched = true; + foreach ($items as $item) if (!$this->matchConditions($item, $dependencies)) { $matched = false; break; } + return $operator === 'not' ? !$matched : $matched; + } + if (Internal\Normalize::isList($conditions) && $conditions !== []) { + foreach ($conditions as $item) if (!$this->matchConditions($item, $dependencies)) return false; + return true; + } + return false; + } + + private function evaluateDependencyFeature(string $type, string $key, array $dependencies, string $purpose = 'condition'): array + { + unset($dependencies['type'], $dependencies['featureKey'], $dependencies['variableKey'], + $dependencies['defaultVariableValue'], $dependencies['defaultVariationValue']); + return Evaluate::evaluateWithModules(array_merge($dependencies, [ + 'type' => $type, 'featureKey' => $key, '__featurevisorPrerequisite' => true, 'dependencyPurpose' => $purpose, + ])); + } + + /** @param mixed $required @return mixed */ + private function evaluateRequiredVariable($required, array $dependencies, string $purpose = 'condition') + { + $options = [ + '__featurevisorStickyFeaturesSource' => $dependencies['__featurevisorStickyFeaturesSource'] ?? null, + '__featurevisorStickyVariablesSource' => $dependencies['__featurevisorStickyVariablesSource'] ?? null, + '__featurevisorChildGeneration' => $dependencies['__featurevisorChildGeneration'] ?? null, + '__featurevisorPreserveJsonObjects' => true, + '__featurevisorSession' => $dependencies['__featurevisorSession'] ?? null, + '__featurevisorPrerequisite' => true, + 'dependencyPurpose' => $purpose, + '__featurevisorEffectiveContext' => true, + ]; + $evaluate = fn() => isset($required['feature']) + ? $this->getVariable($required['feature'], $required['variable'], $dependencies['context'], $options) + : $this->getVariable($required['variable'], $dependencies['context'], $options); + $memoOptions = array_merge($dependencies, ['type' => 'variable', 'variableKey' => $required['variable'], 'dependencyPurpose' => $purpose]); + unset($memoOptions['featureKey']); + if (isset($required['feature'])) $memoOptions['featureKey'] = $required['feature']; + return $dependencies['__featurevisorSession']->memoizeGetter($memoOptions, $evaluate); + } + + private function variableConditionIsMatched($requirements, array $dependencies): bool + { + $value = $this->evaluateRequiredVariable($requirements, $dependencies); + try { + $present = true; + if (array_key_exists('path', $requirements)) { + if (!is_array($requirements['path'])) throw new \RuntimeException('Variable requirement path must be compiled'); + foreach ($requirements['path'] as $part) { + if (is_string($part) && in_array($part, ['__proto__', 'prototype', 'constructor'], true)) { + throw new \RuntimeException('Unsafe variable requirement path'); + } + $isNumericPart = is_int($part) || is_float($part); + $isIndex = $isNumericPart + && is_finite((float) $part) + && $part >= 0 + && $part <= 9007199254740991 + && floor((float) $part) === (float) $part; + if (($isNumericPart && !$isIndex) || (!$isNumericPart && !is_string($part))) { + throw new \RuntimeException('Invalid variable requirement path'); + } + $index = $isIndex ? (int) $part : null; + if ($value instanceof \stdClass) { + if ($index !== null || !property_exists($value, $part)) { + $present = false; + $value = null; + break; + } + $value = $value->{$part}; + continue; + } + + $isListValue = is_array($value) && ($value === [] || array_keys($value) === range(0, count($value) - 1)); + if (is_string($part) && $isListValue && $part === 'length') { + $value = count($value); + continue; + } + $arrayStringIndex = is_string($part) && $isListValue && preg_match('/^(0|[1-9][0-9]*)$/', $part) === 1; + $validContainer = is_array($value) && (($isIndex && $isListValue) || (is_string($part) && !$isListValue) || $arrayStringIndex); + $lookup = $arrayStringIndex ? (int) $part : ($index ?? $part); + if (!$validContainer || !array_key_exists($lookup, $value)) { + $present = false; + $value = null; + break; + } + $value = $value[$lookup]; + } + } + + return Conditions::valueIsMatched( + $value, + $requirements, + function (string $pattern, string $flags): string { + return $this->getRegex($pattern, $flags); + }, + $present + ); + } catch (\Throwable $error) { + throw $error instanceof RequirementError ? $error : new RequirementError($error->getMessage(), 0, $error); + } + } + private function evaluateGlobalVariable(string $variableKey, array $context = [], array $options = []): array { - $evaluationOptions = ['type' => 'variable', 'variableKey' => $variableKey, 'context' => $this->getContext($context)]; + $evaluationOptions = ['type' => 'variable', 'variableKey' => $variableKey, 'context' => !empty($options['__featurevisorEffectiveContext']) ? $context : $this->getContext($context)]; + $session = $options['__featurevisorSession'] ?? new EvaluationSession(); + $options['__featurevisorSession'] = $session; + $stickySource = $options['__featurevisorStickyFeaturesSource'] ?? + fn(): array => $options['__featurevisorChildStickyFeatures'] ?? ($this->stickyFeatures ?? []); + $variableSource = $options['__featurevisorStickyVariablesSource'] ?? + fn(): array => $options['__featurevisorChildStickyVariables'] ?? ($this->stickyVariables ?? []); + $options['__featurevisorStickyFeaturesSource'] = $stickySource; + $options['__featurevisorStickyVariablesSource'] = $variableSource; + if (!empty($options['__featurevisorPrerequisite'])) { + $evaluationOptions['dependencyOf'] = $session->dependencyOf(); + $evaluationOptions['dependencyPurpose'] = $options['dependencyPurpose'] ?? 'condition'; + } if (array_key_exists('defaultVariableValue', $options)) $evaluationOptions['defaultVariableValue'] = $options['defaultVariableValue']; try { + return $session->run(array_merge($evaluationOptions, [ + 'stickyFeatures' => $stickySource(), + 'stickyVariables' => $variableSource(), + '__featurevisorStickyFeaturesSource' => $stickySource, + '__featurevisorStickyVariablesSource' => $variableSource, + '__featurevisorGeneration' => fn(): int => $this->evaluationGeneration, + '__featurevisorChildGeneration' => $options['__featurevisorChildGeneration'] ?? null, + ]), !empty($options['__featurevisorPrerequisite']), function () use (&$evaluationOptions, $options, $session): array { $evaluationOptions = $this->modulesManager->runBeforeEvaluationModules($evaluationOptions); + $session->updateCurrent($evaluationOptions); + foreach (['stickyFeatures', 'stickyVariables'] as $field) { + if (array_key_exists($field, $evaluationOptions)) { + $value = $evaluationOptions[$field]; + $options['__featurevisor'.ucfirst($field).'Source'] = static fn(): array => $value ?? []; + } + } + $options['__featurevisorEffectiveContext'] = true; $resolvedKey = $evaluationOptions['variableKey']; $resolvedContext = $evaluationOptions['context']; $variable = $this->getGlobalVariable($resolvedKey); $evaluation = ['type' => 'variable', 'variableKey' => $resolvedKey, 'reason' => Evaluation::VARIABLE_NOT_FOUND]; - $sticky = $options['__featurevisorChildStickyVariables'] ?? ($this->stickyVariables ?? []); + $sticky = $options['__featurevisorStickyVariablesSource'](); if (array_key_exists($resolvedKey, $sticky)) { $evaluation = array_merge($evaluation, ['reason' => Evaluation::STICKY, 'variable' => $variable, 'variableValue' => $sticky[$resolvedKey]]); } elseif ($variable) { - if (!$this->requiredFeaturesAreMatched($variable['requiredFeatures'] ?? null, $resolvedContext, $options)) { - $evaluation = array_merge($evaluation, ['reason' => 'required_features_unmet', 'variable' => $variable]); - $valueKey = !empty($variable['useDefaultWhenDisabled']) ? 'defaultValue' : 'disabledValue'; + $dependencies = $this->getEvaluationDependencies($resolvedContext, $options); + $dependencies['__featurevisorSession'] = $session; + $composed = array_key_exists('compose', $variable); + if (array_key_exists('conditions', $variable) && !$this->conditionsAreMatched($variable['conditions'], $dependencies)) { + $evaluation = array_merge($evaluation, [ + 'reason' => Evaluation::CONDITIONS_UNMET, 'variable' => $variable, + 'conditions' => Internal\Normalize::publicConditions($variable['conditions']), + ]); + $valueKey = !$composed && !empty($variable['useDefaultWhenDisabled']) ? 'defaultValue' : 'disabledValue'; if (array_key_exists($valueKey, $variable)) $evaluation['variableValue'] = $variable[$valueKey]; + elseif ($composed) $evaluation['variableValue'] = null; } else { - foreach ($variable['overrides'] ?? [] as $index => $override) { - if (!$this->requiredFeaturesAreMatched($override['requiredFeatures'] ?? null, $resolvedContext, $options)) continue; - $conditionsMatch = !isset($override['conditions']) || $this->allConditionsAreMatched( - Conditions::parseConditionsIfStringified($override['conditions'], fn(array $d) => $this->reportDiagnostic($d)), - $resolvedContext - ); - $segmentsMatch = !isset($override['segments']) || $this->allSegmentsAreMatched( - Conditions::parseSegmentsIfStringified($override['segments']), $resolvedContext - ); - if ($conditionsMatch && $segmentsMatch) { - $evaluation = array_merge($evaluation, [ - 'reason' => Evaluation::VARIABLE_OVERRIDE_RULE, - 'variable' => $variable, - 'variableValue' => $override['value'] ?? null, - 'variableOverrideIndex' => $index, - ]); - if (isset($override['key'])) $evaluation['variableOverrideKey'] = $override['key']; - if (isset($override['keyPath'])) $evaluation['variableOverridePath'] = $override['keyPath']; - break; + $source = $composed ? $this->composeVariable($variable['compose'], $dependencies) : ($variable['defaultValue'] ?? null); + $program = $variable['overrideProgram'] ?? null; + $overrides = $program ?? ($variable['overrides'] ?? []); + $appliedPaths = []; + $accumulatedValue = $source; + $matchedIndex = null; + $matchedOverride = null; + for ($index = 0; $index < count($overrides);) { + $override = $overrides[$index]; + $matched = !array_key_exists('conditions', $override) + || $this->conditionsAreMatched($override['conditions'], $dependencies); + if ($matched) { + if ($program !== null) { + $accumulatedValue = Mutations::apply($accumulatedValue, $override['operations'] ?? null); + $appliedPaths[] = $override['keyPath'] ?? [$override['key']]; + } + $matchedIndex = $index; + $matchedOverride = $override; + if ($program === null) break; + } + $next = $program !== null + ? ($matched ? ($override['onMatch'] ?? null) : ($override['onMiss'] ?? null)) + : $index + 1; + if (!is_int($next) || $next <= $index || $next > count($overrides)) { + throw new \RuntimeException('Invalid variable override program jump'); } + $index = $next; } - if ($evaluation['reason'] === Evaluation::VARIABLE_NOT_FOUND) { - $evaluation = array_merge($evaluation, ['reason' => Evaluation::VARIABLE_DEFAULT, 'variable' => $variable]); - if (array_key_exists('defaultValue', $variable)) $evaluation['variableValue'] = $variable['defaultValue']; + if ($matchedOverride !== null) { + $evaluation = array_merge($evaluation, [ + 'reason' => Evaluation::VARIABLE_OVERRIDE_RULE, + 'variable' => $variable, + 'variableValue' => $program !== null + ? $accumulatedValue + : (array_key_exists('value', $matchedOverride) ? $matchedOverride['value'] : null), + 'variableOverrideIndex' => $matchedIndex, + ]); + if (isset($matchedOverride['key'])) $evaluation['variableOverrideKey'] = $matchedOverride['key']; + if (isset($matchedOverride['keyPath'])) $evaluation['variableOverridePath'] = $matchedOverride['keyPath']; + if ($program !== null) $evaluation['variableOverridePaths'] = $appliedPaths; + } else { + $evaluation = array_merge($evaluation, ['reason' => $composed ? Evaluation::VARIABLE_COMPOSED : Evaluation::VARIABLE_DEFAULT, 'variable' => $variable]); + if ($composed || array_key_exists('defaultValue', $variable)) $evaluation['variableValue'] = $source; } } if (!empty($variable['deprecated'])) { - $this->reportDiagnostic(['level' => 'warn', 'code' => 'variable_deprecated', 'message' => 'Variable "'.$resolvedKey.'" is deprecated', 'details' => ['variableKey' => $resolvedKey, 'evaluation' => $evaluation]]); + $this->reportDiagnostic(['level' => 'warn', 'code' => 'deprecated_variable', 'message' => 'Variable "'.$resolvedKey.'" is deprecated', 'details' => ['variableKey' => $resolvedKey, 'evaluation' => $evaluation]]); } } - if (!array_key_exists('variableValue', $evaluation) && array_key_exists('defaultVariableValue', $options)) { - $evaluation['variableValue'] = $options['defaultVariableValue']; + if (!array_key_exists('variableValue', $evaluation) && array_key_exists('defaultVariableValue', $evaluationOptions)) { + $evaluation['variableValue'] = $evaluationOptions['defaultVariableValue']; + } + if (array_key_exists('variableValue', $evaluation)) { + $evaluation['variableValue'] = Helpers::copyValue($evaluation['variableValue']); } $evaluation = $this->modulesManager->runAfterEvaluationModules($evaluation, $evaluationOptions); - $this->reportDiagnostic(['level' => 'debug', 'code' => $evaluation['reason'], 'message' => 'Global variable evaluated', 'details' => $evaluation]); + if ($this->diagnosticIsObserved('debug')) { + $this->reportDiagnostic(['level' => 'debug', 'code' => $evaluation['reason'], 'message' => 'Global variable evaluated', 'details' => $evaluation]); + } return $evaluation; + }); } catch (\Throwable $error) { - $evaluation = ['type' => 'variable', 'variableKey' => $variableKey, 'reason' => Evaluation::ERROR, 'error' => $error]; + if ($error instanceof RequirementError && !empty($options['__featurevisorPrerequisite'])) throw $error; + $evaluation = ['type' => 'variable', 'variableKey' => $evaluationOptions['variableKey'], 'reason' => Evaluation::ERROR, 'error' => $error]; $this->reportDiagnostic(['level' => 'error', 'code' => 'evaluation_error', 'message' => 'Global variable evaluation failed', 'originalError' => $error, 'details' => $evaluation]); return $evaluation; } } + private function composeVariable($compose, array $dependencies) + { + if (is_string($compose) && $compose !== '') return $this->evaluateRequiredVariable(['variable' => $compose], $dependencies, 'composition'); + if (!is_array($compose) || !Internal\Normalize::isList($compose) || $compose === []) throw new RequirementError('Invalid composition'); + $result = new \stdClass(); + foreach ($compose as $entry) { + $key = $entry['key'] ?? null; + if (!is_string($key) || $key === '' || in_array($key, ['__proto__', 'prototype', 'constructor'], true)) throw new RequirementError('Invalid composition property'); + if (isset($entry['variable'])) $value = $this->evaluateRequiredVariable($entry, $dependencies, 'composition'); + elseif (isset($entry['feature'])) { + $type = ($entry['variation'] ?? false) === true ? 'variation' : 'flag'; + $evaluation = $this->evaluateDependencyFeature($type, $entry['feature'], $dependencies, 'composition'); + $value = $type === 'flag' ? ($evaluation['enabled'] ?? false) === true + : (array_key_exists('variationValue', $evaluation) ? $evaluation['variationValue'] : ($evaluation['variation']['value'] ?? null)); + } else throw new RequirementError('Invalid composition source'); + $result->{$key} = $value; + } + return $result; + } + /** * @param string|array|null $variableKeyOrContext * @param array $contextOrOptions @@ -867,18 +1227,25 @@ public function getVariable(string $featureOrVariableKey, string|array|null $var { try { $evaluation = $this->evaluateVariable($featureOrVariableKey, $variableKeyOrContext, $contextOrOptions, $options); + $getterOptions = is_string($variableKeyOrContext) ? $options : $contextOrOptions; + $preserveJsonObjects = !empty($getterOptions['__featurevisorPreserveJsonObjects']); if (array_key_exists('variableValue', $evaluation)) { if ( (($evaluation['variableSchema']['type'] ?? null) === 'json' || ($evaluation['variable']['type'] ?? null) === 'json') && - is_string($evaluation['variableValue']) + !isset($evaluation['variable']['compose']) && is_string($evaluation['variableValue']) ) { - return json_decode($evaluation['variableValue'], true, 512, JSON_THROW_ON_ERROR); + $value = json_decode($evaluation['variableValue'], false, 512, JSON_THROW_ON_ERROR); + return $preserveJsonObjects ? $value : Helpers::objectValueToArray($value); } - return $evaluation['variableValue']; + return $preserveJsonObjects + ? $evaluation['variableValue'] + : Helpers::objectValueToArray($evaluation['variableValue']); } return null; } catch (\Throwable $e) { + $getterOptions = is_string($variableKeyOrContext) ? $options : $contextOrOptions; + if ($e instanceof RequirementError && !empty($getterOptions['__featurevisorPrerequisite'])) throw $e; $this->reportDiagnostic([ 'level' => 'error', 'code' => 'evaluation_error', @@ -939,10 +1306,10 @@ public function getVariableInteger(string $featureKey, string|array|null $variab * defaultVariableValue?: mixed, * } $options */ - public function getVariableDouble(string $featureKey, string|array|null $variableKey = null, array $context = [], array $options = []): ?float + public function getVariableNumber(string $featureKey, string|array|null $variableKey = null, array $context = [], array $options = []): ?float { $value = $this->getVariable($featureKey, $variableKey, $context, $options); - return Helpers::getValueByType($value, 'double'); + return Helpers::getValueByType($value, 'number'); } /** @@ -967,8 +1334,14 @@ public function getVariableArray(string $featureKey, string|array|null $variable */ public function getVariableObject(string $featureKey, string|array|null $variableKey = null, array $context = [], array $options = []) { + if (is_string($variableKey)) { + $options['__featurevisorPreserveJsonObjects'] = true; + } else { + $context['__featurevisorPreserveJsonObjects'] = true; + } $value = $this->getVariable($featureKey, $variableKey, $context, $options); - return Helpers::getValueByType($value, 'object'); + $value = Helpers::getValueByType($value, 'object'); + return Helpers::objectValueToArray($value); } /** @@ -981,9 +1354,13 @@ public function getVariableObject(string $featureKey, string|array|null $variabl */ public function getVariableJSON(string $featureKey, string|array|null $variableKey = null, array $context = [], array $options = []) { + if (is_string($variableKey)) { + $options['__featurevisorPreserveJsonObjects'] = true; + } else { + $context['__featurevisorPreserveJsonObjects'] = true; + } $value = $this->getVariable($featureKey, $variableKey, $context, $options); - - return $value; + return Helpers::objectValueToArray($value); } /** diff --git a/src/Helpers.php b/src/Helpers.php index ad8aa33..c437e0b 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -4,6 +4,30 @@ class Helpers { + /** @param mixed $value @return mixed */ + public static function copyValue($value) + { + if ($value instanceof \stdClass) { + $copy = new \stdClass(); + foreach (get_object_vars($value) as $key => $item) $copy->{$key} = self::copyValue($item); + return $copy; + } + if (is_array($value)) return array_map([self::class, 'copyValue'], $value); + return $value; + } + + /** @param mixed $value @return mixed */ + public static function objectValueToArray($value) + { + if ($value instanceof \stdClass) { + $result = []; + foreach (get_object_vars($value) as $key => $item) $result[$key] = self::objectValueToArray($item); + return $result; + } + if (is_array($value)) return array_map([self::class, 'objectValueToArray'], $value); + return $value; + } + public static function getValueByType($value, string $fieldType) { try { @@ -22,7 +46,7 @@ public static function getValueByType($value, string $fieldType) return (int) $value; } return null; - case 'double': + case 'number': return (is_int($value) || is_float($value)) && is_finite((float) $value) ? (float) $value : null; diff --git a/src/Internal/ConditionsParseFailure.php b/src/Internal/ConditionsParseFailure.php new file mode 100644 index 0000000..837052e --- /dev/null +++ b/src/Internal/ConditionsParseFailure.php @@ -0,0 +1,7 @@ + []]; + } +} diff --git a/src/Internal/JsonValues.php b/src/Internal/JsonValues.php new file mode 100644 index 0000000..856fca3 --- /dev/null +++ b/src/Internal/JsonValues.php @@ -0,0 +1,73 @@ + 'feature', + 'segments' => 'segment', + 'variables' => 'variable', + 'variableSchemas' => 'variableSchema', + 'variableOverrides' => 'override', + ]; + + private const FIELD_CONTEXTS = [ + 'datafile' => ['features' => 'features', 'segments' => 'segments', 'variables' => 'variables'], + 'feature' => ['variablesSchema' => 'variableSchemas', 'force' => 'selection', 'traffic' => 'selection', + 'variations' => 'selection', 'conditions' => 'condition'], + 'segment' => ['conditions' => 'condition'], + 'variable' => ['defaultValue' => 'runtime', 'disabledValue' => 'runtime', 'overrides' => 'override', + 'overrideProgram' => 'override', 'conditions' => 'condition'], + 'variableSchema' => ['defaultValue' => 'runtime', 'disabledValue' => 'runtime'], + 'selection' => ['variables' => 'runtimeMap', 'variableOverrides' => 'variableOverrides', + 'conditions' => 'condition'], + 'override' => ['value' => 'runtime', 'operations' => 'operation', 'children' => 'override', + 'conditions' => 'condition'], + 'operation' => ['value' => 'runtime'], + 'condition' => ['value' => 'runtime', 'and' => 'condition', 'or' => 'condition', 'not' => 'condition'], + 'requirement' => ['value' => 'runtime', 'and' => 'requirement', 'or' => 'requirement', 'not' => 'requirement'], + ]; + + /** @param mixed $value @return mixed */ + public static function normalizeDatafileValue($value) + { + return self::normalize($value, 'datafile', true); + } + + /** @param mixed $value @return mixed */ + public static function normalizeConditionsValue($value, bool $preserveGroupObjects = false) + { + return self::normalize($value, 'condition', $preserveGroupObjects); + } + + /** @param mixed $value @return mixed */ + private static function normalize($value, string $context, bool $preserveGroupObjects = false) + { + if ($context === 'runtime') return Helpers::copyValue($value); + if (!is_array($value) && !$value instanceof \stdClass) return $value; + + $object = $value instanceof \stdClass; + $entries = $object ? get_object_vars($value) : $value; + $result = []; + foreach ($entries as $key => $item) { + if ($preserveGroupObjects && $context === 'condition' && in_array($key, ['and', 'or', 'not'], true) && $item instanceof \stdClass) { + $result[$key] = Helpers::copyValue($item); + continue; + } + if ($context === 'runtimeMap') { + $childContext = 'runtime'; + } elseif (isset(self::DICTIONARY_ENTRIES[$context])) { + $childContext = self::DICTIONARY_ENTRIES[$context]; + } elseif (!$object) { + $childContext = $context; + } else { + $childContext = self::FIELD_CONTEXTS[$context][$key] ?? 'metadata'; + } + $result[$key] = self::normalize($item, $childContext, $preserveGroupObjects); + } + return $result; + } +} diff --git a/src/Internal/Normalize.php b/src/Internal/Normalize.php new file mode 100644 index 0000000..0f8d887 --- /dev/null +++ b/src/Internal/Normalize.php @@ -0,0 +1,110 @@ + $value] : $value; + if (!is_array($value)) return $value; + if (self::isList($value)) return $value === [] ? '*' : array_map(static fn($item) => self::legacy($item, $features), $value); + foreach (['and', 'or', 'not'] as $operator) { + if (!isset($value[$operator]) || !is_array($value[$operator])) continue; + if ($operator === 'and' && $value[$operator] === []) return '*'; + return [$operator => array_map(static fn($item) => self::legacy($item, $features), $value[$operator])]; + } + return $value; + } + + /** Normalize metadata only, leaving runtime object and array containers intact. */ + public static function entity(string $kind, string $key, array $value, bool $legacy = false): array + { + $errors = []; + $parse = static function ($conditions) use ($kind, $key, &$errors) { + if (!is_string($conditions) || preg_match('/^\s*[\{\[\"]/', $conditions) !== 1) return $conditions; + try { + return JsonValues::normalizeConditionsValue(json_decode($conditions, false, 512, JSON_THROW_ON_ERROR), true); + } catch (\Throwable $error) { + $errors[] = ['level' => 'error', 'code' => 'conditions_parse_error', 'message' => 'Error parsing conditions', + 'originalError' => $error, 'details' => ['conditions' => $conditions, 'entityType' => $kind, 'entityKey' => $key]]; + return new InvalidConditions(); + } + }; + $select = static function (array $entry, bool $old = false) use ($parse): array { + $result = $entry; + unset($result['segments'], $result['requiredFeatures'], $result['requiredVariables'], $result['required']); + $parts = []; + if (array_key_exists('conditions', $entry)) { + $conditions = $parse($entry['conditions']); + $parts[] = $old ? self::legacy($conditions) : $conditions; + } + if (array_key_exists('segments', $entry)) $parts[] = self::legacy($parse($entry['segments'])); + if (array_key_exists('requiredFeatures', $entry)) { + $features = self::isList($entry['requiredFeatures']) ? $entry['requiredFeatures'] : [$entry['requiredFeatures']]; + if ($features !== []) $parts[] = array_map(static fn($item) => self::legacy($item, true), $features); + } + foreach ($parts as $part) { + if ($part instanceof InvalidConditions) { + $result['conditions'] = $part; + return $result; + } + } + if (count($parts) === 1) $result['conditions'] = $parts[0]; + elseif ($parts !== []) { + $combined = []; + foreach ($parts as $part) { + if (self::isList($part)) $combined = array_merge($combined, $part === [] ? [['and' => []]] : $part); + else $combined[] = $part; + } + $result['conditions'] = $combined; + } + return $result; + }; + $withOverrides = static function (array $entry) use ($select, $legacy): array { + foreach ($entry['variableOverrides'] ?? [] as $variable => $overrides) { + $entry['variableOverrides'][$variable] = array_map(static fn($item) => $select($item, $legacy), $overrides); + } + return $entry; + }; + if ($kind === 'segment') { + $conditions = $parse($value['conditions'] ?? null); + $result = array_merge($value, ['conditions' => $legacy ? self::legacy($conditions) : $conditions]); + } else { + $result = $select($value); + if ($kind === 'feature') { + $result['traffic'] = array_map(static fn($item) => $withOverrides($select($item)), $value['traffic'] ?? []); + if (isset($value['force'])) $result['force'] = array_map(static fn($item) => $select($item, $legacy), $value['force']); + if (isset($value['variations'])) $result['variations'] = array_map($withOverrides, $value['variations']); + } else { + foreach (['overrides', 'overrideProgram'] as $field) { + if (isset($value[$field])) $result[$field] = array_map(static fn($item) => $select($item, $legacy && $field === 'overrides'), $value[$field]); + } + } + } + return [$result, $errors]; + } + + public static function datafile(array $datafile): array + { + $errors = []; + foreach (['features' => 'feature', 'segments' => 'segment', 'variables' => 'variable'] as $name => $kind) { + foreach ($datafile[$name] ?? [] as $key => $value) { + [$datafile[$name][$key], $diagnostics] = self::entity($kind, (string) $key, $value, true); + $errors = array_merge($errors, $diagnostics); + } + } + return [$datafile, $errors]; + } + + public static function publicConditions($conditions) + { + return $conditions instanceof InvalidConditions ? ['or' => []] : $conditions; + } +} diff --git a/src/ModulesManager.php b/src/ModulesManager.php index e1d8827..7d9143e 100644 --- a/src/ModulesManager.php +++ b/src/ModulesManager.php @@ -134,22 +134,14 @@ public function getAll(): array return $this->modules; } - public function runBeforeModules(array $options): array - { - foreach ($this->modules as $module) { - if (isset($module['before']) && is_callable($module['before'])) { - $options = $module['before']($options); - } - } - - return $options; - } - public function runBeforeEvaluationModules(array $options): array { + if ($this->modules === []) return $options; + $private = array_filter($options, static fn($key): bool => str_starts_with((string) $key, '__featurevisor'), ARRAY_FILTER_USE_KEY); foreach ($this->modules as $module) { if (isset($module['beforeEvaluation']) && is_callable($module['beforeEvaluation'])) { - $options = $module['beforeEvaluation']($options); + $options = array_merge($module['beforeEvaluation'](array_diff_key($options, $private)), $private); + if (isset($private['__featurevisorSession'])) $private['__featurevisorSession']->updateCurrent($options); } } return $options; @@ -185,19 +177,10 @@ public function runBucketValueModules(array $options): int return $bucketValue; } - public function runAfterModules(array $evaluation, array $options): array - { - foreach ($this->modules as $module) { - if (isset($module['after']) && is_callable($module['after'])) { - $evaluation = $module['after']($evaluation, $options); - } - } - - return $evaluation; - } - public function runAfterEvaluationModules(array $evaluation, array $options): array { + if ($this->modules === []) return $evaluation; + $options = array_filter($options, static fn($key): bool => !str_starts_with((string) $key, '__featurevisor'), ARRAY_FILTER_USE_KEY); foreach ($this->modules as $module) { if (isset($module['afterEvaluation']) && is_callable($module['afterEvaluation'])) { $evaluation = $module['afterEvaluation']($evaluation, $options); diff --git a/src/Mutations.php b/src/Mutations.php new file mode 100644 index 0000000..e210ebf --- /dev/null +++ b/src/Mutations.php @@ -0,0 +1,300 @@ + self::MAX_SAFE_INTEGER || floor((float) $value) !== (float) $value) return null; + return (int) $value; + } + + /** @param array $value */ + private static function isList(array $value): bool + { + return $value === [] || array_keys($value) === range(0, count($value) - 1); + } + + /** @param mixed $part @return array{property:string,values:array}|null */ + private static function selector($part): ?array + { + if (!is_array($part) || !isset($part['select']) || !is_array($part['select'])) { + return null; + } + $select = $part['select']; + if (!array_key_exists('property', $select) || !is_string($select['property']) || !isset($select['values']) || !is_array($select['values'])) { + return null; + } + foreach ($select['values'] as $value) { + if (!self::isPrimitive($value)) return null; + } + + return $select; + } + + /** @param mixed $operation */ + private static function validateOperation($operation): void + { + if (!is_array($operation) || !isset($operation['op']) || !is_string($operation['op']) || !array_key_exists('path', $operation)) { + throw new \RuntimeException('Invalid variable mutation operation'); + } + if (!in_array($operation['op'], self::OPERATIONS, true)) { + throw new \RuntimeException('Invalid variable mutation operation'); + } + if ($operation['op'] !== 'remove' && !array_key_exists('value', $operation)) { + throw new \RuntimeException('Variable mutation operation requires a value'); + } + if (in_array($operation['op'], ['appendUnique', 'removeValue'], true)) { + $value = $operation['value'] ?? null; + if (!self::isPrimitive($value) || ((is_int($value) || is_float($value)) && !is_finite((float) $value))) { + throw new \RuntimeException('Variable mutation operation requires a primitive value'); + } + } + if (!is_array($operation['path'])) { + throw new \RuntimeException('Invalid variable mutation path'); + } + foreach ($operation['path'] as $part) { + $key = null; + if (is_string($part)) { + $key = $part; + } elseif (self::arrayIndex($part) !== null) { + continue; + } else { + $selector = self::selector($part); + if ($selector === null) throw new \RuntimeException('Invalid variable mutation path'); + $key = $selector['property']; + } + if (in_array($key, self::DANGEROUS_KEYS, true)) { + throw new \RuntimeException('Unsafe variable mutation key'); + } + } + } + + /** @param mixed $candidate @return array{0:bool,1:mixed} */ + private static function objectProperty($candidate, string $property): array + { + if ($candidate instanceof \stdClass) { + return property_exists($candidate, $property) + ? [true, $candidate->{$property}] + : [false, null]; + } + if (is_array($candidate) && !self::isList($candidate) && array_key_exists($property, $candidate)) { + return [true, $candidate[$property]]; + } + return [false, null]; + } + + /** @param array $value @param array{property:string,values:array} $selector */ + private static function selectedIndex(array $value, array $selector): int + { + foreach ($value as $index => $candidate) { + [$present, $propertyValue] = self::objectProperty($candidate, $selector['property']); + if (!$present || !self::isPrimitive($propertyValue)) continue; + foreach ($selector['values'] as $expected) { + if (self::primitiveEquals($propertyValue, $expected)) return (int) $index; + } + } + + return -1; + } + + /** @param mixed $container @param mixed $part @return string|int */ + private static function pathKey($container, $part) + { + if (is_string($part)) { + if ($container instanceof \stdClass) return $part; + if (!is_array($container) || self::isList($container)) { + throw new \RuntimeException('Variable mutation expected an object'); + } + return $part; + } + if (!is_array($container) || !self::isList($container)) { + throw new \RuntimeException('Variable mutation expected an array'); + } + $index = self::arrayIndex($part); + if ($index !== null) return $index; + $selector = self::selector($part); + if ($selector === null) throw new \RuntimeException('Invalid variable mutation path'); + return self::selectedIndex($container, $selector); + } + + /** @param mixed $container @param string|int $key */ + private static function has($container, $key): bool + { + if ($container instanceof \stdClass) return is_string($key) && property_exists($container, $key); + if (!is_array($container)) return false; + return is_int($key) + ? $key >= 0 && $key < count($container) + : array_key_exists($key, $container); + } + + /** @param mixed $container @param string|int $key @return mixed */ + private static function get($container, $key) + { + return $container instanceof \stdClass ? $container->{$key} : $container[$key]; + } + + /** @param mixed $container @param string|int $key @param mixed $item @return mixed */ + private static function put($container, $key, $item) + { + if ($container instanceof \stdClass) { + $copy = clone $container; + $copy->{$key} = $item; + return $copy; + } + if (is_int($key) && $key === count($container)) { + $container[] = $item; + } else { + $container[$key] = $item; + } + return $container; + } + + /** @param mixed $container @param string|int $key @return mixed */ + private static function removeAt($container, $key) + { + if ($container instanceof \stdClass) { + $copy = clone $container; + unset($copy->{$key}); + return $copy; + } + if (is_int($key)) { + array_splice($container, $key, 1); + } else { + unset($container[$key]); + } + return $container; + } + + /** @param array $array @param mixed $item @return array */ + private static function updateArray(array $array, string $op, $item): array + { + if ($op === 'removeValue') { + $found = false; + $next = []; + foreach ($array as $value) { + if (self::primitiveEquals($value, $item)) { + $found = true; + } else { + $next[] = $value; + } + } + return $found ? $next : $array; + } + if ($op === 'appendUnique') { + foreach ($array as $value) { + if (self::primitiveEquals($value, $item)) return $array; + } + } + $item = Helpers::copyValue($item); + if ($op === 'prepend') { + array_unshift($array, $item); + } else { + $array[] = $item; + } + return $array; + } + + /** @param mixed $value @param array $operation @return mixed */ + private static function updateAtPath($value, array $operation, int $offset) + { + $path = $operation['path']; + $op = $operation['op']; + if ($offset === count($path)) { + if ($op === 'set') return Helpers::copyValue($operation['value']); + if ($op === 'remove') throw new \RuntimeException('Cannot remove the root variable value'); + if ($op === 'before' || $op === 'after') { + throw new \RuntimeException('Variable mutation before/after requires an array element path'); + } + if ($value === null) return $value; + if (!is_array($value) || !self::isList($value)) { + throw new \RuntimeException('Variable mutation expected an array'); + } + return self::updateArray($value, $op, $operation['value']); + } + + if ($value === null) return $value; + $part = $path[$offset]; + if (($op === 'before' || $op === 'after') && $offset === count($path) - 1 && is_string($part)) { + throw new \RuntimeException('Variable mutation before/after requires an array element path'); + } + $key = self::pathKey($value, $part); + $found = self::has($value, $key); + + if ($offset < count($path) - 1) { + if (!$found || self::get($value, $key) === null) return $value; + return self::put( + $value, + $key, + self::updateAtPath(self::get($value, $key), $operation, $offset + 1) + ); + } + + if ($op === 'remove') { + return $found ? self::removeAt($value, $key) : $value; + } + + $item = $operation['value']; + if ($op === 'set') { + if (is_int($key) && ($key < 0 || $key > count($value))) return $value; + return self::put($value, $key, Helpers::copyValue($item)); + } + if ($op === 'before' || $op === 'after') { + if (!$found || !is_array($value)) return $value; + array_splice($value, $key + ($op === 'after' ? 1 : 0), 0, [Helpers::copyValue($item)]); + return $value; + } + + $current = $found ? self::get($value, $key) : null; + if ($current === null && is_int($key)) return $value; + if ($current === null && $op === 'removeValue') return $value; + if ($current !== null && (!is_array($current) || !self::isList($current))) { + throw new \RuntimeException('Variable mutation expected an array'); + } + return self::put($value, $key, self::updateArray($current ?? [], $op, $item)); + } + + /** @param mixed $value @param mixed $operations @return mixed */ + public static function apply($value, $operations) + { + if (!is_array($operations)) throw new \RuntimeException('Invalid variable mutation operations'); + foreach ($operations as $operation) { + self::validateOperation($operation); + $value = self::updateAtPath($value, $operation, 0); + } + return $value; + } +} diff --git a/src/OpenFeatureProvider.php b/src/OpenFeatureProvider.php index d71a5d5..d4bf60f 100644 --- a/src/OpenFeatureProvider.php +++ b/src/OpenFeatureProvider.php @@ -7,19 +7,19 @@ use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; +use OpenFeature\implementation\common\Metadata; use OpenFeature\implementation\provider\AbstractProvider; use OpenFeature\implementation\provider\ResolutionDetails; use OpenFeature\implementation\provider\ResolutionError; +use OpenFeature\interfaces\common\Metadata as MetadataInterface; use OpenFeature\interfaces\flags\EvaluationContext; use OpenFeature\interfaces\provider\ErrorCode; use OpenFeature\interfaces\provider\Reason; use OpenFeature\interfaces\provider\ResolutionDetails as ResolutionDetailsInterface; -/** OpenFeature provider backed by the Featurevisor v3 SDK. Requires PHP 8 and open-feature/sdk. */ +/** OpenFeature provider backed by Featurevisor. Requires PHP 8 and open-feature/sdk. */ final class OpenFeatureProvider extends AbstractProvider { - protected static string $NAME = 'Featurevisor'; - private Featurevisor $featurevisor; private string $targetingKeyField; private string $keySeparator; @@ -29,6 +29,8 @@ final class OpenFeatureProvider extends AbstractProvider private $onTrack; /** @var callable */ private $datafileUnsubscribe; + /** @var callable */ + private $errorUnsubscribe; private ?string $datafileError = null; private bool $ownsFeaturevisor; private bool $closed = false; @@ -55,33 +57,27 @@ public function __construct( } $this->onTrack = $onTrack; $this->ownsFeaturevisor = $featurevisor === null; + $datafile = null; if ($featurevisor !== null) { $this->featurevisor = $featurevisor; } else { - if (isset($options['datafile']) && is_string($options['datafile'])) { - try { - json_decode($options['datafile'], true, 512, JSON_THROW_ON_ERROR); - } catch (\Throwable $error) { - $this->datafileError = 'Could not parse datafile'; - } - } - $originalHandler = $options['onDiagnostic'] ?? null; - $options['onDiagnostic'] = function (array $diagnostic) use ($originalHandler): void { - if (($diagnostic['code'] ?? null) === 'invalid_datafile') { - $this->datafileError = (string) $diagnostic['message']; - } - if (($diagnostic['code'] ?? null) === 'datafile_set') { - $this->datafileError = null; - } - if ($originalHandler !== null) { - $originalHandler($diagnostic); - } - }; + $datafile = $options['datafile'] ?? null; + unset($options['datafile']); + $options['onDiagnostic'] = $options['onDiagnostic'] ?? static function (array $diagnostic): void {}; $this->featurevisor = Featurevisor::createFeaturevisor($options); } $this->datafileUnsubscribe = $this->featurevisor->on('datafile_set', function (): void { $this->datafileError = null; }); + $this->errorUnsubscribe = $this->featurevisor->on('error', function (array $event): void { + $diagnostic = $event['diagnostic'] ?? []; + if (($diagnostic['code'] ?? null) === 'invalid_datafile') { + $this->datafileError = (string) $diagnostic['message']; + } + }); + if ($this->ownsFeaturevisor && $datafile !== null) { + $this->featurevisor->setDatafile($datafile, true); + } } public function getFeaturevisor(): Featurevisor @@ -89,6 +85,11 @@ public function getFeaturevisor(): Featurevisor return $this->featurevisor; } + public function getMetadata(): MetadataInterface + { + return new Metadata('Featurevisor'); + } + public function shutdown(): void { if ($this->closed) { @@ -97,6 +98,7 @@ public function shutdown(): void $this->closed = true; ($this->datafileUnsubscribe)(); + ($this->errorUnsubscribe)(); if ($this->ownsFeaturevisor) { $this->featurevisor->close(); } @@ -150,8 +152,8 @@ private function resolve(string $flagKey, $defaultValue, ?EvaluationContext $eva if ($featureKey === $this->globalVariablePrefix && $selector !== null && $selector !== '') { $evaluation = $this->featurevisor->evaluateVariable($selector, $context); $value = $evaluation['variableValue'] ?? null; - if (($evaluation['variable']['type'] ?? null) === 'json' && is_string($value)) { - $parsed = json_decode($value, true); + if (($evaluation['variable']['type'] ?? null) === 'json' && !isset($evaluation['variable']['compose']) && is_string($value)) { + $parsed = json_decode($value, false); if (json_last_error() === JSON_ERROR_NONE) $value = $parsed; } } elseif ($selector === null || $selector === '') { @@ -167,7 +169,7 @@ private function resolve(string $flagKey, $defaultValue, ?EvaluationContext $eva $evaluation = $this->featurevisor->evaluateVariable($featureKey, $selector, $context); $value = $evaluation['variableValue'] ?? null; if (($evaluation['variableSchema']['type'] ?? null) === 'json' && is_string($value)) { - $parsed = json_decode($value, true); + $parsed = json_decode($value, false); if (json_last_error() === JSON_ERROR_NONE) { $value = $parsed; } @@ -178,6 +180,9 @@ private function resolve(string $flagKey, $defaultValue, ?EvaluationContext $eva if ($errorCode !== null) { return $this->error($defaultValue, $errorCode, $this->errorMessage($evaluation)); } + if ($expectedType === 'object') { + $value = Helpers::objectValueToArray($value); + } if ($value === null) { $value = $defaultValue; } elseif (!$this->matches($value, $expectedType)) { @@ -200,7 +205,7 @@ private function resolve(string $flagKey, $defaultValue, ?EvaluationContext $eva private function context(?EvaluationContext $context): array { $result = $context !== null ? $this->normalize($context->getAttributes()->toArray()) : []; - if ($context !== null && $context->getTargetingKey() !== null && $context->getTargetingKey() !== '') { + if ($context !== null && $context->getTargetingKey() !== null) { $result[$this->targetingKeyField] = $context->getTargetingKey(); } return $result; @@ -209,9 +214,9 @@ private function context(?EvaluationContext $context): array private function reason(string $reason): string { if (in_array($reason, ['feature_not_found', 'variable_not_found', 'no_variations', 'error'], true)) return Reason::ERROR; - if (in_array($reason, ['required', 'forced', 'sticky', 'rule', 'variable_override_variation', 'variable_override_rule'], true)) return Reason::TARGETING_MATCH; + if (in_array($reason, ['forced', 'sticky', 'rule', 'variable_override_variation', 'variable_override_rule', 'variable_composed'], true)) return Reason::TARGETING_MATCH; if ($reason === 'allocated') return Reason::SPLIT; - if (in_array($reason, ['disabled', 'required_features_unmet', 'variation_disabled', 'variable_disabled'], true)) return Reason::DISABLED; + if (in_array($reason, ['disabled', 'conditions_unmet', 'variation_disabled', 'variable_disabled'], true)) return Reason::DISABLED; return Reason::DEFAULT; } diff --git a/src/RequirementError.php b/src/RequirementError.php new file mode 100644 index 0000000..98f0292 --- /dev/null +++ b/src/RequirementError.php @@ -0,0 +1,6 @@ +getVariableInteger('test', 'count')); self::assertEquals(9.99, $childF->getVariable('test', 'price')); - self::assertEquals(9.99, $childF->getVariableDouble('test', 'price')); + self::assertEquals(9.99, $childF->getVariableNumber('test', 'price')); self::assertEquals(['paypal', 'creditcard'], $childF->getVariable('test', 'paymentMethods')); self::assertEquals(['paypal', 'creditcard'], $childF->getVariableArray('test', 'paymentMethods')); diff --git a/tests/ConditionsTest.php b/tests/ConditionsTest.php index 2225deb..5152fa8 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -75,6 +75,44 @@ public function testOperatorNotExistsDotSeparated() { self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser' => ['name' => 'Chrome']])); } + public function testEmptyAndNotEmptyUseShallowSupportedContents(): void { + $hostObject = new class {}; + $cases = [ + ['empty', '', true], + ['notEmpty', '', false], + ['empty', ' ', false], + ['notEmpty', ' ', true], + ['empty', [], true], + ['notEmpty', [null], true], + ['empty', (object) [], true], + ['notEmpty', (object) ['value' => null], true], + ['empty', null, false], + ['notEmpty', null, false], + ['empty', 0, false], + ['notEmpty', false, false], + ['empty', $hostObject, false], + ['notEmpty', $hostObject, false], + ]; + + foreach ($cases as [$operator, $value, $expected]) { + self::assertSame( + $expected, + $this->evaluationData->allConditionsAreMatched( + [['attribute' => 'profile.value', 'operator' => $operator]], + ['profile' => ['value' => $value]] + ), + $operator.' '.get_debug_type($value) + ); + } + + foreach (['empty', 'notEmpty'] as $operator) { + self::assertFalse($this->evaluationData->allConditionsAreMatched( + [['attribute' => 'profile.missing', 'operator' => $operator]], + ['profile' => []] + )); + } + } + public function testOperatorEndsWith() { $conditions = [[ 'attribute' => 'name', 'operator' => 'endsWith', 'value' => 'World' ]]; self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); @@ -246,6 +284,30 @@ public function testOperatorBefore() { self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['date' => '2023-05-13T17:23:59+01:00'])); } + public function testHostDateMillisecondsAndWideTimezone(): void { + $host = new \DateTimeImmutable('1969-12-31T23:59:59.999500Z'); + $condition = [['attribute' => 'date', 'operator' => 'before', 'value' => '1969-12-31T23:59:59.999Z']]; + self::assertFalse($this->evaluationData->allConditionsAreMatched($condition, ['date' => $host])); + $condition[0]['value'] = '1970-01-01T00:00:00Z'; + self::assertTrue($this->evaluationData->allConditionsAreMatched($condition, ['date' => $host])); + $wide = new \DateTimeImmutable('2024-01-01T15:00:00+15:00'); + $condition[0]['value'] = '2024-01-01T00:00:01Z'; + self::assertFalse($this->evaluationData->allConditionsAreMatched($condition, ['date' => $wide])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($condition, ['date' => '2024-01-01T15:00:00+15:00'])); + } + + public function testStringDatesTruncateUpToNineFractionalDigits(): void { + $condition = [['attribute' => 'date', 'operator' => 'before', 'value' => '2024-01-01T00:00:00.124Z']]; + foreach (['1239', '123999', '1239999', '123999999'] as $digits) { + self::assertTrue($this->evaluationData->allConditionsAreMatched($condition, ['date' => "2024-01-01T00:00:00.{$digits}Z"])); + } + self::assertFalse($this->evaluationData->allConditionsAreMatched($condition, ['date' => '2024-01-01T00:00:00.1239999999Z'])); + $condition[0]['value'] = '1969-12-31T23:59:59.999Z'; + self::assertFalse($this->evaluationData->allConditionsAreMatched($condition, ['date' => '1969-12-31T23:59:59.999999999Z'])); + $condition[0]['value'] = '1970-01-01T00:00:00Z'; + self::assertTrue($this->evaluationData->allConditionsAreMatched($condition, ['date' => '1969-12-31T23:59:59.999999999Z'])); + } + public function testOperatorAfter() { $conditions = [[ 'attribute' => 'date', 'operator' => 'after', 'value' => '2023-05-13T16:23:59Z' ]]; self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['date' => '2023-05-14T00:00:00Z'])); diff --git a/tests/DeprecationDiagnosticsTest.php b/tests/DeprecationDiagnosticsTest.php new file mode 100644 index 0000000..3d32f00 --- /dev/null +++ b/tests/DeprecationDiagnosticsTest.php @@ -0,0 +1,57 @@ + [ + 'schemaVersion' => '4', + 'revision' => '1', + 'segments' => [], + 'features' => [ + 'legacy' => [ + 'deprecated' => true, + 'bucketBy' => 'userId', + 'variablesSchema' => [ + 'old' => ['type' => 'string', 'defaultValue' => 'x', 'deprecated' => true], + ], + 'traffic' => [ + ['key' => 'all', 'conditions' => '*', 'percentage' => 100000, 'allocation' => []], + ], + ], + ], + 'variables' => [ + 'oldGlobal' => ['type' => 'string', 'defaultValue' => 'y', 'deprecated' => true], + ], + ], + 'logLevel' => 'warn', + 'onDiagnostic' => function (array $diagnostic) use (&$captured): void { + if (str_contains((string) $diagnostic['code'], 'deprecated')) { + $captured[] = $diagnostic['code']; + } + }, + ]); + + $f->isEnabled('legacy'); + $this->assertSame(['deprecated_feature'], array_values(array_unique($captured))); + + $captured = []; + $f->getVariable('legacy', 'old'); + $codes = array_values(array_unique($captured)); + sort($codes); + $this->assertSame(['deprecated_feature', 'deprecated_variable'], $codes); + + $captured = []; + $f->getVariable('oldGlobal'); + $this->assertSame(['deprecated_variable'], $captured); + + $f->close(); + } +} diff --git a/tests/EmptyModulesHotPathTest.php b/tests/EmptyModulesHotPathTest.php new file mode 100644 index 0000000..cc3976d --- /dev/null +++ b/tests/EmptyModulesHotPathTest.php @@ -0,0 +1,34 @@ + 'flag', 'featureKey' => 'root', '__featurevisorPrivate' => new stdClass()]; + $evaluation = ['type' => 'flag', 'enabled' => true]; + self::assertSame($options, $manager->runBeforeEvaluationModules($options)); + self::assertSame($evaluation, $manager->runAfterEvaluationModules($evaluation, $options)); + $seen = []; + $remove = $manager->add([ + 'beforeEvaluation' => static function (array $visible) use (&$seen): array { + $seen[] = $visible; + return $visible; + }, + 'afterEvaluation' => static function (array $result, array $visible) use (&$seen): array { + $seen[] = $visible; + return $result; + }, + ]); + self::assertSame($options, $manager->runBeforeEvaluationModules($options)); + self::assertSame($evaluation, $manager->runAfterEvaluationModules($evaluation, $options)); + self::assertCount(2, $seen); + foreach ($seen as $visible) self::assertArrayNotHasKey('__featurevisorPrivate', $visible); + $remove(); + self::assertSame($options, $manager->runBeforeEvaluationModules($options)); + self::assertSame($evaluation, $manager->runAfterEvaluationModules($evaluation, $options)); + } +} diff --git a/tests/EvaluationPerformanceContractTest.php b/tests/EvaluationPerformanceContractTest.php new file mode 100644 index 0000000..240b97e --- /dev/null +++ b/tests/EvaluationPerformanceContractTest.php @@ -0,0 +1,211 @@ + */ + private function datafile(string $revision = '1', ?string $conditions = null): array + { + return [ + 'schemaVersion' => '2', + 'revision' => $revision, + 'segments' => [ + 'country' => [ + 'key' => 'country', + 'conditions' => $conditions ?? '[{"attribute":"country","operator":"equals","value":"nl"}]', + ], + ], + 'features' => [ + 'enabled' => [ + 'key' => 'enabled', + 'bucketBy' => 'userId', + 'traffic' => [[ + 'key' => 'country-rule', + 'segments' => '["country"]', + 'percentage' => 100000, + ]], + ], + 'secondary' => [ + 'key' => 'secondary', + 'bucketBy' => 'userId', + 'traffic' => [['key' => 'all', 'segments' => '*', 'percentage' => 100000]], + ], + ], + 'variables' => [ + 'message' => ['type' => 'string', 'defaultValue' => 'hello'], + ], + ]; + } + + /** @return array */ + private function expressionCache(Featurevisor $sdk): array + { + $property = new \ReflectionProperty($sdk, 'entityCache'); + $property->setAccessible(true); + return $property->getValue($sdk); + } + + /** @return array */ + private function evaluationDependencies(Featurevisor $sdk): array + { + $property = new \ReflectionProperty($sdk, 'evaluationDependencies'); + $property->setAccessible(true); + return $property->getValue($sdk); + } + + public function testExpressionCacheFollowsDatafileUpdateAndCloseLifecycle(): void + { + $sdk = Featurevisor::createFeaturevisor(['datafile' => $this->datafile(), 'logLevel' => 'fatal']); + self::assertTrue($sdk->isEnabled('enabled', ['country' => 'nl', 'userId' => 'one'])); + self::assertCount(1, $this->expressionCache($sdk)['features']); + self::assertTrue($sdk->isEnabled('enabled', ['country' => 'nl', 'userId' => 'two'])); + self::assertCount(1, $this->expressionCache($sdk)['features']); + + $sdk->setDatafile('{'); + self::assertCount(1, $this->expressionCache($sdk)['features']); + + $sdk->setDatafile($this->datafile('2'), true); + self::assertSame([], $this->expressionCache($sdk)); + self::assertTrue($sdk->isEnabled('enabled', ['country' => 'nl', 'userId' => 'three'])); + self::assertCount(1, $this->expressionCache($sdk)['features']); + + $sdk->close(); + self::assertSame([], $this->expressionCache($sdk)); + } + + public function testStableEvaluationCallablesRemainLiveAcrossDatafileReplacement(): void + { + $sdk = Featurevisor::createFeaturevisor(['datafile' => $this->datafile(), 'logLevel' => 'fatal']); + $before = $this->evaluationDependencies($sdk); + $beforeIds = array_map('spl_object_id', $before['datafile']); + + $replacement = $this->datafile('replacement'); + $replacement['features']['enabled']['traffic'][0]['segments'] = '*'; + $sdk->setDatafile($replacement, true); + + self::assertTrue($sdk->isEnabled('enabled', ['userId' => 'after'])); + $after = $this->evaluationDependencies($sdk); + self::assertSame($beforeIds, array_map('spl_object_id', $after['datafile'])); + self::assertSame('replacement', $sdk->getRevision()); + } + + public function testClosedInstanceWithStableCallablesIsReleased(): void + { + gc_collect_cycles(); + $sdk = Featurevisor::createFeaturevisor(['datafile' => $this->datafile(), 'logLevel' => 'fatal']); + self::assertTrue($sdk->isEnabled('enabled', ['country' => 'nl', 'userId' => 'warm'])); + + $replacement = $this->datafile('replacement'); + $replacement['features']['enabled']['traffic'][0]['segments'] = '*'; + $sdk->setDatafile($replacement, true); + $sdk->addModule([ + 'name' => 'dynamic', + 'beforeEvaluation' => static fn (array $options): array => $options, + ]); + self::assertTrue($sdk->isEnabled('enabled', ['userId' => 'after'])); + + $reference = \WeakReference::create($sdk); + $sdk->close(); + unset($sdk); + gc_collect_cycles(); + + self::assertNull($reference->get()); + } + + public function testParseFailuresReportOncePerLoadAndAllowReentrantEvaluation(): void + { + $sdk = Featurevisor::createFeaturevisor(['logLevel' => 'fatal']); + $diagnostics = []; + $reentered = []; + $sdk->on('error', static function (array $event) use (&$diagnostics, &$reentered, $sdk): void { + $diagnostic = $event['diagnostic']; + $diagnostics[] = $diagnostic; + if ($diagnostic['code'] === 'conditions_parse_error' && $reentered === []) { + $reentered[] = $sdk->isEnabled('secondary', ['userId' => 'nested']); + } + }); + $sdk->setDatafile($this->datafile('accepted', '{')); + self::assertFalse($sdk->isEnabled('enabled', ['country' => 'nl', 'userId' => 'one'])); + self::assertFalse($sdk->isEnabled('enabled', ['country' => 'nl', 'userId' => 'two'])); + self::assertSame([true], $reentered); + $errors = array_values(array_filter($diagnostics, static fn($item) => $item['code'] === 'conditions_parse_error')); + self::assertCount(1, $errors); + self::assertSame(['conditions' => '{', 'entityType' => 'segment', 'entityKey' => 'country'], $errors[0]['details']); + $sdk->close(); + } + + public function testCallbackChangesToThresholdAndSubscriptionsAreLive(): void + { + $instanceDiagnostics = []; + $moduleDiagnostics = []; + $sdk = null; + $subscribe = null; + $subscribed = false; + + $sdk = Featurevisor::createFeaturevisor([ + 'datafile' => $this->datafile(), + 'logLevel' => 'fatal', + 'onDiagnostic' => static function (array $diagnostic) use (&$instanceDiagnostics): void { + $instanceDiagnostics[] = $diagnostic; + }, + 'modules' => [[ + 'name' => 'live', + 'setup' => static function (array $api) use (&$subscribe): void { + $subscribe = $api['onDiagnostic']; + }, + 'beforeEvaluation' => static function (array $options) use (&$sdk, &$subscribe, &$subscribed, &$moduleDiagnostics): array { + $sdk->setLogLevel('debug'); + if (!$subscribed) { + $subscribe( + static function (array $diagnostic) use (&$moduleDiagnostics): void { + $moduleDiagnostics[] = $diagnostic; + }, + ['logLevel' => 'debug'] + ); + $subscribed = true; + } + return $options; + }, + ]], + ]); + + $evaluation = $sdk->evaluateFlag('enabled', ['country' => 'nl', 'userId' => 'one']); + self::assertSame('rule', $evaluation['reason']); + self::assertSame('rule', $instanceDiagnostics[count($instanceDiagnostics) - 1]['code']); + self::assertSame('rule', $moduleDiagnostics[count($moduleDiagnostics) - 1]['code']); + self::assertSame($evaluation, $instanceDiagnostics[count($instanceDiagnostics) - 1]['details']['evaluation']); + self::assertSame( + $instanceDiagnostics[count($instanceDiagnostics) - 1], + $moduleDiagnostics[count($moduleDiagnostics) - 1] + ); + } + + public function testAfterCallbackThresholdChangeAppliesToGlobalDiagnostic(): void + { + $diagnostics = []; + $sdk = null; + $sdk = Featurevisor::createFeaturevisor([ + 'datafile' => $this->datafile(), + 'logLevel' => 'fatal', + 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { + $diagnostics[] = $diagnostic; + }, + 'modules' => [[ + 'name' => 'live', + 'afterEvaluation' => static function (array $evaluation) use (&$sdk): array { + $sdk->setLogLevel('debug'); + return $evaluation; + }, + ]], + ]); + + $evaluation = $sdk->evaluateVariable('message'); + $diagnostic = $diagnostics[count($diagnostics) - 1]; + self::assertSame('variable_default', $diagnostic['code']); + self::assertSame($evaluation, $diagnostic['details']); + } +} diff --git a/tests/EvaluationSessionTest.php b/tests/EvaluationSessionTest.php new file mode 100644 index 0000000..cc9209e --- /dev/null +++ b/tests/EvaluationSessionTest.php @@ -0,0 +1,623 @@ + 0, 'variable' => 0]; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', 'stickyFeatures' => $unrelated, 'stickyVariables' => $unrelated, + 'datafile' => ['schemaVersion' => '2', 'revision' => 'sticky-work', 'segments' => [], + 'features' => ['leafFlag' => ['bucketBy' => 'userId', 'traffic' => [[ + 'key' => 'all', 'segments' => '*', 'percentage' => 100000, + ]]]], + 'variables' => [ + 'source' => ['type' => 'integer', 'defaultValue' => 1], + 'root' => ['type' => 'integer', 'defaultValue' => 1, + 'requiredFeatures' => ['leafFlag', 'leafFlag'], + 'conditions' => [ + ['variable' => 'source', 'operator' => 'equals', 'value' => 1], + ['variable' => 'source', 'operator' => 'equals', 'value' => 1], + ]], + ]], + 'modules' => [['beforeEvaluation' => static function (array $options) use (&$reads): array { + if (($options['featureKey'] ?? null) === 'leafFlag') $reads['flag']++; + if (($options['variableKey'] ?? null) === 'source') $reads['variable']++; + return $options; + }]], + ]); + $child = $f->spawn([], ['stickyFeatures' => $unrelated, 'stickyVariables' => $unrelated]); + try { + foreach ([$f, $child] as $owner) { + $reads = ['flag' => 0, 'variable' => 0]; + self::assertSame(1, $owner->getVariable('root', ['userId' => 'u'])); + self::assertSame(['flag' => 1, 'variable' => 1], $reads, "size $size"); + } + $child->setStickyVariables(['source' => 2]); + self::assertSame('conditions_unmet', $child->evaluateVariable('root', ['userId' => 'u'])['reason']); + self::assertSame(1, $f->getVariable('root', ['userId' => 'u'])); + } finally { + $child->close(); + $f->close(); + foreach ($unrelated as $resource) fclose($resource); + } + } + } + + public function testModuleStickyReplacementsReachNestedPrerequisites(): void + { + $reads = ['flag' => 0, 'variable' => 0]; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'replacement', 'segments' => [], + 'features' => [ + 'leafFlag' => ['bucketBy' => 'userId', 'traffic' => []], + 'rootFlag' => ['bucketBy' => 'userId', 'traffic' => [[ + 'key' => 'all', 'segments' => '*', 'percentage' => 100000, + ]], 'requiredFeatures' => ['leafFlag', 'leafFlag'], + 'conditions' => [ + ['variable' => 'source', 'operator' => 'equals', 'value' => 2], + ['variable' => 'source', 'operator' => 'equals', 'value' => 2], + ]], + ], 'variables' => ['source' => ['type' => 'integer', 'defaultValue' => 1]]], + ]); + $activeOwner = $f; + $f->addModule(['beforeEvaluation' => static function (array $options) use (&$reads, &$activeOwner): array { + if (($options['featureKey'] ?? null) === 'rootFlag') { + $options['stickyFeatures'] = ['leafFlag' => ['enabled' => true]]; + $options['stickyVariables'] = ['source' => 2]; + $activeOwner->setStickyFeatures(['leafFlag' => ['enabled' => false]], true); + $activeOwner->setStickyVariables(['source' => 1], true); + } + if (($options['featureKey'] ?? null) === 'leafFlag') $reads['flag']++; + if (($options['variableKey'] ?? null) === 'source') $reads['variable']++; + return $options; + }]); + $child = $f->spawn(); + try { + foreach ([$f, $child] as $owner) { + $activeOwner = $owner; + $reads = ['flag' => 0, 'variable' => 0]; + self::assertTrue($owner->isEnabled('rootFlag', ['userId' => 'u'])); + self::assertSame(['flag' => 1, 'variable' => 1], $reads); + self::assertFalse($owner->isEnabled('leafFlag', ['userId' => 'u'])); + self::assertSame(1, $owner->getVariable('source')); + } + } finally { + $child->close(); + $f->close(); + } + } + + public function testEqualContentModuleStickyArraysFollowOwnerSetters(): void + { + $f = null; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'stickyFeatures' => ['leafFlag' => ['enabled' => true]], + 'stickyVariables' => ['source' => 2], + 'datafile' => ['schemaVersion' => '2', 'revision' => 'equal-sticky', 'segments' => [], + 'features' => [ + 'leafFlag' => ['bucketBy' => 'userId', 'traffic' => []], + 'featureGate' => ['bucketBy' => 'userId', 'traffic' => [], + 'requiredFeatures' => ['leafFlag']], + 'variableGate' => ['bucketBy' => 'userId', 'traffic' => [], + 'conditions' => [[ + 'variable' => 'source', 'operator' => 'equals', 'value' => 2, + ]]], + ], 'variables' => ['source' => ['type' => 'integer', 'defaultValue' => 0]]], + 'modules' => [['beforeEvaluation' => static function (array $options) use (&$f): array { + if (($options['featureKey'] ?? null) === 'featureGate') { + // A newly built array with the same value is not an identity replacement in PHP. + $options['stickyFeatures'] = ['leafFlag' => ['enabled' => true]]; + $f->setStickyFeatures(['leafFlag' => ['enabled' => false]], true); + } + if (($options['featureKey'] ?? null) === 'variableGate') { + $options['stickyVariables'] = ['source' => 2]; + $f->setStickyVariables(['source' => 1], true); + } + return $options; + }]], + ]); + try { + self::assertSame('conditions_unmet', $f->evaluateFlag('featureGate', ['userId' => 'u'])['reason']); + self::assertFalse($f->isEnabled('leafFlag', ['userId' => 'u'])); + self::assertSame('conditions_unmet', $f->evaluateFlag('variableGate', ['userId' => 'u'])['reason']); + self::assertSame(1, $f->getVariable('source')); + } finally { + $f->close(); + } + } + + /** @dataProvider distinctContextValues */ + public function testMemoIdentityPreservesJsonContainerKinds($first, $second): void + { + $session = new EvaluationSession(); + $calls = 0; + $session->run(['type' => 'variable', 'variableKey' => 'root'], false, function () use ($session, &$calls, $first, $second): array { + foreach ([$first, $second] as $value) { + $options = ['type' => 'variable', 'variableKey' => 'leaf', 'context' => ['value' => $value]]; + $session->run($options, true, function () use (&$calls): array { + $calls++; + return ['type' => 'variable', 'variableValue' => 1]; + }); + } + return ['type' => 'variable', 'variableValue' => 1]; + }); + self::assertSame(2, $calls); + } + + public static function distinctContextValues(): array + { + return [ + 'empty object versus array' => [(object) [], []], + 'object versus entry list' => [(object) ['a' => 1], [['a', 1]]], + 'object versus associative array' => [(object) ['a' => 1], ['a' => 1]], + 'tag shaped array' => [(object) [], ['object', []]], + 'tag shaped object' => [(object) [], ['object' => []]], + 'boolean versus number' => [false, 0], + 'number versus string' => [1, '1'], + 'sparse numeric map versus list' => [[0 => 'a', 2 => 'b'], ['a', 'b']], + 'out of order numeric map versus list' => [[1 => 'b', 0 => 'a'], ['a', 'b']], + ]; + } + + public function testRepeatedListContextMemoKeepsReturnedObjectsPrivate(): void + { + $session = new EvaluationSession(); + $calls = 0; + $session->run(['type' => 'variable', 'variableKey' => 'root'], false, function () use ($session, &$calls): array { + $options = ['type' => 'variable', 'variableKey' => 'leaf', 'context' => ['values' => [false, 0, null, (object) []]]]; + $read = function () use (&$calls): array { + $calls++; + return ['type' => 'variable', 'variableValue' => (object) ['items' => [1]]]; + }; + $first = $session->run($options, true, $read); + $first['variableValue']->items[0] = 2; + $second = $session->run($options, true, $read); + self::assertSame([1], $second['variableValue']->items); + return ['type' => 'variable']; + }); + self::assertSame(1, $calls); + } + + public function testNestedFeatureGroupsSeeSetterChangesBetweenLeaves(): void + { + $reads = []; + $f = null; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'stickyFeatures' => ['first' => ['enabled' => true]], + 'datafile' => ['schemaVersion' => '2', 'revision' => 'groups', 'segments' => [], + 'features' => [ + 'root' => ['bucketBy' => 'userId', 'traffic' => [[ + 'key' => 'all', 'segments' => '*', 'percentage' => 100000, + ]], 'requiredFeatures' => [ + ['and' => ['first', ['or' => ['second', 'unused']]]], + ['not' => ['blocked']], + ]], + 'first' => ['bucketBy' => 'userId', 'traffic' => []], + 'second' => ['bucketBy' => 'userId', 'traffic' => []], + 'unused' => ['bucketBy' => 'userId', 'traffic' => []], + 'blocked' => ['bucketBy' => 'userId', 'traffic' => []], + ], 'variables' => []], + 'modules' => [['afterEvaluation' => static function (array $evaluation) use (&$f, &$reads): array { + if (($evaluation['type'] ?? null) === 'flag' && ($evaluation['featureKey'] ?? null) !== 'root') { + $reads[] = $evaluation['featureKey']; + if ($evaluation['featureKey'] === 'first') { + $f->setStickyFeatures(['second' => ['enabled' => true]]); + } + } + return $evaluation; + }]], + ]); + try { + self::assertTrue($f->isEnabled('root', ['userId' => 'u'])); + self::assertSame(['first', 'second', 'blocked'], $reads); + } finally { + $f->close(); + } + } + + public function testRecursiveHostInputsBypassMemoWithoutRecursingForever(): void + { + $recursive = []; + $recursive['self'] = &$recursive; + $session = new EvaluationSession(); + $calls = 0; + $session->run(['type' => 'variable', 'variableKey' => 'root'], false, function () use ($session, &$calls, $recursive): array { + for ($i = 0; $i < 2; $i++) { + $session->run(['type' => 'variable', 'variableKey' => 'leaf', 'context' => $recursive], true, function () use (&$calls): array { + $calls++; + return ['type' => 'variable', 'variableValue' => 1]; + }); + } + return ['type' => 'variable', 'variableValue' => 1]; + }); + self::assertSame(2, $calls); + } + + public function testHookContextReplacementDoesNotRestoreStoredContextForDependencies(): void + { + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', 'context' => ['country' => 'nl'], + 'datafile' => ['schemaVersion' => '2', 'revision' => 'context', 'segments' => [], 'features' => [], + 'variables' => [ + 'leaf' => ['type' => 'integer', 'defaultValue' => 1, 'overrides' => [[ + 'key' => 'nl', 'conditions' => ['attribute' => 'country', 'operator' => 'equals', 'value' => 'nl'], 'value' => 2, + ]]], + 'root' => $this->variable([$this->requirement('leaf')]), + ]], + 'modules' => [['beforeEvaluation' => static function (array $options): array { + if (($options['variableKey'] ?? null) === 'root') $options['context'] = []; + return $options; + }]], + ]); + self::assertSame(1, $f->getVariable('root')); + $f->close(); + } + + public function testNestedPrerequisitePathErrorsCannotSatisfyNullOrNegatedChecks(): void + { + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'paths', 'segments' => [], 'features' => [], + 'variables' => [ + 'leaf' => $this->variable(), + 'invalid' => $this->variable([['variable' => 'leaf', 'path' => ['constructor'], 'operator' => 'notExists']]), + 'nullable' => $this->variable([['variable' => 'invalid', 'operator' => 'isNull']]), + 'negated' => $this->variable([['not' => [['variable' => 'invalid', 'operator' => 'notNull']]]]), + ]], + ]); + foreach (['invalid', 'nullable', 'negated'] as $key) { + self::assertSame('error', $f->evaluateVariable($key)['reason']); + self::assertNull($f->getVariable($key)); + } + $f->close(); + } + + public function testMutatedAndRestoredContextDoesNotReuseAnObsoleteInput(): void + { + $contextValue = (object) ['mode' => 'a']; + $calls = 0; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'mutation', 'segments' => [], 'features' => [], + 'variables' => [ + 'leaf' => $this->variable(), 'restore' => $this->variable(), + 'root' => $this->variable([ + $this->requirement('leaf'), $this->requirement('restore'), $this->requirement('leaf'), + ]), + ]], + 'modules' => [['beforeEvaluation' => static function (array $options) use (&$calls): array { + if (($options['variableKey'] ?? null) === 'leaf') { + $calls++; + $options['context']['value']->mode = 'b'; + } + if (($options['variableKey'] ?? null) === 'restore') $options['context']['value']->mode = 'a'; + return $options; + }]], + ]); + self::assertSame(1, $f->getVariable('root', ['value' => $contextValue])); + self::assertSame(2, $calls); + $f->close(); + } + + public function testRetainedModuleOptionsDoNotExposePrivateSessionState(): void + { + $retained = []; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'retained', 'segments' => [], + 'features' => ['root' => ['bucketBy' => 'id', 'conditions' => [$this->requirement('leaf')], 'traffic' => []]], + 'variables' => ['leaf' => $this->variable()]], + 'modules' => [['beforeEvaluation' => static function (array $options) use (&$retained): array { + $retained[] = $options; + return $options; + }, 'afterEvaluation' => static function (array $evaluation, array $options) use (&$retained): array { + $retained[] = $options; + return $evaluation; + }]], + ]); + $f->isEnabled('root'); + self::assertCount(4, $retained); + foreach ($retained as $options) { + self::assertSame([], array_filter(array_keys($options), static fn($key) => str_starts_with($key, '__featurevisor'))); + } + $f->close(); + } + + private function requirement(string $key): array + { + return ['variable' => $key, 'operator' => 'equals', 'value' => 1]; + } + + private function variable(?array $requirements = null): array + { + return ['type' => 'integer', 'defaultValue' => 1, 'disabledValue' => 0] + + ($requirements === null ? [] : ['conditions' => $requirements]); + } + + public function testCompletedVariableDiamondsAreRequestLocalAndExposeImmediateRequester(): void + { + $variables = ['leaf' => $this->variable()]; + $previous = 'leaf'; + for ($i = 0; $i < 12; $i++) { + $variables['level'.$i] = $this->variable([$this->requirement($previous), $this->requirement($previous)]); + $previous = 'level'.$i; + } + $seen = []; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'session', 'segments' => [], 'features' => [], 'variables' => $variables], + 'modules' => [[ + 'beforeEvaluation' => static function (array $options) use (&$seen): array { + $seen[] = [$options['variableKey'] ?? null, $options['dependencyOf'] ?? null]; + return array_merge([], $options); + }, + ]], + ]); + + self::assertSame(1, $f->getVariable($previous)); + self::assertCount(13, $seen); + self::assertNull($seen[0][1]); + self::assertSame(['type' => 'variable', 'variableKey' => $previous], $seen[1][1]); + $seen = []; + self::assertSame(1, $f->getVariable($previous)); + self::assertCount(13, $seen); + $f->close(); + } + + public function testCyclesAreErrorsAndOrdinaryMissingValuesAreNot(): void + { + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'cycle', 'segments' => [], 'features' => [], + 'variables' => [ + 'root' => $this->variable([$this->requirement('root')]), + 'outer' => $this->variable([$this->requirement('root')]), + 'ordinary' => $this->variable([$this->requirement('missing')]), + ]], + ]); + self::assertStringContainsString('Circular', $f->evaluateVariable('root')['error']->getMessage()); + self::assertStringContainsString('Circular', $f->evaluateVariable('outer')['error']->getMessage()); + self::assertSame('conditions_unmet', $f->evaluateVariable('ordinary')['reason']); + $f->close(); + } + + public function testImmediateRequesterDependentHooksDoNotShareCachedValues(): void + { + $observed = []; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'parents', 'segments' => [], 'features' => [], + 'variables' => [ + 'leaf' => $this->variable(), + 'left' => $this->variable([$this->requirement('leaf'), $this->requirement('leaf')]), + 'right' => $this->variable([['variable' => 'leaf', 'operator' => 'equals', 'value' => 2]]), + 'root' => $this->variable([$this->requirement('left'), $this->requirement('right')]), + ]], + 'modules' => [[ + 'afterEvaluation' => static function (array $evaluation, array $options) use (&$observed): array { + if (($options['variableKey'] ?? null) === 'leaf') { + $parent = $options['dependencyOf']['variableKey'] ?? null; + $observed[] = $parent; + $evaluation['variableValue'] = $parent === 'right' ? 2 : 1; + } + return $evaluation; + }, + ]], + ]); + self::assertSame(1, $f->getVariable('root')); + self::assertSame(['left', 'right'], $observed); + $f->close(); + } + + public function testStickySetterInsideHookInvalidatesCompletedPrerequisite(): void + { + $reads = 0; + $f = null; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'state', 'segments' => [], 'features' => [], + 'variables' => [ + 'leaf' => $this->variable(), + 'mutator' => $this->variable(), + 'root' => $this->variable([ + $this->requirement('leaf'), $this->requirement('mutator'), + ['variable' => 'leaf', 'operator' => 'equals', 'value' => 2], + ]), + ]], + 'modules' => [[ + 'afterEvaluation' => static function (array $evaluation, array $options) use (&$f, &$reads): array { + if (($options['variableKey'] ?? null) === 'leaf') $reads++; + if (($options['variableKey'] ?? null) === 'mutator') $f->setStickyVariables(['leaf' => 2]); + return $evaluation; + }, + ]], + ]); + self::assertSame(1, $f->getVariable('root')); + self::assertSame(2, $reads); + $f->close(); + } + + public function testOrdinaryNullOperandFromFailingModuleIsMemoised(): void + { + $reads = 0; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'errors', 'segments' => [], 'features' => [], + 'variables' => [ + 'broken' => $this->variable(), + 'root' => $this->variable([ + ['variable' => 'broken', 'operator' => 'isNull'], + ['variable' => 'broken', 'operator' => 'isNull'], + ]), + ]], + 'modules' => [[ + 'beforeEvaluation' => static function (array $options) use (&$reads): array { + if (($options['variableKey'] ?? null) === 'broken') { + $reads++; + throw new \RuntimeException('Circular is merely text in an ordinary module failure'); + } + return $options; + }, + ]], + ]); + self::assertSame(1, $f->getVariable('root')); + self::assertSame(1, $reads); + $f->close(); + } + + public function testTransformedIdentityParticipatesInCycleDetection(): void + { + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'aliases', 'segments' => [], 'features' => [], + 'variables' => ['actual' => $this->variable([$this->requirement('alias')]), 'alias' => $this->variable()]], + 'modules' => [[ + 'beforeEvaluation' => static function (array $options): array { + if (in_array($options['variableKey'] ?? null, ['root', 'alias'], true)) $options['variableKey'] = 'actual'; + return $options; + }, + ]], + ]); + self::assertStringContainsString('Circular', $f->evaluateVariable('root')['error']->getMessage()); + $f->close(); + } + + public function testPublicReentrantCallbackReadStartsFreshSession(): void + { + $f = null; + $entered = false; + $reads = 0; + $markers = []; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'reentrant', 'segments' => [], 'features' => [], + 'variables' => [ + 'leaf' => $this->variable(), + 'root' => $this->variable([$this->requirement('leaf'), $this->requirement('leaf')]), + ]], + 'modules' => [[ + 'beforeEvaluation' => static function (array $options) use (&$f, &$entered, &$reads, &$markers): array { + if (($options['variableKey'] ?? null) === 'root') $markers[] = $options['dependencyOf'] ?? null; + if (($options['variableKey'] ?? null) === 'leaf') { + $reads++; + if (!$entered) { + $entered = true; + self::assertSame(1, $f->getVariable('root')); + } + } + return $options; + }, + ]], + ]); + self::assertSame(1, $f->getVariable('root')); + self::assertSame(2, $reads); + self::assertSame([null, null], $markers); + $f->close(); + } + + public function testFeatureAndMixedCyclesPropagateButCheapSelectorsSkipThem(): void + { + $flag = ['bucketBy' => 'userId', 'traffic' => [['key' => 'all', 'segments' => '*', 'percentage' => 100000]]]; + $f = Featurevisor::createFeaturevisor([ + 'datafile' => ['schemaVersion' => '2', 'revision' => 'mixed', 'segments' => [], + 'features' => [ + 'self' => $flag + ['requiredFeatures' => ['self']], + 'mixed' => $flag + ['conditions' => [['variable' => 'mixed', 'operator' => 'notNull']]], + ], + 'variables' => [ + 'mixed' => ['type' => 'integer', 'defaultValue' => 1, 'requiredFeatures' => ['mixed']], + 'skipped' => ['type' => 'integer', 'defaultValue' => 1, 'overrides' => [[ + 'key' => 'unmatched', 'conditions' => ['attribute' => 'country', 'operator' => 'equals', 'value' => 'nl'], + 'requiredFeatures' => ['self'], 'value' => 2, + ]]], + ]], + ]); + self::assertSame('error', $f->evaluateFlag('self')['reason']); + self::assertSame('error', $f->evaluateFlag('mixed')['reason']); + self::assertSame('error', $f->evaluateVariable('mixed')['reason']); + self::assertSame(1, $f->getVariable('skipped', ['country' => 'gb'])); + self::assertSame('error', $f->evaluateVariable('skipped', ['country' => 'nl'])['reason']); + $f->close(); + } + + public function testMemoOwnsValuesAndDoesNotRetainWholeDefinitions(): void + { + $session = new EvaluationSession(); + $calls = 0; + $session->run(['type' => 'variable', 'variableKey' => 'root'], false, function () use ($session, &$calls): array { + $options = ['type' => 'variable', 'variableKey' => 'leaf', 'stickyFeatures' => ['flag' => ['enabled' => true]]]; + $evaluate = static function () use (&$calls): array { + $calls++; + return ['type' => 'variable', 'variableValue' => (object) ['nested' => (object) ['value' => 1]], + 'variable' => ['type' => 'object', 'defaultValue' => (object) ['unused' => true], 'overrides' => []]]; + }; + $first = $session->run($options, true, $evaluate); + $first['variableValue']->nested->value = 2; + $second = $session->run($options, true, $evaluate); + self::assertSame(1, $second['variableValue']->nested->value); + self::assertSame(['type' => 'object'], $second['variable']); + $second['variableValue']->nested->value = 3; + self::assertSame(1, $session->run($options, true, $evaluate)['variableValue']->nested->value); + return ['type' => 'variable', 'variableValue' => 1]; + }); + self::assertSame(1, $calls); + } + + /** @dataProvider moduleChanges */ + public function testModuleChangesInvalidatePrerequisites(string $change): void + { + $f = null; + $remove = static function (): void { throw new \LogicException('Module was not registered'); }; + $reads = 0; + $module = ['name' => 'change-value', 'afterEvaluation' => static function (array $evaluation, array $options): array { + if (($options['variableKey'] ?? null) === 'leaf') $evaluation['variableValue'] = 2; + return $evaluation; + }]; + $initial = $change === 'add' ? 1 : 2; + $expected = $change === 'add' ? 2 : 1; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => ['schemaVersion' => '2', 'revision' => 'modules', 'segments' => [], 'features' => [], + 'variables' => [ + 'leaf' => $this->variable(), 'mutator' => $this->variable(), + 'root' => $this->variable([ + ['variable' => 'leaf', 'operator' => 'equals', 'value' => $initial], + $this->requirement('mutator'), + ['variable' => 'leaf', 'operator' => 'equals', 'value' => $expected], + ]), + ]], + 'modules' => [['beforeEvaluation' => static function (array $options) use (&$f, &$remove, &$reads, $change, $module): array { + if (($options['variableKey'] ?? null) === 'leaf') $reads++; + if (($options['variableKey'] ?? null) === 'mutator') { + if ($change === 'add') $f->addModule($module); + elseif ($change === 'remove') $f->removeModule('change-value'); + elseif ($change === 'unsubscribe') $remove(); + else $f->close(); + } + return $options; + }]], + ]); + if ($change !== 'add') $remove = $f->addModule($module); + self::assertSame(1, $f->getVariable('root')); + self::assertSame($change === 'close' ? 1 : 2, $reads); + $f->close(); + } + + public static function moduleChanges(): array + { + return [['add'], ['remove'], ['unsubscribe'], ['close']]; + } +} diff --git a/tests/FeatureAllocationTest.php b/tests/FeatureAllocationTest.php new file mode 100644 index 0000000..7e4c479 --- /dev/null +++ b/tests/FeatureAllocationTest.php @@ -0,0 +1,142 @@ + $key, + 'bucketBy' => 'userId', + 'traffic' => [['key' => '1', 'segments' => '*', 'percentage' => $percentage, 'allocation' => []]], + ]; + } + + private function datafile(array $features): array + { + return ['schemaVersion' => '2', 'revision' => '1', 'segments' => [], 'features' => $features]; + } + + public function testLegacyRangesAreUnknownFieldsWithoutWarnings(): void + { + foreach ([null, [], [[50000, 100000]], [[0, 100000]], 'ignored'] as $ranges) { + foreach ([0, 50000, 100000] as $percentage) { + foreach ([null, false, true] as $enabled) { + $feature = $this->flag('test', $percentage); + if ($enabled !== null) { + $feature['traffic'][0]['enabled'] = $enabled; + } + $baseline = Featurevisor::createFeaturevisor([ + 'datafile' => $this->datafile(['test' => $feature]), 'logLevel' => 'warn', + ]); + $feature['ranges'] = $ranges; + $diagnostics = []; + $sdk = Featurevisor::createFeaturevisor([ + 'datafile' => json_encode($this->datafile(['test' => $feature]), JSON_THROW_ON_ERROR), + 'logLevel' => 'warn', + 'onDiagnostic' => function (array $diagnostic) use (&$diagnostics) { + $diagnostics[] = $diagnostic; + }, + ]); + for ($user = 0; $user < 32; $user++) { + $context = ['userId' => 'user-'.$user]; + self::assertEquals($baseline->evaluateFlag('test', $context), $sdk->evaluateFlag('test', $context)); + } + self::assertSame([], $diagnostics); + $sdk->close(); + $baseline->close(); + } + } + } + } + + public function testJsonWithObsoleteMetadataUsesOrdinaryPercentageAndVariationBoundaries(): void + { + $feature = $this->flag('test', 50000); + $feature['ranges'] = [[50001, 100000]]; + $feature['variations'] = [['value' => 'control'], ['value' => 'treatment']]; + $feature['traffic'][0]['allocation'] = [ + ['variation' => 'control', 'range' => [0, 25000]], + ['variation' => 'treatment', 'range' => [25000, 50000]], + ]; + $bucket = 0; + $diagnostics = []; + $sdk = Featurevisor::createFeaturevisor([ + 'datafile' => json_encode($this->datafile(['test' => $feature]), JSON_THROW_ON_ERROR), + 'logLevel' => 'warn', + 'onDiagnostic' => function (array $event) use (&$diagnostics) { + $diagnostics[] = $event; + }, + 'modules' => [[ + 'name' => 'boundary', + 'bucketValue' => function () use (&$bucket) { + return $bucket; + }, + ]], + ]); + try { + foreach ([ + [0, 'control'], [25000, 'control'], [25001, 'treatment'], + [50000, 'treatment'], [50001, null], [100000, null], + ] as [$value, $expected]) { + $bucket = $value; + self::assertSame($value <= 50000, $sdk->isEnabled('test', ['userId' => 'user'])); + self::assertSame($expected, $sdk->getVariation('test', ['userId' => 'user'])); + } + self::assertSame([], $diagnostics); + } finally { + $sdk->close(); + } + } + + public function testRequiredVariationsAndPriorityExclusionUseNormalHashing(): void + { + $features = []; + foreach (['cohort', 'experimentA', 'experimentB', 'priority', 'fallback'] as $key) { + $features[$key] = $this->flag($key); + } + $features['cohort']['variations'] = [['value' => 'a'], ['value' => 'b'], ['value' => 'holdout']]; + $features['cohort']['traffic'][0]['allocation'] = [ + ['variation' => 'a', 'range' => [0, 40000]], + ['variation' => 'b', 'range' => [40000, 80000]], + ['variation' => 'holdout', 'range' => [80000, 100000]], + ]; + $features['experimentA']['requiredFeatures'] = [['feature' => 'cohort', 'variation' => 'a']]; + $features['experimentB']['requiredFeatures'] = [['feature' => 'cohort', 'variation' => 'b']]; + $features['priority']['traffic'][0]['percentage'] = 50000; + $features['fallback']['requiredFeatures'] = [['feature' => 'priority', 'enabled' => false]]; + $sdk = Featurevisor::createFeaturevisor(['datafile' => $this->datafile($features), 'logLevel' => 'warn']); + $counts = ['a' => 0, 'b' => 0, 'holdout' => 0, 'priority' => 0, 'fallback' => 0]; + $cohortOverlap = $priorityOverlap = 0; + for ($user = 0; $user < 20000; $user++) { + $context = ['userId' => 'user-'.$user]; + $variation = $sdk->getVariation('cohort', $context); + self::assertContains($variation, ['a', 'b', 'holdout']); + $counts[$variation]++; + $a = $sdk->isEnabled('experimentA', $context); + $b = $sdk->isEnabled('experimentB', $context); + self::assertSame($variation === 'a', $a); + self::assertSame($variation === 'b', $b); + $cohortOverlap += (int) ($a && $b); + self::assertSame($variation === 'holdout', !$a && !$b); + $priority = $sdk->isEnabled('priority', $context); + $fallback = $sdk->isEnabled('fallback', $context); + self::assertNotSame($priority, $fallback); + $priorityOverlap += (int) ($priority && $fallback); + $counts[$priority ? 'priority' : 'fallback']++; + } + self::assertSame(0, $cohortOverlap); + self::assertSame(0, $priorityOverlap); + self::assertSame(20000, $counts['a'] + $counts['b'] + $counts['holdout']); + self::assertSame(20000, $counts['priority'] + $counts['fallback']); + // Allow two percentage points around each intended population share. + foreach (['a' => 8000, 'b' => 8000, 'holdout' => 4000, 'priority' => 10000, 'fallback' => 10000] as $key => $expected) { + self::assertLessThanOrEqual(400, abs($counts[$key] - $expected)); + } + $sdk->close(); + } +} diff --git a/tests/FeaturevisorCliOptionsTest.php b/tests/FeaturevisorCliOptionsTest.php new file mode 100644 index 0000000..8810815 --- /dev/null +++ b/tests/FeaturevisorCliOptionsTest.php @@ -0,0 +1,127 @@ + '".str_replace("'", "'\\''", $directory)."/called'\nexit 19\n"); + chmod($directory.'/npx', 0755); + $oldPath = getenv('PATH'); + putenv('PATH='.$directory.PATH_SEPARATOR.$oldPath); + $run = static function (array $arguments): array { + $process = proc_open(array_merge([PHP_BINARY, dirname(__DIR__).'/featurevisor'], $arguments), + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return [proc_close($process), $stdout, $stderr]; + }; + try { + foreach (['test', 'benchmark', 'assess-distribution'] as $command) { + foreach ([ + ['--unknown'], ['extra'], ['--n=0'], ['-n', '-2'], ['--n=1.5'], + ['--inflate=0'], ['--entity-type=unknown'], ['--context=[]'], + ['--context={'], ['--context=null'], ['--context={} trailing'], + ['--target='], ['--populate-uuid='], ['--root-directory-path='], + ['--only-failures=invalid'], ['--target'], ['--populate-uuid'], + ['--key-pattern'], ['--key-pattern=a', '--keyPattern=b'], + ['--target=web', 'extra'], ['--populate-uuid=userId', 'extra'], ['---n=2'], + ] as $options) { + [$code, $stdout, $stderr] = $run(array_merge([$command, '--json'], $options)); + self::assertNotSame(0, $code, json_encode($options)); + self::assertSame('', $stdout); + self::assertNotSame('', $stderr); + self::assertFileDoesNotExist($directory.'/called'); + } + } + foreach ([[], ['unknown'], ['benchmark'], ['assess-distribution'], + ['benchmark', '--feature=ready', '--variation', '--variable=text']] as $options) { + [$code, $stdout, $stderr] = $run($options); + self::assertNotSame(0, $code); + self::assertSame('', $stdout); + self::assertNotSame('', $stderr); + self::assertFileDoesNotExist($directory.'/called'); + } + foreach ([['--help'], ['test', '-h'], ['benchmark', '--help'], ['assess-distribution', '--help']] as $options) { + [$code, $stdout, $stderr] = $run($options); + self::assertSame(0, $code); + self::assertSame('', $stderr); + foreach (['--root-directory-path', '--key-pattern', '--populate-uuid', '--show-datafile'] as $option) { + self::assertStringContainsString($option, $stdout); + } + self::assertFileDoesNotExist($directory.'/called'); + } + } finally { + putenv('PATH='.$oldPath); + foreach (glob($directory.'/*') as $file) unlink($file); + rmdir($directory); + } + } +} diff --git a/tests/FeaturevisorCliProcessTest.php b/tests/FeaturevisorCliProcessTest.php new file mode 100644 index 0000000..d89e496 --- /dev/null +++ b/tests/FeaturevisorCliProcessTest.php @@ -0,0 +1,243 @@ + getcwd(), 'args' => array_slice($argv, 1)])."\n", FILE_APPEND); +echo '{"kind":"featurevisor.test-execution","schemaVersion":1,"tests":[]}'; +PHP; + file_put_contents($directory.'/npx', $script); + chmod($directory.'/npx', 0755); + putenv('PATH='.$directory.PATH_SEPARATOR.$oldPath); + try { + $case = '[null,"scenario","quoted spec",0,null]'; + $pattern = "O'Reilly; \$(echo unsafe) 東京"; + $options = \parseCliArguments(['featurevisor', 'test', '--key-pattern', $pattern, + '--assertion-pattern', 'country: nl', '--case', $case]); + self::assertSame([], \getTests($directory, $options, "web team's set")); + $options['keyPattern'] = '0'; + $options['assertionPattern'] = ''; + self::assertSame([], \getTests($directory, $options)); + $calls = array_map(static fn(string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR), + file($directory.'/calls', FILE_IGNORE_NEW_LINES)); + self::assertSame(realpath($directory), $calls[0]['cwd']); + foreach (['--keyPattern='.$pattern, '--assertionPattern=country: nl', '--case='.$case, "--set=web team's set"] as $argument) { + self::assertContains($argument, $calls[0]['args']); + } + self::assertContains('--keyPattern=0', $calls[1]['args']); + self::assertContains('--assertionPattern=', $calls[1]['args']); + } finally { + putenv('PATH='.$oldPath); + foreach (glob($directory.'/*') as $file) unlink($file); + rmdir($directory); + } + } + + public function testBuiltBaseAndTargetCacheEntriesCannotOverwriteEachOther(): void + { + $directory = sys_get_temp_dir().'/featurevisor-cache-'.bin2hex(random_bytes(6)); + mkdir($directory); + $oldPath = getenv('PATH'); + $script = <<<'PHP' +#!/usr/bin/env php + '2', 'revision' => json_encode([$environment, $target]), 'features' => (object) [], 'segments' => (object) [], 'variables' => (object) []]); +PHP; + file_put_contents($directory.'/npx', $script); + chmod($directory.'/npx', 0755); + putenv('PATH='.$directory.PATH_SEPARATOR.$oldPath); + ob_start(); + try { + $environments = [false, 'false', 'prod', 'prod-target-web', 'prod-target-a', "café\"\\\n東京"]; + $targets = ['web', 'a-target-b', 'b', "web\"\\\n東京"]; + $cache = \buildDatafiles($directory, ['environments' => $environments], $targets); + self::assertCount(count($environments) * (count($targets) + 1), $cache); + foreach ($environments as $environment) { + foreach (array_merge([null], $targets) as $target) { + $datafile = \getDatafileForAssertion(['environment' => $environment, 'target' => $target], $cache); + self::assertIsString($datafile); + $decoded = json_decode($datafile, true, 512, JSON_THROW_ON_ERROR); + self::assertSame([$environment === false ? null : $environment, $target], json_decode($decoded['revision'], true, 512, JSON_THROW_ON_ERROR)); + } + } + } finally { + ob_end_clean(); + putenv('PATH='.$oldPath); + unlink($directory.'/npx'); + rmdir($directory); + } + } + + public function testRuntimeProjectSelectionsAndExitStatuses(): void + { + $directory = sys_get_temp_dir().'/featurevisor cli '.bin2hex(random_bytes(6)); + mkdir($directory); + $oldPath = getenv('PATH'); + $script = <<<'PHP' +#!/usr/bin/env php + strpos($arg, '--set=') === 0)) { fwrite(STDERR, 'This project does not configure sets. Omit --set.'); exit(17); } +elseif (in_array('--set=missing', $args, true)) { fwrite(STDERR, 'Unknown set'); exit(17); } +elseif ($args[0] === 'list' && in_array('--tests', $args, true)) echo '{"kind":"featurevisor.test-execution","schemaVersion":1,"tests":[{"key":"ready","feature":"ready","assertions":[{"at":50,"expectedToBeEnabled":true}]}]}'; +elseif ($args[0] === 'list') echo in_array('--targets', $args, true) ? '["all","web"]' : '[]'; +else echo '{"schemaVersion":"2","revision":"test","segments":{},"features":{"ready":{"bucketBy":"userId","traffic":[{"key":"everyone","segments":"*","percentage":100000}]}},"variables":{}}'; +PHP; + file_put_contents($directory.'/npx', $script); + chmod($directory.'/npx', 0755); + putenv('PATH='.$directory.PATH_SEPARATOR.$oldPath); + $run = static function (string $command, array $args) use ($directory): array { + $process = proc_open(array_merge([PHP_BINARY, dirname(__DIR__).'/featurevisor', $command, '--projectDirectoryPath='.$directory, '--feature=ready', '--n=2'], $args), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $output = stream_get_contents($pipes[1]).stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return [proc_close($process), $output]; + }; + try { + foreach (['test', 'benchmark', 'assess-distribution'] as $command) { + foreach (['--inflate=0', '--inflate=-5', '--inflate=1.5', '--inflate=invalid', '--inflate=', '--inflate=1foo', '--inflate'] as $option) { + [$code, $output] = $run($command, [$option]); + self::assertNotSame(0, $code, $output); + self::assertStringContainsString('--inflate', $output); + self::assertFileDoesNotExist($directory.'/calls', 'Invalid inflation delegated to Node'); + } + } + file_put_contents($directory.'/config', '{"environments":false,"sets":false}'); + foreach (['test', 'benchmark', 'assess-distribution'] as $command) { + foreach ([[], ['--set=blue'], ['--set='], ['--set=0']] as $options) { + file_put_contents($directory.'/calls', ''); + [$code, $output] = $run($command, $options); + self::assertSame($options === [], $code === 0, $output); + $calls = array_map(static fn(string $line): array => json_decode($line, true), file($directory.'/calls', FILE_IGNORE_NEW_LINES)); + if ($options === ['--set=']) { + self::assertSame([], $calls); + self::assertStringContainsString('nonempty value', $output); + continue; + } + $delegated = array_filter($calls, static fn(array $args): bool => $args[0] !== 'config'); + self::assertNotEmpty($delegated); + foreach ($delegated as $args) { + self::assertSame($options, array_values(array_filter($args, static fn(string $arg): bool => strpos($arg, '--set=') === 0))); + } + if ($options !== []) self::assertStringContainsString('Omit --set', $output); + } + } + foreach (['benchmark', 'assess-distribution'] as $command) { + file_put_contents($directory.'/config', '{"environments":false}'); + [$code, $output] = $run($command, ['--set=blue', '--target=all', '--target=web']); + self::assertSame(0, $code, $output); + self::assertStringContainsString('Target: all', $output); + self::assertStringContainsString('Target: web', $output); + self::assertNotSame(0, $run($command, ['--environment=production'])[0]); + self::assertNotSame(0, $run($command, ['--set=missing'])[0]); + self::assertNotSame(0, $run($command, ['--target=missing'])[0]); + file_put_contents($directory.'/config', '{"environments":["production"]}'); + self::assertNotSame(0, $run($command, [])[0]); + self::assertNotSame(0, $run($command, ['--environment=missing'])[0]); + [$code, $output] = $run($command, ['--environment=production']); + self::assertSame(0, $code, $output); + } + $calls = array_map(static fn(string $line): array => json_decode($line, true), file($directory.'/calls', FILE_IGNORE_NEW_LINES)); + $selected = array_values(array_filter($calls, static fn(array $args): bool => $args[0] === 'build' && in_array('--set=blue', $args, true))); + self::assertCount(4, $selected); + foreach ($selected as $args) { + self::assertFalse((bool) array_filter($args, static fn(string $arg): bool => strpos($arg, '--environment=') === 0)); + } + file_put_contents($directory.'/config', '{"environments":false}'); + foreach (['test', 'benchmark', 'assess-distribution'] as $command) { + foreach ([[], ['--inflate=1']] as $options) { + file_put_contents($directory.'/calls', ''); + [$code, $output] = $run($command, $options); + self::assertSame(0, $code, $output); + $calls = array_map(static fn(string $line): array => json_decode($line, true), file($directory.'/calls', FILE_IGNORE_NEW_LINES)); + $builds = array_filter($calls, static fn(array $args): bool => $args[0] === 'build'); + self::assertNotEmpty($builds); + foreach ($builds as $args) { + self::assertSame($options, array_values(array_filter($args, static fn(string $arg): bool => strpos($arg, '--inflate=') === 0))); + } + } + } + } finally { + putenv('PATH='.$oldPath); + foreach (glob($directory.'/*') as $file) unlink($file); + rmdir($directory); + } + } + + public static function setUpBeforeClass(): void + { + if (!defined('FEATUREVISOR_CLI_TEST')) define('FEATUREVISOR_CLI_TEST', true); + require_once dirname(__DIR__).'/featurevisor'; + } + + public function testNodeCommandPrefersTheNearestLocalCliEntry(): void + { + $root = realpath(sys_get_temp_dir()).'/featurevisor-node-cli-'.bin2hex(random_bytes(6)); + $project = $root.'/packages/flags'; + mkdir($project, 0777, true); + self::assertSame([PHP_OS_FAMILY === 'Windows' ? 'npx.cmd' : 'npx', 'featurevisor', 'config'], \nodeCommand($project, ['config'])); + mkdir($root.'/node_modules/@featurevisor/cli', 0777, true); + $entry = $root.'/node_modules/@featurevisor/cli/bin.js'; + touch($entry); + try { + self::assertSame(['node', $entry, 'list', '--set=a b'], \nodeCommand($project, ['list', '--set=a b'])); + } finally { + unlink($entry); + rmdir($root.'/node_modules/@featurevisor/cli'); + rmdir($root.'/node_modules/@featurevisor'); + rmdir($root.'/node_modules'); + rmdir($project); + rmdir($root.'/packages'); + rmdir($root); + } + } + + public function testArgumentListsRunWithoutAShellInTheGivenDirectory(): void + { + $directory = realpath(sys_get_temp_dir()); + $output = \executeCommand([PHP_BINARY, '-r', 'echo json_encode([getcwd(), $argv[1]]);', "it's \"quoted\" \$(x)"], $directory); + self::assertSame([$directory, "it's \"quoted\" \$(x)"], json_decode($output, true, 512, JSON_THROW_ON_ERROR)); + } + + public function testWarningOnSuccessDoesNotCorruptJson(): void + { + $command = escapeshellarg(PHP_BINARY).' -r '.escapeshellarg( + 'fwrite(STDERR, "mutation warning\n"); echo "{\"ok\":true}\n";' + ); + self::assertSame(['ok' => true], json_decode(\executeCommand($command), true, 512, JSON_THROW_ON_ERROR)); + } + + public function testLargeStderrOnFailureRetainsExitCodeAndDiagnostics(): void + { + $command = escapeshellarg(PHP_BINARY).' -r '.escapeshellarg( + 'fwrite(STDERR, str_repeat("W", 262144)."\nsyntax error at build staging\n"); exit(17);' + ); + try { + \executeCommand($command); + self::fail('A failing command was accepted'); + } catch (\RuntimeException $error) { + self::assertStringContainsString('exit code 17', $error->getMessage()); + self::assertStringContainsString('syntax error at build staging', $error->getMessage()); + self::assertGreaterThan(262144, strlen($error->getMessage())); + } + } +} diff --git a/tests/FeaturevisorCliTest.php b/tests/FeaturevisorCliTest.php index 7520794..012cab9 100644 --- a/tests/FeaturevisorCliTest.php +++ b/tests/FeaturevisorCliTest.php @@ -2,10 +2,108 @@ namespace Featurevisor\Tests; +use Featurevisor\Evaluation; +use Featurevisor\Featurevisor; use PHPUnit\Framework\TestCase; class FeaturevisorCliTest extends TestCase { + public function testPreflightSeparatesMalformedComparisonsFromMissingReferences(): void + { + $datafile = ['schemaVersion' => '2', 'revision' => 'preflight', 'features' => [], 'segments' => [], + 'variables' => ['version' => ['type' => 'string', 'defaultValue' => '1.0.0']]]; + $valid = ['variable' => 'version', 'operator' => 'semverEquals', 'value' => '1.0.0']; + foreach ([false, true] as $child) { + foreach ([false, true] as $entity) { + foreach (['', "1.0.0\n", '١.0.0', null] as $invalid) { + $check = $invalid === null ? array_merge($valid, ['variable' => 'missing']) : array_merge($valid, ['value' => $invalid]); + $checks = [$valid, ['not' => [$check]]]; + $assertion = $child ? ['children' => [[], ['checks' => $checks]]] : ['checks' => $checks]; + if ($child) $assertion['children'][0] = ['checks' => [$valid]]; + $assertion['_case'] = ['assertionIndex' => 7]; + $test = $entity ? ['variable' => 'version'] : ['scenario' => 'preflight']; + $coverage = []; + $result = \executeTestAssertion($test, $assertion, [\getBaseDatafileKey(null) => $datafile], [], [], null, $coverage); + self::assertTrue($result['hasError']); + self::assertSame([], $coverage); + self::assertCount(1, $result['errors']); + $error = $result['errors'][0]; + self::assertSame('check', $error['type']); + self::assertSame($invalid === null ? 'valid references' : 'valid checks', $error['expected']); + self::assertSame(array_merge($entity ? ['assertions', 7] : [], $child ? ['children', 1] : [], ['checks', 1]), $error['path']); + self::assertStringContainsString($invalid === null ? 'missing' : 'not valid semver', $error['actual']); + } + } + } + } + + public function testOrdinaryFailuresPreserveAuthoredPathsValuesAndChildren(): void + { + $datafile = [ + 'schemaVersion' => '2', 'revision' => 'failures', 'segments' => [], + 'features' => ['flag' => ['bucketBy' => 'userId', 'traffic' => [['key' => 'all', 'segments' => '*', 'percentage' => 100000, 'allocation' => []]], + 'variablesSchema' => ['text' => ['type' => 'string', 'defaultValue' => 'actual']]]], + 'variables' => ['text' => ['type' => 'string', 'defaultValue' => 'actual']], + ]; + $feature = [ + 'expectedToBeEnabled' => false, 'expectedVariation' => 'missing', + 'expectedVariables' => ['text' => 'wrong'], + 'expectedEvaluations' => ['flag' => ['enabled' => false], 'variation' => ['featureKey' => 'wrong'], 'variables' => ['text' => ['variableValue' => 'wrong']]], + ]; + $variable = ['expectedValue' => 'wrong', 'expectedEvaluation' => ['variableValue' => 'wrong']]; + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); + $diagnostics = []; + try { + $results = [ + \testFeature(array_merge($feature, ['children' => [$feature]]), 'flag', $f, 'fatal', ['assertions', 7]), + \testVariable(array_merge($variable, ['children' => [$variable]]), 'text', $f, $datafile, $diagnostics, ['assertions', 7]), + \testSegment(['expectedToMatch' => false], ['conditions' => '*'], ['assertions', 7]), + ]; + } finally { + $f->close(); + } + $expected = [ + [['flag', ['expectedToBeEnabled'], false, true], + ['variation', ['expectedVariation'], 'missing', null], + ['variable', ['expectedVariables', 'text'], 'wrong', 'actual'], + ['evaluation', ['expectedEvaluations', 'flag', 'enabled'], false, true], + ['evaluation', ['expectedEvaluations', 'variation', 'featureKey'], 'wrong', 'flag'], + ['evaluation', ['expectedEvaluations', 'variables', 'text', 'variableValue'], 'wrong', 'actual']], + [['variable', ['expectedValue'], 'wrong', 'actual'], + ['evaluation', ['expectedEvaluation', 'variableValue'], 'wrong', 'actual']], + [['segment', ['expectedToMatch'], false, true]], + ]; + foreach ($results as $resultIndex => $result) { + self::assertTrue($result['hasError']); + $rows = $expected[$resultIndex]; + $prefixes = $resultIndex === 2 ? [[]] : [[], ['children', 0]]; + self::assertCount(count($rows) * count($prefixes), $result['errors']); + foreach ($prefixes as $child => $prefix) { + foreach ($rows as $index => [$type, $suffix, $wanted, $actual]) { + $error = $result['errors'][$child * count($rows) + $index]; + self::assertSame([$type, array_merge(['assertions', 7], $prefix, $suffix), $wanted, $actual], + [$error['type'], $error['path'], $error['expected'], $error['actual']]); + if ($prefix !== []) self::assertSame(0, $error['details']['childIndex']); + } + } + } + } + + public function testInflationRequiresAnExplicitPositiveInteger(): void + { + self::assertNull(\validateInflateOption(null)); + self::assertSame(1, \validateInflateOption('1')); + self::assertSame(1, \validateInflateOption(1)); + foreach (['0', '-5', '1.5', 'invalid', '', '1foo', true, false, 0, -5, 1.5] as $value) { + try { + \validateInflateOption($value); + self::fail('Invalid inflation was accepted'); + } catch (\InvalidArgumentException $error) { + self::assertStringContainsString('--inflate must be a positive integer', $error->getMessage()); + } + } + } + public function testRepeatedTargetOptions() { self::assertSame( @@ -30,9 +128,10 @@ public static function setUpBeforeClass(): void public function testTargetDatafileCacheKey() { - self::assertSame('false-target-checkout', \getTargetDatafileKey(false, 'checkout')); - self::assertSame('false-target-checkout', \getTargetDatafileKey(null, 'checkout')); - self::assertSame('production-target-checkout', \getTargetDatafileKey('production', 'checkout')); + self::assertSame('[null,"checkout"]', \getTargetDatafileKey(false, 'checkout')); + self::assertSame('[null,"checkout"]', \getTargetDatafileKey(null, 'checkout')); + self::assertSame('["production","checkout"]', \getTargetDatafileKey('production', 'checkout')); + self::assertSame('[null,null]', \getBaseDatafileKey(false)); } public function testEnvironmentListSupportsNoEnvironmentProjects() @@ -42,6 +141,82 @@ public function testEnvironmentListSupportsNoEnvironmentProjects() self::assertSame(['staging', 'production'], \getEnvironmentList(['environments' => ['staging', 'production']])); } + public function testDatafileCacheKeysPreserveEveryEnvironmentTargetPair(): void + { + $environments = [null, 'false', 'prod', 'prod-target-web', 'prod-target-a', "café\"\\\n東京"]; + $targets = [null, 'web', 'a-target-b', 'b', "web\"\\\n東京"]; + $cache = []; + foreach ($environments as $environment) { + foreach ($targets as $target) { + $key = $target === null ? \getBaseDatafileKey($environment) : \getTargetDatafileKey($environment, $target); + self::assertSame([$environment, $target], json_decode($key, true, 512, JSON_THROW_ON_ERROR)); + $cache[$key] = ['route' => [$environment, $target]]; + } + } + self::assertCount(count($environments) * count($targets), $cache); + foreach ($environments as $environment) { + foreach ($targets as $target) { + self::assertSame(['route' => [$environment, $target]], \getDatafileForAssertion(['environment' => $environment, 'target' => $target], $cache)); + } + } + self::assertSame(['route' => [null, null]], \getDatafileForAssertion([], $cache)); + self::assertSame(['route' => [null, 'web']], \getDatafileForAssertion(['environment' => false, 'target' => 'web'], $cache)); + self::assertNull(\getDatafileForAssertion(['environment' => 'prod', 'target' => 'missing'], $cache)); + } + + public function testExecutionProtocolEnvelopeIsStrictlyValidated(): void + { + $valid = [ + 'kind' => 'featurevisor.test-execution', + 'schemaVersion' => 1, + 'tests' => [['scenario' => 'ready', 'assertions' => []]], + ]; + self::assertSame($valid['tests'], \decodeTestExecutionEnvelope(json_encode($valid, JSON_THROW_ON_ERROR))); + + foreach ([ + [], + new \stdClass(), + array_merge($valid, ['kind' => 'other']), + array_merge($valid, ['schemaVersion' => true]), + array_merge($valid, ['schemaVersion' => '1']), + array_merge($valid, ['schemaVersion' => 2]), + array_merge($valid, ['tests' => null]), + array_merge($valid, ['tests' => [null]]), + ] as $payload) { + try { + \decodeTestExecutionEnvelope(json_encode($payload, JSON_THROW_ON_ERROR)); + self::fail('Malformed execution protocol envelope was accepted'); + } catch (\RuntimeException $error) { + self::assertStringContainsString('test execution protocol', strtolower($error->getMessage())); + } + } + } + + public function testExecutionProtocolPreservesMembershipCandidateObjects(): void + { + foreach (['includesAny', 'notIncludesAny'] as $operator) { + foreach ([(object) [], [(object) []], ['read', (object) []], ['read', []]] as $candidate) { + $checks = [['variable' => 'source', 'operator' => $operator, 'value' => $candidate]]; + $envelope = ['kind' => 'featurevisor.test-execution', 'schemaVersion' => 1, + 'tests' => [['scenario' => 'membership', 'assertions' => [['checks' => $checks]]]]]; + $tests = \decodeTestExecutionEnvelope(json_encode($envelope, JSON_THROW_ON_ERROR)); + self::assertSame(json_encode($candidate), json_encode($tests[0]['assertions'][0]['checks'][0]['value'])); + $datafile = ['schemaVersion' => '2', 'revision' => 'any', 'features' => [], 'segments' => [], + 'variables' => ['source' => ['type' => 'array', 'defaultValue' => ['read']]]]; + $sdk = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); + try { + $diagnostics = []; + $report = \FeaturevisorCLI\TestChecks::evaluateResult($sdk, $datafile, $tests[0]['assertions'][0]['checks'], null, $diagnostics); + self::assertTrue($report['error']); + self::assertFalse($report['passed']); + self::assertSame('check', $report['errors'][0]['type']); + } finally { + $sdk->close(); + } + } + } + } + public function testTargetAssertionSelectsTargetDatafile() { $datafile = \getDatafileForAssertion( @@ -50,8 +225,8 @@ public function testTargetAssertionSelectsTargetDatafile() 'target' => 'checkout', ], [ - 'production' => ['kind' => 'base'], - 'production-target-checkout' => ['kind' => 'target'], + '["production",null]' => ['kind' => 'base'], + '["production","checkout"]' => ['kind' => 'target'], ] ); @@ -66,7 +241,7 @@ public function testTargetAssertionDoesNotFallBackToBaseDatafile() 'target' => 'checkout', ], [ - 'production' => ['kind' => 'base'], + '["production",null]' => ['kind' => 'base'], ] ); @@ -80,11 +255,832 @@ public function testNoEnvironmentTargetAssertionSelectsTargetDatafile() 'target' => 'checkout', ], [ - false => ['kind' => 'base'], - 'false-target-checkout' => ['kind' => 'target'], + '[null,null]' => ['kind' => 'base'], + '[null,"checkout"]' => ['kind' => 'target'], ] ); self::assertSame('target', $datafile['kind']); } + + public function testBuiltJsonDatafilesKeepObjectIdentityThroughTheCliCache(): void + { + $program = static function (array $operation): array { + return [[ + 'key' => 'mutation', + 'segments' => '*', + 'operations' => [$operation], + 'onMatch' => 1, + 'onMiss' => 1, + ]]; + }; + $raw = json_encode([ + 'schemaVersion' => '2', + 'revision' => 'cli-raw-json', + 'segments' => [], + 'features' => [], + 'variables' => [ + 'emptyObject' => [ + 'type' => 'object', + 'defaultValue' => (object) [], + 'overrideProgram' => $program([ + 'op' => 'set', + 'path' => ['created'], + 'value' => true, + ]), + ], + 'numericObject' => [ + 'type' => 'object', + 'defaultValue' => (object) ['0' => 'zero'], + 'overrideProgram' => $program([ + 'op' => 'set', + 'path' => ['0'], + 'value' => 'changed', + ]), + ], + ], + ], JSON_THROW_ON_ERROR); + + self::assertSame($raw, \validateDatafileJson($raw)); + $datafile = \getDatafileForAssertion([], ['[null,null]' => $raw]); + self::assertIsString($datafile); + self::assertSame($raw, $datafile); + self::assertStringContainsString('"defaultValue": {}', \formatDatafile($datafile)); + + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); + self::assertSame(['created' => true], $f->getVariable('emptyObject')); + self::assertSame(['0' => 'changed'], $f->getVariable('numericObject')); + self::assertSame(Evaluation::VARIABLE_OVERRIDE_RULE, $f->evaluateVariable('emptyObject')['reason']); + self::assertSame( + ['0' => 'changed'], + \normalizeCliValue($f->evaluateVariable('numericObject')['variableValue']) + ); + $f->close(); + } + + public function testSharedCheckFixtureRunsEveryCaseWithAFreshNativeInstance(): void + { + $contents = file_get_contents(dirname(__DIR__).'/conformance/test-checks.json'); + $fixture = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + $rawFixture = json_decode($contents, false, 512, JSON_THROW_ON_ERROR); + $datafile = json_encode($rawFixture->datafile, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + self::assertSame(3, $fixture['version']); + self::assertCount(255, $fixture['cases']); + self::assertIsArray($fixture['datafile']); + + $executedAny = 0; + foreach ($fixture['cases'] as $index => $item) { + $item['checks'] = \decodeTestExecutionValue($rawFixture->cases[$index]->checks); + self::assertIsString($item['name'] ?? null); + self::assertArrayHasKey('checks', $item); + self::assertIsBool($item['passed'] ?? null); + $diagnostics = []; + $f = Featurevisor::createFeaturevisor([ + 'datafile' => $datafile, + 'logLevel' => 'fatal', + 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { + $diagnostics[] = $diagnostic; + }, + ]); + try { + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $f, + $datafile, + $item['checks'], + $item['owner'] ?? null, + $diagnostics + ); + } finally { + $f->close(); + } + $preflightError = in_array($item['name'], [ + 'or skips unreached missing feature', + 'and skips unreached missing feature', + 'short circuit skips unresolved ref', + 'AND short circuit', + ], true); + self::assertSame($preflightError ? false : $item['passed'], $result['passed'], $item['name'].': '.implode('; ', $result['failures'])); + self::assertSame($preflightError ? true : ($item['error'] ?? false), $result['error'], $item['name'].': '.implode('; ', $result['failures'])); + if (preg_match('/includesAny|notIncludesAny/', json_encode($item['checks']))) $executedAny++; + } + self::assertSame(36, $executedAny); + } + + public function testQuietCheckCollectionRejectsMalformedJsonGetter(): void + { + $datafile = [ + 'schemaVersion' => '2', 'revision' => 'bad-json', 'segments' => [], 'features' => [], + 'variables' => ['broken' => ['type' => 'json', 'defaultValue' => '[']], + ]; + $diagnostics = []; + $f = Featurevisor::createFeaturevisor([ + 'datafile' => $datafile, + 'logLevel' => 'error', + 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { + $diagnostics[] = $diagnostic; + }, + ]); + try { + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $f, + $datafile, + [['variable' => 'broken', 'operator' => 'isNull']], + null, + $diagnostics + ); + } finally { + $f->close(); + } + self::assertFalse($result['passed']); + self::assertTrue($result['error']); + self::assertStringContainsString('variable getter reported', $result['failures'][0]); + } + + public function testFeatureCheckGetterErrorsAndDiagnosticsAreErrors(): void + { + $datafile = ['features' => ['experiment' => []]]; + $throwing = new class { + public array $calls = []; + + public function isEnabled(string $feature, array $context): bool + { + $this->calls[] = "enabled:$feature"; + return false; + } + + public function getVariation(string $feature, array $context): string + { + $this->calls[] = "variation:$feature"; + throw new \RuntimeException('variation exploded'); + } + }; + $diagnostics = []; + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $throwing, + $datafile, + [['feature' => 'experiment', 'enabled' => true, 'variation' => 'treatment']], + null, + $diagnostics + ); + self::assertTrue($result['error']); + self::assertSame(['enabled:experiment', 'variation:experiment'], $throwing->calls); + self::assertStringContainsString('variation getter failed', $result['failures'][0]); + self::assertStringContainsString('feature "experiment"', $result['failures'][0]); + + $diagnostics = []; + $diagnosticEvaluator = new class($diagnostics) { + private \Closure $report; + + public function __construct(array &$diagnostics) + { + $this->report = static function (array $diagnostic) use (&$diagnostics): void { + $diagnostics[] = $diagnostic; + }; + } + + public function isEnabled(string $feature, array $context): bool + { + ($this->report)([ + 'level' => 'error', + 'code' => 'evaluation_error', + 'message' => 'flag failed', + ]); + return false; + } + }; + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $diagnosticEvaluator, + $datafile, + [['feature' => 'experiment', 'enabled' => false]], + null, + $diagnostics + ); + self::assertTrue($result['error']); + self::assertStringContainsString('enabled getter reported evaluation_error', $result['failures'][0]); + self::assertStringContainsString('feature "experiment"', $result['failures'][0]); + + $mismatching = new class { + public function isEnabled(string $feature, array $context): bool + { + return false; + } + + public function getVariation(string $feature, array $context): string + { + return 'treatment'; + } + }; + $diagnostics = []; + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $mismatching, + $datafile, + [['feature' => 'experiment', 'enabled' => true, 'variation' => 'control']], + null, + $diagnostics + ); + self::assertFalse($result['passed']); + self::assertFalse($result['error']); + self::assertStringContainsString('feature "experiment"', $result['failures'][0]); + self::assertStringContainsString('expected enabled=true, actual enabled=false', $result['failures'][0]); + self::assertStringContainsString('expected variation="control", actual variation="treatment"', $result['failures'][0]); + } + + public function testCheckSyntaxIsFullyValidatedBeforeGettersRun(): void + { + $evaluator = new class { + public int $calls = 0; + + public function isEnabled(string $feature, array $context): bool + { + $this->calls++; + return true; + } + }; + $diagnostics = []; + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $evaluator, + ['features' => ['checkout' => []]], + [ + ['feature' => 'checkout', 'enabled' => true], + ['feature' => 'checkout', 'enabled' => 'true'], + ], + null, + $diagnostics + ); + self::assertTrue($result['error']); + self::assertSame(0, $evaluator->calls); + } + + public function testReferencePreflightVisitsSkippedBranchesAndChildrenBeforeGettersRun(): void + { + $evaluator = new class { + public int $calls = 0; + + public function isEnabled(string $feature, array $context): bool + { + $this->calls++; + return true; + } + + public function getVariable(...$args) + { + $this->calls++; + return 'ready'; + } + }; + $datafile = [ + 'features' => [ + 'checkout' => ['variablesSchema' => ['plan' => ['type' => 'string', 'defaultValue' => 'free']]], + ], + 'variables' => [ + 'message' => ['type' => 'string', 'defaultValue' => 'ready'], + 'count' => ['type' => 'integer', 'defaultValue' => 1], + ], + ]; + $segments = ['adult' => ['conditions' => '*']]; + $invalidChecks = [ + [['or' => [['feature' => 'checkout', 'enabled' => true], ['feature' => 'missing', 'enabled' => true]]]], + [['and' => [['feature' => 'checkout', 'enabled' => false], ['variable' => 'missing', 'operator' => 'exists']]]], + [['not' => [['feature' => 'missing', 'enabled' => true]]]], + [['variable' => 'message', 'operator' => 'equals', 'compareTo' => ['variable' => 'missing']]], + [['segment' => 'missing', 'matches' => false]], + [['or' => [['feature' => 'checkout', 'enabled' => true], 'invalid-child']]], + ]; + foreach ($invalidChecks as $checks) { + $diagnostics = []; + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $evaluator, $datafile, $checks, null, $diagnostics, 'checks', null, $segments + ); + self::assertTrue($result['error'], json_encode($result)); + self::assertSame(0, $evaluator->calls); + } + + \FeaturevisorCLI\TestChecks::preflight( + $datafile, + [['segment' => 'adult', 'matches' => true]], + null, + $segments + ); + self::assertSame(0, $evaluator->calls); + + foreach ([ + [ + ['feature' => 'checkout'], + ['expectedVariables' => ['missing' => 'value']], + ], + [ + ['scenario' => 'child'], + ['checks' => [['feature' => 'checkout', 'enabled' => true]], 'children' => [[ + 'checks' => [['feature' => 'missing', 'enabled' => true]], + ]]], + ], + ] as [$test, $assertion]) { + try { + \preflightTestAssertion($test, $assertion, $datafile, $segments); + self::fail('Invalid assertion preflight was accepted'); + } catch (\RuntimeException $error) { + self::assertStringContainsString('absent from', $error->getMessage()); + } + self::assertSame(0, $evaluator->calls); + } + } + + public function testExcludedOwnerExpectationsAllowMissingButChildChecksPreflight(): void + { + $datafile = [ + 'schemaVersion' => '2', 'revision' => 'target', 'segments' => [], 'features' => [], 'variables' => [], + ]; + $datafiles = ['["production","filtered"]' => $datafile]; + $routing = ['environment' => 'production', 'target' => 'filtered']; + $options = ['showDatafile' => false, 'coverage' => true, 'quiet' => true, 'verbose' => false]; + $selections = []; + foreach ([ + ['feature', ['expectedToBeEnabled' => false]], + ['variable', ['expectedEvaluation' => ['reason' => 'variable_not_found']]], + ] as [$type, $expectation]) { + $result = \executeTestAssertion( + [$type => 'excluded'], + array_merge($routing, $expectation, ['children' => [$expectation]]), + $datafiles, [], $options, null, $selections + ); + self::assertFalse($result['hasError'], json_encode($result['errors'])); + } + + $selections = []; + $result = \executeTestAssertion( + ['variable' => 'excluded'], + array_merge($routing, [ + 'expectedValue' => null, + 'children' => [['checks' => [['operator' => 'isNull']]]], + ]), + $datafiles, [], $options, null, $selections + ); + self::assertTrue($result['hasError']); + self::assertStringContainsString('global variable "excluded"', $result['errors'][0]['message']); + self::assertSame([], $selections); + } + + public function testSegmentChecksUseAuthoredDefinitionsAndEffectiveContext(): void + { + $datafile = ['schemaVersion' => '2', 'revision' => 'target', 'segments' => [], 'features' => [], 'variables' => []]; + $segments = [ + 'adult' => ['conditions' => [['attribute' => 'age', 'operator' => 'greaterThanOrEquals', 'value' => 18]]], + 'netherlands' => ['conditions' => ['attribute' => 'country', 'operator' => 'equals', 'value' => 'nl']], + 'not-netherlands' => ['conditions' => ['not' => [['attribute' => 'country', 'operator' => 'equals', 'value' => 'nl']]]], + 'everyone' => ['conditions' => '*'], + ]; + $datafile = \FeaturevisorCLI\Segments::prepare($datafile, $segments); + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'context' => ['country' => 'nl', 'age' => 20], 'logLevel' => 'fatal']); + $diagnostics = []; + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $f, + $datafile, + [['and' => [ + ['segment' => 'adult', 'matches' => true], + ['or' => [ + ['segment' => 'netherlands', 'matches' => true], + ['segment' => 'everyone', 'matches' => false], + ]], + ['not' => [['segment' => 'not-netherlands', 'matches' => true]]], + ]]], + 'unused', + $diagnostics, + 'checks', + null, + $segments, + ['country' => 'nl', 'age' => 20] + ); + self::assertTrue($result['passed'], implode('; ', $result['failures'])); + + $child = \FeaturevisorCLI\TestChecks::evaluateResult( + $f->spawn(['age' => 17]), + $datafile, + [['segment' => 'adult', 'matches' => false]], + null, + $diagnostics, + 'checks', + null, + $segments, + array_merge(['country' => 'nl', 'age' => 20], ['age' => 17]) + ); + self::assertTrue($child['passed'], implode('; ', $child['failures'])); + } + + public function testSegmentChecksRejectInvalidShapesAndMissingDefinitions(): void + { + $datafile = ['segments' => [], 'features' => [], 'variables' => []]; + $segments = ['everyone' => ['conditions' => '*']]; + foreach ([ + ['matches' => true], + ['segment' => '', 'matches' => true], + ['segment' => 'everyone'], + ['segment' => 'everyone', 'matches' => 'true'], + ['segment' => 'everyone', 'matches' => true, 'feature' => 'checkout'], + ] as $leaf) { + $diagnostics = []; + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + new \stdClass(), $datafile, [$leaf], null, $diagnostics, 'checks', null, $segments, [] + ); + self::assertTrue($result['error']); + self::assertStringContainsString('segment', $result['failures'][0]); + } + + $diagnostics = []; + $skipped = \FeaturevisorCLI\TestChecks::evaluateResult( + new \stdClass(), + $datafile, + [['or' => [ + ['segment' => 'everyone', 'matches' => true], + ['segment' => 'everyone', 'matches' => 'yes'], + ]]], + null, + $diagnostics, + 'checks', + null, + $segments, + [] + ); + self::assertTrue($skipped['error']); + + $diagnostics = []; + $missing = \FeaturevisorCLI\TestChecks::evaluateResult( + new \stdClass(), $datafile, [['segment' => 'missing', 'matches' => false]], + null, $diagnostics, 'checks', null, $segments, [] + ); + self::assertTrue($missing['error']); + self::assertStringContainsString('segment "missing"', $missing['failures'][0]); + } + + public function testSegmentCheckFailureHasTypedLeafEvidence(): void + { + $segments = ['adult' => ['conditions' => ['attribute' => 'age', 'operator' => 'greaterThanOrEquals', 'value' => 18]]]; + $datafile = \FeaturevisorCLI\Segments::prepare(['schemaVersion' => '4', 'revision' => 'check', 'segments' => [], 'features' => [], 'variables' => []], $segments); + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'context' => ['age' => 20], 'logLevel' => 'fatal']); + $diagnostics = []; + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $f, + $datafile, + [['key' => 'adult-check', 'segment' => 'adult', 'matches' => false]], + null, + $diagnostics, + 'checks', + null, + ['adult' => ['conditions' => ['attribute' => 'age', 'operator' => 'greaterThanOrEquals', 'value' => 18]]], + ['age' => 20] + ); + self::assertFalse($result['passed']); + self::assertStringContainsString('segment "adult"', $result['failures'][0]); + self::assertSame([ + 'path' => ['checks', 0], + 'reference' => ['segment' => 'adult'], + 'property' => 'matches', + 'operator' => 'equals', + 'expected' => false, + 'actual' => true, + 'key' => 'adult-check', + ], $result['errors'][0]['details']['evaluated'][0]); + } + + public function testScenarioChildrenAndGlobalVariableChecksReceiveSegmentContext(): void + { + $datafile = [ + 'schemaVersion' => '2', 'revision' => 'runner', 'segments' => [], 'features' => [], + 'variables' => ['message' => ['type' => 'string', 'defaultValue' => 'ready']], + ]; + $segments = [ + 'netherlands' => ['conditions' => ['attribute' => 'country', 'operator' => 'equals', 'value' => 'nl']], + 'young-dutch' => ['conditions' => [ + ['attribute' => 'country', 'operator' => 'equals', 'value' => 'nl'], + ['attribute' => 'age', 'operator' => 'lessThan', 'value' => 18], + ]], + ]; + $datafile = \FeaturevisorCLI\Segments::prepare($datafile, $segments); + $diagnostics = []; + $scenario = [ + 'context' => ['country' => 'nl', 'age' => 30], + 'checks' => [['segment' => 'netherlands', 'matches' => true]], + 'children' => [[ + 'context' => ['age' => 17], + 'checks' => [['segment' => 'young-dutch', 'matches' => true]], + ]], + ]; + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'context' => $scenario['context'], 'logLevel' => 'fatal']); + try { + $result = \testScenario($scenario, $f, $datafile, $diagnostics, $segments); + } finally { + $f->close(); + } + self::assertFalse($result['hasError'], json_encode($result['errors'])); + + $variableAssertion = [ + 'context' => ['country' => 'nl'], + 'checks' => [['segment' => 'netherlands', 'matches' => true]], + ]; + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'context' => $variableAssertion['context'], 'logLevel' => 'fatal']); + try { + $result = \testVariable($variableAssertion, 'message', $f, $datafile, $diagnostics, ['assertions', 0], $segments); + } finally { + $f->close(); + } + self::assertFalse($result['hasError'], json_encode($result['errors'])); + } + + public function testNamedCheckMetadataPathsAndOperandEvidence(): void + { + $evaluator = new class { + public function getVariable(string $variable, array $context): string + { + return 'actual'; + } + }; + $datafile = ['variables' => ['message' => ['type' => 'string', 'defaultValue' => 'actual']]]; + $diagnostics = []; + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $evaluator, + $datafile, + [[ + 'key' => 'message-ready', 'description' => 'Message is ready', + 'variable' => 'message', 'operator' => 'equals', 'value' => 'expected', + ]], + null, + $diagnostics, + 'checks' + ); + self::assertFalse($result['passed']); + self::assertSame('check', $result['errors'][0]['type']); + self::assertSame(['checks', 0], $result['errors'][0]['path']); + self::assertTrue($result['errors'][0]['expected']); + self::assertFalse($result['errors'][0]['actual']); + self::assertSame('message-ready', $result['errors'][0]['details']['checkKey']); + self::assertSame('actual', $result['errors'][0]['details']['evaluated'][0]['actual']); + self::assertSame('expected', $result['errors'][0]['details']['evaluated'][0]['expected']); + self::assertSame(['checks', 0], $result['errors'][0]['details']['evaluated'][0]['path']); + self::assertStringContainsString('key="message-ready"', $result['errors'][0]['message']); + self::assertStringContainsString('description="Message is ready"', $result['errors'][0]['message']); + self::assertStringContainsString('left="actual"', $result['errors'][0]['message']); + self::assertStringContainsString('right="expected"', $result['errors'][0]['message']); + + $duplicate = \FeaturevisorCLI\TestChecks::evaluateResult( + $evaluator, + $datafile, + [ + ['key' => 'same', 'variable' => 'message', 'operator' => 'exists'], + ['key' => 'same', 'variable' => 'message', 'operator' => 'exists'], + ], + null, + $diagnostics + ); + self::assertTrue($duplicate['error']); + self::assertStringContainsString('duplicate sibling key "same"', $duplicate['failures'][0]); + } + + public function testCheckEvidenceDistinguishesMissingPathsFromNullAndLiteralStrings(): void + { + $evaluator = new class { + public function getVariable(string $variable, array $context): array + { + return ['nullable' => null, 'text' => '']; + } + }; + $datafile = ['variables' => ['settings' => ['type' => 'object', 'defaultValue' => ['nullable' => null, 'text' => '']]]]; + $diagnostics = []; + $result = \FeaturevisorCLI\TestChecks::evaluateResult( + $evaluator, + $datafile, + [ + ['variable' => 'settings', 'path' => 'nullable', 'operator' => 'isNull'], + ['key' => 'missing-value', 'variable' => 'settings', 'path' => 'absent', 'operator' => 'isNull'], + ], + null, + $diagnostics + ); + self::assertFalse($result['passed']); + self::assertCount(1, $result['errors']); + $missing = $result['errors'][0]['details']['evaluated'][0]; + self::assertTrue($missing['actualMissing']); + self::assertTrue($missing['expectedMissing']); + self::assertSame(['$type' => 'missing'], $missing['actual']); + + $literal = \FeaturevisorCLI\TestChecks::evaluateResult( + $evaluator, + $datafile, + [['variable' => 'settings', 'path' => 'text', 'operator' => 'equals', 'value' => 'different']], + null, + $diagnostics + ); + $literalEvidence = $literal['errors'][0]['details']['evaluated'][0]; + self::assertSame('', $literalEvidence['actual']); + self::assertArrayNotHasKey('actualMissing', $literalEvidence); + } + + public function testCoverageSelectionsExpandPathsAndKeepKeyFallbackAsAPath(): void + { + $selections = []; + \recordCoverageSelection( + ['variableKey' => 'policy', 'variableOverridePaths' => [['first'], ['second', 'nested']]], + ['environment' => 'production', 'target' => 'web'], + 'staging', + $selections + ); + \recordCoverageSelection( + ['featureKey' => 'checkout', 'variableKey' => 'message', 'variableOverrideKey' => 'country'], + [], + null, + $selections + ); + self::assertSame(['first'], $selections[0]['overridePath']); + self::assertSame(['second', 'nested'], $selections[1]['overridePath']); + self::assertSame(['country'], $selections[2]['overridePath']); + self::assertSame('production', $selections[0]['environment']); + self::assertSame('web', $selections[0]['target']); + } + + public function testReportSummarySeparatesEntityAndScenarioCounts(): void + { + $report = \emptyTestReport(); + $report['results'] = [ + ['type' => 'feature', 'passed' => true, 'cases' => [['passed' => true], ['passed' => true]]], + ['type' => 'scenario', 'passed' => false, 'cases' => [['passed' => true], ['passed' => false]]], + ]; + \finalizeTestReport($report); + self::assertSame(['passed' => 1, 'failed' => 1], $report['summary']['specs']); + self::assertSame(['passed' => 3, 'failed' => 1], $report['summary']['assertions']); + self::assertSame(['passed' => 1, 'failed' => 0], $report['summary']['entitySpecs']); + self::assertSame(['passed' => 2, 'failed' => 0], $report['summary']['entityAssertions']); + self::assertSame(['passed' => 0, 'failed' => 1], $report['summary']['scenarioSpecs']); + self::assertSame(['passed' => 1, 'failed' => 1], $report['summary']['scenarioCases']); + self::assertFalse($report['passed']); + } + + public function testCaseReportRetainsExactQuotedIdAndExplicitProjectDirectory(): void + { + global $cliOptions; + $previous = $cliOptions; + $cliOptions['projectDirectoryPath'] = '/tmp/project with spaces'; + try { + $case = \testCaseReport( + [ + 'description' => 'quoted', + '_case' => ['id' => '[null,"feature","it\'s a \\"quoted\\" case",0,null]', 'matrixValues' => []], + ], + ['hasError' => false, 'errors' => []], + null, + [] + ); + } finally { + $cliOptions = $previous; + } + self::assertSame('[null,"feature","it\'s a \\"quoted\\" case",0,null]', $case['id']); + self::assertSame( + escapeshellarg(PHP_BINARY).' '.escapeshellarg(dirname(__DIR__).'/featurevisor'), + $case['rerun']['command'] + ); + self::assertContains('--case='.$case['id'], $case['rerun']['args']); + self::assertContains('--projectDirectoryPath=/tmp/project with spaces', $case['rerun']['args']); + } + + public function testNativeExecutableRunsScenarioChildrenFilteringAndFailureExit(): void + { + $directory = sys_get_temp_dir().'/featurevisor-php-cli-'.bin2hex(random_bytes(6)); + mkdir($directory); + $npx = $directory.'/npx'; + $log = $directory.'/calls.log'; + $datafile = [ + 'schemaVersion' => '2', 'revision' => 'runner', 'segments' => [], 'features' => [], + 'variables' => [ + 'message' => [ + 'type' => 'string', 'defaultValue' => 'parent', + 'overrides' => [[ + 'key' => 'child', 'segments' => '*', + 'conditions' => ['attribute' => 'mode', 'operator' => 'equals', 'value' => 'child'], + 'value' => 'child', + ]], + ], + ], + ]; + $passing = [[ + 'key' => 'scenarios/context.spec', 'scenario' => 'context', + 'assertions' => [[ + 'description' => 'matrix expanded scenario', + 'checks' => [['variable' => 'message', 'operator' => 'equals', 'value' => 'parent']], + 'children' => [[ + 'context' => ['mode' => 'child'], + 'checks' => [['variable' => 'message', 'operator' => 'equals', 'value' => 'child']], + ]], + ]], + ]]; + $failing = [[ + 'key' => 'scenarios/failure.spec', 'scenario' => 'failure', + 'assertions' => [[ + 'description' => 'failing scenario', + 'checks' => [['variable' => 'message', 'operator' => 'equals', 'value' => 'wrong']], + ]], + ]]; + $malformed = [ + ['key' => 'scenarios/empty.spec', 'scenario' => 'empty', 'assertions' => []], + [ + 'key' => 'scenarios/hybrid.spec', 'scenario' => 'hybrid', 'variable' => 'message', + 'assertions' => [[ + 'description' => 'hybrid', + 'checks' => [['variable' => 'message', 'operator' => 'exists']], + ]], + ], + ]; + $script = '#!'.PHP_BINARY."\n".<<<'PHP' + false]; +elseif (array_slice($args, 0, 2) === ['list', '--targets'] || array_slice($args, 0, 2) === ['list', '--segments']) $value = []; +elseif (array_slice($args, 0, 2) === ['list', '--tests']) { + if (getenv('FAKE_MODE') === 'protocol') $value = []; + else { + $fixture = getenv('FAKE_MODE') === 'pass' ? 'FAKE_PASSING' : (getenv('FAKE_MODE') === 'malformed' ? 'FAKE_MALFORMED' : 'FAKE_FAILING'); + $value = [ + 'kind' => 'featurevisor.test-execution', + 'schemaVersion' => 1, + 'tests' => json_decode(getenv($fixture), true), + ]; + } +} +elseif (($args[0] ?? null) === 'build') { + if (getenv('FAKE_MODE') === 'delegate-error') { + fwrite(STDERR, "Syntax error at build staging\n"); + exit(17); + } + fwrite(STDERR, "Mutation order warning\n"); + $value = json_decode(getenv('FAKE_DATAFILE'), true); +} +else { fwrite(STDERR, 'unexpected arguments: '.json_encode($args)); exit(2); } +echo json_encode($value), "\n"; +PHP; + file_put_contents($npx, $script); + chmod($npx, 0755); + $environment = getenv(); + $environment['PATH'] = $directory.PATH_SEPARATOR.$environment['PATH']; + $environment['FAKE_NPX_LOG'] = $log; + $environment['FAKE_DATAFILE'] = json_encode($datafile, JSON_THROW_ON_ERROR); + $environment['FAKE_PASSING'] = json_encode($passing, JSON_THROW_ON_ERROR); + $environment['FAKE_FAILING'] = json_encode($failing, JSON_THROW_ON_ERROR); + $environment['FAKE_MALFORMED'] = json_encode($malformed, JSON_THROW_ON_ERROR); + + try { + $environment['FAKE_MODE'] = 'pass'; + [$status, $output] = $this->runCliProcess($environment, $directory); + self::assertSame(0, $status, $output); + self::assertStringContainsString('Total cases: 1 passed, 0 failed', $output); + self::assertStringContainsString('Mutation order warning', $output); + self::assertStringContainsString('--entityType=scenario', file_get_contents($log)); + self::assertStringContainsString('--set=staging', file_get_contents($log)); + + $environment['FAKE_MODE'] = 'fail'; + [$status, $output] = $this->runCliProcess($environment, $directory); + self::assertSame(1, $status, $output); + self::assertStringContainsString('check 1', $output); + self::assertStringContainsString('left="parent"', $output); + + $environment['FAKE_MODE'] = 'delegate-error'; + [$status, $output] = $this->runCliProcess($environment, $directory); + self::assertSame(1, $status, $output); + self::assertStringContainsString('exit code 17', $output); + self::assertStringContainsString('Syntax error at build staging', $output); + + $environment['FAKE_MODE'] = 'malformed'; + [$status, $output] = $this->runCliProcess($environment, $directory); + self::assertSame(1, $status, $output); + self::assertStringContainsString('scenario spec requires assertions', $output); + self::assertStringContainsString('requires exactly one of feature, variable, segment or scenario', $output); + + file_put_contents($log, ''); + $environment['FAKE_MODE'] = 'protocol'; + [$status, $output] = $this->runCliProcess($environment, $directory); + self::assertSame(1, $status, $output); + self::assertStringContainsString('test execution protocol', strtolower($output)); + $calls = file_get_contents($log); + self::assertStringContainsString('list --tests --execution-protocol --json', $calls); + self::assertStringNotContainsString('featurevisor build', $calls); + } finally { + if (file_exists($log)) unlink($log); + unlink($npx); + rmdir($directory); + } + } + + /** @return array{int,string} */ + private function runCliProcess(array $environment, string $project): array + { + $pipes = []; + $process = proc_open( + [PHP_BINARY, dirname(__DIR__).'/featurevisor', 'test', '--projectDirectoryPath='.$project, '--set=staging', '--entityType=scenario', '--onlyFailures'], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + dirname(__DIR__), + $environment + ); + self::assertIsResource($process); + $output = stream_get_contents($pipes[1]).stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return [proc_close($process), $output]; + } } diff --git a/tests/FeaturevisorTest.php b/tests/FeaturevisorTest.php index efb16b9..46ccbf8 100644 --- a/tests/FeaturevisorTest.php +++ b/tests/FeaturevisorTest.php @@ -284,7 +284,7 @@ public function testShouldInterceptContextBeforeModule() 'modules' => [ [ 'name' => 'unit-test', - 'before' => function($options) use (&$intercepted, &$interceptedFeatureKey, &$interceptedVariableKey) { + 'beforeEvaluation' => function($options) use (&$intercepted, &$interceptedFeatureKey, &$interceptedVariableKey) { $intercepted = true; $interceptedFeatureKey = $options['featureKey']; $interceptedVariableKey = $options['variableKey'] ?? ''; @@ -337,7 +337,7 @@ public function testShouldInterceptValueAfterModule() 'modules' => [ [ 'name' => 'unit-test', - 'after' => function($options) use (&$intercepted, &$interceptedFeatureKey, &$interceptedVariableKey) { + 'afterEvaluation' => function($options) use (&$intercepted, &$interceptedFeatureKey, &$interceptedVariableKey) { $intercepted = true; $interceptedFeatureKey = $options['featureKey']; $interceptedVariableKey = $options['variableKey'] ?? ''; @@ -440,7 +440,7 @@ public function testShouldHonourSimpleRequiredFeatures() 'myKey' => [ 'key' => 'myKey', 'bucketBy' => 'userId', - 'required' => ['requiredKey'], + 'requiredFeatures' => ['requiredKey'], 'traffic' => [ [ 'key' => '1', @@ -479,7 +479,7 @@ public function testShouldHonourSimpleRequiredFeatures() 'myKey' => [ 'key' => 'myKey', 'bucketBy' => 'userId', - 'required' => ['requiredKey'], + 'requiredFeatures' => ['requiredKey'], 'traffic' => [ [ 'key' => '1', @@ -523,9 +523,9 @@ public function testShouldHonourRequiredFeaturesWithVariation() 'myKey' => [ 'key' => 'myKey', 'bucketBy' => 'userId', - 'required' => [ + 'requiredFeatures' => [ [ - 'key' => 'requiredKey', + 'feature' => 'requiredKey', 'variation' => 'control', // different variation ], ], @@ -570,9 +570,9 @@ public function testShouldHonourRequiredFeaturesWithVariation() 'myKey' => [ 'key' => 'myKey', 'bucketBy' => 'userId', - 'required' => [ + 'requiredFeatures' => [ [ - 'key' => 'requiredKey', + 'feature' => 'requiredKey', 'variation' => 'treatment', // desired variation ], ], @@ -702,7 +702,7 @@ public function testShouldCheckIfEnabledForOverriddenFlagsFromRules() self::assertFalse($sdk->isEnabled('test', ['userId' => 'user-123', 'country' => 'nl'])); } - public function testShouldCheckIfEnabledForMutuallyExclusiveFeatures() + public function testLegacyFeatureRangesAreIgnored() { $bucketValue = 10000; @@ -722,7 +722,7 @@ public function testShouldCheckIfEnabledForMutuallyExclusiveFeatures() 'mutex' => [ 'key' => 'mutex', 'bucketBy' => 'userId', - 'ranges' => [[0, 50000]], + 'ranges' => [[50000, 100000]], 'traffic' => [['key' => '1', 'segments' => '*', 'percentage' => 50000, 'allocation' => []]], ], ], @@ -736,6 +736,9 @@ public function testShouldCheckIfEnabledForMutuallyExclusiveFeatures() $bucketValue = 40000; self::assertTrue($sdk->isEnabled('mutex', ['userId' => '123'])); + $bucketValue = 50000; + self::assertTrue($sdk->isEnabled('mutex', ['userId' => '123'])); + $bucketValue = 60000; self::assertFalse($sdk->isEnabled('mutex', ['userId' => '123'])); } @@ -1076,7 +1079,7 @@ public function testShouldGetVariable() self::assertEquals(0, $sdk->getVariableInteger('test', 'count', $context)); self::assertEquals(9.99, $sdk->getVariable('test', 'price', $context)); - self::assertEquals(9.99, $sdk->getVariableDouble('test', 'price', $context)); + self::assertEquals(9.99, $sdk->getVariableNumber('test', 'price', $context)); self::assertEquals(['paypal', 'creditcard'], $sdk->getVariable('test', 'paymentMethods', $context)); self::assertEquals(['paypal', 'creditcard'], $sdk->getVariableArray('test', 'paymentMethods', $context)); diff --git a/tests/HelpersTest.php b/tests/HelpersTest.php index 41d836e..1221d56 100644 --- a/tests/HelpersTest.php +++ b/tests/HelpersTest.php @@ -20,7 +20,9 @@ public function testShouldResolveSupportedTypes() self::assertSame(['1', '2'], Helpers::getValueByType(['1', '2'], 'array')); self::assertNull(Helpers::getValueByType('1', 'integer')); self::assertNull(Helpers::getValueByType(1.1, 'integer')); - self::assertNull(Helpers::getValueByType('1.1', 'double')); + self::assertNull(Helpers::getValueByType('1.1', 'number')); + self::assertSame(1.1, Helpers::getValueByType(1.1, 'number')); + self::assertSame(2.0, Helpers::getValueByType(2, 'number')); self::assertNull(Helpers::getValueByType('true', 'boolean')); self::assertSame(['x' => 1], Helpers::getValueByType(['x' => 1], 'json')); } diff --git a/tests/IncludesAnyTest.php b/tests/IncludesAnyTest.php new file mode 100644 index 0000000..f8d2a47 --- /dev/null +++ b/tests/IncludesAnyTest.php @@ -0,0 +1,133 @@ + 'source', 'path' => ['items'], 'operator' => $operator, 'value' => $candidate]; + $traffic = [['key' => 'all', 'segments' => '*', 'percentage' => 100000, 'allocation' => []]]; + $source = ['type' => 'object', 'defaultValue' => (object) ['items' => $actual]]; + $datafile = [ + 'schemaVersion' => '2', 'revision' => 'any', 'segments' => [], + 'features' => [ + 'sourceFeature' => ['bucketBy' => 'userId', 'traffic' => $traffic, 'variablesSchema' => ['source' => $source]], + 'condition' => ['bucketBy' => 'userId', 'traffic' => [], 'force' => [[ + 'conditions' => ['attribute' => 'profile.items', 'operator' => $operator, 'value' => $candidate], 'enabled' => true, + ]]], + ], + 'variables' => ['source' => $source, 'other' => ['type' => 'object', 'defaultValue' => (object) ['items' => $candidate]]], + ]; + foreach ([false, true] as $featureSource) { + $selected = array_merge($requirement, $featureSource ? ['feature' => 'sourceFeature'] : []); + foreach (['direct', 'not', 'or'] as $group) { + $requirements = $group === 'direct' ? [$selected] : [[$group => [$selected]]]; + $key = 'gate'.(int) $featureSource.$group; + $datafile['variables'][$key] = ['type' => 'boolean', 'defaultValue' => true, 'disabledValue' => false, 'conditions' => $requirements]; + $datafile['features'][$key] = ['bucketBy' => 'userId', 'traffic' => $traffic, 'conditions' => $requirements]; + } + } + $sdk = Featurevisor::createFeaturevisor(['datafile' => json_encode($datafile, JSON_THROW_ON_ERROR), 'logLevel' => 'fatal']); + try { + self::assertSame($expected, $sdk->isEnabled('condition', ['profile' => ['items' => $actual]]), $label); + self::assertFalse($sdk->isEnabled('condition', ['profile' => []]), $label); + $condition = $datafile['features']['condition']['force'][0]['conditions']; + foreach ([null, 'not', 'or'] as $group) { + $expression = $group === null ? $condition : [$group => [$condition]]; + $datafile['features']['condition']['force'][0]['conditions'] = json_encode($expression, JSON_THROW_ON_ERROR); + $sdk->setDatafile(json_encode($datafile, JSON_THROW_ON_ERROR), true); + self::assertSame($group === 'not' ? !$expected : $expected, $sdk->isEnabled('condition', ['profile' => ['items' => $actual]]), $label); + } + foreach ([false, true] as $featureSource) { + $selected = array_merge($requirement, $featureSource ? ['feature' => 'sourceFeature'] : []); + foreach (['direct', 'not', 'or'] as $group) { + $key = 'gate'.(int) $featureSource.$group; + self::assertSame($group === 'not' ? !$expected : $expected, $sdk->getVariable($key), $label); + self::assertSame($group === 'not' ? !$expected : $expected, $sdk->isEnabled($key), $label); + } + foreach ([false, true] as $compareTo) { + $check = $selected; + if ($compareTo) { + unset($check['value']); + $check['compareTo'] = ['variable' => 'other', 'path' => ['items']]; + } + foreach (['direct', 'not', 'or'] as $group) { + $node = $group === 'direct' ? $check : [$group => [$check]]; + $diagnostics = []; + $report = TestChecks::evaluateResult($sdk, $datafile, [$node], null, $diagnostics); + self::assertSame($matched === null, $report['error'], $label); + self::assertSame($matched !== null && ($group === 'not' ? !$expected : $expected), $report['passed'], $label); + if ($matched === null) { + self::assertSame('check', $report['errors'][0]['type'], $label); + self::assertContains($report['errors'][0]['expected'], ['valid checks', 'successful comparison'], $label); + if ($compareTo) self::assertNotEmpty($report['errors'][0]['details']['evaluated'], $label); + } + } + } + } + $check = ['variable' => 'source', 'path' => ['absent'], 'operator' => $operator, 'value' => []]; + $diagnostics = []; + $report = TestChecks::evaluateResult($sdk, $datafile, [$check], null, $diagnostics); + self::assertFalse($report['error'], $label); + self::assertFalse($report['passed'], $label); + $malformed = ['variable' => 'source', 'path' => ['items'], 'operator' => $operator, 'compareTo' => ['variable' => 'other', 'path' => ['items']]]; + $report = TestChecks::evaluateResult($sdk, $datafile, [['or' => [['variable' => 'source', 'operator' => 'exists'], $malformed]]], null, $diagnostics); + self::assertTrue($report['passed'], $label); + self::assertFalse($report['error'], $label); + } finally { + $sdk->close(); + } + } + } + } +} diff --git a/tests/IncrementalDependencyIndexTest.php b/tests/IncrementalDependencyIndexTest.php new file mode 100644 index 0000000..e2977cf --- /dev/null +++ b/tests/IncrementalDependencyIndexTest.php @@ -0,0 +1,264 @@ +datafile(); + $datafile['schemaVersion'] = $schema; + $entity = ['hash' => 'same', 'conditions' => ['variable' => 'source', 'operator' => 'notNull']]; + $datafile['features'] = ['first' => $entity, 'second' => $entity, 'removed' => $entity]; + $datafile['variables'] = ['source' => ['hash' => '1', 'type' => 'integer', 'defaultValue' => 1], + 'other' => ['hash' => '1', 'type' => 'integer', 'defaultValue' => 1], + 'alias' => ['hash' => 'same', 'compose' => 'source'], + 'hashless' => ['type' => 'integer', 'defaultValue' => 0]]; + $sdk = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); + try { + self::assertTrue($sdk->setDatafile($datafile)); + $events = []; + $sdk->on('datafile_set', static function ($event) use (&$events): void { $events[] = $event; }); + $datafile['features'] = ['second' => $entity, 'first' => $entity]; + $datafile['revision'] = 'replacement'; + self::assertTrue($sdk->setDatafile($datafile, true)); + self::assertSame(['removed'], $events[0]['features']); + self::assertSame(['hashless'], $events[0]['variables']); + $index = new \ReflectionProperty($sdk, 'dependencyIndex'); + $index->setAccessible(true); + self::assertArrayNotHasKey('["feature","removed"]', $index->getValue($sdk)['owners']); + $patch = $this->datafile(); + $patch['variables']['source'] = ['hash' => '2', 'type' => 'integer', 'defaultValue' => 2]; + self::assertTrue($sdk->setDatafile($patch)); + self::assertSame(['second', 'first'], $events[1]['features']); + self::assertSame(['source', 'hashless', 'alias'], $events[1]['variables']); + $datafile['variables']['source'] = $patch['variables']['source']; + $datafile['variables']['alias']['compose'] = 'other'; + $datafile['features']['second']['conditions']['variable'] = 'other'; + self::assertTrue($sdk->setDatafile($datafile, true)); + $patch['variables']['source']['hash'] = '3'; + self::assertTrue($sdk->setDatafile($patch)); + self::assertSame(['first'], $events[3]['features']); + self::assertSame(['source', 'hashless'], $events[3]['variables']); + } finally { $sdk->close(); } + } + } + + public function testReuseDoesNotRetainDiagnosticsOrRemovedEntityCaches(): void + { + foreach (['2', '4'] as $schema) foreach ([false, true] as $replace) { + $diagnostics = []; + $datafile = $this->datafile(); + $datafile['schemaVersion'] = $schema; + $datafile['segments']['broken'] = ['conditions' => '{bad']; + $datafile['features']['removed'] = ['hash' => 'same', 'conditions' => '*']; + $datafile['variables']['removedVariable'] = ['type' => 'integer', 'defaultValue' => 1]; + $sdk = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'error', + 'onDiagnostic' => static function ($item) use (&$diagnostics): void { $diagnostics[] = $item; }]); + try { + $sdk->getSegment('broken'); + $sdk->getFeature('removed'); + $sdk->getVariable('removedVariable'); + self::assertCount(1, $diagnostics); + self::assertTrue($sdk->setDatafile($datafile, $replace)); + $sdk->getSegment('broken'); + $sdk->getSegment('broken'); + self::assertSame(['conditions_parse_error', 'conditions_parse_error'], array_column($diagnostics, 'code')); + self::assertTrue($sdk->setDatafile($this->datafile(), true)); + $cache = new \ReflectionProperty($sdk, 'entityCache'); + $cache->setAccessible(true); + self::assertSame([], $cache->getValue($sdk)); + $index = new \ReflectionProperty($sdk, 'dependencyIndex'); + $index->setAccessible(true); + self::assertSame(['reverse' => [], 'owners' => []], $index->getValue($sdk)); + } finally { $sdk->close(); } + } + } + + public function testOnlyIncomingSelectorsAreParsedAfterTheFirstUpdate(): void + { + foreach ([10, 500] as $size) { + $output = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/support/dependency-index-probe.php') . ' ' . $size); + self::assertSame([0, 2 * $size + 1, 1, 0, 0, 1, 1], json_decode($output, true, 512, JSON_THROW_ON_ERROR)); + } + } + + public function testFullMergeReusesIdenticalDefinitionsButRefreshesSameHashEdges(): void + { + $datafile = $this->datafile(); + $datafile['variables'] = [ + 'old' => ['hash' => '1', 'type' => 'integer', 'defaultValue' => 1], + 'new' => ['hash' => '1', 'type' => 'integer', 'defaultValue' => 1], + 'alias' => ['hash' => 'same', 'compose' => 'old'], + 'hashless' => ['type' => 'integer', 'defaultValue' => 1], + ]; + $datafile['segments'] = ['gate' => ['conditions' => ['variable' => 'old', 'operator' => 'equals', 'value' => 1]]]; + $datafile['features'] = ['dependent' => ['hash' => 'same', 'conditions' => 'gate']]; + $sdk = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); + $events = []; + $sdk->on('datafile_set', static function ($event) use (&$events): void { $events[] = $event; }); + self::assertTrue($sdk->setDatafile($datafile)); + self::assertSame([], $events[0]['features']); + self::assertSame(['hashless'], $events[0]['variables']); + $datafile['segments']['gate']['conditions']['variable'] = 'new'; + $datafile['variables']['alias']['compose'] = 'new'; + self::assertTrue($sdk->setDatafile($datafile)); + self::assertSame(['dependent'], $events[1]['features']); + self::assertSame(['hashless'], $events[1]['variables']); + $patch = $this->datafile(); + $patch['variables'] = ['old' => ['hash' => '2', 'type' => 'integer', 'defaultValue' => 2]]; + self::assertTrue($sdk->setDatafile($patch)); + self::assertSame([], $events[2]['features']); + self::assertSame(['old', 'hashless'], $events[2]['variables']); + $patch['variables'] = ['new' => ['hash' => '2', 'type' => 'integer', 'defaultValue' => 2]]; + self::assertTrue($sdk->setDatafile($patch)); + self::assertSame(['dependent'], $events[3]['features']); + self::assertSame(['new', 'hashless', 'alias'], $events[3]['variables']); + $sdk->close(); + } + + public function testTraversalRetainsStrictNumericKeysAndDuplicateSeedCounts(): void + { + $previous = $this->datafile(); + $previous['features'] = [0 => ['hash' => 'old'], 'dependent' => ['hash' => 'same', 'conditions' => ['feature' => '0']]]; + $previous['variables'] = [0 => ['hash' => 'old'], 'alias' => ['hash' => 'same', 'compose' => '0']]; + $current = $previous; + $current['features'][0]['hash'] = 'new'; + $current['variables'][0]['hash'] = 'new'; + $cache = null; + $event = Events::getParamsForDatafileSetEvent($previous, $current, false, $cache, $current); + self::assertSame([0, '0', 'dependent'], $event['features']); + self::assertSame([0, '0', 'alias'], $event['variables']); + $replacement = $current; + $replacement['features']['added'] = ['hash' => 'same', 'conditions' => ['feature' => '0']]; + $event = Events::getParamsForDatafileSetEvent($current, $replacement, true, $cache, $replacement); + self::assertSame(['added'], $event['features']); + $replacement['features'][0]['hash'] = 'next'; + $event = Events::getParamsForDatafileSetEvent($current, $replacement, true, $cache, $replacement); + self::assertSame([0, 'added', '0', 'dependent'], $event['features']); + $traverse = new \ReflectionMethod(Events::class, 'addDependencyChanges'); + $traverse->setAccessible(true); + $features = ['same', 'same', 0]; + $variables = ['same', 'same', 0]; + $traverse->invokeArgs(null, [&$features, &$variables, [], []]); + self::assertSame(['same', 'same', 0, '0'], $features); + self::assertSame(['same', 'same', 0, '0'], $variables); + } + + public function testMergeDoesNotRequireComparableMetadataOrReuseCallerObjects(): void + { + $first = []; + $first['self'] = &$first; + $second = []; + $second['self'] = &$second; + $datafile = $this->datafile(); + $datafile['variables'] = ['value' => ['type' => 'object', 'defaultValue' => (object) ['items' => []], 'metadata' => $first]]; + $sdk = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); + $datafile['variables']['value']['metadata'] = $second; + self::assertTrue($sdk->setDatafile($datafile)); + $replacement = (object) ['items' => []]; + $datafile['variables']['value']['defaultValue'] = $replacement; + self::assertTrue($sdk->setDatafile($datafile)); + $stored = new \ReflectionProperty($sdk, 'datafile'); + $stored->setAccessible(true); + self::assertSame($replacement, $stored->getValue($sdk)['variables']['value']['defaultValue']); + $value = $sdk->evaluateVariable('value')['variableValue']; + self::assertInstanceOf(\stdClass::class, $value); + self::assertNotSame($replacement, $value); + self::assertSame([], $value->items); + $sdk->close(); + } + + public function testReusedEdgesRetainIncomingTraversalOrder(): void + { + $datafile = $this->datafile(); + $entity = ['hash' => 'same', 'conditions' => ['variable' => 'source', 'operator' => 'equals', 'value' => 1]]; + $datafile['features'] = ['first' => $entity, 'second' => $entity]; + $datafile['variables'] = ['source' => ['hash' => '1', 'type' => 'integer', 'defaultValue' => 1], + 'hashless' => ['type' => 'integer', 'defaultValue' => 1]]; + $sdk = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); + self::assertTrue($sdk->setDatafile($datafile)); + $datafile['features'] = ['second' => $entity, 'first' => $entity]; + self::assertTrue($sdk->setDatafile($datafile)); + $events = []; + $sdk->on('datafile_set', static function ($event) use (&$events): void { $events[] = $event; }); + $patch = $this->datafile(); + $patch['variables'] = ['source' => ['hash' => '2', 'type' => 'integer', 'defaultValue' => 2]]; + self::assertTrue($sdk->setDatafile($patch)); + self::assertSame(['second', 'first'], $events[0]['features']); + self::assertSame(['source', 'hashless'], $events[0]['variables']); + $sdk->close(); + } + + public function testLargeFanoutAndCyclesKeepExactKeysAcrossReentrantUpdates(): void + { + $datafile = $this->datafile(); + $datafile['variables'] = ['source' => ['hash' => '1', 'type' => 'integer', 'defaultValue' => 1]]; + for ($i = 0; $i < 500; $i++) { + $datafile['features']['f'.$i] = ['hash' => 'same', 'conditions' => ['variable' => 'source', 'operator' => 'equals', 'value' => 1]]; + $datafile['variables']['v'.$i] = ['hash' => 'same', 'compose' => $i === 0 ? 'source' : 'v'.($i - 1)]; + } + $sdk = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); + $events = []; + $sdk->on('datafile_set', function ($event) use (&$events, $sdk): void { + $events[] = $event; + if (count($events) === 1) { + self::assertSame('2', $sdk->getRevision()); + $patch = $this->datafile(); + $patch['revision'] = '3'; + $patch['variables'] = ['v0' => ['hash' => 'same', 'compose' => 'v499']]; + self::assertTrue($sdk->setDatafile($patch)); + } + }); + $patch = $this->datafile(); + $patch['revision'] = '2'; + $patch['variables'] = ['source' => ['hash' => '2', 'type' => 'integer', 'defaultValue' => 2]]; + self::assertTrue($sdk->setDatafile($patch)); + self::assertSame(array_keys($datafile['features']), $events[0]['features']); + self::assertSame(array_keys($datafile['variables']), $events[0]['variables']); + self::assertSame([], $events[1]['features']); + self::assertSame([], $events[1]['variables']); + $patch['variables']['source']['hash'] = '3'; + self::assertTrue($sdk->setDatafile($patch)); + self::assertSame(array_keys($datafile['features']), $events[2]['features']); + self::assertSame(['source'], $events[2]['variables']); + $sdk->close(); + } + + public function testOldThenNewTraversalDoesNotMixGraphGenerations(): void + { + $old = $this->datafile(); + $old['features'] = [ + 'bridge' => ['hash' => 'same', 'conditions' => '*'], + 'unaffected' => ['hash' => 'same', 'conditions' => ['feature' => 'bridge']], + ]; + $sdk = Featurevisor::createFeaturevisor(['datafile' => $old, 'logLevel' => 'fatal']); + $events = []; + $sdk->on('datafile_set', static function ($event) use (&$events): void { $events[] = $event; }); + $patch = $this->datafile(); + $patch['variables'] = ['source' => ['hash' => 'new', 'type' => 'integer', 'defaultValue' => 1]]; + $patch['features'] = [ + 'bridge' => ['hash' => 'same', 'conditions' => ['variable' => 'source', 'operator' => 'equals', 'value' => 1]], + 'unaffected' => ['hash' => 'same', 'conditions' => '*'], + ]; + self::assertTrue($sdk->setDatafile($patch)); + self::assertSame(['bridge'], $events[0]['features']); + self::assertTrue($sdk->setDatafile($this->datafile(), true)); + self::assertSame(['bridge', 'unaffected'], $events[1]['features']); + $index = new \ReflectionProperty($sdk, 'dependencyIndex'); + $index->setAccessible(true); + self::assertSame(['reverse' => [], 'owners' => []], $index->getValue($sdk)); + $sdk->close(); + self::assertNull($index->getValue($sdk)); + } + + private function datafile(): array + { + return ['schemaVersion' => '4', 'revision' => 'test', 'features' => [], 'segments' => [], 'variables' => []]; + } +} diff --git a/tests/InitialLoadingTest.php b/tests/InitialLoadingTest.php new file mode 100644 index 0000000..bb9c4b1 --- /dev/null +++ b/tests/InitialLoadingTest.php @@ -0,0 +1,131 @@ + '4', 'revision' => 'initial', + 'segments' => [ + 'audience' => ['conditions' => '{"variable":"source","operator":"notNull"}'], + 'alias' => ['conditions' => 'audience'], + 'broken' => ['conditions' => '{bad'], + ], + 'features' => ['gate' => [ + 'hash' => 'stable', 'bucketBy' => 'userId', 'conditions' => 'alias', + 'traffic' => [['key' => 'all', 'conditions' => '*', 'percentage' => 100000]], + ]], + 'variables' => [ + 'source' => ['hash' => 'one', 'type' => 'integer', 'defaultValue' => 1], + 'map' => ['hash' => 'stable', 'type' => 'object', 'compose' => [['key' => 'enabled', 'feature' => 'gate']]], + 'alias' => ['hash' => 'stable', 'type' => 'object', 'compose' => 'map'], + ], + ]; + } + + private function emptyDatafile(string $revision = 'empty'): array + { + return ['schemaVersion' => '4', 'revision' => $revision, 'features' => [], 'segments' => [], 'variables' => []]; + } + + private function assertCold(Featurevisor $sdk): void + { + $cache = new \ReflectionProperty($sdk, 'entityCache'); + $cache->setAccessible(true); + self::assertSame([], $cache->getValue($sdk)); + } + + private function assertAffected(array $event, array $variables): void + { + self::assertSame(['gate'], $event['features']); + self::assertEqualsCanonicalizing($variables, $event['variables']); + } + + private function assertNoDiagnostics(array $diagnostics): void + { + self::assertSame([], $diagnostics); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testInitialEventDoesNotIndexButSegmentOnlyPreviousStateDoes(): void + { + // Observe graph identity encoding only in this isolated test process. + require __DIR__.'/fixtures/initial-loading-index-probe.php'; + + foreach ([false, true] as $replace) { + $event = Events::getParamsForDatafileSetEvent($this->emptyDatafile(), $this->initial(), $replace); + $this->assertAffected($event, ['source', 'map', 'alias']); + self::assertSame('initial', $event['revision']); + self::assertSame('empty', $event['previousRevision']); + self::assertTrue($event['revisionChanged']); + self::assertSame($replace, $event['replaced']); + self::assertSame(0, self::$indexEncodings); + } + $previous = $this->emptyDatafile(); + $previous['segments'] = ['audience' => ['conditions' => '*']]; + Events::getParamsForDatafileSetEvent($previous, $this->initial()); + self::assertGreaterThan(0, self::$indexEncodings); + } + + public function testFirstPartialUpdateBuildsGraphsWithoutWarmingEntitiesOrDiagnostics(): void + { + foreach ([false, true] as $json) { + $diagnostics = []; + $initial = $this->initial(); + $sdk = Featurevisor::createFeaturevisor([ + 'datafile' => $json ? \json_encode($initial, JSON_THROW_ON_ERROR) : $initial, + 'logLevel' => 'error', + 'onDiagnostic' => static function ($item) use (&$diagnostics): void { $diagnostics[] = $item; }, + ]); + try { + $this->assertCold($sdk); + $this->assertNoDiagnostics($diagnostics); + $events = []; + $sdk->on('datafile_set', static function ($event) use (&$events): void { $events[] = $event; }); + $partial = $this->emptyDatafile('partial'); + $partial['variables']['source'] = ['hash' => 'two', 'type' => 'integer', 'defaultValue' => 2]; + self::assertTrue($sdk->setDatafile($json ? \json_encode($partial, JSON_THROW_ON_ERROR) : $partial)); + $this->assertAffected($events[0], ['source', 'map', 'alias']); + $this->assertCold($sdk); + $this->assertNoDiagnostics($diagnostics); + self::assertTrue($sdk->isEnabled('gate')); + self::assertSame(['enabled' => true], $sdk->getVariable('alias')); + $sdk->getSegment('broken'); $sdk->getSegment('broken'); + self::assertCount(1, $diagnostics); + self::assertSame('conditions_parse_error', $diagnostics[0]['code']); + self::assertSame('broken', $diagnostics[0]['details']['entityKey']); + } finally { $sdk->close(); } + } + } + + public function testFirstUpdateUsesNewEdgesAndReplacementUsesRemovedEdges(): void + { + foreach ([false, true] as $replace) { + $sdk = Featurevisor::createFeaturevisor(['datafile' => $this->initial(), 'logLevel' => 'fatal']); + try { + $events = []; + $sdk->on('datafile_set', static function ($event) use (&$events): void { $events[] = $event; }); + if ($replace) { + $update = $this->initial(); + unset($update['variables']['source']); + $update['features']['gate']['conditions'] = '*'; + } else { + $update = $this->emptyDatafile('partial'); + $update['features']['gate'] = ['hash' => 'stable', 'conditions' => ['variable' => 'added', 'operator' => 'notNull']]; + $update['variables']['added'] = ['hash' => 'new', 'type' => 'integer', 'defaultValue' => 2]; + } + self::assertTrue($sdk->setDatafile($update, $replace)); + $this->assertAffected($events[0], [$replace ? 'source' : 'added', 'map', 'alias']); + } finally { $sdk->close(); } + } + } +} diff --git a/tests/InstanceDatafileTest.php b/tests/InstanceDatafileTest.php index bea9351..d303fe8 100644 --- a/tests/InstanceDatafileTest.php +++ b/tests/InstanceDatafileTest.php @@ -30,7 +30,7 @@ private function datafile(array $segments = [], array $features = []): array public function testSharedV3ConformanceFixture(): void { $fixture = $this->fixture(); - self::assertSame(6, $fixture['version']); + self::assertSame(15, $fixture['version']); $bucketValue = 0; $featurevisor = Featurevisor::createFeaturevisor([ @@ -67,7 +67,9 @@ public function testSharedV3ConformanceFixture(): void 'value' => $fixture['regularExpressions']['pattern'], 'regexFlags' => $fixture['regularExpressions']['flags'], ]; - $regex = static fn(string $pattern, string $flags): string => '~'.$pattern.'~'.str_replace(['g', 'y'], '', $flags); + $getRegex = new \ReflectionMethod($featurevisor, 'getRegex'); + $getRegex->setAccessible(true); + $regex = static fn(string $pattern, string $flags): string => $getRegex->invoke($featurevisor, $pattern, $flags); foreach ($fixture['regularExpressions']['values'] as $index => $value) { self::assertSame( $fixture['regularExpressions']['matches'][$index], @@ -91,13 +93,17 @@ public function testSharedV3ConformanceFixture(): void sprintf('pattern %s, flags %s', $testCase['pattern'], $testCase['flags']) ); } + self::assertCount(189, $fixture['conditionCases']); + $executedAny = 0; foreach ($fixture['conditionCases'] as $testCase) { self::assertSame( $testCase['expected'], Conditions::allConditionsAreMatched($testCase['condition'], $testCase['context'], $regex), $testCase['name'] ); + if (preg_match('/includesAny|notIncludesAny/', json_encode($testCase['condition']))) $executedAny++; } + self::assertSame(73, $executedAny); $aggregateCase = $fixture['defaults']['aggregateCase']; $aggregateFeaturevisor = Featurevisor::createFeaturevisor([ @@ -113,7 +119,9 @@ public function testSharedV3ConformanceFixture(): void self::assertSame($aggregateCase['expected']['variation'], $evaluated['variation']); foreach ($fixture['typedVariables'] as $typedVariable) { - $actual = Helpers::getValueByType($typedVariable['value'], $typedVariable['type']); + // The fixture names the fractional number getter by its v3 name. + $type = $typedVariable['type'] === 'double' ? 'number' : $typedVariable['type']; + $actual = Helpers::getValueByType($typedVariable['value'], $type); self::assertSame($typedVariable['valid'], $actual !== null); } @@ -179,6 +187,41 @@ public function testGlobalVariablesAndRequiredFeaturesConformance(): void self::assertSame($case['expectedOverrideKey'], $evaluation['variableOverrideKey']); } + public function testRequiredFeatureEvaluationCases(): void + { + $contract = $this->fixture()['requiredFeatures']; + self::assertArrayHasKey('evaluationCases', $contract); + self::assertNotEmpty($contract['evaluationCases']); + $f = Featurevisor::createFeaturevisor(['datafile' => $contract['datafile'], 'logLevel' => 'fatal']); + try { + foreach ($contract['evaluationCases'] as $case) { + $context = $case['context'] ?? []; + switch ($case['type']) { + case 'flag': + $actual = $f->evaluateFlag($case['feature'], $context); + break; + case 'variation': + $actual = $f->evaluateVariation($case['feature'], $context); + break; + case 'variable': + $actual = array_key_exists('feature', $case) + ? $f->evaluateVariable($case['feature'], $case['variable'], $context) + : $f->evaluateVariable($case['variable'], $context); + break; + default: + self::fail('Unknown evaluation type: '.$case['type']); + } + self::assertNotEmpty($case['expected'], $case['name']); + foreach ($case['expected'] as $key => $expected) { + self::assertArrayHasKey($key, $actual, $case['name']); + self::assertSame($expected, $actual[$key], $case['name'].': '.$key); + } + } + } finally { + $f->close(); + } + } + public function testGlobalVariableChildrenModulesAndDependencyEvents(): void { $globals = $this->fixture()['globalVariables']; @@ -246,10 +289,6 @@ public function testModulePhasesFollowTheCanonicalOrder(): void foreach (['first', 'second'] as $name) { $modules[] = [ 'name' => $name, - 'before' => static function (array $options) use (&$featureEvents, $name): array { - $featureEvents[] = "before:{$name}"; - return $options; - }, 'beforeEvaluation' => static function (array $options) use (&$featureEvents, &$globalEvents, $name): array { $event = "beforeEvaluation:{$name}"; if (($options['featureKey'] ?? null) === null) { @@ -268,10 +307,6 @@ public function testModulePhasesFollowTheCanonicalOrder(): void } return $evaluation; }, - 'after' => static function (array $evaluation) use (&$featureEvents, $name): array { - $featureEvents[] = "after:{$name}"; - return $evaluation; - }, ]; } @@ -293,8 +328,18 @@ public function testModulePhasesFollowTheCanonicalOrder(): void self::assertTrue($f->isEnabled('test', ['userId' => 'user'])); self::assertSame('hello', $f->getVariable('message')); - self::assertSame($this->fixture()['modulePipeline']['featureOrder'], $featureEvents); - self::assertSame($this->fixture()['modulePipeline']['globalOrder'], $globalEvents); + self::assertSame([ + 'beforeEvaluation:first', + 'beforeEvaluation:second', + 'afterEvaluation:first', + 'afterEvaluation:second', + ], $featureEvents); + self::assertSame([ + 'beforeEvaluation:first', + 'beforeEvaluation:second', + 'afterEvaluation:first', + 'afterEvaluation:second', + ], $globalEvents); } public function testSegmentExpressionsMatchJavaScriptSemantics(): void diff --git a/tests/JavaScriptAlignmentTest.php b/tests/JavaScriptAlignmentTest.php index 1665a51..92e42e1 100644 --- a/tests/JavaScriptAlignmentTest.php +++ b/tests/JavaScriptAlignmentTest.php @@ -126,10 +126,10 @@ public function testMalformedExpressionsReportDiagnosticsAndFailSafely(): void ]); $evaluation = $featurevisor->evaluateFlag('test', ['userId' => '1']); - self::assertSame('error', $evaluation['reason']); - self::assertInstanceOf(\Throwable::class, $evaluation['error']); + self::assertSame('no_match', $evaluation['reason']); + self::assertArrayNotHasKey('error', $evaluation); self::assertContains('conditions_parse_error', array_column($diagnostics, 'code')); - self::assertContains('evaluation_error', array_column($diagnostics, 'code')); + self::assertNotContains('evaluation_error', array_column($diagnostics, 'code')); } public function testTypedArrayAndObjectGettersDoNotOverlap(): void @@ -159,7 +159,7 @@ public function testGetFeatureEvaluationsDoesNotCacheNestedFlagEvaluations(): vo }, 'modules' => [[ 'name' => 'counter', - 'before' => static function (array $options) use (&$beforeCalls): array { + 'beforeEvaluation' => static function (array $options) use (&$beforeCalls): array { $beforeCalls++; return $options; }, diff --git a/tests/JsonValuesTest.php b/tests/JsonValuesTest.php new file mode 100644 index 0000000..4a3754d --- /dev/null +++ b/tests/JsonValuesTest.php @@ -0,0 +1,93 @@ + [$name], ['value', 'defaultValue', 'disabledValue', 'variables']); + } + + /** @dataProvider dictionaryNames */ + public function testJsonConstructorDistinguishesDictionaryKeysFromPayloadFields(string $name): void + { + $payload = (object) ['value' => (object) [], 'defaultValue' => [], 'disabledValue' => null, + 'variables' => (object) ['0' => [], 'nested' => [(object) [], []]]]; + $property = ['type' => 'object', 'properties' => ['value' => ['type' => 'string'], + 'defaultValue' => ['type' => 'array'], 'disabledValue' => ['type' => 'null']]]; + $schema = ['type' => 'object', 'defaultValue' => $payload, 'properties' => [$name => $property]]; + $datafile = ['schemaVersion' => '2', 'revision' => 'dictionary-names', + 'segments' => [$name => ['conditions' => '*']], + 'features' => [$name => ['bucketBy' => 'userId', 'variablesSchema' => [$name => $schema], + 'traffic' => [['key' => 'all', 'segments' => [$name], 'percentage' => 100000]]]], + 'variables' => [$name => $schema]]; + $sdk = Featurevisor::createFeaturevisor(['datafile' => json_encode($datafile, JSON_THROW_ON_ERROR), 'logLevel' => 'fatal']); + try { + self::assertTrue($sdk->isEnabled($name, ['userId' => 'member'])); + self::assertSame([$name], $sdk->getFeatureKeys()); + self::assertSame([$name], $sdk->getVariableKeys()); + self::assertSame([$name], $sdk->getVariableKeys($name)); + self::assertSame(['conditions' => '*'], $sdk->getSegment($name)); + $feature = $sdk->getFeature($name); + self::assertIsArray($feature['variablesSchema'][$name]); + self::assertSame($property, $feature['variablesSchema'][$name]['properties'][$name]); + $global = $sdk->evaluateVariable($name); + $local = $sdk->evaluateVariable($name, $name, ['userId' => 'member']); + foreach ([$global['variableValue'], $local['variableValue']] as $value) { + self::assertInstanceOf(\stdClass::class, $value); + self::assertSame(json_encode($payload), json_encode($value)); + self::assertInstanceOf(\stdClass::class, $value->value); + self::assertSame([], $value->defaultValue); + self::assertNull($value->disabledValue); + } + self::assertSame($property, $global['variable']['properties'][$name]); + self::assertSame($property, $local['variableSchema']['properties'][$name]); + } finally { + $sdk->close(); + } + } + + public function testJsonRuntimePayloadsRemainDistinctInDefaultsOverridesAndMutations(): void + { + $payload = (object) ['value' => (object) [], 'defaultValue' => [], 'disabledValue' => null]; + $leaf = ['attribute' => 'permissions', 'operator' => 'notIncludesAny', 'value' => (object) []]; + $datafile = ['schemaVersion' => '2', 'revision' => 'payloads', 'segments' => ['invalid' => ['conditions' => ['and' => [$leaf]]]], + 'features' => [ + 'value' => ['bucketBy' => 'userId', 'traffic' => [['key' => 'all', 'segments' => '*', 'percentage' => 100000, + 'variableOverrides' => ['value' => [['segments' => '*', 'value' => $payload]]]]], + 'variablesSchema' => ['value' => ['type' => 'object', 'defaultValue' => (object) []]]], + 'defaultValue' => ['bucketBy' => 'userId', 'traffic' => [], 'variablesSchema' => ['value' => ['type' => 'object', 'defaultValue' => $payload]], + 'force' => [['conditions' => '*', 'enabled' => true, 'variables' => ['value' => $payload]]]], + 'invalid' => ['bucketBy' => 'userId', 'traffic' => [['key' => 'all', 'segments' => 'invalid', 'percentage' => 100000]]], + ], + 'variables' => [ + 'value' => ['type' => 'object', 'defaultValue' => (object) [], 'overrides' => [['segments' => '*', 'value' => $payload]]], + 'defaultValue' => ['type' => 'object', 'defaultValue' => $payload, 'overrideProgram' => [['key' => 'set', 'segments' => '*', + 'operations' => [['op' => 'set', 'path' => ['value'], 'value' => (object) []], + ['op' => 'set', 'path' => ['added'], 'value' => [(object) [], []]]], 'onMatch' => 1, 'onMiss' => 1]]], + 'disabledValue' => ['type' => 'object', 'defaultValue' => (object) [], 'disabledValue' => $payload, + 'requiredFeatures' => [['feature' => 'invalid']]], + ]]; + $sdk = Featurevisor::createFeaturevisor(['datafile' => json_encode($datafile, JSON_THROW_ON_ERROR), 'logLevel' => 'fatal']); + try { + self::assertFalse($sdk->isEnabled('invalid', ['permissions' => []])); + foreach ([$sdk->evaluateVariable('value'), $sdk->evaluateVariable('disabledValue'), + $sdk->evaluateVariable('value', 'value', ['userId' => 'member']), + $sdk->evaluateVariable('defaultValue', 'value')] as $evaluation) { + self::assertInstanceOf(\stdClass::class, $evaluation['variableValue']); + self::assertSame(json_encode($payload), json_encode($evaluation['variableValue'])); + } + $mutated = $sdk->evaluateVariable('defaultValue'); + self::assertSame('variable_override_rule', $mutated['reason']); + self::assertInstanceOf(\stdClass::class, $mutated['variableValue']->value); + self::assertInstanceOf(\stdClass::class, $mutated['variableValue']->added[0]); + self::assertSame([], $mutated['variableValue']->added[1]); + } finally { + $sdk->close(); + } + } +} diff --git a/tests/LiveModulesTest.php b/tests/LiveModulesTest.php new file mode 100644 index 0000000..d65e2f6 --- /dev/null +++ b/tests/LiveModulesTest.php @@ -0,0 +1,73 @@ +module($trace, $disableGate); + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => 'error', 'datafile' => $this->datafile(), + 'onDiagnostic' => static function ($diagnostic) use (&$sdk, $module): void { + if ($diagnostic['code'] === 'conditions_parse_error') $sdk->addModule($module); + }, + ]); + $evaluation = $global ? $sdk->evaluateVariable('subject') : $sdk->evaluateFlag('subject'); + self::assertSame($disableGate ? ($global ? null : false) : ($global ? 'matched' : true), $evaluation[$global ? 'variableValue' : 'enabled'] ?? null); + if ($disableGate) self::assertSame('conditions_unmet', $evaluation['reason']); + self::assertSame(['after:broken', 'before:gate', 'after:gate', 'after:subject'], $trace); + $sdk->close(); + } + } + + public function testDiagnosticRemovesHooksBeforeTheAfterPhase(): void + { + foreach ([false, true] as $global) { + $trace = []; + $remove = static function (): void { throw new \LogicException('Module not installed'); }; + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => 'error', 'datafile' => $this->datafile(), + 'onDiagnostic' => static function ($diagnostic) use (&$remove): void { + if ($diagnostic['code'] === 'conditions_parse_error') $remove(); + }, + ]); + $remove = $sdk->addModule($this->module($trace)); + self::assertSame($global ? 'matched' : true, $global ? $sdk->getVariable('subject') : $sdk->isEnabled('subject')); + self::assertSame(['before:subject', 'before:broken'], $trace); + $sdk->close(); + } + } + + private function module(array &$trace, bool $disableGate = false): array + { + return [ + 'name' => 'live', + 'beforeEvaluation' => static function ($options) use (&$trace): array { + $trace[] = 'before:' . ($options['featureKey'] ?? $options['variableKey']); + return $options; + }, + 'afterEvaluation' => static function ($evaluation, $options) use (&$trace, $disableGate): array { + $trace[] = 'after:' . ($options['featureKey'] ?? $options['variableKey']); + if ($disableGate && ($options['featureKey'] ?? null) === 'gate') $evaluation['enabled'] = false; + return $evaluation; + }, + ]; + } + + private function datafile(): array + { + $feature = ['bucketBy' => 'id', 'traffic' => [['key' => 'all', 'conditions' => '*', 'percentage' => 100000]]]; + $conditions = ['or' => [['feature' => 'broken'], ['feature' => 'gate']]]; + return ['schemaVersion' => '4', 'revision' => 'live', 'segments' => [], + 'features' => ['broken' => array_merge($feature, ['conditions' => '[broken']), + 'gate' => $feature, 'subject' => array_merge($feature, ['conditions' => $conditions])], + 'variables' => ['subject' => ['type' => 'string', 'defaultValue' => 'matched', 'conditions' => $conditions]]]; + } +} diff --git a/tests/ModulePhaseSnapshotTest.php b/tests/ModulePhaseSnapshotTest.php new file mode 100644 index 0000000..8708de9 --- /dev/null +++ b/tests/ModulePhaseSnapshotTest.php @@ -0,0 +1,93 @@ + $name, + 'beforeEvaluation' => static function (array $options) use ($name, &$trace, $action): array { + $trace[] = "$name:before"; + $action('before'); + return $options; + }, + 'bucketKey' => static function (array $options) use ($name, &$trace, $action): string { + $trace[] = "$name:key"; + $action('key'); + return $options['bucketKey']; + }, + 'bucketValue' => static function (array $options) use ($name, &$trace, $action): int { + $trace[] = "$name:value"; + $action('value'); + return $options['bucketValue']; + }, + 'afterEvaluation' => static function (array $evaluation, array $options) use ($name, &$trace, $action): array { + $trace[] = "$name:after"; + $action('after'); + return $evaluation; + }, + ]; + } + + public function testEveryPhaseUsesStartingMembershipForFeatureAndGlobalEvaluations(): void + { + foreach ([false, true] as $global) { + $phases = $global ? ['before', 'after'] : ['before', 'key', 'value', 'after']; + foreach ($phases as $targetIndex => $target) { + foreach (['add', 'removeAdd', 'remove'] as $mode) { + $trace = []; + $changed = false; + $closed = 0; + $sdk = Featurevisor::createFeaturevisor([ + 'datafile' => [ + 'schemaVersion' => '4', 'revision' => 'phase', 'segments' => [], + 'features' => ['subject' => [ + 'bucketBy' => 'userId', + 'traffic' => [['key' => 'all', 'conditions' => '*', 'percentage' => 100000]], + ]], + 'variables' => ['subject' => ['type' => 'string', 'defaultValue' => 'ok']], + ], + 'logLevel' => 'fatal', + ]); + $late = $this->module('late', $trace, static function (): void {}); + $actor = $this->module('actor', $trace, static function (string $phase) use ($target, $mode, &$changed, $sdk, $late): void { + if ($phase !== $target || $changed) return; + $changed = true; + if ($mode !== 'add') $sdk->removeModule('other'); + if ($mode !== 'remove') $sdk->addModule($late); + }); + $other = $this->module('other', $trace, static function (): void {}); + $other['close'] = static function () use (&$closed): void { $closed++; }; + $sdk->addModule($actor); + $sdk->addModule($other); + $expected = []; + foreach ($phases as $index => $phase) { + $expected[] = "actor:$phase"; + if ($index <= $targetIndex || $mode === 'add') $expected[] = "other:$phase"; + if ($index > $targetIndex && $mode !== 'remove') $expected[] = "late:$phase"; + } + for ($pass = 0; $pass < 2; $pass++) { + $result = $global ? $sdk->evaluateVariable('subject') : $sdk->evaluateFlag('subject', ['userId' => 'u']); + self::assertSame($global ? 'ok' : true, $result[$global ? 'variableValue' : 'enabled']); + self::assertSame($expected, $trace, "$global/$target/$mode/$pass"); + self::assertSame($mode === 'add' ? 0 : 1, $closed); + $trace = []; + $expected = []; + foreach ($phases as $phase) { + $expected[] = "actor:$phase"; + if ($mode === 'add') $expected[] = "other:$phase"; + if ($mode !== 'remove') $expected[] = "late:$phase"; + } + } + $sdk->close(); + self::assertSame(1, $closed); + } + } + } + } +} diff --git a/tests/NativeTimestampTest.php b/tests/NativeTimestampTest.php new file mode 100644 index 0000000..84e51fa --- /dev/null +++ b/tests/NativeTimestampTest.php @@ -0,0 +1,60 @@ + 'date', 'operator' => $operator, 'value' => $expected]], + ['date' => $actual], + static function (): string { throw new \LogicException('No regex expected'); }, + static function (): void { throw new \LogicException('No diagnostic expected'); } + ); + } + + public function testNativePrecisionAndOffsets(): void + { + foreach (['2026-01-01T00:00:00.000Z', '1969-12-31T23:59:59.999Z'] as $stamp) { + $boundary = new \DateTimeImmutable($stamp); + foreach (['DateTime', 'DateTimeImmutable'] as $type) { + foreach (['-14:00', '+00:00', '+14:00'] as $offset) { + $host = new $type(str_replace('Z', '999Z', $stamp)); + $host = $host->setTimezone(new \DateTimeZone($offset)); + $before = $host->format('Y-m-d\TH:i:s.uP'); + foreach (['before', 'after'] as $operator) { + self::assertFalse($this->dateMatches($host, $operator, $stamp)); + self::assertSame($operator === 'before', $this->dateMatches($host, $operator, $boundary->modify('+1 millisecond')->format('Y-m-d\TH:i:s.uP'))); + self::assertSame($operator === 'after', $this->dateMatches($host, $operator, $boundary->modify('-1 millisecond')->format('Y-m-d\TH:i:s.uP'))); + } + self::assertSame($before, $host->format('Y-m-d\TH:i:s.uP')); + } + } + } + } + + public function testNativeBoundsAndInvalidOperands(): void + { + self::assertTrue($this->dateMatches(new \DateTimeImmutable('0001-01-01T00:00:00Z'), 'before', '2026-01-01T00:00:00Z')); + self::assertTrue($this->dateMatches(new \DateTimeImmutable('9999-12-31T23:59:59.999999Z'), 'after', '2026-01-01T00:00:00Z')); + $invalid = ['2026-01-01', '2026-01-01T00:00:00', 1767225600000, null, new \stdClass()]; + foreach ([ + '0000-12-31T23:00:00-01:00', '+10000-01-01T00:00:00+01:00', + '0001-01-01T00:00:00+01:00', '9999-12-31T23:59:59-01:00', + '2026-01-01T00:00:00+14:01', '2026-01-01T00:00:00-15:00' + ] as $stamp) { + $invalid[] = new \DateTimeImmutable($stamp); + $invalid[] = new \DateTime($stamp); + } + foreach ($invalid as $actual) { + foreach (['before', 'after'] as $operator) { + self::assertFalse($this->dateMatches($actual, $operator, '2026-01-01T00:00:00Z')); + self::assertFalse($this->dateMatches(new \DateTimeImmutable('2026-01-01T00:00:00Z'), $operator, $actual)); + } + } + } +} diff --git a/tests/NumberGetterTest.php b/tests/NumberGetterTest.php new file mode 100644 index 0000000..6651713 --- /dev/null +++ b/tests/NumberGetterTest.php @@ -0,0 +1,46 @@ + [ + 'schemaVersion' => '4', + 'revision' => '1', + 'segments' => [], + 'features' => [], + 'variables' => [ + 'rate' => ['type' => 'number', 'defaultValue' => 2.5], + // Earlier datafiles say double. Both spellings read the same. + 'ratio' => ['type' => 'double', 'defaultValue' => 1.5], + 'count' => ['type' => 'number', 'defaultValue' => 3], + ], + ], + 'logLevel' => 'fatal', + ]); + } + + public function testReadsNumberAndEarlierDoubleVariablesTheSameWay(): void + { + $sdk = $this->createInstance(); + self::assertSame(2.5, $sdk->getVariableNumber('rate')); + self::assertSame(1.5, $sdk->getVariableNumber('ratio')); + self::assertSame(3.0, $sdk->getVariableNumber('count')); + self::assertSame(2.5, $sdk->spawn([])->getVariableNumber('rate')); + $sdk->close(); + } + + public function testNoLongerHasTheDoubleGettersRemovedInV4(): void + { + $sdk = $this->createInstance(); + self::assertFalse(method_exists($sdk, 'getVariableDouble')); + self::assertFalse(method_exists($sdk->spawn([]), 'getVariableDouble')); + $sdk->close(); + } +} diff --git a/tests/OpenFeatureProviderTest.php b/tests/OpenFeatureProviderTest.php index f93c387..1bf6b31 100644 --- a/tests/OpenFeatureProviderTest.php +++ b/tests/OpenFeatureProviderTest.php @@ -7,6 +7,8 @@ use Featurevisor\Featurevisor; use Featurevisor\OpenFeatureProvider; use OpenFeature\OpenFeatureAPI; +use OpenFeature\implementation\common\Metadata; +use OpenFeature\isolated\OpenFeatureAPIFactory; use OpenFeature\implementation\flags\Attributes; use OpenFeature\implementation\flags\EvaluationContext; use OpenFeature\interfaces\provider\ErrorCode; @@ -17,6 +19,172 @@ /** @group openfeature */ final class OpenFeatureProviderTest extends TestCase { + public function testProviderOwnsFreshFeaturevisorMetadata(): void + { + $provider = new OpenFeatureProvider(['datafile' => $this->datafile()]); + try { + $metadata = $provider->getMetadata(); + self::assertInstanceOf(Metadata::class, $metadata); + self::assertSame('Featurevisor', $metadata->getName()); + $metadata->setName('changed by caller'); + self::assertSame('Featurevisor', $provider->getMetadata()->getName()); + self::assertNotSame($metadata, $provider->getMetadata()); + } finally { + $provider->shutdown(); + } + } + + public function testIsolatedClientsResolveUpdatesErrorsAndPreserveOwnership(): void + { + foreach ([false, true] as $borrowed) { + $closed = 0; + $options = ['datafile' => $this->datafile(), 'logLevel' => 'fatal', + 'modules' => [['close' => function () use (&$closed) { $closed++; }]]]; + $f = $borrowed ? Featurevisor::createFeaturevisor($options) : null; + $provider = new OpenFeatureProvider($options, $f); + $api = OpenFeatureAPIFactory::createAPI(); + $other = OpenFeatureAPIFactory::createAPI(); + try { + $api->setProvider($provider); + $client = $api->getClient(null, null); + self::assertTrue($client->getBooleanValue('checkout', false)); + self::assertFalse($other->getClient(null, null)->getBooleanValue('checkout', false)); + self::assertSame('Hello', $client->getStringValue('checkout:title', '')); + self::assertSame(['nested' => true], $client->getObjectValue('checkout:json', [])); + $missing = $client->getBooleanDetails('missing', true); + self::assertTrue($missing->getValue()); + self::assertEquals(ErrorCode::FLAG_NOT_FOUND(), $missing->getError()->getResolutionErrorCode()); + $mismatch = $client->getIntegerDetails('checkout:ratio', 9); + self::assertSame(9, $mismatch->getValue()); + self::assertEquals(ErrorCode::TYPE_MISMATCH(), $mismatch->getError()->getResolutionErrorCode()); + $updated = $this->datafile(); + $updated['features']['checkout']['variations'][0]['variables']['title'] = 'Updated'; + self::assertTrue($provider->getFeaturevisor()->setDatafile($updated, true)); + self::assertSame('Updated', $client->getStringValue('checkout:title', '')); + self::assertFalse($provider->getFeaturevisor()->setDatafile('{')); + self::assertEquals(ErrorCode::PARSE_ERROR(), $client->getBooleanDetails('checkout', false)->getError()->getResolutionErrorCode()); + self::assertTrue($provider->getFeaturevisor()->setDatafile($updated, true)); + self::assertSame('Updated', $client->getStringValue('checkout:title', '')); + $provider->shutdown(); + $provider->shutdown(); + self::assertSame($borrowed ? 0 : 1, $closed); + if ($f !== null) { + self::assertFalse($f->setDatafile('{')); + self::assertNull($provider->resolveBooleanValue('checkout', false)->getError()); + } + } finally { + $provider->shutdown(); + if ($f !== null) $f->close(); + } + self::assertSame(1, $closed); + } + } + + public function testInitialHealthDoesNotDependOnDiagnosticFiltering(): void + { + $invalid = ['schemaVersion' => '2', 'revision' => 'x', 'features' => false, 'segments' => []]; + foreach (['info', 'error', 'fatal'] as $level) { + foreach ([$invalid, json_encode($invalid), '{', 'null', [], false, array_merge($this->datafile(), ['variables' => false])] as $input) { + $diagnostics = []; + $revisions = []; + $closed = 0; + $callback = function ($d) use (&$diagnostics) { $diagnostics[] = $d['code']; }; + $options = ['datafile' => $input, 'logLevel' => $level, 'onDiagnostic' => $callback, + 'modules' => [['setup' => function ($api) use (&$revisions) { $revisions[] = $api['getRevision'](); }, + 'close' => function () use (&$closed) { $closed++; }]]]; + $provider = new OpenFeatureProvider($options); + self::assertSame(['unknown'], $revisions); + self::assertSame($callback, $options['onDiagnostic']); + self::assertEquals(ErrorCode::PARSE_ERROR(), $provider->resolveBooleanValue('checkout', false)->getError()->getResolutionErrorCode()); + self::assertSame($level === 'fatal' ? 0 : 1, count(array_filter($diagnostics, fn ($code) => $code === 'invalid_datafile'))); + $provider->getFeaturevisor()->setDatafile($this->datafile(), true); + self::assertNull($provider->resolveBooleanValue('checkout', false)->getError()); + $provider->shutdown(); + $provider->shutdown(); + self::assertSame(1, $closed); + } + } + } + + public function testClientPreservesExplicitEmptyTargetingKey(): void + { + $fixture = json_decode((string) file_get_contents(__DIR__ . '/../conformance/openfeature-provider.json'), true, 512, JSON_THROW_ON_ERROR); + $api = OpenFeatureAPI::getInstance(); + $provider = new OpenFeatureProvider(['datafile' => $fixture['datafile'], 'logLevel' => 'fatal']); + $api->setProvider($provider); + $client = $api->getClient(null, null); + $source = ['userId' => 'forced']; + self::assertTrue($client->getBooleanValue('targetOnly', false, new EvaluationContext(null, new Attributes($source)))); + self::assertTrue($client->getBooleanValue('targetOnly', false, new EvaluationContext('', new Attributes($source)))); + self::assertFalse($client->getBooleanValue('targetOnly', false, new EvaluationContext(null, new Attributes(['userId' => ''])))); + self::assertFalse($client->getBooleanValue('targetOnly', false, new EvaluationContext('other', new Attributes($source)))); + self::assertSame(['userId' => 'forced'], $source); + $provider->shutdown(); + $custom = new OpenFeatureProvider(['datafile' => $fixture['datafile'], 'logLevel' => 'fatal'], null, 'accountId'); + $api->setProvider($custom); + $account = ['accountId' => 'old']; + self::assertFalse($api->getClient(null, null)->getBooleanValue('accountTargeted', false, new EvaluationContext('', new Attributes($account)))); + self::assertTrue($api->getClient(null, null)->getBooleanValue('accountTargeted', false, new EvaluationContext(null, new Attributes(['accountId' => ''])))); + self::assertSame(['accountId' => 'old'], $account); + $custom->shutdown(); + } + + public function testSharedProviderConformance(): void + { + $fixture = json_decode((string) file_get_contents(__DIR__ . '/../conformance/openfeature-provider.json'), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('featurevisor.openfeature-provider', $fixture['kind']); + self::assertSame(1, $fixture['schemaVersion']); + self::assertCount(16, $fixture['cases']); + $provider = new OpenFeatureProvider(['datafile' => $fixture['datafile'], 'logLevel' => 'fatal']); + $resolvers = ['boolean' => 'resolveBooleanValue', 'string' => 'resolveStringValue', 'object' => 'resolveObjectValue']; + foreach ($fixture['cases'] as $case) { + $source = $case['context']; + $attributes = $source; + unset($attributes['targetingKey']); + $context = new EvaluationContext($source['targetingKey'] ?? null, new Attributes($attributes)); + $result = $provider->{$resolvers[$case['type']]}($case['flagKey'], $case['default'], $context); + self::assertSame($case['expected']['value'], $result->getValue(), $case['name']); + if (isset($case['expected']['reason'])) self::assertSame($case['expected']['reason'], $result->getReason(), $case['name']); + self::assertSame($case['expected']['variant'] ?? null, $result->getVariant(), $case['name']); + $code = $result->getError()?->getResolutionErrorCode(); + $method = $case['expected']['errorCode'] ?? null; + $expectedCode = $method !== null ? ErrorCode::$method() : null; + self::assertEquals($expectedCode, $code, $case['name']); + self::assertSame($source, $case['context'], $case['name']); + } + foreach ($fixture['targetingCases'] as $case) { + $source = $case['context']; + $attributes = $source; + unset($attributes['targetingKey']); + $context = new EvaluationContext($source['targetingKey'] ?? null, new Attributes($attributes)); + self::assertSame($case['expectedValue'], $provider->resolveBooleanValue('targetOnly', false, $context)->getValue(), $case['name']); + self::assertSame($source, $case['context'], $case['name']); + } + $provider->shutdown(); + $case = $fixture['customTargetingCase']; + $custom = new OpenFeatureProvider(['datafile' => $fixture['datafile'], 'logLevel' => 'fatal'], null, 'accountId'); + $attributes = $case['context']; + unset($attributes['targetingKey']); + $context = new EvaluationContext($case['context']['targetingKey'], new Attributes($attributes)); + self::assertSame($case['expectedValue'], $custom->resolveBooleanValue($case['flagKey'], false, $context)->getValue()); + $custom->shutdown(); + } + + public function testBorrowedInstanceParseErrorRecoveryAndCleanup(): void + { + $f = Featurevisor::createFeaturevisor(['datafile' => $this->datafile(), 'logLevel' => 'fatal']); + $provider = new OpenFeatureProvider([], $f); + self::assertFalse($f->setDatafile('{')); + self::assertEquals(ErrorCode::PARSE_ERROR(), $provider->resolveBooleanValue('checkout', false)->getError()->getResolutionErrorCode()); + self::assertTrue($f->setDatafile($this->datafile(), true)); + self::assertNull($provider->resolveBooleanValue('checkout', false)->getError()); + $provider->shutdown(); + self::assertFalse($f->setDatafile('{')); + self::assertNull($provider->resolveBooleanValue('checkout', false)->getError()); + $f->close(); + self::assertFalse($f->setDatafile($this->datafile())); + } + /** @return array */ private function datafile(): array { @@ -70,6 +238,7 @@ private function datafile(): array ], 'emptyVariation' => [ 'bucketBy' => 'userId', + 'traffic' => [['key' => 'all', 'conditions' => '*', 'percentage' => 100000]], 'variations' => [], ], ], @@ -108,7 +277,7 @@ private function providerReturning(array $evaluation): array 'logLevel' => 'fatal', 'modules' => [[ 'name' => 'evaluation-result', - 'after' => static function () use ($evaluation): array { + 'afterEvaluation' => static function () use ($evaluation): array { return $evaluation; }, ]], @@ -154,7 +323,7 @@ public function testMapsTargetingKeyDatesArraysAndNestedContextWithoutMutation() $provider = new OpenFeatureProvider( ['datafile' => $this->datafile(), 'logLevel' => 'fatal', 'modules' => [[ 'name' => 'capture-context', - 'before' => static function (array $options) use (&$contexts): array { + 'beforeEvaluation' => static function (array $options) use (&$contexts): array { $contexts[] = $options['context']; return $options; }, @@ -213,6 +382,26 @@ public function testResolvesGlobalVariablesAndCustomPrefix(): void new OpenFeatureProvider(globalVariablePrefix: 'global:variable'); } + public function testRawJsonObjectsAreAdaptedForOpenFeatureObjectValues(): void + { + $datafile = $this->datafile(); + $datafile['variables']['limits']['defaultValue'] = (object) [ + '0' => 'zero', + 'nested' => (object) [], + ]; + $provider = new OpenFeatureProvider([ + 'datafile' => json_encode($datafile, JSON_THROW_ON_ERROR), + 'logLevel' => 'fatal', + ]); + + self::assertSame( + ['0' => 'zero', 'nested' => []], + $provider->resolveObjectValue('variable:limits', [])->getValue() + ); + + $provider->shutdown(); + } + public function testReturnsDefaultsAndStandardErrorsForMissingEntitiesAndMalformedDatafiles(): void { $provider = $this->provider(); @@ -338,12 +527,28 @@ public function testMapsEveryFeaturevisorReason(string $featurevisorReason, stri $featurevisor->close(); } + public function testRealUnmetFeatureRequirementMapsToDisabled(): void + { + $datafile = $this->datafile(); + $datafile['features']['blockedByRequirement'] = [ + 'bucketBy' => 'userId', 'requiredFeatures' => ['missing'], + 'traffic' => [['key' => 'all', 'segments' => '*', 'percentage' => 100000]], + ]; + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile]); + $provider = new OpenFeatureProvider([], $f); + $result = $provider->resolveBooleanValue('blockedByRequirement', true); + self::assertFalse($result->getValue()); + self::assertSame(Reason::DISABLED, $result->getReason()); + self::assertSame('conditions_unmet', $f->evaluateFlag('blockedByRequirement')['reason']); + $provider->shutdown(); + $f->close(); + } + /** @return array */ public function reasonMappings(): array { return [ - 'required' => ['required', Reason::TARGETING_MATCH], - 'required features unmet' => ['required_features_unmet', Reason::DISABLED], + 'required features unmet' => ['conditions_unmet', Reason::DISABLED], 'forced' => ['forced', Reason::TARGETING_MATCH], 'sticky' => ['sticky', Reason::TARGETING_MATCH], 'rule' => ['rule', Reason::TARGETING_MATCH], @@ -353,7 +558,6 @@ public function reasonMappings(): array 'disabled' => ['disabled', Reason::DISABLED], 'variation disabled' => ['variation_disabled', Reason::DISABLED], 'variable disabled' => ['variable_disabled', Reason::DISABLED], - 'out of range' => ['out_of_range', Reason::DEFAULT], 'no match' => ['no_match', Reason::DEFAULT], 'variable default' => ['variable_default', Reason::DEFAULT], ]; diff --git a/tests/ReadmeInitializationTest.php b/tests/ReadmeInitializationTest.php new file mode 100644 index 0000000..559c30d --- /dev/null +++ b/tests/ReadmeInitializationTest.php @@ -0,0 +1,93 @@ + ['pipe', 'r'], 1 => ['file', $directory . '/server.log', 'a'], + 2 => ['file', $directory . '/server.log', 'a'], + ], $pipes, $directory); + self::assertIsResource($server); + fclose($pipes[0]); + $ready = false; + for ($attempt = 0; $attempt < 100; $attempt++) { + $connection = @stream_socket_client('tcp://' . $address, $errno, $message, 0.05); + if ($connection !== false) { + fclose($connection); + $ready = true; + break; + } + usleep(20000); + } + self::assertTrue($ready, 'Local HTTP fixture did not start'); + $source = str_replace('https://cdn.yoursite.com/featurevisor-all.json', + 'http://' . $address . '/datafile.json', $match[1]); + $script = $directory . '/main.php'; + file_put_contents($script, $source); + $fixture = '{"schemaVersion":"2","revision":"readme","segments":{},"variables":{},' + . '"features":{"checkout":{"hash":"checkout","bucketBy":"userId",' + . '"traffic":[{"key":"all","segments":"*","percentage":100000}]}}}'; + foreach ([[200, $fixture, false, "true\n"], + [404, $fixture, true, 'Could not load datafile:'], + [503, $fixture, true, 'Could not load datafile:'], + [200, '{', true, 'JsonException']] as [$status, $body, $failure, $expected]) { + file_put_contents($directory . '/status', (string) $status); + file_put_contents($directory . '/body', $body); + $this->execute($script, $root, $directory, $failure, $expected); + } + proc_terminate($server); + proc_close($server); + $server = null; + $this->execute($script, $root, $directory, true, 'Could not load datafile:'); + } finally { + if (is_resource($server)) { + proc_terminate($server); + proc_close($server); + } + foreach (glob($directory . '/*') as $file) { + unlink($file); + } + rmdir($directory); + } + } + + private function execute(string $script, string $root, string $directory, bool $failure, string $expected): void + { + $outputFile = $directory . '/output'; + $errorFile = $directory . '/errors'; + $process = proc_open([PHP_BINARY, '-d', 'allow_url_fopen=1', '-d', 'default_socket_timeout=2', $script], [ + 0 => ['pipe', 'r'], 1 => ['file', $outputFile, 'w'], 2 => ['file', $errorFile, 'w'], + ], $pipes, $root); + self::assertIsResource($process); + fclose($pipes[0]); + $exit = proc_close($process); + $output = file_get_contents($outputFile) . file_get_contents($errorFile); + self::assertSame($failure, $exit !== 0, $output); + self::assertStringContainsString($expected, $output); + } +} diff --git a/tests/RequiredFeaturesReasonTest.php b/tests/RequiredFeaturesReasonTest.php new file mode 100644 index 0000000..935fac3 --- /dev/null +++ b/tests/RequiredFeaturesReasonTest.php @@ -0,0 +1,40 @@ + [ + 'schemaVersion' => '2', 'revision' => 'reason', 'segments' => [], + 'features' => ['subject' => [ + 'bucketBy' => 'userId', 'requiredFeatures' => ['missing'], 'traffic' => [], + ]], + ], + 'logLevel' => 'fatal', + 'modules' => [['name' => 'observe', 'afterEvaluation' => static function (array $evaluation, array $options) use (&$observed): array { + if (($evaluation['featureKey'] ?? null) === 'subject') $observed[] = $evaluation['reason']; + return $evaluation; + }]], + ]); + try { + $evaluation = $f->evaluateFlag('subject'); + self::assertSame(Evaluation::CONDITIONS_UNMET, $evaluation['reason']); + self::assertFalse($evaluation['enabled']); + self::assertSame(['conditions_unmet'], $observed); + $wire = json_decode(json_encode($evaluation, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('conditions_unmet', $wire['reason']); + self::assertSame([['feature' => 'missing']], $wire['conditions']); + self::assertFalse(defined(Evaluation::class . '::REQUIRED')); + } finally { + $f->close(); + } + } +} diff --git a/tests/ReviewGapsTest.php b/tests/ReviewGapsTest.php new file mode 100644 index 0000000..8064751 --- /dev/null +++ b/tests/ReviewGapsTest.php @@ -0,0 +1,326 @@ + 'mutator', 'operator' => 'notNull'], 'audience']; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $this->datafile([ + 'segments' => ['audience' => ['conditions' => ['or' => []]]], + 'features' => ['root' => $this->feature(['conditions' => $gate]), 'probe' => $this->feature(['conditions' => 'audience'])], + 'variables' => ['root' => ['type' => 'string', 'conditions' => $gate, 'defaultValue' => 'yes'], 'mutator' => ['type' => 'integer', 'defaultValue' => 1]], + ])]); + $f->addModule(['afterEvaluation' => function ($evaluation, $options) use ($f): array { + if (($options['variableKey'] ?? null) === 'mutator') $f->setDatafile($this->datafile(['revision' => 'updated', 'segments' => ['audience' => ['conditions' => '*']]])); + return $evaluation; + }]); + try { + self::assertFalse($f->isEnabled('probe')); + self::assertSame($globalRoot ? 'yes' : true, $globalRoot ? $f->getVariable('root') : $f->isEnabled('root')); + self::assertTrue($f->isEnabled('probe')); + } finally { $f->close(); } + } + } + + public function testTerminalSegmentFastPredicatesPreserveTheActiveDepthBoundary(): void + { + $country = ['attribute' => 'country', 'operator' => 'equals', 'value' => 'nl']; + foreach (['*', $country, ['and' => [$country, ['not' => [array_merge($country, ['value' => 'de'])]]]]] as $terminal) foreach ([256, 257, 258] as $count) { + $segments = []; + for ($i = 0; $i < $count; $i++) $segments[(string) $i] = ['conditions' => $i + 1 < $count ? (string) ($i + 1) : $terminal]; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $this->datafile([ + 'features' => ['root' => $this->feature(['conditions' => '0']), 'independent' => $this->feature()], 'segments' => $segments, + ])]); + try { + $allowed = $count <= ($terminal === '*' ? 256 : 257); + for ($run = 0; $run < 2; $run++) { + self::assertSame($allowed ? 'rule' : 'error', $f->evaluateFlag('root', ['country' => 'nl'])['reason']); + self::assertSame($allowed, $f->isEnabled('root', ['country' => 'nl'])); + if ($allowed && $terminal !== '*') self::assertSame('conditions_unmet', $f->evaluateFlag('root', ['country' => 'de'])['reason']); + self::assertTrue($f->isEnabled('independent')); + } + } finally { $f->close(); } + } + } + + public function testMixedGetterAndSegmentDepthPreservesTerminalFastPredicates(): void + { + $country = ['attribute' => 'country', 'operator' => 'equals', 'value' => 'nl']; + foreach ([1, 8] as $getters) foreach (['*', $country] as $terminal) foreach ([256, 257] as $activeDepth) { + $count = $activeDepth - $getters + ($terminal === '*' ? 0 : 1); + $segments = []; + for ($i = 0; $i < $count; $i++) $segments[(string) $i] = ['conditions' => $i + 1 < $count ? (string) ($i + 1) : $terminal]; + $variables = []; + for ($i = 0; $i < $getters; $i++) $variables['v'.$i] = ['type' => 'integer', 'compose' => 'v'.($i + 1)]; + $variables['v'.$getters] = ['type' => 'integer', 'conditions' => '0', 'defaultValue' => 7]; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $this->datafile(['segments' => $segments, 'variables' => $variables])]); + try { + for ($run = 0; $run < 2; $run++) { + self::assertSame($activeDepth === 256 ? 'variable_composed' : 'error', $f->evaluateVariable('v0', ['country' => 'nl'])['reason']); + self::assertSame($activeDepth === 256 ? 7 : null, $f->getVariable('v0', ['country' => 'nl'])); + self::assertSame(7, $f->getVariable('v'.$getters, ['country' => 'nl'])); + } + } finally { $f->close(); } + } + } + + public function testNotOperandsAndExplicitNullGatesAtEachRootAndSegment(): void + { + $expressions = [[null, false], [['not' => null], false], [['not' => '*'], false], + [['not' => (object) []], false], [['not' => (object) ['0' => null]], false], + [['not' => ['attribute' => 'missing', 'operator' => 'exists']], false], + [['not' => 1], false], [['not' => false], false], [['not' => []], false], [['not' => [null]], true]]; + foreach ($expressions as [$expression, $expected]) foreach (['native', 'selector', 'datafile'] as $encoding) { + $selector = $encoding === 'selector' && $expression !== null ? json_encode($expression, JSON_THROW_ON_ERROR) : $expression; + $d = $this->datafile(['segments' => ['selected' => ['conditions' => $selector]], + 'features' => ['direct' => $this->feature(['conditions' => $selector]), 'segment' => $this->feature(['conditions' => 'selected']), 'absent' => $this->feature()], + 'variables' => ['direct' => ['type' => 'string', 'conditions' => $selector, 'defaultValue' => 'yes', 'disabledValue' => 'no']]]); + $diagnostics = []; + $f = Featurevisor::createFeaturevisor(['datafile' => $encoding === 'datafile' ? json_encode($d, JSON_THROW_ON_ERROR) : $d, + 'logLevel' => 'error', 'onDiagnostic' => static function ($item) use (&$diagnostics): void { $diagnostics[] = $item; }]); + try { + for ($run = 0; $run < 2; $run++) { + self::assertSame($expected, $f->isEnabled('direct')); + self::assertSame($expected, $f->isEnabled('segment')); + if ($expression === null || Normalize::isList($expression['not'] ?? null)) { + self::assertSame($expected ? 'yes' : 'no', $f->getVariable('direct')); + } else self::assertNotSame('yes', $f->getVariable('direct')); + self::assertTrue($f->isEnabled('absent')); + } + self::assertSame([], array_values(array_filter($diagnostics, static fn($item) => $item['code'] === 'conditions_parse_error'))); + if ($expression === null) { + $evaluation = $f->evaluateFlag('direct'); + self::assertSame('conditions_unmet', $evaluation['reason']); + self::assertArrayHasKey('conditions', $evaluation); + self::assertNull($evaluation['conditions']); + } + } finally { $f->close(); } + } + } + + public function testCompositionGateIgnoresExplicitCallerAndModuleDefaults(): void + { + foreach ([false, true] as $alias) foreach ([[], ['disabledValue' => null], ['disabledValue' => false], ['disabledValue' => 0], ['disabledValue' => 'null']] as $disabled) { + $seen = []; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'modules' => [['beforeEvaluation' => static function ($options) use (&$seen): array { + $seen[] = $options['variableKey']; + $options['defaultVariableValue'] = 'module'; + return $options; + }]], 'datafile' => $this->datafile(['variables' => [ + 'root' => array_merge(['type' => $alias ? 'json' : 'object', 'conditions' => null, + 'compose' => $alias ? 'source' : [['key' => 'source', 'variable' => 'source']], + 'overrides' => [['conditions' => '*', 'value' => 'override']]], $disabled), + 'source' => ['type' => 'integer', 'defaultValue' => 1], + ]])]); + try { + for ($run = 0; $run < 2; $run++) { + self::assertSame($disabled['disabledValue'] ?? null, $f->getVariable('root', [], ['defaultVariableValue' => 'caller'])); + $evaluation = $f->evaluateVariable('root', [], ['defaultVariableValue' => 'caller']); + self::assertSame('conditions_unmet', $evaluation['reason']); + self::assertSame($disabled['disabledValue'] ?? null, $evaluation['variableValue']); + } + self::assertSame(['root', 'root', 'root', 'root'], $seen); + } finally { $f->close(); } + } + } + + public function testModuleStickyReplacementsSurviveLaterOwnerSetters(): void + { + $f = null; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'modules' => [[ + 'beforeEvaluation' => static function ($options): array { + if (($options['variableKey'] ?? null) === 'root') { + $options['stickyVariables'] = ['source' => 2]; + $options['stickyFeatures'] = ['source' => ['enabled' => true, 'variables' => ['value' => 3]]]; + } + return $options; + }, + 'afterEvaluation' => static function ($evaluation, $options) use (&$f): array { + if (($options['variableKey'] ?? null) === 'mutator') { + $f->setStickyVariables(['source' => 9]); + $f->setStickyFeatures(['source' => ['enabled' => true, 'variables' => ['value' => 9]]]); + } + return $evaluation; + }, + ]], 'datafile' => $this->datafile([ + 'features' => ['source' => $this->feature(['variablesSchema' => ['value' => ['type' => 'integer', 'defaultValue' => 1]]])], + 'variables' => ['source' => ['type' => 'integer', 'defaultValue' => 1], 'mutator' => ['type' => 'integer', 'defaultValue' => 0], + 'root' => ['type' => 'object', 'compose' => [['key' => 'before', 'variable' => 'source'], + ['key' => 'mutator', 'variable' => 'mutator'], ['key' => 'after', 'variable' => 'source'], + ['key' => 'feature', 'feature' => 'source', 'variable' => 'value']]]], + ])]); + try { + for ($run = 0; $run < 2; $run++) self::assertSame(['before' => 2, 'mutator' => 0, 'after' => 2, 'feature' => 3], $f->getVariable('root')); + self::assertSame(9, $f->getVariable('source')); + self::assertSame(9, $f->getVariable('source', 'value')); + } finally { $f->close(); } + } + + public function testCallbackSettersRefreshCurrentReadAndWholeCompositionSticky(): void + { + foreach ([false, true] as $globalRoot) { + $seen = []; + $f = null; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'modules' => [['beforeEvaluation' => static function ($options) use (&$f, &$seen, $globalRoot): array { + $seen[] = $options['variableKey'] ?? $options['featureKey']; + if (($options['variableKey'] ?? null) === 'root' || ($options['featureKey'] ?? null) === 'root') { + if ($globalRoot) $f->setStickyVariables(['root' => 'whole']); + else $f->setStickyFeatures(['root' => ['enabled' => true, 'variables' => ['value' => 'whole']]]); + } + return $options; + }]], 'datafile' => $this->datafile([ + 'features' => ['root' => $this->feature(['variablesSchema' => ['value' => ['type' => 'string', 'defaultValue' => 'old']]])], + 'variables' => ['root' => ['type' => 'object', 'compose' => [['key' => 'unused', 'variable' => 'unused']]]], + ])]); + try { + self::assertSame('whole', $globalRoot ? $f->getVariable('root') : $f->getVariable('root', 'value')); + self::assertSame($globalRoot ? ['root'] : ['value'], $seen); + } finally { $f->close(); } + } + } + + public function testContextChangesAndIndependentCallbackReentryKeepRequesterAndMemo(): void + { + $seen = []; + $inner = []; + $f = null; + $leaf = ['feature' => 'source', 'variable' => 'value']; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'modules' => [[ + 'beforeEvaluation' => static function ($options) use (&$f, &$seen, &$inner): array { + if (($options['featureKey'] ?? null) === 'source') { + $seen[] = [$options['dependencyOf'] ?? null, $options['dependencyPurpose'] ?? null]; + if (empty($options['context']['inner'])) $inner[] = $f->getVariable('source', 'value', ['inner' => true, 'nested' => (object) ['value' => 9]]); + } + return $options; + }, + 'afterEvaluation' => static function ($evaluation, $options): array { + if (($options['featureKey'] ?? null) === 'source') $evaluation['variableValue'] = $options['context']['nested']->value; + if (($options['variableKey'] ?? null) === 'mutator') $options['context']['nested']->value = 2; + return $evaluation; + }, + ]], 'datafile' => $this->datafile([ + 'features' => ['source' => $this->feature(['variablesSchema' => ['value' => ['type' => 'integer', 'defaultValue' => 0]]])], + 'variables' => ['mutator' => ['type' => 'integer', 'defaultValue' => 0], 'root' => ['type' => 'object', 'compose' => [ + array_merge(['key' => 'first'], $leaf), array_merge(['key' => 'duplicate'], $leaf), + ['key' => 'mutator', 'variable' => 'mutator'], array_merge(['key' => 'changed'], $leaf), + ]]], + ])]); + try { + for ($run = 0; $run < 2; $run++) { + $seen = []; $inner = []; + self::assertSame(['first' => 1, 'duplicate' => 1, 'mutator' => 0, 'changed' => 2], $f->getVariable('root', ['nested' => (object) ['value' => 1]])); + self::assertSame([9, 9], $inner); + $pair = [[['type' => 'variable', 'variableKey' => 'root'], 'composition'], [null, null]]; + self::assertSame(array_merge($pair, $pair), $seen); + } + } finally { $f->close(); } + } + + public function testSourcesKeepExactOrdinaryFallbacksWithoutOuterDefaults(): void + { + $sources = ['null' => null, 'false' => false, 'zero' => 0, 'empty' => '', 'array' => [], 'object' => (object) []]; + $variables = []; + foreach ($sources as $key => $value) $variables[$key] = ['type' => 'object', 'defaultValue' => $value]; + $variables += ['json' => ['type' => 'json', 'defaultValue' => '"null"'], 'broken' => ['type' => 'json', 'defaultValue' => '{'], + 'throws' => ['type' => 'integer', 'defaultValue' => 1], + 'gated' => ['type' => 'json', 'compose' => 'throws', 'conditions' => ['or' => []], 'disabledValue' => 'null']]; + $members = []; + foreach (array_merge(array_keys($sources), ['json', 'broken', 'throws', 'missing', 'gated']) as $key) $members[] = ['key' => $key, 'variable' => $key]; + $members = array_merge($members, [['key' => 'flag', 'feature' => 'absent'], ['key' => 'variation', 'feature' => 'absent', 'variation' => true], + ['key' => 'featureVariable', 'feature' => 'absent', 'variable' => 'value']]); + $variables['root'] = ['type' => 'object', 'compose' => $members]; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $this->datafile(['variables' => $variables]), + 'modules' => [['beforeEvaluation' => static function ($options): array { + if (($options['variableKey'] ?? null) === 'throws') throw new \RuntimeException('ordinary module failure'); + return $options; + }]]]); + try { + for ($run = 0; $run < 2; $run++) { + $result = $f->getVariable('root', [], ['defaultVariableValue' => 'outer', 'defaultVariationValue' => 'outer']); + self::assertSame(['null' => null, 'false' => false, 'zero' => 0, 'empty' => '', 'array' => [], 'object' => [], + 'json' => 'null', 'broken' => null, 'throws' => null, 'missing' => null, 'gated' => 'null', + 'flag' => false, 'variation' => null, 'featureVariable' => null], $result); + } + } finally { $f->close(); } + } + + private function feature(array $values = []): array + { + return array_merge(['bucketBy' => 'userId', 'traffic' => [['key' => 'all', 'conditions' => '*', 'percentage' => 100000]]], $values); + } + + private function datafile(array $values = []): array + { + return array_merge(['schemaVersion' => '4', 'revision' => 'review', 'segments' => [], 'features' => [], 'variables' => []], $values); + } + + public function testGlobalModuleStickyReplacementsReachCompositionAndConditions(): void + { + foreach ([false, true] as $composed) { + $root = ['type' => 'object', 'defaultValue' => 'matched', 'conditions' => [ + ['variable' => 'source', 'operator' => 'equals', 'value' => 2], + ['feature' => 'source', 'variable' => 'value', 'operator' => 'equals', 'value' => 3], + ]]; + if ($composed) $root['compose'] = [['key' => 'global', 'variable' => 'source'], ['key' => 'feature', 'feature' => 'source', 'variable' => 'value']]; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'modules' => [['beforeEvaluation' => static function ($options): array { + if (($options['variableKey'] ?? null) === 'root') { + $options['stickyVariables'] = ['source' => 2]; + $options['stickyFeatures'] = ['source' => ['enabled' => true, 'variables' => ['value' => 3]]]; + } + return $options; + }]], 'datafile' => $this->datafile([ + 'features' => ['source' => $this->feature(['variablesSchema' => ['value' => ['type' => 'integer', 'defaultValue' => 1]]])], + 'variables' => ['source' => ['type' => 'integer', 'defaultValue' => 1], 'root' => $root], + ])]); + try { + for ($run = 0; $run < 2; $run++) self::assertSame($composed ? ['global' => 2, 'feature' => 3] : 'matched', $f->getVariable('root')); + self::assertSame(1, $f->getVariable('source')); + self::assertSame(1, $f->getVariable('source', 'value')); + } finally { $f->close(); } + } + } + + public function testFeatureSourcesRefreshAfterModuleSetters(): void + { + foreach (['datafile', 'sticky'] as $state) foreach ([false, true] as $childMode) foreach ([false, true] as $composed) { + $leaf = ['feature' => 'source', 'variable' => 'value']; + $root = ['type' => 'object', 'defaultValue' => 'matched', 'conditions' => [ + array_merge($leaf, ['operator' => 'equals', 'value' => 1]), + ['variable' => 'mutator', 'operator' => 'equals', 'value' => 0], + array_merge($leaf, ['operator' => 'equals', 'value' => 2]), + ]]; + if ($composed) $root = ['type' => 'object', 'compose' => [array_merge(['key' => 'before'], $leaf), + ['key' => 'mutator', 'variable' => 'mutator'], array_merge(['key' => 'after'], $leaf)]]; + $d = $this->datafile([ + 'features' => ['source' => $this->feature(['variablesSchema' => ['value' => ['type' => 'integer', 'defaultValue' => 1]]])], + 'variables' => ['mutator' => ['type' => 'integer', 'defaultValue' => 0], 'root' => $root], + ]); + $reads = []; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $d]); + $target = $childMode ? $f->spawn() : $f; + $f->addModule(['afterEvaluation' => static function ($evaluation, $options) use ($state, $d, $f, $target, &$reads): array { + if (($options['variableKey'] ?? null) === 'value') $reads[] = $evaluation['variableValue'] ?? null; + if (($options['variableKey'] ?? null) === 'mutator') { + if ($state === 'sticky') $target->setStickyFeatures(['source' => ['enabled' => true, 'variables' => ['value' => 2]]]); + else { + $update = $d; + $update['features']['source']['variablesSchema']['value']['defaultValue'] = 2; + $f->setDatafile($update, true); + } + } + return $evaluation; + }]); + try { + self::assertSame($composed ? ['before' => 1, 'mutator' => 0, 'after' => 2] : 'matched', $target->getVariable('root')); + self::assertSame([1, 2], $reads); + } finally { if ($childMode) $target->close(); $f->close(); } + } + } +} diff --git a/tests/RuntimeIngressTest.php b/tests/RuntimeIngressTest.php new file mode 100644 index 0000000..35fad33 --- /dev/null +++ b/tests/RuntimeIngressTest.php @@ -0,0 +1,185 @@ + (object) [], 'items' => [(object) [], [], null], + 'profile' => (object) ['permissions' => [], 'numeric' => (object) ['0' => 'owner']]]; + $sticky = (object) ['source' => null, 'object' => (object) [], 'array' => [], + 'nested' => (object) ['items' => [(object) [], []]]]; + $featureSticky = (object) ['sourceFeature' => (object) ['enabled' => true, 'variables' => $sticky]]; + $assertion = (object) ['context' => $runtime, 'stickyVariables' => $sticky, 'stickyFeatures' => $featureSticky, + 'children' => [(object) ['context' => $runtime, 'stickyVariables' => $sticky, 'stickyFeatures' => $featureSticky]]]; + $envelope = ['kind' => 'featurevisor.test-execution', 'schemaVersion' => 1, + 'tests' => [['scenario' => 'runtime', 'assertions' => [$assertion]]]]; + $tests = \decodeTestExecutionEnvelope(json_encode($envelope, JSON_THROW_ON_ERROR)); + foreach ([$tests[0]['assertions'][0], $tests[0]['assertions'][0]['children'][0]] as $decoded) { + self::assertIsArray($decoded['context']); + self::assertIsArray($decoded['stickyVariables']); + self::assertIsArray($decoded['stickyFeatures']); + self::assertIsArray($decoded['stickyFeatures']['sourceFeature']); + self::assertIsArray($decoded['stickyFeatures']['sourceFeature']['variables']); + self::assertSame(json_encode($runtime), json_encode((object) $decoded['context'])); + self::assertSame(json_encode($sticky), json_encode((object) $decoded['stickyVariables'])); + self::assertSame(json_encode($sticky), json_encode((object) $decoded['stickyFeatures']['sourceFeature']['variables'])); + self::assertInstanceOf(\stdClass::class, $decoded['context']['permissions']); + self::assertInstanceOf(\stdClass::class, $decoded['context']['items'][0]); + self::assertSame([], $decoded['context']['items'][1]); + self::assertNull($decoded['stickyVariables']['source']); + self::assertInstanceOf(\stdClass::class, $decoded['stickyVariables']['object']); + self::assertSame([], $decoded['stickyVariables']['array']); + } + self::assertSame([], \runtimeMapToArray((object) [])); + $featureAssertion = (object) ['stickyFeatures' => $featureSticky, 'children' => [(object) ['stickyFeatures' => $featureSticky]]]; + $envelope['tests'] = [['feature' => 'subject', 'assertions' => [$featureAssertion]]]; + $tests = \decodeTestExecutionEnvelope(json_encode($envelope, JSON_THROW_ON_ERROR)); + foreach ([$tests[0]['assertions'][0], $tests[0]['assertions'][0]['children'][0]] as $decoded) { + self::assertIsArray($decoded['stickyFeatures']['sourceFeature']['variables']); + self::assertSame(json_encode($sticky), json_encode((object) $decoded['stickyFeatures']['sourceFeature']['variables'])); + } + } + + public function testNativeNestedObjectContextLookupRetainsPresenceAndBucketing(): void + { + $context = ['profile' => (object) ['id' => 'member', 'permissions' => [], 'null' => null, + 'items' => [(object) ['permissions' => []]], 'numeric' => (object) ['0' => 'owner']]]; + self::assertSame('member', Conditions::getValueFromContext($context, 'profile.id')); + self::assertSame('member.subject', \Featurevisor\Bucketer::getBucketKey(['featureKey' => 'subject', 'bucketBy' => 'profile.id', 'context' => $context])); + self::assertTrue(Conditions::pathExists($context, 'profile.null')); + self::assertFalse(Conditions::pathExists($context, 'profile.missing')); + self::assertSame('owner', Conditions::getValueFromContext($context, 'profile.numeric.0')); + foreach (['profile.permissions', 'profile.items.0.permissions'] as $path) { + self::assertTrue(Conditions::conditionIsMatched(['attribute' => $path, 'operator' => 'notIncludesAny', 'value' => ['owner']], $context, static fn() => 'unused')); + } + } + + public function testRealCliPreservesInvalidContextAndStickyRuntimeOperands(): void + { + $nodeModules = dirname(__DIR__, 2).'/featurevisor/node_modules'; + if (!is_file($nodeModules.'/.bin/featurevisor')) { + $this->markTestSkipped('Requires the already built sibling Node CLI. This test does not install or build it.'); + } + $directory = sys_get_temp_dir().'/featurevisor runtime ingress '.bin2hex(random_bytes(6)); + mkdir($directory); + $write = static function (string $path, $value) use ($directory): void { + $parent = dirname($directory.'/'.$path); + if (!is_dir($parent)) mkdir($parent, 0777, true); + file_put_contents($directory.'/'.$path, is_string($value) ? $value : json_encode($value, JSON_THROW_ON_ERROR)); + }; + try { + symlink($nodeModules, $directory.'/node_modules'); + $write('featurevisor.config.js', 'module.exports = { parser: "json", sets: false };'); + $write('attributes/permissions.json', ['description' => 'Permissions', 'type' => 'array', 'items' => ['type' => 'string']]); + $write('attributes/profile.json', ['description' => 'Nested profile', 'type' => 'object', 'properties' => ['permissions' => ['type' => 'array', 'items' => ['type' => 'string']]]]); + $write('variables/source.json', ['description' => 'Sticky array', 'type' => 'array', 'defaultValue' => []]); + $write('variables/nestedSource.json', ['description' => 'Sticky nested array', 'type' => 'object', 'defaultValue' => ['permissions' => []], + 'properties' => ['permissions' => ['type' => 'array', 'items' => ['type' => 'string']]]]); + $write('features/sourceFeature.json', ['description' => 'Sticky feature variable source', 'bucketBy' => 'userId', + 'variablesSchema' => ['source' => ['type' => 'array', 'defaultValue' => []], + 'nestedSource' => ['type' => 'object', 'defaultValue' => ['permissions' => []]]], + 'rules' => [['key' => 'all', 'percentage' => 100, 'conditions' => '*']]]); + $assertions = []; + $cases = [[null, false, false], [(object) [], false, false], [[], false, true], + [[(object) []], false, true], [[[]], false, true], [['owner'], true, false]]; + foreach (['includesAny', 'notIncludesAny'] as $operator) { + $positive = $operator === 'includesAny'; + foreach (['direct' => 'permissions', 'nested' => 'profile.permissions'] as $label => $path) { + $key = $label.$operator; + $write('segments/'.$key.'.json', ['description' => 'Context membership', 'conditions' => [['attribute' => $path, 'operator' => $operator, 'value' => ['owner']]]]); + $write('features/'.$key.'.json', ['description' => 'Context membership', 'bucketBy' => 'userId', + 'rules' => [['key' => 'membership', 'percentage' => 100, 'conditions' => [$key]]]]); + $featureAssertions = []; + foreach ($cases as $index => [$actual, $includes, $notIncludes]) { + $context = $label === 'direct' ? (object) ['permissions' => $actual] : (object) ['profile' => (object) ['permissions' => $actual]]; + $featureAssertions[] = ['at' => 50, 'description' => $key.' '.$index, 'context' => $context, + 'expectedToBeEnabled' => $positive ? $includes : $notIncludes, + 'children' => [['context' => $context, 'expectedToBeEnabled' => $positive ? $includes : $notIncludes]]]; + } + $write('tests/'.$key.'.spec.json', ['feature' => $key, 'assertions' => $featureAssertions]); + } + foreach (['source', 'nestedSource', 'featureSource', 'featureNestedSource'] as $reference) { + $featureSource = strpos($reference, 'feature') === 0; + $source = $reference === 'nestedSource' || $reference === 'featureNestedSource' ? 'nestedSource' : 'source'; + $requirement = ['variable' => $source, 'operator' => $operator, 'value' => ['owner']]; + if ($featureSource) $requirement['feature'] = 'sourceFeature'; + if ($source === 'nestedSource') $requirement['path'] = 'permissions'; + $key = $reference.$operator; + $write('features/'.$key.'.json', ['description' => 'Sticky prerequisite', 'bucketBy' => 'userId', + 'conditions' => [$requirement], 'rules' => [['key' => 'all', 'percentage' => 100, 'conditions' => '*']]]); + $featureAssertions = []; + foreach ($cases as $index => [$actual, $includes, $notIncludes]) { + $stickyValue = $source === 'source' ? $actual : (object) ['permissions' => $actual]; + $check = ['feature' => $key, 'enabled' => $positive ? $includes : $notIncludes]; + $state = $featureSource ? ['stickyFeatures' => (object) ['sourceFeature' => (object) ['enabled' => true, 'variables' => (object) [$source => $stickyValue]]]] + : ['stickyVariables' => (object) [$source => $stickyValue]]; + $assertions[] = array_merge($state, ['description' => $key.' '.$index, + 'checks' => [$check], 'children' => [array_merge($state, ['checks' => [$check]])]]); + $featureState = $state; + $featureAssertions[] = array_merge($featureState, ['at' => 50, 'expectedToBeEnabled' => $positive ? $includes : $notIncludes, + 'children' => [array_merge($featureState, ['expectedToBeEnabled' => $positive ? $includes : $notIncludes])]]); + } + $write('tests/'.$key.'.spec.json', ['feature' => $key, 'assertions' => $featureAssertions]); + } + } + foreach ($assertions as $index => $assertion) $write('tests/sticky'.$index.'.spec.json', $assertion); + $process = proc_open([PHP_BINARY, dirname(__DIR__).'/featurevisor', 'test', '--projectDirectoryPath='.$directory, '--json'], + [1 => ['pipe', 'w'], 2 => ['file', $directory.'/stderr', 'w']], $pipes); + self::assertIsResource($process); + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $status = proc_close($process); + self::assertJson($output, $output.' '.file_get_contents($directory.'/stderr')); + $report = json_decode($output, false, 512, JSON_THROW_ON_ERROR); + self::assertSame(0, $status, $output.' '.file_get_contents($directory.'/stderr')); + self::assertTrue($report->passed); + self::assertSame(120, $report->summary->assertions->passed); + self::assertSame(0, $report->summary->assertions->failed); + $contexts = []; + foreach ($report->results as $spec) { + foreach ($spec->cases as $case) $contexts[] = $case->context; + } + self::assertTrue(count(array_filter($contexts, static fn($context) => isset($context->permissions) && $context->permissions instanceof \stdClass)) > 0); + self::assertTrue(count(array_filter($contexts, static fn($context) => isset($context->permissions) && is_array($context->permissions) && isset($context->permissions[0]) && $context->permissions[0] instanceof \stdClass)) > 0); + foreach (['benchmark', 'assess-distribution'] as $command) { + foreach (['direct', 'nested'] as $location) { + foreach ([(object) [], []] as $permissions) { + $context = $location === 'direct' ? (object) ['permissions' => $permissions] + : (object) ['profile' => (object) ['permissions' => $permissions]]; + $json = json_encode($context, JSON_THROW_ON_ERROR); + $process = proc_open([PHP_BINARY, dirname(__DIR__).'/featurevisor', $command, + '--projectDirectoryPath='.$directory, '--feature='.$location.'notIncludesAny', '--n=1', '--context='.$json], + [1 => ['pipe', 'w'], 2 => ['file', $directory.'/stderr', 'w']], $pipes); + self::assertIsResource($process); + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + self::assertSame(0, proc_close($process), $output.' '.file_get_contents($directory.'/stderr')); + self::assertStringContainsString('Against context: '.$json, $output); + $expected = is_array($permissions) ? 'true' : 'false'; + self::assertStringContainsString($command === 'benchmark' ? 'Evaluated value: '.$expected : (is_array($permissions) ? '1' : '0').': 1', $output); + } + } + } + } finally { + $remove = static function (string $path) use (&$remove): void { + if (is_link($path) || !is_dir($path)) { unlink($path); return; } + foreach (scandir($path) as $entry) { + if ($entry !== '.' && $entry !== '..') $remove($path.'/'.$entry); + } + rmdir($path); + }; + $remove($directory); + } + } +} diff --git a/tests/SdkStatefulTest.php b/tests/SdkStatefulTest.php new file mode 100644 index 0000000..8771bad --- /dev/null +++ b/tests/SdkStatefulTest.php @@ -0,0 +1,141 @@ +> $events @return array> */ + private function normalizeEvents(array $events): array + { + foreach ($events as &$event) { + foreach (['features', 'variables'] as $key) { + if (isset($event['details'][$key])) sort($event['details'][$key], SORT_STRING); + } + } + unset($event); + return $events; + } + + /** @param array> $diagnostics */ + private function hasDiagnosticCode(array $diagnostics, string $code): bool + { + foreach ($diagnostics as $diagnostic) { + if (($diagnostic['code'] ?? null) === $code) return true; + } + return false; + } + + public function testAllStatefulCasesAndOperations(): void + { + $fixture = json_decode( + file_get_contents(__DIR__.'/../conformance/sdk-stateful.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + self::assertSame('featurevisor.sdk-stateful', $fixture['kind']); + self::assertSame(1, $fixture['schemaVersion']); + self::assertIsArray($fixture['cases']); + self::assertNotEmpty($fixture['cases']); + + foreach ($fixture['cases'] as $case) { + $diagnostics = []; + $events = []; + $targets = [ + 'root' => Featurevisor::createFeaturevisor([ + 'datafile' => $case['initial']['datafile'], + 'context' => $case['initial']['context'] ?? [], + 'logLevel' => 'error', + 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { + $diagnostics[] = $diagnostic; + }, + ]), + ]; + $watch = static function (string $targetName) use (&$targets, &$events): void { + foreach (['datafile_set', 'sticky_features_set', 'sticky_variables_set'] as $eventName) { + $targets[$targetName]->on( + $eventName, + static function (array $details) use (&$events, $targetName, $eventName): void { + $events[] = [ + 'target' => $targetName, + 'name' => $eventName, + 'details' => $details, + ]; + } + ); + } + }; + $watch('root'); + + try { + foreach ($case['operations'] as $operation) { + $events = []; + $diagnostics = []; + $error = null; + try { + switch ($operation['action']) { + case 'setDatafile': + $targets[$operation['target']]->setDatafile( + $operation['datafile'], + $operation['replace'] + ); + break; + case 'spawn': + $targets[$operation['target']] = $targets[$operation['parent']]->spawn( + $operation['context'] ?? [], + $operation['options'] ?? [] + ); + $watch($operation['target']); + break; + case 'setStickyFeatures': + $targets[$operation['target']]->setStickyFeatures( + $operation['stickyFeatures'], + $operation['replace'] + ); + break; + case 'setStickyVariables': + $targets[$operation['target']]->setStickyVariables( + $operation['stickyVariables'], + $operation['replace'] + ); + break; + default: + self::fail('Unsupported fixture action: '.$operation['action']); + } + } catch (\Throwable $caught) { + $error = $caught; + } + + $expected = $operation['expect']; + self::assertEquals( + $this->normalizeEvents($expected['events']), + $this->normalizeEvents($events), + $case['name'] + ); + if (isset($expected['error'])) { + self::assertTrue( + $this->hasDiagnosticCode($diagnostics, $expected['error']['code']), + $case['name'] + ); + self::assertNull($error, $case['name']); + } else { + self::assertNull($error, $case['name']); + } + + foreach ($expected['results'] as $result) { + $actual = $targets[$result['target']]->{$result['method']}(...$result['args']); + self::assertSame($result['value'], $actual, $case['name']); + } + } + } finally { + foreach (array_reverse($targets, true) as $name => $target) { + if ($name !== 'root') $target->close(); + } + $targets['root']->close(); + } + } + } +} diff --git a/tests/SemverConformanceTest.php b/tests/SemverConformanceTest.php new file mode 100644 index 0000000..4539c9e --- /dev/null +++ b/tests/SemverConformanceTest.php @@ -0,0 +1,103 @@ + $operator) { + $label = $case['name'].' reverse='.(int) $reverse.' '.$operator; + $expected = $order !== null && [$order === 0, $order !== 0, $order > 0, $order >= 0, $order < 0, $order <= 0][$index]; + $requirement = ['variable' => 'source', 'operator' => $operator, 'value' => $b]; + $traffic = [['key' => 'all', 'segments' => '*', 'percentage' => 100000, 'allocation' => []]]; + $datafile = [ + 'schemaVersion' => '2', 'revision' => 'semver', + 'segments' => ['version' => ['conditions' => ['attribute' => 'version', 'operator' => $operator, 'value' => $b]]], + 'features' => [ + 'condition' => ['bucketBy' => 'userId', 'traffic' => [array_merge($traffic[0], ['segments' => ['version']])]], + 'sourceFeature' => ['bucketBy' => 'userId', 'traffic' => $traffic, 'variablesSchema' => ['source' => ['type' => 'string', 'defaultValue' => $a]]], + ], + 'variables' => ['source' => ['type' => 'string', 'defaultValue' => $a], 'other' => ['type' => 'string', 'defaultValue' => $b]], + ]; + foreach ([false, true] as $featureSource) { + $selected = array_merge($requirement, $featureSource ? ['feature' => 'sourceFeature'] : []); + foreach ([false, true] as $negated) { + $subject = 'subject'.(int) $featureSource.(int) $negated; + $datafile['variables'][$subject] = ['type' => 'boolean', 'defaultValue' => true, 'disabledValue' => false, + 'conditions' => $negated ? [['not' => [$selected]]] : [$selected]]; + } + } + $diagnostics = []; + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal', 'onDiagnostic' => function ($diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }]); + try { + self::assertSame($expected, $f->isEnabled('condition', ['version' => $a]), $label); + foreach ([false, true] as $featureSource) { + foreach ([false, true] as $negated) { + $subject = 'subject'.(int) $featureSource.(int) $negated; + self::assertSame($order === null ? null : $expected !== $negated, $f->getVariable($subject), $label); + if ($order === null) self::assertSame('error', $f->evaluateVariable($subject)['reason'], $label); + } + $selected = array_merge($requirement, $featureSource ? ['feature' => 'sourceFeature'] : []); + foreach ([false, true] as $compareTo) { + $check = $selected; + if ($compareTo) { + unset($check['value']); + $check['compareTo'] = ['variable' => 'other']; + } + foreach ([false, true] as $negated) { + $checks = $negated ? [['not' => [$check]]] : [$check]; + $report = TestChecks::evaluateResult($f, $datafile, $checks, null, $diagnostics); + self::assertSame($order !== null && $expected !== $negated, $report['passed'], $label); + if ($order === null) { + self::assertTrue($report['error'], $label); + self::assertSame('check', $report['errors'][0]['type']); + self::assertContains($report['errors'][0]['expected'], ['valid checks', 'successful comparison']); + } + } + } + } + } finally { + $f->close(); + } + } + } + } + } + + public function testPortableComparisons(): void + { + $fixture = json_decode(file_get_contents(__DIR__.'/../conformance/semver.json'), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('featurevisor.semver', $fixture['kind']); + self::assertSame(1, $fixture['version']); + self::assertCount(44, $fixture['cases']); + foreach ($fixture['cases'] as $case) { + foreach ([false, true] as $reverse) { + [$a, $b] = $reverse ? [$case['right'], $case['left']] : [$case['left'], $case['right']]; + try { + $actual = CompareVersions::compare($a, $b); + } catch (\Exception $error) { + self::assertNull($case['order'], $case['name'].': '.$error->getMessage()); + continue; + } + self::assertNotNull($case['order'], $case['name']); + self::assertSame($reverse ? -$case['order'] : $case['order'], $actual, $case['name']); + } + } + } +} diff --git a/tests/SetDatafileResultTest.php b/tests/SetDatafileResultTest.php new file mode 100644 index 0000000..ec7a605 --- /dev/null +++ b/tests/SetDatafileResultTest.php @@ -0,0 +1,157 @@ + '2', 'revision' => $revision, + 'segments' => [$key => ['conditions' => '*']], + 'features' => [$key => ['hash' => $key, 'bucketBy' => 'userId', 'traffic' => [ + ['key' => 'all', 'segments' => '*', 'percentage' => 100000], + ]]], + 'variables' => [$key => ['hash' => $key, 'type' => 'string', 'defaultValue' => $key]], + ]; + } + + public function testAcceptsNativeAndJsonMergeReplacementAndUnchanged(): void + { + foreach ([false, true] as $json) { + foreach ([false, true] as $replace) { + $sdk = Featurevisor::createFeaturevisor(['datafile' => $this->datafile(), 'logLevel' => 'fatal']); + $child = $sdk->spawn(); + $events = []; + $sdk->on('datafile_set', function ($event) use (&$events) { $events[] = $event; }); + $incoming = $this->datafile('next', '2'); + $original = $incoming; + $input = $json ? json_encode($incoming, JSON_THROW_ON_ERROR) : $incoming; + self::assertTrue($replace ? $sdk->setDatafile($input, true) : $sdk->setDatafile($input)); + self::assertSame($original, $incoming); + self::assertSame('2', $sdk->getRevision()); + self::assertSame($replace ? ['next'] : ['original', 'next'], $sdk->getFeatureKeys()); + self::assertSame($replace ? ['next'] : ['original', 'next'], $sdk->getVariableKeys()); + self::assertSame($replace, $sdk->getSegment('original') === null); + self::assertTrue($child->isEnabled('next')); + self::assertSame('next', $child->getVariable('next')); + self::assertCount(1, $events); + self::assertSame([ + 'revision' => '2', 'previousRevision' => '1', 'revisionChanged' => true, + 'features' => $replace ? ['original', 'next'] : ['next'], + 'variables' => $replace ? ['original', 'next'] : ['next'], 'replaced' => $replace, + ], $events[0]); + self::assertTrue($sdk->setDatafile($input, $replace)); + self::assertCount(2, $events); + self::assertSame([ + 'revision' => '2', 'previousRevision' => '2', 'revisionChanged' => false, + 'features' => [], 'variables' => [], 'replaced' => $replace, + ], $events[1]); + $child->close(); + $sdk->close(); + } + } + } + + public function testAcceptsEmptyDatafilesAndChangedContentWithTheSameRevision(): void + { + $sdk = Featurevisor::createFeaturevisor(['logLevel' => 'fatal']); + $empty = ['schemaVersion' => '2', 'revision' => '1', 'segments' => [], 'features' => []]; + self::assertTrue($sdk->setDatafile($empty)); + self::assertTrue($sdk->setDatafile('{"schemaVersion":"2","revision":"1","segments":{},"features":{}}')); + self::assertTrue($sdk->setDatafile($this->datafile(), true)); + self::assertTrue($sdk->isEnabled('original')); + self::assertTrue($sdk->setDatafile($empty, true)); + self::assertSame([], $sdk->getFeatureKeys()); + self::assertSame([], $sdk->getVariableKeys()); + self::assertSame('1', $sdk->getRevision()); + $sdk->close(); + } + + public function testRejectionPreservesStateAndReportsOnceAtEachLogLevel(): void + { + $invalid = ['{', 'null', '[]', null, false, 1, [], + array_replace($this->datafile(), ['schemaVersion' => 2]), + array_replace($this->datafile(), ['revision' => 2]), + array_replace($this->datafile(), ['segments' => false]), + array_replace($this->datafile(), ['features' => false]), + array_replace($this->datafile(), ['variables' => false])]; + foreach (['info', 'fatal'] as $level) { + $diagnostics = $events = $errors = []; + $sdk = Featurevisor::createFeaturevisor([ + 'datafile' => $this->datafile(), 'logLevel' => $level, + 'onDiagnostic' => function ($diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, + ]); + $child = $sdk->spawn(); + $diagnostics = []; + $sdk->on('datafile_set', function ($event) use (&$events) { $events[] = $event; }); + $sdk->on('error', function ($event) use (&$errors) { $errors[] = $event; }); + foreach ($invalid as $input) { + foreach ([false, true] as $replace) { + self::assertFalse($sdk->setDatafile($input, $replace)); + self::assertSame('1', $sdk->getRevision()); + self::assertSame(['original'], $sdk->getFeatureKeys()); + self::assertNotNull($sdk->getSegment('original')); + self::assertTrue($child->isEnabled('original')); + self::assertSame('original', $child->getVariable('original')); + $error = $errors[count($errors) - 1]['diagnostic']; + self::assertSame('invalid_datafile', $error['code']); + self::assertSame('Could not parse datafile', $error['message']); + self::assertInstanceOf(\Throwable::class, $error['originalError']); + } + } + self::assertSame([], $events); + self::assertCount(count($invalid) * 2, $errors); + self::assertCount($level === 'info' ? count($invalid) * 2 : 0, $diagnostics); + self::assertTrue($sdk->setDatafile($this->datafile(), true)); + $child->close(); + $sdk->close(); + } + } + + public function testClosedReturnsFalseWithoutParsingOrDiagnostics(): void + { + $diagnostics = []; + $sdk = Featurevisor::createFeaturevisor([ + 'datafile' => $this->datafile(), + 'onDiagnostic' => function ($diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, + ]); + $sdk->close(); + $diagnostics = []; + self::assertFalse($sdk->setDatafile($this->datafile('next', '2'))); + self::assertFalse($sdk->setDatafile('{', true)); + self::assertSame('1', $sdk->getRevision()); + self::assertSame([], $diagnostics); + } + + public function testThrowingCallbacksDoNotChangeAcceptanceOrRejection(): void + { + $diagnostics = $events = $errors = []; + $fail = function ($value) { throw new \RuntimeException('callback failed'); }; + $sdk = Featurevisor::createFeaturevisor([ + 'onDiagnostic' => function ($diagnostic) use (&$diagnostics) { + $diagnostics[] = $diagnostic['code']; + throw new \RuntimeException('callback failed'); + }, + 'modules' => [['name' => 'throwing', 'setup' => function ($api) use ($fail) { + $api['onDiagnostic']($fail); + }]], + ]); + $diagnostics = []; + $sdk->on('datafile_set', $fail); + $sdk->on('datafile_set', function ($event) use (&$events) { $events[] = $event; }); + $sdk->on('error', $fail); + $sdk->on('error', function ($event) use (&$errors) { $errors[] = $event; }); + self::assertTrue($sdk->setDatafile($this->datafile())); + self::assertTrue($sdk->setDatafile($this->datafile())); + self::assertFalse($sdk->setDatafile('{')); + self::assertSame(['datafile_set', 'datafile_set', 'invalid_datafile'], $diagnostics); + self::assertCount(2, $events); + self::assertCount(1, $errors); + self::assertSame('1', $sdk->getRevision()); + $sdk->close(); + } +} diff --git a/tests/SharedDiagnosticsTest.php b/tests/SharedDiagnosticsTest.php new file mode 100644 index 0000000..3d90769 --- /dev/null +++ b/tests/SharedDiagnosticsTest.php @@ -0,0 +1,62 @@ +kind); + self::assertSame(1, $fixture->version); + self::assertNotEmpty($fixture->cases); + foreach ($fixture->cases as $case) for ($run = 0; $run < 2; $run++) { + $f = null; + $captured = []; + $state = (object) ['reentering' => false]; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'error', 'onDiagnostic' => static function ($item) use (&$f, &$captured, $state): void { + if ($item['code'] !== 'conditions_parse_error') return; + $captured[] = ['code' => $item['code'], 'level' => $item['level'], 'details' => $item['details'], 'revision' => $f->getRevision()]; + if ($item['details']['entityType'] !== 'feature' || $state->reentering) return; + $state->reentering = true; + try { + $count = count($captured); + $f->isEnabled($item['details']['entityKey']); + self::assertCount($count, $captured, 'reentry must not diagnose again'); + } finally { $state->reentering = false; } + }]); + try { + $executed = 0; + foreach ($case->operations as $operation) { + $start = count($captured); + if ($operation->action === 'setDatafile') { + $result = $f->setDatafile(json_encode($operation->datafile, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION), $operation->replace); + } else { + self::assertSame('isEnabled', $operation->action); + $result = $f->isEnabled($operation->feature); + } + self::assertSame($operation->expectedResult, $result, $case->name); + $actual = array_slice($captured, $start); + self::assertCount(count($operation->diagnostics), $actual, $case->name); + foreach ($operation->diagnostics as $expected) { + $found = false; + foreach ($actual as $index => $diagnostic) { + try { V4CapabilitiesTest::assertJsonSame($expected, $diagnostic); } + catch (\PHPUnit\Framework\AssertionFailedError $error) { continue; } + unset($actual[$index]); + $found = true; + break; + } + self::assertTrue($found, $case->name.' missing diagnostic '.json_encode($expected)); + } + self::assertSame([], $actual); + $executed++; + } + self::assertSame(count($case->operations), $executed); + } finally { $f->close(); } + } + } +} diff --git a/tests/StringifiedConditionsTest.php b/tests/StringifiedConditionsTest.php new file mode 100644 index 0000000..3885b7f --- /dev/null +++ b/tests/StringifiedConditionsTest.php @@ -0,0 +1,133 @@ + ['["owner"]', '{}', null], + 'numeric key object candidates' => ['["owner"]', '{"0":"owner"}', null], + 'malformed after match' => ['["owner"]', '["owner",{}]', null], + 'malformed before match' => ['["owner"]', '[{},"owner"]', null], + 'nested array candidates' => ['["owner"]', '["owner",[]]', null], + 'null candidates' => ['["owner"]', 'null', null], + 'scalar candidates' => ['["owner"]', '"owner"', null], + 'empty candidate array' => ['["owner"]', '[]', false], + 'empty actual array' => ['[]', '["owner"]', false], + 'both empty arrays' => ['[]', '[]', false], + 'integer matches float' => ['[1]', '[1.0]', true], + 'float matches integer' => ['[1.0]', '[1]', true], + 'large finite number' => ['[1e30]', '[1e30]', true], + 'mixed structured actual' => ['[{},[],true,null,1,"owner"]', '[false,1.0]', true], + 'boolean is not number' => ['[true]', '[1]', false], + 'number is not boolean' => ['[1]', '[true]', false], + 'string is not number' => ['["1"]', '[1.0]', false], + 'null element' => ['[null]', '[null]', true], + 'object actual' => ['{}', '[]', null], + 'null actual' => ['null', '[]', null], + ]; + } + + /** @dataProvider membershipCases */ + public function testHelperAndSdkShareStringifiedGroupAndSegmentSemantics(string $actualJson, string $candidateJson, ?bool $matched): void + { + $actual = json_decode($actualJson, false, 512, JSON_THROW_ON_ERROR); + $candidate = json_decode($candidateJson, false, 512, JSON_THROW_ON_ERROR); + $context = ['profile' => (object) ['values' => $actual], 'never' => false]; + $regex = static fn() => 'unused'; + foreach (['includesAny', 'notIncludesAny'] as $operator) { + $leaf = ['attribute' => 'profile.values', 'operator' => $operator, 'value' => $candidate]; + $expected = $matched !== null && ($operator === 'includesAny' ? $matched : !$matched); + $expressions = [ + [$leaf, $expected], + [['and' => [$leaf, ['or' => [$leaf]]]], $expected], + [['or' => [['attribute' => 'never', 'operator' => 'equals', 'value' => true], ['and' => [$leaf]]]], $expected], + [['not' => [['or' => [$leaf]]]], !$expected], + ]; + foreach ($expressions as [$expression, $result]) { + $json = json_encode($expression, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + $parsed = Conditions::parseConditionsIfStringified($json); + self::assertSame($json, json_encode($parsed, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)); + self::assertSame($result, Conditions::allConditionsAreMatched($parsed, $context, $regex), $operator.' '.$json); + $segment = ['conditions' => $json]; + $getSegment = static fn(string $key) => $key === 'membership' ? $segment : null; + foreach (['membership', ['and' => [['or' => ['membership']]]]] as $selection) { + self::assertSame($result, Conditions::allSegmentsAreMatched($selection, $context, $getSegment, $regex), $operator.' '.$json); + } + $datafile = ['schemaVersion' => '2', 'revision' => 'stringified', 'segments' => ['membership' => $segment], + 'features' => [ + 'segment' => ['bucketBy' => 'userId', 'traffic' => [['key' => 'all', 'segments' => ['and' => [['or' => ['membership']]]], 'percentage' => 100000]]], + 'force' => ['bucketBy' => 'userId', 'traffic' => [], 'force' => [['conditions' => $json, 'enabled' => true]]], + ]]; + foreach ([$datafile, json_encode($datafile, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)] as $input) { + $sdk = Featurevisor::createFeaturevisor(['datafile' => $input, 'logLevel' => 'fatal']); + try { + for ($attempt = 0; $attempt < 2; $attempt++) { + self::assertSame($result, $sdk->isEnabled('segment', $context)); + self::assertSame($result, $sdk->isEnabled('force', $context)); + self::assertSame($result, $sdk->evaluateFlag('segment', $context)['enabled'] ?? false); + } + } finally { + $sdk->close(); + } + } + } + } + } + + public function testParserRetainsExistingScalarAndMetadataShapesAndDiagnostics(): void + { + foreach (['null', 'true', 'false', '0', '1', '1.0', '1e30', '"text"', '[]', '{}', '{"and":{}}'] as $json) { + self::assertSame(json_decode($json, true, 512, JSON_THROW_ON_ERROR), Conditions::parseConditionsIfStringified($json)); + } + foreach ([1, 1.0, 1e30, true, false, null, 'text', []] as $value) { + $condition = ['attribute' => 'value', 'operator' => 'equals', 'value' => $value, 'regexFlags' => 'g', 'extra' => ['nested' => []]]; + $json = json_encode($condition, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + $parsed = Conditions::parseConditionsIfStringified($json); + self::assertSame($condition, $parsed); + self::assertSame($condition, Conditions::parseConditionsIfStringified($condition)); + $sdk = Featurevisor::createFeaturevisor(['datafile' => ['schemaVersion' => '2', 'revision' => 'scalars', 'segments' => [], + 'features' => ['subject' => ['bucketBy' => 'userId', 'traffic' => [], 'force' => [['conditions' => $json, 'enabled' => true]]]]], 'logLevel' => 'fatal']); + try { + self::assertSame(!is_array($value), $sdk->isEnabled('subject', ['value' => $value])); + } finally { + $sdk->close(); + } + } + $diagnostics = []; + self::assertSame('{bad', Conditions::parseConditionsIfStringified('{bad', static function ($diagnostic) use (&$diagnostics): void { $diagnostics[] = $diagnostic; })); + self::assertCount(1, $diagnostics); + self::assertSame('conditions_parse_error', $diagnostics[0]['code']); + self::assertSame(['conditions' => '{bad'], $diagnostics[0]['details']); + self::assertSame('*', Conditions::parseConditionsIfStringified('*')); + } + + public function testNumericEqualityAndMembershipPreserveParsedNumberTypes(): void + { + foreach ([['1', '1.0', true], ['1.0', '1', true], ['1e30', '1e30', true], ['true', '1', false]] as [$left, $right, $expected]) { + foreach (['equals', 'includes', 'includesAny'] as $operator) { + $actualJson = $operator === 'equals' ? $left : '['.$left.']'; + $valueJson = $operator === 'includesAny' ? '['.$right.']' : $right; + $json = '{"attribute":"value","operator":"'.$operator.'","value":'.$valueJson.'}'; + $parsed = Conditions::parseConditionsIfStringified($json); + self::assertSame(json_decode($valueJson, false, 512, JSON_THROW_ON_ERROR), $parsed['value']); + $context = ['value' => json_decode($actualJson, false, 512, JSON_THROW_ON_ERROR)]; + self::assertSame($expected, Conditions::allConditionsAreMatched($parsed, $context, static fn() => 'unused')); + $sdk = Featurevisor::createFeaturevisor(['datafile' => ['schemaVersion' => '2', 'revision' => 'numbers', + 'segments' => ['numbers' => ['conditions' => $json]], 'features' => ['subject' => ['bucketBy' => 'userId', + 'traffic' => [['key' => 'all', 'segments' => 'numbers', 'percentage' => 100000]]]]], 'logLevel' => 'fatal']); + try { + self::assertSame($expected, $sdk->isEnabled('subject', $context)); + } finally { + $sdk->close(); + } + } + } + } +} diff --git a/tests/V4AlignmentTest.php b/tests/V4AlignmentTest.php new file mode 100644 index 0000000..0f22aa4 --- /dev/null +++ b/tests/V4AlignmentTest.php @@ -0,0 +1,417 @@ + '4', 'revision' => 'alignment', 'features' => [], 'segments' => [], 'variables' => []], $values); + } + + private function feature(array $values = []): array + { + return array_replace(['bucketBy' => 'userId', 'traffic' => [['key' => 'all', 'conditions' => '*', 'percentage' => 100000]]], $values); + } + + public function testParseFailuresAtEverySelectorSiteHaveExactTimingAndDetails(): void + { + foreach (['2', '4'] as $schema) foreach (['{bad', '[{"feature":', '"unterminated', " \n {bad"] as $raw) { + foreach (['feature', 'variable', 'segment', 'force', 'traffic', 'ruleOverride', 'variationOverride', 'overrides', 'overrideProgram'] as $site) { + $d = $this->datafile(['schemaVersion' => $schema, 'features' => ['healthy' => $this->feature()], + 'variables' => ['healthy' => ['type' => 'integer', 'defaultValue' => 9]]]); + $subject = $this->feature(['variablesSchema' => ['copy' => ['type' => 'string', 'defaultValue' => 'fallback']]]); + $kind = 'feature'; + $key = 'subject'; + $expected = 'fallback'; + $read = static fn($f) => $f->getVariable('subject', 'copy'); + if ($site === 'feature') { + $subject['conditions'] = $raw; + $read = static fn($f) => $f->evaluateFlag('subject')['reason']; + $expected = 'conditions_unmet'; + } elseif ($site === 'segment') { + $kind = 'segment'; $key = 'broken'; + $d['segments'] = ['broken' => ['conditions' => $raw], 'alias' => ['conditions' => ['not' => ['broken']]]]; + $subject['conditions'] = ['not' => ['alias']]; + $read = static fn($f) => $f->evaluateFlag('subject')['reason']; + $expected = 'conditions_unmet'; + } elseif (in_array($site, ['variable', 'overrides', 'overrideProgram'], true)) { + $kind = 'variable'; + $variable = ['type' => 'string', 'defaultValue' => 'fallback', 'disabledValue' => 'disabled']; + if ($site === 'variable') { $variable['conditions'] = $raw; $expected = 'disabled'; } + elseif ($site === 'overrides') $variable['overrides'] = [['conditions' => $raw, 'value' => 'bad']]; + else $variable['overrideProgram'] = [['key' => 'bad', 'conditions' => $raw, + 'operations' => [['op' => 'set', 'path' => [], 'value' => 'bad']], 'onMatch' => 1, 'onMiss' => 1]]; + $d['variables']['subject'] = $variable; + $read = static fn($f) => $f->getVariable('subject'); + } elseif ($site === 'force') $subject['force'] = [['conditions' => $raw, 'variables' => ['copy' => 'bad']]]; + elseif ($site === 'traffic') array_unshift($subject['traffic'], ['key' => 'bad', 'conditions' => $raw, 'percentage' => 100000, 'variables' => ['copy' => 'bad']]); + elseif ($site === 'ruleOverride') $subject['traffic'][0]['variableOverrides'] = ['copy' => [['conditions' => $raw, 'value' => 'bad']]]; + else { + $subject['variations'] = [['value' => 'on', 'variableOverrides' => ['copy' => [['conditions' => $raw, 'value' => 'bad']]]]]; + $subject['traffic'][0]['variation'] = 'on'; + } + $d['features']['subject'] = $subject; + $before = serialize($d); + $diagnostics = []; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'error', 'onDiagnostic' => static function ($item) use (&$diagnostics): void { $diagnostics[] = $item; }]); + try { + self::assertTrue($f->setDatafile($d, true)); + $errors = static function () use (&$diagnostics): array { return array_values(array_filter($diagnostics, static fn($item) => $item['code'] === 'conditions_parse_error')); }; + self::assertCount($schema === '2' ? 1 : 0, $errors(), $site); + self::assertTrue($f->isEnabled('healthy')); + self::assertSame(9, $f->getVariable('healthy')); + for ($run = 0; $run < 2; $run++) self::assertSame($expected, $read($f), $site); + self::assertCount(1, $errors(), $site); + self::assertSame(['conditions' => $raw, 'entityType' => $kind, 'entityKey' => $key], $errors()[0]['details']); + $f->setDatafile($this->datafile(['revision' => 'unrelated'])); + self::assertSame($expected, $read($f)); + self::assertCount(1, $errors()); + $f->setDatafile($d, true); + self::assertSame($expected, $read($f)); + self::assertCount(2, $errors()); + self::assertSame($before, serialize($d)); + } finally { $f->close(); } + } + } + } + + public function testEagerStorageAndLazyCachePrecedeReentrantDiagnosticCallbacks(): void + { + foreach (['2', '4'] as $schema) foreach (['feature', 'segment', 'variable'] as $kind) { + $f = null; + $observed = []; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'error', 'onDiagnostic' => static function ($item) use (&$f, &$observed, $kind): void { + if ($item['code'] !== 'conditions_parse_error') return; + $evaluation = $kind === 'variable' ? $f->evaluateVariable('bad') : $f->evaluateFlag('bad'); + $observed[] = [$f->getRevision(), $evaluation['reason']]; + }]); + $d = $this->datafile(['schemaVersion' => $schema, 'revision' => 'accepted']); + if ($kind === 'feature') $d['features']['bad'] = $this->feature(['conditions' => '{']); + elseif ($kind === 'segment') { + $d['segments']['bad'] = ['conditions' => '{']; + $d['features']['bad'] = $this->feature(['conditions' => ['not' => ['bad']]]); + } else $d['variables']['bad'] = ['type' => 'object', 'conditions' => '{']; + try { + self::assertTrue($f->setDatafile($d)); + $evaluation = $kind === 'variable' ? $f->evaluateVariable('bad') : $f->evaluateFlag('bad'); + self::assertSame('conditions_unmet', $evaluation['reason']); + self::assertSame([['accepted', 'conditions_unmet']], $observed); + } finally { $f->close(); } + } + } + + public function testMixedVersionsPublicConditionShapesAndIgnoredFields(): void + { + $country = ['attribute' => 'country', 'operator' => 'equals', 'value' => 'nl']; + $d = $this->datafile(['schemaVersion' => '2', 'segments' => ['empty' => ['conditions' => ['and' => []]]], + 'features' => ['legacy' => $this->feature(['force' => [['conditions' => [], 'enabled' => true]]]), + 'root' => $this->feature(['conditions' => []]), 'segment' => $this->feature(['conditions' => 'empty'])]]); + $f = Featurevisor::createFeaturevisor(['datafile' => $d, 'logLevel' => 'fatal']); + try { + $f->setDatafile($this->datafile()); + self::assertTrue($f->isEnabled('legacy')); + self::assertTrue($f->isEnabled('segment')); + self::assertFalse($f->isEnabled('root')); + $f->setDatafile($this->datafile(['features' => [ + 'mixed' => $this->feature(['conditions' => [$country], 'segments' => ['missing'], 'requiredFeatures' => ['gate']]), + 'empty' => $this->feature(['conditions' => [], 'segments' => '*']), + 'broken' => $this->feature(['conditions' => '{']), + 'ignored' => $this->feature(['required' => ['missing'], 'requiredVariables' => [['variable' => 'missing', 'operator' => 'notNull']]]), + ]])); + self::assertSame([$country, 'missing', ['feature' => 'gate']], $f->evaluateFlag('mixed')['conditions']); + self::assertSame([['and' => []], '*'], $f->evaluateFlag('empty')['conditions']); + self::assertSame(['or' => []], $f->evaluateFlag('broken')['conditions']); + self::assertTrue($f->isEnabled('ignored')); + self::assertSame('4', $f->getSchemaVersion()); + } finally { $f->close(); } + } + + public function testMemoSeparatesPurposesAndBorrowsCompositionValuesWithoutCopying(): void + { + $source = (object) ['items' => range(0, 10000)]; + $session = new EvaluationSession(); + $reads = 0; + $session->run(['type' => 'variable', 'variableKey' => 'root'], false, function () use ($session, $source, &$reads): array { + $options = ['type' => 'variable', 'variableKey' => 'source', 'context' => [], 'dependencyPurpose' => 'composition']; + $evaluate = static function () use ($source, &$reads): array { $reads++; return ['type' => 'variable', 'variableValue' => $source]; }; + self::assertSame($source, $session->run($options, true, $evaluate)['variableValue']); + self::assertSame($source, $session->run($options, true, $evaluate)['variableValue']); + $options['dependencyPurpose'] = 'condition'; + $session->run($options, true, $evaluate); + self::assertNotSame($source, $session->run($options, true, $evaluate)['variableValue']); + $getterReads = 0; + $getValue = static function () use ($source, &$getterReads) { $getterReads++; return $source; }; + $options['dependencyPurpose'] = 'composition'; + self::assertSame($source, $session->memoizeGetter($options, $getValue)); + self::assertSame($source, $session->memoizeGetter($options, $getValue)); + $options['dependencyPurpose'] = 'condition'; + self::assertSame($source, $session->memoizeGetter($options, $getValue)); + $snapshot = $session->memoizeGetter($options, $getValue); + self::assertNotSame($source, $snapshot); + $snapshot->items[] = 'changed'; + self::assertCount(10001, $session->memoizeGetter($options, $getValue)->items); + self::assertCount(10001, $source->items); + self::assertSame(2, $getterReads); + return ['type' => 'variable']; + }); + self::assertSame(2, $reads); + } + + public function testInternalFlagKeepsOriginalVariableAndVariationRequester(): void + { + $seen = []; + $subject = $this->feature(['conditions' => [['feature' => 'gate'], ['variable' => 'source', 'operator' => 'notNull']], + 'variablesSchema' => ['value' => ['type' => 'string', 'defaultValue' => 'present']], + 'variations' => [['value' => 'on']]]); + $subject['traffic'][0]['variation'] = 'on'; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $this->datafile([ + 'features' => ['subject' => $subject, 'gate' => $this->feature()], + 'variables' => ['source' => ['type' => 'string', 'defaultValue' => 'source']], + ]), 'modules' => [['beforeEvaluation' => static function ($options) use (&$seen): array { + $seen[] = array_intersect_key($options, array_flip(['type', 'featureKey', 'variableKey', 'dependencyOf', 'dependencyPurpose'])); + return $options; + }]]]); + try { + foreach (['variable', 'variation'] as $type) { + $requester = ['type' => $type, 'featureKey' => 'subject']; + if ($type === 'variable') $requester['variableKey'] = 'value'; + for ($run = 0; $run < 2; $run++) { + $seen = []; + self::assertSame($type === 'variable' ? 'present' : 'on', + $type === 'variable' ? $f->getVariable('subject', 'value') : $f->getVariation('subject')); + self::assertEquals([ + $requester, + ['type' => 'flag', 'featureKey' => 'gate', 'dependencyOf' => $requester, 'dependencyPurpose' => 'condition'], + ['type' => 'variable', 'variableKey' => 'source', 'dependencyOf' => $requester, 'dependencyPurpose' => 'condition'], + ], $seen); + } + } + } finally { $f->close(); } + } + + public function testConditionMemoStoresOrdinaryJsonGetterFallbackAndSeparatesComposition(): void + { + foreach ([false, true] as $featureSource) foreach ([false, true] as $composition) { + $seen = []; + $diagnostics = []; + $source = ['type' => 'json', 'defaultValue' => '{oops']; + $leaf = ['variable' => $featureSource ? 'value' : 'source', 'operator' => 'isNull']; + if ($featureSource) $leaf['feature'] = 'source'; + $member = ['variable' => $leaf['variable']]; + if ($featureSource) $member['feature'] = 'source'; + $subject = ['type' => 'object', 'conditions' => [$leaf, $leaf], 'defaultValue' => 'matched']; + if ($composition) $subject['compose'] = [array_merge(['key' => 'a'], $member), array_merge(['key' => 'b'], $member)]; + $d = $this->datafile(['variables' => ['subject' => $subject]]); + if ($featureSource) $d['features']['source'] = $this->feature(['variablesSchema' => ['value' => $source]]); + else $d['variables']['source'] = $source; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'error', 'datafile' => $d, + 'onDiagnostic' => static function ($item) use (&$diagnostics): void { + if ($item['message'] === 'getVariable failed') $diagnostics[] = $item; + }, 'modules' => [['beforeEvaluation' => static function ($options) use (&$seen): array { + $seen[] = array_intersect_key($options, array_flip(['type', 'featureKey', 'variableKey', 'dependencyOf', 'dependencyPurpose'])); + return $options; + }]]]); + try { + for ($run = 1; $run <= 2; $run++) { + $seen = []; + self::assertSame($composition ? ['a' => null, 'b' => null] : 'matched', $f->getVariable('subject')); + self::assertCount($run * ($composition ? 2 : 1), $diagnostics); + $identity = ['type' => 'variable']; + if ($featureSource) $identity['featureKey'] = 'source'; + $identity['variableKey'] = $leaf['variable']; + $expected = [['type' => 'variable', 'variableKey' => 'subject'], array_merge($identity, [ + 'dependencyOf' => ['type' => 'variable', 'variableKey' => 'subject'], 'dependencyPurpose' => 'condition'])]; + if ($composition) $expected[] = array_merge($identity, [ + 'dependencyOf' => ['type' => 'variable', 'variableKey' => 'subject'], 'dependencyPurpose' => 'composition']); + self::assertEquals($expected, $seen); + } + } finally { $f->close(); } + } + } + + public function testMemoizedJsonStringsDecodeOnceAndNotCannotInvertComparisonErrors(): void + { + $leaf = ['variable' => 'source', 'operator' => 'equals', 'value' => 'null']; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $this->datafile(['variables' => [ + 'source' => ['type' => 'json', 'defaultValue' => '"null"'], + 'alias' => ['type' => 'json', 'compose' => 'source'], + 'subject' => ['type' => 'object', 'conditions' => [$leaf, $leaf], 'compose' => [ + ['key' => 'a', 'variable' => 'alias'], ['key' => 'b', 'variable' => 'alias']]], + 'unsafe' => ['type' => 'string', 'conditions' => ['not' => [[ + 'variable' => 'source', 'path' => ['__proto__'], 'operator' => 'isNull']]], 'defaultValue' => 'wrong'], + 'badRegex' => ['type' => 'string', 'conditions' => ['not' => [[ + 'variable' => 'source', 'operator' => 'matches', 'value' => '[']]], 'defaultValue' => 'wrong'], + ]])]); + try { + for ($run = 0; $run < 2; $run++) { + self::assertSame(['a' => 'null', 'b' => 'null'], $f->getVariable('subject')); + self::assertSame('error', $f->evaluateVariable('unsafe')['reason']); + self::assertSame('error', $f->evaluateVariable('badRegex')['reason']); + } + } finally { $f->close(); } + } + + public function testCompositionRetainsRuntimeContainersJsonOnceAndImmutability(): void + { + $source = (object) ['0' => (object) [], 'items' => [(object) [], [], null]]; + $d = $this->datafile(['variables' => [ + 'source' => ['type' => 'object', 'defaultValue' => $source], + 'text' => ['type' => 'json', 'defaultValue' => '"null"'], + 'alias' => ['type' => 'json', 'compose' => 'text'], + 'root' => ['type' => 'object', 'compose' => [['key' => 'a', 'variable' => 'source'], ['key' => 'b', 'variable' => 'source'], + ['key' => 'text1', 'variable' => 'alias'], ['key' => 'text2', 'variable' => 'alias']], + 'overrideProgram' => [['key' => 'add', 'conditions' => '*', + 'operations' => [['op' => 'append', 'path' => ['a', 'items'], 'value' => 'extra']], 'onMatch' => 1, 'onMiss' => 1]]], + ]]); + $before = serialize($d); + $f = Featurevisor::createFeaturevisor(['datafile' => $d, 'logLevel' => 'fatal']); + try { + for ($run = 0; $run < 2; $run++) { + $evaluation = $f->evaluateVariable('root'); + $value = $evaluation['variableValue']; + self::assertInstanceOf(\stdClass::class, $value); + self::assertInstanceOf(\stdClass::class, $value->a->{'0'}); + self::assertInstanceOf(\stdClass::class, $value->a->items[0]); + self::assertSame([], $value->a->items[1]); + self::assertSame('extra', $value->a->items[3]); + self::assertCount(3, $value->b->items); + self::assertSame('null', $value->text1); + self::assertSame('null', $value->text2); + self::assertSame('variable_override_rule', $evaluation['reason']); + } + self::assertSame($before, serialize($d)); + } finally { $f->close(); } + } + + public function testCompositionUsesEffectiveChildStateAndImmediateRequester(): void + { + $seen = []; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'context' => ['stored' => true], + 'datafile' => $this->datafile(['variables' => ['source' => ['type' => 'integer', 'defaultValue' => 1], + 'root' => ['type' => 'object', 'compose' => [['key' => 'a', 'variable' => 'source'], ['key' => 'b', 'variable' => 'source']]]]]), + 'modules' => [['beforeEvaluation' => static function ($options) use (&$seen): array { + if (!isset($options['dependencyOf'])) return array_merge($options, ['context' => ['country' => 'effective']]); + $seen[] = [$options['context'], $options['dependencyPurpose'], $options['dependencyOf']]; + self::assertSame([], array_filter(array_keys($options), static fn($key) => str_starts_with($key, '__featurevisor'))); + return $options; + }]]]); + $child = $f->spawn(['country' => 'nl'], ['stickyVariables' => ['source' => 2]]); + try { + self::assertSame(['a' => 2, 'b' => 2], $child->getVariable('root', [], ['defaultVariableValue' => 'outer'])); + self::assertSame([[['country' => 'effective'], 'composition', ['type' => 'variable', 'variableKey' => 'root']]], $seen); + $seen = []; + $child->setStickyVariables(['root' => ['whole' => true]]); + self::assertSame(['whole' => true], $child->getVariable('root')); + self::assertSame([], $seen); + } finally { $child->close(); $f->close(); } + } + + public function testCombinedDepthLimitCyclesAndIndependentRecovery(): void + { + foreach ([256, 257] as $depth) { + $variables = []; + for ($i = 0; $i < $depth; $i++) $variables['v'.$i] = ['type' => 'integer', 'compose' => 'v'.($i + 1)]; + $variables['v'.$depth] = ['type' => 'integer', 'defaultValue' => 7]; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $this->datafile(['variables' => $variables])]); + try { + self::assertSame($depth === 256 ? 'variable_composed' : 'error', $f->evaluateVariable('v0')['reason']); + self::assertSame($depth === 256 ? 7 : null, $f->getVariable('v0')); + self::assertSame(7, $f->getVariable('v'.$depth)); + } finally { $f->close(); } + } + $segments = []; + for ($i = 0; $i < 200; $i++) $segments['s'.$i] = ['conditions' => 's'.($i + 1)]; + $segments['s200'] = ['conditions' => ['variable' => 'v0', 'operator' => 'notNull']]; + $f = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $this->datafile(['variables' => $variables, + 'segments' => $segments, 'features' => ['subject' => $this->feature(['conditions' => 's0'])]])]); + try { + self::assertSame('error', $f->evaluateFlag('subject')['reason']); + $f->setDatafile($this->datafile(['segments' => ['s200' => ['conditions' => '*']]])); + self::assertTrue($f->isEnabled('subject')); + $f->setDatafile($this->datafile(['segments' => ['s200' => ['conditions' => ['not' => [['feature' => 'subject']]]]]])); + self::assertSame('error', $f->evaluateFlag('subject')['reason']); + } finally { $f->close(); } + } + + public function testDependencyEventsReadRawSelectorsWithoutWarmingCaches(): void + { + $diagnostics = []; + $d = $this->datafile(['segments' => ['source' => ['conditions' => '{"variable":"source","operator":"notNull"}'], + 'alias' => ['conditions' => 'source'], 'bad' => ['conditions' => '{']], + 'features' => ['gate' => $this->feature(['hash' => 'gate', 'conditions' => 'alias'])], + 'variables' => ['source' => ['type' => 'integer', 'defaultValue' => 1, 'hash' => 'one'], + 'map' => ['type' => 'object', 'compose' => [['key' => 'enabled', 'feature' => 'gate']], 'hash' => 'same'], + 'alias' => ['type' => 'object', 'compose' => 'map', 'hash' => 'same']]]); + $f = Featurevisor::createFeaturevisor(['datafile' => $d, 'logLevel' => 'error', 'onDiagnostic' => static function ($item) use (&$diagnostics): void { $diagnostics[] = $item; }]); + $events = []; + $f->on('datafile_set', static function ($event) use (&$events): void { $events[] = $event; }); + try { + $f->setDatafile($this->datafile(['variables' => ['source' => ['type' => 'integer', 'defaultValue' => 2, 'hash' => 'two']]])); + self::assertEqualsCanonicalizing(['source', 'map', 'alias'], $events[0]['variables']); + self::assertSame(['gate'], $events[0]['features']); + $cache = new \ReflectionProperty($f, 'entityCache'); $cache->setAccessible(true); + self::assertSame([], $cache->getValue($f)); + self::assertSame([], $diagnostics); + unset($d['variables']['source']); + $f->setDatafile($d, true); + self::assertContains('alias', $events[1]['variables']); + self::assertContains('gate', $events[1]['features']); + } finally { $f->close(); } + } + + public function testSegmentOwnersAndChecksUseSdkStateAndPreserveTargetSpecialization(): void + { + $segments = ['audience' => ['conditions' => [['variable' => 'limit', 'path' => ['value'], 'operator' => 'equals', 'value' => 50], ['feature' => 'gate']]]]; + $d = $this->datafile(['features' => ['gate' => $this->feature(['traffic' => [['key' => 'limited', 'conditions' => '*', 'percentage' => 25000]]])], + 'variables' => ['limit' => ['type' => 'object', 'defaultValue' => (object) ['value' => 50]]]]); + $target = array_replace($d, ['segments' => ['audience' => ['conditions' => ['attribute' => 'country', 'operator' => 'equals', 'value' => 'de']]]]); + $datafiles = [\getBaseDatafileKey('production') => $d, \getTargetDatafileKey('production', 'web') => $target]; + $test = ['key' => 'audience', 'segment' => 'audience', '_segments' => $segments]; + $options = \parseCliArguments(['featurevisor', 'test', '--json', '--coverage']); + $coverage = []; + foreach ([['environment' => 'production', 'at' => 0, 'expectedToMatch' => true], + ['environment' => 'production', 'at' => 50, 'expectedToMatch' => false], + ['environment' => 'production', 'target' => 'web', 'context' => ['country' => 'nl'], 'expectedToMatch' => false], + ['environment' => 'production', 'stickyFeatures' => ['gate' => ['enabled' => false]], 'expectedToMatch' => false]] as $assertion) { + $result = \executeTestAssertion($test, $assertion, $datafiles, [], $options, null, $coverage); + self::assertFalse($result['hasError'], json_encode($result['errors'])); + } + $check = ['scenario' => 'audience', '_segments' => $segments]; + $result = \executeTestAssertion($check, ['environment' => 'production', 'target' => 'web', 'context' => ['country' => 'nl'], + 'checks' => [['segment' => 'audience', 'matches' => false]]], $datafiles, [], $options, null, $coverage); + self::assertFalse($result['hasError'], json_encode($result['errors'])); + foreach ($coverage as $selection) self::assertFalse(str_starts_with($selection['featureKey'] ?? '', '__featurevisor_segment_test__')); + $result = \executeTestAssertion($test, ['expectedToMatch' => false], $datafiles, [], $options, null, $coverage); + self::assertTrue($result['hasError']); + self::assertStringContainsString('limit', $result['errors'][0]['message']); + } + + public function testPreflightUsesProtocolReferencesAndTraversesFeatureAndCompositionDependencies(): void + { + $d = $this->datafile(['segments' => ['audience' => ['conditions' => '*']], + 'features' => ['gate' => $this->feature(['variablesSchema' => ['copy' => ['type' => 'string', 'defaultValue' => 'ok']]])], + 'variables' => ['alias' => ['type' => 'object', 'compose' => 'missing']]]); + $segments = ['audience' => ['conditions' => ['variable' => 'alias', 'operator' => 'isNull']], + 'flag' => ['conditions' => ['feature' => 'gate']], 'copy' => ['conditions' => ['feature' => 'gate', 'variable' => 'copy', 'operator' => 'notNull']]]; + try { + \FeaturevisorCLI\Segments::preflight('audience', $d, $segments); + self::fail('Specialized Target conditions hid a transitive authored reference'); + } catch (\RuntimeException $error) { self::assertStringContainsString('missing', $error->getMessage()); } + $d['features']['gate']['traffic'][0]['variableOverrides']['copy'] = [['conditions' => ['variable' => 'missing', 'operator' => 'notNull'], 'value' => 'bad']]; + \FeaturevisorCLI\Segments::preflight('flag', $d, $segments); + $this->expectExceptionMessage('missing'); + \FeaturevisorCLI\Segments::preflight('copy', $d, $segments); + } +} diff --git a/tests/V4CapabilitiesTest.php b/tests/V4CapabilitiesTest.php new file mode 100644 index 0000000..7d371de --- /dev/null +++ b/tests/V4CapabilitiesTest.php @@ -0,0 +1,134 @@ + 'fatal', 'datafile' => '{"schemaVersion":"4","revision":"mutation","features":{},"segments":{},"variables":{"nil":{"type":"boolean","defaultValue":null},"object":{"type":"object","defaultValue":{}}}}']); + try { + foreach ([['nil', false], ['nil', 0], ['object', []]] as [$key, $mutated]) { + foreach ([$f->getVariable($key, [], ['__featurevisorPreserveJsonObjects' => true]), $f->evaluateVariable($key)['variableValue']] as $actual) { + self::assertJsonSame($key === 'nil' ? null : new \stdClass(), $actual); + $rejected = false; + try { self::assertJsonSame($mutated, $actual); } + catch (\PHPUnit\Framework\AssertionFailedError $error) { $rejected = true; } + self::assertTrue($rejected, 'adapter accepted mutated '.$key); + } + } + self::assertJsonSame((object) ['a' => 1.0, 'b' => false], (object) ['b' => false, 'a' => 1]); + } finally { $f->close(); } + } + + public static function assertJsonSame($expected, $actual, string $label = ''): void + { + self::assertSame(self::normalizedJson($expected), self::normalizedJson($actual), $label); + } + + private static function normalizedJson($value) + { + $value = json_decode(json_encode($value, JSON_THROW_ON_ERROR), false, 512, JSON_THROW_ON_ERROR); + if ($value instanceof \stdClass) { + $entries = get_object_vars($value); + ksort($entries); + return ['object', array_map([self::class, 'normalizedJson'], $entries)]; + } + if (is_array($value)) return ['list', array_map([self::class, 'normalizedJson'], $value)]; + return $value; + } + + public static function options(\stdClass $options): array + { + $result = (array) $options; + foreach (['context', 'stickyFeatures', 'stickyVariables'] as $key) { + if (isset($result[$key])) $result[$key] = (array) $result[$key]; + } + foreach ($result['stickyFeatures'] ?? [] as $key => $feature) { + $result['stickyFeatures'][$key] = (array) $feature; + if (isset($feature->variables)) $result['stickyFeatures'][$key]['variables'] = (array) $feature->variables; + } + return $result; + } + + public function testEveryConditionsCaseTwice(): void + { + $fixture = json_decode(file_get_contents(__DIR__.'/../conformance/sdk-conditions.json'), false, 512, JSON_THROW_ON_ERROR); + self::assertSame(1, $fixture->version); + self::assertSame('unified-conditions', $fixture->capability); + self::assertNotEmpty($fixture->cases); + $executed = 0; + foreach ($fixture->cases as $rawCase) { + $case = (array) $rawCase; + $case['context'] = (array) ($rawCase->context ?? new \stdClass()); + $case['expected'] = (array) $rawCase->expected; + $dependencies = []; + $before = static function (array $options) use (&$dependencies): array { + if (isset($options['dependencyPurpose'])) { + $entry = []; + if (isset($options['featureKey'])) $entry['feature'] = $options['featureKey']; + if (isset($options['variableKey'])) $entry['variable'] = $options['variableKey']; + $entry['dependencyPurpose'] = $options['dependencyPurpose']; + $dependencies[] = $entry; + } + return $options; + }; + $original = serialize($case); + $f = Featurevisor::createFeaturevisor(array_merge(['datafile' => json_encode($case['datafile'], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION), + 'logLevel' => 'fatal'], self::options($rawCase->options ?? new \stdClass()), ['modules' => [['beforeEvaluation' => $before]]])); + try { + for ($run = 0; $run < 2; $run++) { + $dependencies = []; + $evaluation = $case['type'] === 'flag' ? $f->evaluateFlag($case['feature'], $case['context']) + : $f->evaluateVariable($case['variable'], $case['context']); + foreach ($case['expected'] as $key => $value) { + if ($key !== 'value') { + self::assertArrayHasKey($key, $evaluation, $case['name'].' '.$key); + self::assertJsonSame($value, $evaluation[$key], $case['name'].' '.$key); + } + } + if (isset($case['dependencies'])) self::assertJsonSame($case['dependencies'], $dependencies, $case['name']); + if (array_key_exists('value', $case['expected'])) self::assertJsonSame($case['expected']['value'], $f->getVariable($case['variable'], $case['context'], ['__featurevisorPreserveJsonObjects' => true]), $case['name']); + if ($case['type'] === 'flag') self::assertSame(($case['expected']['enabled'] ?? false) === true, $f->isEnabled($case['feature'], $case['context']), $case['name']); + $executed++; + } + self::assertSame($original, serialize($case)); + } finally { $f->close(); } + } + self::assertSame(count($fixture->cases) * 2, $executed); + } + + public function testEveryCompositionCaseTwice(): void + { + $fixture = json_decode(file_get_contents(__DIR__.'/../conformance/sdk-composition.json'), false, 512, JSON_THROW_ON_ERROR); + self::assertSame(2, $fixture->version); + self::assertSame('composition-and-overrides', $fixture->capability); + self::assertNotEmpty($fixture->cases); + $executed = 0; + foreach ($fixture->cases as $rawCase) { + $case = (array) $rawCase; + $case['context'] = (array) ($rawCase->context ?? new \stdClass()); + $f = Featurevisor::createFeaturevisor(array_merge(self::options((object) [ + 'stickyFeatures' => $rawCase->stickyFeatures ?? new \stdClass(), + 'stickyVariables' => $rawCase->stickyVariables ?? new \stdClass(), + ]), ['datafile' => json_encode($fixture->datafile, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION), 'logLevel' => 'fatal'])); + try { + for ($run = 0; $run < 2; $run++) { + self::assertJsonSame($case['expectedValue'], $f->getVariable($case['variable'], $case['context'], ['__featurevisorPreserveJsonObjects' => true]), $case['name']); + $evaluation = $f->evaluateVariable($case['variable'], $case['context']); + foreach ($case['expectedEvaluation'] as $key => $value) { + self::assertArrayHasKey($key, $evaluation, $case['name'].' '.$key); + self::assertJsonSame($value, $evaluation[$key], $case['name'].' '.$key); + } + $executed++; + } + self::assertJsonSame(property_exists($rawCase->stickyVariables ?? new \stdClass(), 'policy') && $rawCase->stickyVariables->policy === null + ? null : (object) ['limit' => 50, 'methods' => ['card']], $f->getVariable('policy')); + } finally { $f->close(); } + } + self::assertSame(count($fixture->cases) * 2, $executed); + } +} diff --git a/tests/V4RuntimeTest.php b/tests/V4RuntimeTest.php new file mode 100644 index 0000000..200a26d --- /dev/null +++ b/tests/V4RuntimeTest.php @@ -0,0 +1,713 @@ + $item) $result[$key] = $this->normalizeValue($item); + return $result; + } + if (is_array($value)) return array_map([$this, 'normalizeValue'], $value); + return $value; + } + + public function testEmptyOperatorsWorkForConditionsAndRequiredVariables(): void + { + $datafile = json_encode([ + 'schemaVersion' => '2', 'revision' => 'empty-operators', 'segments' => (object) [], + 'features' => [ + 'emptyContext' => [ + 'bucketBy' => 'userId', 'traffic' => [], + 'force' => [[ + 'conditions' => [['attribute' => 'profile.items', 'operator' => 'empty']], + 'enabled' => true, + ]], + ], + 'nonemptyContext' => [ + 'bucketBy' => 'userId', 'traffic' => [], + 'force' => [[ + 'conditions' => [['attribute' => 'profile.items', 'operator' => 'notEmpty']], + 'enabled' => true, + ]], + ], + ], + 'variables' => [ + 'emptyArray' => ['type' => 'array', 'defaultValue' => []], + 'emptyObject' => ['type' => 'object', 'defaultValue' => (object) []], + 'nonemptyObject' => ['type' => 'object', 'defaultValue' => (object) ['value' => null]], + 'nullValue' => ['type' => 'json', 'defaultValue' => null], + 'emptyArrayGate' => [ + 'type' => 'boolean', 'defaultValue' => true, 'disabledValue' => false, + 'conditions' => [['variable' => 'emptyArray', 'operator' => 'empty']], + ], + 'emptyObjectGate' => [ + 'type' => 'boolean', 'defaultValue' => true, 'disabledValue' => false, + 'conditions' => [['variable' => 'emptyObject', 'operator' => 'empty']], + ], + 'nonemptyObjectGate' => [ + 'type' => 'boolean', 'defaultValue' => true, 'disabledValue' => false, + 'conditions' => [['variable' => 'nonemptyObject', 'operator' => 'notEmpty']], + ], + 'nullGate' => [ + 'type' => 'boolean', 'defaultValue' => true, 'disabledValue' => false, + 'conditions' => [['variable' => 'nullValue', 'operator' => 'notEmpty']], + ], + ], + ], JSON_THROW_ON_ERROR); + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); + + self::assertTrue($f->isEnabled('emptyContext', ['profile' => ['items' => []]])); + self::assertTrue($f->isEnabled('nonemptyContext', ['profile' => ['items' => [null]]])); + self::assertTrue($f->getVariable('emptyArrayGate')); + self::assertTrue($f->getVariable('emptyObjectGate')); + self::assertTrue($f->getVariable('nonemptyObjectGate')); + self::assertFalse($f->getVariable('nullGate')); + $f->close(); + } + + public function testPortableV4CasesThroughGetterAndDetailedEvaluationTwice(): void + { + $fixturePath = __DIR__.'/../conformance/sdk-v4-cases.json'; + $fixture = json_decode(file_get_contents($fixturePath), true, 512, JSON_THROW_ON_ERROR); + $rawFixture = json_decode(file_get_contents($fixturePath), false, 512, JSON_THROW_ON_ERROR); + self::assertSame(1, $fixture['version']); + self::assertCount(255, $fixture['cases']); + + foreach ($fixture['cases'] as $caseIndex => $case) { + $datafile = json_encode($rawFixture->cases[$caseIndex]->datafile, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + $originalDatafile = $datafile; + $options = $case['options'] ?? []; + $unsupported = array_diff(array_keys($options), ['context', 'logLevel', 'stickyFeatures', 'stickyVariables']); + self::assertSame([], $unsupported, $case['name'].' unsupported instance options'); + $featurevisor = Featurevisor::createFeaturevisor(array_merge( + ['logLevel' => 'fatal'], + $options, + ['datafile' => $datafile] + )); + + for ($attempt = 1; $attempt <= 2; $attempt++) { + $context = $case['context'] ?? []; + $expected = $case['expected']; + + self::assertSame( + $expected['value'], + $featurevisor->getVariable('subject', $context), + $case['name']." getter attempt {$attempt}" + ); + + $evaluation = $featurevisor->evaluateVariable('subject', $context); + self::assertSame( + $expected['value'], + $this->normalizeValue($evaluation['variableValue'] ?? null), + $case['name']." value attempt {$attempt}" + ); + foreach ($expected as $key => $value) { + if ($key === 'value') continue; + self::assertSame($value, $evaluation[$key] ?? null, $case['name']." {$key} attempt {$attempt}"); + } + } + + self::assertSame($originalDatafile, $datafile, $case['name'].' input datafile unchanged'); + $featurevisor->close(); + } + } + + public function testCanonicalFeatureAndVariableRequirementGroups(): void + { + $fixture = $this->fixture('sdk-v3.json'); + self::assertSame(15, $fixture['version']); + + foreach ($fixture['requiredFeatureGroups'] as $group) { + foreach ($fixture['requiredFeatureGroupInputs'] as $index => $input) { + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'stickyFeatures' => [ + 'a' => ['enabled' => $input['a']], + 'b' => ['enabled' => $input['b']], + ], + 'datafile' => [ + 'schemaVersion' => '2', 'revision' => 'groups', 'segments' => [], + 'features' => [ + 'a' => ['bucketBy' => 'userId', 'traffic' => []], + 'b' => ['bucketBy' => 'userId', 'traffic' => []], + 'subject' => [ + 'bucketBy' => 'userId', + 'requiredFeatures' => $group['requirements'], + 'traffic' => [['key' => 'all', 'segments' => '*', 'percentage' => 100000]], + ], + ], + ], + ]); + self::assertSame($group['results'][$index], $f->isEnabled('subject'), $group['name']); + $f->close(); + } + } + + $convert = function ($requirements) use (&$convert) { + if (is_string($requirements)) return ['variable' => $requirements, 'operator' => 'equals', 'value' => true]; + if (isset($requirements[0]) || $requirements === []) return array_map($convert, $requirements); + foreach (['and', 'or', 'not'] as $operator) { + if (isset($requirements[$operator])) return [$operator => array_map($convert, $requirements[$operator])]; + } + return $requirements; + }; + foreach ($fixture['requiredVariables']['groups'] as $group) { + foreach ($fixture['requiredVariables']['groupInputs'] as $index => $input) { + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'stickyVariables' => $input, + 'datafile' => [ + 'schemaVersion' => '2', 'revision' => 'variable-groups', 'segments' => [], 'features' => [], + 'variables' => [ + 'a' => ['type' => 'boolean', 'defaultValue' => false], + 'b' => ['type' => 'boolean', 'defaultValue' => false], + 'subject' => [ + 'type' => 'string', 'defaultValue' => 'matched', 'disabledValue' => 'miss', + 'conditions' => $convert($group['requirements']), + ], + ], + ], + ]); + $expected = $group['results'][$index] ? 'matched' : 'miss'; + self::assertSame($expected, $f->getVariable('subject'), $group['name']); + $f->close(); + } + } + } + + public function testCanonicalRequiredVariableStickyAndAccumulationCases(): void + { + $fixturePath = __DIR__.'/../conformance/sdk-v3.json'; + $fixture = json_decode(file_get_contents($fixturePath), true, 512, JSON_THROW_ON_ERROR)['requiredVariables']; + $rawFixture = json_decode(file_get_contents($fixturePath), false, 512, JSON_THROW_ON_ERROR); + $datafile = json_encode($rawFixture->requiredVariables->datafile, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + foreach ($fixture['stickyCases'] as $case) { + $f = Featurevisor::createFeaturevisor([ + 'datafile' => $datafile, + 'stickyFeatures' => $case['stickyFeatures'], + 'stickyVariables' => $case['stickyVariables'], + 'logLevel' => 'fatal', + ]); + self::assertSame($case['matched'], $f->isEnabled('gated'), $case['name']); + self::assertSame($case['matched'] ? 'matched' : 'default', $f->getVariable('root'), $case['name']); + self::assertSame($case['matched'] ? 'matched' : 'default', $f->getVariable('featureOverride', 'message'), $case['name']); + self::assertSame($case['accumulated'], $f->getVariable('accumulated'), $case['name']); + $f->close(); + } + + $f = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); + self::assertNull($f->getVariable('malformed')); + self::assertSame('matched', $f->getVariable('nullGate')); + $f->close(); + } + + public function testMutationNumericEqualityAndOrdinaryNullPresence(): void + { + self::assertSame([1], Mutations::apply([1], [['op' => 'appendUnique', 'path' => [], 'value' => 1.0]])); + self::assertSame([true, 1], Mutations::apply([true], [['op' => 'appendUnique', 'path' => [], 'value' => 1]])); + self::assertSame([], Mutations::apply([1.0, 1], [['op' => 'removeValue', 'path' => [], 'value' => 1]])); + self::assertSame( + [['id' => 1.0, 'value' => 'matched']], + Mutations::apply([['id' => 1.0, 'value' => 'old']], [[ + 'op' => 'set', + 'path' => [['select' => ['property' => 'id', 'values' => [1]]], 'value'], + 'value' => 'matched', + ]]) + ); + + $regex = static fn(string $pattern, string $flags): string => '~'.$pattern.'~'.$flags; + self::assertFalse(Conditions::conditionIsMatched(['attribute' => 'missing', 'operator' => 'isNull'], [], $regex)); + self::assertFalse(Conditions::conditionIsMatched(['attribute' => 'missing', 'operator' => 'notNull'], [], $regex)); + self::assertTrue(Conditions::conditionIsMatched(['attribute' => 'present', 'operator' => 'isNull'], ['present' => null], $regex)); + self::assertFalse(Conditions::conditionIsMatched(['attribute' => 'present', 'operator' => 'notNull'], ['present' => null], $regex)); + + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => [ + 'schemaVersion' => '2', 'revision' => 'empty-program', 'segments' => [], 'features' => [], + 'variables' => [ + 'subject' => [ + 'type' => 'string', + 'defaultValue' => 'default', + 'overrideProgram' => [], + 'overrides' => [['key' => 'legacy', 'segments' => '*', 'value' => 'wrong']], + ], + 'emptyArray' => ['type' => 'json', 'defaultValue' => '[]'], + 'emptyObject' => ['type' => 'json', 'defaultValue' => '{}'], + ], + ], + ]); + $evaluation = $f->evaluateVariable('subject'); + self::assertSame(Evaluation::VARIABLE_DEFAULT, $evaluation['reason']); + self::assertSame('default', $evaluation['variableValue']); + self::assertArrayNotHasKey('variableOverrideKey', $evaluation); + self::assertArrayNotHasKey('variableOverridePaths', $evaluation); + self::assertSame([], $f->getVariable('emptyArray')); + self::assertSame([], $f->getVariable('emptyObject')); + self::assertSame([], $f->getVariableJSON('emptyObject')); + $f->close(); + } + + public function testSafeIntegralNumericPathComponents(): void + { + self::assertSame( + ['zero', 'changed'], + Mutations::apply(['zero', 'one'], [['op' => 'set', 'path' => [1.0], 'value' => 'changed']]) + ); + self::assertSame( + ['zero'], + Mutations::apply(['zero'], [['op' => 'set', 'path' => [9007199254740991.0], 'value' => 'ignored']]) + ); + foreach ([-1, 1.5, 9007199254740992, 9007199254740992.0, true, NAN, INF] as $part) { + try { + Mutations::apply(['zero', 'one'], [['op' => 'set', 'path' => [$part], 'value' => 'invalid']]); + self::fail('Expected invalid numeric mutation path to fail'); + } catch (\RuntimeException $error) { + self::assertSame('Invalid variable mutation path', $error->getMessage()); + } + } + + $datafile = [ + 'schemaVersion' => '2', 'revision' => 'numeric-paths', 'segments' => [], 'features' => [], + 'variables' => [ + 'source' => ['type' => 'array', 'defaultValue' => ['zero', 'one']], + 'mutated' => [ + 'type' => 'array', 'defaultValue' => ['zero', 'one'], + 'overrideProgram' => [[ + 'key' => 'raw', 'segments' => '*', + 'operations' => [['op' => 'set', 'path' => [1.0], 'value' => 'changed']], + 'onMatch' => 1, 'onMiss' => 1, + ]], + ], + 'gate' => [ + 'type' => 'string', 'defaultValue' => 'matched', 'disabledValue' => 'miss', + 'conditions' => [[ + 'variable' => 'source', 'path' => [1.0], 'operator' => 'equals', 'value' => 'one', + ]], + ], + ], + ]; + $raw = json_encode($datafile, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + self::assertStringContainsString('"path":[1.0]', $raw); + $f = Featurevisor::createFeaturevisor(['datafile' => $raw, 'logLevel' => 'fatal']); + self::assertSame(['zero', 'changed'], $f->getVariable('mutated')); + self::assertSame('matched', $f->getVariable('gate')); + $f->close(); + + foreach ([-1, 1.5, 9007199254740992, 9007199254740992.0, true, NAN, INF] as $part) { + $invalid = $datafile; + $invalid['variables']['gate']['conditions'][0]['path'] = [$part]; + $f = Featurevisor::createFeaturevisor(['datafile' => $invalid, 'logLevel' => 'fatal']); + self::assertSame(Evaluation::ERROR, $f->evaluateVariable('gate')['reason']); + $f->close(); + } + } + + public function testRawJsonPreservesObjectAndArrayContainers(): void + { + $program = static function (array $operations): array { + return [[ + 'key' => 'mutation', + 'keyPath' => ['mutation'], + 'segments' => '*', + 'operations' => $operations, + 'onMatch' => 1, + 'onMiss' => 1, + ]]; + }; + $gate = static function (string $source, array $path, string $operator, $expected = null): array { + $requirement = ['variable' => $source, 'path' => $path, 'operator' => $operator]; + if ($operator !== 'notExists') $requirement['value'] = $expected; + return [ + 'type' => 'string', + 'defaultValue' => 'matched', + 'disabledValue' => 'miss', + 'conditions' => [$requirement], + ]; + }; + + $datafile = [ + 'schemaVersion' => '2', + 'revision' => 'raw-json-containers', + 'segments' => [], + 'features' => [], + 'variables' => [ + 'emptyObject' => [ + 'type' => 'object', + 'defaultValue' => (object) [], + 'overrideProgram' => $program([['op' => 'set', 'path' => ['created'], 'value' => true]]), + ], + 'plainEmptyObject' => ['type' => 'object', 'defaultValue' => (object) []], + 'numericObject' => [ + 'type' => 'object', + 'defaultValue' => (object) ['0' => 'object'], + 'overrideProgram' => $program([['op' => 'set', 'path' => ['0'], 'value' => 'changed']]), + ], + 'numericObjectWrongPath' => [ + 'type' => 'object', + 'defaultValue' => (object) ['0' => 'object'], + 'overrideProgram' => $program([['op' => 'set', 'path' => [0], 'value' => 'wrong']]), + ], + 'actualArray' => [ + 'type' => 'array', + 'defaultValue' => ['old'], + 'overrideProgram' => $program([['op' => 'set', 'path' => [0], 'value' => 'changed']]), + ], + 'actualArrayWrongPath' => [ + 'type' => 'array', + 'defaultValue' => ['old'], + 'overrideProgram' => $program([['op' => 'set', 'path' => ['0'], 'value' => 'wrong']]), + ], + 'nestedSelection' => [ + 'type' => 'array', + 'defaultValue' => [(object) ['id' => 1.0, 'details' => (object) []]], + 'overrideProgram' => $program([[ + 'op' => 'set', + 'path' => [['select' => ['property' => 'id', 'values' => [1]]], 'details', 'active'], + 'value' => true, + ]]), + ], + 'objectSource' => ['type' => 'object', 'defaultValue' => (object) ['0' => 'object']], + 'arraySource' => ['type' => 'array', 'defaultValue' => ['array']], + 'objectStringGate' => $gate('objectSource', ['0'], 'equals', 'object'), + 'objectNumericGate' => $gate('objectSource', [0], 'notExists'), + 'arrayStringGate' => $gate('arraySource', ['0'], 'equals', 'array'), + 'arrayNumericGate' => $gate('arraySource', [0], 'equals', 'array'), + 'arrayLengthGate' => $gate('arraySource', ['length'], 'equals', 1.0), + ], + ]; + $json = json_encode($datafile, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + $f = Featurevisor::createFeaturevisor(['datafile' => $json, 'logLevel' => 'fatal']); + + for ($attempt = 1; $attempt <= 2; $attempt++) { + $empty = $f->getVariable('emptyObject'); + self::assertSame(['created' => true], $empty, "empty object mutation attempt {$attempt}"); + self::assertInstanceOf( + \stdClass::class, + $f->evaluateVariable('emptyObject')['variableValue'], + "detailed empty object attempt {$attempt}" + ); + self::assertSame(['created' => true], $f->getVariableObject('emptyObject'), "typed object attempt {$attempt}"); + self::assertSame([], $f->getVariableObject('plainEmptyObject'), "empty typed object attempt {$attempt}"); + self::assertInstanceOf( + \stdClass::class, + $f->evaluateVariable('plainEmptyObject')['variableValue'], + "detailed plain empty object attempt {$attempt}" + ); + self::assertSame(['0' => 'changed'], $f->getVariable('numericObject'), "numeric object attempt {$attempt}"); + self::assertSame(['changed'], $f->getVariable('actualArray'), "array numeric path attempt {$attempt}"); + $nested = $f->getVariable('nestedSelection'); + self::assertSame( + [['id' => 1.0, 'details' => ['active' => true]]], + $nested, + "nested selector attempt {$attempt}" + ); + $nestedDetailed = $f->evaluateVariable('nestedSelection')['variableValue']; + self::assertSame(1.0, $nestedDetailed[0]->id, "selector numeric value attempt {$attempt}"); + self::assertInstanceOf(\stdClass::class, $nestedDetailed[0]->details, "nested object attempt {$attempt}"); + foreach (['objectStringGate', 'objectNumericGate', 'arrayStringGate', 'arrayNumericGate', 'arrayLengthGate'] as $key) { + self::assertSame('matched', $f->getVariable($key), "{$key} attempt {$attempt}"); + } + self::assertSame(Evaluation::ERROR, $f->evaluateVariable('numericObjectWrongPath')['reason']); + self::assertSame(Evaluation::ERROR, $f->evaluateVariable('actualArrayWrongPath')['reason']); + } + + $arrayPatch = [ + 'schemaVersion' => '2', + 'revision' => 'array-patch', + 'segments' => [], + 'features' => [], + 'variables' => ['unrelatedArrayPatch' => ['type' => 'boolean', 'defaultValue' => true]], + ]; + $f->setDatafile($arrayPatch); + self::assertSame(Evaluation::ERROR, $f->evaluateVariable('actualArrayWrongPath')['reason']); + self::assertSame(['0' => 'changed'], $f->getVariable('numericObject')); + + $jsonPatch = json_encode([ + 'schemaVersion' => '2', + 'revision' => 'json-patch', + 'segments' => [], + 'features' => [], + 'variables' => ['unrelatedJsonPatch' => ['type' => 'boolean', 'defaultValue' => true]], + ], JSON_THROW_ON_ERROR); + $f->setDatafile($jsonPatch); + self::assertSame(Evaluation::ERROR, $f->evaluateVariable('actualArrayWrongPath')['reason']); + self::assertSame(['0' => 'changed'], $f->getVariable('numericObject')); + + self::assertSame($json, json_encode($datafile, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)); + $f->close(); + } + + public function testAccumulationCallbacksLegacyAliasesAndMixedDependencyEvents(): void + { + $before = 0; + $after = 0; + $legacy = 0; + $datafile = [ + 'schemaVersion' => '2', 'revision' => 'one', 'segments' => [], 'features' => [ + 'featureRoot' => [ + 'hash' => 'feature-root', + 'bucketBy' => 'userId', + 'conditions' => [['variable' => 'source', 'operator' => 'equals', 'value' => true]], + 'traffic' => [['key' => 'all', 'segments' => '*', 'percentage' => 100000]], + ], + 'owner' => [ + 'hash' => 'owner', + 'bucketBy' => 'userId', + 'variablesSchema' => ['message' => ['type' => 'string', 'defaultValue' => 'default']], + 'traffic' => [[ + 'key' => 'all', + 'segments' => '*', + 'percentage' => 100000, + 'variableOverrides' => [ + 'message' => [[ + 'key' => 'source', + 'conditions' => [['variable' => 'source', 'operator' => 'equals', 'value' => true]], + 'value' => 'matched', + ]], + ], + ]], + ], + ], + 'variables' => [ + 'source' => ['hash' => 'source-one', 'type' => 'boolean', 'defaultValue' => true], + 'subject' => ['hash' => 'subject', 'type' => 'object', 'defaultValue' => (object) [], 'overrideProgram' => [ + ['key' => 'first', 'keyPath' => ['first'], 'segments' => '*', 'operations' => [['op' => 'set', 'path' => ['first'], 'value' => true]], 'onMatch' => 1, 'onMiss' => 1], + ['key' => 'second', 'keyPath' => ['second'], 'segments' => '*', 'operations' => [['op' => 'set', 'path' => ['second'], 'value' => true]], 'onMatch' => 2, 'onMiss' => 2], + ]], + 'programDependent' => ['hash' => 'program', 'type' => 'string', 'defaultValue' => 'default', 'overrideProgram' => [['key' => 'source', 'conditions' => [['variable' => 'source', 'operator' => 'equals', 'value' => true]], 'operations' => [['op' => 'set', 'path' => [], 'value' => 'matched']], 'onMatch' => 1, 'onMiss' => 1]]], + 'featureDependent' => ['hash' => 'feature-dependent', 'type' => 'string', 'defaultValue' => 'default', 'conditions' => [['feature' => 'owner', 'variable' => 'message', 'operator' => 'equals', 'value' => 'matched']]], + ], + ]; + $f = Featurevisor::createFeaturevisor([ + 'datafile' => $datafile, + 'logLevel' => 'fatal', + 'modules' => [[ + 'name' => 'callbacks', + 'beforeEvaluation' => static function (array $options) use (&$before): array { $before++; return $options; }, + 'afterEvaluation' => static function (array $evaluation) use (&$after): array { $after++; return $evaluation; }, + 'before' => static function (array $options) use (&$legacy): array { $legacy++; return $options; }, + 'after' => static function (array $evaluation) use (&$legacy): array { $legacy++; return $evaluation; }, + ]], + ]); + $evaluation = $f->evaluateVariable('subject'); + self::assertSame(['first' => true, 'second' => true], $this->normalizeValue($evaluation['variableValue'])); + self::assertSame([['first'], ['second']], $evaluation['variableOverridePaths']); + self::assertSame(1, $before); + self::assertSame(1, $after); + self::assertSame(0, $legacy); + + $events = []; + $f->on('datafile_set', static function (array $details) use (&$events): void { $events[] = $details; }); + $f->setDatafile([ + 'schemaVersion' => '2', 'revision' => 'two', 'segments' => [], 'features' => [], + 'variables' => ['source' => ['hash' => 'source-two', 'type' => 'boolean', 'defaultValue' => false]], + ]); + sort($events[0]['features']); + sort($events[0]['variables']); + self::assertSame(['featureRoot', 'owner'], $events[0]['features']); + self::assertSame(['featureDependent', 'programDependent', 'source'], $events[0]['variables']); + self::assertFalse(method_exists($f, 'setSticky')); + self::assertFalse(method_exists($f, 'getAllEvaluations')); + $f->close(); + } + + public function testPrecomputedAndRuntimeFirstMatchTreesRemainEquivalentThroughChildAndDatafileState(): void + { + $fixture = $this->fixture('sdk-v4-cases.json'); + $names = [ + 'first match precomputed parsed: child', + 'first match runtime parsed: child', + ]; + $cases = []; + foreach ($names as $name) { + foreach ($fixture['cases'] as $case) { + if ($case['name'] === $name) { + $cases[] = $case; + break; + } + } + } + self::assertCount(2, $cases); + + $childValue = [ + 'stage' => 'parent', + 'rows' => [['id' => 0, 'tags' => []], ['id' => 1, 'tags' => ['parent', 'child']]], + 'flags' => ['a'], + ]; + $fallbackValue = ['stage' => 'fallback', 'rows' => [], 'flags' => ['a']]; + $stickyValue = ['stage' => 'sticky', 'rows' => [], 'flags' => [null]]; + $replacementValue = ['stage' => 'replacement', 'rows' => [], 'flags' => []]; + + foreach ($cases as $case) { + $datafile = $case['datafile']; + $originalVariables = json_encode($datafile['variables'], JSON_THROW_ON_ERROR); + $f = Featurevisor::createFeaturevisor([ + 'datafile' => json_encode($datafile, JSON_THROW_ON_ERROR), + 'context' => $case['context'], + 'logLevel' => 'fatal', + ]); + $child = $f->spawn(['country' => 'be', 'city' => 'rotterdam']); + $stickyChild = $f->spawn([], ['stickyVariables' => ['subject' => $stickyValue]]); + + for ($attempt = 1; $attempt <= 2; $attempt++) { + self::assertSame($childValue, $f->getVariable('subject')); + self::assertSame($childValue, $this->normalizeValue($f->evaluateVariable('subject')['variableValue'])); + self::assertSame($fallbackValue, $child->getVariable('subject')); + self::assertSame($stickyValue, $stickyChild->getVariable('subject')); + } + + $stickyChild->setStickyVariables([], true); + self::assertSame($childValue, $stickyChild->getVariable('subject')); + + $replacement = [ + 'schemaVersion' => '2', 'revision' => 'replacement', 'segments' => [], 'features' => [], + 'variables' => ['subject' => ['type' => 'object', 'defaultValue' => (object) $replacementValue]], + ]; + $f->setDatafile(json_encode($replacement, JSON_THROW_ON_ERROR), true); + self::assertSame($replacementValue, $f->getVariable('subject')); + self::assertSame($replacementValue, $child->getVariable('subject')); + + $f->setDatafile(json_encode($datafile, JSON_THROW_ON_ERROR)); + self::assertSame($childValue, $f->getVariable('subject')); + self::assertSame($fallbackValue, $child->getVariable('subject')); + self::assertSame($originalVariables, json_encode($datafile['variables'], JSON_THROW_ON_ERROR)); + + $stickyChild->close(); + $child->close(); + $f->close(); + } + } + + public function testRequiredVariableGetterErrorsAndArrayPropertyPaths(): void + { + $sourceCallbacks = 0; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'modules' => [[ + 'beforeEvaluation' => static function (array $options) use (&$sourceCallbacks): array { + if (($options['variableKey'] ?? null) === 'throwingSource') { + $sourceCallbacks++; + throw new \RuntimeException('source callback failed'); + } + return $options; + }, + 'afterEvaluation' => static function (array $evaluation): array { + if (($evaluation['variableKey'] ?? null) === 'moduleArraySource') { + $evaluation['variableValue'] = ['module item']; + } + if (($evaluation['variableKey'] ?? null) === 'moduleObjectSource') { + $evaluation['variableValue'] = (object) ['item' => 'module object']; + } + return $evaluation; + }, + ]], + 'stickyVariables' => ['stickyArraySource' => ['sticky item']], + 'datafile' => [ + 'schemaVersion' => '2', 'revision' => 'paths', 'segments' => [], 'features' => [], + 'variables' => [ + 'throwingSource' => ['type' => 'string', 'defaultValue' => 'value'], + 'arraySource' => ['type' => 'array', 'defaultValue' => ['first']], + 'objectSource' => ['type' => 'object', 'defaultValue' => (object) ['0' => 'object value']], + 'moduleArraySource' => ['type' => 'object', 'defaultValue' => (object) ['item' => 'default']], + 'moduleObjectSource' => ['type' => 'array', 'defaultValue' => ['default']], + 'stickyArraySource' => ['type' => 'object', 'defaultValue' => (object) ['item' => 'default']], + 'throwGate' => ['type' => 'string', 'defaultValue' => 'matched', 'disabledValue' => 'miss', 'conditions' => [[ + 'variable' => 'throwingSource', 'operator' => 'isNull', + ]]], + 'stringIndexGate' => ['type' => 'string', 'defaultValue' => 'matched', 'disabledValue' => 'miss', 'conditions' => [[ + 'variable' => 'arraySource', 'path' => ['0'], 'operator' => 'equals', 'value' => 'first', + ]]], + 'lengthGate' => ['type' => 'string', 'defaultValue' => 'matched', 'disabledValue' => 'miss', 'conditions' => [[ + 'variable' => 'arraySource', 'path' => ['length'], 'operator' => 'equals', 'value' => 1.0, + ]]], + 'numericObjectGate' => ['type' => 'string', 'defaultValue' => 'matched', 'disabledValue' => 'miss', 'conditions' => [[ + 'variable' => 'objectSource', 'path' => [0], 'operator' => 'notExists', + ]]], + 'moduleArrayGate' => ['type' => 'string', 'defaultValue' => 'matched', 'disabledValue' => 'miss', 'conditions' => [[ + 'variable' => 'moduleArraySource', 'path' => [0], 'operator' => 'equals', 'value' => 'module item', + ]]], + 'moduleObjectGate' => ['type' => 'string', 'defaultValue' => 'matched', 'disabledValue' => 'miss', 'conditions' => [[ + 'variable' => 'moduleObjectSource', 'path' => ['item'], 'operator' => 'equals', 'value' => 'module object', + ]]], + 'stickyArrayGate' => ['type' => 'string', 'defaultValue' => 'matched', 'disabledValue' => 'miss', 'conditions' => [[ + 'variable' => 'stickyArraySource', 'path' => [0], 'operator' => 'equals', 'value' => 'sticky item', + ]]], + ], + ], + ]); + + self::assertSame('matched', $f->getVariable('throwGate')); + self::assertSame(1, $sourceCallbacks); + self::assertSame('matched', $f->getVariable('stringIndexGate')); + self::assertSame('matched', $f->getVariable('lengthGate')); + self::assertSame('matched', $f->getVariable('numericObjectGate')); + self::assertSame('matched', $f->getVariable('moduleArrayGate')); + self::assertSame('matched', $f->getVariable('moduleObjectGate')); + self::assertSame('matched', $f->getVariable('stickyArrayGate')); + $f->close(); + } + + public function testFeatureVariableOverridePrerequisitesRunBeforeFalseSelectors(): void + { + $sourceCallbacks = 0; + $requirement = ['variable' => 'source', 'operator' => 'equals', 'value' => true]; + $falseCondition = ['attribute' => 'country', 'operator' => 'equals', 'value' => 'never']; + $f = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'modules' => [[ + 'beforeEvaluation' => static function (array $options) use (&$sourceCallbacks): array { + if (($options['variableKey'] ?? null) === 'source') $sourceCallbacks++; + return $options; + }, + ]], + 'datafile' => [ + 'schemaVersion' => '2', 'revision' => 'feature-override-order', 'segments' => [], + 'features' => [ + 'owner' => [ + 'bucketBy' => 'userId', + 'variablesSchema' => [ + 'ruleValue' => ['type' => 'string', 'defaultValue' => 'default'], + 'variationValue' => ['type' => 'string', 'defaultValue' => 'default'], + ], + 'variations' => [[ + 'value' => 'on', + 'variableOverrides' => ['variationValue' => [[ + 'key' => 'variation', 'conditions' => [$falseCondition, $requirement], 'value' => 'wrong', + ]]], + ]], + 'traffic' => [[ + 'key' => 'all', 'segments' => '*', 'percentage' => 100000, 'variation' => 'on', + 'variableOverrides' => ['ruleValue' => [[ + 'key' => 'rule', 'conditions' => [$falseCondition, $requirement], 'value' => 'wrong', + ]]], + ]], + ], + ], + 'variables' => ['source' => ['type' => 'boolean', 'defaultValue' => true]], + ], + ]); + + self::assertSame('default', $f->getVariable('owner', 'ruleValue', ['country' => 'other'])); + self::assertSame(0, $sourceCallbacks); + self::assertSame('default', $f->getVariable('owner', 'variationValue', ['country' => 'other'])); + self::assertSame(0, $sourceCallbacks); + $f->close(); + } +} diff --git a/tests/fixtures/initial-loading-index-probe.php b/tests/fixtures/initial-loading-index-probe.php new file mode 100644 index 0000000..976a469 --- /dev/null +++ b/tests/fixtures/initial-loading-index-probe.php @@ -0,0 +1,10 @@ + '4', 'revision' => '1', 'features' => [], 'segments' => [], 'variables' => []]; +for ($i = 0; $i < $count; $i++) { + $datafile['features']['f' . $i] = ['hash' => 'f', 'conditions' => '{"variable":"source","operator":"equals","value":1}']; + $datafile['segments']['s' . $i] = ['conditions' => '{"attribute":"id","operator":"equals","value":1}']; +} +$datafile['variables']['source'] = ['hash' => '1', 'type' => 'integer', 'defaultValue' => 1]; +$sdk = Featurevisor::createFeaturevisor(['datafile' => $datafile, 'logLevel' => 'fatal']); +$counts = [$GLOBALS['selectorParses']]; +for ($i = 2; $i <= 3; $i++) { + $GLOBALS['selectorParses'] = 0; + $patch = ['schemaVersion' => '4', 'revision' => (string) $i, 'features' => [], 'segments' => [], + 'variables' => ['source' => ['hash' => (string) $i, 'type' => 'integer', 'defaultValue' => $i, + 'conditions' => '{"attribute":"id","operator":"equals","value":1}']]]; + if (!$sdk->setDatafile($patch)) throw new \RuntimeException('Update rejected'); + $counts[] = $GLOBALS['selectorParses']; +} +$datafile['variables'] = $patch['variables']; +foreach ([false, true] as $replace) { + $GLOBALS['selectorParses'] = 0; + if (!$sdk->setDatafile($datafile, $replace)) throw new \RuntimeException('Update rejected'); + $counts[] = $GLOBALS['selectorParses']; +} +$datafile['features']['f0']['conditions'] = '{"variable":"other","operator":"equals","value":1}'; +$GLOBALS['selectorParses'] = 0; +if (!$sdk->setDatafile($datafile, true)) throw new \RuntimeException('Update rejected'); +$counts[] = $GLOBALS['selectorParses']; +$datafile['features']['f1']['metadata'] = ['conditions' => '{"variable":"ignored"}']; +$GLOBALS['selectorParses'] = 0; +if (!$sdk->setDatafile($datafile, true)) throw new \RuntimeException('Update rejected'); +$counts[] = $GLOBALS['selectorParses']; +$sdk->close(); +echo \json_encode($counts, JSON_THROW_ON_ERROR);