diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d83de1e..e263f33 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -54,8 +54,17 @@ jobs: bundler-cache: true - name: Test both packages + env: + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + LC_CTYPE: C.UTF-8 run: make test + - name: Test both packages with C and unset locales + run: | + env LANG=C LC_ALL=C LC_CTYPE=C bundle exec rspec spec/ + env -u LANG -u LC_ALL -u LC_CTYPE bundle exec rspec spec/ + - name: Build and verify both gem artifacts run: make build @@ -68,7 +77,7 @@ jobs: run: | mkdir example-1 cd example-1 - npx --yes @featurevisor/cli@3 init --example=1 + npx --yes @featurevisor/cli@latest init --example=1 npm install npx featurevisor build npx featurevisor test --onlyFailures diff --git a/Gemfile.lock b/Gemfile.lock index 56369a8..5195371 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - featurevisor (3.1.0) + featurevisor (4.0.0) benchmark (>= 0, < 1) GEM diff --git a/README.md b/README.md index 8b41090..7c2618b 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,13 @@ # Featurevisor Ruby SDK -This is a port of Featurevisor [JavaScript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to Ruby, providing a way to evaluate feature flags, variations, and variables in your Ruby applications. - -This SDK is compatible with Featurevisor v3 projects and v2 datafiles. +Use the Featurevisor Ruby SDK to evaluate flags, variations and variables in Ruby 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,10 +16,13 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [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 global variables](#getting-global-variables) -- [Getting aggregate evaluations](#getting-aggregate-evaluations) -- [Sticky features and variables](#sticky-features-and-variables) + - [Getting global variables](#getting-global-variables) +- [Conditions](#conditions) +- [Composition](#composition) +- [Getting evaluations](#getting-evaluations) +- [Sticky](#sticky) - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) @@ -30,7 +31,6 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) - [Interval-based update](#interval-based-update) -- [Evaluation details](#evaluation-details) - [Diagnostics](#diagnostics) - [Levels](#levels) - [Handler](#handler) @@ -39,6 +39,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [`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) @@ -50,6 +51,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Test against local monorepo's example-1](#test-against-local-monorepos-example-1) - [Benchmark](#benchmark) - [Assess distribution](#assess-distribution) +- [GitHub repositories](#github-repositories) - [Development](#development) - [Setting up](#setting-up) - [Running tests](#running-tests) @@ -60,38 +62,26 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. ## Installation +Upgrading an existing application? Follow the [Ruby migration guide](https://featurevisor.com/docs/migrations/v4/ruby/). + Add this line to your application's Gemfile: ```ruby -gem 'featurevisor' +gem 'featurevisor', '~> 4.0' ``` And then execute: ```bash -$ bundle install +bundle install ``` Or install it yourself as: ```bash -$ gem install featurevisor +gem install featurevisor -v "~> 4.0" ``` -## Public API - -The main runtime API is `Featurevisor.create_featurevisor`: - -```ruby -f = Featurevisor.create_featurevisor( - datafile: datafile_content -) -``` - -Most applications only need this factory and the returned `Featurevisor::Instance`. Public extension and observability APIs include modules, diagnostics, events, and the datafile structures accepted by the factory. - -Concurrent evaluations are safe after an instance is configured. Do not mutate or close the same instance concurrently with evaluations. Serialize calls to `set_datafile`, `set_context`, `set_sticky_features`, `set_sticky_variables`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. - ## Initialization The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: @@ -102,8 +92,11 @@ require 'net/http' require 'json' # Fetch datafile from URL -datafile_url = 'https://cdn.yoursite.com/datafile.json' +datafile_url = 'https://cdn.yoursite.com/featurevisor-all.json' response = Net::HTTP.get_response(URI(datafile_url)) +unless response.is_a?(Net::HTTPSuccess) + raise "Datafile request failed: HTTP #{response.code}" +end # Parse JSON with symbolized keys (required) datafile_content = JSON.parse(response.body, symbolize_names: true) @@ -112,11 +105,18 @@ datafile_content = JSON.parse(response.body, symbolize_names: true) f = Featurevisor.create_featurevisor( datafile: datafile_content ) +begin + puts f.is_enabled('checkout', { userId: '123' }) +ensure + f.close +end ``` +Network errors, unsuccessful HTTP responses and invalid JSON stop initialization. Handle these exceptions at your application's startup boundary. + **Important**: When parsing JSON datafiles, you must use `symbolize_names: true` to ensure proper key handling by the SDK. -Alternatively, you can pass a JSON string directly and the SDK will parse it automatically: +Pass a JSON string for the SDK to parse: ```ruby # Option 1: Parse JSON yourself (recommended) @@ -127,21 +127,38 @@ f = Featurevisor.create_featurevisor(datafile: datafile_content) f = Featurevisor.create_featurevisor(datafile: json_string) ``` -## Evaluation types +### Public API + +The main runtime API is `Featurevisor.create_featurevisor`: + +```ruby +f = Featurevisor.create_featurevisor( + datafile: datafile_content +) +``` + +Most applications only need this factory and the returned `Featurevisor::Instance`. Public extension and observability APIs include modules, diagnostics, events, and the datafile structures accepted by the factory. + +Concurrent evaluations and a single background datafile refresh writer are supported on MRI. Warm entity reads rely on MRI's execution model. Thread safety is not guaranteed on other Ruby implementations. CI covers MRI 3.0 and 3.4. Serialize writer operations. Do not change context, sticky state or modules, or close the instance, concurrently with evaluations on other threads. Module, event, and diagnostic callbacks must synchronize 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 -- [**Variation**](#getting-variation) (`string`): the variation of the feature (if any) -- [**Variables**](#getting-variables): variable values of the feature (if any) +Featurevisor evaluates four kinds of values: + +- [**Flag**](#check-if-enabled) (`true` or `false`): whether the feature is enabled or not +- [**Variation**](#getting-variation) (`String`): the variation 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 with `before` and `after` accept portable timestamp strings, `Time` and `DateTime`. Native values are compared by their instant and truncated to milliseconds without rounding. Both the native written year and the UTC year must be within 1 to 9999. Native offsets must have a magnitude of at most 14 hours, including any seconds or fractions supported by the type. Offsets within that limit retain their full precision. A plain `Date` is not accepted because it has no time or offset. Pass `Time.utc(2026, 1, 1)` or `'2026-01-01T00:00:00Z'` instead of `Date.new(2026, 1, 1)`. Invalid values make both operators return `false`. They are plain hashes: @@ -153,11 +170,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: ```ruby require 'featurevisor' @@ -174,7 +189,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: ```ruby f.set_context({ @@ -186,7 +201,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: ```ruby f.set_context({ @@ -199,7 +214,7 @@ f.set_context({ ### 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: ```ruby context = { @@ -207,21 +222,19 @@ context = { country: 'nl' } -is_enabled = f.is_enabled('my_feature', context) -variation = f.get_variation('my_feature', context) -variable_value = f.get_variable('my_feature', 'my_variable', context) +is_enabled = f.is_enabled('checkout', context) +variation = f.get_variation('checkout', context) +variable_value = f.get_variable('checkout', 'bgColor', 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: ```ruby -feature_key = 'my_feature' +feature_key = 'checkout' is_enabled = f.is_enabled(feature_key) @@ -230,7 +243,7 @@ if is_enabled end ``` -You can also pass additional context per evaluation: +Pass additional context to one evaluation: ```ruby is_enabled = f.is_enabled(feature_key, { @@ -240,10 +253,10 @@ is_enabled = f.is_enabled(feature_key, { ## 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): ```ruby -feature_key = 'my_feature' +feature_key = 'checkout' variation = f.get_variation(feature_key) @@ -254,7 +267,7 @@ else end ``` -Additional context per evaluation can also be passed: +Pass additional context to one evaluation: ```ruby variation = f.get_variation(feature_key, { @@ -264,18 +277,20 @@ variation = f.get_variation(feature_key, { ## 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): ```ruby variable_key = 'bgColor' -bg_color_value = f.get_variable('my_feature', variable_key) +bg_color_value = f.get_variable('checkout', variable_key) ``` -Additional context per evaluation can also be passed: +Pass additional context to one evaluation: ```ruby -bg_color_value = f.get_variable('my_feature', variable_key, { +bg_color_value = f.get_variable('checkout', variable_key, { # ...additional context }) ``` @@ -288,7 +303,7 @@ Next to generic `get_variable()` methods, there are also type specific methods a f.get_variable_boolean(feature_key, variable_key, context = {}) f.get_variable_string(feature_key, variable_key, context = {}) f.get_variable_integer(feature_key, variable_key, context = {}) -f.get_variable_double(feature_key, variable_key, context = {}) +f.get_variable_number(feature_key, variable_key, context = {}) f.get_variable_array(feature_key, variable_key, context = {}) f.get_variable_object(feature_key, variable_key, context = {}) f.get_variable_json(feature_key, variable_key, context = {}) @@ -296,7 +311,9 @@ f.get_variable_json(feature_key, variable_key, context = {}) Type specific methods do not coerce values. `get_variable_integer` returns `nil` for the string `"1"`, and boolean getters return `nil` for non-boolean values. -## Getting global variables +`get_variable_number` 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 `get_variable_double` was removed in v4. + +### Getting global variables Global variables use the same methods with a single variable key: @@ -306,18 +323,89 @@ value = f.get_variable('checkoutSettings', context) evaluation = f.evaluate_variable('checkoutSettings', context) ``` -Global variables resolve sticky values first, then required features, then the first matching override, and finally their default value. +Overrides and conditions are applied for you. See [Variables](https://featurevisor.com/docs/variables/#global-variables) and [Conditions](https://featurevisor.com/docs/conditions/) for how to define them. + +To see which overrides changed a value, inspect `variable_override_paths` in the detailed evaluation: + +```ruby +evaluation = f.evaluate_variable('runtimePolicy', country: 'nl') +value = evaluation[:variable_value] +matched_paths = evaluation[:variable_override_paths] +``` + +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. + +Modules should return the same result for the same input during one evaluation. Pass values that change through context. + +## Conditions + +[Conditions](https://featurevisor.com/docs/conditions/) are shared by segments, feature and global variable root gates, force entries, traffic rules and variable overrides. Generated v4 datafiles use `schemaVersion: "4"`. Arrays imply AND. Logical expressions use `and`, `or` and `not`. NOT negates the AND of its direct children. Empty expressions are false. + +Leaves compare context attributes, reference a segment by its literal string key, check a feature's enabled state and optional variation, or compare an evaluated variable. Feature enabled checks default to true. Variable leaves may name a feature owner and use already compiled path arrays. Dots and slashes in variable keys remain literal. + +For example, a compiled root gate can combine all four leaf kinds: + +```json +{ + "conditions": [ + {"attribute": "country", "operator": "equals", "value": "nl"}, + "eligibleAudience", + {"feature": "checkout", "variation": "treatment"}, + {"variable": "policy", "path": ["items", 0, "enabled"], "operator": "equals", "value": true} + ] +} +``` + +Continue using ordinary getters: + +```ruby +enabled = f.is_enabled('checkout', country: 'nl') +policy = f.get_variable('policy') +``` + +Feature evaluation checks sticky state, force, its root gate and then traffic rules. Force bypasses the gate. Global variables check sticky state and their gate before reading sources or applying overrides. A missed root gate reports `conditions_unmet` and `evaluation[:conditions]`. Variation and feature variable evaluations retain their ordinary disabled reasons and fallbacks. + +Conditions preserve written order and short circuit. Segments and getter dependencies share the current request session and a combined depth limit of 256 internal handoffs. Cycles and excessive depth fail the whole evaluation. Later independent calls start fresh sessions. An ordinary completed false or nil getter result remains a value. + +## Composition + +[Composed variables](https://featurevisor.com/docs/variables/#compose-values) read sources through ordinary getters with the effective context, modules and sticky state. A compiled list assembles one fresh outer hash with literal property names: + +```json +{ + "type": "object", + "compose": [ + {"key": "enabled", "feature": "checkout"}, + {"key": "assignment", "feature": "checkout", "variation": true}, + {"key": "title", "feature": "checkout", "variable": "title"}, + {"key": "policy", "variable": "policy"} + ] +} +``` + +The string form, `"compose": "policy"`, is an alias of that literal global variable key. It returns the evaluated source directly, including false, zero, empty strings, arrays and nil. JSON sources are decoded exactly once, including when their decoded strings resemble JSON. + +```ruby +screen = f.get_variable('screen', country: 'nl') +details = f.evaluate_variable('screen', country: 'nl') +``` + +Compositions do not declare `defaultValue` or `useDefaultWhenDisabled`. They may have root `conditions` and `disabledValue`. An unmet gate skips every source and override and returns the disabled value or nil. Whole variable sticky state bypasses both assembly and overrides. Explicit defaults supplied to the outer getter are never forwarded to sources. + +All members are read before runtime overrides. Overrides use the existing mutation operations and provenance fields. The reason is `variable_composed`, or `variable_override_rule` when an override matches. Mutations copy changed containers without modifying source values, sibling properties or sticky values. -## Getting aggregate evaluations +Treat returned hashes and arrays as read only. Composition properties and aliases keep ordinary getter ownership, so descendants may be shared. Only the map composition's outer hash is guaranteed to be fresh. Ordinary source getter failures retain their normal fallbacks. Dependency failures invalidate the whole composition. -You can get evaluations of all features available in the SDK instance: +## Getting evaluations + +Evaluate all features available in the instance: ```ruby all_evaluations = f.get_feature_evaluations({}) puts all_evaluations # { -# myFeature: { +# checkout: { # enabled: true, # variation: "control", # variables: { @@ -340,9 +428,9 @@ Global variable values are available separately: global_variables = f.get_variable_evaluations({}) ``` -## 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. Feature and global variable sticky values are independent. @@ -353,7 +441,7 @@ require 'featurevisor' f = Featurevisor.create_featurevisor( sticky_features: { - myFeatureKey: { + checkout: { enabled: true, # optional variation: 'treatment', @@ -375,11 +463,11 @@ 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: ```ruby f.set_sticky_features({ - myFeatureKey: { + checkout: { enabled: true, variation: 'treatment', variables: { @@ -407,7 +495,9 @@ f.set_datafile(datafile_content) f.set_datafile(json_string) ``` -**Important**: When calling `set_datafile()`, ensure JSON is parsed with `symbolize_names: true` if you're parsing it yourself. +`set_datafile` 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. + +Schema version `"2"` selectors normalize eagerly before merging. Schema version `"4"` entities normalize lazily on first read. Informational schema metadata is retained. Partial updates invalidate only incoming entities. Replacement clears normalized caches. Caller definitions are never changed. ### Merging by default @@ -416,45 +506,32 @@ By default, `set_datafile(datafile)` merges the incoming datafile into the SDK's - top-level metadata such as `schemaVersion`, `revision`, and `featurevisorVersion` comes from the incoming datafile - `segments` are merged, with incoming entries overriding existing ones - `features` are merged, with incoming entries overriding existing ones +- global `variables` are merged, with incoming entries overriding existing ones -This means you can call `set_datafile` more than once with different datafiles, and the SDK instance accumulates their features, segments, and global variables together. +Call `set_datafile` with different datafiles to merge their features, segments, and global variables. ### Replacing To fully replace the stored datafile, pass `true` as the second argument: ```ruby -f.set_datafile(datafile_content, true) +f.set_datafile(datafile_content, 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: ```ruby -require "open-uri" - -f = Featurevisor.create_featurevisor({}) - -def load_datafile(f, target) - url = "https://cdn.yoursite.com/production/featurevisor-#{target}.json" - datafile = JSON.parse(URI.open(url).read, symbolize_names: true) - - # merges into whatever was loaded before - f.set_datafile(datafile) -end - -load_datafile(f, "products") - -# later, when the user reaches checkout -load_datafile(f, "checkout") +f.set_datafile(products_datafile) +f.set_datafile(checkout_datafile) ``` +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: @@ -465,7 +542,7 @@ The triggers for setting the datafile again can be: ### Interval-based update -Here's an example of using interval-based update: +Replace the complete datafile on each refresh so definitions removed upstream are removed from the SDK too. Use merging only when loading partial datafiles intentionally. ```ruby require 'net/http' @@ -478,7 +555,7 @@ def update_datafile(f, datafile_url) begin response = Net::HTTP.get_response(URI(datafile_url)) datafile_content = JSON.parse(response.body) - f.set_datafile(datafile_content) + f.set_datafile(datafile_content, true) # replace rescue => e # handle error puts "Failed to update datafile: #{e.message}" @@ -522,12 +599,13 @@ Every diagnostic has `:level`, `:code`, `:message`, and an object-shaped `:detai Diagnostic handlers are isolated from SDK behaviour. An exception in a handler does not stop other handlers or evaluations. +Malformed stringified selectors report `conditions_parse_error` at error level once per selector per incoming entity load. Details contain exactly `conditions` with the original string, `entityType` and `entityKey`. Schema 2 diagnostics run after accepted datafile storage. V4 diagnostics run after caching the first entity read. Handlers may safely read the accepted revision again. A malformed selector fails locally, even through NOT or segment references, without rejecting the datafile or disabling unrelated entities. ## 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` @@ -548,7 +626,7 @@ end unsubscribe.call ``` -The `features` array will contain keys of features that have either been: +The `features` and `variables` arrays include direct changes and transitive dependencies through conditions, segments and composition sources. They contain entities that have been: - added, or - updated, or @@ -600,7 +678,7 @@ The `error` event is emitted for diagnostics reported with `level: "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: ```ruby # flag @@ -632,14 +710,24 @@ And optionally these properties depending on whether you are evaluating a featur - `variable_key`: the variable key - `variable_value`: the variable value - `variable_schema`: the variable schema +- `variable_override_index`: index of the final matched global variable override +- `variable_override_key`: key of the final matched global variable override +- `variable_override_path`: authoring path of the final matched global variable override +- `variable_override_paths`: all matched authoring paths from an accumulating override +- `conditions`: the normalized root expression when its gate is unmet +- `variable`: the global variable definition when applicable ## Modules -Modules allow you to intercept the evaluation process and customize SDK behaviour. +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 `dependency_of` and `dependency_purpose` on internal reads. The requester contains `type`, optional `feature_key` and optional `variable_key`. Purpose is `condition` for gating reads or `composition` for consumed source values. Application calls have neither marker. Tracking modules can skip condition reads while retaining composition exposures. -For feature evaluations, all `before` callbacks run in registration order, followed by all `before_evaluation` callbacks. After evaluation and caller defaults, all `after_evaluation` callbacks run, followed by all `after` callbacks. Global variable evaluations use only `before_evaluation` and `after_evaluation`. Required feature checks run through the complete module pipeline, and transformed defaults are preserved. +Equivalent dependency reads are memoized within one public call, separating purpose, requester, effective context and current SDK state. Condition snapshots are owned. Composition values retain ordinary getter ownership. Callback options do not retain private sessions. Independent public getters called from callbacks start fresh sessions. -`before` and `after` remain available as deprecated feature-only compatibility callbacks. Use `before_evaluation` and `after_evaluation` for new modules so the same callbacks can handle feature and global variable evaluations. +Modules can intercept evaluation and participate in SDK lifecycle. The canonical callbacks are `setup`, `bucket_key`, `bucket_value`, `before_evaluation`, `after_evaluation`, and `close`. + +`before_evaluation` and `after_evaluation` run for feature and global variable evaluations. Condition and composition dependency getters use the same module pipeline, while an accumulating override publishes only its final result to `after_evaluation`. ### Defining a module @@ -710,7 +798,7 @@ The module API passed to `setup` exposes: ### Registering modules -You can register modules at the time of SDK initialization: +Register modules when you create the instance: ```ruby require 'featurevisor' @@ -749,12 +837,12 @@ child_f = 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: ```ruby -is_enabled = child_f.is_enabled('my_feature') -variation = child_f.get_variation('my_feature') -variable_value = child_f.get_variable('my_feature', 'my_variable') +is_enabled = child_f.is_enabled('checkout') +variation = child_f.get_variation('checkout') +variable_value = child_f.get_variable('checkout', 'bgColor') global_value = child_f.get_variable('welcomeMessage') ``` @@ -772,7 +860,7 @@ Similar to parent SDK, child instances also support several additional methods: - `get_variable_boolean` - `get_variable_string` - `get_variable_integer` -- `get_variable_double` +- `get_variable_number` - `get_variable_array` - `get_variable_object` - `get_variable_json` @@ -783,80 +871,14 @@ Similar to parent SDK, child instances also support several additional methods: ## Close -Both primary and child instances support a `.close()` method, that removes forgotten event listeners (via `on` method) and cleans up any potential memory leaks. +Both primary and child instances support a `.close()` method. It removes event listeners and child subscriptions. The primary instance also closes modules, clears diagnostic subscriptions and parsed expression caches, and releases its diagnostic handler after reporting any module shutdown errors. Closing is idempotent. + +Ruby can collect unreachable reference cycles, but objects still referenced by your application remain alive. Call unsubscribe functions when finished with individual subscriptions, and close instances and children when finished with them. Retained instance handles still own their datafiles and context. Closing does not promise that every allocation is returned to the operating system. ```ruby f.close() ``` -## CLI usage - -This package also provides a CLI tool for running your Featurevisor [project](https://featurevisor.com/docs/projects/)'s test specs and benchmarking against this Ruby SDK. - -- Global installation: you can access it as `featurevisor` -- Local installation: you can access it as `bundle exec featurevisor` -- From this repository: you can access it as `bin/featurevisor` - -### Test - -Learn more about testing [here](https://featurevisor.com/docs/testing/). - -```bash -$ bundle exec featurevisor test --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" -``` - -Additional options that are available: - -```bash -$ bundle exec featurevisor test \ - --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \ - --quiet|--verbose \ - --onlyFailures \ - --keyPattern="myFeatureKey" \ - --assertionPattern="#1" -``` - -The Ruby test runner builds base datafiles and Target datafiles in memory via `npx featurevisor build --json`. When an assertion contains `target`, it is evaluated against the matching Target datafile. - -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 against local monorepo's example-1 - -```bash -$ cd /absolute/path/to/featurevisor-ruby -$ bundle exec ruby bin/featurevisor test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures -$ make test-example-1 -``` - -### Benchmark - -Learn more about benchmarking [here](https://featurevisor.com/docs/cli/#benchmarking). - -```bash -$ bundle exec 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). - -```bash -$ bundle exec 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 OpenFeature provider is published as a separate gem. This keeps OpenFeature code and dependencies out of applications that only use the Featurevisor SDK. @@ -866,10 +888,10 @@ The provider currently requires Ruby 3.4 or newer because that is the minimum ve Install the provider: ```ruby -gem "featurevisor-openfeature", "~> 3.0" +gem "featurevisor-openfeature", "~> 4.0" ``` -It installs the matching `featurevisor` gem and the official `openfeature-sdk` dependency. The provider and base SDK deliberately share the same version, and the provider requires that exact Featurevisor version. +It installs the matching `featurevisor` gem and the official `openfeature-sdk ~> 0.6.5` dependency. The provider requires Ruby 3.4 or later, while the separate base gem supports Ruby 3.0 or later without OpenFeature. The provider and base SDK deliberately share the same version, and the provider requires that exact Featurevisor version. ```ruby require "featurevisor/openfeature_provider" @@ -894,7 +916,7 @@ Use `checkout` for a flag, `checkout:variation` for its variation, `checkout:tit OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, `variation_key`, and `global_variable_prefix` can customize the mapping. The global variable prefix defaults to `variable` and cannot contain the separator. -You can pass any Featurevisor initialization options directly to the provider. These options are used to create the Featurevisor instance owned by the provider: +Pass Featurevisor initialization options to the provider to configure the instance it owns: ```ruby provider = Featurevisor::OpenFeatureProvider.new( @@ -905,19 +927,111 @@ provider = Featurevisor::OpenFeatureProvider.new( ) ``` -You can also reuse an existing Featurevisor instance: +Reuse an existing Featurevisor instance: ```ruby -featurevisor = Featurevisor.create_featurevisor(datafile: datafile_content) -provider = Featurevisor::OpenFeatureProvider.new(featurevisor: featurevisor) +f = Featurevisor.create_featurevisor(datafile: datafile_content) +provider = Featurevisor::OpenFeatureProvider.new(featurevisor: f) ``` -The caller owns an instance passed this way. 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 the options hash. +The caller owns an instance passed this way. Provider shutdown does not close it. Call `f.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 the options hash. + +An invalid initial datafile in an owned instance fails `set_provider_and_wait` with a provider initialization error carrying `PARSE_ERROR`, independently of `log_level`. A valid datafile update clears the error and emits readiness. Shutdown removes provider subscriptions and closes only an owned instance. + +Root `conditions_unmet` outcomes map to OpenFeature `DISABLED`. Composed values and matched overrides map to `TARGETING_MATCH`. The provider does not decode composed JSON values again. See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages. +## CLI usage + +After adding the gem to your Gemfile as shown in [Installation](#installation), install it with: + +```bash +bundle install +``` + +Use `bundle exec featurevisor` for a bundled installation, `featurevisor` for a global gem installation, or `bin/featurevisor` from this SDK repository. + +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 +``` + +The native CLI provides `test`, `benchmark` and `assess-distribution`. Use the Node.js CLI for authoring commands such as `init`, `lint` and `build`. Native commands delegate project parsing, test listing, matrix expansion and datafile generation to `npx featurevisor`, then evaluate with this Ruby SDK. The SDK runtime has no Node.js dependency. + +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. + +Duplicate targets are evaluated once, at their first position. `--n` and `-n` accept a positive integer. + +### Test + +Segment owners and checks run through ordinary SDK gates, including feature and variable dependencies, fixed `at`, context, sticky state and child state. Selected Target segment specializations remain authoritative. Compiled execution definitions restore only missing segments. Attribute only chains may run without an environment. Dependent segments require their selected datafile. Transitive references are preflighted before getters run. Synthetic gates are excluded from coverage. Testing does not automatically run lint. + +```bash +bundle exec 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. + +Printed rerun commands use POSIX shell quoting. JSON reports also include raw argument arrays. + +### Test against local monorepo's example-1 + +Contributor instructions are in the [repository development guide](https://github.com/featurevisor/featurevisor-ruby#development). + +### 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. Ruby evaluates them. Replace `checkout` with a feature defined in your project. + +```bash +bundle exec 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 +bundle exec featurevisor assess-distribution \ + --project-directory-path=/path/to/featurevisor-project \ + --feature=checkout \ + --populate-uuid=userId \ + --n=1000 +``` + +Repeat `--populate-uuid=` to generate UUID values for multiple context attributes. Supply one key per option. See [Assess distribution](https://featurevisor.com/docs/cli/#assess-distribution). + +## GitHub repositories + +- SDK: [featurevisor/featurevisor-ruby](https://github.com/featurevisor/featurevisor-ruby) +- Example application: [featurevisor/featurevisor-example-ruby](https://github.com/featurevisor/featurevisor-example-ruby) + + + +The aggregate evaluation reference has moved to [Getting evaluations](#getting-evaluations). + + + +The sticky state reference has moved to [Sticky](#sticky). + ## Development ### Setting up @@ -932,6 +1046,8 @@ $ make test-base $ make test-example-1 ``` +Run `bundle exec rspec spec/readme_initialization_spec.rb` to execute the actual initialization snippet with simulated HTTP responses and transport errors. This check also runs in the ordinary SDK suite and CI. + Build and verify both gems with: ```bash @@ -942,11 +1058,13 @@ The build produces `featurevisor-VERSION.gem` and `featurevisor-openfeature-VERS ### Releasing +The next breaking release is `4.0.0` for both gems. Keep both Gemfile lockfiles aligned with the shared version. Run `make test`, `make test-base` and `make build` before tagging. The artefact check verifies the provider's exact dependency on the base gem and their separate contents. + - Update the shared version in `lib/featurevisor/version.rb` - Run `bundle install` - Push commit to `main` branch - Wait for CI to complete -- Tag the release with the same version number, for example `v3.1.0` +- Tag the release with the same version number, for example `vX.Y.Z` - The workflow verifies that the tag matches the shared version - The workflow publishes `featurevisor` first, followed by `featurevisor-openfeature` diff --git a/bin/cli.rb b/bin/cli.rb index 68e0fa7..1883424 100644 --- a/bin/cli.rb +++ b/bin/cli.rb @@ -7,7 +7,9 @@ class Options attr_accessor :command, :assertion_pattern, :context, :environment, :feature, :key_pattern, :n, :only_failures, :quiet, :variable, :variation, :verbose, :inflate, :show_datafile, :schema_version, :project_directory_path, - :populate_uuid, :with_scopes, :with_tags, :targets + :populate_uuid, :with_scopes, :with_tags, :targets, :entity_type, :set, + :json, :pretty, :case_id, :coverage, :uncovered, + :project_directory_path_explicit def initialize @n = 1000 @@ -26,12 +28,15 @@ def self.parse(args) end options.command = args[0] + unless %w[test benchmark assess-distribution].include?(options.command) + raise ArgumentError, "Unknown command: #{options.command}" + end remaining_args = args[1..-1] OptionParser.new do |opts| - opts.banner = "Usage: featurevisor [command] [options]" + opts.banner = "Usage: featurevisor #{options.command} [options]" - opts.on("--assertionPattern=PATTERN", "Assertion pattern") do |v| + opts.on("--assertion-pattern=PATTERN", "--assertionPattern=PATTERN", "Assertion pattern") do |v| options.assertion_pattern = v end @@ -39,23 +44,31 @@ def self.parse(args) options.context = v end - opts.on("--environment=ENV", "Environment (required for benchmark)") do |v| + opts.on("--environment=ENV", "Configured environment (omit for flat projects)") do |v| options.environment = v end opts.on("--feature=FEATURE", "Feature key") do |v| - options.feature = v + options.feature = nonempty_value("--feature", v) end - opts.on("--keyPattern=PATTERN", "Key pattern") do |v| + opts.on("--key-pattern=PATTERN", "--keyPattern=PATTERN", "Key pattern") do |v| options.key_pattern = v end - opts.on("-n", "--iterations=N", "--n=N", Integer, "Number of iterations (default: 1000)") do |v| - options.n = v + opts.on("--entity-type=TYPE", "--entityType=TYPE", %w[feature segment variable scenario], "Filter tests by entity type") do |v| + options.entity_type = v + end + + opts.on("--iterations=N", "--n=N", "Number of iterations (default: 1000)") do |v| + options.n = positive_iterations(v) end - opts.on("--onlyFailures", "Only show failures") do + opts.on("-n N", "Number of iterations (default: 1000)") do |v| + options.n = positive_iterations(v.delete_prefix("=")) + end + + opts.on("--only-failures", "--onlyFailures", "Only show failures") do options.only_failures = true end @@ -64,7 +77,7 @@ def self.parse(args) end opts.on("--variable=VARIABLE", "Variable key") do |v| - options.variable = v + options.variable = nonempty_value("--variable", v) end opts.on("--variation", "Variation mode") do @@ -76,10 +89,11 @@ def self.parse(args) end opts.on("--inflate=N", Integer, "Inflate mode") do |v| + raise OptionParser::InvalidArgument, "--inflate must be a positive integer" unless v.positive? options.inflate = v end - opts.on("--showDatafile", "Show datafile content for each test") do + opts.on("--show-datafile", "--showDatafile", "Show datafile content for each test") do options.show_datafile = true end @@ -95,29 +109,79 @@ def self.parse(args) options.with_tags = true end - opts.on("--projectDirectoryPath=PATH", "Project directory path") do |v| - options.project_directory_path = v + opts.on("--project-directory-path=PATH", "--projectDirectoryPath=PATH", "--root-directory-path=PATH", "--rootDirectoryPath=PATH", "Project directory path") do |v| + options.project_directory_path = nonempty_value("--project-directory-path", v) + options.project_directory_path_explicit = true end - opts.on("--populateUuid=KEY", "Populate UUID for attribute key") do |v| - options.populate_uuid << v + opts.on("--populate-uuid=KEY", "--populateUuid=KEY", "Populate UUID for attribute key") do |v| + options.populate_uuid << nonempty_value("--populate-uuid", v) end opts.on("--target=TARGET", "Target datafile; repeat for multiple targets") do |v| + v = nonempty_value("--target", v) options.targets << v unless options.targets.include?(v) end + opts.on("--set=SET", "Project set") do |v| + options.set = v + end + + opts.on("--json", "Print a versioned JSON test report") do + options.json = true + end + + opts.on("--pretty", "Pretty print JSON output") do + options.pretty = true + end + + opts.on("--case=ID", "Run one exact expanded test case") do |v| + options.case_id = v + end + + opts.on("--coverage", "Report observed rule and override selections") do + options.coverage = true + end + + opts.on("--uncovered", "List features and variables without associated tests") do + options.uncovered = true + end + opts.on("-h", "--help", "Show this help message") do puts opts exit end end.parse!(remaining_args) + unless remaining_args.empty? + raise OptionParser::InvalidArgument, "Unexpected arguments: #{remaining_args.join(' ')}" + end + unless options.context.nil? + context = JSON.parse(options.context) + raise ArgumentError, "--context must be a JSON object" unless context.is_a?(Hash) + end options end + + def self.positive_iterations(value) + parsed = Integer(value, 10) + raise ArgumentError unless parsed.positive? + parsed + rescue ArgumentError + raise OptionParser::InvalidArgument, "--n must be a positive integer" + end + + def self.nonempty_value(name, value) + if value.empty? || value.start_with?("--") + raise OptionParser::InvalidArgument, "#{name} requires a nonempty value" + end + value + end end def self.run(args) + return show_help if args.empty? || args == ["--help"] || args == ["-h"] + options = Parser.parse(args) case options.command @@ -130,6 +194,9 @@ def self.run(args) else show_help end + rescue ArgumentError, OptionParser::ParseError, JSON::ParserError, SystemCallError => e + warn "Error: #{e.message}" + exit 1 end def self.show_help @@ -146,10 +213,10 @@ def self.show_help puts "" puts "Examples:" puts " featurevisor test" - puts " featurevisor test --keyPattern=pattern" + puts " featurevisor test --key-pattern=pattern" puts " featurevisor benchmark --feature=myFeature --environment=dev --n=10000" puts " featurevisor assess-distribution --feature=myFeature --n=10000" puts "" - puts "Note: benchmark requires --environment and either --feature or --variable" + puts "Note: benchmark requires --feature or --variable; --environment is required only when configured" end end diff --git a/bin/commands.rb b/bin/commands.rb index 7ee0933..6f588d8 100644 --- a/bin/commands.rb +++ b/bin/commands.rb @@ -1,3 +1,4 @@ +require_relative "commands/node_cli" require_relative "commands/test" require_relative "commands/benchmark" require_relative "commands/assess_distribution" diff --git a/bin/commands/assess_distribution.rb b/bin/commands/assess_distribution.rb index 60a4f66..bc35869 100644 --- a/bin/commands/assess_distribution.rb +++ b/bin/commands/assess_distribution.rb @@ -1,10 +1,12 @@ require "json" require "securerandom" require "open3" +require_relative "runtime_project" module FeaturevisorCLI module Commands class AssessDistribution + include RuntimeProject # UUID_LENGTHS matches the TypeScript implementation UUID_LENGTHS = [4, 2, 2, 2, 6] @@ -19,11 +21,6 @@ def initialize(options) def run # Validate required options - unless @options.environment - puts "Error: --environment is required for assess-distribution command" - exit 1 - end - unless @options.feature puts "Error: --feature is required for assess-distribution command" exit 1 @@ -31,8 +28,10 @@ def run context = parse_context - targets = resolve_targets - (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) } + each_project do + targets = resolve_targets + (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) } + end end private @@ -40,7 +39,7 @@ def run def run_for_target(context, target) puts "\nAssess Featurevisor distribution" puts " Feature: #{@options.feature}" - puts " Environment: #{@options.environment}" + puts " Environment: #{@options.environment}" if @options.environment puts " Target: #{target}" if target puts " Iterations: #{@options.n}" @@ -112,26 +111,12 @@ def run_for_target(context, target) end end - def resolve_targets - return [] if @options.targets.empty? - stdout, stderr, status = Open3.capture3("npx", "featurevisor", "list", "--targets", "--json", chdir: @project_path) - unless status.success? - puts stderr - exit 1 - end - available = JSON.parse(stdout).map { |item| item.is_a?(Hash) ? (item["key"] || item["name"]) : item } - unknown = @options.targets.find { |target| !available.include?(target) } - if unknown - puts "Unknown target \"#{unknown}\". Available targets: #{available.empty? ? "none" : available.join(", ")}." - exit 1 - end - @options.targets - end def parse_context if @options.context begin context = JSON.parse(@options.context) + raise ArgumentError, "--context must be a JSON object" unless context.is_a?(Hash) # Convert string keys to symbols for the SDK context.transform_keys(&:to_sym) rescue JSON::ParserError => e @@ -143,38 +128,11 @@ def parse_context end end - def build_datafile(environment, target = nil) - puts "Building datafile for environment: #{environment}..." - - # Build the command similar to Go implementation - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"] - command_parts << "--target=#{target}" if target - - if @options.inflate - command_parts << "--inflate=#{@options.inflate}" - end - - command = command_parts.join(" ") - - stdout, stderr, exit_status = execute_command(command) - - if exit_status != 0 - puts "Error: Command failed with exit code #{exit_status}" - puts "Command: #{command}" - puts "Stderr: #{stderr}" - exit 1 - end - - begin - JSON.parse(stdout, symbolize_names: true) - rescue JSON::ParserError => e - puts "Error: Failed to parse datafile JSON: #{e.message}" - exit 1 - end - end def execute_command(command) stdout, stderr, exit_status = Open3.capture3(command) + stdout.force_encoding(Encoding::UTF_8) + stderr.force_encoding(Encoding::UTF_8) [stdout, stderr, exit_status.exitstatus] end diff --git a/bin/commands/benchmark.rb b/bin/commands/benchmark.rb index 6019939..c1429d2 100644 --- a/bin/commands/benchmark.rb +++ b/bin/commands/benchmark.rb @@ -2,10 +2,12 @@ require "json" require "time" require "open3" +require_relative "runtime_project" module FeaturevisorCLI module Commands class Benchmark + include RuntimeProject def self.run(options) new(options).run end @@ -17,19 +19,16 @@ def initialize(options) def run # Validate required options - unless @options.environment - puts "Error: --environment is required for benchmark command" - exit 1 - end - unless @options.feature || @options.variable puts "Error: --feature or --variable is required for benchmark command" exit 1 end context = parse_context - targets = resolve_targets - (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) } + each_project do + targets = resolve_targets + (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) } + end end private @@ -43,7 +42,7 @@ def run_for_target(context, target) puts "\nBenchmark Featurevisor feature" puts " Feature: #{@options.feature}" if @options.feature puts " Global variable: #{@options.variable}" if @options.variable && !@options.feature - puts " Environment: #{@options.environment}" + puts " Environment: #{@options.environment}" if @options.environment puts " Target: #{target}" if target puts " Iterations: #{@options.n}" puts " Build duration: #{datafile_build_duration_ms}ms" @@ -85,26 +84,12 @@ def run_for_target(context, target) puts "Maximum duration: #{format_duration_ms(output[:max_duration])}" end - def resolve_targets - return [] if @options.targets.empty? - stdout, stderr, status = Open3.capture3("npx", "featurevisor", "list", "--targets", "--json", chdir: @project_path) - unless status.success? - puts stderr - exit 1 - end - available = JSON.parse(stdout).map { |target| target.is_a?(Hash) ? (target["key"] || target["name"]) : target } - unknown = @options.targets.find { |target| !available.include?(target) } - if unknown - puts "Unknown target \"#{unknown}\". Available targets: #{available.empty? ? "none" : available.join(", ")}." - exit 1 - end - @options.targets - end def parse_context if @options.context begin context = JSON.parse(@options.context) + raise ArgumentError, "--context must be a JSON object" unless context.is_a?(Hash) # Convert string keys to symbols for the SDK context.transform_keys(&:to_sym) rescue JSON::ParserError => e @@ -116,35 +101,12 @@ def parse_context end end - def build_datafile(environment, target = nil) - puts "Building datafile for environment: #{environment}..." - - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"] - command_parts << "--target=#{target}" if target - - # Add inflate if specified - if @options.inflate && @options.inflate > 0 - command_parts << "--inflate=#{@options.inflate}" - end - - command = command_parts.join(" ") - - # Execute the command and capture output - datafile_output = execute_command(command) - - # Parse the JSON output - begin - JSON.parse(datafile_output, symbolize_names: true) - rescue JSON::ParserError => e - puts "Error: Failed to parse datafile JSON: #{e.message}" - puts "Command output: #{datafile_output}" - exit 1 - end - end def execute_command(command) # Execute the command and capture stdout/stderr stdout, stderr, status = Open3.capture3(command) + stdout.force_encoding(Encoding::UTF_8) + stderr.force_encoding(Encoding::UTF_8) unless status.success? puts "Error: Command failed with exit code #{status.exitstatus}" diff --git a/bin/commands/node_cli.rb b/bin/commands/node_cli.rb new file mode 100644 index 0000000..aeeaa55 --- /dev/null +++ b/bin/commands/node_cli.rb @@ -0,0 +1,20 @@ +module FeaturevisorCLI + # Runs the project's own Node based Featurevisor CLI. + # + # Node starts the local package entry directly, with an argument list and a + # working directory, so no shell quoting is involved on any platform. Without a + # local package, npx resolves the CLI as before, through npx.cmd on Windows. + module NodeCli + def self.command(project_path) + directory = File.expand_path(project_path) + loop do + entry = File.join(directory, "node_modules", "@featurevisor", "cli", "bin.js") + return ["node", entry] if File.file?(entry) + parent = File.dirname(directory) + break if parent == directory + directory = parent + end + [Gem.win_platform? ? "npx.cmd" : "npx", "featurevisor"] + end + end +end diff --git a/bin/commands/runtime_project.rb b/bin/commands/runtime_project.rb new file mode 100644 index 0000000..23dea35 --- /dev/null +++ b/bin/commands/runtime_project.rb @@ -0,0 +1,63 @@ +require "json" +require "open3" + +module FeaturevisorCLI + module Commands + module RuntimeProject + def each_project + raise ArgumentError, "--n must be a positive integer" unless @options.n.positive? + if @options.variation && @options.variable + raise ArgumentError, "--variation and --variable cannot be combined" + end + config = project_json("config", "--json", include_set: false) + environments = config["environments"] + if environments.is_a?(Array) + raise ArgumentError, "Pass --environment= when selecting one datafile." unless @options.environment + unless environments.include?(@options.environment) + raise ArgumentError, "Unknown environment #{@options.environment.inspect}. Available environments: #{environments.join(', ')}." + end + elsif @options.environment + raise ArgumentError, "This project does not configure environments. Omit --environment." + end + sets = [@options.set] + if config["sets"] && !@options.set + directory = File.expand_path(config["setsDirectoryPath"] || "sets", @project_path) + sets = Dir.children(directory).select { |entry| File.directory?(File.join(directory, entry)) }.sort + raise ArgumentError, "No sets found in #{directory}." if sets.empty? + end + sets.each do |set| + @active_set = set + puts "Set #{set.inspect}" if set + yield + end + end + + def project_json(*args, include_set: true) + args << "--set=#{@active_set || @options.set}" if include_set && (@active_set || @options.set) + stdout, stderr, status = Open3.capture3(*FeaturevisorCLI::NodeCli.command(@project_path), *args, chdir: @project_path) + stdout.force_encoding(Encoding::UTF_8) + stderr.force_encoding(Encoding::UTF_8) + raise ArgumentError, stderr.empty? ? "Node CLI failed with exit code #{status.exitstatus}" : stderr unless status.success? + warn stderr unless stderr.empty? + JSON.parse(stdout) + end + + def resolve_targets + return [] if @options.targets.empty? + available = project_json("list", "--targets", "--json").map { |target| target.is_a?(Hash) ? (target["key"] || target["name"]) : target } + unknown = @options.targets.find { |target| !available.include?(target) } + raise ArgumentError, "Unknown target #{unknown.inspect}. Available targets: #{available.join(', ')}." if unknown + @options.targets + end + + def build_datafile(environment, target = nil) + args = ["build", "--json"] + args << "--environment=#{environment}" if environment + args << "--target=#{target}" if target + args << "--inflate=#{@options.inflate}" if @options.inflate && @options.inflate.positive? + # Symbol keys are the native SDK datafile representation. + JSON.parse(JSON.generate(project_json(*args)), symbolize_names: true) + end + end + end +end diff --git a/bin/commands/test.rb b/bin/commands/test.rb index 468d0f6..2e5e73e 100644 --- a/bin/commands/test.rb +++ b/bin/commands/test.rb @@ -2,10 +2,21 @@ require "find" require "open3" require "shellwords" +require_relative "../test_checks" +require_relative "../segment_tests" module FeaturevisorCLI module Commands class Test + class InfrastructureError < StandardError + attr_reader :code + + def initialize(message, code: nil) + super(message) + @code = code + end + end + def self.run(options) new(options).run end @@ -13,61 +24,74 @@ def self.run(options) def initialize(options) @options = options @project_path = options.project_directory_path + @coverage_selections = [] end def run - puts "Running tests..." - - # Get project configuration - config = get_config - environments = get_environments(config) - available_targets = get_targets - unknown_target = @options.targets.find { |target| !available_targets.include?(target) } - if unknown_target - puts "Unknown target \"#{unknown_target}\". Available targets: #{available_targets.empty? ? "none" : available_targets.join(", ")}." - exit 1 + progress "Running tests..." + root_config = get_config(nil) + sets = selected_sets(root_config) + report = empty_report + report[:uncovered] = [] if @options.uncovered + matched_case = @options.case_id.nil? + + sets.each do |set| + @active_set = set + config = get_config(set) + report[:uncovered] << get_uncovered(set) if @options.uncovered + begin + tests = get_tests(set) + rescue InfrastructureError => e + if @options.case_id && !@options.set && e.code == "test_case_not_found" + next + end + raise + end + matched_case = true + environments = get_environments(config) + available_targets = get_targets(set) + unknown_target = @options.targets.find { |target| !available_targets.include?(target) } + if unknown_target + raise InfrastructureError, + "Unknown target #{unknown_target.inspect}. Available targets: #{available_targets.empty? ? 'none' : available_targets.join(', ')}." + end + targets = @options.targets.empty? ? available_targets : @options.targets + segments_by_key = get_segments(set) + datafiles_by_key = build_datafiles(environments, targets, @options.inflate, set) + merge_report!(report, run_tests(tests, datafiles_by_key, segments_by_key, get_logger_level, config, set)) end - targets = @options.targets.empty? ? available_targets : @options.targets - segments_by_key = get_segments - - # Build base and Target datafiles for all environments. - datafiles_by_key = build_datafiles(environments, targets, @options.inflate) - - puts "" - - # Get log level - level = get_logger_level - tests = get_tests - - if tests.empty? - puts "No tests found" - return + if @options.case_id && !matched_case + raise InfrastructureError, "No test case matches --case and the selected filters" end - # Run tests - run_tests(tests, datafiles_by_key, segments_by_key, level, config) + report[:coverage] = coverage_report if @options.coverage + finalize_report!(report) + print_report(report) + exit 1 unless report[:passed] + rescue InfrastructureError => e + report ||= empty_report + report[:errors] << { message: e.message } + finalize_report!(report) + print_report(report) + exit 1 end private - def get_config - puts "Getting config..." - command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor config --json)" - config_output = execute_command(command) + def get_config(set = @active_set) + progress "Getting config#{set ? " for set #{set.inspect}" : ''}..." + config_output = execute_command(node_command("config", "--json")) begin JSON.parse(config_output, symbolize_names: true) rescue JSON::ParserError => e - puts "Error: Failed to parse config JSON: #{e.message}" - puts "Command output: #{config_output}" - exit 1 + raise InfrastructureError, "Failed to parse config JSON: #{e.message}" end end - def get_segments - puts "Getting segments..." - command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor list --segments --json)" - segments_output = execute_command(command) + def get_segments(set = @active_set) + progress "Getting segments..." + segments_output = execute_command(node_command("list", "--segments", "--json", *set_arguments(set))) begin segments = JSON.parse(segments_output, symbolize_names: true) @@ -79,16 +103,13 @@ def get_segments end segments_by_key rescue JSON::ParserError => e - puts "Error: Failed to parse segments JSON: #{e.message}" - puts "Command output: #{segments_output}" - exit 1 + raise InfrastructureError, "Failed to parse segments JSON: #{e.message}" end end - def get_targets - puts "Getting targets..." - command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor list --targets --json)" - targets_output = execute_command(command) + def get_targets(set = @active_set) + progress "Getting targets..." + targets_output = execute_command(node_command("list", "--targets", "--json", *set_arguments(set))) begin targets = JSON.parse(targets_output, symbolize_names: true) @@ -107,12 +128,23 @@ def get_targets [] end rescue JSON::ParserError => e - puts "Error: Failed to parse targets JSON: #{e.message}" - puts "Command output: #{targets_output}" - exit 1 + raise InfrastructureError, "Failed to parse targets JSON: #{e.message}" end end + def selected_sets(config) + return [@options.set] if @options.set + return [nil] unless config[:sets] + + directory = config[:setsDirectoryPath] || "sets" + path = File.expand_path(directory, @project_path) + sets = Dir.children(path).select { |entry| File.directory?(File.join(path, entry)) }.sort + raise InfrastructureError, "No sets found in #{directory}." if sets.empty? + sets + rescue SystemCallError => e + raise InfrastructureError, "Failed to list project sets in #{directory}: #{e.message}" + end + def get_environments(config) environments = config[:environments] @@ -123,35 +155,33 @@ def get_environments(config) end def base_datafile_key(environment) - environment == false ? false : environment + [environment == false ? nil : environment, nil].freeze end def target_datafile_key(environment, target) - if environment == false - "false-target-#{target}" - else - "#{environment}-target-#{target}" - end + [environment == false ? nil : environment, target].freeze end - def build_datafiles(environments, targets, inflate) + def build_datafiles(environments, targets, inflate, set = @active_set) datafiles_by_key = {} environments.each do |environment| environment_label = environment == false ? "default (no environment)" : environment - puts "Building datafile for environment: #{environment_label}..." + progress "Building datafile for environment: #{environment_label}..." datafiles_by_key[base_datafile_key(environment)] = build_single_datafile( environment: environment, - inflate: inflate + inflate: inflate, + set: set ) targets.each do |target| - puts "Building datafile for target: #{target}..." + progress "Building datafile for target: #{target}..." datafiles_by_key[target_datafile_key(environment, target)] = build_single_datafile( environment: environment, inflate: inflate, - target: target + target: target, + set: set ) end end @@ -159,30 +189,17 @@ def build_datafiles(environments, targets, inflate) datafiles_by_key end - def build_single_datafile(environment:, inflate:, target: nil) - command_parts = ["npx", "featurevisor", "build", "--json"] - - if environment != false && !environment.nil? - command_parts << "--environment=#{Shellwords.escape(environment.to_s)}" - end - - if inflate && inflate > 0 - command_parts << "--inflate=#{inflate}" - end - - if target - command_parts << "--target=#{Shellwords.escape(target.to_s)}" - end - - command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})" - datafile_output = execute_command(command) + def build_single_datafile(environment:, inflate:, target: nil, set: @active_set) + arguments = ["build", "--json"] + arguments << "--environment=#{environment}" if environment != false && !environment.nil? + arguments << "--inflate=#{inflate}" if inflate && inflate > 0 + arguments << "--target=#{target}" if target + datafile_output = execute_command(node_command(*arguments, *set_arguments(set))) begin JSON.parse(datafile_output, symbolize_names: true) rescue JSON::ParserError => e - puts "Error: Failed to parse datafile JSON: #{e.message}" - puts "Command output: #{datafile_output}" - exit 1 + raise InfrastructureError, "Failed to parse datafile JSON: #{e.message}" end end @@ -196,29 +213,46 @@ def get_logger_level end end - def get_tests - command_parts = ["npx", "featurevisor", "list", "--tests", "--applyMatrix", "--json"] - - if @options.key_pattern && !@options.key_pattern.empty? - command_parts << "--keyPattern=#{Shellwords.escape(@options.key_pattern)}" - end - + def get_tests(set = @active_set) + arguments = ["list", "--tests", "--execution-protocol", "--json"] + arguments << "--keyPattern=#{@options.key_pattern}" if @options.key_pattern && !@options.key_pattern.empty? if @options.assertion_pattern && !@options.assertion_pattern.empty? - command_parts << "--assertionPattern=#{Shellwords.escape(@options.assertion_pattern)}" + arguments << "--assertionPattern=#{@options.assertion_pattern}" end - - command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})" - tests_output = execute_command(command) + arguments << "--entityType=#{@options.entity_type}" if @options.entity_type && !@options.entity_type.empty? + arguments << "--case=#{@options.case_id}" if @options.case_id + tests_output = execute_command(node_command(*arguments, *set_arguments(set))) begin - JSON.parse(tests_output, symbolize_names: true) + payload = JSON.parse(tests_output, symbolize_names: true) + unless payload.is_a?(Hash) + raise InfrastructureError, "Invalid test execution protocol: expected an envelope object" + end + unless payload[:kind] == "featurevisor.test-execution" + raise InfrastructureError, 'Invalid test execution protocol: kind must be "featurevisor.test-execution"' + end + version = payload[:schemaVersion] + unless version.is_a?(Numeric) && version == 1 + raise InfrastructureError, "Unsupported test execution protocol schemaVersion; expected numeric 1" + end + tests = payload[:tests] + unless tests.is_a?(Array) && tests.all? { |test| test.is_a?(Hash) } + raise InfrastructureError, "Invalid test execution protocol: tests must be an array of objects" + end + tests rescue JSON::ParserError => e - puts "Error: Failed to parse tests JSON: #{e.message}" - puts "Command output: #{tests_output}" - exit 1 + raise InfrastructureError, "Failed to parse tests JSON: #{e.message}" end end + def set_arguments(set = @active_set) + set ? ["--set=#{set}"] : [] + end + + def node_command(*arguments) + [*FeaturevisorCLI::NodeCli.command(@project_path), *arguments] + end + def resolve_datafile_for_assertion(assertion, datafiles_by_key) environment = assertion.key?(:environment) ? assertion[:environment] : false environment = false if environment.nil? @@ -228,15 +262,18 @@ def resolve_datafile_for_assertion(assertion, datafiles_by_key) datafiles_by_key[base_datafile_key(environment)] end - def create_tester_instance(datafile, level, assertion) - sticky = parse_sticky(assertion[:stickyFeatures] || assertion[:sticky]) + def create_tester_instance(datafile, level, assertion, set = @active_set) + sticky = parse_sticky(assertion[:stickyFeatures]) sticky_variables = assertion[:stickyVariables].is_a?(Hash) ? assertion[:stickyVariables] : {} - Featurevisor.create_featurevisor( + diagnostics = [] + check_level = assertion.key?(:checks) || !Array(assertion[:children]).empty? ? "error" : level + instance = Featurevisor.create_featurevisor( datafile: datafile, sticky_features: sticky, sticky_variables: sticky_variables, - log_level: level, + log_level: check_level, + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic }, modules: [ { name: "test-module", @@ -247,132 +284,156 @@ def create_tester_instance(datafile, level, assertion) else options[:bucket_value] end + end, + after_evaluation: ->(evaluation, _evaluation_options) do + record_coverage_selection(evaluation, assertion, set) if @options.coverage && !(datafile[:_segment_test_keys] || {}).value?(evaluation[:feature_key].to_s) + evaluation end } ] ) + instance.instance_variable_set(:@__segment_test_keys, datafile[:_segment_test_keys] || {}) + instance.instance_variable_set(:@__segment_test_diagnostics, diagnostics) + instance.on("error", ->(event) { diagnostics << event[:diagnostic] if event[:diagnostic] }) + [instance, diagnostics] end - def run_tests(tests, datafiles_by_key, segments_by_key, level, config) - passed_tests_count = 0 - failed_tests_count = 0 - passed_assertions_count = 0 - failed_assertions_count = 0 + def run_tests(tests, datafiles_by_key, segments_by_key, level, _config, set) + report = empty_report tests.each do |test| - test_key = test[:key] + discriminators = %i[feature variable segment scenario].select { |key| test.key?(key) } + type = discriminators.first + invalid_discriminator = discriminators.length != 1 assertions = test[:assertions] || [] - if (test[:feature] || test[:variable]) && !@options.targets.empty? - assertions = assertions.select do |assertion| - assertion[:target].nil? || @options.targets.include?(assertion[:target]) - end + if invalid_discriminator + assertions = [{ description: "malformed test spec", __invalid_discriminator: true }] + elsif type == :scenario && assertions.empty? + assertions = [{ description: "malformed scenario spec", __missing_assertions: true }] + end + if !invalid_discriminator && %i[feature variable scenario].include?(type) && !@options.targets.empty? + assertions = assertions.select { |assertion| assertion[:target].nil? || @options.targets.include?(assertion[:target]) } next if assertions.empty? end - results = "" - test_has_error = false - test_duration = 0.0 - - assertions.each do |assertion| - if assertion.is_a?(Hash) - test_result = nil - - if test[:feature] - datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key) - if datafile.nil? - test_result = { - has_error: true, - errors: " ✘ no datafile found for assertion target/environment combination\n", - duration: 0 - } - end - if datafile - instance = create_tester_instance(datafile, level, assertion) + result = { + type: (type || :unknown).to_s, + key: type ? test[type] : test[:key], + source: test.dig(:_test, :source) || test[:key], + set: test.dig(:_test, :set) || set, + passed: true, + cases: [] + } - # Show datafile if requested - if @options.show_datafile - puts "" - puts JSON.pretty_generate(datafile) - puts "" - end + assertions.each do |assertion| + next unless assertion.is_a?(Hash) + test_result = execute_assertion(test, type, assertion, datafiles_by_key, segments_by_key, level, set) + test_result ||= { has_error: true, errors: " ✘ test assertion could not be evaluated\n", duration: 0 } + errors = error_objects(test_result[:errors], type: type, assertion: assertion) + case_result = { + id: assertion.dig(:_case, :id), + description: assertion[:description], + environment: normalized_environment(assertion[:environment]), + target: assertion[:target], + context: assertion[:context].is_a?(Hash) ? assertion[:context] : {}, + matrixValues: assertion.dig(:_case, :matrixValues) || {}, + passed: !test_result[:has_error], + errors: errors, + rerun: rerun_instruction(assertion, result[:set]) + } + result[:cases] << case_result + result[:passed] = false unless case_result[:passed] + end - begin - test_result = run_test_feature(assertion, test[:feature], instance, level) - ensure - instance.close - end - end - elsif test[:variable] - datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key) - if datafile - if @options.show_datafile - puts "" - puts JSON.pretty_generate(datafile) - puts "" - end - instance = create_tester_instance(datafile, level, assertion) - begin - test_result = run_test_variable(assertion, test[:variable], instance) - ensure - instance.close - end - else - test_result = { has_error: true, errors: " ✘ no datafile found for assertion target/environment combination\n", duration: 0 } - end - elsif test[:segment] - segment_key = test[:segment] - segment = segments_by_key[segment_key] - if segment.is_a?(Hash) - test_result = run_test_segment(assertion, segment, level) - end - end + report[:results] << result + end - if test_result - test_duration += test_result[:duration] + finalize_report!(report) + report + end - if test_result[:has_error] - results += " ✘ #{assertion[:description]} (#{(test_result[:duration] * 1000).round(2)}ms)\n" - results += test_result[:errors] - test_has_error = true - failed_assertions_count += 1 - else - results += " ✔ #{assertion[:description]} (#{(test_result[:duration] * 1000).round(2)}ms)\n" - passed_assertions_count += 1 - end - end - end - end + def execute_assertion(test, type, assertion, datafiles_by_key, segments_by_key, level, set) + if assertion[:__invalid_discriminator] + return { has_error: true, errors: " ✘ test spec requires exactly one of feature, variable, segment or scenario\n", duration: 0 } + end - if !@options.only_failures || (@options.only_failures && test_has_error) - puts "\nTesting: #{test_key} (#{(test_duration * 1000).round(2)}ms)" - print results + datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key) + if type == :segment + if !datafile && ((assertion[:environment].is_a?(String) && !assertion[:environment].empty?) || assertion[:target]) + return { has_error: true, errors: " ✘ no datafile found for assertion target/environment combination\n", duration: 0 } end + authored = test[:_segments].is_a?(Hash) ? test[:_segments] : segments_by_key + return run_test_segment(assertion, authored[test[:segment].to_sym] || authored[test[:segment]], level, datafile, authored, test[:segment], set) + end + return { has_error: true, errors: " ✘ no datafile found for assertion target/environment combination\n", duration: 0 } unless datafile - if test_has_error - failed_tests_count += 1 - else - passed_tests_count += 1 - end + authored_segments = test[:_segments].is_a?(Hash) ? test[:_segments] : {} + preflight_details = {} + begin + preflight_assertion!(test, assertion, datafile, authored_segments, preflight_details) + rescue FeaturevisorCLI::TestChecks::CheckError => e + error = FeaturevisorCLI::TestChecks.comparison_error( + e.message, "valid checks", e.message, **preflight_details + ) + return { has_error: true, errors: [error], duration: 0 } end - puts "" - puts "Test specs: #{passed_tests_count} passed, #{failed_tests_count} failed" - puts "Assertions: #{passed_assertions_count} passed, #{failed_assertions_count} failed" - puts "" + datafile = SegmentTests.prepare(datafile, authored_segments) + progress JSON.pretty_generate(datafile) if @options.show_datafile + instance, diagnostics = create_tester_instance(datafile, level, assertion, set) + begin + return run_test_feature(assertion, test[:feature], instance, level) if type == :feature + return run_test_variable(assertion, test[:variable], instance, datafile, diagnostics, authored_segments) if type == :variable + return run_test_scenario(assertion, instance, datafile, diagnostics, authored_segments) if type == :scenario + ensure + instance.close + end + end - if failed_tests_count > 0 - exit 1 + def preflight_assertion!(test, assertion, datafile, segments, details = {}) + if test[:feature] + feature = FeaturevisorCLI::TestChecks.keyed(FeaturevisorCLI::TestChecks.keyed(datafile, :features), test[:feature]) + variables = FeaturevisorCLI::TestChecks.keyed(feature, :variablesSchema) + [assertion, *Array(assertion[:children])].each do |current| + next unless current.is_a?(Hash) + (current[:expectedVariables] || {}).each_key do |variable| + unless FeaturevisorCLI::TestChecks.keyed(variables, variable) + raise FeaturevisorCLI::TestChecks::CheckError, + "variable #{variable.inspect} is absent from feature #{test[:feature].inspect} in the selected datafile" + end + end + end + end + owner = test[:variable] + FeaturevisorCLI::TestChecks.preflight!(datafile, assertion[:checks], owner_variable: owner, segments: segments) if assertion.key?(:checks) + children = assertion[:children] || [] + raise FeaturevisorCLI::TestChecks::CheckError, "children must be an array" unless children.is_a?(Array) + children.each_with_index do |child, index| + raise FeaturevisorCLI::TestChecks::CheckError, "children[#{index}] must be an object" unless child.is_a?(Hash) + if child.key?(:checks) || test[:scenario] + details[:child_index] = index + FeaturevisorCLI::TestChecks.preflight!(datafile, child[:checks], owner_variable: owner, segments: segments) + end end end - def run_test_variable(assertion, variable_key, instance) - instance.set_context(parse_context(assertion[:context]), true) + def run_test_variable(assertion, variable_key, instance, datafile, diagnostics, segments = {}) + root_context = parse_context(assertion[:context]) + matcher = segment_matcher(instance, segments) + instance.set_context(root_context, true) options = {} options[:default_variable_value] = assertion[:defaultVariableValue] if assertion.key?(:defaultVariableValue) started = Time.now - errors = "" + errors = [] - errors += test_variable_expectation(assertion, variable_key, instance, options) + errors.concat(error_objects(test_variable_expectation(assertion, variable_key, instance, options))) + if assertion.key?(:checks) + check_result = FeaturevisorCLI::TestChecks.evaluate_result( + instance, datafile, assertion[:checks], owner_variable: variable_key, diagnostics: diagnostics, + segments: segments, context: root_context, segment_matcher: matcher + ) + errors.concat(check_result[:errors]) + end Array(assertion[:children]).each_with_index do |child_assertion, child_index| child = instance.spawn( @@ -385,38 +446,103 @@ def run_test_variable(assertion, variable_key, instance) if child_assertion.key?(:defaultVariableValue) child_options[:default_variable_value] = child_assertion[:defaultVariableValue] end - errors += test_variable_expectation( - child_assertion, - variable_key, + errors.concat(error_objects(test_variable_expectation( + child_assertion, variable_key, child, child_options, child_index + ))) + if child_assertion.key?(:checks) + check_result = FeaturevisorCLI::TestChecks.evaluate_result( + child, + datafile, + child_assertion[:checks], + owner_variable: variable_key, + diagnostics: diagnostics, + child_index: child_index, + segments: segments, + context: root_context.merge(parse_context(child_assertion[:context])), + segment_matcher: segment_matcher(child, segments, instance.instance_variable_get(:@__segment_test_keys), diagnostics) + ) + errors.concat(check_result[:errors]) + end + ensure + child.close + end + end + + { has_error: !errors.empty?, errors: errors, duration: Time.now - started } + end + + def run_test_scenario(assertion, instance, datafile, diagnostics, segments = {}) + started = Time.now + errors = [] + root_context = parse_context(assertion[:context]) + matcher = segment_matcher(instance, segments) + instance.set_context(root_context, true) + if assertion[:__missing_assertions] + errors << { message: "scenario spec requires assertions" } + elsif assertion.key?(:checks) + check_result = FeaturevisorCLI::TestChecks.evaluate_result( + instance, datafile, assertion[:checks], owner_variable: nil, diagnostics: diagnostics, + segments: segments, context: root_context, segment_matcher: matcher + ) + errors.concat(check_result[:errors]) + elsif Array(assertion[:children]).empty? + errors << { message: "scenario assertion requires checks or children" } + end + Array(assertion[:children]).each_with_index do |child_assertion, child_index| + child = instance.spawn( + parse_context(child_assertion[:context]), + sticky_features: parse_sticky(child_assertion[:stickyFeatures]), + sticky_variables: child_assertion[:stickyVariables].is_a?(Hash) ? child_assertion[:stickyVariables] : {} + ) + begin + check_result = FeaturevisorCLI::TestChecks.evaluate_result( child, - child_options, - "children[#{child_index}]." + datafile, + child_assertion[:checks], + owner_variable: nil, + diagnostics: diagnostics, + child_index: child_index, + segments: segments, + context: root_context.merge(parse_context(child_assertion[:context])), + segment_matcher: segment_matcher(child, segments, instance.instance_variable_get(:@__segment_test_keys), diagnostics) ) + errors.concat(check_result[:errors]) ensure child.close end end - { has_error: !errors.empty?, errors: errors, duration: Time.now - started } end - def test_variable_expectation(assertion, variable_key, evaluator, options, prefix = "") + def test_variable_expectation(assertion, variable_key, evaluator, options, child_index = nil) evaluation = evaluator.evaluate_variable(variable_key, {}, options) - errors = "" - if assertion.key?(:expectedValue) && !compare_values(evaluation[:variable_value], assertion[:expectedValue]) - errors += " ✘ #{prefix}expectedValue: expected #{format_test_value(assertion[:expectedValue])} but received #{format_test_value(evaluation[:variable_value])}\n" + details = { variableKey: variable_key } + details[:childIndex] = child_index unless child_index.nil? + errors = [] + if assertion.key?(:expectedValue) + value = evaluator.get_variable(variable_key, {}, options) + errors << expectation_error("variable", assertion[:expectedValue], value, details) unless compare_values(value, assertion[:expectedValue]) end if assertion[:expectedEvaluation].is_a?(Hash) assertion[:expectedEvaluation].each do |key, expected| actual = get_evaluation_value(evaluation, key) unless compare_values(actual, expected) - errors += " ✘ #{prefix}expectedEvaluation.#{key}: expected #{format_test_value(expected)} but received #{format_test_value(actual)}\n" + errors << expectation_error( + "evaluation", + expected, + actual, + details.merge(evaluationType: "variable", evaluationKey: key) + ) end end end errors end + def expectation_error(type, expected, actual, details = {}) + { type: type, expected: expected, actual: actual, details: details } + end + def format_test_value(value) JSON.generate(value) rescue JSON::GeneratorError @@ -425,7 +551,7 @@ def format_test_value(value) def run_test_feature(assertion, feature_key, instance, level) context = parse_context(assertion[:context]) - sticky = parse_sticky(assertion[:sticky]) + sticky = parse_sticky(assertion[:stickyFeatures]) # Set context and sticky for this assertion instance.set_context(context, false) @@ -437,7 +563,7 @@ def run_test_feature(assertion, feature_key, instance, level) override_options = create_override_options(assertion) has_error = false - errors = "" + errors = [] start_time = Time.now # Test expectedToBeEnabled @@ -447,7 +573,7 @@ def run_test_feature(assertion, feature_key, instance, level) if is_enabled != expected_to_be_enabled has_error = true - errors += " ✘ expectedToBeEnabled: expected #{expected_to_be_enabled} but received #{is_enabled}\n" + errors << expectation_error("flag", expected_to_be_enabled, is_enabled) end end @@ -459,7 +585,7 @@ def run_test_feature(assertion, feature_key, instance, level) variation_value = variation.nil? ? nil : variation if !compare_values(variation_value, expected_variation) has_error = true - errors += " ✘ expectedVariation: expected #{expected_variation} but received #{variation_value}\n" + errors << expectation_error("variation", expected_variation, variation_value) end end @@ -490,8 +616,7 @@ def run_test_feature(assertion, feature_key, instance, level) if !passed has_error = true - actual_json = actual_value.to_json - errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_json}\n" + errors << expectation_error("variable", expected_value, actual_value, variableKey: variable_key) end next rescue JSON::ParserError @@ -502,7 +627,7 @@ def run_test_feature(assertion, feature_key, instance, level) # Regular comparison for non-JSON strings or when JSON parsing fails if !compare_values(actual_value, expected_value) has_error = true - errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_value}\n" + errors << expectation_error("variable", expected_value, actual_value, variableKey: variable_key) end end end @@ -518,7 +643,10 @@ def run_test_feature(assertion, feature_key, instance, level) actual_value = get_evaluation_value(evaluation, key) if !compare_values(actual_value, expected_value) has_error = true - errors += " ✘ expectedEvaluations.flag.#{key}: expected #{expected_value} but received #{actual_value}\n" + errors << expectation_error( + "evaluation", expected_value, actual_value, + evaluationType: "flag", evaluationKey: key + ) end end end @@ -530,7 +658,10 @@ def run_test_feature(assertion, feature_key, instance, level) actual_value = get_evaluation_value(evaluation, key) if !compare_values(actual_value, expected_value) has_error = true - errors += " ✘ expectedEvaluations.variation.#{key}: expected #{expected_value} but received #{actual_value}\n" + errors << expectation_error( + "evaluation", expected_value, actual_value, + evaluationType: "variation", evaluationKey: key + ) end end end @@ -544,7 +675,10 @@ def run_test_feature(assertion, feature_key, instance, level) actual_value = get_evaluation_value(evaluation, key) if !compare_values(actual_value, expected_value) has_error = true - errors += " ✘ expectedEvaluations.variables.#{variable_key}.#{key}: expected #{expected_value} but received #{actual_value}\n" + errors << expectation_error( + "evaluation", expected_value, actual_value, + variableKey: variable_key, evaluationKey: key + ) end end end @@ -554,24 +688,25 @@ def run_test_feature(assertion, feature_key, instance, level) # Test children if assertion[:children] - assertion[:children].each do |child| + assertion[:children].each_with_index do |child, child_index| if child.is_a?(Hash) child_context = parse_context(child[:context]) # Create override options for child with sticky values child_instance = instance.spawn( child_context, - sticky_features: sticky || {} + sticky_features: parse_sticky(child[:stickyFeatures]) || {}, + sticky_variables: child[:stickyVariables].is_a?(Hash) ? child[:stickyVariables] : {} ) begin - child_result = run_test_feature_child(child, feature_key, child_instance, level) + child_result = run_test_feature_child(child, feature_key, child_instance, level, child_index) ensure child_instance.close end if child_result[:has_error] has_error = true - errors += child_result[:errors] + errors.concat(child_result[:errors]) end end end @@ -586,12 +721,13 @@ def run_test_feature(assertion, feature_key, instance, level) } end - def run_test_feature_child(assertion, feature_key, instance, level) + def run_test_feature_child(assertion, feature_key, instance, level, child_index = nil) context = parse_context(assertion[:context]) override_options = create_override_options(assertion) has_error = false - errors = "" + errors = [] + child_details = child_index.nil? ? {} : { childIndex: child_index } start_time = Time.now # Test expectedToBeEnabled @@ -601,7 +737,7 @@ def run_test_feature_child(assertion, feature_key, instance, level) if is_enabled != expected_to_be_enabled has_error = true - errors += " ✘ expectedToBeEnabled: expected #{expected_to_be_enabled} but received #{is_enabled}\n" + errors << expectation_error("flag", expected_to_be_enabled, is_enabled, child_details) end end @@ -613,7 +749,7 @@ def run_test_feature_child(assertion, feature_key, instance, level) variation_value = variation.nil? ? nil : variation if !compare_values(variation_value, expected_variation) has_error = true - errors += " ✘ expectedVariation: expected #{expected_variation} but received #{variation_value}\n" + errors << expectation_error("variation", expected_variation, variation_value, child_details) end end @@ -644,8 +780,10 @@ def run_test_feature_child(assertion, feature_key, instance, level) if !passed has_error = true - actual_json = actual_value.to_json - errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_json}\n" + errors << expectation_error( + "variable", expected_value, actual_value, + child_details.merge(variableKey: variable_key) + ) end next rescue JSON::ParserError @@ -656,7 +794,10 @@ def run_test_feature_child(assertion, feature_key, instance, level) # Regular comparison for non-JSON strings or when JSON parsing fails if !compare_values(actual_value, expected_value) has_error = true - errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_value}\n" + errors << expectation_error( + "variable", expected_value, actual_value, + child_details.merge(variableKey: variable_key) + ) end end end @@ -671,7 +812,10 @@ def run_test_feature_child(assertion, feature_key, instance, level) actual_value = get_evaluation_value(evaluation, key) if !compare_values(actual_value, expected_value) has_error = true - errors += " ✘ expectedEvaluations.flag.#{key}: expected #{expected_value} but received #{actual_value}\n" + errors << expectation_error( + "evaluation", expected_value, actual_value, + child_details.merge(evaluationType: "flag", evaluationKey: key) + ) end end end @@ -682,7 +826,10 @@ def run_test_feature_child(assertion, feature_key, instance, level) actual_value = get_evaluation_value(evaluation, key) if !compare_values(actual_value, expected_value) has_error = true - errors += " ✘ expectedEvaluations.variation.#{key}: expected #{expected_value} but received #{actual_value}\n" + errors << expectation_error( + "evaluation", expected_value, actual_value, + child_details.merge(evaluationType: "variation", evaluationKey: key) + ) end end end @@ -702,7 +849,10 @@ def run_test_feature_child(assertion, feature_key, instance, level) actual_value = get_evaluation_value(evaluation, key) if !compare_values(actual_value, expected_value) has_error = true - errors += " ✘ expectedEvaluations.variables.#{variable_key}.#{key}: expected #{expected_value} but received #{actual_value}\n" + errors << expectation_error( + "evaluation", expected_value, actual_value, + child_details.merge(variableKey: variable_key, evaluationKey: key) + ) end end end @@ -719,35 +869,27 @@ def run_test_feature_child(assertion, feature_key, instance, level) } end - def run_test_segment(assertion, segment, level) + def run_test_segment(assertion, segment, level, selected = nil, segments = {}, segment_key = "subject", set = @active_set) + return { has_error: true, errors: "segment was not found", duration: 0 } unless segment.is_a?(Hash) context = parse_context(assertion[:context]) - conditions = segment[:conditions] - - # Create a minimal datafile for segment testing - datafile = { - schemaVersion: "2", - revision: "tester", - features: {}, - segments: {} - } - - # Create SDK instance for segment testing - instance = Featurevisor.create_featurevisor( - datafile: datafile, - log_level: level - ) + segments = segments.merge(segment_key.to_sym => segment) + datafile = selected || { schemaVersion: "4", revision: "tester", features: {}, segments: {}, variables: {} } + SegmentTests.references!(segment_key, datafile, segments) + datafile = SegmentTests.prepare(datafile, segments) + instance, = create_tester_instance(datafile, level, assertion, set) + instance.instance_variable_set(:@__segment_test_keys, datafile[:_segment_test_keys]) has_error = false - errors = "" + errors = [] start_time = Time.now if assertion.key?(:expectedToMatch) expected_to_match = assertion[:expectedToMatch] - actual = instance.instance_variable_get(:@datafile).all_conditions_are_matched(conditions, context) + actual = segment_matcher(instance).call(segment_key, context) if actual != expected_to_match has_error = true - errors += " ✘ expectedToMatch: expected #{expected_to_match} but received #{actual}\n" + errors << expectation_error("segment", expected_to_match, actual) end end @@ -758,6 +900,29 @@ def run_test_segment(assertion, segment, level) errors: errors, duration: duration } + rescue FeaturevisorCLI::TestChecks::CheckError => error + { has_error: true, errors: [expectation_error("segment", "valid references", error.message)], duration: 0 } + ensure + instance&.close + end + + def segment_matcher(instance, segments = {}, keys = nil, diagnostics = nil) + keys ||= instance.instance_variable_get(:@__segment_test_keys) + keys = {} if !keys && segments.empty? + unless keys + datafile = SegmentTests.prepare(instance.instance_variable_get(:@datafile).get_datafile, segments) + instance.set_datafile(datafile, true) + keys = datafile[:_segment_test_keys] + instance.instance_variable_set(:@__segment_test_keys, keys) + end + diagnostics ||= instance.instance_variable_get(:@__segment_test_diagnostics) || [] + lambda do |key, context| + diagnostics.clear + evaluation = instance.evaluate_flag(keys.fetch(key.to_s), context) + failed = diagnostics.any? { |item| %w[error fatal].include?(item[:level].to_s) } + raise TestChecks::CheckError, "segment #{key.inspect} evaluation failed" if evaluation[:reason] == "error" || failed + evaluation[:enabled] == true + end end def parse_context(context_data) @@ -865,12 +1030,10 @@ def get_evaluation_value(evaluation, key) evaluation[:force_index] when :force evaluation[:force] - when :required - evaluation[:required] - when :requiredFeatures - evaluation[:required_features] - when :sticky - evaluation[:sticky] + when :conditions + evaluation[:conditions] + when :stickyFeature + evaluation[:sticky_feature] when :variation evaluation[:variation] when :variationValue @@ -887,6 +1050,8 @@ def get_evaluation_value(evaluation, key) evaluation[:variable_override_key] when :variableOverridePath evaluation[:variable_override_path] + when :variableOverridePaths + evaluation[:variable_override_paths] else nil end @@ -989,16 +1154,326 @@ def normalize_hash_keys(obj) end end + def empty_report + { + schemaVersion: 1, + runner: "ruby", + 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: [] + } + end + def merge_report!(destination, source) + destination[:results].concat(source[:results]) + destination[:errors].concat(source[:errors]) + end + def finalize_report!(report) + summary = empty_report[:summary] + report[:results].each do |result| + spec_bucket = result[:type] == "scenario" ? :scenarioSpecs : :entitySpecs + case_bucket = result[:type] == "scenario" ? :scenarioCases : :entityAssertions + spec_state = result[:passed] ? :passed : :failed + summary[:specs][spec_state] += 1 + summary[spec_bucket][spec_state] += 1 + result[:cases].each do |item| + case_state = item[:passed] ? :passed : :failed + summary[:assertions][case_state] += 1 + summary[case_bucket][case_state] += 1 + end + end + report[:summary] = summary + report[:passed] = report[:errors].empty? && summary[:specs][:failed].zero? && summary[:assertions][:failed].zero? + report[:uncovered] = report[:uncovered].sort_by { |item| item[:set].to_s } if report.key?(:uncovered) + report + end + + def error_objects(value, type: nil, assertion: nil) + errors = if value.is_a?(Array) + value.map { |error| error.is_a?(Hash) ? deep_copy(error) : { message: error.to_s } } + else + value.to_s.lines.filter_map do |line| + message = line.sub(/^\s*✘\s*/, "").strip + next if message.empty? + { message: message } + end + end + return errors unless type && assertion + + errors.map do |error| + if error[:type] == "check" + decorate_check_error(error, type, assertion) + else + decorate_expectation_error(error, type, assertion) + end + end + end + + def decorate_check_error(error, type, assertion) + return error unless error[:type] == "check" + + details = error[:details].is_a?(Hash) ? error[:details] : {} + assertion_index = assertion.dig(:_case, :assertionIndex) + base = type == :scenario ? [] : ["assertions", assertion_index || 0] + base += ["children", details[:childIndex]] if details.key?(:childIndex) + collection = "checks" + error[:path] = base + [collection] + error[:path] << details[:checkIndex] if details.key?(:checkIndex) + if details[:evaluated].is_a?(Array) + details[:evaluated] = details[:evaluated].map do |leaf| + leaf.merge(path: base + [collection] + Array(leaf[:path])) + end + end + if error[:expected] == true && error[:actual] == false + error[:message] = "Check did not match" + end + error + end + + def decorate_expectation_error(error, type, assertion) + details = error[:details].is_a?(Hash) ? error[:details] : {} + assertion_index = assertion.dig(:_case, :assertionIndex) || 0 + path = ["assertions", assertion_index] + path += ["children", details[:childIndex]] if details.key?(:childIndex) + suffix = case error[:type] + when "flag" then ["expectedToBeEnabled"] + when "segment" then ["expectedToMatch"] + when "variation" then ["expectedVariation"] + when "variable" + type == :variable ? ["expectedValue"] : ["expectedVariables", details[:variableKey]] + when "evaluation" + if type == :variable + ["expectedEvaluation", details[:evaluationKey]] + elsif details.key?(:variableKey) + ["expectedEvaluations", "variables", details[:variableKey], details[:evaluationKey]] + else + ["expectedEvaluations", details[:evaluationType], details[:evaluationKey]] + end + else + [] + end + error[:path] = path + suffix unless suffix.empty? + error[:message] ||= "expected #{format_test_value(error[:expected])}, received #{format_test_value(error[:actual])}" + error + end + + def deep_copy(value) + Marshal.load(Marshal.dump(value)) + end + + def normalized_environment(environment) + environment == false ? nil : environment + end + + def rerun_instruction(assertion, set) + args = ["test"] + args << "--case=#{assertion.dig(:_case, :id)}" if assertion.dig(:_case, :id) + if @options.project_directory_path_explicit + args << "--projectDirectoryPath=#{@options.project_directory_path}" + end + args << "--set=#{set}" if set + @options.targets.each { |target| args << "--target=#{target}" } + { command: "bundle exec featurevisor", args: args } + end + + def progress(message) + stream = @options.json ? $stderr : $stdout + stream.puts(message) + end + + def print_report(report) + if @options.json + puts(@options.pretty ? JSON.pretty_generate(report) : JSON.generate(report)) + return + end + + if report[:results].empty? && report[:errors].empty? + puts "No tests found" + end + report[:results].each do |result| + next if @options.only_failures && result[:passed] + puts "\nTesting: #{result[:source]}" + result[:cases].each_with_index do |item, index| + next if @options.only_failures && item[:passed] + puts " #{item[:passed] ? '✔' : '✘'} #{human_case_label(item, index)}" + item[:errors].each do |error| + human_error_lines(error).each { |line| puts " ✘ #{line}" } + end + next if item[:passed] || item.dig(:rerun, :args).nil? + command = "#{item.dig(:rerun, :command)} #{item.dig(:rerun, :args).map { |arg| Shellwords.escape(arg) }.join(' ')}" + puts " Rerun (POSIX shell): #{command}" + end + end + report[:errors].each { |error| puts "Error: #{error[:message]}" } + summary = report[:summary] + puts "" + puts "Entity specs: #{summary[:entitySpecs][:passed]} passed, #{summary[:entitySpecs][:failed]} failed" + puts "Entity assertions: #{summary[:entityAssertions][:passed]} passed, #{summary[:entityAssertions][:failed]} failed" + puts "Scenario specs: #{summary[:scenarioSpecs][:passed]} passed, #{summary[:scenarioSpecs][:failed]} failed" + puts "Scenario cases: #{summary[:scenarioCases][:passed]} passed, #{summary[:scenarioCases][:failed]} failed" + puts "Total cases: #{summary[:assertions][:passed]} passed, #{summary[:assertions][:failed]} failed" + if report[:uncovered] + puts "\nDefinitions without associated specs (not evaluation coverage):" + report[:uncovered].each do |item| + puts " Set: #{item[:set]}" if item[:set] + puts " Features: #{item[:features].empty? ? 'none' : item[:features].join(', ')}" + puts " Global variables: #{item[:variables].empty? ? 'none' : item[:variables].join(', ')}" + end + end + if report[:coverage] + puts "\nObserved rule and override selections (not branch coverage):" + selections = report.dig(:coverage, :selections) + selections.each { |selection| puts " #{human_coverage_selection(selection)}" } + puts " No selections observed" if selections.empty? + end + puts "" + end + + def human_case_label(item, index) + label = item[:description].to_s.empty? ? "assertion #{index + 1}" : item[:description] + details = ["case #{index + 1}"] + matrix = item[:matrixValues] + if matrix.is_a?(Hash) && !matrix.empty? + details << "matrix #{matrix.map { |key, value| "#{key}=#{JSON.generate(value)}" }.join(', ')}" + end + details << "target #{item[:target]}" if item[:target] + "#{label} (#{details.join('; ')})" + end + + def human_coverage_selection(selection) + owner = if selection[:featureKey] + "feature #{selection[:featureKey]}#{selection[:variableKey] ? " variable #{selection[:variableKey]}" : ''}" + else + "global variable #{selection[:variableKey]}" + end + scope = %i[set environment target].filter_map { |key| "#{key} #{selection[key]}" if selection[key] } + picks = [] + picks << "rule #{selection[:ruleKey]}" if selection[:ruleKey] + picks << "variation #{JSON.generate(selection[:variation])}" if selection.key?(:variation) + picks << "override #{Array(selection[:overridePath]).join(' > ')}" if selection[:overridePath] + picks << "override #{selection[:overrideIndex] + 1}" if selection.key?(:overrideIndex) + "#{owner}#{scope.empty? ? '' : " (#{scope.join(', ')})"}: #{picks.join(', ')}" + end + + def human_error_lines(error) + details = error[:details] + return [error[:message]] unless error[:type] == "check" && details.is_a?(Hash) + + named = details[:checkKey] ? " #{details[:checkKey].inspect}" : "" + description = details[:checkDescription] ? " (#{details[:checkDescription]})" : "" + lines = ["#{human_check_message(error[:message])}#{named}#{description} at #{human_path(error[:path])}"] + Array(details[:evaluated]).each do |leaf| + reference = human_reference(leaf[:reference]) + comparison = leaf[:compareTo] ? human_reference(leaf[:compareTo]) : "literal" + reference = "#{reference}.#{leaf[:property]}" if leaf[:property] + lines << "#{human_path(leaf[:path])}: #{reference} #{leaf[:operator]} #{comparison} " \ + "(left=#{JSON.generate(leaf[:actual])}, right=#{JSON.generate(leaf[:expected])})" + end + lines + end + + def human_check_message(message) + message.to_s.gsub(/\b(checks|children)\[(\d+)\]/) do + "#{Regexp.last_match(1) == 'children' ? 'Child' : 'check'} #{Regexp.last_match(2).to_i + 1}" + end + end + + def human_path(path) + Array(path).each_with_index.map do |part, index| + part.is_a?(Integer) ? "[#{part + 1}]" : index.zero? ? part.to_s : ".#{part}" + end.join + end + + def human_reference(reference) + return "" unless reference.is_a?(Hash) + variable = reference[:variable] + root = reference[:feature] ? [reference[:feature], variable].compact.join(".") : variable + reference.key?(:path) ? "#{root}:#{JSON.generate(reference[:path])}" : root.to_s + end + + def get_uncovered(set) + { + set: set, + features: get_uncovered_entities("features", set), + variables: get_uncovered_entities("variables", set) + } + end + + def get_uncovered_entities(selector, set) + command = node_command("list", "--#{selector}", "--without-tests", "--json", *set_arguments(set)) + output = JSON.parse(execute_command(command), symbolize_names: true) + Array(output).filter_map { |item| item.is_a?(Hash) ? item[:key] : item }.map(&:to_s).sort + rescue JSON::ParserError => e + raise InfrastructureError, "Failed to parse uncovered #{selector} JSON: #{e.message}" + end + + def record_coverage_selection(evaluation, assertion, set) + rule_key = evaluation[:rule_key] + paths = Array(evaluation[:variable_override_paths]) + paths = [evaluation[:variable_override_path]] if paths.empty? && evaluation[:variable_override_path] + paths = [evaluation[:variable_override_key]] if paths.empty? && evaluation[:variable_override_key] + paths = [nil] if paths.empty? && evaluation.key?(:variable_override_index) + paths = [nil] if paths.empty? && rule_key + paths.each do |path| + next unless rule_key || path || evaluation.key?(:variable_override_index) + item = { + set: set, + environment: normalized_environment(assertion[:environment]), + target: assertion[:target] + } + item[:featureKey] = evaluation[:feature_key] if evaluation[:feature_key] + item[:variableKey] = evaluation[:variable_key] if evaluation[:variable_key] + item[:ruleKey] = rule_key if rule_key + item[:variation] = evaluation.dig(:variation, :value) if evaluation[:variation].is_a?(Hash) && evaluation[:variation].key?(:value) + item[:overridePath] = path if path + item[:overrideIndex] = evaluation[:variable_override_index] if !path && evaluation.key?(:variable_override_index) + @coverage_selections << item + end + end + + def coverage_report + unique = @coverage_selections.each_with_object({}) do |selection, items| + items[JSON.generate(selection)] = selection + end.values + { + mode: "observed-selections", + selections: unique.sort_by { |item| JSON.generate(item) } + } + end + + + + # Node CLI calls pass an argument list run in the project directory. def execute_command(command) - stdout, stderr, status = Open3.capture3(command) + stdout, stderr, status = + command.is_a?(Array) ? Open3.capture3(*command, chdir: @project_path) : Open3.capture3(command) + stdout.force_encoding(Encoding::UTF_8) + stderr.force_encoding(Encoding::UTF_8) unless status.success? - puts "Error: Command failed with exit code #{status.exitstatus}" - puts "Command: #{command}" - puts "Stderr: #{stderr}" unless stderr.empty? - exit 1 + begin + payload = JSON.parse(stderr) + error = payload["error"] || {} + if error["message"] || error["code"] + raise InfrastructureError.new( + error["message"] || "Command failed with exit code #{status.exitstatus}", + code: error["code"] + ) + end + rescue JSON::ParserError + nil + end + details = stderr.empty? ? "" : ": #{stderr.strip}" + raise InfrastructureError, "Command failed with exit code #{status.exitstatus}#{details}" end stdout diff --git a/bin/segment_tests.rb b/bin/segment_tests.rb new file mode 100644 index 0000000..74ae7b7 --- /dev/null +++ b/bin/segment_tests.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module FeaturevisorCLI + module SegmentTests + module_function + + def prepare(datafile, segments) + return datafile if segments.empty? + + definitions = segments.transform_keys(&:to_sym).merge((datafile[:segments] || {}).transform_keys(&:to_sym)) + features = (datafile[:features] || {}).dup + keys = {} + segments.each_key do |key| + helper = "__featurevisor_segment_test__#{key}" + helper += "_" while features.key?(helper.to_sym) || features.key?(helper) + keys[key.to_s] = helper + features[helper.to_sym] = { bucketBy: "__segment_test__", conditions: key.to_s, + traffic: [{ key: "segment", conditions: "*", percentage: 100_000 }] } + end + datafile.merge(features: features, segments: definitions, _segment_test_keys: keys) + end + + def references!(key, datafile, segments) + pending = [[:segment, key, nil]] + seen = {} + expression = nil + expression = lambda do |value| + case value + when String + next if value == "*" + if value.lstrip.match?(/\A[\{\[\"]/) + begin + expression.call(JSON.parse(value, symbolize_names: true)) + rescue JSON::ParserError + nil + end + else + pending << [:segment, value, nil] + end + when Array + value.each { |child| expression.call(child) } + when Hash + if value.key?(:variable) + pending << [value[:feature] ? :feature : :variable, value[:feature] || value[:variable], value[:feature] && value[:variable]] + elsif value.key?(:feature) + pending << [:feature, value[:feature], nil] + else + %i[and or not].each { |group| expression.call(value[group]) if value.key?(group) } + end + end + end + cursor = 0 + while cursor < pending.length + kind, name, variable = pending[cursor] + cursor += 1 + identity = [kind, name.to_s, variable] + next if seen[identity] + seen[identity] = true + definitions = datafile[{ feature: :features, segment: :segments, variable: :variables }.fetch(kind)] || {} + definition = kind == :segment ? TestChecks.keyed(segments, name) : nil + definition ||= TestChecks.keyed(definitions, name) + unless definition.is_a?(Hash) + raise TestChecks::CheckError, "#{kind} #{name.inspect} is absent from the selected datafile and test definitions" + end + if variable && !TestChecks.keyed(definition[:variablesSchema], variable) + raise TestChecks::CheckError, "variable #{variable.inspect} is absent from feature #{name.inspect} in the selected datafile" + end + normalized, = Featurevisor::Normalize.entity(kind, name, definition) + expression.call(normalized[:conditions]) + if kind == :feature + (Array(normalized[:force]) + Array(normalized[:traffic])).each { |entry| expression.call(entry[:conditions]) } + if variable + (Array(normalized[:traffic]) + Array(normalized[:variations])).each do |entry| + Array(TestChecks.keyed(entry[:variableOverrides], variable)).each { |override| expression.call(override[:conditions]) } + end + end + elsif kind == :variable + (Array(normalized[:overrides]) + Array(normalized[:overrideProgram])).each { |entry| expression.call(entry[:conditions]) } + compose = normalized[:compose] + if compose.is_a?(String) + pending << [:variable, compose, nil] + elsif compose.is_a?(Array) + compose.each { |reference| expression.call(reference) } + end + end + end + end + end +end diff --git a/bin/test_checks.rb b/bin/test_checks.rb new file mode 100644 index 0000000..7a52c47 --- /dev/null +++ b/bin/test_checks.rb @@ -0,0 +1,767 @@ +require "json" +require "date" +require_relative "segment_tests" + +module FeaturevisorCLI + module TestChecks + MISSING = Object.new.freeze + MAX_SAFE_INTEGER = 9_007_199_254_740_991 + UNSAFE_PATH_PARTS = %w[__proto__ prototype constructor].freeze + VALUELESS = %w[exists notExists isNull notNull empty notEmpty].freeze + OPERATORS = (VALUELESS + %w[ + 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 + ]).freeze + + class CheckError < StandardError; end + + module_function + + def evaluate(evaluator, datafile, checks, owner_variable:, diagnostics:, child_index: nil, segments: {}, context: {}, segment_matcher: nil) + evaluate_result( + evaluator, + datafile, + checks, + owner_variable: owner_variable, + diagnostics: diagnostics, + child_index: child_index, + segments: segments, + context: context, + segment_matcher: segment_matcher + )[:failures] + end + + def evaluate_result(evaluator, datafile, checks, owner_variable:, diagnostics:, child_index: nil, segments: {}, context: {}, segment_matcher: nil) + unless checks.is_a?(Array) && !checks.empty? + errors = [comparison_error("checks must be a nonempty array", "valid checks", checks, child_index: child_index)] + return result_from_errors(errors, true) + end + + resolver = Resolver.new(evaluator, datafile, diagnostics, owner_variable, segments, context, segment_matcher) + begin + validate_sibling_keys(checks, "checks") + rescue CheckError => e + errors = [comparison_error(e.message, "valid checks", checks, child_index: child_index)] + return result_from_errors(errors, true) + end + validation_errors = checks.each_with_index.filter_map do |check, index| + path = "checks[#{index}]" + begin + validate_node(check, owner_variable, path) + nil + rescue CheckError => e + message = e.message.start_with?(path) ? e.message : "#{path}: #{e.message}" + comparison_error( + message, + "valid check", + check, + check_index: index, + check: check, + child_index: child_index + ) + end + end + unless validation_errors.empty? + return result_from_errors(validation_errors, true) + end + begin + checks.each_with_index do |check, index| + preflight_node(check, datafile, owner_variable, segments, "checks[#{index}]") + end + rescue CheckError => e + errors = [comparison_error(e.message, "valid references", checks, child_index: child_index)] + return result_from_errors(errors, true) + end + + has_error = false + errors = checks.each_with_index.filter_map do |check, index| + evaluated = [] + begin + matched = evaluate_node(check, resolver, [index], evaluated) + unless matched + comparison_error( + format_failed_check(check, evaluated), + true, + false, + check_index: index, + check: check, + child_index: child_index, + evaluated: evaluated + ) + end + rescue CheckError => e + has_error = true + comparison_error( + e.message, + "successful comparison", + e.message, + check_index: index, + check: check, + child_index: child_index, + evaluated: evaluated + ) + end + end + result_from_errors(errors, has_error) + end + + def preflight!(datafile, checks, owner_variable:, segments: {}) + raise CheckError, "checks must be a nonempty array" unless checks.is_a?(Array) && !checks.empty? + validate_sibling_keys(checks, "checks") + checks.each_with_index { |check, index| validate_node(check, owner_variable, "checks[#{index}]") } + checks.each_with_index do |check, index| + preflight_node(check, datafile, owner_variable, segments, "checks[#{index}]") + end + end + + def result_from_errors(errors, has_error) + { passed: errors.empty?, error: has_error, failures: errors.map { |error| error[:message] }, errors: errors } + end + + def comparison_error(message, expected, actual, check_index: nil, check: nil, child_index: nil, evaluated: nil) + details = {} + details[:checkIndex] = check_index unless check_index.nil? + details[:checkKey] = check[:key] if check.is_a?(Hash) && check.key?(:key) + details[:checkDescription] = check[:description] if check.is_a?(Hash) && check.key?(:description) + details[:evaluated] = evaluated if evaluated + details[:childIndex] = child_index unless child_index.nil? + { type: "check", message: message, expected: expected, actual: actual, details: details } + end + + def validate_sibling_keys(nodes, path) + seen = {} + nodes.each_with_index do |node, index| + next unless node.is_a?(Hash) && node.key?(:key) + key = node[:key] + raise CheckError, "#{path}[#{index}].key: key must be a nonempty string" unless key.is_a?(String) && !key.empty? + if seen.key?(key) + raise CheckError, "#{path}[#{index}].key: duplicate sibling key #{key.inspect}, first used at #{path}[#{seen[key]}]" + end + seen[key] = index + end + end + + def keyed(hash, key) + return nil unless hash.is_a?(Hash) + hash[key.to_sym] || hash[key.to_s] + end + + def validate_reference_membership(reference, datafile, owner_variable, path) + variable = reference[:variable] || owner_variable + feature = reference[:feature] + if feature + definition = keyed(keyed(datafile, :features), feature) + raise CheckError, "#{path}: feature #{feature.inspect} is absent from the selected datafile" unless definition.is_a?(Hash) + variables = keyed(definition, :variablesSchema) + unless keyed(variables, variable) + raise CheckError, "#{path}: variable #{variable.inspect} is absent from feature #{feature.inspect} in the selected datafile" + end + elsif keyed(keyed(datafile, :variables), variable).nil? + raise CheckError, "#{path}: global variable #{variable.inspect} is absent from the selected datafile" + end + end + + def preflight_node(node, datafile, owner_variable, segments, path) + if segment_check?(node) + definition = keyed(segments, node[:segment]) + unless definition.is_a?(Hash) && (definition.key?(:conditions) || definition.key?("conditions")) + raise CheckError, "#{path}: segment #{node[:segment].inspect} definition is missing from the normalized test spec" + end + SegmentTests.references!(node[:segment], datafile, segments) + return + end + %i[and or not].each do |group| + next unless node.key?(group) + node[group].each_with_index do |child, index| + preflight_node(child, datafile, owner_variable, segments, "#{path}.#{group}[#{index}]") + end + return + end + if feature_check?(node) + unless keyed(keyed(datafile, :features), node[:feature]).is_a?(Hash) + raise CheckError, "#{path}: feature #{node[:feature].inspect} is absent from the selected datafile" + end + return + end + validate_reference_membership(node, datafile, owner_variable, path) + validate_reference_membership(node[:compareTo], datafile, owner_variable, "#{path}.compareTo") if node.key?(:compareTo) + end + + def validate_node(node, owner_variable, path) + raise CheckError, "#{path}: check must be an object" unless node.is_a?(Hash) + if node.key?(:key) && (!node[:key].is_a?(String) || node[:key].empty?) + raise CheckError, "#{path}.key: key must be a nonempty string" + end + if node.key?(:description) && !node[:description].is_a?(String) + raise CheckError, "#{path}.description: description must be a string" + end + if segment_check?(node) + validate_segment_check(node, path) + return + end + groups = %i[and or not].select { |key| node.key?(key) } + unless groups.empty? + allowed = [groups.first, :key, :description] + unknown = node.keys.find { |key| !allowed.include?(key) } + unless groups.length == 1 && unknown.nil? + raise CheckError, "#{path}: group must contain exactly one of and, or or not plus optional key and description" + end + children = node[groups.first] + raise CheckError, "#{path}.#{groups.first}: group must be a nonempty array" unless children.is_a?(Array) && !children.empty? + validate_sibling_keys(children, "#{path}.#{groups.first}") + children.each_with_index { |child, index| validate_node(child, owner_variable, "#{path}.#{groups.first}[#{index}]") } + return + end + if feature_check?(node) + validate_feature_check(node, path) + return + end + operator = node[:operator] + allowed = %i[variable feature path operator value compareTo regexFlags key description] + unknown = node.keys.find { |key| !allowed.include?(key) } + raise CheckError, "#{path}: unknown check property #{unknown.inspect}" if unknown + raise CheckError, "#{path}: unknown check operator #{operator.inspect}" unless OPERATORS.include?(operator) + if node.key?(:feature) && (!node[:feature].is_a?(String) || node[:feature].empty?) + raise CheckError, "#{path}: feature must be a nonempty string" + end + if node.key?(:variable) && (!node[:variable].is_a?(String) || node[:variable].empty?) + raise CheckError, "#{path}: variable must be a nonempty string" + end + raise CheckError, "#{path}: a feature reference requires an explicit variable" if node.key?(:feature) && !node.key?(:variable) + raise CheckError, "#{path}: variable reference requires an explicit variable" unless node[:variable] || owner_variable + raise CheckError, "#{path}: path must not be null" if node.key?(:path) && node[:path].nil? + path_parts(node[:path]) + supplied = (node.key?(:value) ? 1 : 0) + (node.key?(:compareTo) ? 1 : 0) + if VALUELESS.include?(operator) + raise CheckError, "#{path}: operator #{operator.inspect} accepts neither value nor compareTo" unless supplied.zero? + elsif supplied != 1 + raise CheckError, "#{path}: exactly one of value or compareTo is required" + end + validate_literal(node, path) if node.key?(:value) + if node.key?(:regexFlags) && !%w[matches notMatches].include?(operator) + raise CheckError, "#{path}: regexFlags are only allowed with matches or notMatches" + end + portable_regex_flags(node[:regexFlags], allow_nil: false) if node.key?(:regexFlags) + return unless node.key?(:compareTo) + other = node[:compareTo] + raise CheckError, "#{path}.compareTo: reference must be an object" unless other.is_a?(Hash) + if other.key?(:feature) && (!other[:feature].is_a?(String) || other[:feature].empty?) + raise CheckError, "#{path}.compareTo: feature must be a nonempty string" + end + if other.key?(:variable) && (!other[:variable].is_a?(String) || other[:variable].empty?) + raise CheckError, "#{path}.compareTo: variable must be a nonempty string" + end + raise CheckError, "#{path}.compareTo: a feature reference requires an explicit variable" if other.key?(:feature) && !other.key?(:variable) + raise CheckError, "#{path}.compareTo: variable reference requires an explicit variable" unless other[:variable] || owner_variable + unknown = other.keys.find { |key| !%i[variable feature path].include?(key) } + raise CheckError, "#{path}.compareTo: unknown reference property #{unknown.inspect}" if unknown + raise CheckError, "#{path}.compareTo: path must not be null" if other.key?(:path) && other[:path].nil? + path_parts(other[:path]) + end + + def feature_check?(node) + node.key?(:enabled) || node.key?(:variation) || + (node.key?(:feature) && !node.key?(:variable) && !node.key?(:operator)) + end + + def segment_check?(node) + node.key?(:segment) || node.key?(:matches) + end + + def validate_segment_check(node, path) + unknown = node.keys.find { |key| !%i[segment matches key description].include?(key) } + raise CheckError, "#{path}: segment check cannot contain property #{unknown.inspect}" if unknown + unless node[:segment].is_a?(String) && !node[:segment].empty? + raise CheckError, "#{path}: segment check requires a nonempty segment" + end + unless node.key?(:matches) && [true, false].include?(node[:matches]) + raise CheckError, "#{path}: segment #{node[:segment].inspect} matches must be a boolean" + end + end + + def validate_feature_check(node, path) + unknown = node.keys.find { |key| !%i[feature enabled variation key description].include?(key) } + raise CheckError, "#{path}: feature check cannot contain property #{unknown.inspect}" if unknown + unless node[:feature].is_a?(String) && !node[:feature].empty? + raise CheckError, "#{path}: feature must be a nonempty string" + end + unless node.key?(:enabled) || node.key?(:variation) + raise CheckError, "#{path}: feature check requires enabled or variation" + end + if node.key?(:enabled) && ![true, false].include?(node[:enabled]) + raise CheckError, "#{path}: enabled must be a boolean" + end + if node.key?(:variation) && !node[:variation].nil? && !node[:variation].is_a?(String) + raise CheckError, "#{path}: variation must be a string or null" + end + end + + def validate_literal(node, path) + operator = node[:operator] + value = node[:value] + numeric = %w[greaterThan greaterThanOrEquals lessThan lessThanOrEquals] + strings = %w[contains notContains startsWith endsWith matches notMatches semverEquals semverNotEquals semverGreaterThan semverGreaterThanOrEquals semverLessThan semverLessThanOrEquals before after] + if %w[equals notEquals].include?(operator) && !primitive?(value) + raise CheckError, "#{path}: operator #{operator.inspect} requires a scalar value" + elsif numeric.include?(operator) && (!value.is_a?(Numeric) || !value.finite?) + raise CheckError, "#{path}: operator #{operator.inspect} requires a numeric value" + elsif strings.include?(operator) && !value.is_a?(String) + raise CheckError, "#{path}: operator #{operator.inspect} requires a string value" + elsif %w[in notIn].include?(operator) && (!value.is_a?(Array) || !value.all? { |item| membership_primitive?(item) }) + raise CheckError, "#{path}: operator #{operator.inspect} requires an array of string, number or null values" + elsif %w[includes notIncludes].include?(operator) && !membership_primitive?(value) + raise CheckError, "#{path}: operator #{operator.inspect} requires a scalar value" + elsif %w[includesAny notIncludesAny].include?(operator) && (!value.is_a?(Array) || !value.all? { |item| primitive?(item) }) + raise CheckError, "#{path}: operator #{operator.inspect} requires an array of primitive values" + end + portable_regex(value, node[:regexFlags]) if %w[matches notMatches].include?(operator) + portable_datetime(value) if %w[before after].include?(operator) + Featurevisor.compare_versions(value, value) if operator.start_with?("semver") + rescue TypeError, ArgumentError => e + raise CheckError, e.message + end + + def evaluate_node(node, resolver, path, evaluated) + %i[and or not].each do |group| + next unless node.key?(group) + if %i[and not].include?(group) + node[group].each_with_index do |child, index| + matched = evaluate_node(child, resolver, path + [group.to_s, index], evaluated) + return group == :not unless matched + end + return group != :not + end + node[group].each_with_index do |child, index| + matched = evaluate_node(child, resolver, path + [group.to_s, index], evaluated) + return true if matched + end + return false + end + + return evaluate_segment_check(node, resolver, path, evaluated) if segment_check?(node) + return evaluate_feature_check(node, resolver, path, evaluated) if feature_check?(node) + + left_ref = node.select { |key, _| %i[variable feature path].include?(key) } + trace = { + path: path, + reference: normalized_reference(left_ref, resolver.owner_variable), + operator: node[:operator], + actual: missing_value, + expected: node.key?(:compareTo) ? missing_value : report_operand(node.key?(:value) ? node[:value] : MISSING) + } + trace[:key] = node[:key] if node.key?(:key) + trace[:description] = node[:description] if node.key?(:description) + if node.key?(:compareTo) + trace[:compareTo] = normalized_reference(node[:compareTo], resolver.owner_variable) + end + evaluated << trace + begin + left = resolver.resolve(left_ref) + rescue CheckError => e + raise CheckError, "#{format_path(path)} left operand (#{reference_label(left_ref, resolver.owner_variable)}): #{e.message}" + end + trace[:actual] = report_operand(left) + trace[:actualMissing] = true if left.equal?(MISSING) + if node.key?(:compareTo) + right_ref = node[:compareTo] + begin + right = resolver.resolve(right_ref) + rescue CheckError => e + raise CheckError, "#{format_path(path)} compareTo operand (#{reference_label(right_ref, resolver.owner_variable)}): #{e.message}" + end + else + right = node.key?(:value) ? node[:value] : MISSING + end + trace[:expected] = report_operand(right) + trace[:expectedMissing] = true if node.key?(:compareTo) && right.equal?(MISSING) + compare(node[:operator], left, right, node[:regexFlags]) + end + + def evaluate_feature_check(node, resolver, path, evaluated) + feature = node[:feature] + matched = true + if node.key?(:enabled) + matched = evaluate_feature_property(node, resolver, path, evaluated, :enabled) && matched + end + if node.key?(:variation) + matched = evaluate_feature_property(node, resolver, path, evaluated, :variation) && matched + end + matched + end + + def evaluate_segment_check(node, resolver, path, evaluated) + trace = { + path: path, + reference: { segment: node[:segment] }, + property: "matches", + operator: "equals", + actual: missing_value, + expected: node[:matches] + } + trace[:key] = node[:key] if node.key?(:key) + trace[:description] = node[:description] if node.key?(:description) + evaluated << trace + actual = resolver.resolve_segment(node[:segment]) + trace[:actual] = actual + actual == node[:matches] + end + + def evaluate_feature_property(node, resolver, path, evaluated, property) + trace = { + path: path, + reference: { feature: node[:feature] }, + property: property.to_s, + operator: "equals", + actual: missing_value, + expected: node[property] + } + trace[:key] = node[:key] if node.key?(:key) + trace[:description] = node[:description] if node.key?(:description) + evaluated << trace + actual = resolver.resolve_feature(node[:feature], property) + trace[:actual] = report_operand(actual) + strict_equal(actual, node[property]) + end + + def normalized_reference(reference, owner_variable) + normalized = { variable: reference[:variable] || owner_variable } + normalized[:feature] = reference[:feature] if reference.key?(:feature) + normalized[:path] = reference[:path] if reference.key?(:path) + normalized.delete(:variable) if normalized[:variable].nil? + normalized + end + + def missing_value + { "$type" => "undefined" } + end + + def report_operand(value) + value.equal?(MISSING) ? missing_value : value + end + + def format_path(path) + path.each_with_index.map { |part, index| index.zero? ? "checks[#{part}]" : part.is_a?(Integer) ? "[#{part}]" : ".#{part}" }.join + end + + def format_failed_check(check, evaluated) + label = check_label(format_path([evaluated.first&.dig(:path)&.first || 0]), check) + if evaluated.all? { |trace| trace.dig(:reference, :segment) } + evidence = evaluated.map do |trace| + "segment #{trace.dig(:reference, :segment).inspect}: expected matches=#{json(trace[:expected])}, actual matches=#{json(trace[:actual])}" + end + return "#{label}: segment expectations failed (#{evidence.join(', ')})" + end + if evaluated.all? { |trace| trace.dig(:reference, :feature) } + evidence = evaluated.map do |trace| + "expected #{trace[:property]}=#{json(trace[:expected])}, actual #{trace[:property]}=#{json(trace[:actual])}" + end + return "#{label}: feature #{evaluated.first.dig(:reference, :feature).inspect} expectations failed (#{evidence.join(', ')})" + end + if evaluated.all? { |trace| trace[:property] } + evidence = evaluated.map do |trace| + kind = trace.dig(:reference, :segment) ? "segment" : "feature" + reference = trace.dig(:reference, :segment) || trace.dig(:reference, :feature) + "#{kind} #{reference.inspect}: expected #{trace[:property]}=#{json(trace[:expected])}, actual #{trace[:property]}=#{json(trace[:actual])}" + end + return "#{label}: expectations failed (#{evidence.join(', ')})" + end + evidence = evaluated.map do |trace| + reference = reference_label(trace[:reference], nil) + "#{reference} #{trace[:operator]} failed (left=#{json(trace[:actual])}, right=#{json(trace[:expected])})" + end + "#{label}: #{evidence.join('; ')}" + end + + def check_label(path, node) + metadata = [] + metadata << "key=#{node[:key].inspect}" if node.key?(:key) + metadata << "description=#{node[:description].inspect}" if node.key?(:description) + metadata.empty? ? path : "#{path} [#{metadata.join(', ')}]" + end + + def path_parts(path) + return [] if path.nil? + if path.is_a?(String) + parts = path.split(".", -1) + raise CheckError, "path must not contain empty dot components" if parts.any?(&:empty?) + parts = parts.map do |part| + if part.match?(/\A(?:0|[1-9]\d*)\z/) + index = Integer(part, 10) + raise CheckError, "path indices must be safe nonnegative integers" if index > MAX_SAFE_INTEGER + index + else + part + end + end + elsif path.is_a?(Array) + parts = path + else + raise CheckError, "path must be a dot separated string or an array" + end + parts.each do |part| + unless part.is_a?(String) || (part.is_a?(Integer) && part.between?(0, MAX_SAFE_INTEGER)) + raise CheckError, "path entries must be string keys or nonnegative integer indices" + end + raise CheckError, "unsafe path segment #{part.inspect} is not allowed" if part.is_a?(String) && UNSAFE_PATH_PARTS.include?(part) + raise CheckError, "wildcards are not allowed in paths" if part == "*" + end + parts + end + + def at_path(value, parts) + parts.reduce(value) do |current, part| + if part.is_a?(Integer) + return MISSING unless current.is_a?(Array) && part < current.length + current[part] + else + if current.is_a?(Array) + if part == "length" + current.length + elsif part.match?(/\A(?:0|[1-9]\d*)\z/) && part.to_i < current.length + current[part.to_i] + else + return MISSING + end + elsif current.is_a?(Hash) + key = current.key?(part) ? part : part.to_sym + return MISSING unless current.key?(key) + current[key] + else + return MISSING + end + end + end + end + + def primitive?(value) + value.nil? || value.is_a?(String) || value == true || value == false || + (value.is_a?(Numeric) && value.finite?) + end + + def membership_primitive?(value) + value.nil? || value.is_a?(String) || (value.is_a?(Numeric) && value.finite?) + end + + def strict_equal(left, right) + return left.nil? && right.nil? if left.nil? || right.nil? + return left == right if [true, false].include?(left) && [true, false].include?(right) + return left == right if left.is_a?(Numeric) && right.is_a?(Numeric) && left.finite? && right.finite? + left.is_a?(String) && right.is_a?(String) && left == right + end + + def compare(operator, left, right, regex_flags) + return !left.equal?(MISSING) if operator == "exists" + return left.equal?(MISSING) if operator == "notExists" + return left.nil? if operator == "isNull" + return !left.equal?(MISSING) && !left.nil? if operator == "notNull" + if %w[empty notEmpty].include?(operator) + return false if left.equal?(MISSING) || left.nil? + unless left.is_a?(String) || left.is_a?(Array) || left.is_a?(Hash) + raise CheckError, "operator #{operator.inspect} requires a string, array or object operand" + end + return operator == "empty" ? left.empty? : !left.empty? + end + return false if left.equal?(MISSING) || right.equal?(MISSING) + if %w[equals notEquals].include?(operator) + raise CheckError, "operator #{operator.inspect} only supports scalar values" unless primitive?(left) && primitive?(right) + matched = strict_equal(left, right) + return operator == "equals" ? matched : !matched + end + if %w[greaterThan greaterThanOrEquals lessThan lessThanOrEquals].include?(operator) + unless left.is_a?(Numeric) && right.is_a?(Numeric) && ![left, right].any? { |value| [true, false].include?(value) } && left.finite? && right.finite? + raise CheckError, "operator #{operator.inspect} requires numeric operands" + end + return { "greaterThan" => left > right, "greaterThanOrEquals" => left >= right, + "lessThan" => left < right, "lessThanOrEquals" => left <= right }[operator] + end + if %w[contains notContains startsWith endsWith matches notMatches].include?(operator) + raise CheckError, "operator #{operator.inspect} requires string operands" unless left.is_a?(String) && right.is_a?(String) + return left.include?(right) if operator == "contains" + return !left.include?(right) if operator == "notContains" + return left.start_with?(right) if operator == "startsWith" + return left.end_with?(right) if operator == "endsWith" + matched = !portable_regex(right, regex_flags).match(left).nil? + return operator == "matches" ? matched : !matched + end + if operator.start_with?("semver") + raise CheckError, "operator #{operator.inspect} requires string operands" unless left.is_a?(String) && right.is_a?(String) + compared = Featurevisor.compare_versions(left, right) + return { "semverEquals" => compared == 0, "semverNotEquals" => compared != 0, + "semverGreaterThan" => compared == 1, "semverGreaterThanOrEquals" => compared >= 0, + "semverLessThan" => compared == -1, "semverLessThanOrEquals" => compared <= 0 }[operator] + end + if %w[before after].include?(operator) + begin + left_date = portable_datetime(left) + right_date = portable_datetime(right) + rescue TypeError, ArgumentError => e + raise CheckError, "date comparisons require valid portable timestamps: #{e.message}" + end + return operator == "before" ? left_date < right_date : left_date > right_date + end + if %w[in notIn].include?(operator) + unless membership_primitive?(left) && right.is_a?(Array) && right.all? { |value| membership_primitive?(value) } + raise CheckError, "operator #{operator.inspect} requires a scalar left operand and scalar array right operand" + end + matched = right.any? { |value| strict_equal(left, value) } + return operator == "in" ? matched : !matched + end + if %w[includesAny notIncludesAny].include?(operator) + unless left.is_a?(Array) && right.is_a?(Array) && right.all? { |value| primitive?(value) } + raise CheckError, "operator #{operator.inspect} requires an array left operand and primitive array right operand" + end + matched = right.any? { |candidate| left.any? { |actual| strict_equal(actual, candidate) } } + return operator == "includesAny" ? matched : !matched + end + if %w[includes notIncludes].include?(operator) + unless left.is_a?(Array) && membership_primitive?(right) + raise CheckError, "operator #{operator.inspect} requires an array left operand and scalar right operand" + end + matched = left.any? { |value| strict_equal(value, right) } + return operator == "includes" ? matched : !matched + end + raise CheckError, "unknown check operator #{operator.inspect}" + rescue ArgumentError => e + raise CheckError, e.message + end + + def portable_regex(pattern, flags) + flags = portable_regex_flags(flags) + if pattern.match?(/\(\?/) || pattern.match?(/\\(?:[1-9]|k<|k'|g<|g')/) || pattern.match?(/(?:[?*+]|\{\d+(?:,\d*)?\})\+/) + raise CheckError, "regular expression uses syntax outside the portable subset" + end + native = 0 + native |= Regexp::IGNORECASE if flags.include?("i") + native |= Regexp::MULTILINE if flags.include?("s") + Regexp.new(pattern, native) + rescue RegexpError => e + raise CheckError, "invalid regular expression: #{e.message}" + end + + def portable_regex_flags(flags, allow_nil: true) + flags = "" if flags.nil? && allow_nil + unless flags.is_a?(String) && flags.match?(/\A[gims]*\z/) && flags.chars.uniq.length == flags.length + raise CheckError, "regexFlags must contain unique characters from g, i, m and s" + end + flags + end + + def portable_datetime(value) + match = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})\z/.match(value) + raise CheckError, "date comparisons require valid portable timestamps" unless match + + year, month, day, hour, minute, second = match.captures.first(6).map(&:to_i) + unless year.between?(1, 9999) && Date.valid_date?(year, month, day) && + hour.between?(0, 23) && minute.between?(0, 59) && second.between?(0, 59) + raise CheckError, "date comparisons require valid portable timestamps" + end + + zone = match[8] + offset = 0 + unless zone == "Z" + offset_hour, offset_minute = zone[1..].split(":").map(&:to_i) + unless offset_hour <= 14 && offset_minute <= 59 && (offset_hour < 14 || offset_minute.zero?) + raise CheckError, "date comparisons require valid portable timestamps" + end + offset = (offset_hour * 3_600) + (offset_minute * 60) + offset = -offset if zone.start_with?("-") + end + + fraction = match[7] ? Rational(match[7][0, 3].ljust(3, "0").to_i, 1_000) : 0 + instant = Date.new(year, month, day, Date::GREGORIAN).jd * 86_400 + hour * 3_600 + minute * 60 + second + fraction - offset + minimum = Date.new(1, 1, 1, Date::GREGORIAN).jd * 86_400 + maximum = Date.new(9999, 12, 31, Date::GREGORIAN).jd * 86_400 + 86_399 + Rational(999, 1_000) + raise CheckError, "date comparisons require valid portable timestamps" unless instant.between?(minimum, maximum) + instant + rescue TypeError + raise CheckError, "date comparisons require valid portable timestamps" + end + + def json(value) + return "" if value.equal?(MISSING) + JSON.generate(value) + rescue JSON::GeneratorError + value.inspect + end + + def reference_label(reference, owner_variable) + variable = reference[:variable] || owner_variable || "" + root = reference[:feature] ? "#{reference[:feature]}.#{variable}" : variable + reference[:path].nil? || reference[:path] == [] ? root : "#{root}:#{json(reference[:path])}" + end + + class Resolver + attr_reader :owner_variable + + def initialize(evaluator, datafile, diagnostics, owner_variable, segments, context, segment_matcher) + @evaluator = evaluator + @datafile = datafile + @diagnostics = diagnostics + @owner_variable = owner_variable + @segments = segments.is_a?(Hash) ? segments : {} + @context = context.is_a?(Hash) ? context : {} + @segment_matcher = segment_matcher + end + + def resolve(reference) + variable = reference[:variable] || @owner_variable + feature = reference[:feature] + raise CheckError, "variable reference requires a nonempty variable" unless variable.is_a?(String) && !variable.empty? + if feature + raise CheckError, "feature reference requires a nonempty feature" unless feature.is_a?(String) && !feature.empty? + definition = (@datafile[:features] || {})[feature.to_sym] + raise CheckError, "feature #{feature.inspect} is absent from the selected datafile" unless definition.is_a?(Hash) + unless (definition[:variablesSchema] || {}).key?(variable.to_sym) + raise CheckError, "variable #{variable.inspect} is absent from feature #{feature.inspect} in the selected datafile" + end + elsif !(@datafile[:variables] || {}).key?(variable.to_sym) + raise CheckError, "global variable #{variable.inspect} is absent from the selected datafile" + end + @diagnostics.clear + value = feature ? @evaluator.get_variable(feature, variable, {}) : @evaluator.get_variable(variable, {}) + diagnostic = @diagnostics.find { |item| (item[:level] || item["level"]).to_s == "error" } + if diagnostic + raise CheckError, "variable getter reported #{diagnostic[:code] || diagnostic["code"] || "an error"}: " \ + "#{diagnostic[:message] || diagnostic["message"] || "evaluation failed"}" + end + TestChecks.at_path(value, TestChecks.path_parts(reference[:path])) + rescue CheckError + raise + rescue => e + raise CheckError, "variable getter failed: #{e.message}" + end + + def resolve_feature(feature, getter) + definition = (@datafile[:features] || {})[feature.to_sym] + raise CheckError, "feature #{feature.inspect} is absent from the selected datafile" unless definition.is_a?(Hash) + @diagnostics.clear + value = getter == :enabled ? @evaluator.is_enabled(feature, {}) : @evaluator.get_variation(feature, {}) + diagnostic = @diagnostics.find { |item| (item[:level] || item["level"]).to_s == "error" } + if diagnostic + raise CheckError, "feature #{feature.inspect} #{getter} getter reported #{diagnostic[:code] || diagnostic["code"] || "an error"}: " \ + "#{diagnostic[:message] || diagnostic["message"] || "evaluation failed"}" + end + value + rescue CheckError + raise + rescue => e + raise CheckError, "feature #{feature.inspect} #{getter} getter failed: #{e.message}" + end + + def resolve_segment(segment) + definition = @segments[segment.to_sym] || @segments[segment] + unless definition.is_a?(Hash) && (definition.key?(:conditions) || definition.key?("conditions")) + raise CheckError, "segment #{segment.inspect} definition is missing from the normalized test spec" + end + raise CheckError, "segment #{segment.inspect} cannot be evaluated" unless @segment_matcher.respond_to?(:call) + @segment_matcher.call(segment, @context) + rescue CheckError + raise + rescue => e + raise CheckError, "segment #{segment.inspect} evaluation failed: #{e.message}" + end + end + end +end 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/gemfiles/base.gemfile.lock b/gemfiles/base.gemfile.lock index eebfb38..ff6dc1e 100644 --- a/gemfiles/base.gemfile.lock +++ b/gemfiles/base.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - featurevisor (3.1.0) + featurevisor (4.0.0) benchmark (>= 0, < 1) GEM diff --git a/lib/featurevisor.rb b/lib/featurevisor.rb index 3fbe9dc..54f4785 100644 --- a/lib/featurevisor.rb +++ b/lib/featurevisor.rb @@ -4,9 +4,14 @@ require_relative "featurevisor/diagnostics" require_relative "featurevisor/emitter" require_relative "featurevisor/conditions" +require_relative "featurevisor/normalize" +require_relative "featurevisor/mutations" +require_relative "featurevisor/dependencies" require_relative "featurevisor/evaluation_data_provider" require_relative "featurevisor/bucketer" require_relative "featurevisor/modules" +require_relative "featurevisor/evaluation_session" +require_relative "featurevisor/unified_conditions" require_relative "featurevisor/evaluate" require_relative "featurevisor/instance" require_relative "featurevisor/child_instance" @@ -17,4 +22,6 @@ class Error < StandardError; end private_constant :InstanceEvaluationDataProvider private_constant :DiagnosticReporter + private_constant :Mutations + private_constant :Dependencies end diff --git a/lib/featurevisor/child_instance.rb b/lib/featurevisor/child_instance.rb index fa491a0..5f78d70 100644 --- a/lib/featurevisor/child_instance.rb +++ b/lib/featurevisor/child_instance.rb @@ -10,9 +10,13 @@ def initialize(options) @sticky_variables = options[:sticky_variables] || {} @emitter = Featurevisor::Emitter.new @parent_unsubscribers = [] + @closed = false + @evaluation_generation = 0 end def on(event_name, callback = nil, &block) + return -> {} if @closed + callback = block if block_given? if %w[context_set sticky_features_set sticky_variables_set].include?(event_name) return @emitter.on(event_name, callback) @@ -33,6 +37,9 @@ def on(event_name, callback = nil, &block) end def close + return if @closed + + @closed = true @parent_unsubscribers.dup.each(&:call) @parent_unsubscribers.clear @emitter.clear_all @@ -40,6 +47,7 @@ def close def set_context(context, replace = false) @context = replace ? context : { **@context, **context } + @evaluation_generation += 1 @emitter.trigger("context_set", context: @context, replaced: replace) end @@ -50,6 +58,7 @@ def get_context(context = nil) def set_sticky_features(sticky, replace = false) previous = @sticky_features @sticky_features = replace ? sticky : { **@sticky_features, **sticky } + @evaluation_generation += 1 params = Featurevisor::Events.get_params_for_sticky_features_set_event(previous, @sticky_features, replace) @emitter.trigger("sticky_features_set", params) end @@ -57,6 +66,7 @@ def set_sticky_features(sticky, replace = false) def set_sticky_variables(sticky, replace = false) previous = @sticky_variables @sticky_variables = replace ? sticky : { **@sticky_variables, **sticky } + @evaluation_generation += 1 @emitter.trigger( "sticky_variables_set", Featurevisor::Events.get_params_for_sticky_variables_set_event(previous, @sticky_variables, replace) @@ -87,7 +97,7 @@ def evaluate_variable(feature_or_variable_key, variable_key_or_context = nil, co delegate_variable(:evaluate_variable, feature_or_variable_key, variable_key_or_context, context_or_options, options) end - %i[boolean string integer double array object json].each do |type| + %i[boolean string integer number array object json].each do |type| define_method("get_variable_#{type}") do |feature_or_variable_key, variable_key_or_context = nil, context_or_options = {}, options = {}| delegate_variable("get_variable_#{type}".to_sym, feature_or_variable_key, variable_key_or_context, context_or_options, options) end @@ -111,7 +121,10 @@ def child_options(options) { **options, __featurevisor_child_sticky_features: @sticky_features, - __featurevisor_child_sticky_variables: @sticky_variables + __featurevisor_child_sticky_variables: @sticky_variables, + __featurevisor_sticky_features_source: -> { @sticky_features }, + __featurevisor_sticky_variables_source: -> { @sticky_variables }, + __featurevisor_child_generation: -> { @evaluation_generation } } end diff --git a/lib/featurevisor/compare_versions.rb b/lib/featurevisor/compare_versions.rb index e7ac709..e117641 100644 --- a/lib/featurevisor/compare_versions.rb +++ b/lib/featurevisor/compare_versions.rb @@ -19,7 +19,7 @@ def self.validate_and_parse(version) end match = version.match(SEMVER_REGEX) - unless match + unless match && version.ascii_only? && match[0] == version raise ArgumentError, "Invalid argument not valid semver ('#{version}' received)" end @@ -34,50 +34,29 @@ def self.wildcard?(s) s == "*" || s.downcase == "x" end - # Forces types to be the same for comparison - # @param a [String, Integer] First value - # @param b [String, Integer] Second value - # @return [Array] Array with both values converted to same type - def self.force_type(a, b) - if a.is_a?(Integer) != b.is_a?(Integer) - [a.to_s, b.to_s] - else - [a, b] - end - end - - # Tries to parse a string as an integer - # @param v [String] String to parse - # @return [Integer, String] Parsed integer or original string - def self.try_parse(v) - Integer(v, 10) - rescue ArgumentError - v - end - # Compares two strings for version comparison # @param a [String] First string # @param b [String] Second string # @return [Integer] -1 if a < b, 0 if equal, 1 if a > b - def self.compare_strings(a, b) - return 0 if wildcard?(a) || wildcard?(b) - - ap, bp = force_type(try_parse(a), try_parse(b)) - - if ap > bp - 1 - elsif ap < bp - -1 - else - 0 + def self.compare_strings(a, b, prerelease = false) + return 0 if !prerelease && (wildcard?(a) || wildcard?(b)) + an = /\A[0-9]+\z/.match?(a) + bn = /\A[0-9]+\z/.match?(b) + if an && bn + a = a.sub(/\A0+/, "") + b = b.sub(/\A0+/, "") + return a.length <=> b.length if a.length != b.length + elsif prerelease && an != bn + return an ? -1 : 1 end + a <=> b end # Compares version segments # @param a [Array, MatchData] First version segments # @param b [Array, MatchData] Second version segments # @return [Integer] -1 if a < b, 0 if equal, 1 if a > b - def self.compare_segments(a, b) + def self.compare_segments(a, b, prerelease = false) # Convert to arrays if needed a_array = a.is_a?(MatchData) ? a.to_a[1..-1] : a.to_a b_array = b.is_a?(MatchData) ? b.to_a[1..-1] : b.to_a @@ -85,10 +64,13 @@ def self.compare_segments(a, b) max_length = [a_array.length, b_array.length].max (0...max_length).each do |i| + if prerelease && (i == a_array.length || i == b_array.length) + return a_array.length <=> b_array.length + end a_val = a_array[i] || "0" b_val = b_array[i] || "0" - result = compare_strings(a_val, b_val) + result = compare_strings(a_val, b_val, prerelease) return result unless result == 0 end @@ -116,7 +98,7 @@ def self.compare_versions(v1, v2) # Validate pre-release if p1 && p2 - compare_segments(p1.split("."), p2.split(".")) + compare_segments(p1.split("."), p2.split("."), true) elsif p1 || p2 p1 ? -1 : 1 else diff --git a/lib/featurevisor/conditions.rb b/lib/featurevisor/conditions.rb index edffbc9..d8e065d 100644 --- a/lib/featurevisor/conditions.rb +++ b/lib/featurevisor/conditions.rb @@ -23,6 +23,16 @@ def self.get_value_from_context(obj, path) def self.get_value_with_presence(obj, path) return MISSING unless obj.is_a?(Hash) && path.is_a?(String) + # Most attributes are direct keys. Avoid allocating a path array for + # every condition while retaining the existing nested path behaviour. + unless path.empty? || path.include?(".") + symbol = path.to_sym + return obj[symbol] if obj.key?(symbol) + return obj[path] if obj.key?(path) + + return MISSING + end + path.split(".").reduce(obj) do |current, key| break MISSING unless current.is_a?(Hash) @@ -39,7 +49,7 @@ def self.get_value_with_presence(obj, path) def self.strict_equal?(left, right) return true if left.nil? && right.nil? - return left.to_f == right.to_f if left.is_a?(Numeric) && right.is_a?(Numeric) + return left == right if left.is_a?(Numeric) && right.is_a?(Numeric) return left == right if left.is_a?(String) && right.is_a?(String) return left == right if (left == true || left == false) && (right == true || right == false) @@ -47,6 +57,15 @@ def self.strict_equal?(left, right) end private_class_method :strict_equal? + def self.missing_value + MISSING + end + + def self.value_is_matched(raw_value, condition, get_regex) + context = raw_value.equal?(MISSING) ? {} : { value: raw_value } + condition_is_matched(condition.merge(attribute: "value"), context, get_regex) + end + # Check if a condition is matched against context # @param condition [Hash] Condition to evaluate # @param context [Hash] Context to evaluate against @@ -55,7 +74,11 @@ def self.strict_equal?(left, right) def self.condition_is_matched(condition, context, get_regex) attribute = condition["attribute"] || condition[:attribute] operator = condition["operator"] || condition[:operator] - value = condition["value"] || condition[:value] + value = if condition.key?(:value) + condition[:value] + elsif condition.key?("value") + condition["value"] + end regex_flags = condition["regexFlags"] || condition[:regexFlags] raw_context_value = get_value_with_presence(context, attribute) @@ -63,6 +86,18 @@ def self.condition_is_matched(condition, context, get_regex) attribute_exists = !raw_context_value.equal?(MISSING) case operator + when "empty", "notEmpty" + return false unless attribute_exists && !context_value_from_path.nil? + + size = if context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Array) || + context_value_from_path.is_a?(Hash) + context_value_from_path.size + end + !size.nil? && (operator == "empty" ? size.zero? : size.positive?) + when "isNull" + attribute_exists && context_value_from_path.nil? + when "notNull" + attribute_exists && !context_value_from_path.nil? when "equals" attribute_exists && strict_equal?(context_value_from_path, value) when "notEquals" @@ -148,6 +183,12 @@ def self.condition_is_matched(condition, context, get_regex) attribute_exists when "notExists" !attribute_exists + when "includesAny", "notIncludesAny" + return false unless context_value_from_path.is_a?(Array) && value.is_a?(Array) + return false unless value.all? { |candidate| candidate.nil? || candidate.is_a?(String) || candidate.is_a?(Integer) || candidate.is_a?(Float) || candidate == true || candidate == false } + + matched = value.any? { |candidate| context_value_from_path.any? { |actual| strict_equal?(actual, candidate) } } + operator == "includesAny" ? matched : !matched when "includes", "notIncludes" # includes / notIncludes (where context value is an array) if context_value_from_path.is_a?(Array) && @@ -167,15 +208,56 @@ def self.condition_is_matched(condition, context, get_regex) end def self.portable_date(value) - return value if value.is_a?(Time) || value.is_a?(DateTime) - return value.to_time if value.is_a?(Date) - return nil unless value.is_a?(String) - return nil unless value.match?(/T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})\z/) - - Time.iso8601(value) - rescue ArgumentError + if value.is_a?(String) + 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/.match(value) + return nil unless match + return nil if value.start_with?("0000-") + if match[2] && (match[2].to_i > 14 || match[3].to_i > 59 || (match[2] == "14" && match[3] != "00")) + return nil + end + parsed = Time.iso8601(value) + # Time.iso8601 must not normalize an invalid calendar date or clock time. + return nil unless parsed.getlocal(match[1] ? "#{match[1]}#{match[2]}:#{match[3]}" : "+00:00").strftime("%Y-%m-%dT%H:%M:%S") == value[0, 19] + elsif value.is_a?(Time) || value.is_a?(DateTime) + return nil unless (1..9999).cover?(value.year) + offset_seconds = value.is_a?(DateTime) ? value.offset * 86400 : value.utc_offset + return nil if offset_seconds.abs > 14 * 3600 + parsed = value.to_time + else + return nil + end + milliseconds = (parsed.to_r * 1000).floor + result = Time.at(Rational(milliseconds, 1000)).utc + return nil if result.year < 1 || result.year > 9999 + result + rescue ArgumentError, RangeError nil end private_class_method :portable_date + + def self.normalize_portable_regex(pattern) + inside = { + 'd' => '0-9', 'D' => '\\u{0}-\\u{2F}\\u{3A}-\\u{10FFFF}', + 'w' => 'A-Za-z0-9_', 'W' => '\\u{0}-\\u{2F}\\u{3A}-\\u{40}\\u{5B}-\\u{5E}\\u{60}\\u{7B}-\\u{10FFFF}' + } + outside = { 'd' => '[0-9]', 'D' => '[^0-9]', 'w' => '(?-i:[A-Za-z0-9_])', 'W' => '(?-i:[^A-Za-z0-9_])' } + output = +'' + in_class = false + index = 0 + while index < pattern.length + char = pattern[index] + if char == '\\' && index + 1 < pattern.length + code = pattern[index + 1] + output << (in_class ? inside : outside).fetch(code, pattern[index, 2]) + index += 2 + next + end + in_class = true if char == '[' && !in_class + in_class = false if char == ']' && in_class + output << char + index += 1 + end + output + end end end diff --git a/lib/featurevisor/dependencies.rb b/lib/featurevisor/dependencies.rb new file mode 100644 index 0000000..9fd775d --- /dev/null +++ b/lib/featurevisor/dependencies.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "json" + +module Featurevisor + module Dependencies + def self.extract_entity_dependencies(entry) + sources = {} + add = lambda do |kind, key, dependent| + source = [kind, key.to_sym] + sources[source] = true + end + conditions = nil + conditions = lambda do |value, dependent| + case value + when String + next if value == "*" + if value.lstrip.match?(/\A[\{\[\"]/) + begin + conditions.call(JSON.parse(value, symbolize_names: true), dependent) + rescue JSON::ParserError + nil + end + else + add.call(:segment, value, dependent) + end + when Array + value.each { |child| conditions.call(child, dependent) } + when Hash + if value.key?(:variable) + add.call(value[:feature] ? :feature : :variable, value[:feature] || value[:variable], dependent) + elsif value.key?(:feature) + add.call(:feature, value[:feature], dependent) + elsif !value.key?(:attribute) + %i[and or not].each { |group| conditions.call(value[group], dependent) if value.key?(group) } + end + end + end + visit = nil + visit = lambda do |entry, dependent| + conditions.call(entry[:conditions], dependent) if entry.key?(:conditions) + %i[force traffic variations overrides overrideProgram].each do |name| + (entry[name] || []).each { |child| visit.call(child, dependent) } + end + (entry[:variableOverrides] || {}).each_value do |overrides| + overrides.each { |child| visit.call(child, dependent) } + end + end + visit.call(entry, nil) + compose = entry[:compose] + if compose.is_a?(String) + add.call(:variable, compose, nil) + elsif compose.is_a?(Array) + compose.each do |reference| + add.call(reference[:feature] ? :feature : :variable, reference[:feature] || reference[:variable], nil) + end + end + sources.keys + end + + COLLECTIONS = { segments: :segment, features: :feature, variables: :variable }.freeze + + # Edge identities do not retain old providers, definitions or caller values. + class Index + EMPTY = [].freeze + + def initialize + @reverse = {} + @owners = {} + @order = {} + @next_order = 0 + end + + def update(datafile, incoming, previous = nil) + updates = [] + COLLECTIONS.each do |name, kind| + definitions = datafile[name] || {} + (incoming[name] || {}).each_key do |key| + owner = [kind, key.to_sym] + entry = definitions[key.to_sym] + # Hashes do not identify edges. Reuse only an identical definition. + next if previous && @owners.key?(owner) && entry && entry == (previous[name] || {})[key.to_sym] + updates << [owner, entry && Dependencies.extract_entity_dependencies(entry)] + end + end + updates.each do |owner, sources| + (@owners.delete(owner) || []).each do |source| + dependents = @reverse[source] + dependents.delete(owner) + @reverse.delete(source) if dependents.empty? + end + unless sources + @order.delete(owner) + next + end + unless @order.key?(owner) + @order[owner] = @next_order + @next_order += 1 + end + @owners[owner] = sources + sources.each { |source| (@reverse[source] ||= {})[owner] = true } + end + end + + def reorder(datafile) + @order = {} + COLLECTIONS.each do |name, kind| + (datafile[name] || {}).each_key.with_index { |key, rank| @order[[kind, key.to_sym]] = rank } + end + @next_order = (@order.values.max || -1) + 1 + end + + def dependents(identity) + dependents = @reverse[identity] + dependents ? dependents.keys.sort_by { |owner| @order.fetch(owner) } : EMPTY + end + end + + def self.create_index(datafile) + index = Index.new + index.update(datafile, datafile) + index + end + + def self.add_changes(features, variables, changed_segments, index) + kinds = %i[feature variable segment] + affected = [features, variables, changed_segments].map { |keys| keys.to_h { |key| [key.to_sym, true] } } + queues = affected.map(&:keys) + cursors = [0, 0, 0] + loop do + advanced = false + kinds.each_with_index do |kind, source| + next if cursors[source] >= queues[source].length + advanced = true + key = queues[source][cursors[source]] + cursors[source] += 1 + index.dependents([kind, key]).each do |target_kind, target_key| + target = kinds.index(target_kind) + next if affected[target].key?(target_key) + affected[target][target_key] = true + queues[target] << target_key + end + end + break unless advanced + end + features.replace(affected[0].keys) + variables.replace(affected[1].keys) + end + end +end diff --git a/lib/featurevisor/emitter.rb b/lib/featurevisor/emitter.rb index 3b7854e..05c3cb3 100644 --- a/lib/featurevisor/emitter.rb +++ b/lib/featurevisor/emitter.rb @@ -31,6 +31,7 @@ def on(event_name, callback) is_active = false index = listeners.index(callback) listeners.delete_at(index) if index && index >= 0 + callback = nil end end @@ -54,6 +55,9 @@ def trigger(event_name, details = {}) # Clear all event listeners def clear_all + # Cleanup functions may still reference the previous listener arrays. + # Empty those arrays too, rather than retaining unrelated callbacks. + @listeners.each_value(&:clear) @listeners = {} end end diff --git a/lib/featurevisor/evaluate.rb b/lib/featurevisor/evaluate.rb index 5e1fad8..0d8d43a 100644 --- a/lib/featurevisor/evaluate.rb +++ b/lib/featurevisor/evaluate.rb @@ -6,8 +6,6 @@ module EvaluationReason # Feature specific FEATURE_NOT_FOUND = "feature_not_found" # feature is not found in datafile DISABLED = "disabled" # feature is disabled - REQUIRED = "required" # required features are not enabled - OUT_OF_RANGE = "out_of_range" # out of range when mutually exclusive experiments are involved via Groups # Variations specific NO_VARIATIONS = "no_variations" # feature has no variations @@ -19,7 +17,8 @@ module EvaluationReason VARIABLE_DISABLED = "variable_disabled" # feature is disabled, and variable's disabledValue is used VARIABLE_OVERRIDE_VARIATION = "variable_override_variation" # variable overridden from inside a variation VARIABLE_OVERRIDE_RULE = "variable_override_rule" # variable overridden from inside a rule - REQUIRED_FEATURES_UNMET = "required_features_unmet" + CONDITIONS_UNMET = "conditions_unmet" + VARIABLE_COMPOSED = "variable_composed" # Common NO_MATCH = "no_match" # no rules matched @@ -36,49 +35,8 @@ module EvaluationReason # Evaluation module for feature flag evaluation module Evaluate - def self.required_features_are_matched(requirements, datafile, options) - return true if requirements.nil? - - items = requirements.is_a?(Array) ? requirements : [requirements] - clean_options = options.reject do |key, _| - %i[feature_key variable_key default_variation_value default_variable_value].include?(key) - end - items.all? do |required| - if required.is_a?(String) - key = required - enabled = true - variation = nil - elsif required.key?(:feature) - key = required[:feature] - enabled = required.key?(:enabled) ? required[:enabled] : true - variation = required[:variation] - else - key = required[:key] - enabled = true - variation = required[:variation] - end - - flag = evaluate_with_modules(clean_options.merge(type: "flag", feature_key: key, datafile: datafile)) - next false unless (flag[:enabled] == true) == enabled - next true if variation.nil? - - evaluated_variation = evaluate_with_modules(clean_options.merge(type: "variation", feature_key: key, datafile: datafile)) - value = evaluated_variation.key?(:variation_value) ? evaluated_variation[:variation_value] : evaluated_variation.dig(:variation, :value) - value == variation - end - end - def self.variable_override_matches?(override, datafile, context, options) - return false unless required_features_are_matched(override[:requiredFeatures], datafile, options) - - conditions_match = !override[:conditions] || datafile.all_conditions_are_matched( - datafile.parse_conditions_if_stringified(override[:conditions]), context - ) - segments_match = !override[:segments] || datafile.all_segments_are_matched( - datafile.parse_segments_if_stringified(override[:segments]), context - ) - conditions_match && segments_match && - (override.key?(:conditions) || override.key?(:segments) || override.key?(:requiredFeatures)) + override.key?(:conditions) && UnifiedConditions.matches?(override[:conditions], options) end # Evaluate with modules @@ -86,13 +44,36 @@ def self.variable_override_matches?(override, datafile, context, options) # @return [Hash] Evaluation result def self.evaluate_with_modules(options) begin + session = options[:__featurevisor_session] || EvaluationSession.new + options = options.merge(__featurevisor_session: session) + if options[:__featurevisor_prerequisite] + options[:sticky] = options[:__featurevisor_sticky_features_source].call if options[:__featurevisor_sticky_features_source] + options[:sticky_variables] = options[:__featurevisor_sticky_variables_source].call if options[:__featurevisor_sticky_variables_source] + options[:datafile] = options[:__featurevisor_datafile_source].call if options[:__featurevisor_datafile_source] + end + options[:dependency_of] = session.dependency_of if options[:__featurevisor_prerequisite] + options[:dependency_purpose] ||= "condition" if options[:__featurevisor_prerequisite] + return session.run(options, prerequisite: options[:__featurevisor_prerequisite]) do modules_manager = options[:modules_manager] - - # Run before modules + # The usual read has no modules. Keep its evaluation options intact so + # it does not allocate two filtered hashes and a merged hash. result_options = options - if modules_manager - result_options = modules_manager.run_before_modules(result_options) + if modules_manager && !modules_manager.get_all.empty? + private_options = options.select { |key, _| private_evaluation_option?(key) } + public_options = options.reject { |key, _| private_evaluation_option?(key) } + result_options = modules_manager.run_before_evaluation_modules(public_options) + result_options = result_options.reject { |key, _| private_evaluation_option?(key) }.merge(private_options) end + { sticky: :__featurevisor_sticky_features_source, + sticky_variables: :__featurevisor_sticky_variables_source }.each do |field, source| + if result_options[field].equal?(options[field]) + result_options[field] = options[source].call if options[source] + else + replacement = result_options[field] + result_options[source] = -> { replacement } + end + end + session.update_current(result_options) # Evaluate evaluation = evaluate(result_options) @@ -113,12 +94,18 @@ def self.evaluate_with_modules(options) end # Run after modules - if modules_manager - evaluation = modules_manager.run_after_modules(evaluation, result_options) + # A callback during evaluation may add a module. In that case its + # after hook still receives only public options. + if modules_manager && !modules_manager.get_all.empty? + evaluation = modules_manager.run_after_evaluation_modules( + evaluation, result_options.reject { |key, _| private_evaluation_option?(key) } + ) end evaluation + end rescue => e + raise if e.is_a?(RequirementError) && options[:__featurevisor_prerequisite] type = options[:type] feature_key = options[:feature_key] variable_key = options[:variable_key] @@ -132,12 +119,18 @@ def self.evaluate_with_modules(options) error: e } + session.remember(options, evaluation) if session + diagnostics.error("Error during evaluation", evaluation) evaluation end end + def self.private_evaluation_option?(key) + key.to_s.start_with?("__featurevisor_") + end + # Main evaluation function # @param options [Hash] Evaluation options # @return [Hash] Evaluation result @@ -228,7 +221,7 @@ def self.evaluate(options) type: type, feature_key: feature_key, reason: Featurevisor::EvaluationReason::STICKY, - sticky: sticky_feature, + sticky_feature: sticky_feature, enabled: sticky_feature[:enabled] } @@ -341,7 +334,7 @@ def self.evaluate(options) end # Forced - force_result = datafile.get_matched_force(feature, context) + force_result = datafile.get_matched_force(feature, context, options) force = force_result[:force] force_index = force_result[:forceIndex] @@ -402,25 +395,17 @@ def self.evaluate(options) end end - # Required - required_features = feature[:requiredFeatures] || feature[:required] - if type == "flag" && required_features && !Array(required_features).empty? - required_features_are_enabled = required_features_are_matched(required_features, datafile, options) - - unless required_features_are_enabled - evaluation = { - type: type, - feature_key: feature_key, - reason: Featurevisor::EvaluationReason::REQUIRED, - required: feature[:required], - required_features: feature[:requiredFeatures], - enabled: required_features_are_enabled - } - - diagnostics.debug("required features not enabled", evaluation) - - return evaluation - end + if type == "flag" && feature.key?(:conditions) && + !UnifiedConditions.matches?(feature[:conditions], options) + evaluation = { + type: type, + feature_key: feature_key, + reason: Featurevisor::EvaluationReason::CONDITIONS_UNMET, + conditions: feature[:conditions], + enabled: false + } + diagnostics.debug("conditions did not match", evaluation) + return evaluation end # Bucketing @@ -455,13 +440,13 @@ def self.evaluate(options) matched_allocation = nil if type != "flag" - matched_traffic = datafile.get_matched_traffic(feature[:traffic], context) + matched_traffic = datafile.get_matched_traffic(feature[:traffic], context, options) if matched_traffic matched_allocation = datafile.get_matched_allocation(matched_traffic, bucket_value) end else - matched_traffic = datafile.get_matched_traffic(feature[:traffic], context) + matched_traffic = datafile.get_matched_traffic(feature[:traffic], context, options) end if matched_traffic @@ -485,45 +470,6 @@ def self.evaluate(options) # flag if type == "flag" - # flag: check if mutually exclusive - if feature[:ranges] && feature[:ranges].length > 0 - matched_range = feature[:ranges].find do |range| - bucket_value >= range[0] && bucket_value < range[1] - end - - # matched - if matched_range - evaluation = { - type: type, - feature_key: feature_key, - reason: Featurevisor::EvaluationReason::ALLOCATED, - bucket_key: bucket_key, - bucket_value: bucket_value, - rule_key: matched_traffic[:key], - traffic: matched_traffic, - enabled: matched_traffic[:enabled].nil? ? true : matched_traffic[:enabled] - } - - diagnostics.debug("matched", evaluation) - - return evaluation - end - - # no match - evaluation = { - type: type, - feature_key: feature_key, - reason: Featurevisor::EvaluationReason::OUT_OF_RANGE, - bucket_key: bucket_key, - bucket_value: bucket_value, - enabled: false - } - - diagnostics.debug("not matched", evaluation) - - return evaluation - end - # flag: override from rule if matched_traffic.key?(:enabled) evaluation = { @@ -634,7 +580,8 @@ def self.evaluate(options) variable_schema: variable_schema, variable_value: override[:value], variable_override_index: override_index, - variable_override_key: override[:key] + variable_override_key: override[:key], + variable_override_path: override[:keyPath] } diagnostics.debug("variable override from rule", evaluation) @@ -698,7 +645,8 @@ def self.evaluate(options) variable_schema: variable_schema, variable_value: override[:value], variable_override_index: override_index, - variable_override_key: override[:key] + variable_override_key: override[:key], + variable_override_path: override[:keyPath] } diagnostics.debug("variable override from variation", evaluation) @@ -791,6 +739,7 @@ def self.evaluate(options) evaluation rescue => e + raise if e.is_a?(RequirementError) evaluation = { type: type, feature_key: feature_key, diff --git a/lib/featurevisor/evaluation_data_provider.rb b/lib/featurevisor/evaluation_data_provider.rb index 7074bfc..f6b87fc 100644 --- a/lib/featurevisor/evaluation_data_provider.rb +++ b/lib/featurevisor/evaluation_data_provider.rb @@ -5,7 +5,8 @@ module Featurevisor # Private datafile and matching adapter used by the evaluator. class InstanceEvaluationDataProvider - attr_reader :schema_version, :revision, :featurevisor_version, :segments, :features, :variables, :diagnostics, :regex_cache + attr_reader :schema_version, :revision, :featurevisor_version, :segments, :features, :variables, :diagnostics, :regex_cache, :normalized + attr_accessor :dependency_index # Initialize a new evaluation data provider. # @param options [Hash] Options hash containing datafile and diagnostics @@ -21,38 +22,14 @@ def initialize(options) @segments = (datafile[:segments] || {}).transform_keys(&:to_sym) @features = (datafile[:features] || {}).transform_keys(&:to_sym) @variables = (datafile[:variables] || {}).transform_keys(&:to_sym) - - # Transform nested structures to use symbol keys - @features.each do |_key, feature| - if feature[:variablesSchema] - feature[:variablesSchema] = feature[:variablesSchema].transform_keys(&:to_sym) - end - if feature[:variations] - feature[:variations].each do |variation| - if variation[:variables] - variation[:variables] = variation[:variables].transform_keys(&:to_sym) - end - if variation[:variableOverrides] - variation[:variableOverrides] = variation[:variableOverrides].transform_keys(&:to_sym) - end - end - end - if feature[:force] - feature[:force].each do |force_rule| - if force_rule[:variables] - force_rule[:variables] = force_rule[:variables].transform_keys(&:to_sym) - end - end - end - if feature[:traffic] - feature[:traffic].each do |traffic_rule| - if traffic_rule[:variables] - traffic_rule[:variables] = traffic_rule[:variables].transform_keys(&:to_sym) - end - end - end - end + @normalized = { feature: {}, segment: {}, variable: {} } + @normalization_slots = { feature: {}, segment: {}, variable: {} } + @normalization_mutex = Mutex.new @regex_cache = {} + @conditions_cache = {} + @segments_cache = {} + @attribute_conditions_cache = {} + @get_regex = method(:get_regex) end # Get the revision of the datafile @@ -82,12 +59,7 @@ def get_datafile # @param segment_key [String] Segment key to retrieve # @return [Hash, nil] Segment data or nil if not found def get_segment(segment_key) - segment = @segments[segment_key.to_sym] || @segments[segment_key] - - return nil unless segment - - segment[:conditions] = parse_conditions_if_stringified(segment[:conditions]) - segment + read_entity(:segment, segment_key, @segments) end def get_segment_keys @@ -104,7 +76,7 @@ def get_feature_keys # @param feature_key [String] Feature key to retrieve # @return [Hash, nil] Feature data or nil if not found def get_feature(feature_key) - @features[feature_key.to_sym] || @features[feature_key] + read_entity(:feature, feature_key, @features) end # Get variable keys for a feature @@ -121,7 +93,60 @@ def get_variable_keys(feature_key = nil) end def get_global_variable(variable_key) - @variables[variable_key.to_sym] || @variables[variable_key] + read_entity(:variable, variable_key, @variables) + end + + def attribute_only_conditions?(conditions) + key = conditions.object_id + cached = @attribute_conditions_cache[key] + return cached[1] if cached + + # Retain the syntax with its identity. Cache classification, never matches. + @attribute_conditions_cache[key] = [conditions, UnifiedConditions.send(:attribute_only?, conditions)] + @attribute_conditions_cache[key][1] + end + + def retain_normalized(previous, incoming) + @normalization_mutex, @normalization_slots, @normalized = previous.normalization_snapshot + { feature: :features, segment: :segments, variable: :variables }.each do |kind, name| + (incoming[name] || {}).each_key do |key| + @normalization_slots[kind][key.to_sym] = [] + @normalized[kind].delete(key.to_sym) + end + end + end + + # Share individual cold slots, never a chain of previous providers. + def normalization_snapshot + @normalization_mutex.synchronize do + { feature: @features, segment: @segments, variable: @variables }.each do |kind, definitions| + next if @normalization_slots[kind].size == definitions.size + definitions.each_key { |key| @normalization_slots[kind][key] ||= [] } + end + [@normalization_mutex, @normalization_slots.transform_values(&:dup), @normalized.transform_values(&:dup)] + end + end + + def read_entity(kind, key, definitions) + name = key.to_sym + cached = @normalized[kind][name] + return cached if cached + source = definitions[name] || definitions[key] + return nil unless source + errors = [] + value = @normalization_mutex.synchronize do + slot = @normalization_slots[kind][name] ||= [] + if slot.empty? + normalized, errors = Normalize.entity(kind, name, source) + slot << normalized + end + @normalized[kind][name] = slot.first + end + # Publication precedes diagnostics, and application callbacks never hold the mutex. + errors.each do |error| + @diagnostics.error(error[:message], { code: error[:code], error: error[:original_error], details: error[:details] }) + end + value end # Check if a feature has variations @@ -153,7 +178,7 @@ def get_regex(regex_string, regex_flags = "") invalid_flags = flags.delete("gimsuy") raise ArgumentError, "invalid regular expression flags: #{invalid_flags}" unless invalid_flags.empty? - regex = Regexp.new(regex_string, ruby_flags) + regex = Regexp.new(Conditions.normalize_portable_regex(regex_string), ruby_flags) @regex_cache[cache_key] = regex @regex_cache[cache_key] end @@ -168,11 +193,9 @@ def all_conditions_are_matched(conditions, context) return false end - get_regex_proc = ->(regex_string, regex_flags) { get_regex(regex_string, regex_flags) } - if conditions.is_a?(Hash) && (conditions[:attribute] || conditions["attribute"]) begin - result = Conditions.condition_is_matched(conditions, context, get_regex_proc) + result = Conditions.condition_is_matched(conditions, context, @get_regex) return result rescue => e @diagnostics.warn(e.message, { @@ -186,11 +209,11 @@ def all_conditions_are_matched(conditions, context) end if conditions.is_a?(Hash) && conditions[:and] && conditions[:and].is_a?(Array) - return conditions[:and].all? { |c| all_conditions_are_matched(c, context) } + return !conditions[:and].empty? && conditions[:and].all? { |c| all_conditions_are_matched(c, context) } end if conditions.is_a?(Hash) && conditions["and"] && conditions["and"].is_a?(Array) - return conditions["and"].all? { |c| all_conditions_are_matched(c, context) } + return !conditions["and"].empty? && conditions["and"].all? { |c| all_conditions_are_matched(c, context) } end if conditions.is_a?(Hash) && conditions[:or] && conditions[:or].is_a?(Array) @@ -214,7 +237,7 @@ def all_conditions_are_matched(conditions, context) if conditions.is_a?(Array) - return conditions.all? { |c| all_conditions_are_matched(c, context) } + return !conditions.empty? && conditions.all? { |c| all_conditions_are_matched(c, context) } end false @@ -288,8 +311,9 @@ def all_segments_are_matched(group_segments, context) # @param traffic [Array] Traffic array to search # @param context [Hash] Context to evaluate against # @return [Hash, nil] Matched traffic or nil - def get_matched_traffic(traffic, context) + def get_matched_traffic(traffic, context, options = nil) traffic.find do |t| + next t.key?(:conditions) && UnifiedConditions.matches?(t[:conditions], options) if options segments = parse_segments_if_stringified(t[:segments]) matched = all_segments_are_matched(segments, context) next false unless matched @@ -314,7 +338,7 @@ def get_matched_allocation(traffic, bucket_value) # @param feature_key [String, Hash] Feature key or feature object # @param context [Hash] Context to evaluate against # @return [Hash] Force result with force and forceIndex - def get_matched_force(feature_key, context) + def get_matched_force(feature_key, context, options = nil) result = { force: nil, forceIndex: nil @@ -325,6 +349,14 @@ def get_matched_force(feature_key, context) return result unless feature && feature[:force] feature[:force].each_with_index do |current_force, i| + if options + if current_force.key?(:conditions) && UnifiedConditions.matches?(current_force[:conditions], options) + result[:force] = current_force + result[:forceIndex] = i + break + end + next + end if current_force[:conditions] && all_conditions_are_matched( parse_conditions_if_stringified(current_force[:conditions]), context ) @@ -352,9 +384,10 @@ def parse_conditions_if_stringified(conditions) return conditions unless conditions.is_a?(String) return conditions if conditions == "*" + return @conditions_cache[conditions] if @conditions_cache.key?(conditions) begin - JSON.parse(conditions) + @conditions_cache[conditions] = JSON.parse(conditions) rescue => e @diagnostics.error("Error parsing conditions", { error: e, @@ -371,11 +404,27 @@ def parse_conditions_if_stringified(conditions) # @return [Array, Hash, String] Parsed segments def parse_segments_if_stringified(segments) if segments.is_a?(String) && (segments.start_with?("{") || segments.start_with?("[")) - return JSON.parse(segments) + return @segments_cache[segments] if @segments_cache.key?(segments) + + return @segments_cache[segments] = JSON.parse(segments) end segments end + + # Cache parsed expressions, never context dependent matching results. + # A new provider owns a new generation after each datafile update. + def clear_caches + @dependency_index = nil + @normalization_mutex.synchronize do + @normalized.each_value(&:clear) + @normalization_slots.each_value(&:clear) + end + @regex_cache.clear + @conditions_cache.clear + @segments_cache.clear + @attribute_conditions_cache.clear + end end private_constant :InstanceEvaluationDataProvider diff --git a/lib/featurevisor/evaluation_session.rb b/lib/featurevisor/evaluation_session.rb new file mode 100644 index 0000000..06019dc --- /dev/null +++ b/lib/featurevisor/evaluation_session.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "json" + +module Featurevisor + class RequirementError < StandardError; end + class RequirementCycleError < RequirementError; end + + # One evaluation owns its prerequisite stack and completed prerequisite values. + class EvaluationSession + def initialize + @root = nil + @root_current = nil + @active = nil + @memo = nil + @sticky_inputs = nil + @segments = nil + end + + def dependency_of + @active ? @active.last&.dig(:current) : @root_current + end + + def update_current(options) + current = dependency_of + return if current && current[:type] == options[:type] && + current[:feature_key] == options[:feature_key] && current[:variable_key] == options[:variable_key] + + identity = identity_of(options) + if @active + @active.last[:current] = identity + else + @root_current = identity + end + end + + def run(options, prerequisite: false) + # A root needs its original and redirected identities, but no dependency + # stack, memo or retained sticky maps until a handoff is actually reached. + if !@root && !@active && !prerequisite + @root = @root_current = identity_of(options) + begin + return yield + ensure + clear + end + end + + @active ||= @root ? [{ identity: @root, current: @root_current }] : [] + identity = identity_of(options) + raise RequirementCycleError, "Circular evaluation requirement" if @active.any? { |frame| frame[:identity] == identity || frame[:current] == identity } + raise RequirementError, "Evaluation dependency depth exceeded" if @active.length + (@segments&.length || 0) > 256 + + requester = dependency_of + key = prerequisite ? fingerprint(identity, requester, options) : nil + borrowed = options[:dependency_purpose] == "composition" + return @memo[key] if key && @memo&.key?(key) + + @active << { identity: identity, current: identity } + begin + result = yield + if prerequisite && options[:__featurevisor_project_variable] + result = options[:__featurevisor_project_variable].call(result) + end + if key && key == fingerprint(identity, requester, options) + minimal = minimal_result(result, borrowed) + (@memo ||= {})[key] = borrowed ? minimal : duplicate(minimal) if minimal + end + result + rescue => e + @pending_error = { identity: identity, requester: requester, key: key } + raise + ensure + @active.pop + clear if @active.empty? + end + end + + def segment(key) + @segments ||= [] + raise RequirementCycleError, "Circular dependency" if @segments.include?(key) + active_length = @active ? @active.length : (@root ? 1 : 0) + raise RequirementError, "Evaluation dependency depth exceeded" if @segments.length + [active_length - 1, 0].max >= 256 + @segments << key + begin + yield + ensure + @segments.pop + end + end + + def remember(options, result) + return unless options[:__featurevisor_prerequisite] + + identity = identity_of(options) + pending = @pending_error + @pending_error = nil + return unless pending && pending[:identity] == identity && pending[:requester] == dependency_of + + key = fingerprint(identity, dependency_of, options) + return unless key && key == pending[:key] + borrowed = options[:dependency_purpose] == "composition" + minimal = minimal_result(result, borrowed) + (@memo ||= {})[key] = borrowed ? minimal : duplicate(minimal) if minimal + end + + private + + def clear + @root = @root_current = nil + @active&.clear + @memo&.clear + @sticky_inputs&.clear + @segments&.clear + @active = nil + @pending_error = nil + end + + def identity_of(options) + identity = {} + identity[:type] = options[:type] unless options[:type].nil? + identity[:feature_key] = options[:feature_key] unless options[:feature_key].nil? + identity[:variable_key] = options[:variable_key] unless options[:variable_key].nil? + identity + end + + def fingerprint(identity, requester, options) + @sticky_inputs ||= {} + # Keep referenced maps alive until the root ends so object_id cannot be reused. + [options[:sticky], options[:sticky_variables]].each do |input| + @sticky_inputs[input.object_id] = input if input + end + JSON.generate(canonical([ + identity, requester, options[:dependency_purpose], options[:context], options[:sticky]&.object_id, options[:sticky_variables]&.object_id, + options[:__featurevisor_generation]&.call, + options[:__featurevisor_child_generation]&.call, + options[:datafile]&.object_id + ])) + rescue ArgumentError, TypeError, JSON::GeneratorError + nil + end + + def canonical(value, ancestors = []) + raise ArgumentError, "Too deep" if ancestors.length > 64 + + case value + when NilClass, TrueClass, FalseClass, String, Integer + value + when Float + raise ArgumentError, "Nonfinite number" unless value.finite? + value + when Array + raise ArgumentError, "Circular value" if ancestors.include?(value.object_id) + ["array", value.map { |entry| canonical(entry, ancestors + [value.object_id]) }] + when Hash + raise ArgumentError, "Circular value" if ancestors.include?(value.object_id) + keys = value.keys + raise ArgumentError, "Unsupported key" unless keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) } + pairs = keys.sort_by { |key| [key.to_s, key.is_a?(String) ? 0 : 1] }.map do |key| + [key.is_a?(String) ? "string" : "symbol", key.to_s, + canonical(value[key], ancestors + [value.object_id])] + end + ["object", pairs] + else + raise ArgumentError, "Unsupported value" + end + end + + def minimal_result(result, borrowed = false) + return nil unless result.is_a?(Hash) + + keys = %i[type feature_key variable_key reason enabled variation_value variable_value __featurevisor_decoded_json] + value = result.select { |key, _| keys.include?(key) } + value[:variation] = { value: result[:variation][:value] } if result[:variation].is_a?(Hash) + value[:variable_schema] = { type: result[:variable_schema][:type] } if result[:variable_schema].is_a?(Hash) + if result[:variable].is_a?(Hash) + value[:variable] = { type: result[:variable][:type] } + value[:variable][:compose] = true if result[:variable][:compose] + end + return value if borrowed + canonical(value) + duplicate(value) + rescue ArgumentError, TypeError + nil + end + + def duplicate(value) + Marshal.load(Marshal.dump(value)) + end + end +end diff --git a/lib/featurevisor/events.rb b/lib/featurevisor/events.rb index 1a4b9a1..9bc3ada 100644 --- a/lib/featurevisor/events.rb +++ b/lib/featurevisor/events.rb @@ -33,7 +33,7 @@ def self.get_params_for_sticky_variables_set_event(previous_sticky = {}, new_sti # @param previous_reader [InstanceEvaluationDataProvider] Previous datafile # @param new_reader [InstanceEvaluationDataProvider] New datafile # @return [Hash] Event parameters - def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace = false) + def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace = false, incoming = nil) previous_revision = previous_reader.get_revision previous_feature_keys = previous_reader.get_feature_keys @@ -42,6 +42,17 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace previous_variable_keys = previous_reader.get_variable_keys new_variable_keys = new_reader.get_variable_keys + if previous_feature_keys.empty? && previous_variable_keys.empty? && previous_reader.get_segment_keys.empty? + return { + revision: new_revision, + previousRevision: previous_revision, + revisionChanged: previous_revision != new_revision, + features: new_feature_keys, + variables: new_variable_keys, + replaced: replace + } + end + # results removed_features = [] changed_features = [] @@ -49,18 +60,20 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace # checking against existing datafile previous_feature_keys.each do |previous_feature_key| - if !new_feature_keys.include?(previous_feature_key) + if !new_reader.features.key?(previous_feature_key) # feature was removed in new datafile removed_features << previous_feature_key next end # feature exists in both datafiles, check if it was changed - previous_feature = previous_reader.get_feature(previous_feature_key) - new_feature = new_reader.get_feature(previous_feature_key) + previous_feature = previous_reader.features[previous_feature_key] + new_feature = new_reader.features[previous_feature_key] if previous_feature && new_feature && - (previous_feature[:hash].nil? || new_feature[:hash].nil? || previous_feature[:hash] != new_feature[:hash]) + (previous_feature[:hash].nil? || previous_feature[:hash].empty? || + new_feature[:hash].nil? || new_feature[:hash].empty? || + previous_feature[:hash] != new_feature[:hash]) # feature was changed in new datafile changed_features << previous_feature_key end @@ -68,7 +81,7 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace # checking against new datafile new_feature_keys.each do |new_feature_key| - if !previous_feature_keys.include?(new_feature_key) + if !previous_reader.features.key?(new_feature_key) # feature was added in new datafile added_features << new_feature_key end @@ -77,41 +90,33 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace # combine all affected feature keys all_affected_features = (removed_features + changed_features + added_features).uniq all_affected_variables = (previous_variable_keys + new_variable_keys).uniq.select do |key| - previous = previous_reader.get_global_variable(key) - current = new_reader.get_global_variable(key) - previous.nil? || current.nil? || previous[:hash].nil? || current[:hash].nil? || previous[:hash] != current[:hash] + previous = previous_reader.variables[key] + current = new_reader.variables[key] + previous.nil? || current.nil? || + previous[:hash].nil? || previous[:hash].empty? || current[:hash].nil? || current[:hash].empty? || + previous[:hash] != current[:hash] end + new_datafile = new_reader.get_datafile changed_segments = (previous_reader.get_segment_keys + new_reader.get_segment_keys).uniq.select do |key| - previous_reader.get_segment(key) != new_reader.get_segment(key) - end.map(&:to_s) - feature_keys = (previous_feature_keys + new_feature_keys).uniq - loop do - before = all_affected_features.length - feature_keys.each do |key| - next if all_affected_features.include?(key) - - candidates = [previous_reader.get_feature(key), new_reader.get_feature(key)].compact - all_affected_features << key if candidates.any? do |feature| - dependency = feature_dependencies(feature) - !(dependency[:segments].map(&:to_s) & changed_segments).empty? || - !(dependency[:features].map(&:to_s) & all_affected_features.map(&:to_s)).empty? - end - end - break if before == all_affected_features.length + previous = previous_reader.segments[key] + current = new_reader.segments[key] + !previous.equal?(current) && previous != current end - - (previous_variable_keys + new_variable_keys).uniq.each do |key| - next if all_affected_variables.include?(key) - - candidates = [previous_reader.get_global_variable(key), new_reader.get_global_variable(key)].compact - all_affected_variables << key if candidates.any? do |variable| - dependency = global_variable_dependencies(variable) - !(dependency[:segments].map(&:to_s) & changed_segments).empty? || - !(dependency[:features].map(&:to_s) & all_affected_features.map(&:to_s)).empty? + index = previous_reader.dependency_index || Dependencies.create_index(previous_reader.get_datafile) + if replace || incoming.nil? + incoming = Dependencies::COLLECTIONS.to_h do |name, _| + [name, (previous_reader.public_send(name).keys + new_datafile[name].keys).uniq.to_h { |key| [key, true] }] end end + Dependencies.add_changes(all_affected_features, all_affected_variables, changed_segments, + index) + index.update(new_datafile, incoming, previous_reader.get_datafile) + index.reorder(new_datafile) if replace + Dependencies.add_changes(all_affected_features, all_affected_variables, changed_segments, + index) + new_reader.dependency_index = index { revision: new_revision, @@ -123,71 +128,5 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace } end - def self.required_feature_keys(values) - items = values.is_a?(Array) ? values : [values].compact - items.filter_map do |required| - required.is_a?(String) ? required : (required[:feature] || required[:key]) - end - end - private_class_method :required_feature_keys - - def self.segment_keys(value) - return [] if value.nil? || value == "*" - if value.is_a?(String) - if value.start_with?("{", "[") - begin - return segment_keys(JSON.parse(value, symbolize_names: true)) - rescue JSON::ParserError - return [] - end - end - return [value] - end - return value.flat_map { |item| segment_keys(item) }.uniq if value.is_a?(Array) - return [] unless value.is_a?(Hash) - - %i[and or not].flat_map { |key| segment_keys(value[key] || value[key.to_s]) }.uniq - end - private_class_method :segment_keys - - def self.override_dependencies(groups) - overrides = groups.is_a?(Hash) ? groups.values.flatten : [] - { - segments: overrides.flat_map { |override| segment_keys(override[:segments]) }.uniq, - features: overrides.flat_map { |override| required_feature_keys(override[:requiredFeatures]) }.uniq - } - end - private_class_method :override_dependencies - - def self.feature_dependencies(feature) - requirements = feature.key?(:requiredFeatures) ? feature[:requiredFeatures] : feature[:required] - segments = [] - features = required_feature_keys(requirements) - Array(feature[:traffic]).each do |traffic| - segments.concat(segment_keys(traffic[:segments])) - nested = override_dependencies(traffic[:variableOverrides]) - segments.concat(nested[:segments]) - features.concat(nested[:features]) - end - Array(feature[:force]).each { |force| segments.concat(segment_keys(force[:segments])) } - Array(feature[:variations]).each do |variation| - nested = override_dependencies(variation[:variableOverrides]) - segments.concat(nested[:segments]) - features.concat(nested[:features]) - end - { segments: segments.uniq, features: features.uniq } - end - private_class_method :feature_dependencies - - def self.global_variable_dependencies(variable) - segments = [] - features = required_feature_keys(variable[:requiredFeatures]) - Array(variable[:overrides]).each do |override| - segments.concat(segment_keys(override[:segments])) - features.concat(required_feature_keys(override[:requiredFeatures])) - end - { segments: segments.uniq, features: features.uniq } - end - private_class_method :global_variable_dependencies end end diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index 6425e8a..0e185f4 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -35,6 +35,8 @@ def initialize(options = {}) @sticky_features = options[:sticky_features] || options[:stickyFeatures] || {} @sticky_variables = options[:sticky_variables] || options[:stickyVariables] || {} @closed = false + @evaluation_generation = 0 + @datafile_mutex = Mutex.new @module_diagnostic_subscriptions = [] # datafile @@ -47,6 +49,7 @@ def initialize(options = {}) modules: options[:modules] || [], report_diagnostic: method(:report_diagnostic), module_api_factory: method(:create_module_api), + on_change: method(:invalidate_evaluation_memo), clear_module_diagnostic_subscriptions: method(:clear_module_diagnostic_subscriptions) ) @@ -68,6 +71,8 @@ def set_log_level(level) end def handle_evaluation_diagnostic(level, message, details = nil) + return unless diagnostic_interested?(level) + details = (details || {}).dup code = details.delete(:code) || details.delete("code") || details[:reason] || details["reason"] || message code = "deprecated_feature" if message == "feature is deprecated" @@ -97,8 +102,9 @@ def handle_evaluation_diagnostic(level, message, details = nil) # Set the datafile # @param datafile [Hash, String] Datafile content or JSON string # @param replace [Boolean] Whether to replace instead of merge + # @return [Boolean] Whether the datafile was accepted, even if unchanged def set_datafile(datafile, replace = false) - return if @closed + return false if @closed begin parsed_datafile = if datafile.is_a?(String) @@ -114,14 +120,26 @@ def set_datafile(datafile, replace = false) (!parsed_datafile.key?(:variables) || parsed_datafile[:variables].is_a?(Hash)) raise ArgumentError, "Invalid datafile" end - next_datafile = replace ? parsed_datafile : merge_datafiles(@datafile.get_datafile, parsed_datafile) - new_datafile = InstanceEvaluationDataProvider.new( - datafile: next_datafile, - diagnostics: @diagnostics - ) + normalization_errors = [] + if parsed_datafile[:schemaVersion] == "2" + parsed_datafile, normalization_errors = Normalize.datafile(parsed_datafile) + end + # Serialize merge and publication, leaving application callbacks outside. + details = @datafile_mutex.synchronize do + return false if @closed + next_datafile = replace ? parsed_datafile : merge_datafiles(@datafile.get_datafile, parsed_datafile) + new_datafile = InstanceEvaluationDataProvider.new( + datafile: next_datafile, + diagnostics: @diagnostics + ) + new_datafile.retain_normalized(@datafile, parsed_datafile) unless replace + event = Featurevisor::Events.get_params_for_datafile_set_event(@datafile, new_datafile, replace, parsed_datafile) + @datafile = new_datafile + @evaluation_generation += 1 + event + end - details = Featurevisor::Events.get_params_for_datafile_set_event(@datafile, new_datafile, replace) - @datafile = new_datafile + normalization_errors.each { |diagnostic| report_diagnostic(diagnostic) } report_diagnostic( level: "info", @@ -130,6 +148,7 @@ def set_datafile(datafile, replace = false) details: details ) @emitter.trigger("datafile_set", details) + true rescue => e report_diagnostic( level: "error", @@ -137,6 +156,7 @@ def set_datafile(datafile, replace = false) message: "Could not parse datafile", original_error: e ) + false end end @@ -156,6 +176,7 @@ def set_sticky_features(sticky, replace = false) end params = Featurevisor::Events.get_params_for_sticky_features_set_event(previous_sticky_features, @sticky_features, replace) + @evaluation_generation += 1 report_diagnostic( level: "info", @@ -169,6 +190,7 @@ def set_sticky_features(sticky, replace = false) def set_sticky_variables(sticky, replace = false) previous = @sticky_variables || {} @sticky_variables = replace ? sticky : { **@sticky_variables, **sticky } + @evaluation_generation += 1 params = Featurevisor::Events.get_params_for_sticky_variables_set_event(previous, @sticky_variables, replace) report_diagnostic(level: "info", code: "sticky_variables_set", message: "Sticky variables set", details: params) @emitter.trigger("sticky_variables_set", params) @@ -214,6 +236,11 @@ def add_module(mod) @modules_manager.add(mod) end + def invalidate_evaluation_memo + @evaluation_generation += 1 + end + private :invalidate_evaluation_memo + def remove_module(name) @modules_manager.remove(name) end @@ -230,12 +257,15 @@ def on(event_name, callback) # Close the instance def close - return if @closed - - @closed = true + @datafile_mutex.synchronize do + return if @closed + @closed = true + end @modules_manager.close_all @module_diagnostic_subscriptions = [] @emitter.clear_all + @on_diagnostic = nil + @datafile.clear_caches end # Set context @@ -247,6 +277,7 @@ def set_context(context, replace = false) else @context = { **@context, **context } end + @evaluation_generation += 1 @emitter.trigger("context_set", { context: @context, @@ -315,6 +346,7 @@ def is_enabled(feature_key, context = {}, options = {}) evaluation = evaluate_flag(feature_key, context, options) evaluation[:enabled] == true rescue => e + raise if e.is_a?(RequirementError) && options[:__featurevisor_prerequisite] report_diagnostic(level: "error", code: "evaluation_error", message: "isEnabled failed", originalError: e, details: { featureKey: feature_key }) false end @@ -351,6 +383,7 @@ def get_variation(feature_key, context = {}, options = {}) nil end rescue => e + raise if e.is_a?(RequirementError) && options[:__featurevisor_prerequisite] report_diagnostic(level: "error", code: "evaluation_error", message: "getVariation failed", originalError: e, details: { featureKey: feature_key }) nil end @@ -389,7 +422,7 @@ def get_variable(feature_or_variable_key, variable_key_or_context = nil, context if evaluation.key?(:variable_value) variable_type = evaluation.dig(:variable_schema, :type) || evaluation.dig(:variable, :type) - if variable_type == "json" && + if variable_type == "json" && !evaluation[:__featurevisor_decoded_json] && !evaluation.dig(:variable, :compose) && evaluation[:variable_value].is_a?(String) JSON.parse(evaluation[:variable_value], symbolize_names: true) else @@ -399,6 +432,8 @@ def get_variable(feature_or_variable_key, variable_key_or_context = nil, context nil end rescue => e + getter_options = variable_key_or_context.is_a?(String) ? options : context_or_options + raise if e.is_a?(RequirementError) && getter_options[:__featurevisor_prerequisite] report_diagnostic(level: "error", code: "evaluation_error", message: "getVariable failed", originalError: e, details: { variableKey: variable_key_or_context || feature_or_variable_key }) nil end @@ -437,15 +472,15 @@ def get_variable_integer(*args) get_value_by_type(variable_value, "integer") end - # Get variable as double + # Get variable as a number, whole or fractional # @param feature_key [String] Feature key # @param variable_key [String] Variable key # @param context [Hash] Context # @param options [Hash] Override options # @return [Float, nil] Float value or nil - def get_variable_double(*args) + def get_variable_number(*args) variable_value = get_variable(*args) - get_value_by_type(variable_value, "double") + get_value_by_type(variable_value, "number") end # Get variable as array @@ -538,14 +573,24 @@ def evaluate_variable_without_feature(variable_key, context = {}, options = {}) evaluation_options = { type: "variable", variable_key: variable_key.to_s, - context: get_context(context) + context: options[:__featurevisor_effective_context] ? context : get_context(context) } + session = options[:__featurevisor_session] || EvaluationSession.new + evaluation_options[:dependency_of] = session.dependency_of if options[:__featurevisor_prerequisite] + evaluation_options[:dependency_purpose] = options[:dependency_purpose] || "condition" if options[:__featurevisor_prerequisite] evaluation_options[:default_variable_value] = options[:default_variable_value] if options.key?(:default_variable_value) begin - evaluation_options = @modules_manager.run_before_evaluation_modules(evaluation_options) + # Only prerequisite entries fingerprint effective dependency inputs. + session_options = options[:__featurevisor_prerequisite] ? global_variable_session_options(evaluation_options, options) : evaluation_options + return session.run(session_options, + prerequisite: options[:__featurevisor_prerequisite]) do + unless @modules_manager.get_all.empty? + evaluation_options = @modules_manager.run_before_evaluation_modules(evaluation_options) + end + session.update_current(evaluation_options) resolved_key = evaluation_options[:variable_key] variable = @datafile.get_global_variable(resolved_key) - sticky = options[:__featurevisor_child_sticky_variables] || @sticky_variables + sticky = options[:__featurevisor_sticky_variables_source]&.call || options[:__featurevisor_child_sticky_variables] || @sticky_variables evaluation = { type: "variable", variable_key: resolved_key, reason: Featurevisor::EvaluationReason::VARIABLE_NOT_FOUND } if sticky.key?(resolved_key) || sticky.key?(resolved_key.to_sym) @@ -553,28 +598,49 @@ def evaluate_variable_without_feature(variable_key, context = {}, options = {}) evaluation.merge!(reason: Featurevisor::EvaluationReason::STICKY, variable: variable, variable_value: sticky_value) elsif variable - unless required_features_are_matched(variable[:requiredFeatures], evaluation_options[:context], options) - value_key = variable[:useDefaultWhenDisabled] ? :defaultValue : :disabledValue - evaluation.merge!(reason: Featurevisor::EvaluationReason::REQUIRED_FEATURES_UNMET, - variable: variable) + dependencies = get_evaluation_dependencies(evaluation_options[:context], options.merge(__featurevisor_effective_context: true, __featurevisor_session: session)) + if variable.key?(:conditions) && !UnifiedConditions.matches?(variable[:conditions], dependencies) + value_key = !variable[:compose] && variable[:useDefaultWhenDisabled] ? :defaultValue : :disabledValue + evaluation.merge!(reason: Featurevisor::EvaluationReason::CONDITIONS_UNMET, + conditions: variable[:conditions], variable: variable) evaluation[:variable_value] = variable[value_key] if variable.key?(value_key) + evaluation[:variable_value] = nil if variable[:compose] && !variable.key?(value_key) + if variable[:compose].is_a?(Array) && evaluation[:variable_value].is_a?(Hash) + evaluation[:variable_value] = evaluation[:variable_value].dup + end else - (variable[:overrides] || []).each_with_index do |override, index| - next unless required_features_are_matched(override[:requiredFeatures], evaluation_options[:context], options) - conditions_match = !override[:conditions] || @datafile.all_conditions_are_matched( - @datafile.parse_conditions_if_stringified(override[:conditions]), evaluation_options[:context] - ) - segments_match = !override[:segments] || @datafile.all_segments_are_matched( - @datafile.parse_segments_if_stringified(override[:segments]), evaluation_options[:context] - ) - next unless conditions_match && segments_match - - evaluation.merge!(reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_RULE, - variable: variable, - variable_override_index: index, variable_override_key: override[:key], - variable_override_path: override[:keyPath]) - evaluation[:variable_value] = override[:value] if override.key?(:value) - break + if variable[:compose] + evaluation.merge!(reason: Featurevisor::EvaluationReason::VARIABLE_COMPOSED, + variable: variable, variable_value: compose_value(variable[:compose], dependencies)) + end + program = variable[:overrideProgram] + overrides = program || (variable[:compose] ? [] : variable[:overrides]) || [] + accumulated_value = variable[:compose] ? evaluation[:variable_value] : variable[:defaultValue] + applied_paths = [] + index = 0 + while index < overrides.length + override = overrides[index] + matched = !override.key?(:conditions) || UnifiedConditions.matches?(override[:conditions], dependencies) + if matched + if program + accumulated_value = Mutations.apply(accumulated_value, override[:operations]) + if variable[:compose].is_a?(Array) && override[:operations].any? { |operation| operation[:op] == "set" && operation[:path].empty? } + accumulated_value = accumulated_value.dup + end + applied_paths << (override[:keyPath] || [override[:key]]) + end + evaluation.merge!(reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_RULE, + variable: variable, variable_override_index: index, + variable_override_key: override[:key], variable_override_path: override[:keyPath]) + evaluation[:variable_value] = program ? accumulated_value : override[:value] + evaluation[:variable_override_paths] = applied_paths.dup if program + break unless program + end + next_index = program ? override[matched ? :onMatch : :onMiss] : index + 1 + if program && (!next_index.is_a?(Integer) || next_index <= index || next_index > overrides.length) + raise ArgumentError, "Invalid variable override program jump" + end + index = next_index end if evaluation[:reason] == Featurevisor::EvaluationReason::VARIABLE_NOT_FOUND evaluation.merge!(reason: Featurevisor::EvaluationReason::VARIABLE_DEFAULT, @@ -582,58 +648,167 @@ def evaluate_variable_without_feature(variable_key, context = {}, options = {}) evaluation[:variable_value] = variable[:defaultValue] if variable.key?(:defaultValue) end end - report_diagnostic(level: "warn", code: "variable_deprecated", message: "Variable \"#{resolved_key}\" is deprecated", + report_diagnostic(level: "warn", code: "deprecated_variable", message: "Variable \"#{resolved_key}\" is deprecated", details: { variableKey: resolved_key, evaluation: evaluation }) if variable[:deprecated] end if !evaluation.key?(:variable_value) && evaluation_options.key?(:default_variable_value) evaluation[:variable_value] = evaluation_options[:default_variable_value] end - evaluation = @modules_manager.run_global_after_modules(evaluation, evaluation_options) + unless @modules_manager.get_all.empty? + evaluation = @modules_manager.run_after_evaluation_modules(evaluation, evaluation_options) + end report_diagnostic(level: "debug", code: evaluation[:reason], message: "Global variable evaluated", details: evaluation) evaluation + end rescue => e + raise if e.is_a?(RequirementError) && options[:__featurevisor_prerequisite] evaluation = { type: "variable", variable_key: evaluation_options[:variable_key], reason: Featurevisor::EvaluationReason::ERROR, error: e } + session.remember(global_variable_session_options(evaluation_options, options).merge(__featurevisor_prerequisite: true), evaluation) if options[:__featurevisor_prerequisite] report_diagnostic(level: "error", code: "evaluation_error", message: "Global variable evaluation failed", originalError: e, details: evaluation) evaluation end end - def required_features_are_matched(requirements, context, options) - return true if requirements.nil? + def global_variable_session_options(evaluation_options, options) + evaluation_options.merge( + sticky: options[:__featurevisor_sticky_features_source]&.call || options[:__featurevisor_child_sticky_features] || @sticky_features, + sticky_variables: options[:__featurevisor_sticky_variables_source]&.call || options[:__featurevisor_child_sticky_variables] || @sticky_variables, + datafile: @datafile, + __featurevisor_generation: -> { @evaluation_generation }, + __featurevisor_child_generation: options[:__featurevisor_child_generation], + __featurevisor_project_variable: options[:__featurevisor_project_variable] + ) + end - items = requirements.is_a?(Array) ? requirements : [requirements] - clean_options = options.reject { |key, _| %i[default_variation_value default_variable_value].include?(key) } - items.all? do |required| - if required.is_a?(String) - key = required - expected_enabled = true - expected_variation = nil - else - key = required[:feature] - expected_enabled = required.key?(:enabled) ? required[:enabled] : true - expected_variation = required[:variation] + def project_variable_dependency(evaluation) + return evaluation unless evaluation.key?(:variable_value) + type = evaluation.dig(:variable_schema, :type) || evaluation.dig(:variable, :type) + return evaluation unless type == "json" && !evaluation.dig(:variable, :compose) && evaluation[:variable_value].is_a?(String) + + evaluation.merge(variable_value: JSON.parse(evaluation[:variable_value], symbolize_names: true), __featurevisor_decoded_json: true) + rescue JSON::ParserError => error + report_diagnostic(level: "error", code: "evaluation_error", message: "getVariable failed", originalError: error, + details: { featureKey: evaluation[:feature_key], variableKey: evaluation[:variable_key] }) + evaluation.merge(variable_value: nil, __featurevisor_decoded_json: true) + end + + def evaluate_required_variable(required, dependencies) + options = { + __featurevisor_sticky_features_source: dependencies[:__featurevisor_sticky_features_source], + __featurevisor_sticky_variables_source: dependencies[:__featurevisor_sticky_variables_source], + __featurevisor_child_generation: dependencies[:__featurevisor_child_generation], + __featurevisor_session: dependencies[:__featurevisor_session], + __featurevisor_prerequisite: true, + __featurevisor_effective_context: true, + dependency_purpose: "condition", + __featurevisor_project_variable: method(:project_variable_dependency) + } + if required[:feature] + get_variable(required[:feature], required[:variable], dependencies[:context], options) + else + get_variable(required[:variable], dependencies[:context], options) + end + end + + def composition_source(reference, dependencies) + context = dependencies[:context] + options = { + __featurevisor_session: dependencies[:__featurevisor_session], + __featurevisor_prerequisite: true, + __featurevisor_effective_context: true, + __featurevisor_sticky_features_source: dependencies[:__featurevisor_sticky_features_source], + __featurevisor_sticky_variables_source: dependencies[:__featurevisor_sticky_variables_source], + __featurevisor_child_generation: dependencies[:__featurevisor_child_generation], + dependency_purpose: "composition", + __featurevisor_project_variable: method(:project_variable_dependency) + } + if reference.key?(:variable) + reference.key?(:feature) ? get_variable(reference[:feature], reference[:variable], context, options) : get_variable(reference[:variable], context, options) + elsif reference[:variation] + get_variation(reference[:feature], context, options) + else + is_enabled(reference[:feature], context, options) + end + end + + def compose_value(compose, dependencies) + return composition_source({ variable: compose }, dependencies) if compose.is_a?(String) + raise RequirementError, "Invalid composition" unless compose.is_a?(Array) && !compose.empty? + compose.each_with_object({}) do |reference, result| + key = reference[:key] + if !key.is_a?(String) || key.empty? || %w[__proto__ prototype constructor].include?(key) + raise RequirementError, "Invalid composition property" end - next false unless is_enabled(key, context, clean_options) == expected_enabled - expected_variation.nil? || get_variation(key, context, clean_options) == expected_variation + result[key.to_sym] = composition_source(reference, dependencies) end end + def variable_condition_matches?(required, dependencies) + value = evaluate_required_variable(required, dependencies) + if required.key?(:path) + raise ArgumentError, "Variable requirement path must be compiled" unless required[:path].is_a?(Array) + required[:path].each do |part| + raise ArgumentError, "Unsafe variable requirement path" if %w[__proto__ prototype constructor].include?(part) + index = Mutations.send(:array_index, part) + if part.is_a?(Numeric) && index.nil? + raise ArgumentError, "Invalid variable requirement path" + end + value = if !index.nil? + value.is_a?(Array) && index < value.length ? value[index] : Conditions.missing_value + elsif part.is_a?(String) + if value.is_a?(Hash) + key = value.key?(part.to_sym) ? part.to_sym : part + value.key?(key) ? value[key] : Conditions.missing_value + elsif value.is_a?(Array) && part == "length" + value.length + elsif value.is_a?(Array) && part.match?(/\A(?:0|[1-9]\d*)\z/) && part.to_i < value.length + value[part.to_i] + else + Conditions.missing_value + end + else + raise ArgumentError, "Invalid variable requirement path" + end + end + end + Conditions.value_is_matched(value, required, ->(pattern, flags) { @datafile.get_regex(pattern, flags) }) + rescue RequirementError + raise + rescue => error + raise RequirementError, error.message + end + # Get evaluation dependencies # @param context [Hash] Context # @param options [Hash] Override options # @return [Hash] Evaluation dependencies def get_evaluation_dependencies(context, options = {}) + sticky_source = options[:__featurevisor_sticky_features_source] || + -> { options[:__featurevisor_child_sticky_features] || @sticky_features } + variable_source = options[:__featurevisor_sticky_variables_source] || + -> { options[:__featurevisor_child_sticky_variables] || @sticky_variables } { - context: get_context(context), + context: options[:__featurevisor_effective_context] ? context : get_context(context), diagnostics: @diagnostics, modules_manager: @modules_manager, datafile: @datafile, - sticky: options[:__featurevisor_child_sticky_features] || @sticky_features, - sticky_variables: options[:__featurevisor_child_sticky_variables] || @sticky_variables, + __featurevisor_instance: self, + sticky: sticky_source.call, + sticky_variables: variable_source.call, + __featurevisor_project_variable: options[:__featurevisor_project_variable], + __featurevisor_session: options[:__featurevisor_session], + __featurevisor_prerequisite: options[:__featurevisor_prerequisite], + __featurevisor_generation: -> { @evaluation_generation }, + __featurevisor_child_generation: options[:__featurevisor_child_generation], + __featurevisor_sticky_features_source: sticky_source, + __featurevisor_sticky_variables_source: variable_source, + __featurevisor_datafile_source: -> { @datafile }, }.tap do |dependencies| + dependencies[:dependency_purpose] = options[:dependency_purpose] if options[:dependency_purpose] + dependencies[:dependency_of] = options[:__featurevisor_session].dependency_of if options[:__featurevisor_prerequisite] && options[:__featurevisor_session] dependencies[:default_variation_value] = options[:default_variation_value] if options.key?(:default_variation_value) dependencies[:default_variable_value] = options[:default_variable_value] if options.key?(:default_variable_value) end @@ -652,7 +827,7 @@ def get_value_by_type(value, type) when "integer" return value if value.is_a?(Integer) value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : nil - when "double" + when "number" value.is_a?(Numeric) && value.finite? ? value.to_f : nil when "boolean" value == true || value == false ? value : nil @@ -696,6 +871,8 @@ def create_module_api(mod) { get_revision: -> { instance.get_revision }, on_diagnostic: lambda do |handler, options = {}| + next -> {} if @closed + subscription = { id: SecureRandom.uuid, module_id: mod.id, @@ -714,6 +891,8 @@ def clear_module_diagnostic_subscriptions(mod) end def report_diagnostic(diagnostic, source_module = nil) + return unless diagnostic_interested?((diagnostic || {})[:level] || "info", source_module) + diagnostic = (diagnostic || {}).dup diagnostic[:level] ||= "info" diagnostic[:module] = source_module.name if source_module && source_module.name @@ -772,6 +951,26 @@ def should_report_diagnostic?(diagnostic_level, subscriber_level) subscriber_index >= diagnostic_index end + # Check live subscriptions before copying and formatting evaluation details. + # Errors also reach the error event, independently of diagnostic levels. + def diagnostic_interested?(level, source_module = nil) + return true if level == "error" + + # The default console reporter and explicit handlers have historically + # handled unrecognized levels differently. Preserve both policies. + instance_interested = if @on_diagnostic + should_report_diagnostic?(level, @diagnostics.level) + else + LOG_LEVELS.index(level).to_i <= LOG_LEVELS.index(@diagnostics.level).to_i + end + instance_interested || + @module_diagnostic_subscriptions.any? do |subscription| + (!source_module || subscription[:module_id] != source_module.id) && + should_report_diagnostic?(level, subscription[:level]) + end + end + private :diagnostic_interested? + def camelize_diagnostic_value(value) case value when Hash diff --git a/lib/featurevisor/modules.rb b/lib/featurevisor/modules.rb index 260b63a..91bd0b7 100644 --- a/lib/featurevisor/modules.rb +++ b/lib/featurevisor/modules.rb @@ -12,11 +12,9 @@ def initialize(options = {}) @id = SecureRandom.uuid @name = options[:name] @setup = options[:setup] - @before = options[:before] @before_evaluation = options[:before_evaluation] || options[:beforeEvaluation] @bucket_key = options[:bucket_key] @bucket_value = options[:bucket_value] - @after = options[:after] @after_evaluation = options[:after_evaluation] || options[:afterEvaluation] @close = options[:close] end @@ -25,12 +23,6 @@ def call_setup(api) @setup.call(api) if @setup end - def call_before(options) - return options unless @before - - @before.call(options) - end - def call_before_evaluation(options) return options unless @before_evaluation @@ -49,12 +41,6 @@ def call_bucket_value(options) @bucket_value.call(options) end - def call_after(evaluation, options) - return evaluation unless @after - - @after.call(evaluation, options) - end - def call_after_evaluation(evaluation, options) return evaluation unless @after_evaluation @@ -71,6 +57,7 @@ class ModulesManager def initialize(options = {}) @modules = [] + @on_change = options[:on_change] @report_diagnostic = options[:report_diagnostic] @module_api_factory = options[:module_api_factory] @clear_module_diagnostic_subscriptions = options[:clear_module_diagnostic_subscriptions] @@ -114,7 +101,9 @@ def add(mod) close_module(mod) return nil end - @modules << mod + # Replace membership so an active phase keeps its starting list. + @modules = @modules + [mod] + @on_change&.call -> { remove(mod) } end @@ -126,6 +115,7 @@ def remove(name_or_module) removed << mod if matches matches end + @on_change&.call unless removed.empty? removed.each do |mod| @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions @@ -137,15 +127,6 @@ def get_all @modules end - def run_before_modules(options) - result = @modules.reduce(options) do |current, mod| - mod.call_before(current) - end - @modules.reduce(result) do |current, mod| - mod.call_before_evaluation(current) - end - end - def run_before_evaluation_modules(options) @modules.reduce(options) do |result, mod| mod.call_before_evaluation(result) @@ -158,28 +139,6 @@ def run_after_evaluation_modules(evaluation, options) end end - def run_after_modules(evaluation, options) - result = @modules.reduce(evaluation) do |current, mod| - mod.call_after_evaluation(current, options) - end - @modules.reduce(result) do |current, mod| - mod.call_after(current, options) - end - end - - # Deprecated feature-only callbacks are intentionally excluded here. - def run_global_after_modules(evaluation, options) - @modules.reduce(evaluation) do |result, mod| - mod.call_after_evaluation(result, options) - end - end - - def run_legacy_before_modules(options) - @modules.reduce(options) do |result, mod| - mod.call_before(result) - end - end - def run_bucket_key_modules(options) bucket_key = options[:bucket_key] @modules.each do |mod| @@ -197,6 +156,7 @@ def run_bucket_value_modules(options) end def close_all + @on_change&.call unless @modules.empty? @modules.each do |mod| @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions close_module(mod) diff --git a/lib/featurevisor/mutations.rb b/lib/featurevisor/mutations.rb new file mode 100644 index 0000000..bee4d5c --- /dev/null +++ b/lib/featurevisor/mutations.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +module Featurevisor + module Mutations + DANGEROUS_KEYS = %w[__proto__ prototype constructor].freeze + OPERATIONS = %w[set append prepend appendUnique removeValue before after remove].freeze + MAX_SAFE_INTEGER = 9_007_199_254_740_991 + private_constant :DANGEROUS_KEYS, :OPERATIONS, :MAX_SAFE_INTEGER + + def self.apply(value, operations) + raise ArgumentError, "Invalid variable mutation operations" unless operations.is_a?(Array) + + operations.reduce(value) do |current, operation| + validate(operation) + update_at_path(current, operation, 0) + end + end + + def self.validate(operation) + unless operation.is_a?(Hash) && operation.key?(:op) && operation.key?(:path) + raise ArgumentError, "Invalid variable mutation operation" + end + op = operation[:op] + raise ArgumentError, "Invalid variable mutation operation" unless OPERATIONS.include?(op) + if op != "remove" && !operation.key?(:value) + raise ArgumentError, "Variable mutation operation requires a value" + end + if %w[appendUnique removeValue].include?(op) + value = operation[:value] + unless primitive?(value) && (!value.is_a?(Numeric) || !value.respond_to?(:finite?) || value.finite?) + raise ArgumentError, "Variable mutation operation requires a primitive value" + end + end + raise ArgumentError, "Invalid variable mutation path" unless operation[:path].is_a?(Array) + + operation[:path].each do |part| + key = if part.is_a?(String) + part + elsif !array_index(part).nil? + nil + else + select = selector(part) + raise ArgumentError, "Invalid variable mutation path" unless select + select[:property] + end + raise ArgumentError, "Unsafe variable mutation key" if key && DANGEROUS_KEYS.include?(key) + end + end + private_class_method :validate + + def self.primitive?(value) + value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false + end + private_class_method :primitive? + + def self.same?(left, right) + return true if left.nil? && right.nil? + return left == right if left.is_a?(Numeric) && right.is_a?(Numeric) + return left == right if left.is_a?(String) && right.is_a?(String) + return left == right if [true, false].include?(left) && [true, false].include?(right) + + left.equal?(right) + end + private_class_method :same? + + def self.array_index(value) + return nil unless value.is_a?(Numeric) + return nil if value.respond_to?(:finite?) && !value.finite? + return nil unless value >= 0 && value <= MAX_SAFE_INTEGER && value.to_i == value + + value.to_i + end + private_class_method :array_index + + def self.selector(part) + return nil unless part.is_a?(Hash) + select = part[:select] || part["select"] + return nil unless select.is_a?(Hash) + property = select[:property] || select["property"] + values = select[:values] || select["values"] + return nil unless property.is_a?(String) && values.is_a?(Array) && values.all? { |value| primitive?(value) } + + { property: property, values: values } + end + private_class_method :selector + + def self.hash_key(hash, key) + return key if hash.key?(key) + symbol = key.to_sym + hash.key?(symbol) ? symbol : symbol + end + private_class_method :hash_key + + def self.selected_index(array, select) + array.each_with_index do |candidate, index| + next unless candidate.is_a?(Hash) + key = hash_key(candidate, select[:property]) + next unless candidate.key?(key) + value = candidate[key] + return index if primitive?(value) && select[:values].any? { |expected| same?(value, expected) } + end + -1 + end + private_class_method :selected_index + + def self.path_key(container, part) + if part.is_a?(String) + raise ArgumentError, "Variable mutation expected an object" unless container.is_a?(Hash) + return hash_key(container, part) + end + raise ArgumentError, "Variable mutation expected an array" unless container.is_a?(Array) + + index = array_index(part) + return index unless index.nil? + + select = selector(part) + raise ArgumentError, "Invalid variable mutation path" unless select + + selected_index(container, select) + end + private_class_method :path_key + + def self.exists?(container, key) + container.is_a?(Hash) ? container.key?(key) : key >= 0 && key < container.length + end + private_class_method :exists? + + def self.replace(container, key, value) + result = container.dup + if result.is_a?(Array) && key == result.length + result << value + else + result[key] = value + end + result + end + private_class_method :replace + + def self.update_array(array, op, item) + if op == "removeValue" + return array unless array.any? { |value| same?(value, item) } + return array.reject { |value| same?(value, item) } + end + return array if op == "appendUnique" && array.any? { |value| same?(value, item) } + + op == "prepend" ? [item, *array] : [*array, item] + end + private_class_method :update_array + + def self.update_final(container, part, operation) + return container if container.nil? + op = operation[:op] + if %w[before after].include?(op) && part.is_a?(String) + raise ArgumentError, "Variable mutation before/after requires an array element path" + end + key = path_key(container, part) + found = exists?(container, key) + + if op == "remove" + return container unless found + result = container.dup + result.is_a?(Array) ? result.delete_at(key) : result.delete(key) + return result + end + + item = operation[:value] + if op == "set" + return container if key.is_a?(Integer) && (key.negative? || key > container.length) + return container if found && same?(container[key], item) + return replace(container, key, item) + end + if %w[before after].include?(op) + return container unless found + result = container.dup + result.insert(key + (op == "after" ? 1 : 0), item) + return result + end + + current = found ? container[key] : nil + return container if current.nil? && key.is_a?(Integer) + return container if current.nil? && op == "removeValue" + raise ArgumentError, "Variable mutation expected an array" if !current.nil? && !current.is_a?(Array) + array = current || [] + result = update_array(array, op, item) + result.equal?(current) ? container : replace(container, key, result) + end + private_class_method :update_final + + def self.update_at_path(value, operation, offset) + path = operation[:path] + if offset == path.length + return same?(value, operation[:value]) ? value : operation[:value] if operation[:op] == "set" + raise ArgumentError, "Cannot remove the root variable value" if operation[:op] == "remove" + if %w[before after].include?(operation[:op]) + raise ArgumentError, "Variable mutation before/after requires an array element path" + end + return value if value.nil? + raise ArgumentError, "Variable mutation expected an array" unless value.is_a?(Array) + return update_array(value, operation[:op], operation[:value]) + end + + return update_final(value, path[offset], operation) if offset == path.length - 1 + return value if value.nil? + key = path_key(value, path[offset]) + return value unless exists?(value, key) + current = value[key] + return value if current.nil? + result = update_at_path(current, operation, offset + 1) + result.equal?(current) ? value : replace(value, key, result) + end + private_class_method :update_at_path + end +end diff --git a/lib/featurevisor/normalize.rb b/lib/featurevisor/normalize.rb new file mode 100644 index 0000000..0010ca8 --- /dev/null +++ b/lib/featurevisor/normalize.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "json" + +module Featurevisor + module Normalize + class InvalidConditions < Hash + def initialize + super + self[:or] = [] + end + end + + class ParseFailure < StandardError; end + + def self.legacy(value, features = false) + return value if value.is_a?(InvalidConditions) + case value + when String + features ? { feature: value } : value + when Array + value.empty? ? "*" : value.map { |entry| legacy(entry, features) } + when Hash + group = %i[and or not].find { |name| value[name].is_a?(Array) } + return value unless group + return "*" if group == :and && value[group].empty? + { group => value[group].map { |entry| legacy(entry, features) } } + else + value + end + end + + def self.entity(kind, key, source, legacy = false) + errors = [] + parse = lambda do |value| + next value unless value.is_a?(String) && value.lstrip.match?(/\A[\{\[\"]/) + begin + JSON.parse(value, symbolize_names: true) + rescue JSON::ParserError => error + errors << { level: "error", code: "conditions_parse_error", message: "Error parsing conditions", + original_error: error, + details: { conditions: value, entityType: kind.to_s, entityKey: key.to_s } } + InvalidConditions.new + end + end + select = lambda do |entry, old = false| + result = entry.reject { |name, _| %i[segments requiredFeatures requiredVariables required].include?(name) } + parts = [] + if entry.key?(:conditions) + value = parse.call(entry[:conditions]) + parts << (old ? self.legacy(value) : value) + end + parts << self.legacy(parse.call(entry[:segments])) if entry.key?(:segments) + if entry.key?(:requiredFeatures) + features = entry[:requiredFeatures] + features = [features] unless features.is_a?(Array) + parts << features.map { |value| self.legacy(value, true) } unless features.empty? + end + if parts.any? { |part| part.is_a?(InvalidConditions) } + result[:conditions] = InvalidConditions.new + elsif parts.length == 1 + result[:conditions] = parts.first + elsif !parts.empty? + result[:conditions] = parts.flat_map { |part| part.is_a?(Array) ? (part.empty? ? [{ and: [] }] : part) : [part] } + end + result + end + with_overrides = lambda do |entry| + next entry unless entry[:variableOverrides] + entry.merge(variableOverrides: entry[:variableOverrides].transform_values do |values| + values.map { |override| select.call(override, legacy) } + end) + end + if kind.to_s == "segment" + value = parse.call(source[:conditions]) + result = source.merge(conditions: legacy ? self.legacy(value) : value) + else + result = select.call(source) + if kind.to_s == "feature" + result[:traffic] = (source[:traffic] || []).map { |entry| with_overrides.call(select.call(entry)) } + result[:force] = source[:force].map { |entry| select.call(entry, legacy) } if source[:force] + result[:variations] = source[:variations].map { |entry| with_overrides.call(entry) } if source[:variations] + else + %i[overrides overrideProgram].each do |name| + result[name] = source[name].map { |entry| select.call(entry, legacy && name == :overrides) } if source[name] + end + end + end + [result, errors] + end + + def self.datafile(source) + errors = [] + result = source.dup + { features: :feature, segments: :segment, variables: :variable }.each do |name, kind| + next unless source[name] + result[name] = source[name].each_with_object({}) do |(key, entry), values| + values[key], diagnostics = entity(kind, key, entry, true) + errors.concat(diagnostics) + end + end + [result, errors] + end + end +end diff --git a/lib/featurevisor/openfeature_provider.rb b/lib/featurevisor/openfeature_provider.rb index 047e6e5..06b74a4 100644 --- a/lib/featurevisor/openfeature_provider.rb +++ b/lib/featurevisor/openfeature_provider.rb @@ -3,11 +3,12 @@ require "json" require "time" require "open_feature/sdk" -require_relative "../featurevisor" +require "featurevisor" module Featurevisor class OpenFeatureProvider Provider = OpenFeature::SDK::Provider + include Provider::EventEmitter attr_reader :metadata, :featurevisor @@ -33,13 +34,26 @@ def initialize(options = {}, featurevisor: nil, targeting_key_field: "userId", k end @error_unsubscribe = @featurevisor.on("error", lambda do |event| diagnostic = event[:diagnostic] - @datafile_error = diagnostic[:message] if diagnostic&.dig(:code) == "invalid_datafile" + if diagnostic&.dig(:code) == "invalid_datafile" + @datafile_error = diagnostic[:message] + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + error_code: Provider::ErrorCode::PARSE_ERROR, message: @datafile_error) + end + end) + @datafile_unsubscribe = @featurevisor.on("datafile_set", lambda do |*| + recovering = !@datafile_error.nil? + @datafile_error = nil + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) if recovering end) - @datafile_unsubscribe = @featurevisor.on("datafile_set", ->(*) { @datafile_error = nil }) - @featurevisor.set_datafile(initial_datafile, true) if @owns_featurevisor && initial_datafile + @featurevisor.set_datafile(initial_datafile, true) if @owns_featurevisor && !initial_datafile.nil? end - def init(_evaluation_context = nil); end + def init(_evaluation_context = nil) + if @datafile_error + raise OpenFeature::SDK::ProviderInitializationError.new(@datafile_error, + provider: self, error_code: Provider::ErrorCode::PARSE_ERROR) + end + end def shutdown return if @shutdown @@ -50,7 +64,7 @@ def shutdown end def hooks = [] - def track(tracking_event_name:, evaluation_context: nil, tracking_event_details: nil) + def track(name = nil, tracking_event_name: name, evaluation_context: nil, tracking_event_details: nil) @on_track&.call(tracking_event_name, evaluation_context, tracking_event_details) end @@ -86,7 +100,7 @@ def resolve(flag_key, default_value, evaluation_context, expected_type) feature_key, selector = split_key(flag_key) context = normalize(evaluation_context&.fields || {}) targeting_key = evaluation_context&.targeting_key - context[@targeting_key_field] = targeting_key if targeting_key && !targeting_key.empty? + context[@targeting_key_field] = targeting_key unless targeting_key.nil? if feature_key == @global_variable_prefix && selector && !selector.empty? evaluation = featurevisor.evaluate_variable(selector, context) @@ -101,8 +115,10 @@ def resolve(flag_key, default_value, evaluation_context, expected_type) else evaluation = featurevisor.evaluate_variable(feature_key, selector, context) value = evaluation[:variable_value] + end + if evaluation[:type] == :variable || evaluation[:type] == "variable" variable_type = evaluation.dig(:variable_schema, :type) || evaluation.dig(:variable, :type) - if variable_type == "json" && value.is_a?(String) + if variable_type == "json" && !evaluation.dig(:variable, :compose) && value.is_a?(String) begin value = JSON.parse(value) rescue JSON::ParserError @@ -155,9 +171,9 @@ def metadata_for(evaluation) def reason(value) return Provider::Reason::ERROR if %w[feature_not_found variable_not_found no_variations error].include?(value) - return Provider::Reason::TARGETING_MATCH if %w[required forced sticky rule variable_override_variation variable_override_rule].include?(value) + return Provider::Reason::TARGETING_MATCH if %w[forced sticky rule variable_override_variation variable_override_rule variable_composed].include?(value) return Provider::Reason::SPLIT if value == "allocated" - return Provider::Reason::DISABLED if %w[disabled variation_disabled variable_disabled required_features_unmet].include?(value) + return Provider::Reason::DISABLED if %w[disabled variation_disabled variable_disabled conditions_unmet].include?(value) Provider::Reason::DEFAULT end diff --git a/lib/featurevisor/unified_conditions.rb b/lib/featurevisor/unified_conditions.rb new file mode 100644 index 0000000..3ced339 --- /dev/null +++ b/lib/featurevisor/unified_conditions.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Featurevisor + module UnifiedConditions + def self.matches?(conditions, options) + match(conditions, options) + rescue Normalize::ParseFailure + false + end + + def self.match(conditions, options) + raise Normalize::ParseFailure if conditions.is_a?(Normalize::InvalidConditions) + return true if conditions == "*" + if conditions.is_a?(String) + datafile = options[:__featurevisor_datafile_source]&.call || options[:datafile] + segment = datafile.get_segment(conditions) + return false unless segment + raise Normalize::ParseFailure if segment[:conditions].is_a?(Normalize::InvalidConditions) + if datafile.attribute_only_conditions?(segment[:conditions]) + return datafile.all_conditions_are_matched(segment[:conditions], options[:context]) + end + session = options[:__featurevisor_session] ||= EvaluationSession.new + return session.segment(conditions) { match(segment[:conditions], options) } + end + if conditions.is_a?(Array) + return !conditions.empty? && conditions.all? { |child| match(child, options) } + end + return false unless conditions.is_a?(Hash) + if conditions.key?(:variable) + begin + return options[:__featurevisor_instance].send(:variable_condition_matches?, conditions, options) + rescue RequirementError + raise + rescue => error + raise RequirementError, error.message + end + end + if conditions.key?(:feature) + clean = options.reject { |key, _| %i[feature_key variable_key default_variable_value default_variation_value].include?(key) } + clean = clean.merge(__featurevisor_prerequisite: true, dependency_purpose: "condition") + flag = Evaluate.evaluate_with_modules(clean.merge(type: "flag", feature_key: conditions[:feature])) + enabled = conditions[:enabled].nil? ? true : conditions[:enabled] + return false unless (flag[:enabled] == true) == enabled + return true unless conditions.key?(:variation) + variation = Evaluate.evaluate_with_modules(clean.merge(type: "variation", feature_key: conditions[:feature])) + value = variation.key?(:variation_value) ? variation[:variation_value] : variation.dig(:variation, :value) + return value == conditions[:variation] + end + if conditions.key?(:attribute) + return options[:datafile].all_conditions_are_matched(conditions, options[:context]) + end + %i[and or not].each do |group| + next unless conditions[group].is_a?(Array) + children = conditions[group] + return false if children.empty? + return children.any? { |child| match(child, options) } if group == :or + result = children.all? { |child| match(child, options) } + return group == :not ? !result : result + end + false + end + private_class_method :match + + def self.attribute_only?(conditions) + case conditions + when Array + conditions.all? { |child| attribute_only?(child) } + when Hash + return false if conditions.is_a?(Normalize::InvalidConditions) || conditions.key?(:feature) || conditions.key?(:variable) + return true if conditions.key?(:attribute) + group = %i[and or not].find { |name| conditions.key?(name) } + group.nil? || attribute_only?(conditions[group]) + else + false + end + end + private_class_method :attribute_only? + end +end diff --git a/lib/featurevisor/version.rb b/lib/featurevisor/version.rb index 2ffd7ff..04aede5 100644 --- a/lib/featurevisor/version.rb +++ b/lib/featurevisor/version.rb @@ -1,3 +1,3 @@ module Featurevisor - VERSION = "3.1.0" + VERSION = "4.0.0" end diff --git a/scripts/benchmark_sticky_prerequisites.rb b/scripts/benchmark_sticky_prerequisites.rb new file mode 100644 index 0000000..d7d2cca --- /dev/null +++ b/scripts/benchmark_sticky_prerequisites.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "json" +require_relative "../lib/featurevisor" + +iterations = [Integer(ARGV.fetch(0, "1000")), 1].max +exported = ARGV[1] && JSON.parse(File.read(ARGV[1]), symbolize_names: true) +traffic = [{ key: "all", segments: "*", percentage: 100_000 }] +datafile = exported || { schemaVersion: "2", revision: "sticky-benchmark", segments: {}, features: {}, variables: {} } +datafile[:features][:leafFlag] = { bucketBy: "userId", traffic: traffic } +datafile[:variables] ||= {} +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, + requiredVariables: Array.new(2) { { variable: "source", operator: "equals", value: 1 } } } + +puts "kind,size,owner,mean_microseconds_per_evaluation,iterations" +%w[features variables].each do |kind| + [0, 100, 1000, 5000].each do |size| + sticky = size.times.to_h do |index| + ["unrelated#{index}", kind == "features" ? { enabled: true } : index] + end + %w[instance child].each do |owner_name| + field = kind == "features" ? :sticky_features : :sticky_variables + # The no op hook exercises the public sticky handoff on every prerequisite. + sdk = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal", + modules: [{ before_evaluation: ->(options) { options } }], field => sticky) + owner = owner_name == "child" ? sdk.spawn({}, field => sticky) : sdk + read = if kind == "features" + -> { owner.get_variable("featureGated", { userId: "bench" }) == 1 } + else + -> { owner.get_variable("gated", { userId: "bench" }) == 1 } + end + 100.times { raise "Incorrect warmup result" unless read.call } + total = 0 + iterations.times do + started = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) + raise "Incorrect benchmark result" unless read.call + total += Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - started + end + puts format("%s,%d,%s,%.3f,%d", kind, size, owner_name, total.to_f / iterations / 1000, iterations) + owner.close if owner_name == "child" + sdk.close + end + end +end diff --git a/scripts/verify_gems.rb b/scripts/verify_gems.rb index 523fa5b..4694d77 100644 --- a/scripts/verify_gems.rb +++ b/scripts/verify_gems.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true require "rubygems/package" +require "tmpdir" +require "open3" +require "rbconfig" require_relative "../lib/featurevisor/version" version = Featurevisor::VERSION @@ -36,4 +39,47 @@ def inspect_gem(path) raise "Provider must depend on matching Featurevisor version" unless featurevisor_dependency&.requirement == Gem::Requirement.new("= #{version}") raise "Provider must depend on openfeature-sdk ~> 0.6.5" unless openfeature_dependency&.requirement == Gem::Requirement.new("~> 0.6.5") +Dir.mktmpdir("featurevisor-packaged-") do |directory| + base_directory = File.join(directory, "base") + provider_directory = File.join(directory, "provider") + Gem::Package.new(base_path).extract_files(base_directory) + Gem::Package.new(provider_path).extract_files(provider_directory) + paths = [File.join(base_directory, "lib"), File.join(provider_directory, "lib")] + paths.concat(Gem::Specification.find_by_name("openfeature-sdk", "~> 0.6.5").full_require_paths) + base_script = <<~RUBY + require "featurevisor" + abort "OpenFeature leaked into base gem" if defined?(OpenFeature) + instance = Featurevisor.create_featurevisor(log_level: "fatal") + instance.close + RUBY + stdout, stderr, status = Open3.capture3( + {"RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil}, + RbConfig.ruby, "--disable-gems", "-I", paths.first, "-e", base_script, + chdir: directory + ) + raise "Packaged base could not load without OpenFeature: #{stdout}#{stderr}" unless status.success? + script = <<~RUBY + require "featurevisor-openfeature" + abort "Wrong packaged version" unless Featurevisor::VERSION == #{version.inspect} + tracked = [] + provider = Featurevisor::OpenFeatureProvider.new( + log_level: "fatal", on_track: ->(*args) { tracked << args }, + datafile: { schemaVersion: "4", revision: "packaged", segments: {}, + features: { checkout: { bucketBy: "userId", traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] } } } + ) + OpenFeature::SDK.configure { |config| config.set_provider_and_wait(provider) } + client = OpenFeature::SDK.build_client + abort "Packaged client resolution failed" unless client.fetch_boolean_value(flag_key: "checkout", default_value: false) + client.track("purchase") + abort "Packaged tracking failed" unless tracked.first&.first == "purchase" + OpenFeature::SDK.shutdown + RUBY + stdout, stderr, status = Open3.capture3( + {"RUBYOPT" => nil, "RUBYLIB" => nil, "BUNDLE_GEMFILE" => nil}, + RbConfig.ruby, *paths.flat_map { |path| ["-I", path] }, "-e", script, + chdir: directory + ) + raise "Packaged provider could not load its separate core gem: #{stdout}#{stderr}" unless status.success? +end + puts "Verified #{base_path} and #{provider_path}" diff --git a/spec/child_instance_spec.rb b/spec/child_instance_spec.rb index 56ccbe6..b9f62f7 100644 --- a/spec/child_instance_spec.rb +++ b/spec/child_instance_spec.rb @@ -235,7 +235,7 @@ expect(child_f.get_variable_integer("test", "count")).to eq(0) expect(child_f.get_variable("test", "price")).to eq(9.99) - expect(child_f.get_variable_double("test", "price")).to eq(9.99) + expect(child_f.get_variable_number("test", "price")).to eq(9.99) expect(child_f.get_variable("test", "paymentMethods")).to eq(["paypal", "creditcard"]) expect(child_f.get_variable_array("test", "paymentMethods")).to eq(["paypal", "creditcard"]) diff --git a/spec/cli_options_spec.rb b/spec/cli_options_spec.rb new file mode 100644 index 0000000..1892638 --- /dev/null +++ b/spec/cli_options_spec.rb @@ -0,0 +1,153 @@ +require "featurevisor" +require "open3" +require "rbconfig" +require "tmpdir" +require_relative "../bin/cli" + +RSpec.describe "Native CLI option alignment" do + let(:parser) { FeaturevisorCLI::Parser } + + it "maps camelCase and kebab case aliases in both value forms" do + aliases = { + "projectDirectoryPath" => ["project-directory-path", :project_directory_path, "/tmp/quoted ' path"], + "keyPattern" => ["key-pattern", :key_pattern, "^ready.*$"], + "assertionPattern" => ["assertion-pattern", :assertion_pattern, "with spaces"], + "onlyFailures" => ["only-failures", :only_failures, nil], + "showDatafile" => ["show-datafile", :show_datafile, nil], + "populateUuid" => ["populate-uuid", :populate_uuid, "userId"], + "entityType" => ["entity-type", :entity_type, "feature"] + } + %w[test benchmark assess-distribution].each do |command| + aliases.each do |camel, (kebab, field, value)| + [false, true].each do |separate| + args = ->(name) { value.nil? ? ["--#{name}"] : (separate ? ["--#{name}", value] : ["--#{name}=#{value}"]) } + expect(parser.parse([command, *args.call(camel)]).public_send(field)) + .to eq(parser.parse([command, *args.call(kebab)]).public_send(field)) + end + end + %w[project-directory-path projectDirectoryPath root-directory-path rootDirectoryPath].each do |name| + [["--#{name}=/tmp/quoted ' path"], ["--#{name}", "/tmp/quoted ' path"]].each do |args| + options = parser.parse([command, *args]) + expect(options.project_directory_path).to eq("/tmp/quoted ' path") + expect(options.project_directory_path_explicit).to be(true) + end + end + end + end + + it "accepts iteration forms and preserves repeated selectors and quoted JSON" do + context = '{"userId":"a b", "literal":"$(touch injected)"}' + %w[test benchmark assess-distribution].each do |command| + [["--n=2"], ["--n", "2"], ["-n=2"], ["-n", "2"], ["--iterations=2"]].each do |iterations| + options = parser.parse([command, *iterations, "--target=web space", "--target", "mobile", + "--target=web space", "--populate-uuid=userId", "--populateUuid", "accountId", "--context", context]) + expect(options.n).to eq(2) + expect(options.targets).to eq(["web space", "mobile"]) + expect(options.populate_uuid).to eq(%w[userId accountId]) + expect(options.context).to eq(context) + end + end + end + + it "rejects usage errors before any subprocess and without stdout" do + invalid = [["--unknown"], ["extra"], ["--", "extra"], ["--target", "web", "mobile"], + ["--populate-uuid", "userId", "accountId"], ["--target="], ["--populate-uuid="], + ["--root-directory-path="], ["--target"], ["--populate-uuid"], ["--context="], + ["--context=[]"], ["--context=null"], ["--context={"], ["--feature="], ["--variable="], + ["--entity-type=unknown"], ["--target", "--quiet"]] + %w[--n -n --inflate].each do |name| + %w[0 -1 1.5 bad].push("").each { |value| invalid << ["#{name}=#{value}"] } + invalid << [name] + end + expect(Open3).not_to receive(:capture3) + %w[test benchmark assess-distribution].each do |command| + invalid.each do |args| + expect { + expect { + expect { FeaturevisorCLI.run([command, "--feature=ready", *args]) } + .to raise_error(SystemExit) { |error| expect(error.status).not_to eq(0) } + }.to output(/Error:/).to_stderr + }.to output("").to_stdout + end + end + end + + it "shows root and command help and rejects unknown dispatch" do + [[], ["--help"], ["-h"]].each do |args| + expect { FeaturevisorCLI.run(args) }.to output(/Commands:/).to_stdout + end + %w[test benchmark assess-distribution].each do |command| + expect { + expect { FeaturevisorCLI.run([command, "--help"]) } + .to raise_error(SystemExit) { |error| expect(error.status).to eq(0) } + }.to output(/Usage: featurevisor #{command}.*--root-directory-path.*--populate-uuid/m).to_stdout + end + %w[evaluate typo --unknown].each do |command| + expect { + expect { FeaturevisorCLI.run([command]) } + .to raise_error(SystemExit) { |error| expect(error.status).not_to eq(0) } + }.to output(/Unknown command/).to_stderr + end + end + + it "preserves literal paths, selectors and the JSON output boundary in real processes" do + Dir.mktmpdir("featurevisor cli ") do |directory| + project = File.join(directory, "project ' café $(touch injected)") + Dir.mkdir(project) + calls = File.join(directory, "calls.jsonl") + shim = File.join(directory, "npx") + File.write(shim, <<~'RUBY') + #!/usr/bin/env ruby + require "json" + args = ARGV.drop(1) + File.open(ENV.fetch("CLI_CALLS"), "a") { |log| log.puts(JSON.generate([Dir.pwd, args])) } + value = if args[0] == "config" + {environments: false, sets: false} + elsif args[0] == "list" + if args.include?("--tests") + {kind: "featurevisor.test-execution", schemaVersion: 1, tests: [{feature: "ready", assertions: [{expectedToBeEnabled: true}]}]} + elsif args.include?("--targets") + ["web space", "mobile'quote"] + else + [] + end + else + warn "delegated warning" + {schemaVersion: "2", revision: "test", segments: {}, features: {ready: {bucketBy: "userId", traffic: [{key: "all", segments: "*", percentage: 100000}]}}} + end + puts JSON.generate(value) + RUBY + File.chmod(0o755, shim) + context = JSON.generate({userId: "a b ' café", literal: "$(touch injected)"}) + pattern = "ready ' café;$()" + %w[test benchmark assess-distribution].each do |command| + File.write(calls, "") + options = command == "test" ? ["--json", "--show-datafile", "--only-failures", "--key-pattern", pattern, + "--assertion-pattern=with spaces", "--entity-type=feature"] : ["--context", context] + stdout, stderr, status = Open3.capture3( + {"PATH" => "#{directory}:#{ENV['PATH']}", "CLI_CALLS" => calls}, RbConfig.ruby, + File.expand_path("../bin/featurevisor", __dir__), command, + "--root-directory-path", project, "--feature=ready", "-n=2", + "--target=web space", "--target", "mobile'quote", "--target=web space", *options + ) + stdout.force_encoding(Encoding::UTF_8) + stderr.force_encoding(Encoding::UTF_8) + expect(status.success?).to be(true), stdout + stderr + recorded = File.readlines(calls, encoding: "UTF-8").map { |line| JSON.parse(line) } + expect(recorded.map { |cwd, _| File.realpath(cwd) }.uniq).to eq([File.realpath(project)]) + targets = recorded.filter_map { |_, call| call.grep(/^--target=/) if call[0] == "build" }.flatten + expect(targets).to eq(["--target=web space", "--target=mobile'quote"]) + if command == "test" + expect(JSON.parse(stdout)["passed"]).to be(true) + delegated = recorded.find { |_, call| call.include?("--tests") }.last + expect(delegated).to include("--keyPattern=#{pattern}", "--assertionPattern=with spaces", "--entityType=feature") + expect(stderr).to include('"schemaVersion": "2"') + else + expect(stdout).not_to include("Environment:") + expect(stdout).to include(context) + end + expect(File.exist?(File.join(project, "injected"))).to be(false) + end + end + end +end diff --git a/spec/cli_process_encoding_spec.rb b/spec/cli_process_encoding_spec.rb new file mode 100644 index 0000000..770ab08 --- /dev/null +++ b/spec/cli_process_encoding_spec.rb @@ -0,0 +1,73 @@ +require "featurevisor" +require "rbconfig" +require "shellwords" +require "stringio" +require "tmpdir" + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "bin")) +require "cli" + +RSpec.describe "CLI subprocess encoding" do + let(:options) do + FeaturevisorCLI::Options.new.tap do |value| + value.project_directory_path = Dir.pwd + end + end + + def ruby_command(exit_code) + script = 'STDOUT.write([99, 97, 102, 195, 169].pack("C*")); STDERR.write([195, 169].pack("C*")); exit ARGV[0].to_i' + [RbConfig.ruby, "-e", script, exit_code.to_s].shelljoin + end + + around do |example| + original_encoding = Encoding.default_external + Encoding.default_external = Encoding::US_ASCII + example.run + ensure + Encoding.default_external = original_encoding + end + + it "decodes test command output and error diagnostics as UTF-8" do + command = FeaturevisorCLI::Commands::Test.new(options) + expect(command.send(:execute_command, ruby_command(0))).to eq("café") + expect { command.send(:execute_command, ruby_command(7)) } + .to raise_error(FeaturevisorCLI::Commands::Test::InfrastructureError, /é/) + end + + it "decodes benchmark output and error diagnostics as UTF-8" do + command = FeaturevisorCLI::Commands::Benchmark.new(options) + expect(command.send(:execute_command, ruby_command(0))).to eq("café") + original_stdout = $stdout + output = StringIO.new + $stdout = output + expect { command.send(:execute_command, ruby_command(7)) } + .to raise_error(SystemExit) { |error| expect(error.status).to eq(1) } + expect(output.string).to include("Stderr: é") + ensure + $stdout = original_stdout + end + + it "decodes assessment output and error diagnostics as UTF-8" do + command = FeaturevisorCLI::Commands::AssessDistribution.new(options) + stdout, stderr, status = command.send(:execute_command, ruby_command(7)) + expect([stdout, stderr, status]).to eq(["café", "é", 7]) + expect(stdout.encoding).to eq(Encoding::UTF_8) + expect(stderr.encoding).to eq(Encoding::UTF_8) + end + + it "decodes target lists as UTF-8" do + Dir.mktmpdir do |directory| + script = File.join(directory, "npx") + File.write(script, "#!/bin/sh\nprintf '[\"caf\\303\\251\"]'\n") + File.chmod(0o755, script) + original_path = ENV["PATH"] + ENV["PATH"] = "#{directory}:#{original_path}" + options.targets = ["café"] + [FeaturevisorCLI::Commands::Benchmark, FeaturevisorCLI::Commands::AssessDistribution].each do |klass| + expect(klass.new(options).send(:resolve_targets)).to eq(["café"]) + end + ensure + ENV["PATH"] = original_path + end + end +end diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index 55094d1..2591dcf 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -1,4 +1,7 @@ require "featurevisor" +require "open3" +require "rbconfig" +require "tmpdir" # Test the CLI functionality by loading it directly $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "bin")) @@ -31,13 +34,86 @@ expect { FeaturevisorCLI.show_help }.to output(/Commands:/).to_stdout end - it "shows benchmark environment requirement note" do - expect { FeaturevisorCLI.show_help }.to output(/Note: benchmark requires --environment and either --feature or --variable/).to_stdout + it "shows the conditional environment requirement" do + expect { FeaturevisorCLI.show_help }.to output(/--environment is required only when configured/).to_stdout end end end RSpec.describe FeaturevisorCLI::Parser do + it "preserves explicit sets for Node validation in flat projects" do + Dir.mktmpdir("featurevisor-sets-") do |directory| + shim = File.join(directory, "npx") + File.write(shim, <<~'RUBY') + #!/usr/bin/env ruby + require "json" + args = ARGV.drop(1) + File.open(File.join(__dir__, "calls"), "a") { |file| file.puts(JSON.generate(args)) } + if args[0] == "config" + puts JSON.generate({environments: false, sets: false}) + elsif args.any? { |arg| arg.start_with?("--set=") } + warn "This project does not configure sets. Omit --set." + exit 17 + elsif args[0] == "list" + puts JSON.generate(args.include?("--tests") ? {kind: "featurevisor.test-execution", schemaVersion: 1, tests: []} : []) + else + puts JSON.generate({schemaVersion: "2", revision: "test", segments: {}, variables: {}, features: {ready: {bucketBy: "userId", traffic: [{key: "everyone", segments: "*", percentage: 100000}]}}}) + end + RUBY + File.chmod(0o755, shim) + %w[test benchmark assess-distribution].each do |command| + [[], ["--set=blue"], ["--set="], ["--set=0"]].each do |args| + File.write(File.join(directory, "calls"), "") + stdout, stderr, status = Open3.capture3( + {"PATH" => "#{directory}:#{ENV['PATH']}"}, RbConfig.ruby, + File.expand_path("../bin/featurevisor", __dir__), command, + "--projectDirectoryPath=#{directory}", "--feature=ready", "--n=2", *args + ) + expect(status.success?).to eq(args.empty?), stdout + stderr + calls = File.readlines(File.join(directory, "calls"), encoding: "UTF-8").map { |line| JSON.parse(line) }.reject { |call| call[0] == "config" } + expect(calls).not_to be_empty + calls.each { |call| expect(call.grep(/^--set=/)).to eq(args) } + expect(stdout + stderr).to include("Omit --set") unless args.empty? + end + end + end + end + + it "rejects invalid inflation in CLI processes before Node delegation" do + Dir.mktmpdir("featurevisor-inflate-") do |directory| + marker = File.join(directory, "delegated") + shim = File.join(directory, "npx") + File.write(shim, "#!/bin/sh\ntouch '#{marker}'\nexit 19\n") + File.chmod(0o755, shim) + %w[test benchmark assess-distribution].each do |command| + ["--inflate=0", "--inflate=-5", "--inflate=1.5", "--inflate=invalid", "--inflate=", "--inflate=1foo", "--inflate"].each do |option| + stdout, stderr, status = Open3.capture3( + {"PATH" => "#{directory}:#{ENV['PATH']}"}, RbConfig.ruby, + File.expand_path("../bin/featurevisor", __dir__), command, "--feature=ready", option + ) + expect(status.success?).to be(false), stdout + stderr + expect(stdout + stderr).to include("inflate") + expect(File.exist?(marker)).to be(false) + end + end + end + end + + it "accepts omitted inflation and positive integers for every command" do + %w[test benchmark assess-distribution].each do |command| + expect(described_class.parse([command]).inflate).to be_nil + expect(described_class.parse([command, "--inflate=1"]).inflate).to eq(1) + end + end + + it "rejects invalid explicit inflation before running any command" do + %w[test benchmark assess-distribution].each do |command| + ["0", "-5", "1.5", "invalid", "", "1foo"].each do |value| + expect { described_class.parse([command, "--inflate=#{value}"]) }.to raise_error(OptionParser::InvalidArgument) + end + end + end + it "parses and deduplicates repeated targets" do options = described_class.parse(["test", "--target=web", "--target=mobile", "--target=web"]) expect(options.targets).to eq(["web", "mobile"]) @@ -95,12 +171,13 @@ opts end - it "requires environment parameter" do + it "requires environment when the project configures environments" do + allow_any_instance_of(described_class).to receive(:project_json).with("config", "--json", include_set: false).and_return({"environments" => ["development"]}) options_without_env = FeaturevisorCLI::Options.new options_without_env.feature = "testFeature" options_without_env.n = 100 - expect { FeaturevisorCLI::Commands::Benchmark.run(options_without_env) }.to raise_error(SystemExit) + expect { FeaturevisorCLI::Commands::Benchmark.run(options_without_env) }.to raise_error(ArgumentError, /--environment/) end it "requires feature parameter" do @@ -112,6 +189,7 @@ end it "accepts valid parameters" do + allow_any_instance_of(described_class).to receive(:project_json).with("config", "--json", include_set: false).and_return({"environments" => ["development"]}) # Mock the build_datafile method to avoid external command execution allow_any_instance_of(FeaturevisorCLI::Commands::Benchmark).to receive(:build_datafile).and_return({ "schemaVersion" => "2", diff --git a/spec/conditions_spec.rb b/spec/conditions_spec.rb index 59fc4ad..8593f39 100644 --- a/spec/conditions_spec.rb +++ b/spec/conditions_spec.rb @@ -14,7 +14,141 @@ ) end + describe "native date values" do + def date_matches(actual, operator, expected) + datafile_reader.all_conditions_are_matched( + { attribute: "date", operator: operator, value: expected }, { date: actual } + ) + end + + %w[UTC America/Los_Angeles Asia/Tokyo].each do |zone| + context "with TZ=#{zone}" do + around do |example| + previous = ENV["TZ"] + ENV["TZ"] = zone + example.run + ensure + previous.nil? ? ENV.delete("TZ") : ENV["TZ"] = previous + end + + it "rejects plain Date on either side of both operators" do + [Date.new(2026, 1, 1), Class.new(Date).new(2026, 1, 1)].each do |value| + %w[before after].each do |operator| + expect(date_matches(value, operator, "2026-01-01T05:00:00Z")).to be false + expect(date_matches("2026-01-01T05:00:00Z", operator, value)).to be false + end + end + end + + it "accepts Time, DateTime and portable timestamps as the same instant" do + [Time.utc(2026, 1, 1), Time.new(2026, 1, 1, 9, 0, 0, "+09:00"), + DateTime.iso8601("2025-12-31T16:00:00-08:00"), + Class.new(DateTime).iso8601("2026-01-01T00:00:00Z"), + "2026-01-01T09:00:00+09:00"].each do |value| + expect(date_matches(value, "before", "2026-01-01T00:00:00.001Z")).to be true + expect(date_matches(value, "after", "2025-12-31T23:59:59.999Z")).to be true + expect(date_matches("2026-01-01T00:00:00.001Z", "after", value)).to be true + expect(date_matches("2025-12-31T23:59:59.999Z", "before", value)).to be true + %w[before after].each do |operator| + expect(date_matches(value, operator, "2026-01-01T00:00:00Z")).to be false + end + end + end + + it "truncates native fractions to milliseconds including before the epoch" do + %w[2026-01-01T00:00:00.000Z 1969-12-31T23:59:59.999Z].each do |boundary| + instant = Time.iso8601(boundary) + value = instant + Rational(999999, 1_000_000_000) + [value, value.getlocal("+09:00"), value.to_datetime].each do |native| + %w[before after].each do |operator| + expect(date_matches(native, operator, boundary)).to be false + expect(date_matches(boundary, operator, native)).to be false + end + expect(date_matches(native, "before", instant + Rational(1, 1000))).to be true + expect(date_matches(native, "after", instant - Rational(1, 1000))).to be true + end + end + end + + it "accepts offsets up to fourteen hours without discarding seconds" do + instant = Time.utc(2026, 1, 1) + [-50400, 50400, -50399, 50399, -1, 1].each do |seconds| + [instant.getlocal(seconds), instant.to_datetime.new_offset(Rational(seconds, 86400))].each do |value| + expect(date_matches(value, "before", "2026-01-01T00:00:00.001Z")).to be true + expect(date_matches(value, "after", "2025-12-31T23:59:59.999Z")).to be true + expect(date_matches("2026-01-01T00:00:00.001Z", "after", value)).to be true + expect(date_matches("2025-12-31T23:59:59.999Z", "before", value)).to be true + %w[before after].each do |operator| + expect(date_matches(value, operator, "2026-01-01T00:00:00Z")).to be false + expect(date_matches("2026-01-01T00:00:00Z", operator, value)).to be false + end + end + end + [-1, 1].each do |sign| + value = instant.getlocal(sign * Rational(100799, 2)) + expect(date_matches(value, "before", "2026-01-01T00:00:00.001Z")).to be true + expect(date_matches(value, "after", "2025-12-31T23:59:59.999Z")).to be true + end + end + + it "rejects offsets beyond fourteen hours including extra seconds and fractions" do + instant = Time.utc(2026, 1, 1) + values = [-54000, 54000, -50401, 50401].flat_map do |seconds| + [instant.getlocal(seconds), instant.to_datetime.new_offset(Rational(seconds, 86400))] + end + values += [-1, 1].map { |sign| instant.getlocal(sign * Rational(100801, 2)) } + values.each do |value| + %w[before after].each do |operator| + %w[2025-12-31T23:59:59.999Z 2026-01-01T00:00:00.001Z].each do |boundary| + expect(date_matches(value, operator, boundary)).to be false + expect(date_matches(boundary, operator, value)).to be false + end + end + end + end + + it "rejects native written or UTC years outside the portable range without raising" do + ["0000-12-31T23:00:00-02:00", "10000-01-01T01:00:00+02:00", + "0001-01-01T00:00:00+01:00", "9999-12-31T23:59:59-01:00"].each do |text| + [Time.iso8601(text), DateTime.iso8601(text)].each do |value| + %w[before after].each do |operator| + expect(date_matches(value, operator, "2026-01-01T00:00:00Z")).to be false + expect(date_matches("2026-01-01T00:00:00Z", operator, value)).to be false + end + end + end + end + + it "accepts both native year range endpoints" do + [Time.utc(1, 1, 1), Time.utc(9999, 12, 31, 23, 59, Rational(59999, 1000))].each do |value| + [value, value.to_datetime].each do |native| + operator = value.year == 1 ? "before" : "after" + expect(date_matches(native, operator, "2026-01-01T00:00:00Z")).to be true + end + end + end + end + end + end + describe "basic functionality" do + it "truncates one to nine string fractional digits to milliseconds" do + parse = ->(value) { Featurevisor::Conditions.send(:portable_date, value) } + %w[1239 123999 1239999 123999999].each do |digits| + expect(parse.call("2024-01-01T00:00:00.#{digits}Z")).to eq(parse.call("2024-01-01T00:00:00.123Z")) + end + expect(parse.call("2024-01-01T00:00:00.1234567890Z")).to be_nil + expect(parse.call("1969-12-31T23:59:59.999999999Z").to_r).to eq(Rational(-1, 1_000)) + end + + it "floors pre epoch host dates to milliseconds and rejects offsets beyond fourteen hours" do + host = Time.at(Rational(-1, 2000)).utc + parsed = Featurevisor::Conditions.send(:portable_date, host) + expect(parsed.to_r).to eq(Rational(-1, 1000)) + expect(Featurevisor::Conditions.send(:portable_date, Time.new(2024, 1, 1, 15, 0, 0, "+15:00"))).to be_nil + expect(Featurevisor::Conditions.send(:portable_date, "2024-01-01T15:00:00+15:00")).to be_nil + end + it "should be a module" do expect(Featurevisor::Conditions).to be_a(Module) end @@ -92,6 +226,38 @@ )).to be false end + it "matches empty and notEmpty for shallow string, array and object contents" do + cases = [ + ["empty", "", true], + ["notEmpty", "", false], + ["empty", " ", false], + ["notEmpty", " ", true], + ["empty", [], true], + ["notEmpty", [nil], true], + ["empty", {}, true], + ["notEmpty", { value: nil }, true], + ["empty", nil, false], + ["notEmpty", nil, false], + ["empty", 0, false], + ["notEmpty", false, false], + ["empty", Object.new, false], + ["notEmpty", Object.new, false] + ] + + cases.each do |operator, value, expected| + condition = { attribute: "profile.value", operator: operator } + context = { profile: { value: value } } + expect(Featurevisor::Conditions.condition_is_matched(condition, context, get_regex)).to be(expected) + end + + expect(Featurevisor::Conditions.condition_is_matched( + { attribute: "profile.missing", operator: "empty" }, { profile: {} }, get_regex + )).to be false + expect(Featurevisor::Conditions.condition_is_matched( + { attribute: "profile.missing", operator: "notEmpty" }, { profile: {} }, get_regex + )).to be false + end + describe "equals" do it "should match exact values" do condition = { attribute: "browser_type", operator: "equals", value: "chrome" } diff --git a/spec/conformance_spec.rb b/spec/conformance_spec.rb index f819cb6..8541196 100644 --- a/spec/conformance_spec.rb +++ b/spec/conformance_spec.rb @@ -5,8 +5,9 @@ RSpec.describe "Featurevisor v3 conformance" do it "uses the shared inclusive allocation contract" do - fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) - expect(fixture[:version]).to eq(6) + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__), encoding: "UTF-8"), symbolize_names: true) + expect(fixture[:version]).to eq(15) + expect(fixture[:conditionCases].length).to eq(189) reader = Featurevisor.const_get(:InstanceEvaluationDataProvider).new( datafile: { schemaVersion: "2", revision: "conformance", segments: {}, features: {} }, diagnostics: Featurevisor.const_get(:DiagnosticReporter).new(level: "fatal") @@ -40,11 +41,14 @@ ).to eq(test_case[:expected]), "pattern #{test_case[:pattern]}, flags #{test_case[:flags]}" end + executed_any = 0 fixture[:conditionCases].each do |test_case| expect( reader.all_conditions_are_matched(test_case[:condition], test_case[:context]) ).to eq(test_case[:expected]), test_case[:name] + executed_any += 1 if JSON.generate(test_case[:condition]).match?(/includesAny|notIncludesAny/) end + expect(executed_any).to eq(73) aggregate_case = fixture.dig(:defaults, :aggregateCase) featurevisor = Featurevisor.create_featurevisor(datafile: aggregate_case[:datafile], log_level: "fatal") @@ -58,7 +62,7 @@ end it "evaluates every shared global variable case" do - fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__), encoding: "UTF-8"), symbolize_names: true) globals = fixture[:globalVariables] globals[:cases].each do |test_case| @@ -107,7 +111,7 @@ end it "supports canonical required features for flags and feature variable overrides" do - fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__), encoding: "UTF-8"), symbolize_names: true) required = fixture[:requiredFeatures] f = Featurevisor.create_featurevisor(datafile: required[:datafile], log_level: "fatal") @@ -120,8 +124,90 @@ expect(evaluation[:variable_override_key]).to eq(test_case[:expectedOverrideKey]) end + it "verifies every canonical prerequisite evaluation detail" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__), encoding: "UTF-8"), symbolize_names: true) + required = fixture.fetch(:requiredFeatures) + cases = required.fetch(:evaluationCases) + expect(cases).not_to be_empty + sdk = Featurevisor.create_featurevisor(datafile: required.fetch(:datafile), log_level: "fatal") + + cases.each do |test_case| + context = test_case.fetch(:context, {}) + evaluation = case test_case.fetch(:type) + when "flag" + sdk.evaluate_flag(test_case.fetch(:feature), context) + when "variation" + sdk.evaluate_variation(test_case.fetch(:feature), context) + when "variable" + if test_case.key?(:feature) + sdk.evaluate_variable(test_case.fetch(:feature), test_case.fetch(:variable), context) + else + sdk.evaluate_variable(test_case.fetch(:variable), context) + end + else + raise "Unknown evaluation type: #{test_case[:type]}" + end + + test_case.fetch(:expected).each do |field, expected| + native_field = field.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym + expect(evaluation).to have_key(native_field), test_case.fetch(:name) + expect(evaluation[native_field]).to eq(expected), "#{test_case.fetch(:name)}: #{field}" + end + end + ensure + sdk&.close + end + + it "evaluates every recursive required feature group truth table" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__), encoding: "UTF-8"), symbolize_names: true) + + fixture[:requiredFeatureGroups].each do |test_case| + fixture[:requiredFeatureGroupInputs].zip(test_case[:results]).each do |inputs, expected| + sdk = Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "2", revision: "groups", segments: {}, + features: { + a: { bucketBy: "userId", force: [{ segments: "*", enabled: inputs[:a] }], traffic: [] }, + b: { bucketBy: "userId", force: [{ segments: "*", enabled: inputs[:b] }], traffic: [] }, + subject: { + bucketBy: "userId", + requiredFeatures: test_case[:requirements], + traffic: [{ key: "all", segments: "*", percentage: 100_000 }] + } + } + }, + log_level: "fatal" + ) + expect(sdk.is_enabled("subject")).to eq(expected), "#{test_case[:name]}: #{inputs}" + end + end + end + + it "evaluates shared required variable roots, groups, sticky sources and accumulation" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__), encoding: "UTF-8"), symbolize_names: true) + required = fixture[:requiredVariables] + + required[:stickyCases].each do |test_case| + sdk = Featurevisor.create_featurevisor( + datafile: required[:datafile], + sticky_features: test_case[:stickyFeatures], + sticky_variables: test_case[:stickyVariables], + log_level: "fatal" + ) + expected = test_case[:matched] ? required.dig(:expected, :matchedValue) : required.dig(:expected, :defaultValue) + expect(sdk.is_enabled("gated")).to eq(test_case[:matched]), test_case[:name] + expect(sdk.get_variable("root")).to eq(expected), test_case[:name] + expect(sdk.get_variable("override")).to eq(expected), test_case[:name] + expect(sdk.get_variable("featureOverride", "message")).to eq(expected), test_case[:name] + expect(sdk.get_variable("accumulated")).to eq(test_case[:accumulated]), test_case[:name] + end + + sdk = Featurevisor.create_featurevisor(datafile: required[:datafile], log_level: "fatal") + expect(sdk.get_variable("nullGate")).to eq(required.dig(:expected, :matchedValue)) + end + it "reports direct and dependency driven variable updates for merge and replacement" do - fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__), encoding: "UTF-8"), symbolize_names: true) globals = fixture[:globalVariables] update = globals[:datafileUpdateCase] f = Featurevisor.create_featurevisor(datafile: update[:initial], log_level: "fatal") @@ -156,7 +242,7 @@ end it "keeps child sticky variables isolated and runs unified module callbacks" do - fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__), encoding: "UTF-8"), symbolize_names: true) datafile = fixture.dig(:globalVariables, :datafile) callbacks = [] f = Featurevisor.create_featurevisor( diff --git a/spec/deprecation_diagnostics_spec.rb b/spec/deprecation_diagnostics_spec.rb new file mode 100644 index 0000000..7fd4790 --- /dev/null +++ b/spec/deprecation_diagnostics_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "featurevisor" + +RSpec.describe "deprecation diagnostics" do + let(:captured) { [] } + let(:sdk) do + Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "4", revision: "1", segments: {}, + features: { + legacy: { + deprecated: true, + bucketBy: "userId", + variablesSchema: { old: { type: "string", defaultValue: "x", deprecated: true } }, + traffic: [{ key: "all", conditions: "*", percentage: 100_000, allocation: [] }] + } + }, + variables: { oldGlobal: { type: "string", defaultValue: "y", deprecated: true } } + }, + log_level: "warn", + on_diagnostic: ->(diagnostic) { captured << diagnostic[:code].to_s if diagnostic[:code].to_s.include?("deprecated") } + ) + end + + after { sdk.close } + + it "reports deprecated features and variables with one code per kind" do + sdk.is_enabled("legacy") + expect(captured.uniq).to eq(["deprecated_feature"]) + + captured.clear + sdk.get_variable("legacy", "old") + expect(captured.uniq.sort).to eq(%w[deprecated_feature deprecated_variable]) + + captured.clear + sdk.get_variable("oldGlobal") + expect(captured).to eq(["deprecated_variable"]) + end +end diff --git a/spec/evaluate_spec.rb b/spec/evaluate_spec.rb index a1c424a..b88dabb 100644 --- a/spec/evaluate_spec.rb +++ b/spec/evaluate_spec.rb @@ -5,8 +5,8 @@ it "should have feature specific reasons" do expect(Featurevisor::EvaluationReason::FEATURE_NOT_FOUND).to eq("feature_not_found") expect(Featurevisor::EvaluationReason::DISABLED).to eq("disabled") - expect(Featurevisor::EvaluationReason::REQUIRED).to eq("required") - expect(Featurevisor::EvaluationReason::OUT_OF_RANGE).to eq("out_of_range") + expect(Featurevisor::EvaluationReason::CONDITIONS_UNMET).to eq("conditions_unmet") + expect(Featurevisor::EvaluationReason.const_defined?(:REQUIRED, false)).to be(false) end it "should have variation specific reasons" do @@ -79,7 +79,7 @@ it "should apply default variation value when specified" do mod = Featurevisor::Modules::FeaturevisorModule.new( name: "test-module", - after: ->(eval, opts) { eval.merge(module_applied: true) } + after_evaluation: ->(eval, _opts) { eval.merge(module_applied: true) } ) modules_manager.add(mod) @@ -105,7 +105,7 @@ it "should apply default variable value when specified" do mod = Featurevisor::Modules::FeaturevisorModule.new( name: "test-module", - after: ->(eval, opts) { eval.merge(module_applied: true) } + after_evaluation: ->(eval, _opts) { eval.merge(module_applied: true) } ) modules_manager.add(mod) @@ -186,7 +186,7 @@ expect(result[:reason]).to eq(Featurevisor::EvaluationReason::STICKY) expect(result[:enabled]).to be true - expect(result[:sticky]).to eq(sticky["test-feature"]) + expect(result[:sticky_feature]).to eq(sticky["test-feature"]) end it "should handle sticky variations" do @@ -274,7 +274,7 @@ it "should handle required features" do feature = { key: "test-feature", - required: ["required-feature"] + conditions: [{ feature: "required-feature" }] } # Mock the get_feature method to return our feature @@ -312,17 +312,22 @@ enabled: false }) + allow(diagnostics).to receive(:debug).and_call_original result = Featurevisor::Evaluate.evaluate(options) - expect(result[:reason]).to eq(Featurevisor::EvaluationReason::REQUIRED) + expect(result[:reason]).to eq(Featurevisor::EvaluationReason::CONDITIONS_UNMET) expect(result[:enabled]).to be false - expect(result[:required]).to eq(["required-feature"]) + expect(result[:conditions]).to eq([{ feature: "required-feature" }]) + expect(JSON.parse(JSON.generate(result))["reason"]).to eq("conditions_unmet") + expect(diagnostics).to have_received(:debug).with( + "conditions did not match", hash_including(reason: "conditions_unmet", enabled: false) + ) end it "should treat required variation value false as a valid match" do feature = { key: "test-feature", - required: [{ key: "required-feature", variation: false }] + conditions: [{ feature: "required-feature", variation: false }] } allow(datafile_reader).to receive(:get_feature).and_return(feature) @@ -359,13 +364,13 @@ datafile: datafile_reader ) - expect(result[:reason]).not_to eq(Featurevisor::EvaluationReason::REQUIRED) + expect(result[:reason]).not_to eq(Featurevisor::EvaluationReason::CONDITIONS_UNMET) end it "should treat required variation value 0 as a valid match" do feature = { key: "test-feature", - required: [{ key: "required-feature", variation: 0 }] + requiredFeatures: [{ feature: "required-feature", variation: 0 }] } allow(datafile_reader).to receive(:get_feature).and_return(feature) @@ -402,7 +407,7 @@ datafile: datafile_reader ) - expect(result[:reason]).not_to eq(Featurevisor::EvaluationReason::REQUIRED) + expect(result[:reason]).not_to eq(Featurevisor::EvaluationReason::CONDITIONS_UNMET) end it "should handle errors gracefully" do diff --git a/spec/evaluation_data_provider_spec.rb b/spec/evaluation_data_provider_spec.rb index a1c1014..05d526a 100644 --- a/spec/evaluation_data_provider_spec.rb +++ b/spec/evaluation_data_provider_spec.rb @@ -79,16 +79,17 @@ expect(reader.get_schema_version).to eq("2") expect(reader.get_segment("netherlands")).to eq(datafile_json[:segments][:netherlands]) expect(reader.get_segment("belgium")).to be_nil - expect(reader.get_feature("test")).to eq(datafile_json[:features][:test]) + expected, = Featurevisor::Normalize.entity(:feature, :test, datafile_json[:features][:test]) + expect(reader.get_feature("test")).to eq(expected) expect(reader.get_feature("test2")).to be_nil end it "should parse stringified conditions" do segment = reader.get_segment("germany") expect(segment[:conditions]).to be_an(Array) - expect(segment[:conditions][0]["value"]).to eq("de") - expect(segment[:conditions][0]["attribute"]).to eq("country") - expect(segment[:conditions][0]["operator"]).to eq("equals") + expect(segment[:conditions][0][:value]).to eq("de") + expect(segment[:conditions][0][:attribute]).to eq("country") + expect(segment[:conditions][0][:operator]).to eq("equals") end end @@ -493,14 +494,14 @@ ).to be true end - it "should match with empty conditions" do + it "defensively rejects empty v4 conditions" do conditions = [] expect( datafile_reader.all_conditions_are_matched(conditions, { browser_type: "chrome" }) - ).to be true + ).to be false end it "should match with multiple conditions" do diff --git a/spec/evaluation_performance_spec.rb b/spec/evaluation_performance_spec.rb new file mode 100644 index 0000000..6a6a7c2 --- /dev/null +++ b/spec/evaluation_performance_spec.rb @@ -0,0 +1,292 @@ +require "featurevisor" +require "weakref" + +RSpec.describe "Warm evaluation and lifecycle contracts" do + it "skips empty module dispatch and still filters a module installed during evaluation" do + f = instance + manager = f.instance_variable_get(:@modules_manager) + expect(manager).not_to receive(:run_before_evaluation_modules) + expect(manager).not_to receive(:run_after_evaluation_modules) + expect(f.is_enabled("flag", country: "nl")).to be(true) + expect(f.get_variable("greeting", country: "nl")).to eq("nl") + f.close + + received = [] + holder = {} + handler = ->(diagnostic) { + next unless diagnostic[:message] == "using sticky enabled" && received.empty? + + holder[:sdk].add_module(after_evaluation: ->(evaluation, options) { + received << options + evaluation + }) + } + f = instance(log_level: "debug", sticky_features: { "flag" => { enabled: true } }, on_diagnostic: handler) + holder[:sdk] = f + expect(f.is_enabled("flag")).to be(true) + expect(received.size).to eq(1) + expect(received.first).to include(type: "flag", feature_key: "flag") + expect(received.first.keys.grep(/__featurevisor_/)).to be_empty + f.close + end + + def datafile(country = "nl") + condition = JSON.generate(attribute: "country", operator: "equals", value: country) + { + schemaVersion: "2", revision: country, + segments: { audience: { conditions: condition } }, + features: { + flag: { bucketBy: "userId", traffic: [ + { key: "audience", segments: '["audience"]', percentage: 100_000 } + ] } + }, + variables: { + greeting: { type: "string", defaultValue: "default", overrides: [ + { key: "local", conditions: condition, value: country } + ] } + } + } + end + + def instance(**options) + Featurevisor.create_featurevisor({ datafile: datafile, log_level: "fatal" }.merge(options)) + end + + def capturing_handler(value) + ->(_) { value } + end + + def closed_instance_with_handler + captured = Object.new + weak = WeakRef.new(captured) + f = instance(on_diagnostic: capturing_handler(captured)) + f.close + [f, weak] + end + + def replace_warmed_provider(f) + f.get_variable("greeting", country: "nl") + weak = WeakRef.new(f.instance_variable_get(:@datafile)) + f.set_datafile(datafile, true) + weak + end + + it "skips metadata formatting when no diagnostic consumer is interested" do + f = instance + expect(f).not_to receive(:camelize_diagnostic_value) + expect(f.is_enabled("flag", country: "nl")).to eq(true) + expect(f.get_variable("greeting", country: "nl")).to eq("nl") + end + + it "preserves complete diagnostics when a module enables them during evaluation" do + received = [] + f = instance(on_diagnostic: ->(diagnostic) { received << diagnostic }) + f.add_module(before_evaluation: ->(options) { f.set_log_level("debug"); options }) + expect(f.is_enabled("flag", country: "nl")).to eq(true) + diagnostic = received.find { |item| item.dig(:details, :evaluation, :enabled) == true } + expect(diagnostic[:details]).to include(featureKey: "flag") + expect(diagnostic[:details][:evaluation]).to include(type: "flag", featureKey: "flag", enabled: true) + f.close + end + + it "observes new debug subscriptions even with the instance level set to fatal" do + received = [] + api = nil + subscribed = false + f = instance(modules: [{ + name: "observer", setup: ->(value) { api = value }, + before_evaluation: ->(options) { + unless subscribed + subscribed = true + api[:on_diagnostic].call(->(d) { received << d }, log_level: "debug") + end + options + } + }]) + expect(f.get_variable("greeting", country: "nl")).to eq("nl") + expect(received.last).to include(level: "debug", code: "variable_override_rule") + expect(received.last[:details]).to include(variableKey: "greeting", variableValue: "nl") + f.close + end + + it "keeps module source exclusion and subscription levels intact" do + received = [] + api = nil + f = instance(modules: [{ name: "source", setup: ->(value) { + api = value + api[:on_diagnostic].call(->(d) { received << d }, log_level: "debug") + } }]) + received.clear + api[:report_diagnostic].call(level: "debug", code: "own", message: "ignored by source") + expect(received).to be_empty + f.is_enabled("flag", country: "nl") + expect(received).not_to be_empty + f.close + end + + it "still prints eligible warnings without an explicit handler" do + f = instance(log_level: "info", on_diagnostic: ->(_) {}) + f.instance_variable_set(:@on_diagnostic, nil) + expect { f.is_enabled("missing") }.to output(/feature not found/).to_stderr + end + + it "preserves default console filtering for symbol and unrecognized levels" do + [:fatal, "unrecognized"].each do |level| + f = instance(log_level: level) + expect { f.send(:report_diagnostic, level: "fatal", message: "fatal message") } + .to output(/fatal message/).to_stderr + expect { f.is_enabled("missing") }.not_to output.to_stderr + end + end + + it "parses encoded v4 selectors once per entity but never caches their matches" do + f = instance(datafile: datafile.merge(schemaVersion: "4")) + source = datafile[:variables][:greeting][:overrides][0][:conditions] + expect(JSON).to receive(:parse).with(source, symbolize_names: true).twice.and_call_original + expect(JSON).to receive(:parse).with('["audience"]', symbolize_names: true).once.and_call_original + 3.times do + expect(f.get_variable("greeting", country: "nl")).to eq("nl") + expect(f.get_variable("greeting", country: "de")).to eq("default") + expect(f.is_enabled("flag", country: "nl")).to eq(true) + expect(f.is_enabled("flag", country: "de")).to eq(false) + end + end + + it "caches parsed null and false without confusing them with a cache miss" do + provider = instance.instance_variable_get(:@datafile) + ["null", "false"].each do |source| + expect(JSON).to receive(:parse).with(source).once.and_call_original + 3.times { provider.parse_conditions_if_stringified(source) } + end + end + + it "reports invalid v4 selectors once per incoming entity and caches before reentrant reads" do + errors = [] + f = instance(on_diagnostic: ->(diagnostic) { + next unless diagnostic[:code] == "conditions_parse_error" + errors << diagnostic + expect(f.get_variable("greeting", country: "nl")).to eq("default") + }, log_level: "error") + malformed = datafile.merge(schemaVersion: "4") + malformed[:variables][:greeting][:overrides][0][:conditions] = "{bad JSON" + 3.times do + f.set_datafile(malformed, true) + 2.times { expect(f.get_variable("greeting", country: "nl")).to eq("default") } + end + expect(errors.size).to eq(3) + expect(errors.first[:details]).to eq(conditions: "{bad JSON", entityType: "variable", entityKey: "greeting") + f.set_datafile(datafile, true) + expect(f.get_variable("greeting", country: "nl")).to eq("nl") + ensure + f&.close + end + + it "keeps invalid encoded segments raising rather than caching a silent miss" do + provider = instance.instance_variable_get(:@datafile) + 2.times do + expect { provider.parse_segments_if_stringified("[") }.to raise_error(JSON::ParserError) + end + end + + it "replaces caches with datafile generations and preserves them on invalid updates" do + f = instance + [false, true].each do |replace| + old = f.instance_variable_get(:@datafile) + expect(f.get_variable("greeting", country: "nl")).to eq("nl") + f.set_datafile("bad JSON", replace) + expect(f.instance_variable_get(:@datafile)).to equal(old) + f.set_datafile(datafile("de"), replace) + expect(f.instance_variable_get(:@datafile)).not_to equal(old) + expect(f.get_variable("greeting", country: "nl")).to eq("default") + expect(f.get_variable("greeting", country: "de")).to eq("de") + f.set_datafile(datafile, true) + end + end + + it "evaluates concurrent contexts independently after warming expressions" do + f = instance + f.is_enabled("flag", country: "nl") + threads = 8.times.map do |index| + Thread.new do + country = index.even? ? "nl" : "de" + 100.times do + raise "wrong flag" unless f.is_enabled("flag", country: country) == index.even? + raise "wrong variable" unless f.get_variable("greeting", country: country) == (index.even? ? "nl" : "default") + end + end + end + threads.each(&:value) + end + + it "preserves string and symbol attributes, null, false, empty and nested paths" do + matcher = Featurevisor::Conditions + context = { flag: false, "flag" => true, value: nil, "text" => "", nested: { "city" => "amsterdam" } } + expect(matcher.get_value_from_context(context, "flag")).to eq(false) + expect(matcher.get_value_from_context(context, "value")).to be_nil + expect(matcher.get_value_from_context(context, "text")).to eq("") + expect(matcher.get_value_from_context(context, "nested.city")).to eq("amsterdam") + expect(matcher.get_value_from_context(context, "missing")).to be_nil + expect(matcher.get_value_from_context(context, "")).to equal(context) + end + + it "releases diagnostics and caches after module shutdown errors are reported" do + received = [] + api = nil + f = instance(on_diagnostic: ->(d) { received << d }, log_level: "error", modules: [{ + name: "shutdown", setup: ->(value) { api = value }, close: -> { raise "shutdown failure" } + }]) + f.get_variable("greeting", country: "nl") + provider = f.instance_variable_get(:@datafile) + provider.get_regex("abc") + expect(provider.normalized[:variable]).not_to be_empty + f.close + f.close + expect(received.count { |d| d[:code] == "module_close_error" }).to eq(1) + expect(f.instance_variable_get(:@on_diagnostic)).to be_nil + expect(provider.normalized.values).to all(be_empty) + %i[@conditions_cache @segments_cache @regex_cache].each do |name| + expect(provider.instance_variable_get(name)).to be_empty + end + api[:on_diagnostic].call(->(_) { raise "closed" }) + expect(f.instance_variable_get(:@module_diagnostic_subscriptions)).to be_empty + end + + it "clears old emitter arrays retained by unsubscribe functions" do + emitter = Featurevisor::Emitter.new + calls = [] + unsubscribe = emitter.on("error", ->(_) { calls << true }) + original = emitter.listeners["error"] + emitter.clear_all + expect(original).to be_empty + unsubscribe.call + unsubscribe.call + emitter.trigger("error") + expect(calls).to be_empty + end + + it "allows captured handlers and obsolete datafile generations to be collected" do + closed, handler = closed_instance_with_handler + f = instance + old_providers = 20.times.map { replace_warmed_provider(f) } + GC.start(full_mark: true, immediate_sweep: true) + expect(handler.weakref_alive?).to be_falsey + expect(old_providers.any?(&:weakref_alive?)).to eq(false) + expect(closed.get_revision).to eq("nl") + expect(f.get_variable("greeting", country: "nl")).to eq("nl") + end + + it "does not retain child bookkeeping after unsubscribe or accept listeners after close" do + f = instance + child = f.spawn + 1_000.times do + unsubscribe = child.on("datafile_set", ->(_) {}) + unsubscribe.call + end + expect(child.instance_variable_get(:@parent_unsubscribers)).to be_empty + child.close + child.close + %w[datafile_set context_set].each { |event| child.on(event, ->(_) { raise "closed" }) } + expect(child.instance_variable_get(:@parent_unsubscribers)).to be_empty + expect(child.instance_variable_get(:@emitter).listeners).to be_empty + end +end diff --git a/spec/evaluation_session_bookkeeping_spec.rb b/spec/evaluation_session_bookkeeping_spec.rb new file mode 100644 index 0000000..507420d --- /dev/null +++ b/spec/evaluation_session_bookkeeping_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "featurevisor" + +RSpec.describe "Evaluation session bookkeeping" do + it "does not allocate prerequisite fingerprint options on ordinary global roots" do + sdk = Featurevisor.create_featurevisor(log_level: "fatal", datafile: { + schemaVersion: "4", revision: "allocations", features: {}, segments: { + eligible: { conditions: '{"attribute":"country","operator":"equals","value":"de"}' } + }, variables: { + value: { type: "object", defaultValue: { currency: "USD" }, overrides: [ + { key: "eligible", conditions: "eligible", value: { currency: "EUR" } } + ] } + } + }) + context = { country: "de", userId: "one" } + 20.times { expect(sdk.get_variable("value", context)).to eq(currency: "EUR") } + expect(sdk).not_to receive(:global_variable_session_options) + before = GC.stat(:total_allocated_objects) + 100.times { sdk.get_variable("value", context) } + allocated = GC.stat(:total_allocated_objects) - before + expect(allocated).to be <= 2000 + ensure + sdk&.close + end + + it "keeps ordinary roots free of dependency containers and identity copies" do + session = Featurevisor::EvaluationSession.new + options = { type: "flag", feature_key: "root", context: {} } + 2.times do + session.run(options) do + original = session.dependency_of + session.update_current(options.merge(context: { country: "nl" })) + expect(session.dependency_of).to equal(original) + %i[@active @memo @sticky_inputs @segments].each do |name| + expect(session.instance_variable_get(name)).to be_nil + end + end + expect(session.dependency_of).to be_nil + expect(session.instance_variable_get(:@root)).to be_nil + end + end + + it "activates bookkeeping at a handoff and retains both original and redirected roots" do + session = Featurevisor::EvaluationSession.new + original = { type: "flag", feature_key: "root", context: {} } + redirected = original.merge(feature_key: "redirected") + expect do + session.run(original) do + session.update_current(redirected) + expect(session.dependency_of).to eq(type: "flag", feature_key: "redirected") + session.segment("gate") do + session.run(original, prerequisite: true) { raise "cycle was missed" } + end + end + end.to raise_error(Featurevisor::RequirementCycleError) + expect(session.dependency_of).to be_nil + expect(session.instance_variable_get(:@segments)).to be_empty + expect(session.instance_variable_get(:@pending_error)).to be_nil + session.run(original) do + session.update_current(redirected) + expect { session.run(redirected, prerequisite: true) {} }.to raise_error(Featurevisor::RequirementCycleError) + end + expect(session.run(original) { :recovered }).to eq(:recovered) + end + + def datafile + { + schemaVersion: "4", revision: "session-bookkeeping", segments: { + audience: { conditions: { and: [ + { attribute: "country", operator: "equals", value: "nl" }, + { not: [{ attribute: "blocked", operator: "equals", value: true }] } + ] } }, + dependent: { conditions: { feature: "leaf" } } + }, features: { + root: { conditions: "audience", bucketBy: "userId", + traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] }, + leaf: { conditions: "*", bucketBy: "userId", + traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] }, + mixed: { conditions: "dependent", bucketBy: "userId", + traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] } + }, variables: {} + } + end + + it "classifies each segment syntax once while evaluating fresh contexts" do + sdk = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + provider = sdk.instance_variable_get(:@datafile) + syntax = provider.get_segment("audience")[:conditions] + allow(Featurevisor::UnifiedConditions).to receive(:attribute_only?).and_call_original + expect(Featurevisor::UnifiedConditions).to receive(:attribute_only?).with(syntax).once.and_call_original + 3.times do + expect(sdk.is_enabled("root", country: "nl")).to be(true) + expect(sdk.is_enabled("root", country: "us")).to be(false) + expect(sdk.is_enabled("root", country: "nl", blocked: true)).to be(false) + end + cache = provider.instance_variable_get(:@attribute_conditions_cache) + expect(cache[syntax.object_id]).to eq([syntax, true]) + expect(cache[syntax.object_id][0]).to equal(syntax) + expect(provider.get_feature("root")).to equal(provider.get_feature("root")) + expect(provider.get_segment("audience")).to equal(provider.get_segment("audience")) + sdk.close + expect(cache).to be_empty + ensure + sdk&.close + end + + it "keeps mixed segment dependencies on the session path and refreshes syntax after updates" do + reads = [] + sdk = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal", modules: [{ + before_evaluation: ->(options) { reads << options; options } + }]) + 2.times do + expect(sdk.is_enabled("mixed")).to be(true) + expect(reads.last[:dependency_of]).to eq(type: "flag", feature_key: "mixed") + expect(reads.last[:dependency_purpose]).to eq("condition") + end + sdk.set_sticky_features({ "leaf" => { enabled: false } }) + expect(sdk.is_enabled("mixed")).to be(false) + expect(sdk.is_enabled("root", country: "nl")).to be(true) + updated = datafile + updated[:revision] = "next" + updated[:segments][:audience][:conditions] = { attribute: "country", operator: "equals", value: "us" } + expect(sdk.set_datafile(updated, true)).to be(true) + expect(sdk.is_enabled("root", country: "nl")).to be(false) + expect(sdk.is_enabled("root", country: "us")).to be(true) + ensure + sdk&.close + end +end diff --git a/spec/evaluation_session_spec.rb b/spec/evaluation_session_spec.rb new file mode 100644 index 0000000..7e570aa --- /dev/null +++ b/spec/evaluation_session_spec.rb @@ -0,0 +1,460 @@ +# frozen_string_literal: true + +require "featurevisor" + +RSpec.describe "request evaluation sessions" do + it "does not enumerate unrelated sticky entries during repeated feature and variable reads" do + [0, 100, 1000, 5000].each do |size| + unrelated = size.times.to_h { |index| ["unrelated#{index}", Object.new] } + reads = { flag: 0, variable: 0 } + sdk = Featurevisor.create_featurevisor( + datafile: datafile(features: { leafFlag: feature }, variables: { + source: variable, + root: { type: "integer", defaultValue: 1, + requiredFeatures: ["leafFlag", "leafFlag"], + conditions: [requires("source"), requires("source")] } + }), log_level: "fatal", sticky_features: unrelated, sticky_variables: unrelated, + modules: [{ before_evaluation: ->(options) { + reads[:flag] += 1 if options[:feature_key] == "leafFlag" + reads[:variable] += 1 if options[:variable_key] == "source" + options + } }] + ) + child = sdk.spawn({}, sticky_features: unrelated, sticky_variables: unrelated) + begin + [sdk, child].each do |owner| + reads = { flag: 0, variable: 0 } + expect(owner.get_variable("root", { userId: "u" })).to eq(1) + expect(reads).to eq({ flag: 1, variable: 1 }), "size #{size}" + end + child.set_sticky_variables({ "source" => 2 }) + expect(child.evaluate_variable("root", { userId: "u" })[:reason]).to eq("conditions_unmet") + expect(sdk.get_variable("root", { userId: "u" })).to eq(1) + ensure + child.close + sdk.close + end + end + end + + it "carries module sticky replacements into nested feature and variable reads" do + reads = { flag: 0, variable: 0 } + sdk = Featurevisor.create_featurevisor( + datafile: datafile(features: { + leafFlag: feature(traffic: []), + rootFlag: feature(requiredFeatures: ["leafFlag", "leafFlag"], + conditions: [{ variable: "source", operator: "equals", value: 2 }] * 2) + }, variables: { source: variable }), log_level: "fatal", + modules: [{ before_evaluation: ->(options) { + if options[:feature_key] == "rootFlag" + options = options.merge(sticky: { "leafFlag" => { enabled: true } }, sticky_variables: { "source" => 2 }) + end + reads[:flag] += 1 if options[:feature_key] == "leafFlag" + reads[:variable] += 1 if options[:variable_key] == "source" + options + } }] + ) + child = sdk.spawn + [sdk, child].each do |owner| + reads = { flag: 0, variable: 0 } + expect(owner.is_enabled("rootFlag", { userId: "u" })).to be(true) + expect(reads).to eq({ flag: 1, variable: 1 }) + end + ensure + child&.close + sdk&.close + end + + def datafile(features: {}, variables: {}) + { schemaVersion: "2", revision: "session", segments: {}, features: features, variables: variables } + end + + def feature(**values) + { bucketBy: "userId", traffic: [{ key: "all", segments: "*", percentage: 100_000 }], **values } + end + + def variable(required = nil) + { type: "integer", defaultValue: 1, disabledValue: 0 }.tap { |value| value[:conditions] = required unless required.nil? } + end + + def requires(key) + { variable: key, operator: "equals", value: 1 } + end + + it "memoises completed variable and feature diamonds per public read" do + variables = { leaf: variable } + features = { leaf: feature } + previous = "leaf" + 12.times do |index| + variables["level#{index}".to_sym] = variable([requires(previous), requires(previous)]) + features["level#{index}".to_sym] = feature(requiredFeatures: [previous, previous]) + previous = "level#{index}" + end + seen = [] + sdk = Featurevisor.create_featurevisor( + datafile: datafile(features: features, variables: variables), log_level: "fatal", + modules: [{ before_evaluation: ->(options) { + seen << [options[:type], options[:feature_key], options[:variable_key], options[:dependency_of]] + options.dup + } }] + ) + + expect(sdk.get_variable(previous)).to eq(1) + expect(seen.length).to eq(13) + expect(seen[0][3]).to be_nil + expect(seen[1][3]).to eq(type: "variable", variable_key: previous) + seen.clear + expect(sdk.is_enabled(previous)).to be(true) + expect(seen.length).to eq(13) + expect(seen[1][3]).to eq(type: "flag", feature_key: previous) + seen.clear + expect(sdk.get_variable(previous)).to eq(1) + expect(seen.length).to eq(13) + ensure + sdk&.close + end + + it "reports root and nested cycles as errors while preserving ordinary getter fallback" do + sdk = Featurevisor.create_featurevisor( + datafile: datafile(variables: { + root: variable([requires("root")]), + outer: variable([requires("root")]), + ordinary: variable([requires("missing")]) + }), log_level: "fatal" + ) + expect(sdk.evaluate_variable("root")[:error].message).to include("Circular") + expect(sdk.evaluate_variable("outer")[:error].message).to include("Circular") + expect(sdk.evaluate_variable("ordinary")[:reason]).to eq("conditions_unmet") + ensure + sdk&.close + end + + it "keeps immediate requester dependent hook values separate in the memo" do + observed = [] + sdk = Featurevisor.create_featurevisor( + datafile: datafile(variables: { + leaf: variable, + left: variable([requires("leaf"), requires("leaf")]), + right: variable([{ variable: "leaf", operator: "equals", value: 2 }]), + root: variable([requires("left"), requires("right")]) + }), log_level: "fatal", + modules: [{ after_evaluation: ->(evaluation, options) { + if options[:variable_key] == "leaf" + parent = options.dig(:dependency_of, :variable_key) + observed << parent + evaluation[:variable_value] = parent == "right" ? 2 : 1 + end + evaluation + } }] + ) + + expect(sdk.get_variable("root")).to eq(1) + expect(observed).to eq(%w[left right]) + ensure + sdk&.close + end + + it "invalidates completed reads after sticky state changes inside a hook" do + reads = 0 + sdk = nil + sdk = Featurevisor.create_featurevisor( + datafile: datafile(variables: { + leaf: variable, + mutator: variable, + root: variable([requires("leaf"), requires("mutator"), + { variable: "leaf", operator: "equals", value: 2 }]) + }), log_level: "fatal", + modules: [{ after_evaluation: ->(evaluation, _options) { + reads += 1 if evaluation[:variable_key] == "leaf" + sdk.set_sticky_variables({ "leaf" => 2 }) if evaluation[:variable_key] == "mutator" + evaluation + } }] + ) + + expect(sdk.get_variable("root")).to eq(1) + expect(reads).to eq(2) + ensure + sdk&.close + end + + it "invalidates completed reads after a datafile replacement inside a hook" do + reads = 0 + sdk = nil + root = variable([requires("leaf"), requires("mutator"), + { variable: "leaf", operator: "equals", value: 2 }]) + sdk = Featurevisor.create_featurevisor( + datafile: datafile(variables: { leaf: variable, mutator: variable, root: root }), + log_level: "fatal", + modules: [{ after_evaluation: ->(evaluation, _options) { + reads += 1 if evaluation[:variable_key] == "leaf" + if evaluation[:variable_key] == "mutator" + sdk.set_datafile(datafile(variables: { + leaf: variable.merge(defaultValue: 2), mutator: variable, root: root + }), true) + end + evaluation + } }] + ) + + expect(sdk.get_variable("root")).to eq(1) + expect(reads).to eq(2) + ensure + sdk&.close + end + + it "reuses ordinary nil operands from failing prerequisite modules" do + reads = 0 + sdk = Featurevisor.create_featurevisor( + datafile: datafile(variables: { + broken: variable, + root: variable([{ variable: "broken", operator: "isNull" }, + { variable: "broken", operator: "isNull" }]) + }), log_level: "fatal", + modules: [{ before_evaluation: ->(options) { + if options[:variable_key] == "broken" + reads += 1 + raise "ordinary module failure" + end + options + } }] + ) + expect(sdk.get_variable("root")).to eq(1) + expect(reads).to eq(1) + ensure + sdk&.close + end + + it "tracks transformed identities when detecting cycles" do + sdk = Featurevisor.create_featurevisor( + datafile: datafile(variables: { actual: variable([requires("alias")]), alias: variable }), + log_level: "fatal", + modules: [{ before_evaluation: ->(options) { + case options[:variable_key] + when "root" then options.merge(variable_key: "actual") + when "alias" then options.merge(variable_key: "actual") + else options + end + } }] + ) + expect(sdk.evaluate_variable("root")[:error].message).to include("Circular") + ensure + sdk&.close + end + + it "allows a redirected prerequisite to finish before any recursive entry" do + features = { a: feature(requiredFeatures: ["b"]), b: feature } + build = ->(sticky) { + Featurevisor.create_featurevisor( + datafile: datafile(features: features), log_level: "fatal", + modules: [{ before_evaluation: ->(options) { + if options[:feature_key] == "b" + redirected = options.merge(feature_key: "a") + sticky ? redirected.merge(sticky: { "a" => { enabled: true } }) : redirected + else + options + end + } }] + ) + } + safe = build.call(true) + recursive = build.call(false) + + expect(safe.evaluate_flag("a")[:enabled]).to be(true) + expect(recursive.evaluate_flag("a")[:error].message).to include("Circular") + ensure + safe&.close + recursive&.close + end + + it "clears root memo and pending errors even when a session object is retained" do + session = Featurevisor::EvaluationSession.new + root = { type: "variable", variable_key: "root", context: {} } + leaf = { type: "variable", variable_key: "leaf", context: {} } + reads = 0 + + 2.times do + session.run(root) do + 2.times do + session.run(leaf, prerequisite: true) do + reads += 1 + { type: "variable", variable_key: "leaf", variable_value: 1 } + end + end + end + expect(session.instance_variable_get(:@memo)).to be_empty + expect(session.instance_variable_get(:@pending_error)).to be_nil + end + expect(reads).to eq(2) + + expect { session.run(root) { raise "failed root" } }.to raise_error("failed root") + expect(session.instance_variable_get(:@memo)).to be_empty + expect(session.instance_variable_get(:@pending_error)).to be_nil + end + + it "keeps private session plumbing out of retained module options" do + retained = [] + sdk = Featurevisor.create_featurevisor( + datafile: datafile(features: { a: feature(requiredFeatures: ["b"]), b: feature }), + log_level: "fatal", + modules: [{ before_evaluation: ->(options) { retained << options; options.dup }, + after_evaluation: ->(evaluation, options) { retained << options; evaluation } }] + ) + expect(sdk.is_enabled("a")).to be(true) + expect(retained.length).to eq(4) + retained.each do |options| + expect(options.keys.map(&:to_s).grep(/^__featurevisor_/)).to be_empty + expect(options).not_to have_key(:required_variables_are_matched) + end + expect(retained.any? { |options| options[:dependency_of] }).to be(true) + ensure + sdk&.close + end + + it "refreshes inherited feature sticky state after a before hook setter" do + sdk = Featurevisor.create_featurevisor(datafile: datafile(features: { a: feature }), log_level: "fatal") + sdk.add_module(before_evaluation: ->(options) { + sdk.set_sticky_features({ "a" => { enabled: false } }, true) + options + }) + expect(sdk.evaluate_flag("a")[:enabled]).to be(false) + sdk.close + + sdk = Featurevisor.create_featurevisor(datafile: datafile(features: { a: feature }), log_level: "fatal") + sdk.add_module(before_evaluation: ->(options) { + sdk.set_sticky_features({ "a" => { enabled: false } }, true) + options.merge(sticky: { "a" => { enabled: true } }) + }) + expect(sdk.evaluate_flag("a")[:enabled]).to be(true) + ensure + sdk&.close + end + + it "starts a fresh session for public reads made inside callbacks" do + sdk = nil + entered = false + reads = 0 + markers = [] + sdk = Featurevisor.create_featurevisor( + datafile: datafile(variables: { + leaf: variable, root: variable([requires("leaf"), requires("leaf")]) + }), log_level: "fatal", + modules: [{ before_evaluation: ->(options) { + markers << options[:dependency_of] if options[:variable_key] == "root" + if options[:variable_key] == "leaf" + reads += 1 + unless entered + entered = true + expect(sdk.get_variable("root")).to eq(1) + end + end + options + } }] + ) + expect(sdk.get_variable("root")).to eq(1) + expect(reads).to eq(2) + expect(markers).to eq([nil, nil]) + ensure + sdk&.close + end + + it "distinguishes object and array contexts and refuses cyclic or host results" do + session = Featurevisor::EvaluationSession.new + root = { type: "variable", variable_key: "root", context: {} } + reads = 0 + session.run(root) do + [{ "nested" => {} }, { "nested" => [] }, + { "nested" => { "array" => [] } }, { "nested" => ["object", []] }, + { "nested" => { "x" => 1 } }, { "nested" => { x: 1 } }].each do |context| + options = { type: "variable", variable_key: "leaf", context: context, + __featurevisor_prerequisite: true } + 2.times do + session.run(options, prerequisite: true) do + reads += 1 + { type: "variable", variable_key: "leaf", reason: "variable_default", variable_value: 1 } + end + end + end + cyclic = [] + cyclic << cyclic + 2.times do + session.run({ type: "variable", variable_key: "leaf", context: cyclic }, prerequisite: true) do + reads += 1 + { type: "variable", reason: "variable_default", variable_value: 1 } + end + end + 2.times do + session.run({ type: "variable", variable_key: "leaf", context: { host: "result" } }, prerequisite: true) do + reads += 1 + { type: "variable", reason: "variable_default", variable_value: Object.new } + end + end + end + expect(reads).to eq(10) + end + + it "propagates nested requirement path and comparison errors" do + sdk = Featurevisor.create_featurevisor( + datafile: datafile(variables: { + source: { type: "string", defaultValue: "abc" }, + broken: variable([{ variable: "source", path: "not compiled", operator: "isNull" }]), + outer: variable([{ variable: "broken", operator: "isNull" }]) + }), log_level: "fatal" + ) + expect(sdk.evaluate_variable("outer")[:reason]).to eq("error") + expect(sdk.evaluate_variable("outer")[:error].message).to include("path") + ensure + sdk&.close + end + + it "does not remember a failing read whose hook changed state" do + sdk = nil + reads = 0 + sdk = Featurevisor.create_featurevisor( + datafile: datafile(variables: { + leaf: variable, + root: variable([{ variable: "leaf", operator: "isNull" }, + { variable: "leaf", operator: "isNull" }]) + }), log_level: "fatal", + modules: [{ before_evaluation: ->(options) { + if options[:variable_key] == "leaf" + reads += 1 + sdk.set_sticky_variables({ "unrelated" => reads }) + raise "ordinary failure" + end + options + } }] + ) + expect(sdk.get_variable("root")).to eq(1) + expect(reads).to eq(2) + ensure + sdk&.close + end + + it "does not merge nested string and symbol context keys observed by modules" do + reads = 0 + sdk = Featurevisor.create_featurevisor( + datafile: datafile(variables: { + leaf: variable, + mutator: variable, + root: variable([requires("leaf"), requires("mutator"), + { variable: "leaf", operator: "equals", value: 2 }]) + }), log_level: "fatal", + modules: [{ after_evaluation: ->(evaluation, options) { + nested = options[:context]["nested"] + if evaluation[:variable_key] == "leaf" + reads += 1 + evaluation[:variable_value] = nested.key?(:x) ? 2 : 1 + elsif evaluation[:variable_key] == "mutator" + nested.clear + nested[:x] = 1 + end + evaluation + } }] + ) + + expect(sdk.get_variable("root", { "nested" => { "x" => 1 } })).to eq(1) + expect(reads).to eq(2) + ensure + sdk&.close + end +end diff --git a/spec/events_spec.rb b/spec/events_spec.rb index c3e613f..72ed66f 100644 --- a/spec/events_spec.rb +++ b/spec/events_spec.rb @@ -49,18 +49,46 @@ end describe ".get_params_for_datafile_set_event" do - def build_reader(revision:, features:) + def build_reader(revision:, features:, variables: {}) Featurevisor.const_get(:InstanceEvaluationDataProvider).new( datafile: { schemaVersion: "1.0.0", revision: revision, features: features, - segments: {} + segments: {}, + variables: variables }, diagnostics: diagnostics ) end + it "reports all initially added entities without constructing dependency indexes" do + previous = build_reader(revision: "empty", features: {}) + current = build_reader(revision: "loaded", features: { + subject: { hash: "subject", conditions: { variable: "policy", operator: "notNull" } }, + gate: { hash: "gate", conditions: { feature: "subject" } } + }, variables: { + policy: { hash: "policy", type: "object", compose: [{ key: "enabled", feature: "gate" }] } + }) + expect(Featurevisor.const_get(:Dependencies)).not_to receive(:create_index) + expect(described_class.get_params_for_datafile_set_event(previous, current, true)).to eq( + revision: "loaded", previousRevision: "empty", revisionChanged: true, + features: %i[subject gate], variables: [:policy], replaced: true + ) + end + + it "still indexes existing dependents when an update contains only new definitions" do + previous = build_reader(revision: "old", features: { + subject: { hash: "unchanged", conditions: { variable: "policy", operator: "notNull" } } + }) + current = build_reader(revision: "new", features: previous.features, variables: { + policy: { hash: "new", type: "string", defaultValue: "available" } + }) + result = described_class.get_params_for_datafile_set_event(previous, current) + expect(result[:features]).to eq([:subject]) + expect(result[:variables]).to eq([:policy]) + end + it "should get params for datafile set event: empty to new" do previous_reader = build_reader(revision: "1", features: {}) new_reader = build_reader( @@ -138,5 +166,21 @@ def build_reader(revision:, features:) replaced: false }) end + + it "marks identical features and global variables with empty hashes as changed" do + features = { + feature1: { bucketBy: "userId", hash: "", traffic: [] } + } + variables = { + variable1: { type: "string", defaultValue: "value", hash: "" } + } + previous_reader = build_reader(revision: "1", features: features, variables: variables) + new_reader = build_reader(revision: "2", features: features, variables: variables) + + result = described_class.get_params_for_datafile_set_event(previous_reader, new_reader) + + expect(result[:features]).to eq([:feature1]) + expect(result[:variables]).to eq([:variable1]) + end end end diff --git a/spec/feature_allocation_spec.rb b/spec/feature_allocation_spec.rb new file mode 100644 index 0000000..71bf604 --- /dev/null +++ b/spec/feature_allocation_spec.rb @@ -0,0 +1,111 @@ +require "featurevisor" +require "json" + +RSpec.describe "Feature allocation" do + def flag(key, percentage = 100_000) + { + key: key, bucketBy: "userId", + traffic: [{ key: "1", segments: "*", percentage: percentage, allocation: [] }] + } + end + + def datafile(features) + { schemaVersion: "2", revision: "1", segments: {}, features: features } + end + + it "ignores legacy ranges as unknown fields without warnings" do + [nil, [], [[50_000, 100_000]], [[0, 100_000]], "ignored"].each do |ranges| + [0, 50_000, 100_000].each do |percentage| + [nil, false, true].each do |enabled| + feature = flag("test", percentage) + feature[:traffic][0][:enabled] = enabled unless enabled.nil? + baseline = Featurevisor.create_featurevisor(datafile: datafile(test: feature), log_level: "warn") + diagnostics = [] + sdk = Featurevisor.create_featurevisor( + datafile: JSON.generate(datafile(test: feature.merge(ranges: ranges))), + log_level: "warn", + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic } + ) + 32.times do |user| + context = { userId: "user-#{user}" } + expect(sdk.evaluate_flag("test", context)).to eq(baseline.evaluate_flag("test", context)) + end + expect(diagnostics).to be_empty + sdk.close + baseline.close + end + end + end + end + + it "uses ordinary percentage and variation boundaries when JSON contains obsolete metadata" do + feature = flag("test", 50_000) + feature[:ranges] = [[50_001, 100_000]] + feature[:variations] = [{ value: "control" }, { value: "treatment" }] + feature[:traffic][0][:allocation] = [ + { variation: "control", range: [0, 25_000] }, + { variation: "treatment", range: [25_000, 50_000] } + ] + bucket = 0 + diagnostics = [] + sdk = Featurevisor.create_featurevisor( + datafile: JSON.generate(datafile(test: feature)), + log_level: "warn", + on_diagnostic: ->(event) { diagnostics << event }, + modules: [{ name: "boundary", bucket_value: ->(_options) { bucket } }] + ) + [[0, "control"], [25_000, "control"], [25_001, "treatment"], + [50_000, "treatment"], [50_001, nil], [100_000, nil]].each do |value, expected| + bucket = value + expect(sdk.is_enabled("test", { userId: "user" })).to eq(value <= 50_000) + expect(sdk.get_variation("test", { userId: "user" })).to eq(expected) + end + expect(diagnostics).to be_empty + ensure + sdk&.close + end + + it "uses normal hashing for required variation cohorts and priority exclusion" do + features = %w[cohort experimentA experimentB priority fallback].to_h { |key| [key.to_sym, flag(key)] } + features[:cohort][:variations] = %w[a b holdout].map { |value| { value: value } } + features[:cohort][:traffic][0][:allocation] = [ + { variation: "a", range: [0, 40_000] }, + { variation: "b", range: [40_000, 80_000] }, + { variation: "holdout", range: [80_000, 100_000] } + ] + features[:experimentA][:requiredFeatures] = [{ feature: "cohort", variation: "a" }] + features[:experimentB][:requiredFeatures] = [{ feature: "cohort", variation: "b" }] + features[:priority][:traffic][0][:percentage] = 50_000 + features[:fallback][:requiredFeatures] = [{ feature: "priority", enabled: false }] + sdk = Featurevisor.create_featurevisor(datafile: datafile(features), log_level: "warn") + counts = { "a" => 0, "b" => 0, "holdout" => 0, "priority" => 0, "fallback" => 0 } + cohort_overlap = priority_overlap = 0 + 20_000.times do |user| + context = { userId: "user-#{user}" } + variation = sdk.get_variation("cohort", context) + expect(%w[a b holdout]).to include(variation) + counts[variation] += 1 + a = sdk.is_enabled("experimentA", context) + b = sdk.is_enabled("experimentB", context) + expect(a).to eq(variation == "a") + expect(b).to eq(variation == "b") + cohort_overlap += 1 if a && b + expect(!a && !b).to eq(variation == "holdout") + priority = sdk.is_enabled("priority", context) + fallback = sdk.is_enabled("fallback", context) + expect(priority).not_to eq(fallback) + priority_overlap += 1 if priority && fallback + counts[priority ? "priority" : "fallback"] += 1 + end + expect(cohort_overlap).to eq(0) + expect(priority_overlap).to eq(0) + expect(counts.values_at("a", "b", "holdout").sum).to eq(20_000) + expect(counts.values_at("priority", "fallback").sum).to eq(20_000) + # Allow two percentage points around each intended population share. + { "a" => 8000, "b" => 8000, "holdout" => 4000, "priority" => 10_000, "fallback" => 10_000 }.each do |key, expected| + expect(counts[key]).to be_within(400).of(expected) + end + ensure + sdk&.close + end +end diff --git a/spec/includes_any_spec.rb b/spec/includes_any_spec.rb new file mode 100644 index 0000000..650e977 --- /dev/null +++ b/spec/includes_any_spec.rb @@ -0,0 +1,122 @@ +require "featurevisor" +require "json" +require_relative "../bin/test_checks" + +RSpec.describe "Array overlap operators" do + cases = JSON.parse(<<~'JSON') + [ + ["strings",["read","write"],["admin","write"],true], + ["no match",["read"],["write"],false], + ["mixed primitives",["one",2,true,null],["missing",2,false],true], + ["null",[null],[null],true], + ["true",[true],[true],true], + ["false",[false],[false],true], + ["numeric equality",[1],[1],true], + ["number not boolean",[1],[true],false], + ["boolean not number",[true],[1],false], + ["zero not false",[0],[false],false], + ["false not zero",[false],[0],false], + ["number not string",[1],["1"],false], + ["string not number",["1"],[1],false], + ["null not false",[null],[false],false], + ["duplicates",["write","write"],["write","write"],true], + ["candidate order",["read","write"],["write","read"],true], + ["empty candidate",["read"],[],false], + ["empty actual",[],["read"],false], + ["both empty",[],[],false], + ["structured actual ignored",[{},[],{"x":1},"read"],["read"],true], + ["only structured actual",[{},[],{"x":1}],[null,1],false], + ["null actual",null,[],null], + ["string actual","read",["read"],null], + ["number actual",1,[1],null], + ["boolean actual",true,[true],null], + ["object actual",{},[],null], + ["numeric object actual",{"0":"read"},["read"],null], + ["null candidate",["read"],null,null], + ["scalar candidate",["read"],"read",null], + ["object candidate",["read"],{},null], + ["nested candidate after match",["read"],["read",[]],null], + ["object candidate after match",["read"],["read",{}],null], + ["invalid before match",["read"],[{},"read"],null], + ["invalid candidate empty actual",[],[{}],null] + ] + JSON + + cases.each do |name, actual, candidate, matched| + %w[includesAny notIncludesAny].each do |operator| + it "#{name} #{operator}" do + expected = !matched.nil? && (operator == "includesAny" ? matched : !matched) + requirement = { variable: "source", path: ["items"], operator: operator, value: candidate } + traffic = [{ key: "all", segments: "*", percentage: 100000, allocation: [] }] + source = { type: "object", defaultValue: { 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: { items: candidate } } } + } + [false, true].each do |feature_source| + selected = requirement.merge(feature_source ? { feature: "sourceFeature" } : {}) + %w[direct not or].each do |group| + requirements = group == "direct" ? [selected] : [{ group.to_sym => [selected] }] + key = "gate#{feature_source}#{group}".to_sym + datafile[:variables][key] = { type: "boolean", defaultValue: true, disabledValue: false, conditions: requirements } + datafile[:features][key] = { bucketBy: "userId", traffic: traffic, conditions: requirements } + end + end + sdk = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + begin + expect(sdk.is_enabled("condition", profile: { items: actual })).to eq(expected) + expect(sdk.is_enabled("condition", profile: {})).to eq(false) + condition = datafile[:features][:condition][:force][0][:conditions] + [nil, :not, :or].each do |group| + expression = group.nil? ? condition : { group => [condition] } + datafile[:features][:condition][:force][0][:conditions] = JSON.generate(expression) + sdk.set_datafile(datafile, true) + expect(sdk.is_enabled("condition", profile: { items: actual })).to eq(group == :not ? !expected : expected) + end + [false, true].each do |feature_source| + selected = requirement.merge(feature_source ? { feature: "sourceFeature" } : {}) + %w[direct not or].each do |group| + key = "gate#{feature_source}#{group}" + expect(sdk.get_variable(key)).to eq(group == "not" ? !expected : expected) + expect(sdk.is_enabled(key)).to eq(group == "not" ? !expected : expected) + end + [false, true].each do |compare_to| + check = selected.dup + if compare_to + check.delete(:value) + check[:compareTo] = { variable: "other", path: ["items"] } + end + %w[direct not or].each do |group| + node = group == "direct" ? check : { group.to_sym => [check] } + report = FeaturevisorCLI::TestChecks.evaluate_result(sdk, datafile, [node], owner_variable: nil, diagnostics: []) + expect(report[:error]).to eq(matched.nil?) + expect(report[:passed]).to eq(!matched.nil? && (group == "not" ? !expected : expected)) + if matched.nil? + expect(report[:errors][0][:type]).to eq("check") + expect(["valid check", "successful comparison"]).to include(report[:errors][0][:expected]) + expect(report[:errors][0][:details][:evaluated]).not_to be_empty if compare_to + end + end + end + end + check = { variable: "source", path: ["absent"], operator: operator, value: [] } + report = FeaturevisorCLI::TestChecks.evaluate_result(sdk, datafile, [check], owner_variable: nil, diagnostics: []) + expect(report[:error]).to eq(false) + expect(report[:passed]).to eq(false) + malformed = { variable: "source", path: ["items"], operator: operator, compareTo: { variable: "other", path: ["items"] } } + report = FeaturevisorCLI::TestChecks.evaluate_result(sdk, datafile, [{ or: [{ variable: "source", operator: "exists" }, malformed] }], owner_variable: nil, diagnostics: []) + expect(report[:passed]).to eq(true) + expect(report[:error]).to eq(false) + ensure + sdk.close + end + end + end + end +end diff --git a/spec/incremental_dependencies_spec.rb b/spec/incremental_dependencies_spec.rb new file mode 100644 index 0000000..de1ea67 --- /dev/null +++ b/spec/incremental_dependencies_spec.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require "featurevisor" +require "timeout" +require "weakref" + +RSpec.describe "incremental dependency indexing" do + def datafile(revision = "initial", **values) + { schemaVersion: "4", revision: revision, segments: {}, features: {}, variables: {} }.merge(values) + end + + def feature(**values) + { hash: "stable", bucketBy: "userId", traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] }.merge(values) + end + + [false, true].each do |replace| + it "reuses identical edges on full #{replace ? 'replacement' : 'merge'} without suppressing hashless events" do + source = datafile(features: { root: feature(conditions: "gate"), hashless: feature(hash: nil) }, + segments: { gate: { conditions: "*" } }, variables: { + source: { type: "integer", defaultValue: 1, hash: "1" }, + alias: { type: "integer", compose: "source", hash: "same" } + }) + sdk = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + events = [] + sdk.on("datafile_set", ->(event) { events << event }) + dependencies = Featurevisor.const_get(:Dependencies) + expect(dependencies).to receive(:extract_entity_dependencies).exactly(5).times.and_call_original + expect(sdk.set_datafile(source, replace)).to be(true) + expect(events.last[:features]).to eq([:hashless]) + expect(events.last[:variables]).to eq([]) + expect(events.last[:replaced]).to eq(replace) + expect(sdk.set_datafile(source.merge(revision: "same"), replace)).to be(true) + expect(events.last[:features]).to eq([:hashless]) + expect(events.last[:variables]).to eq([]) + ensure + sdk&.close + end + + it "refreshes same hash edges and removes obsolete owners on full #{replace ? 'replacement' : 'merge'}" do + source = datafile(features: { root: feature(conditions: "gate") }, + segments: { gate: { conditions: { variable: "source", operator: "notNull" } } }, + variables: { + source: { type: "integer", defaultValue: 1, hash: "1" }, + alias: { type: "integer", compose: "source", hash: "same" } + }) + sdk = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + events = [] + sdk.on("datafile_set", ->(event) { events << event }) + expect(sdk.set_datafile(source, replace)).to be(true) + changed = datafile(features: source[:features], segments: { gate: { conditions: "*" } }, variables: { + source: source[:variables][:source], alias: { type: "integer", compose: "other", hash: "same" } + }) + expect(sdk.set_datafile(changed, replace)).to be(true) + expect(events.last[:features]).to eq([:root]) + expect(events.last[:variables]).to eq([]) + expect(sdk.set_datafile(datafile("next", variables: { + source: { type: "integer", defaultValue: 2, hash: "2" } + }))).to be(true) + expect(events.last[:features]).to eq([]) + expect(events.last[:variables]).to eq([:source]) + expect(sdk.set_datafile(datafile("other", variables: { + other: { type: "integer", defaultValue: 3, hash: "3" } + }))).to be(true) + expect(events.last[:variables]).to eq(%i[other alias]) + expect(sdk.set_datafile(datafile("removed"), true)).to be(true) + expect(events.last[:features]).to eq([:root]) + expect(events.last[:variables]).to eq(%i[source alias other]) + ensure + sdk&.close + end + end + + it "does not traverse unrelated retained entities on cached updates" do + sdk = nil + [16, 512].each do |size| + sdk = Featurevisor.create_featurevisor(log_level: "fatal", datafile: datafile( + segments: size.times.to_h { |i| ["s#{i}".to_sym, { conditions: '{"attribute":"country","operator":"notNull"}' }] }, + features: size.times.to_h { |i| ["f#{i}".to_sym, feature(conditions: "s#{i}")] }, + variables: { source: { type: "integer", defaultValue: 1, hash: "1" } } + )) + expect(sdk.set_datafile(datafile("warm"))).to be(true) + index = sdk.instance_variable_get(:@datafile).dependency_index + expect(index).to receive(:dependents).exactly(2).times.and_call_original + extractions = 0 + allow(Featurevisor.const_get(:Dependencies)).to receive(:extract_entity_dependencies).and_wrap_original do |original, *args| + extractions += 1 + original.call(*args) + end + expect(sdk.set_datafile(datafile("next", variables: { source: { type: "integer", defaultValue: 2, hash: "2" } }))).to be(true) + expect(extractions).to eq(1) + sdk.close + end + ensure + sdk&.close + end + + it "extracts and parses only incoming entities after the first update at increasing corpus sizes" do + sdk = nil + [8, 128, 1024].each do |size| + condition = JSON.generate(variable: "source", operator: "notNull") + source = datafile( + segments: size.times.to_h { |i| ["s#{i}".to_sym, { conditions: condition }] }, + features: size.times.to_h { |i| ["f#{i}".to_sym, feature(conditions: "s#{i}")] }, + variables: { source: { type: "integer", defaultValue: 1, hash: "1" } }.merge( + size.times.to_h { |i| ["v#{i}".to_sym, { type: "integer", compose: "source", hash: "stable" }] }) + ) + extractions = 0 + parses = 0 + allow(Featurevisor.const_get(:Dependencies)).to receive(:extract_entity_dependencies).and_wrap_original do |original, *args| + extractions += 1 + original.call(*args) + end + allow(JSON).to receive(:parse).and_wrap_original do |original, *args, **kwargs| + parses += 1 if args.first == condition + original.call(*args, **kwargs) + end + sdk = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + expect(extractions).to eq(0) + expect(parses).to eq(0) + events = [] + sdk.on("datafile_set", ->(event) { events << event }) + expect(sdk.set_datafile(datafile("cold", variables: { source: { type: "integer", defaultValue: 2, hash: "2" } }))).to be(true) + expect(extractions).to eq(size * 3 + 2) + expect(parses).to eq(size) + index = sdk.instance_variable_get(:@datafile).dependency_index + extractions = parses = 0 + 3.times do |revision| + expect(sdk.set_datafile(datafile(revision.to_s, variables: { source: { + type: "integer", defaultValue: revision, hash: revision.to_s, conditions: condition + } }))).to be(true) + expect(sdk.instance_variable_get(:@datafile).dependency_index).to equal(index) + expect(events.last[:features]).to eq(size.times.map { |i| "f#{i}".to_sym }) + expect(events.last[:variables]).to eq([:source] + size.times.map { |i| "v#{i}".to_sym }) + end + expect(extractions).to eq(3) + expect(parses).to eq(3) + sdk.close + end + ensure + sdk&.close + end + + it "does not compare unchanged segment contents" do + retained = Class.new(Hash) do + def ==(other) + raise "retained segment compared" + end + end.new + retained[:conditions] = "*" + sdk = Featurevisor.create_featurevisor(datafile: datafile(segments: { retained: retained }), log_level: "fatal") + sdk.instance_variable_get(:@datafile).segments[:retained] = retained + expect(sdk.set_datafile(datafile("next"))).to be(true) + ensure + sdk&.close + end + + it "does not walk a new edge followed by an old edge" do + sdk = Featurevisor.create_featurevisor(datafile: datafile(variables: { + source: { type: "integer", defaultValue: 1, hash: "1" }, + bridge: { type: "integer", defaultValue: 0, hash: "same" }, + unreachable: { type: "integer", compose: "bridge", hash: "same" } + }), log_level: "fatal") + events = [] + sdk.on("datafile_set", ->(event) { events << event }) + expect(sdk.set_datafile(datafile("next", variables: { + source: { type: "integer", defaultValue: 2, hash: "2" }, + bridge: { type: "integer", compose: "source", hash: "same" }, + unreachable: { type: "integer", defaultValue: 0, hash: "same" } + }))).to be(true) + expect(events.last[:variables]).to eq(%i[source bridge]) + ensure + sdk&.close + end + + it "propagates chains, aliases, compositions and feature variable overrides and removes obsolete edges" do + source = datafile(segments: { + leaf: { conditions: { variable: "source", operator: "notNull" } }, chain: { conditions: "leaf" } + }, features: { + root: feature(conditions: "chain"), override: feature(traffic: [{ + key: "all", conditions: "*", percentage: 100_000, + variableOverrides: { message: [{ conditions: { variable: "alias", operator: "notNull" }, value: "yes" }] } + }]) + }, variables: { + source: { type: "integer", defaultValue: 1, hash: "1" }, + alias: { type: "integer", compose: "source", hash: "same" }, + view: { type: "object", compose: [{ key: "flag", feature: "root" }], hash: "same" } + }) + sdk = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + events = [] + sdk.on("datafile_set", ->(event) { events << event }) + %w[2 3].each do |revision| + expect(sdk.set_datafile(datafile(revision, variables: { source: { type: "integer", defaultValue: 2, hash: revision } }))).to be(true) + expect(events.last[:features]).to match_array(%i[root override]) + expect(events.last[:variables]).to match_array(%i[source alias view]) + end + expect(sdk.set_datafile(datafile("segments", segments: { chain: { conditions: "*" } }))).to be(true) + expect(events.last[:features]).to eq([:root]) + expect(events.last[:variables]).to eq([:view]) + expect(sdk.set_datafile(datafile("removed"), true)).to be(true) + index = sdk.instance_variable_get(:@datafile).dependency_index + expect(index.instance_variable_get(:@owners)).to be_empty + expect(index.instance_variable_get(:@reverse)).to be_empty + expect(sdk.set_datafile(datafile("fresh", variables: { source: { type: "integer", defaultValue: 3, hash: "3" } }))).to be(true) + expect(events.last[:features]).to be_empty + expect(events.last[:variables]).to eq([:source]) + ensure + sdk&.close + end + + it "serializes concurrent writers without dropping accepted partial updates" do + sdk = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + entered, release = Queue.new, Queue.new + allow(sdk).to receive(:merge_datafiles).and_wrap_original do |original, previous, incoming| + if incoming[:revision] == "first" + entered << true + release.pop + end + original.call(previous, incoming) + end + threads = [Thread.new { sdk.set_datafile(datafile("first", variables: { one: { type: "integer", defaultValue: 1, hash: "1" } })) }] + Timeout.timeout(5) { entered.pop } + threads << Thread.new { sdk.set_datafile(datafile("second", variables: { two: { type: "integer", compose: "one", hash: "2" } })) } + Thread.pass + release << true + expect(Timeout.timeout(5) { threads.map(&:value) }).to eq([true, true]) + expect(sdk.get_variable("two")).to eq(1) + expect(sdk.get_variable_keys).to match_array(%i[one two]) + ensure + threads&.each { |thread| thread.kill if thread.alive? } + threads&.each(&:join) + sdk&.close + end + + it "runs callbacks outside the writer mutex and releases old providers and the closed index" do + sdk = Featurevisor.create_featurevisor(datafile: datafile(variables: { source: { type: "integer", defaultValue: 1, hash: "1" } }), log_level: "fatal") + old = WeakRef.new(sdk.instance_variable_get(:@datafile)) + sdk.on("datafile_set", ->(event) { + if event[:revision] == "first" + worker = Thread.new { sdk.set_datafile(datafile("callback")) } + raise "callback refresh rejected" unless Timeout.timeout(5) { worker.value } + end + }) + expect(sdk.set_datafile(datafile("first"))).to be(true) + expect(sdk.get_revision).to eq("callback") + GC.start(full_mark: true, immediate_sweep: true) + expect(old.weakref_alive?).to be_falsey + sdk.close + expect(sdk.instance_variable_get(:@datafile).dependency_index).to be_nil + ensure + sdk&.close + end +end diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index 45da7ec..0d0eba9 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -226,7 +226,7 @@ modules: [ { name: "unit-test", - before: ->(options) { + before_evaluation: ->(options) { feature_key = options[:feature_key] variable_key = options[:variable_key] @@ -282,7 +282,7 @@ modules: [ { name: "unit-test", - after: ->(evaluation, options) { + after_evaluation: ->(evaluation, options) { feature_key = options[:feature_key] variable_key = options[:variable_key] @@ -396,7 +396,7 @@ myKey: { key: "myKey", bucketBy: "userId", - required: ["requiredKey"], + requiredFeatures: ["requiredKey"], traffic: [ { key: "1", @@ -435,7 +435,7 @@ myKey: { key: "myKey", bucketBy: "userId", - required: ["requiredKey"], + requiredFeatures: ["requiredKey"], traffic: [ { key: "1", @@ -478,9 +478,9 @@ myKey: { key: "myKey", bucketBy: "userId", - required: [ + requiredFeatures: [ { - key: "requiredKey", + feature: "requiredKey", variation: "control" # different variation } ], @@ -525,9 +525,9 @@ myKey: { key: "myKey", bucketBy: "userId", - required: [ + requiredFeatures: [ { - key: "requiredKey", + feature: "requiredKey", variation: "treatment" # desired variation } ], @@ -655,7 +655,7 @@ expect(sdk.is_enabled("test", { userId: "user-123", country: "nl" })).to be false end - it "should check if enabled for mutually exclusive features" do + it "ignores legacy feature ranges and retains traffic percentage boundaries" do bucket_value = 10_000 sdk = Featurevisor.create_featurevisor( @@ -674,7 +674,7 @@ mutex: { key: "mutex", bucketBy: "userId", - ranges: [[0, 50_000]], + ranges: [[50_000, 100_000]], traffic: [{ key: "1", segments: "*", percentage: 50_000, allocation: [] }] } }, @@ -688,6 +688,9 @@ bucket_value = 40_000 expect(sdk.is_enabled("mutex", { userId: "123" })).to be true + bucket_value = 50_000 + expect(sdk.is_enabled("mutex", { userId: "123" })).to be true + bucket_value = 60_000 expect(sdk.is_enabled("mutex", { userId: "123" })).to be false end @@ -1047,7 +1050,7 @@ expect(sdk.get_variable_integer("test", "count", context)).to eq(0) expect(sdk.get_variable("test", "price", context)).to eq(9.99) - expect(sdk.get_variable_double("test", "price", context)).to eq(9.99) + expect(sdk.get_variable_number("test", "price", context)).to eq(9.99) expect(sdk.get_variable("test", "paymentMethods", context)).to eq(["paypal", "creditcard"]) expect(sdk.get_variable_array("test", "paymentMethods", context)).to eq([ @@ -1662,9 +1665,9 @@ expect(sdk.send(:get_value_by_type, "1", "integer")).to be_nil end - it "returns finite numbers only for double type" do - expect(sdk.send(:get_value_by_type, 1.1, "double")).to eq(1.1) - expect(sdk.send(:get_value_by_type, "1.1", "double")).to be_nil + it "returns finite numbers only for number type" do + expect(sdk.send(:get_value_by_type, 1.1, "number")).to eq(1.1) + expect(sdk.send(:get_value_by_type, "1.1", "number")).to be_nil end it "returns nil when value is nil" do diff --git a/spec/live_modules_spec.rb b/spec/live_modules_spec.rb new file mode 100644 index 0000000..c8980ac --- /dev/null +++ b/spec/live_modules_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "featurevisor" + +RSpec.describe "module membership between callback phases" do + def feature(**values) + { bucketBy: "userId", traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] }.merge(values) + end + + [false, true].each do |global_root| + [false, true].product([false, true]).each do |removal, disable_gate| + it "refreshes membership for global root #{global_root}, removal #{removal}, disabled gate #{disable_gate}" do + trace = [] + key = ->(options) { options[:feature_key] || options[:variable_key] } + mod = { name: "late", + before_evaluation: ->(options) { trace << "before:#{key.call(options)}"; options }, + after_evaluation: ->(result, options) { + trace << "after:#{key.call(options)}" + disable_gate && key.call(options) == "gate" ? result.merge(enabled: false) : result + } } + condition = { or: [{ feature: "broken" }, { feature: "gate" }] } + sdk = Featurevisor.create_featurevisor(log_level: "error", modules: removal ? [mod] : [], on_diagnostic: ->(diagnostic) { + if diagnostic[:code] == "conditions_parse_error" + removal ? sdk.remove_module("late") : sdk.add_module(mod) + end + }, datafile: { + schemaVersion: "4", revision: "live", segments: {}, features: { + broken: feature(conditions: "[broken"), gate: feature, subject: feature(conditions: condition) + }, variables: { subject: { type: "string", defaultValue: "matched", conditions: condition } } + }) + evaluation = global_root ? sdk.evaluate_variable("subject") : sdk.evaluate_flag("subject") + if disable_gate && !removal + expect(evaluation[:reason]).to eq("conditions_unmet") + expect(global_root ? evaluation[:variable_value] : evaluation[:enabled]).to eq(global_root ? nil : false) + else + expect(global_root ? evaluation[:variable_value] : evaluation[:enabled]).to eq(global_root ? "matched" : true) + end + expect(trace).to eq(removal ? %w[before:subject before:broken] : %w[after:broken before:gate after:gate after:subject]) + ensure + sdk&.close + end + end + end + + it "keeps after phase membership stable once that phase starts" do + trace = [] + added = { name: "added", after_evaluation: ->(result, _) { trace << "added"; result } } + sdk = Featurevisor.create_featurevisor(log_level: "fatal", modules: [ + { name: "first", after_evaluation: ->(result, _) { + trace << "first" + sdk.add_module(added) + sdk.remove_module("second") + result + } }, { name: "second", after_evaluation: ->(result, _) { trace << "second"; result } } + ]) + sdk.evaluate_flag("missing") + expect(trace).to eq(%w[first second]) + ensure + sdk&.close + end +end diff --git a/spec/module_phase_snapshots_spec.rb b/spec/module_phase_snapshots_spec.rb new file mode 100644 index 0000000..1960106 --- /dev/null +++ b/spec/module_phase_snapshots_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "featurevisor" + +RSpec.describe "module phase snapshots" do + phases = %i[before_evaluation bucket_key bucket_value after_evaluation] + [false, true].product(phases, %i[add remove_add remove unsubscribe]).each do |global_root, changed_phase, action| + it "snapshots global #{global_root}, #{changed_phase}, #{action}" do + trace = [] + closed = [] + changed = false + sdk = nil + unsubscribe = nil + make_module = nil + make_module = ->(name) { + hooks = phases.to_h do |phase| + [phase, ->(*args) { + options = args.last + active = %i[bucket_key bucket_value].include?(phase) || + (global_root ? options[:variable_key] == "subject" : options[:feature_key] == "gate") + if active + trace << "#{phase}:#{name}" + if name == "first" && phase == changed_phase && !changed + changed = true + if action == :unsubscribe + unsubscribe.call + elsif action != :add + sdk.remove_module("other") + end + sdk.add_module(make_module.call("late")) if %i[add remove_add].include?(action) + end + end + %i[bucket_key bucket_value].include?(phase) ? options[phase] : args.first + }] + end + hooks.merge(name: name, close: -> { closed << name }) + } + sdk = Featurevisor.create_featurevisor(log_level: "fatal", datafile: { + schemaVersion: "4", revision: "phases", segments: {}, features: { + gate: { bucketBy: "userId", traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] } + }, variables: { subject: { type: "string", defaultValue: "matched", conditions: { feature: "gate" } } } + }) + sdk.add_module(make_module.call("first")) + unsubscribe = sdk.add_module(make_module.call("other")) + evaluate = -> { + result = global_root ? sdk.evaluate_variable("subject") : sdk.evaluate_flag("gate") + expect(global_root ? result[:variable_value] : result[:enabled]).to eq(global_root ? "matched" : true) + } + evaluate.call + after = ["first"] + (action == :add ? ["other"] : []) + (%i[add remove_add].include?(action) ? ["late"] : []) + expected = phases.each_with_index.flat_map do |phase, index| + (index <= phases.index(changed_phase) ? %w[first other] : after).map { |name| "#{phase}:#{name}" } + end + expect(trace).to eq(expected) + expect(closed).to eq(action == :add ? [] : ["other"]) + trace.clear + evaluate.call + expect(trace).to eq(phases.flat_map { |phase| after.map { |name| "#{phase}:#{name}" } }) + ensure + sdk&.close + end + end +end diff --git a/spec/modules_spec.rb b/spec/modules_spec.rb index 692735a..be95307 100644 --- a/spec/modules_spec.rb +++ b/spec/modules_spec.rb @@ -12,15 +12,15 @@ mod = Featurevisor::Modules::FeaturevisorModule.new( name: "test-mod", setup: ->(_api) {}, - before: ->(opts) { opts.merge(test: true) }, - after: ->(eval, opts) { eval.merge(test: true) }, + before_evaluation: ->(opts) { opts.merge(test: true) }, + after_evaluation: ->(eval, _opts) { eval.merge(test: true) }, close: -> {} ) expect(mod.name).to eq("test-mod") expect(mod).to respond_to(:call_setup) - expect(mod).to respond_to(:call_before) - expect(mod).to respond_to(:call_after) + expect(mod).to respond_to(:call_before_evaluation) + expect(mod).to respond_to(:call_after_evaluation) expect(mod).to respond_to(:call_close) end @@ -52,10 +52,10 @@ it "should call before mod when defined" do mod = Featurevisor::Modules::FeaturevisorModule.new( name: "test-mod", - before: ->(opts) { opts.merge(test: true) } + before_evaluation: ->(opts) { opts.merge(test: true) } ) - result = mod.call_before({ original: true }) + result = mod.call_before_evaluation({ original: true }) expect(result[:test]).to be true expect(result[:original]).to be true end @@ -63,7 +63,7 @@ it "should return original options when before mod is not defined" do mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") - result = mod.call_before({ original: true }) + result = mod.call_before_evaluation({ original: true }) expect(result[:original]).to be true expect(result[:test]).to be_nil end @@ -105,10 +105,10 @@ it "should call after mod when defined" do mod = Featurevisor::Modules::FeaturevisorModule.new( name: "test-mod", - after: ->(eval, opts) { eval.merge(test: true) } + after_evaluation: ->(eval, _opts) { eval.merge(test: true) } ) - result = mod.call_after({ original: true }, { options: true }) + result = mod.call_after_evaluation({ original: true }, { options: true }) expect(result[:test]).to be true expect(result[:original]).to be true end @@ -116,7 +116,7 @@ it "should return original evaluation when after mod is not defined" do mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") - result = mod.call_after({ original: true }, { options: true }) + result = mod.call_after_evaluation({ original: true }, { options: true }) expect(result[:original]).to be true expect(result[:test]).to be_nil end @@ -208,17 +208,17 @@ it "should run before modules" do module1 = Featurevisor::Modules::FeaturevisorModule.new( name: "module1", - before: ->(opts) { opts.merge(module1: true) } + before_evaluation: ->(opts) { opts.merge(module1: true) } ) module2 = Featurevisor::Modules::FeaturevisorModule.new( name: "module2", - before: ->(opts) { opts.merge(module2: true) } + before_evaluation: ->(opts) { opts.merge(module2: true) } ) modules_manager.add(module1) modules_manager.add(module2) - result = modules_manager.run_before_modules({ original: true }) + result = modules_manager.run_before_evaluation_modules({ original: true }) expect(result[:module1]).to be true expect(result[:module2]).to be true expect(result[:original]).to be true @@ -263,17 +263,17 @@ it "should run after modules" do module1 = Featurevisor::Modules::FeaturevisorModule.new( name: "module1", - after: ->(eval, opts) { eval.merge(module1: true) } + after_evaluation: ->(eval, _opts) { eval.merge(module1: true) } ) module2 = Featurevisor::Modules::FeaturevisorModule.new( name: "module2", - after: ->(eval, opts) { eval.merge(module2: true) } + after_evaluation: ->(eval, _opts) { eval.merge(module2: true) } ) modules_manager.add(module1) modules_manager.add(module2) - result = modules_manager.run_after_modules({ original: true }, { options: true }) + result = modules_manager.run_after_evaluation_modules({ original: true }, { options: true }) expect(result[:module1]).to be true expect(result[:module2]).to be true expect(result[:original]).to be true @@ -284,21 +284,17 @@ %w[first second].each do |name| modules_manager.add(Featurevisor::Modules::FeaturevisorModule.new( name: name, - before: ->(options) { order << "before:#{name}"; options }, before_evaluation: ->(options) { order << "beforeEvaluation:#{name}"; options }, - after_evaluation: ->(evaluation, _options) { order << "afterEvaluation:#{name}"; evaluation }, - after: ->(evaluation, _options) { order << "after:#{name}"; evaluation } + after_evaluation: ->(evaluation, _options) { order << "afterEvaluation:#{name}"; evaluation } )) end - options = modules_manager.run_before_modules(type: "flag", feature_key: "test") - modules_manager.run_after_modules({ type: "flag", feature_key: "test" }, options) + options = modules_manager.run_before_evaluation_modules(type: "flag", feature_key: "test") + modules_manager.run_after_evaluation_modules({ type: "flag", feature_key: "test" }, options) expect(order).to eq([ - "before:first", "before:second", "beforeEvaluation:first", "beforeEvaluation:second", - "afterEvaluation:first", "afterEvaluation:second", - "after:first", "after:second" + "afterEvaluation:first", "afterEvaluation:second" ]) end diff --git a/spec/normalization_concurrency_spec.rb b/spec/normalization_concurrency_spec.rb new file mode 100644 index 0000000..78f0664 --- /dev/null +++ b/spec/normalization_concurrency_spec.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +require "featurevisor" +require "timeout" + +RSpec.describe "lazy normalization concurrency" do + def datafile(revision = "initial") + { schemaVersion: "4", revision: revision, segments: {}, features: {}, variables: {} } + end + + def feature + { bucketBy: [], traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] } + end + + def wait_for_thread(thread) + Timeout.timeout(5) { Thread.pass while thread.alive? && thread.status != "sleep" } + end + + def join_threads(threads) + Timeout.timeout(10) { threads.map(&:value) } + ensure + threads.each { |thread| thread.kill if thread.alive? } + threads.each(&:join) + end + + { feature: [:features, :get_feature], segment: [:segments, :get_segment], variable: [:variables, :get_global_variable] }.each do |kind, (collection, getter)| + it "publishes a cold #{kind} once before concurrent readers and reentrant diagnostics" do + source = datafile + definition = kind == :feature ? feature : kind == :variable ? { type: "boolean", defaultValue: true } : {} + source[collection][:broken] = definition.merge(conditions: "{broken") + source[collection][:healthy] = definition.merge(conditions: "*") + captured = Queue.new + reentries = Queue.new + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { + next unless item[:code] == "conditions_parse_error" + captured << item + # A different thread must be able to acquire the cache lock during the callback. + worker = Thread.new do + provider = f.instance_variable_get(:@datafile) + value = provider.public_send(getter, "broken") + provider.public_send(getter, "healthy") + value + end + reentries << join_threads([worker]).first + }) + provider = f.instance_variable_get(:@datafile) + entered, release, attempted = Queue.new, Queue.new, Queue.new + calls = Queue.new + allow(Featurevisor::Normalize).to receive(:entity).and_wrap_original do |original, *args| + calls << args.first(2) + if Thread.current[:first_reader] + entered << true + release.pop + end + original.call(*args) + end + threads = [Thread.new { Thread.current[:first_reader] = true; provider.public_send(getter, "broken") }] + Timeout.timeout(5) { entered.pop } + threads << Thread.new { attempted << true; provider.public_send(getter, "broken") } + Timeout.timeout(5) { attempted.pop } + wait_for_thread(threads.last) + release << true + values = join_threads(threads) + expect(values.first).to equal(values.last) + expect(calls.size).to eq(2) + expect([calls.pop, calls.pop]).to match_array([[kind, :broken], [kind, :healthy]]) + expect(captured.size).to eq(1) + expect(Timeout.timeout(5) { reentries.pop }).to equal(values.first) + expect(captured.pop[:details]).to eq(conditions: "{broken", entityType: kind.to_s, entityKey: "broken") + ensure + threads&.each { |thread| thread.kill if thread.alive? } + threads&.each(&:join) + f&.close + end + end + + it "does not insert into a hash while refresh takes its retained cache snapshot" do + source = datafile + source[:features] = { warm: feature, cold: feature } + captured = Queue.new + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { captured << item }) + expect(f.is_enabled("warm")).to be(true) + entered, release = Queue.new, Queue.new + gate_hash = Class.new(Hash) do + attr_accessor :entered, :release + + def each + return enum_for(:each) unless block_given? + super do |key, value| + if Thread.current[:retaining] && entered + self.entered << true + self.entered = nil + release.pop + end + yield key, value + end + end + + def dup + each_with_object({}) { |(key, value), copy| copy[key] = value } + end + end.new + provider = f.instance_variable_get(:@datafile) + gate_hash.update(provider.normalized[:feature]) + gate_hash.entered, gate_hash.release = entered, release + provider.normalized[:feature] = gate_hash + threads = [Thread.new { Thread.current[:retaining] = true; f.set_datafile(datafile("refresh")) }] + Timeout.timeout(5) { entered.pop } + attempted = Queue.new + threads << Thread.new { attempted << true; f.is_enabled("cold") } + Timeout.timeout(5) { attempted.pop } + wait_for_thread(threads.last) + release << true + expect(join_threads(threads)).to eq([true, true]) + expect(f.get_revision).to eq("refresh") + expect(f.evaluate_flag("cold")).to include(enabled: true, reason: "rule") + expect(captured.size).to eq(0) + ensure + threads&.each { |thread| thread.kill if thread.alive? } + threads&.each(&:join) + f&.close + end + + it "shares unread slots across retained generations and resets only replaced entities" do + source = datafile + source[:features][:broken] = feature.merge(force: [{ conditions: "{broken", enabled: false }]) + captured = Queue.new + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { captured << item }) + old = f.instance_variable_get(:@datafile) + expect(f.set_datafile(datafile("retained"))).to be(true) + current = f.instance_variable_get(:@datafile) + expect(old.normalized.values).to all(be_empty) + expect(current.normalized.values).to all(be_empty) + threads = [old, current].map { |provider| Thread.new { provider.get_feature("broken") } } + values = join_threads(threads) + expect(values.first).to equal(values.last) + expect(captured.size).to eq(1) + 10.times { |index| expect(f.set_datafile(datafile("unrelated#{index}"))).to be(true) } + expect(f.is_enabled("broken")).to be(true) + expect(captured.size).to eq(1) + expect(f.set_datafile(source)).to be(true) + expect(f.is_enabled("broken")).to be(true) + expect(captured.size).to eq(2) + expect(f.set_datafile(source, true)).to be(true) + expect(f.is_enabled("broken")).to be(true) + expect(captured.size).to eq(3) + ensure + f&.close + end + + it "allows diagnostic setters and module changes outside the lock to invalidate completed source memos" do + source = datafile + source[:features][:broken] = feature.merge(force: [{ conditions: "{broken", enabled: false }]) + source[:features][:healthy] = feature + source[:variables][:source] = { type: "string", defaultValue: "before" } + source[:variables][:view] = { type: "object", compose: [ + { key: "first", variable: "source" }, { key: "flag", feature: "broken" }, { key: "last", variable: "source" } + ] } + captured = [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { + next unless item[:code] == "conditions_parse_error" + captured << item + worker = Thread.new do + patch = datafile("callback") + patch[:variables][:source] = { type: "string", defaultValue: "after" } + raise "callback refresh rejected" unless f.set_datafile(patch) + f.set_context(callback: true) + f.add_module(name: "diagnostic-installed", after_evaluation: ->(result, options) { + options[:variable_key] == "source" ? result.merge(variable_value: "#{result[:variable_value]}-hook") : result + }) + raise "callback reentry failed" unless f.is_enabled("healthy") + end + join_threads([worker]) + }) + expect(f.get_variable("view")).to eq(first: "before", flag: true, last: "after-hook") + expect(f.get_revision).to eq("callback") + expect(captured.length).to eq(1) + ensure + f&.close + end + + it "accepts every valid refresh without false flags, null variables or error evaluations" do + source = datafile + count, rounds, workers = 32, 30, 4 + count.times do |index| + source[:segments]["s#{index}".to_sym] = { conditions: '{"attribute":"country","operator":"equals","value":"nl"}' } + source[:features]["f#{index}".to_sym] = feature.merge(conditions: "s#{index}") + source[:variables]["v#{index}".to_sym] = { type: "string", conditions: "s#{index}", defaultValue: "present" } + end + captured = Queue.new + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { captured << item }) + starts, finishes = Array.new(workers) { Queue.new }, Queue.new + threads = workers.times.map do |worker| + Thread.new do + rounds.times do + starts[worker].pop + count.times do |offset| + index = (offset + worker) % count + raise "silent false flag" unless f.is_enabled("f#{index}", country: "nl") == true + raise "silent variable fallback" unless f.get_variable("v#{index}", country: "nl") == "present" + raise "flag error" if f.evaluate_flag("f#{index}", country: "nl")[:reason] == "error" + raise "variable error" if f.evaluate_variable("v#{index}", country: "nl")[:reason] == "error" + Thread.pass + end + finishes << true + end + end + end + Timeout.timeout(30) do + rounds.times do |round| + # Replacement makes every round cold, followed by partial refreshes while readers run. + expect(f.set_datafile(source, true)).to be(true) + starts.each { |queue| queue << true } + 4.times do |refresh| + expect(f.set_datafile(datafile("r#{round}.#{refresh}"))).to be(true) + Thread.pass + end + workers.times { finishes.pop } + end + expect(join_threads(threads)).to eq(Array.new(workers, rounds)) + end + expect(captured.size).to eq(0) + ensure + threads&.each { |thread| thread.kill if thread.alive? } + threads&.each(&:join) + f&.close + end +end diff --git a/spec/number_getter_spec.rb b/spec/number_getter_spec.rb new file mode 100644 index 0000000..bd7f0d2 --- /dev/null +++ b/spec/number_getter_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "featurevisor" + +RSpec.describe "number getters" do + let(:sdk) do + Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "4", revision: "1", segments: {}, features: {}, + variables: { + rate: { type: "number", defaultValue: 2.5 }, + ratio: { type: "double", defaultValue: 1.5 } + } + }, + log_level: "fatal" + ) + end + + after { sdk.close } + + it "reads number variables and earlier double variables the same way" do + expect(sdk.get_variable_number("rate")).to eq(2.5) + expect(sdk.get_variable_number("ratio")).to eq(1.5) + expect(sdk.spawn({}).get_variable_number("rate")).to eq(2.5) + end + + it "no longer has the double getters removed in v4" do + expect(sdk).not_to respond_to(:get_variable_double) + expect(sdk.spawn({})).not_to respond_to(:get_variable_double) + end +end diff --git a/spec/openfeature_provider_spec.rb b/spec/openfeature_provider_spec.rb index ea7fe79..2a15b3a 100644 --- a/spec/openfeature_provider_spec.rb +++ b/spec/openfeature_provider_spec.rb @@ -1,6 +1,90 @@ require "featurevisor/openfeature_provider" +require "json" RSpec.describe Featurevisor::OpenFeatureProvider do + it "rejects invalid initial registration and emits recovery independently of logging" do + ["{", "null", {}, false, { schemaVersion: "2", revision: "x", features: [], segments: {} }].each do |input| + instance = described_class.new(datafile: input, log_level: "fatal") + expect { OpenFeature::SDK.configure { |configuration| configuration.set_provider_and_wait(instance) } } + .to raise_error(OpenFeature::SDK::ProviderInitializationError) { |error| expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) } + instance.featurevisor.set_datafile(datafile, true) + expect(OpenFeature::SDK.build_client.fetch_boolean_value(flag_key: "checkout", default_value: false)).to be(true) + instance.shutdown + end + end + + it "preserves empty targeting keys through the OpenFeature client" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/openfeature-provider.json", __dir__), encoding: "UTF-8")) + instance = described_class.new(datafile: fixture.fetch("datafile"), log_level: "fatal") + OpenFeature::SDK.configure { |configuration| configuration.set_provider_and_wait(instance) } + client = OpenFeature::SDK.build_client + fields = { userId: "forced" } + absent = OpenFeature::SDK::EvaluationContext.new(**fields) + empty = OpenFeature::SDK::EvaluationContext.new(targeting_key: "", **fields) + other = OpenFeature::SDK::EvaluationContext.new(targeting_key: "other", **fields) + expect(client.fetch_boolean_value(flag_key: "targetOnly", default_value: false, evaluation_context: absent)).to be(true) + expect(client.fetch_boolean_value(flag_key: "targetOnly", default_value: false, evaluation_context: empty)).to be(false) + expect(client.fetch_boolean_value(flag_key: "targetOnly", default_value: false, evaluation_context: other)).to be(false) + expect(fields).to eq({ userId: "forced" }) + custom = described_class.new(datafile: fixture.fetch("datafile"), log_level: "fatal", targeting_key_field: "accountId") + OpenFeature::SDK.configure { |configuration| configuration.set_provider_and_wait(custom) } + account = { accountId: "old" } + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "", **account) + expect(OpenFeature::SDK.build_client.fetch_boolean_value(flag_key: "accountTargeted", default_value: false, evaluation_context: context)).to be(true) + expect(account).to eq({ accountId: "old" }) + ensure + instance&.shutdown + custom&.shutdown + end + + it "runs the shared provider conformance corpus" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/openfeature-provider.json", __dir__), encoding: "UTF-8")) + expect(fixture.fetch("kind")).to eq("featurevisor.openfeature-provider") + expect(fixture.fetch("schemaVersion")).to eq(1) + expect(fixture.fetch("cases").length).to eq(16) + instance = described_class.new(datafile: fixture.fetch("datafile"), log_level: "fatal") + OpenFeature::SDK.configure { |configuration| configuration.set_provider_and_wait(instance) } + client = OpenFeature::SDK.build_client + resolvers = { + "boolean" => :fetch_boolean_value, + "string" => :fetch_string_value, + "object" => :fetch_object_value + } + fixture.fetch("cases").each do |test_case| + source = Marshal.load(Marshal.dump(test_case.fetch("context"))) + attributes = source.reject { |key, _| key == "targetingKey" }.transform_keys(&:to_sym) + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: source["targetingKey"], **attributes) + result = instance.public_send(resolvers.fetch(test_case.fetch("type")), flag_key: test_case.fetch("flagKey"), default_value: test_case.fetch("default"), evaluation_context: context) + expected = test_case.fetch("expected") + expect(result.value).to eq(expected.fetch("value")), test_case.fetch("name") + expect(result.reason).to eq(expected.fetch("reason")), test_case.fetch("name") if expected.key?("reason") + expect(result.variant).to eq(expected["variant"]), test_case.fetch("name") + expect(result.error_code).to eq(expected["errorCode"]), test_case.fetch("name") + method = resolvers.fetch(test_case.fetch("type")).to_s.sub(/_value\z/, "_details") + client_result = client.public_send(method, flag_key: test_case.fetch("flagKey"), default_value: test_case.fetch("default"), evaluation_context: context) + expect(client_result.value).to eq(expected.fetch("value")), test_case.fetch("name") + expect(client_result.reason).to eq(result.reason), test_case.fetch("name") + expect(client_result.variant).to eq(result.variant), test_case.fetch("name") + expect(client_result.error_code).to eq(result.error_code), test_case.fetch("name") + expect(source).to eq(test_case.fetch("context")), test_case.fetch("name") + end + fixture.fetch("targetingCases").each do |test_case| + source = Marshal.load(Marshal.dump(test_case.fetch("context"))) + attributes = source.reject { |key, _| key == "targetingKey" }.transform_keys(&:to_sym) + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: source["targetingKey"], **attributes) + result = instance.fetch_boolean_value(flag_key: "targetOnly", default_value: false, evaluation_context: context) + expect(result.value).to eq(test_case.fetch("expectedValue")), test_case.fetch("name") + expect(source).to eq(test_case.fetch("context")), test_case.fetch("name") + end + custom = described_class.new(datafile: fixture.fetch("datafile"), log_level: "fatal", targeting_key_field: "accountId") + test_case = fixture.fetch("customTargetingCase") + attributes = test_case.fetch("context").reject { |key, _| key == "targetingKey" }.transform_keys(&:to_sym) + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: test_case.fetch("context").fetch("targetingKey"), **attributes) + expect(custom.fetch_boolean_value(flag_key: test_case.fetch("flagKey"), default_value: false, evaluation_context: context).value).to eq(test_case.fetch("expectedValue")) + ensure + instance&.shutdown + custom&.shutdown + end let(:datafile) do { schemaVersion: "2", revision: "openfeature-test", featurevisorVersion: "3.0.1", segments: {}, @@ -29,6 +113,16 @@ def provider(**options) described_class.new({ datafile: datafile, log_level: "fatal" }, **options) end + it "decodes global JSON variables just like feature JSON variables" do + datafile[:variables][:config] = { type: "json", defaultValue: '{"nested":[null,true]}' } + instance = provider + result = instance.fetch_object_value(flag_key: "variable:config", default_value: {}) + expect(result.value).to eq({ "nested" => [nil, true] }) + expect(result.error_code).to be_nil + ensure + instance&.shutdown + end + it "accepts Featurevisor options as documented keyword arguments" do instance = described_class.new(datafile: datafile, log_level: "fatal") expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).value).to be(true) @@ -87,15 +181,83 @@ def provider(**options) expect(value).to be(true) end + [false, true].each do |borrowed| + it "resolves, tracks, updates and shuts down through the client with borrowed=#{borrowed}" do + tracked, closed, events = [], [], [] + options = { datafile: datafile, log_level: "fatal", modules: [{ close: -> { closed << true } }] } + featurevisor = Featurevisor.create_featurevisor(options) if borrowed + instance = described_class.new(options, featurevisor: featurevisor, on_track: ->(*args) { tracked << args }) + OpenFeature::SDK.configure { |configuration| configuration.set_provider_and_wait(instance) } + client = OpenFeature::SDK.build_client + client.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) { |_| events << :error } + client.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) { |_| events << :ready } + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "user") + expect(client.fetch_integer_value(flag_key: "checkout:count", default_value: 0)).to eq(3) + expect(client.fetch_float_value(flag_key: "checkout:ratio", default_value: 0.0)).to eq(1.5) + expect(client.fetch_object_value(flag_key: "checkout:json", default_value: {})).to eq("nested" => true) + missing = client.fetch_boolean_details(flag_key: "missing", default_value: true) + expect(missing.value).to be(true) + expect(missing.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) + mismatch = client.fetch_integer_details(flag_key: "checkout:ratio", default_value: 9) + expect(mismatch.value).to eq(9) + expect(mismatch.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + details = { value: 3, currency: "EUR" } + client.track("purchase", evaluation_context: context, tracking_event_details: details) + expect(tracked.first.first).to eq("purchase") + expect(tracked.first[1].targeting_key).to eq("user") + expect(tracked.first[2]).to equal(details) + updated = datafile + updated[:features]["checkout"][:variations][0][:variables][:title] = "Updated" + expect(instance.featurevisor.set_datafile(updated, true)).to be(true) + expect(client.fetch_string_value(flag_key: "checkout:title", default_value: "")).to eq("Updated") + expect(instance.featurevisor.set_datafile("{")).to be(false) + expect(client.fetch_boolean_details(flag_key: "checkout", default_value: false).reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(instance.featurevisor.set_datafile(updated, true)).to be(true) + expect(client.fetch_string_value(flag_key: "checkout:title", default_value: "")).to eq("Updated") + expect(events.last(2)).to eq([:error, :ready]) + OpenFeature::SDK.shutdown + instance.shutdown + expect(closed).to eq(borrowed ? [] : [true]) + if borrowed + expect(featurevisor.set_datafile("{")).to be(false) + expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).error_code).to be_nil + featurevisor.close + end + expect(closed).to eq([true]) + ensure + OpenFeature::SDK.shutdown + instance&.shutdown + featurevisor&.close + end + end + it "returns a parse error for malformed datafiles" do instance = described_class.new(datafile: "{", log_level: "fatal") result = instance.fetch_boolean_value(flag_key: "checkout", default_value: false) expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) expect(result.error_message).to eq("Could not parse datafile") - instance.featurevisor.set_datafile(datafile, true) + expect(instance.featurevisor.set_datafile(datafile, true)).to be(true) expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).value).to be(true) end + it "preserves provider recovery and ownership with boolean setter results" do + featurevisor = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + instance = described_class.new(featurevisor: featurevisor) + expect(featurevisor.set_datafile("{")).to be(false) + expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).error_code) + .to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) + expect(featurevisor.set_datafile(datafile, true)).to be(true) + expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).error_code).to be_nil + instance.shutdown + expect(featurevisor.set_datafile("{")).to be(false) + expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).error_code).to be_nil + featurevisor.close + expect(featurevisor.set_datafile(datafile)).to be(false) + ensure + instance&.shutdown + featurevisor&.close + end + it "uses the exact OpenFeature number resolver types" do instance = provider @@ -126,7 +288,7 @@ def provider(**options) datafile: datafile, log_level: "fatal", targeting_key_field: "accountId", - modules: [{ name: "capture", before: ->(options) { captured << options[:context]; options } }] + modules: [{ name: "capture", before_evaluation: ->(options) { captured << options[:context]; options } }] ) context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "subject", **fields) @@ -154,8 +316,22 @@ def provider(**options) instance&.shutdown end + it "maps a real unmet feature prerequisite to disabled" do + blocked = datafile + blocked[:features]["blocked"] = { + bucketBy: "userId", requiredFeatures: ["missing"], + traffic: [{ key: "all", segments: "*", percentage: 100_000 }] + } + instance = described_class.new(datafile: blocked, log_level: "fatal") + result = instance.fetch_boolean_value(flag_key: "blocked", default_value: true) + expect(result.value).to be(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DISABLED) + expect(result.flag_metadata["featurevisorReason"]).to eq("conditions_unmet") + ensure + instance&.shutdown + end + { - "required" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, "forced" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, "sticky" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, "rule" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, @@ -165,8 +341,8 @@ def provider(**options) "disabled" => OpenFeature::SDK::Provider::Reason::DISABLED, "variation_disabled" => OpenFeature::SDK::Provider::Reason::DISABLED, "variable_disabled" => OpenFeature::SDK::Provider::Reason::DISABLED, - "required_features_unmet" => OpenFeature::SDK::Provider::Reason::DISABLED, - "out_of_range" => OpenFeature::SDK::Provider::Reason::DEFAULT, + "conditions_unmet" => OpenFeature::SDK::Provider::Reason::DISABLED, + "variable_composed" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, "no_match" => OpenFeature::SDK::Provider::Reason::DEFAULT, "variable_default" => OpenFeature::SDK::Provider::Reason::DEFAULT }.each do |featurevisor_reason, openfeature_reason| diff --git a/spec/readme_initialization_spec.rb b/spec/readme_initialization_spec.rb new file mode 100644 index 0000000..e0a9932 --- /dev/null +++ b/spec/readme_initialization_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'featurevisor' +require 'net/http' +require 'json' + +RSpec.describe 'README initialization' do + let(:source) do + readme = File.read(File.expand_path('../README.md', __dir__), encoding: 'UTF-8') + readme.match(/## Initialization\n.*?```ruby\n(.*?)\n```/m)[1] + end + let(:fixture) do + JSON.generate(schemaVersion: '2', revision: 'readme', segments: {}, variables: {}, + features: { checkout: { hash: 'checkout', bucketBy: 'userId', + traffic: [{ key: 'all', segments: '*', percentage: 100_000 }] } }) + end + + def respond_with(code, body) + response = Net::HTTPResponse::CODE_TO_OBJ.fetch(code).new('1.1', code, 'Fixture') + allow(response).to receive(:body).and_return(body) + allow(Net::HTTP).to receive(:get_response) + .with(URI('https://cdn.yoursite.com/featurevisor-all.json')).and_return(response) + end + + it 'executes the actual snippet and evaluates the loaded feature' do + respond_with('200', fixture) + expect { eval(source, binding, 'README.md') }.to output(/true\n/).to_stdout + end + + %w[302 404 503].each do |code| + it "rejects HTTP #{code} even with valid datafile content" do + respond_with(code, fixture) + expect(Featurevisor).not_to receive(:create_featurevisor) + expect { eval(source, binding, 'README.md') }.to raise_error(RuntimeError, /HTTP #{code}/) + end + end + + it 'propagates invalid JSON without creating an instance' do + respond_with('200', '{') + expect(Featurevisor).not_to receive(:create_featurevisor) + expect { eval(source, binding, 'README.md') }.to raise_error(JSON::ParserError) + end + + it 'propagates transport errors without creating an instance' do + allow(Net::HTTP).to receive(:get_response).and_raise(IOError, 'connection failed') + expect(Featurevisor).not_to receive(:create_featurevisor) + expect { eval(source, binding, 'README.md') }.to raise_error(IOError, 'connection failed') + end +end diff --git a/spec/refresh_ownership_spec.rb b/spec/refresh_ownership_spec.rb new file mode 100644 index 0000000..b163178 --- /dev/null +++ b/spec/refresh_ownership_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "featurevisor" + +RSpec.describe "full refresh ownership" do + %w[2 4].each do |schema| + [false, true].each do |replace| + it "keeps nested values and cold readers independent for schema #{schema}, replace #{replace}" do + payload = { conditions: { variable: "ignored", operator: "notNull" }, rows: [{ label: "old" }] } + definition = { type: "object", hash: "same", defaultValue: payload, metadata: { nested: ["old"] } } + source = { schemaVersion: schema, revision: "initial", segments: {}, features: {}, variables: { + value: definition, cold: definition, removed: definition, + broken: { type: "boolean", defaultValue: true, conditions: "[", hash: "same" } + } } + errors = [] + sdk = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { + errors << item if item[:code] == "conditions_parse_error" + }) + old = sdk.instance_variable_get(:@datafile) + expect(errors.length).to eq(schema == "2" ? 1 : 0) + 2.times { expect(sdk.get_variable("broken")).to be_nil } + expect(errors.length).to eq(1) + expect(sdk.get_variable("value")).to eq(payload) + updated = Marshal.load(Marshal.dump(source)) + updated[:revision] = "next" + updated[:variables].delete(:removed) + updated[:variables].values_at(:value, :cold).each do |entry| + entry[:defaultValue][:rows][0][:label] = "new" + entry[:metadata][:nested][0] = "new" + end + expect(sdk.set_datafile(updated, replace)).to be(true) + updated[:variables][:value][:defaultValue][:rows][0][:label] = "caller" + expect(sdk.get_variable("value")[:rows][0][:label]).to eq("new") + expect(sdk.get_variable("cold")[:rows][0][:label]).to eq("new") + current = sdk.instance_variable_get(:@datafile) + expect(current.get_global_variable("cold")[:metadata]).to eq(nested: ["new"]) + expect(old.get_global_variable("cold")[:metadata]).to eq(nested: ["old"]) + expect(old.get_global_variable("cold")[:defaultValue][:rows][0][:label]).to eq("old") + 2.times { expect(sdk.get_variable("broken")).to be_nil } + expect(errors.length).to eq(2) + expect(sdk.get_variable_keys.include?(:removed)).to eq(!replace) + events = [] + sdk.on("datafile_set", ->(event) { events << event }) + expect(sdk.set_datafile({ schemaVersion: schema, revision: "payload", segments: {}, features: {}, variables: { + ignored: { type: "integer", defaultValue: 1, hash: "new" } + } })).to be(true) + expect(events.last[:variables]).to eq([:ignored]) + expect(sdk.set_datafile({ schemaVersion: schema, revision: "empty", segments: {}, features: {}, variables: {} }, true)).to be(true) + expect(sdk.get_variable_keys).to be_empty + expect(sdk.instance_variable_get(:@datafile).normalized.values).to all(be_empty) + ensure + sdk&.close + end + end + end +end diff --git a/spec/runtime_project_spec.rb b/spec/runtime_project_spec.rb new file mode 100644 index 0000000..6ebd19c --- /dev/null +++ b/spec/runtime_project_spec.rb @@ -0,0 +1,60 @@ +require "featurevisor" +require_relative "../bin/cli" + +RSpec.describe FeaturevisorCLI::Commands::RuntimeProject do + [FeaturevisorCLI::Commands::Benchmark, FeaturevisorCLI::Commands::AssessDistribution].each do |klass| + describe klass.name do + let(:options) do + FeaturevisorCLI::Options.new.tap do |value| + value.feature = "ready" + value.n = 2 + value.project_directory_path = "/tmp/project with spaces" + end + end + let(:command) { klass.new(options) } + + it "delegates flat datafile builds without an environment argument" do + expect(command).to receive(:project_json).with("config", "--json", include_set: false).and_return({"environments" => false}) + expect(command).to receive(:project_json).with("build", "--json").and_return({"features" => {}}) + command.send(:each_project) { expect(command.send(:build_datafile, nil)).to eq({features: {}}) } + end + + it "delegates explicit positive inflation" do + options.inflate = 1 + expect(command).to receive(:project_json).with("build", "--json", "--inflate=1").and_return({"features" => {}}) + command.send(:build_datafile, nil) + end + + it "validates missing, unknown and flat environment selections" do + allow(command).to receive(:project_json).and_return({"environments" => ["production"]}) + expect { command.send(:each_project) {} }.to raise_error(ArgumentError, /--environment/) + options.environment = "unknown" + expect { command.send(:each_project) {} }.to raise_error(ArgumentError, /Unknown environment/) + allow(command).to receive(:project_json).and_return({"environments" => false}) + expect { command.send(:each_project) {} }.to raise_error(ArgumentError, /Omit --environment/) + end + + it "delegates the set and target as separate subprocess arguments" do + options.set = "blue" + options.environment = "production" + status = double(success?: true) + expect(Open3).to receive(:capture3).with("npx", "featurevisor", "config", "--json", chdir: options.project_directory_path).and_return(['{"environments":["production"]}', "", status]) + expect(Open3).to receive(:capture3).with("npx", "featurevisor", "build", "--json", "--environment=production", "--target=web", "--set=blue", chdir: options.project_directory_path).and_return(['{"features":{}}', "", status]) + command.send(:each_project) { command.send(:build_datafile, options.environment, "web") } + end + + it "forwards the set while resolving targets and propagates Node failures" do + options.set = "blue" + options.targets = ["web"] + expect(Open3).to receive(:capture3).with("npx", "featurevisor", "list", "--targets", "--json", "--set=blue", chdir: options.project_directory_path).and_return(['["web"]', "", double(success?: true)]) + expect(command.send(:resolve_targets)).to eq(["web"]) + allow(Open3).to receive(:capture3).and_return(["", "café build failed", double(success?: false)]) + expect { command.send(:build_datafile, nil) }.to raise_error(ArgumentError, /café build failed/) + end + end + end + + it "returns a failing process status for invalid runtime options" do + expect { FeaturevisorCLI.run(["benchmark", "--feature=ready", "--n=0"]) }.to raise_error(SystemExit) { |error| expect(error.status).to eq(1) } + end +end diff --git a/spec/sdk_diagnostics_spec.rb b/spec/sdk_diagnostics_spec.rb new file mode 100644 index 0000000..ea29a24 --- /dev/null +++ b/spec/sdk_diagnostics_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "json" +require "featurevisor" + +RSpec.describe "shared SDK diagnostics" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-diagnostics.json", __dir__), encoding: "UTF-8"), symbolize_names: true) + + it "identifies the shared diagnostics contract" do + expect(fixture[:kind]).to eq("featurevisor.sdk-diagnostics") + expect(fixture[:version]).to eq(1) + expect(fixture[:cases]).not_to be_empty + end + + fixture[:cases].cycle(2).each_with_index do |test_case, index| + it "#{test_case[:name]} (run #{index / fixture[:cases].length + 1})" do + captured = [] + reentering = false + f = Featurevisor.create_featurevisor(log_level: "error", on_diagnostic: ->(diagnostic) { + next unless diagnostic[:code] == "conditions_parse_error" + captured << diagnostic.slice(:code, :level, :details).merge(revision: f.get_revision) + next if reentering || diagnostic[:details][:entityType] != "feature" + before = captured.length + begin + reentering = true + f.is_enabled(diagnostic[:details][:entityKey]) + ensure + reentering = false + end + expect(captured.length).to eq(before), "reentry must not report another diagnostic" + }) + test_case[:operations].each_with_index do |operation, index| + before = captured.length + result = case operation[:action] + when "setDatafile" then f.set_datafile(operation[:datafile], operation[:replace]) + when "isEnabled" then f.is_enabled(operation[:feature]) + else raise "Unknown diagnostics operation: #{operation[:action]}" + end + expect(result).to equal(operation[:expectedResult]), "operation #{index}: #{operation[:action]}" + expect(captured.drop(before)).to match_array(operation[:diagnostics]), "operation #{index}: #{operation[:action]} diagnostics" + end + ensure + f&.close + end + end +end diff --git a/spec/sdk_stateful_spec.rb b/spec/sdk_stateful_spec.rb new file mode 100644 index 0000000..1fa76e3 --- /dev/null +++ b/spec/sdk_stateful_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "json" +require "featurevisor" + +RSpec.describe "Featurevisor stateful conformance" do + def normalize_events(events) + events.map do |event| + details = event[:details].dup + %i[features variables].each do |key| + details[key] = details[key].map(&:to_s).sort if details.key?(key) + end + event.merge(details: details) + end + end + + it "runs every stateful fixture case and operation" do + fixture = JSON.parse( + File.read(File.expand_path("../conformance/sdk-stateful.json", __dir__), encoding: "UTF-8"), + symbolize_names: true + ) + expect(fixture[:kind]).to eq("featurevisor.sdk-stateful") + expect(fixture[:schemaVersion]).to eq(1) + expect(fixture[:cases].length).to eq(8) + + fixture[:cases].each do |test_case| + diagnostics = [] + events = [] + targets = { + "root" => Featurevisor.create_featurevisor( + datafile: test_case.dig(:initial, :datafile), + context: test_case.dig(:initial, :context) || {}, + log_level: "error", + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic } + ) + } + watch = lambda do |target_name| + %w[datafile_set sticky_features_set sticky_variables_set].each do |event_name| + targets.fetch(target_name).on(event_name, lambda do |details| + events << { target: target_name, name: event_name, details: details } + end) + end + end + watch.call("root") + + begin + test_case[:operations].each do |operation| + events.clear + diagnostics.clear + error = nil + begin + case operation[:action] + when "setDatafile" + targets.fetch(operation[:target]).set_datafile(operation[:datafile], operation[:replace]) + when "spawn" + targets[operation[:target]] = targets.fetch(operation[:parent]).spawn( + operation[:context] || {}, operation[:options] || {} + ) + watch.call(operation[:target]) + when "setStickyFeatures" + targets.fetch(operation[:target]).set_sticky_features( + operation[:stickyFeatures], operation[:replace] + ) + when "setStickyVariables" + targets.fetch(operation[:target]).set_sticky_variables( + operation[:stickyVariables], operation[:replace] + ) + else + raise "Unsupported fixture action: #{operation[:action]}" + end + rescue => caught + error = caught + end + + expected = operation[:expect] + expect(normalize_events(events)).to eq(normalize_events(expected[:events])), test_case[:name] + if expected[:error] + matching = diagnostics.any? { |diagnostic| diagnostic[:code] == expected.dig(:error, :code) } + expect(matching).to be(true), test_case[:name] + expect(error).to be_nil, test_case[:name] + else + expect(error).to be_nil, test_case[:name] + end + + expected[:results].each do |result| + target = targets.fetch(result[:target]) + actual = case result[:method] + when "getRevision" then target.get_revision + when "isEnabled" then target.is_enabled(*result[:args]) + when "getVariable" then target.get_variable(*result[:args]) + else raise "Unsupported fixture method: #{result[:method]}" + end + expect(actual).to eq(result[:value]), test_case[:name] + end + end + ensure + targets.reverse_each { |name, target| target.close unless name == "root" } + targets.fetch("root").close + end + end + end +end diff --git a/spec/segment_tests_spec.rb b/spec/segment_tests_spec.rb new file mode 100644 index 0000000..08c817d --- /dev/null +++ b/spec/segment_tests_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "featurevisor" +require_relative "../bin/cli" + +RSpec.describe "SDK backed CLI segment gates" do + def feature + { bucketBy: "userId", traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] } + end + + def datafile + { schemaVersion: "4", revision: "segment-tests", segments: {}, features: { flag: feature }, variables: {} } + end + + def command(coverage: false) + options = FeaturevisorCLI::Options.new + options.coverage = coverage + FeaturevisorCLI::Commands::Test.new(options) + end + + def reference!(segments, source = datafile) + FeaturevisorCLI::SegmentTests.references!("subject", source, segments) + end + + it "does not prepare or reinstall datafiles when no segment gates are needed" do + source = datafile + source[:variables][:policy] = { type: "string", defaultValue: "value" } + expect(FeaturevisorCLI::SegmentTests.prepare(source, {})).to equal(source) + runner = command + checks = [{ variable: "policy", operator: "equals", value: "value" }] + f, diagnostics = runner.send(:create_tester_instance, source, "error", {}) + expect(f.instance_variable_get(:@__segment_test_keys)).to eq({}) + expect(f).not_to receive(:set_datafile) + expect(runner.send(:run_test_variable, { expectedValue: "value", checks: checks }, "policy", f, source, diagnostics)[:has_error]).to be(false) + expect(runner.send(:run_test_scenario, { checks: checks }, f, source, diagnostics)[:has_error]).to be(false) + ensure + f&.close + end + + it "does not reinstall an ordinary SDK instance merely to create an empty matcher" do + f = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + expect(f).not_to receive(:set_datafile) + expect(command.send(:segment_matcher, f, {})).to respond_to(:call) + ensure + f&.close + end + + it "preserves selected Target definitions for owners and checks while restoring missing transitive segments" do + source = datafile + source[:segments][:subject] = { conditions: "restored" } + segments = { subject: { conditions: { feature: "flag", enabled: false } }, restored: { conditions: "*" } } + runner = command + result = runner.send(:run_test_segment, { expectedToMatch: true }, segments[:subject], "error", source, segments) + expect(result[:has_error]).to be(false) + prepared = FeaturevisorCLI::SegmentTests.prepare(source, segments) + f, diagnostics = runner.send(:create_tester_instance, prepared, "error", {}) + result = FeaturevisorCLI::TestChecks.evaluate_result(f, prepared, [{ segment: "subject", matches: true }], + owner_variable: nil, diagnostics: diagnostics, segments: segments, segment_matcher: runner.send(:segment_matcher, f)) + expect(result).to include(passed: true, error: false) + expect(source[:segments]).to eq(subject: { conditions: "restored" }) + expect(source[:features].keys).to eq([:flag]) + ensure + f&.close + end + + it "preflights preferred protocol definitions even when selected segments are specialized" do + source = datafile + source[:segments][:subject] = { conditions: "*" } + expect { reference!({ subject: { conditions: { feature: "missing" } } }, source) } + .to raise_error(FeaturevisorCLI::TestChecks::CheckError, /feature.*missing/) + end + + %i[root force traffic rule_override variation_override].each do |site| + it "preflights the reached feature #{site} closure without reading getters" do + source = datafile + source[:features][:flag][:variablesSchema] = { title: { type: "string", defaultValue: "ok" } } + target = source[:features][:flag] + case site + when :root then target[:conditions] = { feature: "missing" } + when :force then target[:force] = [{ conditions: { feature: "missing" }, enabled: true }] + when :traffic then target[:traffic][0][:conditions] = { feature: "missing" } + when :rule_override then target[:traffic][0][:variableOverrides] = { title: [{ conditions: { feature: "missing" }, value: "wrong" }] } + when :variation_override then target[:variations] = [{ value: "on", variableOverrides: { title: [{ conditions: { feature: "missing" }, value: "wrong" }] } }] + end + segments = { subject: { conditions: { feature: "flag", variable: "title", operator: "notNull" } } } + expect(Featurevisor).not_to receive(:create_featurevisor) + expect { reference!(segments, source) }.to raise_error(FeaturevisorCLI::TestChecks::CheckError, /feature.*missing/) + end + end + + %i[root override program alias map].each do |site| + it "preflights the reached global #{site} closure without reading getters" do + source = datafile + source[:variables][:policy] = { type: "object", defaultValue: {} } + target = source[:variables][:policy] + case site + when :root then target[:conditions] = { variable: "missing", operator: "isNull" } + when :override then target[:overrides] = [{ conditions: { variable: "missing", operator: "isNull" }, value: {} }] + when :program then target[:overrideProgram] = [{ conditions: { variable: "missing", operator: "isNull" }, operations: [] }] + when :alias then target[:compose] = "missing" + when :map then target[:compose] = [{ key: "property", variable: "missing" }] + end + expect(Featurevisor).not_to receive(:create_featurevisor) + expect { reference!({ subject: { conditions: { variable: "policy", operator: "notNull" } } }, source) } + .to raise_error(FeaturevisorCLI::TestChecks::CheckError, /variable.*missing/) + end + end + + it "visits each feature variable identity and terminates cyclic closure traversal" do + source = datafile + source[:features][:flag][:variablesSchema] = { first: { type: "string" }, second: { type: "string" } } + source[:features][:flag][:traffic][0][:variableOverrides] = { second: [{ conditions: "missing", value: "wrong" }] } + segments = { subject: { conditions: ["subject", { feature: "flag", variable: "first", operator: "isNull" }, + { feature: "flag", variable: "second", operator: "isNull" }] } } + expect { reference!(segments, source) }.to raise_error(FeaturevisorCLI::TestChecks::CheckError, /segment.*missing/) + source[:features][:flag][:traffic][0].delete(:variableOverrides) + expect { reference!(segments, source) }.not_to raise_error + end + + it "uses fixed bucketing and sticky values through ordinary SDK getters" do + source = datafile + source[:features][:flag][:traffic][0][:percentage] = 50_000 + segments = { subject: { conditions: { feature: "flag" } } } + runner = command + [[25, {}, true], [75, {}, false], [75, { flag: { enabled: true } }, true]].each do |at, sticky, expected| + result = runner.send(:run_test_segment, { at: at, stickyFeatures: sticky, expectedToMatch: expected }, segments[:subject], "error", source, segments) + expect(result[:has_error]).to be(false) + end + end + + it "binds child checks to child context and sticky state and excludes only actual helpers from coverage" do + source = datafile + collision = :__featurevisor_segment_test__subject + source[:features][collision] = feature + segments = { subject: { conditions: [{ feature: collision.to_s }, { variable: "policy", operator: "equals", value: "child" }] } } + source[:variables][:policy] = { type: "string", defaultValue: "parent" } + prepared = FeaturevisorCLI::SegmentTests.prepare(source, segments) + expect(prepared[:_segment_test_keys]["subject"]).to eq("#{collision}_") + assertion = { checks: [{ segment: "subject", matches: false }], children: [ + { context: { country: "nl" }, stickyVariables: { policy: "child" }, checks: [{ segment: "subject", matches: true }] }, + { stickyVariables: { policy: "child" }, stickyFeatures: { collision => { enabled: false } }, checks: [{ segment: "subject", matches: false }] } + ] } + runner = command(coverage: true) + f, diagnostics = runner.send(:create_tester_instance, prepared, "error", assertion) + expect(runner.send(:run_test_scenario, assertion, f, prepared, diagnostics, segments)[:has_error]).to be(false) + selections = runner.send(:coverage_report)[:selections] + expect(selections.map { |item| item[:featureKey] }).to include(collision.to_s) + expect(selections.map { |item| item[:featureKey] }).not_to include("#{collision}_") + ensure + f&.close + end + + it "allows unscoped attribute chains but rejects absent dependencies and malformed selectors" do + runner = command + segments = { subject: { conditions: "attributes" }, attributes: { conditions: { attribute: "country", operator: "equals", value: "nl" } } } + expect(runner.send(:run_test_segment, { context: { country: "nl" }, expectedToMatch: true }, segments[:subject], "error", nil, segments)[:has_error]).to be(false) + [{ conditions: { feature: "flag" } }, { conditions: "{oops" }, { conditions: { not: ["subject"] } }].each do |segment| + %w[error fatal].each do |level| + expect(runner.send(:run_test_segment, { expectedToMatch: false }, segment, level, nil, {})[:has_error]).to be(true) + end + end + end +end diff --git a/spec/semver_conformance_spec.rb b/spec/semver_conformance_spec.rb new file mode 100644 index 0000000..ba4242f --- /dev/null +++ b/spec/semver_conformance_spec.rb @@ -0,0 +1,74 @@ +require "featurevisor" +require "json" +require_relative "../bin/test_checks" + +RSpec.describe "Portable version comparisons" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/semver.json", __dir__), encoding: "UTF-8")) + it "identifies the complete contract" do + expect([fixture["kind"], fixture["version"], fixture["cases"].length]).to eq(["featurevisor.semver", 1, 44]) + end + fixture["cases"].each do |entry| + [false, true].each do |reverse| + it "#{entry['name']} #{reverse ? 'reverse' : 'forward'}" do + left, right = reverse ? [entry["right"], entry["left"]] : [entry["left"], entry["right"]] + if entry["order"].nil? + expect { Featurevisor.compare_versions(left, right) }.to raise_error(ArgumentError) + else + expect(Featurevisor.compare_versions(left, right)).to eq(reverse ? -entry["order"] : entry["order"]) + end + order = entry["order"] && (reverse ? -entry["order"] : entry["order"]) + { + "semverEquals" => order == 0, + "semverNotEquals" => !order.nil? && order != 0, + "semverGreaterThan" => !order.nil? && order > 0, + "semverGreaterThanOrEquals" => !order.nil? && order >= 0, + "semverLessThan" => !order.nil? && order < 0, + "semverLessThanOrEquals" => !order.nil? && order <= 0 + }.each do |operator, expected| + requirement = { variable: "source", operator: operator, value: right } + feature_requirement = { feature: "sourceFeature", variable: "version", operator: operator, value: right } + datafile = { + schemaVersion: "2", revision: "semver", segments: {}, + features: { + condition: { bucketBy: "userId", traffic: [], force: [{ + conditions: { attribute: "version", operator: operator, value: right }, enabled: true + }] }, + sourceFeature: { bucketBy: "userId", traffic: [], force: [{ segments: "*", enabled: true }], + variablesSchema: { version: { type: "string", defaultValue: left } } } + }, + variables: { + source: { type: "string", defaultValue: left }, + other: { type: "string", defaultValue: right }, + subject: { type: "boolean", defaultValue: true, disabledValue: false, conditions: [requirement] }, + negated: { type: "boolean", defaultValue: true, disabledValue: false, conditions: [{ not: [requirement] }] }, + featureSubject: { type: "boolean", defaultValue: true, disabledValue: false, conditions: [feature_requirement] }, + featureNegated: { type: "boolean", defaultValue: true, disabledValue: false, conditions: [{ not: [feature_requirement] }] } + } + } + sdk = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + begin + expect(sdk.is_enabled("condition", version: left)).to eq(expected), operator + { "subject" => expected, "negated" => !expected, "featureSubject" => expected, "featureNegated" => !expected }.each do |variable, value| + evaluation = sdk.evaluate_variable(variable) + if order.nil? + expect(evaluation[:reason]).to eq("error"), operator + else + expect(evaluation[:variable_value]).to eq(value), operator + end + end + check = { variable: "source", operator: operator, compareTo: { variable: "other" } } + [[check, expected], [{ not: [check] }, !expected]].each do |node, matched| + result = FeaturevisorCLI::TestChecks.evaluate_result( + sdk, datafile, [node], owner_variable: nil, diagnostics: [] + ) + expect(result[:error]).to eq(order.nil?), operator + expect(result[:passed]).to eq(!order.nil? && matched), operator + end + ensure + sdk.close + end + end + end + end + end +end diff --git a/spec/set_datafile_result_spec.rb b/spec/set_datafile_result_spec.rb new file mode 100644 index 0000000..41506ee --- /dev/null +++ b/spec/set_datafile_result_spec.rb @@ -0,0 +1,137 @@ +require "featurevisor" +require "json" + +RSpec.describe "set_datafile acceptance result" do + def datafile(key = "original", revision = "1") + { + schemaVersion: "2", revision: revision, + segments: { key => { conditions: "*" } }, + features: { key => { hash: key, bucketBy: "userId", traffic: [ + { key: "all", segments: "*", percentage: 100_000 } + ] } }, + variables: { key => { hash: key, type: "string", defaultValue: key } } + } + end + + it "accepts empty datafiles and changed content with the same revision" do + sdk = Featurevisor.create_featurevisor(log_level: "fatal") + empty = { schemaVersion: "2", revision: "1", segments: {}, features: {} } + expect(sdk.set_datafile(empty)).to be(true) + expect(sdk.set_datafile(JSON.generate(empty))).to be(true) + expect(sdk.set_datafile(datafile, true)).to be(true) + expect(sdk.is_enabled("original")).to be(true) + expect(sdk.set_datafile(empty, true)).to be(true) + expect(sdk.get_feature_keys).to eq([]) + expect(sdk.get_variable_keys).to eq([]) + expect(sdk.get_revision).to eq("1") + ensure + sdk&.close + end + + [false, true].each do |json| + [false, true].each do |replace| + it "accepts content and unchanged updates with json=#{json}, replace=#{replace}" do + sdk = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + child = sdk.spawn + events = [] + sdk.on("datafile_set", ->(event) { events << event }) + incoming = datafile("next", "2") + original = Marshal.load(Marshal.dump(incoming)) + input = json ? JSON.generate(incoming) : incoming + expect(replace ? sdk.set_datafile(input, true) : sdk.set_datafile(input)).to be(true) + expect(incoming).to eq(original) + expect(sdk.get_revision).to eq("2") + expect(sdk.get_feature_keys).to eq(replace ? [:next] : [:original, :next]) + expect(sdk.get_variable_keys).to eq(replace ? [:next] : [:original, :next]) + expect(sdk.get_segment("original").nil?).to eq(replace) + expect(child.is_enabled("next")).to be(true) + expect(child.get_variable("next")).to eq("next") + expect(events).to eq([{ + revision: "2", previousRevision: "1", revisionChanged: true, + features: replace ? [:original, :next] : [:next], + variables: replace ? [:original, :next] : [:next], replaced: replace + }]) + expect(sdk.set_datafile(input, replace)).to be(true) + expect(events.length).to eq(2) + expect(events.last).to eq({ + revision: "2", previousRevision: "2", revisionChanged: false, + features: [], variables: [], replaced: replace + }) + ensure + child&.close + sdk&.close + end + end + end + + ["info", "fatal"].each do |level| + it "preserves state and reports each rejection once at #{level}" do + diagnostics, events, errors = [], [], [] + sdk = Featurevisor.create_featurevisor(datafile: datafile, log_level: level, + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic }) + child = sdk.spawn + diagnostics.clear + sdk.on("datafile_set", ->(event) { events << event }) + sdk.on("error", ->(event) { errors << event }) + invalid = ["{", "null", "[]", nil, false, 1, [], {}, + datafile.merge(schemaVersion: 2), datafile.merge(revision: 2), + datafile.merge(segments: []), datafile.merge(features: []), datafile.merge(variables: [])] + invalid.each do |input| + [false, true].each do |replace| + expect(sdk.set_datafile(input, replace)).to be(false) + expect(sdk.get_revision).to eq("1") + expect(sdk.get_feature_keys).to eq([:original]) + expect(sdk.get_segment("original")).not_to be_nil + expect(child.is_enabled("original")).to be(true) + expect(child.get_variable("original")).to eq("original") + expect(errors.last[:diagnostic]).to include(code: "invalid_datafile", message: "Could not parse datafile") + expect(errors.last[:diagnostic][:originalError]).to be_a(StandardError) + end + end + expect(events).to eq([]) + expect(errors.length).to eq(invalid.length * 2) + expect(diagnostics.length).to eq(level == "info" ? invalid.length * 2 : 0) + expect(sdk.set_datafile(datafile, true)).to be(true) + ensure + child&.close + sdk&.close + end + end + + it "returns false after close without diagnostics or parsing" do + diagnostics = [] + sdk = Featurevisor.create_featurevisor(datafile: datafile, on_diagnostic: ->(diagnostic) { diagnostics << diagnostic }) + sdk.close + diagnostics.clear + expect(sdk.set_datafile(datafile("next", "2"))).to be(false) + expect(sdk.set_datafile("{", true)).to be(false) + expect(sdk.get_revision).to eq("1") + expect(diagnostics).to eq([]) + end + + it "isolates throwing callbacks without changing acceptance or rejection" do + diagnostics, events, errors = [], [], [] + fail_callback = ->(_value) { raise "callback failed" } + sdk = Featurevisor.create_featurevisor( + on_diagnostic: ->(diagnostic) { + diagnostics << diagnostic[:code] + raise "callback failed" + }, + modules: [{ name: "throwing", setup: ->(api) { api[:on_diagnostic].call(fail_callback) } }] + ) + diagnostics.clear + sdk.on("datafile_set", fail_callback) + sdk.on("datafile_set", ->(event) { events << event }) + sdk.on("error", fail_callback) + sdk.on("error", ->(event) { errors << event }) + expect(sdk.set_datafile(datafile)).to be(true) + expect(sdk.set_datafile(datafile)).to be(true) + expect(sdk.set_datafile("{")).to be(false) + expect(diagnostics).to eq(%w[datafile_set datafile_set invalid_datafile]) + expect(events.length).to eq(2) + expect(errors.length).to eq(1) + expect(sdk.get_revision).to eq("1") + ensure + sdk&.close + end +end diff --git a/spec/test_command_spec.rb b/spec/test_command_spec.rb index 08da624..34bab21 100644 --- a/spec/test_command_spec.rb +++ b/spec/test_command_spec.rb @@ -1,5 +1,6 @@ require "featurevisor" require "stringio" +require "tmpdir" $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "bin")) require "cli" @@ -12,12 +13,35 @@ end describe "datafile routing helpers" do + it "keeps every built base and target separate for delimiter and Unicode names" do + command = described_class.new(options) + environments = [false, "false", "prod", "prod-target-web", "prod-target-a", "café\"\\\n東京"] + targets = ["web", "a-target-b", "b", "web\"\\\n東京"] + allow(command).to receive(:build_single_datafile) do |environment:, target: nil, **_options| + { route: [environment == false ? nil : environment, target] } + end + cache = command.send(:build_datafiles, environments, targets, nil) + expect(cache.length).to eq(environments.length * (targets.length + 1)) + environments.each do |environment| + [nil, *targets].each do |target| + assertion = { environment: environment, target: target } + expected = [environment == false ? nil : environment, target] + expect(command.send(:resolve_datafile_for_assertion, assertion, cache)[:route]).to eq(expected) + end + end + expect(command.send(:base_datafile_key, nil)).to eq([nil, nil]) + expect(command.send(:target_datafile_key, nil, "web")).to eq([nil, "web"]) + expect(command.send(:resolve_datafile_for_assertion, {}, cache)[:route]).to eq([nil, nil]) + expect(command.send(:resolve_datafile_for_assertion, { environment: nil, target: "web" }, cache)[:route]).to eq([nil, "web"]) + expect(command.send(:resolve_datafile_for_assertion, { environment: "prod", target: "missing" }, cache)).to be_nil + end + it "prefers target datafile over base datafile" do command = described_class.new(options) datafiles_by_key = { - "production" => { schemaVersion: "2" }, - "production-target-checkout" => { schemaVersion: "2", target: "checkout" } + ["production", nil] => { schemaVersion: "2" }, + ["production", "checkout"] => { schemaVersion: "2", target: "checkout" } } assertion = { @@ -31,19 +55,43 @@ it "does not fall back when a target datafile is missing" do command = described_class.new(options) - datafiles_by_key = { "production" => { schemaVersion: "2" } } + datafiles_by_key = { ["production", nil] => { schemaVersion: "2" } } assertion = { environment: "production", target: "checkout" } expect(command.send(:resolve_datafile_for_assertion, assertion, datafiles_by_key)).to be_nil end end + describe "Node CLI resolution" do + it "runs the nearest local CLI entry with node, then falls back to npx" do + Dir.mktmpdir do |directory| + root = File.realpath(directory) + project = File.join(root, "packages", "flags") + FileUtils.mkdir_p(project) + expect(FeaturevisorCLI::NodeCli.command(project)).to eq(["npx", "featurevisor"]) + entry = File.join(root, "node_modules", "@featurevisor", "cli", "bin.js") + FileUtils.mkdir_p(File.dirname(entry)) + File.write(entry, "") + expect(FeaturevisorCLI::NodeCli.command(project)).to eq(["node", entry]) + end + end + + it "passes values with quotes and spaces without shell quoting" do + command = described_class.new(options) + command.instance_variable_set(:@active_set, "web team's set") + expect(command).to receive(:execute_command) + .with(satisfy { |cmd| cmd.last(3) == ["--json", "--target=it's checkout", "--set=web team's set"] }) + .and_return('{"schemaVersion":"2","revision":"1","segments":{},"features":{}}') + command.send(:build_single_datafile, environment: false, inflate: nil, target: "it's checkout") + end + end + describe "build command generation" do it "includes target and environment flags when building target datafiles" do command = described_class.new(options) expect(command).to receive(:execute_command) - .with(include("featurevisor build", "--environment=production", "--target=checkout", "--json")) + .with(satisfy { |cmd| cmd.last(4) == ["build", "--json", "--environment=production", "--target=checkout"] }) .and_return('{"schemaVersion":"2","revision":"1","segments":{},"features":{}}') datafile = command.send( @@ -60,7 +108,7 @@ command = described_class.new(options) expect(command).to receive(:execute_command) - .with(satisfy { |cmd| cmd.include?("featurevisor build") && !cmd.include?("--environment=") }) + .with(satisfy { |cmd| cmd.include?("build") && cmd.none? { |part| part.start_with?("--environment=") } }) .and_return('{"schemaVersion":"2","revision":"1","segments":{},"features":{}}') datafile = command.send( @@ -73,7 +121,728 @@ end end + describe "test execution protocol" do + it "requires the versioned execution envelope" do + command = described_class.new(options) + valid = { + kind: "featurevisor.test-execution", + schemaVersion: 1, + tests: [{ scenario: "ready", assertions: [] }] + } + malformed = [ + [], {}, valid.merge(kind: "other"), valid.merge(schemaVersion: true), + valid.merge(schemaVersion: "1"), valid.merge(schemaVersion: 2), + valid.merge(tests: nil), valid.merge(tests: [nil]) + ] + malformed.each do |payload| + allow(command).to receive(:execute_command).and_return(JSON.generate(payload)) + expect { command.send(:get_tests, nil) } + .to raise_error(FeaturevisorCLI::Commands::Test::InfrastructureError) + end + + expect(command).to receive(:execute_command) + .with(satisfy { |cmd| cmd.last(5) == ["list", "--tests", "--execution-protocol", "--json", "--keyPattern=ready"] }) + .and_return(JSON.generate(valid)) + options.key_pattern = "ready" + expect(command.send(:get_tests, nil)).to eq(valid[:tests]) + end + end + describe "test execution behavior" do + it "executes every shared check fixture case with a fresh native instance" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/test-checks.json", __dir__), encoding: "UTF-8"), symbolize_names: true) + expect(fixture[:version]).to eq(3) + expect(fixture[:cases].length).to eq(255) + expect(fixture[:datafile]).to be_a(Hash) + + executed_any = 0 + fixture[:cases].each do |item| + expect(item[:name]).to be_a(String) + expect(item).to include(:checks, :passed) + diagnostics = [] + instance = Featurevisor.create_featurevisor( + datafile: fixture[:datafile], log_level: "fatal", + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic } + ) + begin + result = FeaturevisorCLI::TestChecks.evaluate_result( + instance, fixture[:datafile], item[:checks], + owner_variable: item[:owner], diagnostics: diagnostics + ) + ensure + instance.close + end + preflight_error = [ + "or skips unreached missing feature", "and skips unreached missing feature", + "short circuit skips unresolved ref", "AND short circuit" + ].include?(item[:name]) + expect(result[:passed]).to eq(preflight_error ? false : item[:passed]), "#{item[:name]}: #{result[:failures].join("; ")}" + expect(result[:error]).to eq(preflight_error ? true : item.fetch(:error, false)), "#{item[:name]}: #{result[:failures].join("; ")}" + executed_any += 1 if JSON.generate(item[:checks]).match?(/includesAny|notIncludesAny/) + end + expect(executed_any).to eq(36) + end + + it "turns malformed JSON getter diagnostics into errors at quiet log levels" do + datafile = { + schemaVersion: "2", revision: "bad-json", segments: {}, features: {}, + variables: { broken: { type: "json", defaultValue: "[" } } + } + diagnostics = [] + instance = Featurevisor.create_featurevisor( + datafile: datafile, log_level: "error", + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic } + ) + result = FeaturevisorCLI::TestChecks.evaluate_result( + instance, datafile, [{ variable: "broken", operator: "isNull" }], + owner_variable: nil, diagnostics: diagnostics + ) + expect(result[:passed]).to be(false) + expect(result[:error]).to be(true) + expect(result[:failures].first).to include("variable getter reported") + ensure + instance&.close + end + + it "treats feature getter exceptions and diagnostics as check errors" do + datafile = { features: { experiment: {} } } + calls = [] + evaluator = double("feature evaluator") + allow(evaluator).to receive(:is_enabled) do |feature, _context| + calls << "enabled:#{feature}" + false + end + allow(evaluator).to receive(:get_variation) do |feature, _context| + calls << "variation:#{feature}" + raise "variation exploded" + end + + result = FeaturevisorCLI::TestChecks.evaluate_result( + evaluator, datafile, [{ feature: "experiment", enabled: true, variation: "treatment" }], + owner_variable: nil, diagnostics: [] + ) + expect(result[:error]).to be(true) + expect(calls).to eq(%w[enabled:experiment variation:experiment]) + expect(result[:failures].first).to include("variation getter failed") + expect(result[:failures].first).to include('feature "experiment"') + + diagnostics = [] + diagnostic_evaluator = double("diagnostic feature evaluator") + allow(diagnostic_evaluator).to receive(:is_enabled) do + diagnostics << { level: "error", code: "evaluation_error", message: "flag failed" } + false + end + result = FeaturevisorCLI::TestChecks.evaluate_result( + diagnostic_evaluator, datafile, [{ feature: "experiment", enabled: false }], + owner_variable: nil, diagnostics: diagnostics + ) + expect(result[:error]).to be(true) + expect(result[:failures].first).to include("enabled getter reported evaluation_error") + expect(result[:failures].first).to include('feature "experiment"') + + mismatching = double("mismatching feature evaluator", is_enabled: false, get_variation: "treatment") + result = FeaturevisorCLI::TestChecks.evaluate_result( + mismatching, datafile, [{ feature: "experiment", enabled: true, variation: "control" }], + owner_variable: nil, diagnostics: [] + ) + expect(result[:passed]).to be(false) + expect(result[:error]).to be(false) + expect(result[:failures].first).to include('feature "experiment"') + expect(result[:failures].first).to include("expected enabled=true, actual enabled=false") + expect(result[:failures].first).to include('expected variation="control", actual variation="treatment"') + end + + it "validates the complete check syntax before running getters" do + evaluator = double("tracking evaluator") + expect(evaluator).not_to receive(:is_enabled) + + result = FeaturevisorCLI::TestChecks.evaluate_result( + evaluator, + { features: { checkout: {} } }, + [ + { feature: "checkout", enabled: true }, + { feature: "checkout", enabled: "true" } + ], + owner_variable: nil, + diagnostics: [] + ) + expect(result[:error]).to be(true) + end + + it "evaluates segment checks from authored definitions with nested and merged contexts" do + 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: "*" } + } + instance = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + matcher = described_class.new(options).send(:segment_matcher, instance, segments) + checks = [{ and: [ + { segment: "adult", matches: true }, + { or: [{ segment: "netherlands", matches: true }, { segment: "everyone", matches: false }] }, + { not: [{ segment: "not_netherlands", matches: true }] } + ] }] + result = FeaturevisorCLI::TestChecks.evaluate_result( + instance, datafile, checks, owner_variable: "unused", diagnostics: [], + segments: segments, context: { country: "nl", age: 20 }, segment_matcher: matcher + ) + expect(result[:passed]).to be(true), result[:failures].join("; ") + + child = FeaturevisorCLI::TestChecks.evaluate_result( + instance, datafile, [{ segment: "adult", matches: false }], + owner_variable: nil, diagnostics: [], segments: segments, + context: { country: "nl", age: 20 }.merge(age: 17), segment_matcher: matcher + ) + expect(child[:passed]).to be(true), child[:failures].join("; ") + ensure + instance&.close + end + + it "rejects malformed segment checks in skipped groups and missing definitions" do + datafile = { schemaVersion: "2", revision: "target", segments: {}, features: {}, variables: {} } + segments = { everyone: { conditions: "*" } } + instance = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + matcher = described_class.new(options).send(:segment_matcher, instance, segments) + [ + { matches: true }, + { segment: "", matches: true }, + { segment: "everyone" }, + { segment: "everyone", matches: "true" }, + { segment: "everyone", matches: true, operator: "equals" } + ].each do |leaf| + result = FeaturevisorCLI::TestChecks.evaluate_result( + instance, datafile, [leaf], owner_variable: nil, diagnostics: [], + segments: segments, context: {}, segment_matcher: matcher + ) + expect(result[:error]).to be(true) + expect(result[:failures].first).to include("segment") + end + + skipped = FeaturevisorCLI::TestChecks.evaluate_result( + instance, datafile, + [{ or: [{ segment: "everyone", matches: true }, { segment: "everyone", matches: "yes" }] }], + owner_variable: nil, diagnostics: [], segments: segments, context: {}, segment_matcher: matcher + ) + expect(skipped[:error]).to be(true) + + missing = FeaturevisorCLI::TestChecks.evaluate_result( + instance, datafile, [{ segment: "missing", matches: false }], + owner_variable: nil, diagnostics: [], segments: segments, context: {}, segment_matcher: matcher + ) + expect(missing[:error]).to be(true) + expect(missing[:failures].first).to include('segment "missing"') + ensure + instance&.close + end + + it "records typed evidence for a failed segment check" do + datafile = { schemaVersion: "2", revision: "target", segments: {}, features: {}, variables: {} } + segments = { adult: { conditions: { attribute: "age", operator: "greaterThanOrEquals", value: 18 } } } + instance = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + matcher = described_class.new(options).send(:segment_matcher, instance, segments) + result = FeaturevisorCLI::TestChecks.evaluate_result( + instance, datafile, [{ key: "adult-check", segment: "adult", matches: false }], + owner_variable: nil, diagnostics: [], + segments: { adult: { conditions: { attribute: "age", operator: "greaterThanOrEquals", value: 18 } } }, + context: { age: 20 }, segment_matcher: matcher + ) + expect(result[:passed]).to be(false) + expect(result[:failures].first).to include('segment "adult"') + expect(result.dig(:errors, 0, :details, :evaluated, 0)).to eq( + path: [0], key: "adult-check", reference: { segment: "adult" }, property: "matches", + operator: "equals", expected: false, actual: true + ) + ensure + instance&.close + end + + it "passes root, child and global variable contexts to segment checks" do + command = described_class.new(options) + 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 } + ] } + } + diagnostics = [] + scenario = { + context: { country: "nl", age: 30 }, + checks: [{ segment: "netherlands", matches: true }], + children: [{ context: { age: 17 }, checks: [{ segment: "young_dutch", matches: true }] }] + } + instance = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + scenario_result = command.send(:run_test_scenario, scenario, instance, datafile, diagnostics, segments) + expect(scenario_result[:has_error]).to be(false), scenario_result[:errors].inspect + instance.close + + variable_assertion = { + context: { country: "nl" }, + checks: [{ segment: "netherlands", matches: true }] + } + instance = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + variable_result = command.send( + :run_test_variable, variable_assertion, "message", instance, datafile, diagnostics, segments + ) + expect(variable_result[:has_error]).to be(false), variable_result[:errors].inspect + ensure + instance&.close + end + + it "validates named check metadata and preserves operand evidence" do + evaluator = double("named evaluator", get_variable: "actual") + datafile = { variables: { message: { type: "string", defaultValue: "actual" } } } + checks = [{ key: "message-ready", description: "Message is ready", variable: "message", operator: "equals", value: "expected" }] + + result = FeaturevisorCLI::TestChecks.evaluate_result( + evaluator, datafile, checks, owner_variable: nil, diagnostics: [] + ) + expect(result[:passed]).to be(false) + expect(result[:errors].first[:message]).to include('key="message-ready"', 'description="Message is ready"') + expect(result[:errors].first[:message]).to include('left="actual"', 'right="expected"') + expect(result[:errors].first).to include(type: "check", expected: true, actual: false) + expect(result[:errors].first[:details]).to include( + checkIndex: 0, + checkKey: "message-ready", + checkDescription: "Message is ready" + ) + expect(result.dig(:errors, 0, :details, :evaluated, 0)).to include( + path: [0], + reference: { variable: "message" }, + operator: "equals", + actual: "actual", + expected: "expected" + ) + + duplicate = FeaturevisorCLI::TestChecks.evaluate_result( + evaluator, + datafile, + [ + { key: "same", variable: "message", operator: "exists" }, + { key: "same", variable: "message", operator: "exists" } + ], + owner_variable: nil, + diagnostics: [] + ) + expect(duplicate[:error]).to be(true) + expect(duplicate[:failures].first).to include('duplicate sibling key "same"') + end + + it "preflights skipped references, comparison operands and children before getters" do + calls = 0 + evaluator = double("tracking evaluator") + allow(evaluator).to receive(:get_variable) { calls += 1; "ready" } + allow(evaluator).to receive(:is_enabled) { calls += 1; true } + allow(evaluator).to receive(:get_variation) { calls += 1; "control" } + datafile = { + features: { checkout: { variablesSchema: { message: { type: "string" } } } }, + variables: { message: { type: "string", defaultValue: "ready" } } + } + invalid = [ + [{ or: [{ variable: "message", operator: "exists" }, { feature: "missing", enabled: true }] }], + [{ and: [{ feature: "checkout", enabled: true }, { variable: "missing", operator: "exists" }] }], + [{ not: [{ feature: "checkout", variable: "missing", operator: "exists" }] }], + [{ variable: "message", operator: "equals", compareTo: { variable: "missing" } }], + [{ segment: "missing", matches: false }], + [{ or: [{ variable: "message", operator: "exists" }, { variable: "message", operator: "unknown", value: "x" }] }] + ] + invalid.each do |checks| + result = FeaturevisorCLI::TestChecks.evaluate_result( + evaluator, datafile, checks, owner_variable: nil, diagnostics: [], + segments: { known: { conditions: "*" } } + ) + expect(result[:error]).to be(true), checks.inspect + expect(calls).to eq(0) + end + + expect do + FeaturevisorCLI::TestChecks.preflight!( + datafile, [{ segment: "known", matches: true }], owner_variable: nil, + segments: { known: { conditions: { attribute: "never-called" } } } + ) + end.not_to raise_error + + command = described_class.new(options) + expect do + command.send(:preflight_assertion!, { variable: "missing" }, { checks: [{ operator: "isNull" }] }, datafile, {}) + end.to raise_error(FeaturevisorCLI::TestChecks::CheckError) + expect do + command.send( + :preflight_assertion!, + { scenario: "child" }, + { + checks: [{ variable: "message", operator: "exists" }], + children: [{ checks: [{ variable: "missing", operator: "exists" }] }] + }, + datafile, + {} + ) + end.to raise_error(FeaturevisorCLI::TestChecks::CheckError) + expect(calls).to eq(0) + end + + it "records comparison references, typed operands and missing values without parsing labels" do + evaluator = double("evidence evaluator") + allow(evaluator).to receive(:get_variable) do |variable, _context| + { "minimum" => 12, "maximum" => 10, "nothing" => nil }.fetch(variable) + end + datafile = { + variables: { + minimum: { type: "integer", defaultValue: 12 }, + maximum: { type: "integer", defaultValue: 10 }, + nothing: { type: "object", defaultValue: nil } + } + } + checks = [{ + key: "bounds", and: [ + { key: "checks[1]", variable: "minimum", operator: "lessThan", compareTo: { variable: "maximum" } } + ] + }] + + result = FeaturevisorCLI::TestChecks.evaluate_result( + evaluator, datafile, checks, owner_variable: nil, diagnostics: [], child_index: 2 + ) + error = result[:errors].first + expect(error).to include(type: "check", expected: true, actual: false) + expect(error[:details]).to include(checkIndex: 0, checkKey: "bounds", childIndex: 2) + expect(error.dig(:details, :evaluated, 0)).to include( + path: [0, "and", 0], key: "checks[1]", + reference: { variable: "minimum" }, compareTo: { variable: "maximum" }, + operator: "lessThan", actual: 12, expected: 10 + ) + + missing = FeaturevisorCLI::TestChecks.evaluate_result( + evaluator, + datafile, + [{ variable: "nothing", path: "absent", operator: "equals", value: nil }], + owner_variable: nil, + diagnostics: [] + ) + leaf = missing.dig(:errors, 0, :details, :evaluated, 0) + expect(leaf[:actual]).to eq("$type" => "undefined") + expect(leaf[:actualMissing]).to be(true) + expect(leaf[:expected]).to be_nil + expect(leaf).not_to have_key(:expectedMissing) + + nullable = FeaturevisorCLI::TestChecks.evaluate_result( + evaluator, + datafile, + [{ variable: "nothing", operator: "isNull" }], + owner_variable: nil, + diagnostics: [] + ) + expect(nullable[:passed]).to be(true) + end + + it "emits a clean versioned JSON report with exact reruns, coverage and uncovered entities" do + datafile = { + schemaVersion: "2", revision: "report", segments: {}, features: {}, + variables: { + message: { + type: "string", defaultValue: "default", + overrides: [{ key: "selected", keyPath: "selected", segments: "*", value: "actual" }] + } + } + } + tests = [{ + key: "tests/scenarios/checkout.spec.yml", scenario: "checkout", + _test: { source: "tests/scenarios/checkout.spec.yml", set: nil, entityType: "scenario" }, + assertions: [{ + description: "Checkout is ready", environment: "production", target: "checkout", context: {}, + checks: [{ key: "message", variable: "message", operator: "equals", value: "expected" }], + _case: { id: "opaque case", assertionIndex: 0, matrixIndex: nil, matrixValues: {} } + }] + }] + + Dir.mktmpdir do |directory| + npx = File.join(directory, "npx") + log = File.join(directory, "calls.log") + File.write(npx, <<~RUBY) + #!#{RbConfig.ruby} + require "json" + File.open(ENV.fetch("FAKE_NPX_LOG"), "a") { |file| file.puts(ARGV.join(" ")) } + args = ARGV.drop(1) + value = if args[0, 2] == ["config", "--json"] + { environments: ["production"] } + elsif args[0, 2] == ["list", "--targets"] + ["checkout"] + elsif args[0, 2] == ["list", "--segments"] + [] + elsif args[0, 2] == ["list", "--tests"] + { kind: "featurevisor.test-execution", schemaVersion: 1, tests: JSON.parse(ENV.fetch("FAKE_TESTS")) } + elsif args[0, 2] == ["list", "--features"] + [{ key: "untestedFeature" }] + elsif args[0, 2] == ["list", "--variables"] + [{ key: "untestedVariable" }] + elsif args.first == "build" + JSON.parse(ENV.fetch("FAKE_DATAFILE")) + else + abort "unexpected arguments: \#{args.inspect}" + end + puts JSON.generate(value) + RUBY + File.chmod(0o755, npx) + env = { + "PATH" => "#{directory}#{File::PATH_SEPARATOR}#{ENV.fetch("PATH")}", + "FAKE_NPX_LOG" => log, + "FAKE_DATAFILE" => JSON.generate(datafile), + "FAKE_TESTS" => JSON.generate(tests) + } + stdout, stderr, status = Open3.capture3( + env, RbConfig.ruby, "bin/featurevisor", "test", "--projectDirectoryPath=#{directory}", + "--json", "--pretty", "--case=opaque case", "--target=checkout", "--coverage", "--uncovered" + ) + expect(status.exitstatus).to eq(1), stdout + stderr + report = JSON.parse(stdout) + expect(report).to include("schemaVersion" => 1, "runner" => "ruby", "passed" => false) + expect(report.dig("summary", "scenarioCases")).to eq("passed" => 0, "failed" => 1) + expect(report.dig("results", 0, "cases", 0, "rerun", "args")).to eq( + ["test", "--case=opaque case", "--projectDirectoryPath=#{directory}", "--target=checkout"] + ) + check_error = report.dig("results", 0, "cases", 0, "errors", 0) + expect(check_error).to include( + "type" => "check", "message" => "Check did not match", + "expected" => true, "actual" => false, "path" => ["checks", 0] + ) + expect(check_error.dig("details", "checkKey")).to eq("message") + expect(check_error.dig("details", "evaluated", 0)).to include( + "path" => ["checks", 0], + "reference" => { "variable" => "message" }, + "operator" => "equals", "actual" => "actual", "expected" => "expected" + ) + expect(report.dig("coverage", "mode")).to eq("observed-selections") + expect(report.dig("coverage", "selections", 0)).to include( + "environment" => "production", "target" => "checkout", + "variableKey" => "message", "overridePath" => "selected" + ) + expect(report["uncovered"]).to eq([ + { "set" => nil, "features" => ["untestedFeature"], "variables" => ["untestedVariable"] } + ]) + expect(stderr).to include("Running tests...") + expect(File.read(log, encoding: "UTF-8")).to include( + "list --tests --execution-protocol --json --case=opaque case" + ) + end + end + + it "evaluates native checks with paths, references, feature variables and groups" do + datafile = { + schemaVersion: "2", revision: "checks", segments: {}, + features: { + account: { + bucketBy: "userId", + variablesSchema: { plan: { type: "string", defaultValue: "pro" } }, + force: [{ segments: "*", enabled: true }], traffic: [] + } + }, + variables: { + profile: { type: "object", defaultValue: { name: "Ada", :"a.b" => "literal" } }, + count: { type: "integer", defaultValue: 2 }, + numbers: { type: "array", defaultValue: [1, 2, 3] }, + nothing: { type: "json", defaultValue: nil }, + version: { type: "semver", defaultValue: "2.1.0" } + } + } + diagnostics = [] + instance = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal", on_diagnostic: ->(item) { diagnostics << item }) + checks = [ + { path: "name", operator: "equals", value: "Ada" }, + { path: ["a.b"], operator: "equals", value: "literal" }, + { variable: "numbers", path: "1", operator: "equals", compareTo: { variable: "count" } }, + { variable: "numbers", operator: "includes", value: 3 }, + { variable: "nothing", operator: "isNull" }, + { not: [{ variable: "profile", path: "missing", operator: "notNull" }] }, + { feature: "account", variable: "plan", operator: "equals", value: "pro" }, + { variable: "version", operator: "semverGreaterThan", value: "2.0.0" }, + { or: [ + { variable: "count", operator: "equals", value: 1 }, + { not: [{ variable: "count", operator: "lessThan", value: 2 }] } + ] } + ] + + expect(FeaturevisorCLI::TestChecks.evaluate(instance, datafile, checks, owner_variable: "profile", diagnostics: diagnostics)).to eq([]) + type_errors = FeaturevisorCLI::TestChecks.evaluate( + instance, datafile, [{ variable: "count", operator: "notContains", value: "2" }], + owner_variable: "profile", diagnostics: diagnostics + ) + expect(type_errors.first).to include("requires string operands") + missing_errors = FeaturevisorCLI::TestChecks.evaluate( + instance, datafile, [{ variable: "missing", operator: "notEquals", value: "anything" }], + owner_variable: "profile", diagnostics: diagnostics + ) + expect(missing_errors.first).to include("absent from the selected datafile") + ensure + instance&.close + end + + it "runs scenario children through the executable and preserves failure exits and filtering" do + 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" }] }] + } + ] + + Dir.mktmpdir do |directory| + npx = File.join(directory, "npx") + log = File.join(directory, "calls.log") + File.write(npx, <<~RUBY) + #!#{RbConfig.ruby} + require "json" + File.open(ENV.fetch("FAKE_NPX_LOG"), "a") { |file| file.puts(ARGV.join(" ")) } + args = ARGV.drop(1) + value = if args[0, 2] == ["config", "--json"] + { environments: false } + elsif args[0, 2] == ["list", "--targets"] || args[0, 2] == ["list", "--segments"] + [] + elsif args[0, 2] == ["list", "--tests"] + if ENV["FAKE_MODE"] == "protocol" + [] + else + fixture = ENV["FAKE_MODE"] == "pass" ? "FAKE_PASSING" : (ENV["FAKE_MODE"] == "malformed" ? "FAKE_MALFORMED" : "FAKE_FAILING") + { kind: "featurevisor.test-execution", schemaVersion: 1, tests: JSON.parse(ENV.fetch(fixture)) } + end + elsif args.first == "build" + JSON.parse(ENV.fetch("FAKE_DATAFILE")) + else + abort "unexpected arguments: \#{args.inspect}" + end + puts JSON.generate(value) + RUBY + File.chmod(0o755, npx) + env = { + "PATH" => "#{directory}#{File::PATH_SEPARATOR}#{ENV.fetch("PATH")}", + "FAKE_NPX_LOG" => log, + "FAKE_DATAFILE" => JSON.generate(datafile), + "FAKE_PASSING" => JSON.generate(passing), + "FAKE_FAILING" => JSON.generate(failing), + "FAKE_MALFORMED" => JSON.generate(malformed), + "FAKE_MODE" => "pass" + } + stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, "bin/featurevisor", "test", "--projectDirectoryPath=#{directory}", "--set=staging", "--entityType=scenario", "--onlyFailures") + expect(status).to be_success, stdout + stderr + expect(stdout).to include("Total cases: 1 passed, 0 failed") + expect(File.read(log, encoding: "UTF-8")).to include("--entityType=scenario") + expect(File.read(log, encoding: "UTF-8")).to include("--set=staging") + + env["FAKE_MODE"] = "fail" + stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, "bin/featurevisor", "test", "--projectDirectoryPath=#{directory}", "--set=staging", "--entityType=scenario", "--onlyFailures") + expect(status.exitstatus).to eq(1), stdout + stderr + expect(stdout).to include("checks[1]", 'left="parent"') + + env["FAKE_MODE"] = "malformed" + stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, "bin/featurevisor", "test", "--projectDirectoryPath=#{directory}", "--set=staging", "--entityType=scenario", "--onlyFailures") + expect(status.exitstatus).to eq(1), stdout + stderr + expect(stdout).to include("scenario spec requires assertions") + expect(stdout).to include("requires exactly one of feature, variable, segment or scenario") + + File.write(log, "") + env["FAKE_MODE"] = "protocol" + stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, "bin/featurevisor", "test", "--projectDirectoryPath=#{directory}", "--set=staging", "--entityType=scenario", "--onlyFailures") + expect(status.exitstatus).to eq(1), stdout + stderr + expect((stdout + stderr).downcase).to include("test execution protocol") + calls = File.read(log, encoding: "UTF-8") + expect(calls).to include("list --tests --execution-protocol --json") + expect(calls).not_to include("featurevisor build") + end + end + + it "allows expectations for excluded owners while preflighting child checks before owner evaluation" do + command = described_class.new(options) + datafile = { schemaVersion: "2", revision: "target", features: {}, variables: {}, segments: {} } + datafiles = { ["production", "filtered"] => datafile } + routing = { environment: "production", target: "filtered" } + [ + [:feature, { expectedToBeEnabled: false }], + [:variable, { expectedEvaluation: { reason: "variable_not_found" } }] + ].each do |type, expectation| + result = command.send( + :execute_assertion, { type => "excluded" }, type, + routing.merge(expectation).merge(children: [expectation]), datafiles, {}, "fatal", nil + ) + expect(result[:has_error]).to be(false), result[:errors].inspect + end + + expect(command).not_to receive(:create_tester_instance) + result = command.send( + :execute_assertion, { variable: "excluded" }, :variable, + routing.merge( + expectedValue: nil, + children: [{ checks: [{ operator: "isNull" }] }] + ), + datafiles, {}, "fatal", nil + ) + expect(result[:has_error]).to be(true) + expect(result[:errors].first[:message]).to include('global variable "excluded"') + end + + it "retains structured malformed version errors from preflight and evaluated comparisons" do + command = described_class.new(options) + datafile = { + schemaVersion: "2", revision: "semver-errors", features: {}, segments: {}, + variables: { version: { type: "string", defaultValue: "" } } + } + datafiles = { [nil, nil] => datafile } + [ + [{ variable: "version", operator: "semverEquals", value: "" }, "valid checks"], + [{ variable: "version", operator: "semverEquals", value: "1.0.0" }, "successful comparison"], + [{ variable: "version", operator: "semverEquals", compareTo: { variable: "version" } }, "successful comparison"] + ].each do |check, expected| + [check, { not: [check] }].each do |node| + [:scenario, :variable].each do |type| + [false, true].each do |child| + expectation = { checks: [node] } + assertion = child ? { children: [expectation] } : expectation + assertion = assertion.merge(_case: { assertionIndex: 4 }) + result = command.send( + :execute_assertion, { type => "version" }, type, assertion, + datafiles, {}, "fatal", nil + ) + errors = command.send(:error_objects, result[:errors], type: type, assertion: assertion) + expect(result[:has_error]).to be(true) + expect(errors.length).to eq(1) + error = errors.first + path = type == :scenario ? [] : ["assertions", 4] + path += ["children", 0] if child + path += ["checks"] + path << 0 if expected == "successful comparison" + expect(error).to include(type: "check", expected: expected, path: path) + expect(error[:actual]).to eq(error[:message]) + expect(error[:actual]).to include("Invalid argument not valid semver") + end + end + end + end + end + it "evaluates expectedEvaluations in child assertions" do command = described_class.new(options) instance = double("child-instance") @@ -95,7 +864,53 @@ result = command.send(:run_test_feature_child, assertion, "myFeature", instance, "warn") expect(result[:has_error]).to be true - expect(result[:errors]).to include("expectedEvaluations.flag.enabled") + expect(result[:errors]).to include( + type: "evaluation", + expected: true, + actual: false, + details: { evaluationType: "flag", evaluationKey: :enabled } + ) + end + + it "reports typed expectation values and authored assertion and child paths" do + command = described_class.new(options) + assertion = { _case: { assertionIndex: 4 } } + errors = command.send( + :error_objects, + [ + { type: "flag", expected: true, actual: false, details: {} }, + { type: "variation", expected: "control", actual: nil, details: { childIndex: 2 } }, + { type: "variable", expected: 10, actual: 12, details: { variableKey: "limit" } } + ], + type: :feature, + assertion: assertion + ) + + expect(errors[0]).to include( + type: "flag", expected: true, actual: false, + path: ["assertions", 4, "expectedToBeEnabled"] + ) + expect(errors[1]).to include( + type: "variation", expected: "control", actual: nil, + path: ["assertions", 4, "children", 2, "expectedVariation"] + ) + expect(errors[2]).to include( + type: "variable", expected: 10, actual: 12, + path: ["assertions", 4, "expectedVariables", "limit"] + ) + end + + it "reports actual segment failures with typed values and an authored path" do + command = described_class.new(options) + assertion = { expectedToMatch: false, _case: { assertionIndex: 4 } } + result = command.send(:run_test_segment, assertion, { conditions: "*" }, "fatal") + errors = command.send(:error_objects, result[:errors], type: :segment, assertion: assertion) + expect(result[:has_error]).to be(true) + expect(errors.length).to eq(1) + expect(errors.first).to include( + type: "segment", expected: false, actual: true, + path: ["assertions", 4, "expectedToMatch"] + ) end it "counts missing-datafile assertion as failed" do @@ -117,20 +932,12 @@ } ] - output = StringIO.new - original_stdout = $stdout - $stdout = output - begin - expect do - command.send(:run_tests, tests, {}, {}, "warn", {}) - end.to raise_error(SystemExit) - ensure - $stdout = original_stdout - end - - expect(output.string).to include("no datafile found for assertion target/environment combination") - expect(output.string).to include("Test specs: 0 passed, 1 failed") - expect(output.string).to include("Assertions: 0 passed, 1 failed") + report = command.send(:run_tests, tests, {}, {}, "warn", {}, nil) + expect(report[:passed]).to be(false) + expect(report.dig(:summary, :entityAssertions, :failed)).to eq(1) + expect(report.dig(:results, 0, :cases, 0, :errors, 0, :message)).to include( + "no datafile found for assertion target/environment combination" + ) end end diff --git a/spec/v4_callback_state_spec.rb b/spec/v4_callback_state_spec.rb new file mode 100644 index 0000000..31590e8 --- /dev/null +++ b/spec/v4_callback_state_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require "featurevisor" + +RSpec.describe "v4 callback state changes" do + def flag(conditions = "*") + { bucketBy: [], conditions: conditions, + traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] } + end + + def datafile(segment) + { schemaVersion: "4", revision: segment, segments: { gate: { conditions: segment } }, + features: { subject: flag([{ variable: "mutator", operator: "notNull" }, "alias"]), + healthy: flag }, + variables: { mutator: { type: "boolean", defaultValue: true }, + subject: { type: "object", conditions: [{ variable: "mutator", operator: "notNull" }, "alias"], + compose: [{ key: "value", variable: "source" }] }, + source: { type: "string", defaultValue: "source" } } }.tap do |value| + value[:segments][:alias] = { conditions: "gate" } + end + end + + [false, true].each do |attribute_terminal| + [[0, 256], [0, 257], [0, 258], [1, 255], [1, 256], [1, 257], + [128, 128], [128, 129], [128, 130]].each do |getters, segments| + it "matches the JS depth bound for #{getters} getters and #{segments} segments, attribute terminal #{attribute_terminal}" do + source = datafile("*") + terminal = attribute_terminal ? { attribute: "country", operator: "equals", value: "nl" } : "*" + segments.times do |index| + source[:segments]["s#{index}".to_sym] = { conditions: index == segments - 1 ? terminal : "s#{index + 1}" } + end + (0..getters).each do |index| + source[:variables]["g#{index}".to_sym] = index == getters ? + { type: "boolean", conditions: "s0", defaultValue: true } : + { type: "boolean", compose: "g#{index + 1}" } + end + source[:features][:depth] = flag("s0") + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + failed = getters + segments - (attribute_terminal ? 1 : 0) > 256 + 2.times do + evaluation = f.evaluate_variable("g0", country: "nl") + expect(evaluation[:reason]).to eq(failed ? "error" : getters.zero? ? "variable_default" : "variable_composed") + expect(f.get_variable("g0", country: "nl")).to eq(failed ? nil : true) + if getters.zero? + expect(f.evaluate_flag("depth", country: "nl")[:reason]).to eq(failed ? "error" : "rule") + expect(f.is_enabled("depth", country: "nl")).to eq(!failed) + end + expect(f.is_enabled("healthy")).to be(true) + end + ensure + f&.close + end + end + end + + [nil, "[", {}, false, 1, [], [nil]].each do |operand| + it "rejects nonarray NOT #{operand.inspect} without rejecting the valid null child" do + source = datafile("*") + expression = { not: operand } + source[:segments][:operand] = { conditions: expression } + source[:features][:direct] = flag(expression) + source[:features][:segment] = flag("operand") + source[:features][:encoded] = flag(JSON.generate(expression)) + errors = [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { errors << item }) + %w[direct segment encoded].each do |key| + 2.times { expect(f.is_enabled(key)).to eq(operand == [nil]) } + end + expect(errors.none? { |item| item[:code] == "conditions_parse_error" }).to be(true) + ensure + f&.close + end + end + + it "distinguishes explicit null conditions from an absent root gate" do + source = datafile("*") + source[:variables][:absent] = { type: "string", defaultValue: "ordinary" } + source[:variables][:null] = { type: "string", defaultValue: "ordinary", conditions: nil, disabledValue: "disabled" } + source[:features][:absent] = flag.tap { |value| value.delete(:conditions) } + source[:features][:null] = flag(nil) + f = Featurevisor.create_featurevisor(datafile: JSON.generate(source), log_level: "fatal") + expect(f.get_variable("absent")).to eq("ordinary") + expect(f.evaluate_variable("null")).to include(reason: "conditions_unmet", conditions: nil, variable_value: "disabled") + expect(f.is_enabled("absent")).to be(true) + expect(f.evaluate_flag("null")).to include(reason: "conditions_unmet", conditions: nil, enabled: false) + ensure + f&.close + end + + ["source", [{ key: "value", variable: "source" }]].each do |compose| + [:absent, nil, false, "[1]"].each do |disabled| + it "keeps gated #{compose.is_a?(String) ? 'alias' : 'map'} fallback #{disabled.inspect} ahead of caller and module defaults" do + source = datafile("*") + variable = { type: "json", compose: compose, conditions: nil, overrideProgram: [ + { key: "wrong", conditions: "*", operations: [{ op: "set", path: [], value: "override" }], onMatch: 1, onMiss: 1 } + ] } + variable[:disabledValue] = disabled unless disabled == :absent + source[:variables][:view] = variable + source[:variables][:source] = { type: "json", defaultValue: "{" } + reads, errors = [], [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { errors << item }, + modules: [{ before_evaluation: ->(options) { + reads << options[:variable_key] + options.merge(default_variable_value: "module") + } }]) + expected = disabled == :absent ? nil : disabled + expect(f.evaluate_variable("view", {}, default_variable_value: "caller")).to include( + reason: "conditions_unmet", conditions: nil, variable_value: expected) + expect(f.get_variable("view", {}, default_variable_value: "caller")).to eq(expected) + expect(reads).to eq(["view", "view"]) + expect(errors).to be_empty + ensure + f&.close + end + end + end + + it "notifies transitive dependants on missing source addition and removal before reentrant event reads" do + source = datafile("*") + source[:variables].delete(:source) + source[:variables].delete(:subject) + source[:variables][:alias] = { type: "object", compose: "source" } + source[:variables][:view] = { type: "object", compose: [ + { key: "enabled", feature: "subject" }, { key: "value", variable: "alias" } + ] } + source[:segments][:bridge] = { conditions: '{"variable":"alias","operator":"notNull"}' } + source[:features][:subject] = flag("bridge") + source[:features][:broken] = flag("[") + errors, events, values = [], [], [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { errors << item }) + f.on("datafile_set", ->(event) { events << event; values << f.get_variable("view") }) + expect(f.get_variable("view")).to eq(enabled: false, value: nil) + expect(f.set_datafile({ schemaVersion: "4", revision: "added", segments: {}, features: {}, + variables: { source: { hash: "source", type: "boolean", defaultValue: false } } })).to be(true) + expect(f.set_datafile(source, true)).to be(true) + expect(values).to eq([{ enabled: true, value: false }, { enabled: false, value: nil }]) + events.each do |event| + expect(event[:features]).to include(:subject) + expect(event[:variables]).to include(:source, :alias, :view) + end + expect(errors).to be_empty + ensure + f&.close + end + + %i[context sticky datafile module module_remove module_unsubscribe].each do |change| + it "invalidates borrowed source memos after #{change} changes without leaking outer defaults or decoding JSON twice" do + source = datafile("*") + source[:features][:disabled] = flag({ or: [] }).merge( + variablesSchema: { value: { type: "json", defaultValue: '"wrong"', disabledValue: false } }) + source[:variables][:source] = { type: "json", defaultValue: '"null"' } + source[:variables][:view] = { type: "object", compose: [ + { key: "first", variable: "source" }, { key: "mutator", variable: "mutator" }, + { key: "second", variable: "source" }, { key: "third", variable: "source" }, + { key: "missing", variable: "missing" }, { key: "flag", feature: "missing" }, + { key: "disabled", feature: "disabled", variable: "value" } + ] } + context = { payload: { encoded: '"null"' } } + reads = [] + f = nil + unsubscribe = nil + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal", modules: [{ + before_evaluation: ->(options) { + reads << options.slice(:dependency_of, :dependency_purpose) if options[:variable_key] == "source" + options + }, + after_evaluation: ->(result, options) { + if options[:variable_key] == "source" && change == :context + result = result.merge(variable_value: options[:context][:payload][:encoded]) + elsif options[:variable_key] == "mutator" + case change + when :context then options[:context][:payload][:encoded] = '"[1]"' + when :sticky then f.set_sticky_variables({ "source" => '"[1]"' }, true) + when :datafile + expect(f.set_datafile({ schemaVersion: "4", revision: "updated", segments: {}, features: {}, + variables: { source: { type: "json", defaultValue: '"[1]"' } } })).to be(true) + when :module + f.add_module(after_evaluation: ->(value, input) { + input[:variable_key] == "source" ? value.merge(variable_value: '"[1]"') : value + }) + when :module_remove then f.remove_module("source") + when :module_unsubscribe then unsubscribe.call + end + end + result + } + }]) + removing = %i[module_remove module_unsubscribe].include?(change) + if removing + unsubscribe = f.add_module(name: "source", after_evaluation: ->(result, options) { + options[:variable_key] == "source" ? result.merge(variable_value: '"[1]"') : result + }) + end + expect(f.get_variable("view", context, default_variable_value: "outer")).to eq( + first: removing ? "[1]" : "null", mutator: true, second: removing ? "null" : "[1]", + third: removing ? "null" : "[1]", missing: nil, flag: false, disabled: false) + expect(reads).to eq([{ dependency_of: { type: "variable", variable_key: "view" }, dependency_purpose: "composition" }] * 2) + expect(f.is_enabled("healthy")).to be(true) + ensure + f&.close + end + end + + %w[feature variable].each do |kind| + [false, true].each do |malformed| + it "uses current segments after a module replaces data during a #{kind} root, malformed #{malformed}" do + errors, reads = [], [] + f = nil + updated = false + replacement = datafile(malformed ? "[" : { or: [] }) + replacement[:revision] = "replacement" + f = Featurevisor.create_featurevisor(datafile: datafile("*"), log_level: "error", + on_diagnostic: ->(item) { errors << item }, modules: [{ + before_evaluation: ->(options) { reads << options.slice(:type, :variable_key, :feature_key, :dependency_of, :dependency_purpose); options }, + after_evaluation: ->(result, options) { + if options[:variable_key] == "mutator" && !updated + updated = true + expect(f.set_datafile(replacement, true)).to be(true) + expect(f.is_enabled("healthy")).to be(true) + end + result + } + }]) + evaluation = kind == "feature" ? f.evaluate_flag("subject") : f.evaluate_variable("subject") + expect(evaluation[:reason]).to eq("conditions_unmet") + expect(evaluation[:enabled]).to be(false) if kind == "feature" + expect(evaluation[:variable_value]).to be_nil if kind == "variable" + expect(reads.none? { |item| item[:variable_key] == "source" }).to be(true) + expect(reads.find { |item| item[:variable_key] == "mutator" }).to include( + dependency_of: kind == "feature" ? { type: "flag", feature_key: "subject" } : { type: "variable", variable_key: "subject" }, + dependency_purpose: "condition") + expect(errors.count { |item| item[:code] == "conditions_parse_error" }).to eq(malformed ? 1 : 0) + expect(f.is_enabled("healthy")).to be(true) + ensure + f&.close + end + end + end +end diff --git a/spec/v4_capabilities_spec.rb b/spec/v4_capabilities_spec.rb new file mode 100644 index 0000000..0a15b61 --- /dev/null +++ b/spec/v4_capabilities_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "json" +require "featurevisor" + +RSpec.describe "v4 conditions and composition capabilities" do + def portable(value) + JSON.parse(JSON.generate(value)) + end + + { "sdk-conditions.json" => [1, "unified-conditions"], + "sdk-composition.json" => [2, "composition-and-overrides"] }.each do |filename, (version, capability)| + it "executes all #{capability} cases twice", :aggregate_failures do + fixture = JSON.parse(File.read(File.expand_path("../conformance/#{filename}", __dir__)), symbolize_names: true) + expect(fixture[:version]).to eq(version) + expect(fixture[:capability]).to eq(capability) + expect(fixture[:cases]).not_to be_empty + expect(fixture[:cases].map { |test| test[:name] }.uniq.length).to eq(fixture[:cases].length) + fields = { ruleKey: :rule_key, variableOverridePaths: :variable_override_paths, variableOverridePath: :variable_override_path, + variableOverrideKey: :variable_override_key, variableOverrideIndex: :variable_override_index } + fixture[:cases].each do |test_case| + dependencies = [] + options = (test_case[:options] || {}).to_h do |key, value| + [{ stickyFeatures: :sticky_features, stickyVariables: :sticky_variables, logLevel: :log_level }.fetch(key, key), value] + end + options[:sticky_features] = test_case[:stickyFeatures] if test_case.key?(:stickyFeatures) + options[:sticky_variables] = test_case[:stickyVariables] if test_case.key?(:stickyVariables) + options[:modules] = [{ name: "trace", before_evaluation: lambda do |input| + if input[:dependency_purpose] + dependencies << { feature: input[:feature_key], variable: input[:variable_key], dependencyPurpose: input[:dependency_purpose] }.compact + end + input + end }] + datafile = test_case[:datafile] || fixture[:datafile] + source = portable(datafile) + f = Featurevisor.create_featurevisor(**options, datafile: datafile, log_level: "fatal") + begin + 2.times do |run| + context = test_case[:context] || {} + type = test_case[:type] || "variable" + value = type == "flag" ? f.is_enabled(test_case[:feature], context) : f.get_variable(test_case[:variable] || "subject", context) + expected = test_case[:expected] || test_case[:expectedEvaluation].merge(value: test_case[:expectedValue]) + expect(value).to eq(expected[:enabled] == true), "#{test_case[:name]} flag getter #{run}" if type == "flag" + expect(portable(value)).to eq(portable(expected[:value])), "#{test_case[:name]} getter #{run}" if expected.key?(:value) + dependencies.clear + evaluated = type == "flag" ? f.evaluate_flag(test_case[:feature], context) : f.evaluate_variable(test_case[:variable] || "subject", context) + expected.each do |key, wanted| + next if key == :value + expect(evaluated).to have_key(:conditions), test_case[:name] if key == :conditions + expect(portable(evaluated[fields.fetch(key, key)])).to eq(portable(wanted)), "#{test_case[:name]} #{key} #{run}" + end + expect(portable(dependencies)).to eq(portable(test_case[:dependencies])), test_case[:name] if test_case.key?(:dependencies) + if capability == "composition-and-overrides" + sticky_policy = test_case.fetch(:stickyVariables, {}) + policy = sticky_policy.key?(:policy) && sticky_policy[:policy].nil? ? nil : { limit: 50, methods: ["card"] } + expect(f.get_variable("policy")).to eq(policy), "#{test_case[:name]} source ownership #{run}" + definition = datafile[:variables][test_case[:variable].to_sym] + if definition[:compose].is_a?(Array) && !%w[error sticky].include?(expected[:reason]) + fresh = f.get_variable(test_case[:variable], context) + expect(fresh).to be_a(Hash) + expect(fresh).not_to equal(value), "#{test_case[:name]} outer ownership #{run}" + end + end + end + expect(portable(datafile)).to eq(source) + ensure + f.close + end + end + end + end +end diff --git a/spec/v4_cases_spec.rb b/spec/v4_cases_spec.rb new file mode 100644 index 0000000..7302d8e --- /dev/null +++ b/spec/v4_cases_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "json" +require "featurevisor" + +RSpec.describe "Featurevisor v4 portable conformance" do + fixture_path = File.expand_path("../conformance/sdk-v4-cases.json", __dir__) + fixture = JSON.parse(File.read(fixture_path, encoding: "UTF-8"), symbolize_names: true) + + def json_value(value) + JSON.parse(JSON.generate(value)) + end + + def instance_options(options) + keys = { + context: :context, + logLevel: :log_level, + stickyFeatures: :sticky_features, + stickyVariables: :sticky_variables + } + options.to_h { |key, value| [keys.fetch(key), value] } + end + + it "runs all 255 cases twice through the getter and detailed evaluation" do + expect(fixture[:version]).to eq(1) + expect(fixture[:cases].length).to eq(255) + + fixture[:cases].each do |test_case| + input = JSON.parse(JSON.generate(test_case[:datafile]), symbolize_names: true) + original_variables = JSON.generate(input[:variables]) + options = instance_options(test_case[:options] || {}) + sdk = Featurevisor.create_featurevisor({ log_level: "fatal", **options, datafile: input }) + context = test_case[:context] || {} + expected = test_case[:expected] + + 2.times do |run| + aggregate_failures "#{test_case[:name]} run #{run + 1}" do + expect(json_value(sdk.get_variable("subject", context))).to eq(json_value(expected[:value])) + + evaluation = sdk.evaluate_variable("subject", context) + fields = { + reason: :reason, + variableOverridePaths: :variable_override_paths, + variableOverrideKey: :variable_override_key, + variableOverrideIndex: :variable_override_index, + variableOverridePath: :variable_override_path + } + expected.each do |key, value| + next if key == :value + + expect(json_value(evaluation[fields.fetch(key)])).to eq(json_value(value)), key.to_s + end + end + end + expect(JSON.generate(input[:variables])).to eq(original_variables) + sdk.close + end + end +end diff --git a/spec/v4_hardening_spec.rb b/spec/v4_hardening_spec.rb new file mode 100644 index 0000000..681920d --- /dev/null +++ b/spec/v4_hardening_spec.rb @@ -0,0 +1,438 @@ +# frozen_string_literal: true + +require "featurevisor" +require "json" + +RSpec.describe "native v4 normalization, sessions and ownership" do + def datafile(version = "4") + { schemaVersion: version, revision: "accepted", segments: {}, + features: { healthy: feature }, variables: { healthy: { type: "string", defaultValue: "healthy" } } } + end + + def feature + { bucketBy: [], traffic: [{ key: "all", conditions: "*", percentage: 100_000 }] } + end + + def frozen_tree(value) + case value + when Hash then value.each { |key, child| key.freeze; frozen_tree(child) } + when Array then value.each { |child| frozen_tree(child) } + end + value.freeze + end + + %w[2 4].each do |version| + it "stores and caches before schema #{version} diagnostics and retains unrelated entity caches" do + errors = [] + f = Featurevisor.create_featurevisor(log_level: "error", on_diagnostic: ->(diagnostic) { + next unless diagnostic[:code] == "conditions_parse_error" + errors << diagnostic + expect(f.get_revision).to eq("accepted") + expect(f.evaluate_variable("broken")[:reason]).to eq("conditions_unmet") + f.set_context(callback: true) + }) + source = datafile(version) + source[:variables][:broken] = { type: "string", defaultValue: "wrong", disabledValue: "disabled", conditions: " \n {oops" } + frozen_tree(source) + expect(f.set_datafile(source, true)).to be(true) + expect(errors.length).to eq(version == "2" ? 1 : 0) + 2.times { expect(f.get_variable("broken")).to eq("disabled") } + expect(errors.length).to eq(1) + expect(errors.first[:level]).to eq("error") + expect(errors.first[:details]).to eq(conditions: " \n {oops", entityType: "variable", entityKey: "broken") + f.set_datafile(datafile) + f.get_variable("broken") + expect(errors.length).to eq(1) + f.set_datafile(source) + f.get_variable("broken") + expect(errors.length).to eq(2) + f.set_datafile(source, true) + f.get_variable("broken") + expect(errors.length).to eq(3) + ensure + f&.close + end + + %i[feature segment force traffic rule_override variation_override variable override program legacy_segments].each do |site| + ["{oops", '[{"feature":', '"unterminated', " \n {bad} "].each do |malformed| + it "fails only #{site} for schema #{version} selector #{malformed.inspect}" do + source = datafile(version) + subject = feature.merge(variablesSchema: { copy: { type: "string", defaultValue: "fallback" } }) + case site + when :feature then subject[:conditions] = malformed + when :segment + source[:segments][:broken] = { conditions: malformed } + subject[:conditions] = { not: ["broken"] } + when :force then subject[:force] = [{ conditions: malformed, enabled: true, variables: { copy: "wrong" } }] + when :traffic, :legacy_segments + selector = site == :traffic ? :conditions : :segments + subject[:traffic].unshift(key: "broken", selector => malformed, percentage: 100_000, variables: { copy: "wrong" }) + when :rule_override + subject[:traffic][0][:variableOverrides] = { copy: [{ conditions: malformed, value: "wrong" }] } + when :variation_override + subject[:variations] = [{ value: "on", variables: { copy: "fallback" }, variableOverrides: { copy: [{ conditions: malformed, value: "wrong" }] } }] + subject[:traffic][0][:allocation] = [{ variation: "on", range: [0, 100_000] }] + else + variable = { type: "string", defaultValue: "fallback", disabledValue: "fallback" } + if site == :variable + variable[:conditions] = malformed + elsif site == :override + variable[:overrides] = [{ conditions: malformed, value: "wrong" }] + else + variable[:overrideProgram] = [{ key: "broken", conditions: malformed, operations: [{ op: "set", path: [], value: "wrong" }], onMatch: 1, onMiss: 1 }] + end + source[:variables][:subject] = variable + end + source[:features][:subject] = subject + frozen_tree(source) + errors = [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { errors << item if item[:code] == "conditions_parse_error" }) + expect(errors.length).to eq(version == "2" ? 1 : 0) + 2.times do + if %i[feature segment].include?(site) + evaluation = f.evaluate_flag("subject") + expect(evaluation).to include(reason: "conditions_unmet", enabled: false) + expect(JSON.parse(JSON.generate(evaluation))["conditions"]).to eq("or" => []) if site == :feature + elsif source[:variables].key?(:subject) + expect(f.get_variable("subject")).to eq("fallback") + else + expect(f.get_variable("subject", "copy")).to eq("fallback") + end + end + expect(errors.length).to eq(1) + expect(f.is_enabled("healthy")).to be(true) + expect(f.get_variable("healthy")).to eq("healthy") + ensure + f&.close + end + end + end + end + + %w[2 3 4].each do |version| + [false, true].each do |empty| + it "keeps exact flattened mixed selector metadata for schema #{version}, empty #{empty}" do + conditions = empty ? [] : [{ attribute: "country", operator: "equals", value: "nl" }] + source = datafile(version) + source[:features][:subject] = feature.merge(conditions: conditions, requiredFeatures: ["missing"]) + f = Featurevisor.create_featurevisor(datafile: frozen_tree(source), log_level: "fatal") + expression = empty ? [{ and: [] }, { feature: "missing" }] : [*conditions, { feature: "missing" }] + expect(f.evaluate_flag("subject", country: "nl")).to include(reason: "conditions_unmet", conditions: expression, enabled: false) + ensure + f&.close + end + end + end + + it "preserves unread legacy and modern semantics in either mixed merge order" do + [true, false].each do |legacy_first| + legacy, modern = datafile("2"), datafile + legacy[:segments][:legacy] = { conditions: [] } + legacy[:features][:legacy] = feature.merge(conditions: "legacy") + modern[:segments][:modern] = { conditions: [] } + modern[:features][:modern] = feature.merge(conditions: "modern") + first, second = legacy_first ? [legacy, modern] : [modern, legacy] + f = Featurevisor.create_featurevisor(datafile: frozen_tree(first), log_level: "fatal") + expect(f.set_datafile(frozen_tree(second))).to be(true) + expect(f.is_enabled("legacy")).to be(true) + expect(f.is_enabled("modern")).to be(false) + f.close + end + end + + [nil, true, false, 42, [true], [], { and: [] }, { or: [] }, { not: [] }].each do |conditions| + it "defensively fails unsupported or empty root #{conditions.inspect}" do + source = datafile + source[:features][:subject] = feature.merge(conditions: conditions) + source[:variables][:subject] = { type: "string", defaultValue: "wrong", conditions: conditions } + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + expect(f.evaluate_flag("subject")).to include(reason: "conditions_unmet", enabled: false) + expect(f.evaluate_variable("subject")[:reason]).to eq("conditions_unmet") + ensure + f&.close + end + end + + [0, 1, 128, 256, 257].each do |getters| + [0, 1, 128, 255, 256, 257].each do |segments| + next if getters.zero? && segments.zero? + it "shares the exact depth bound across #{getters} getter and #{segments} segment handoffs" do + source = datafile + (0..getters).each do |index| + source[:variables]["g#{index}".to_sym] = if index < getters + { type: "boolean", compose: "g#{index + 1}" } + else + { type: "boolean", defaultValue: true }.tap { |value| value[:conditions] = "s0" if segments.positive? } + end + end + segments.times { |index| source[:segments]["s#{index}".to_sym] = { conditions: index + 1 < segments ? "s#{index + 1}" : "*" } } + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + expect(f.evaluate_variable("g0")[:reason] == "error").to eq(getters + segments > 256) + expect(f.is_enabled("healthy")).to be(true) + expect(f.get_variable("healthy")).to eq("healthy") + ensure + f&.close + end + end + end + + [127, 128, 129, 140].each do |depth| + it "bounds #{depth} alternating segment and feature handoffs" do + source = datafile + source[:features][:subject] = feature.merge(conditions: "s0") + depth.times do |index| + source[:segments]["s#{index}".to_sym] = { conditions: { feature: "f#{index}" } } + source[:features]["f#{index}".to_sym] = feature.merge(conditions: index + 1 < depth ? "s#{index + 1}" : "*") + end + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + expect(f.evaluate_flag("subject")[:reason] == "error").to eq(depth > 128) + expect(f.is_enabled("healthy")).to be(true) + ensure + f&.close + end + end + + %i[segment feature variable mixed].each do |kind| + it "fails #{kind} cycles through NOT without retaining failed request state" do + source = datafile + source[:features][:subject] = feature.merge(conditions: "s0") + case kind + when :segment then source[:segments][:s0] = { conditions: { not: ["s0"] } } + when :feature then source[:segments][:s0] = { conditions: { not: [{ feature: "subject" }] } } + when :variable + source[:variables][:loop] = { type: "boolean", defaultValue: true, conditions: { not: [{ variable: "loop", operator: "isNull" }] } } + source[:segments][:s0] = { conditions: { variable: "loop", operator: "isNull" } } + when :mixed + source[:segments][:s0] = { conditions: { variable: "loop", operator: "notNull" } } + source[:variables][:loop] = { type: "object", compose: [{ key: "flag", feature: "subject" }] } + end + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + 2.times { expect(f.evaluate_flag("subject")[:reason]).to eq("error") } + expect(f.is_enabled("healthy")).to be(true) + ensure + f&.close + end + end + + it "keeps composition values borrowed, JSON decoded once and condition snapshots owned" do + source = datafile + source[:variables][:policy] = { type: "object", defaultValue: { limit: 50 } } + source[:variables][:raw] = { type: "json", defaultValue: '{"items":[1]}' } + source[:features][:gate] = feature.merge(conditions: [ + { variable: "policy", path: ["limit"], operator: "equals", value: 50 }, + { variable: "raw", operator: "notNull" }, + { variable: "policy", path: ["limit"], operator: "equals", value: 50 } + ]) + source[:variables][:screen] = { type: "object", compose: [ + { key: "first", variable: "policy" }, { key: "flag", feature: "gate" }, + { key: "last", variable: "policy" }, { key: "json1", variable: "raw" }, { key: "json2", variable: "raw" } + ] } + purposes, retained = [], [] + policy = nil + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal", modules: [{ before_evaluation: ->(options) { + retained << options + purposes << options[:dependency_purpose] if options[:variable_key] == "policy" && options[:dependency_of] + policy[:limit] = 80 if policy && options[:variable_key] == "raw" && options[:dependency_purpose] == "condition" + options + } }]) + policy = f.get_variable("policy") + result = f.get_variable("screen") + expect(result).to include(first: { limit: 80 }, last: { limit: 80 }, flag: true) + expect(result[:first]).to equal(policy) + expect(result[:last]).to equal(policy) + expect(result[:json1]).to equal(result[:json2]) + expect(purposes).to eq(%w[composition condition]) + expect(retained.flat_map(&:keys).grep(/__featurevisor_/)).to be_empty + expect(retained.select { |options| options[:dependency_of] }.map { |options| options[:dependency_purpose] }.uniq.sort).to eq(%w[composition condition]) + ensure + f&.close + end + + it "runs sources eagerly before overrides without mutating siblings or sticky sources" do + sticky = { methods: ["card"] } + source = datafile + source[:variables][:policy] = { type: "object", defaultValue: { methods: ["default"] } } + source[:variables][:screen] = { type: "object", compose: [{ key: "changed", variable: "policy" }, { key: "original", variable: "policy" }], overrideProgram: [ + { key: "modify", conditions: "*", operations: [{ op: "append", path: ["changed", "methods"], value: "ideal" }], onMatch: 1, onMiss: 1 } + ] } + reads = [] + f = Featurevisor.create_featurevisor(datafile: source, sticky_variables: { policy: frozen_tree(sticky) }, log_level: "fatal", modules: [{ before_evaluation: ->(options) { reads << options[:variable_key]; options } }]) + result = f.evaluate_variable("screen") + expect(reads).to eq(%w[screen policy]) + expect(result).to include(reason: "variable_override_rule", variable_value: { changed: { methods: %w[card ideal] }, original: { methods: ["card"] } }) + expect(result[:variable_value][:original]).to equal(sticky) + expect(sticky).to eq(methods: ["card"]) + expect(f.get_variable("screen")).not_to equal(result[:variable_value]) + ensure + f&.close + end + + [false, 0, "", "null", "123", nil, [], { nested: true }].each do |value| + it "preserves decoded alias value #{value.inspect} exactly once" do + source = datafile + source[:variables][:json] = { type: "json", defaultValue: JSON.generate(value) } + source[:variables][:alias] = { type: "json", compose: "json" } + source[:variables][:chain] = { type: "json", compose: "alias" } + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + 2.times { expect(f.get_variable("chain")).to eq(value) } + ensure + f&.close + end + end + + it "uses effective module context and isolated child sticky state for all sources" do + source = datafile + source[:variables][:policy] = { type: "string", defaultValue: "default", overrides: [{ conditions: { attribute: "country", operator: "equals", value: "nl" }, value: "dutch" }] } + source[:variables][:screen] = { type: "object", compose: [{ key: "flag", feature: "healthy" }, { key: "policy", variable: "policy" }] } + f = Featurevisor.create_featurevisor(datafile: source, context: { country: "de" }, sticky_variables: { policy: "parent" }, log_level: "fatal") + child = f.spawn({ country: "nl" }, sticky_features: { healthy: { enabled: false } }, sticky_variables: {}) + expect(child.get_variable("screen")).to eq(flag: false, policy: "dutch") + expect(f.get_variable("screen")).to eq(flag: true, policy: "parent") + child.set_sticky_variables({ screen: nil }, true) + expect(child.get_variable("screen")).to be_nil + child.set_sticky_variables({}, true) + f.add_module(before_evaluation: ->(options) { options[:variable_key] == "screen" ? options.merge(context: { country: "nl" }) : options }) + expect(child.get_variable("screen", country: "de")).to eq(flag: false, policy: "dutch") + ensure + child&.close + f&.close + end + + it "skips all sources for a missed gate and returns deliberate null rather than a caller default" do + source = datafile + source[:variables][:screen] = { type: "json", compose: "healthy", conditions: [], overrideProgram: [{ key: "wrong", conditions: "*", operations: [{ op: "set", path: [], value: "wrong" }], onMatch: 1, onMiss: 1 }] } + reads = [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal", modules: [{ before_evaluation: ->(options) { reads << options[:variable_key]; options } }]) + expect(f.get_variable("screen", {}, default_variable_value: "caller")).to be_nil + expect(reads).to eq(["screen"]) + expect(f.evaluate_variable("screen")).to include(reason: "conditions_unmet", variable_value: nil) + ensure + f&.close + end + + it "indexes raw conditions and compose across all entity kinds without warming caches or diagnostics" do + source = datafile + source[:features][:healthy][:hash] = "old" + source[:segments][:bridge] = { conditions: '{"feature":"healthy"}' } + source[:features][:dependent] = feature.merge(hash: "dependent", conditions: "bridge") + source[:variables][:policy] = { hash: "policy", type: "boolean", defaultValue: true, conditions: "bridge" } + source[:variables][:alias] = { hash: "alias", type: "boolean", compose: "policy" } + source[:segments][:last] = { conditions: { variable: "alias", operator: "notNull" } } + source[:features][:last] = feature.merge(hash: "last", conditions: "last") + source[:variables][:broken] = { hash: "broken", conditions: "{oops", defaultValue: true } + diagnostics = [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { diagnostics << item }) + events = [] + f.on("datafile_set", ->(event) { events << event }) + patch = datafile + patch[:features][:healthy][:hash] = "new" + f.set_datafile(patch) + expect(events.last[:features]).to match_array(%i[healthy dependent last]) + expect(events.last[:variables]).to match_array(%i[healthy policy alias]) + expect(f.instance_variable_get(:@datafile).normalized.values).to all(be_empty) + expect(diagnostics).to be_empty + f.set_datafile(datafile, true) + expect(events.last[:features]).to match_array(%i[healthy dependent last]) + expect(events.last[:variables]).to match_array(%i[healthy policy alias broken]) + ensure + f&.close + end + + %w[2 4].each do |version| + [[], { and: [] }].each do |empty| + it "limits historical empty truth to old schema #{version} selector locations, #{empty.inspect}" do + source = datafile(version) + source[:segments][:empty] = { conditions: empty } + source[:features][:segment] = feature.merge(conditions: "empty") + source[:features][:forced] = feature.merge(traffic: [], force: [{ conditions: empty, enabled: true }]) + source[:features][:rule] = feature.merge(traffic: [{ key: "empty", conditions: empty, percentage: 100_000 }]) + source[:features][:root] = feature.merge(conditions: empty) + source[:features][:override] = feature.merge(variablesSchema: { title: { type: "string", defaultValue: "default" } }) + source[:features][:override][:traffic][0][:variableOverrides] = { title: [{ conditions: empty, value: "override" }] } + source[:variables][:ordinary] = { type: "string", defaultValue: "default", overrides: [{ conditions: empty, value: "override" }] } + source[:variables][:root] = { type: "string", defaultValue: "default", disabledValue: "disabled", conditions: empty } + source[:variables][:program] = { type: "string", defaultValue: "default", overrideProgram: [ + { conditions: empty, operations: [{ op: "set", path: [], value: "override" }], onMatch: 1, onMiss: 1 } + ] } + f = Featurevisor.create_featurevisor(datafile: frozen_tree(source), log_level: "fatal") + expect(f.is_enabled("segment")).to eq(version == "2") + expect(f.is_enabled("forced")).to eq(version == "2") + expect(f.is_enabled("rule")).to be(false) + expect(f.is_enabled("root")).to be(false) + expect(f.get_variable("override", "title")).to eq(version == "2" ? "override" : "default") + expect(f.get_variable("ordinary")).to eq(version == "2" ? "override" : "default") + expect(f.get_variable("root")).to eq("disabled") + expect(f.get_variable("program")).to eq("default") + ensure + f&.close + end + end + end + + it "propagates invalid markers before depth checks and never inverts them through NOT" do + source = datafile + source[:segments][:broken] = { conditions: "{oops" } + source[:variables][:g256] = { type: "boolean", conditions: { not: ["broken"] }, defaultValue: true, disabledValue: false } + 256.times { |index| source[:variables]["g#{index}".to_sym] = { type: "boolean", compose: "g#{index + 1}" } } + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + expect(f.get_variable("g0")).to be(false) + expect(f.evaluate_variable("g0")[:reason]).to eq("variable_composed") + ensure + f&.close + end + + it "does not traverse or copy borrowed host values to memoise repeated composition reads" do + source = datafile + source[:variables][:policy] = { type: "object", defaultValue: {} } + source[:variables][:screen] = { type: "object", compose: [{ key: "first", variable: "policy" }, { key: "second", variable: "policy" }] } + host = { callback: -> { true } } + host[:self] = host + reads = 0 + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal", modules: [{ after_evaluation: ->(evaluation, _options) { + if evaluation[:variable_key] == "policy" + reads += 1 + evaluation[:variable_value] = host + end + evaluation + } }]) + result = f.get_variable("screen") + expect(result[:first]).to equal(host) + expect(result[:second]).to equal(host) + expect(reads).to eq(1) + fresh = f.get_variable("screen") + expect(fresh).not_to equal(result) + expect(fresh[:first]).to equal(host) + expect(reads).to eq(2) + ensure + f&.close + end + + it "invalidates composition memo entries after an SDK setter during source evaluation" do + source = datafile + source[:variables][:policy] = { type: "integer", defaultValue: 1 } + source[:variables][:mutator] = { type: "integer", defaultValue: 0 } + source[:variables][:screen] = { type: "object", compose: [ + { key: "first", variable: "policy" }, { key: "mutator", variable: "mutator" }, { key: "second", variable: "policy" } + ] } + f = nil + reads = 0 + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal", modules: [{ after_evaluation: ->(evaluation, _options) { + reads += 1 if evaluation[:variable_key] == "policy" + f.set_sticky_variables({ policy: 2 }, true) if evaluation[:variable_key] == "mutator" + evaluation + } }]) + expect(f.get_variable("screen")).to eq(first: 1, mutator: 0, second: 2) + expect(reads).to eq(2) + ensure + f&.close + end + + it "treats an explicit null feature enabled selector as the default true" do + source = datafile + source[:features][:subject] = feature.merge(conditions: { feature: "healthy", enabled: nil }) + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal") + expect(f.is_enabled("subject")).to be(true) + ensure + f&.close + end +end diff --git a/spec/v4_requester_parity_spec.rb b/spec/v4_requester_parity_spec.rb new file mode 100644 index 0000000..4f44747 --- /dev/null +++ b/spec/v4_requester_parity_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "featurevisor" + +RSpec.describe "v4 requester and JSON getter parity" do + def feature(**options) + { bucketBy: [], traffic: [{ key: "all", conditions: "*", percentage: 100_000 }], **options } + end + + def datafile + { schemaVersion: "4", revision: "parity", segments: {}, features: {}, variables: {} } + end + + def trace(input) + input.select { |key, _| %i[type feature_key variable_key dependency_of dependency_purpose].include?(key) } + end + + %w[variable variation].each do |type| + [false, true].each do |composed| + it "retains the original #{type} requester through its internal flag, composed #{composed}" do + source = datafile + source[:variables][:source] = { type: "boolean", defaultValue: true } + source[:features][:gate] = feature(conditions: { variable: "source", operator: "equals", value: true }) + source[:features][:subject] = feature( + conditions: [{ feature: "gate" }, { feature: "gate" }], + variablesSchema: { value: { type: "string", defaultValue: "value" } }, + variations: [{ value: "treatment" }] + ) + source[:features][:subject][:traffic][0][:variation] = "treatment" + reference = type == "variable" ? { feature: "subject", variable: "value" } : { feature: "subject", variation: true } + source[:variables][:screen] = { type: "object", compose: [{ key: "result", **reference }] } + seen = [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal", modules: [{ before_evaluation: ->(input) { seen << trace(input); input } }]) + requester = { type: type, feature_key: "subject" } + requester[:variable_key] = "value" if type == "variable" + expected = [ + requester.merge(composed ? { dependency_of: { type: "variable", variable_key: "screen" }, dependency_purpose: "composition" } : {}), + { type: "flag", feature_key: "gate", dependency_of: requester, dependency_purpose: "condition" }, + { type: "variable", variable_key: "source", dependency_of: { type: "flag", feature_key: "gate" }, dependency_purpose: "condition" } + ] + expected.unshift(type: "variable", variable_key: "screen") if composed + 2.times do + seen.clear + result = if composed + f.get_variable("screen")[:result] + elsif type == "variable" + f.get_variable("subject", "value") + else + f.get_variation("subject") + end + expect(result).to eq(type == "variable" ? "value" : "treatment") + expect(seen).to eq(expected) + end + ensure + f&.close + end + end + end + + [false, true].each do |feature_owned| + [false, true].each do |negated| + it "memoises malformed JSON as an ordinary null getter, feature owned #{feature_owned}, negated #{negated}" do + source = datafile + json = { type: "json", defaultValue: "{oops" } + reference = { variable: "source", operator: "isNull" } + identity = { type: "variable", variable_key: "source" } + if feature_owned + source[:features][:owner] = feature(variablesSchema: { source: json }) + reference[:feature] = "owner" + identity[:feature_key] = "owner" + else + source[:variables][:source] = json + end + leaves = [reference, reference.dup] + source[:features][:subject] = feature(conditions: negated ? { not: leaves } : leaves) + diagnostics, seen = [], [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { diagnostics << item }, + modules: [{ before_evaluation: ->(input) { seen << trace(input); input } }]) + 2.times do |root| + seen.clear + result = f.evaluate_flag("subject") + expect(result[:enabled]).to eq(!negated) + expect(result[:reason]).to eq(negated ? "conditions_unmet" : "rule") + expect(seen).to eq([ + { type: "flag", feature_key: "subject" }, + identity.merge(dependency_of: { type: "flag", feature_key: "subject" }, dependency_purpose: "condition") + ]) + errors = diagnostics.select { |item| item[:message] == "getVariable failed" } + expect(errors.length).to eq(root + 1) + expect(errors.last).to include(level: "error", code: "evaluation_error") + expect(errors.last[:details]).to eq(featureKey: feature_owned ? "owner" : nil, variableKey: "source") + expect(errors.last[:originalError]).to be_a(JSON::ParserError) + expect(diagnostics.length).to eq(root + 1) + end + ensure + f&.close + end + end + end + + it "does not invert variable comparison errors through NOT and recovers on the next request" do + source = datafile + source[:variables][:source] = { type: "string", defaultValue: "value" } + source[:features][:subject] = feature(conditions: { not: [{ variable: "source", operator: "matches", value: "[" }] }) + source[:features][:healthy] = feature + seen = [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "fatal", modules: [{ before_evaluation: ->(input) { seen << trace(input); input } }]) + 2.times do + seen.clear + expect(f.evaluate_flag("subject")).to include(reason: "error") + expect(seen).to eq([ + { type: "flag", feature_key: "subject" }, + { type: "variable", variable_key: "source", dependency_of: { type: "flag", feature_key: "subject" }, dependency_purpose: "condition" } + ]) + expect(f.is_enabled("healthy")).to be(true) + end + ensure + f&.close + end + + ['"null"', '{"nested":[1]}'].each do |raw| + it "separates owned condition and borrowed composition memo entries and decodes #{raw} once per purpose" do + source = datafile + source[:variables][:source] = { type: "json", defaultValue: raw } + source[:variables][:screen] = { type: "object", conditions: [{ variable: "source", operator: "notNull" }] * 2, + compose: [{ key: "first", variable: "source" }, { key: "second", variable: "source" }] } + seen, diagnostics = [], [] + f = Featurevisor.create_featurevisor(datafile: source, log_level: "error", on_diagnostic: ->(item) { diagnostics << item }, + modules: [{ before_evaluation: ->(input) { seen << trace(input); input } }]) + allow(JSON).to receive(:parse).and_call_original + expect(JSON).to receive(:parse).with(raw, symbolize_names: true).exactly(4).times.and_call_original + previous = nil + 2.times do + seen.clear + result = f.get_variable("screen") + expect(result[:first]).to eq(raw == '"null"' ? "null" : { nested: [1] }) + expect(result[:second]).to equal(result[:first]) + expect(result).not_to equal(previous) if previous + previous = result + expect(seen).to eq([ + { type: "variable", variable_key: "screen" }, + { type: "variable", variable_key: "source", dependency_of: { type: "variable", variable_key: "screen" }, dependency_purpose: "condition" }, + { type: "variable", variable_key: "source", dependency_of: { type: "variable", variable_key: "screen" }, dependency_purpose: "composition" } + ]) + expect(diagnostics).to be_empty + end + ensure + f&.close + end + end +end diff --git a/spec/v4_runtime_spec.rb b/spec/v4_runtime_spec.rb new file mode 100644 index 0000000..dd730ae --- /dev/null +++ b/spec/v4_runtime_spec.rb @@ -0,0 +1,429 @@ +# frozen_string_literal: true + +require "featurevisor" + +RSpec.describe "Featurevisor v4 runtime" do + def feature(**values) + { bucketBy: "userId", traffic: [], **values } + end + + it "uses empty and notEmpty in required variable groups and conditions" do + sdk = Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "2", revision: "empty-operators", + segments: {}, + features: { + emptyContext: feature( + force: [{ conditions: [{ attribute: "profile.items", operator: "empty" }], enabled: true }] + ), + nonemptyContext: feature( + force: [{ conditions: [{ attribute: "profile.items", operator: "notEmpty" }], enabled: true }] + ) + }, + variables: { + emptyArray: { type: "array", defaultValue: [] }, + nonemptyObject: { type: "object", defaultValue: { value: nil } }, + nilValue: { type: "json", defaultValue: nil }, + emptyGate: { + type: "boolean", defaultValue: true, disabledValue: false, + conditions: [{ variable: "emptyArray", operator: "empty" }] + }, + nonemptyGate: { + type: "boolean", defaultValue: true, disabledValue: false, + conditions: [{ variable: "nonemptyObject", operator: "notEmpty" }] + }, + nilGate: { + type: "boolean", defaultValue: true, disabledValue: false, + conditions: [{ variable: "nilValue", operator: "notEmpty" }] + } + } + }, + log_level: "fatal" + ) + + expect(sdk.is_enabled("emptyContext", profile: { items: [] })).to be true + expect(sdk.is_enabled("nonemptyContext", profile: { items: [nil] })).to be true + expect(sdk.get_variable("emptyGate")).to be true + expect(sdk.get_variable("nonemptyGate")).to be true + expect(sdk.get_variable("nilGate")).to be false + ensure + sdk&.close + end + + it "treats an empty override program as authoritative and rejects malformed jumps" do + sdk = Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "2", revision: "programs", segments: {}, features: {}, + variables: { + subject: { + type: "object", defaultValue: { default: true }, overrideProgram: [], + overrides: [{ key: "must-not-run", segments: "*", value: { legacy: true } }] + }, + broken: { + type: "object", defaultValue: {}, + overrideProgram: [{ key: "bad", segments: "*", operations: [], onMatch: 0, onMiss: 1 }] + } + } + }, + log_level: "fatal" + ) + + expect(sdk.get_variable("subject")).to eq(default: true) + expect(sdk.evaluate_variable("subject")[:reason]).to eq("variable_default") + expect(sdk.get_variable("broken")).to be_nil + expect(sdk.evaluate_variable("broken")[:reason]).to eq("error") + end + + it "publishes one final accumulated value and its complete metadata to modules" do + observed = [] + datafile = { + schemaVersion: "2", revision: "callbacks", segments: {}, features: {}, + variables: { + subject: { + type: "object", defaultValue: { trace: [] }, + overrideProgram: [ + { key: "parent", keyPath: ["parent"], segments: "*", operations: [{ op: "append", path: ["trace"], value: "parent" }], onMatch: 1, onMiss: 2 }, + { key: "child", keyPath: ["parent", "child"], segments: "*", operations: [{ op: "append", path: ["trace"], value: "child" }], onMatch: 2, onMiss: 2 } + ] + } + } + } + sdk = Featurevisor.create_featurevisor( + datafile: datafile, + log_level: "fatal", + modules: [{ + name: "observer", + after_evaluation: ->(evaluation, _options) { + observed << [evaluation[:variable_value], evaluation[:variable_override_paths]] + evaluation + } + }] + ) + + expect(sdk.get_variable("subject")).to eq(trace: %w[parent child]) + expect(observed).to eq([[{ trace: %w[parent child] }, [["parent"], %w[parent child]]]]) + expect(datafile.dig(:variables, :subject, :defaultValue)).to eq(trace: []) + end + + it "uses ordinary getter fallback for source errors but keeps current comparator errors" do + datafile = { + schemaVersion: "2", revision: "requirement-errors", segments: {}, features: {}, + variables: { + source: { type: "object", defaultValue: { items: [nil] } }, + sourceString: { type: "string", defaultValue: "value" }, + subject: { + type: "string", defaultValue: "matched", + conditions: [{ variable: "source", operator: "isNull" }] + }, + badComparator: { + type: "string", defaultValue: "bad", + conditions: [{ variable: "sourceString", operator: "matches", value: "[" }] + } + } + } + sdk = Featurevisor.create_featurevisor( + datafile: datafile, + log_level: "fatal", + modules: [{ + name: "throw-source-only", + before_evaluation: ->(options) { raise "source failed" if options[:variable_key] == "source"; options } + }] + ) + + evaluation = sdk.evaluate_variable("subject") + expect(evaluation[:variable_value]).to eq("matched") + expect(evaluation[:reason]).to eq("variable_default") + + plain = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + expect(plain.evaluate_variable("badComparator")[:reason]).to eq("error") + end + + it "treats a required feature module error as disabled for enabled false and NOT" do + sdk = Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "2", revision: "required-feature-error", segments: {}, + features: { + source: feature(traffic: [{ key: "all", segments: "*", percentage: 100_000 }]), + disabledSource: feature( + requiredFeatures: [{ feature: "source", enabled: false }], + traffic: [{ key: "all", segments: "*", percentage: 100_000 }] + ), + notSource: feature( + requiredFeatures: [{ not: ["source"] }], + traffic: [{ key: "all", segments: "*", percentage: 100_000 }] + ) + } + }, + log_level: "fatal", + modules: [{ + name: "throw-source-feature", + before_evaluation: ->(options) { raise "source failed" if options[:feature_key] == "source"; options } + }] + ) + + expect(sdk.evaluate_flag("source")[:reason]).to eq("error") + expect(sdk.is_enabled("disabledSource")).to be true + expect(sdk.is_enabled("notSource")).to be true + end + + it "recovers after cycles and isolates simultaneous requirement stacks per thread" do + cycle_sdk = Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "2", revision: "cycle", segments: {}, features: {}, + variables: { + a: { type: "string", defaultValue: "a", conditions: [{ variable: "b", operator: "equals", value: "b" }] }, + b: { type: "string", defaultValue: "b", conditions: [{ variable: "a", operator: "equals", value: "a" }] }, + healthy: { type: "string", defaultValue: "ok" } + } + }, + log_level: "fatal" + ) + expect(cycle_sdk.evaluate_variable("a")[:reason]).to eq("error") + expect(cycle_sdk.get_variable("healthy")).to eq("ok") + + mutex = Mutex.new + condition = ConditionVariable.new + arrived = 0 + sdk = Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "2", revision: "threads", segments: {}, features: {}, + variables: { + source: { type: "string", defaultValue: "none", overrides: [ + { key: "a", conditions: [{ attribute: "worker", operator: "equals", value: "a" }], value: "a" }, + { key: "b", conditions: [{ attribute: "worker", operator: "equals", value: "b" }], value: "b" } + ] }, + subjectA: { type: "boolean", defaultValue: true, conditions: [{ variable: "source", operator: "equals", value: "a" }] }, + subjectB: { type: "boolean", defaultValue: true, conditions: [{ variable: "source", operator: "equals", value: "b" }] } + } + }, + log_level: "fatal", + modules: [{ + name: "rendezvous", + before_evaluation: ->(options) { + if options[:variable_key] == "source" + mutex.synchronize do + arrived += 1 + condition.broadcast if arrived == 2 + condition.wait(mutex, 1) while arrived < 2 + end + end + options + } + }] + ) + threads = [["subjectA", "a"], ["subjectB", "b"]].map do |key, worker| + Thread.new { sdk.get_variable(key, { worker: worker }) } + end + expect(threads.map(&:value)).to eq([true, true]) + end + + it "uses JavaScript own array property semantics for compiled requirement paths" do + sdk = Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "2", revision: "requirement-paths", segments: {}, features: {}, + variables: { + array: { type: "array", defaultValue: ["first"] }, + object: { type: "object", defaultValue: { "0": "object-first" } }, + stringIndex: { type: "boolean", defaultValue: true, conditions: [{ variable: "array", path: ["0"], operator: "equals", value: "first" }] }, + numericIndex: { type: "boolean", defaultValue: true, conditions: [{ variable: "array", path: [0], operator: "equals", value: "first" }] }, + length: { type: "boolean", defaultValue: true, conditions: [{ variable: "array", path: ["length"], operator: "equals", value: 1.0 }] }, + numericObject: { type: "boolean", defaultValue: true, conditions: [{ variable: "object", path: [0], operator: "notExists" }] } + } + }, + log_level: "fatal" + ) + + %w[stringIndex numericIndex length numericObject].each do |key| + expect(sdk.get_variable(key)).to be true + end + end + + it "accepts safe integral numeric path components and rejects invalid ones" do + mutations = Featurevisor.const_get(:Mutations, false) + expect(mutations.apply( + %w[zero one], + [{ op: "set", path: [1.0], value: "changed" }] + )).to eq(%w[zero changed]) + expect(mutations.apply( + ["zero"], + [{ op: "set", path: [9_007_199_254_740_991.0], value: "ignored" }] + )).to eq(["zero"]) + + [-1, 1.5, 9_007_199_254_740_992, 9_007_199_254_740_992.0, true, Float::NAN, Float::INFINITY].each do |part| + expect do + mutations.apply(%w[zero one], [{ op: "set", path: [part], value: "invalid" }]) + end.to raise_error(ArgumentError) + end + + datafile = { + schemaVersion: "2", revision: "numeric-paths", segments: {}, features: {}, + variables: { + source: { type: "array", defaultValue: %w[zero one] }, + mutated: { + type: "array", defaultValue: %w[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.generate(datafile) + expect(raw).to include('"path":[1.0]') + sdk = Featurevisor.create_featurevisor(datafile: raw, log_level: "fatal") + expect(sdk.get_variable("mutated")).to eq(%w[zero changed]) + expect(sdk.get_variable("gate")).to eq("matched") + + [-1, 1.5, 9_007_199_254_740_992, 9_007_199_254_740_992.0, true].each do |part| + invalid = Marshal.load(Marshal.dump(datafile)) + invalid[:variables][:gate][:conditions][0][:path] = [part] + invalid_sdk = Featurevisor.create_featurevisor(datafile: invalid, log_level: "fatal") + expect(invalid_sdk.evaluate_variable("gate")[:reason]).to eq("error") + invalid_sdk.close + end + sdk.close + end + + it "isolates child sticky state while resolving variable requirements" do + datafile = { + schemaVersion: "2", revision: "children", segments: {}, + features: { + source: feature(variablesSchema: { plan: { type: "string", defaultValue: "free" } }), + subject: feature( + conditions: [{ feature: "source", variable: "plan", operator: "equals", value: "pro" }], + traffic: [{ key: "all", segments: "*", percentage: 100_000 }] + ) + }, + variables: { + globalSource: { type: "string", defaultValue: "free" }, + globalSubject: { + type: "string", defaultValue: "allowed", + conditions: [{ variable: "globalSource", operator: "equals", value: "pro" }] + } + } + } + sdk = Featurevisor.create_featurevisor( + datafile: datafile, + sticky_features: { source: { enabled: true, variables: { plan: "pro" } } }, + sticky_variables: { globalSource: "pro" }, + log_level: "fatal" + ) + default_child = sdk.spawn + child = sdk.spawn({}, sticky_features: {}) + + expect(sdk.is_enabled("subject")).to be true + expect(sdk.get_variable("globalSubject")).to eq("allowed") + expect(default_child.is_enabled("subject")).to be false + expect(default_child.evaluate_variable("globalSubject")[:reason]).to eq("conditions_unmet") + expect(child.is_enabled("subject")).to be false + child.set_sticky_features({ source: { enabled: true, variables: { plan: "pro" } } }) + expect(child.is_enabled("subject")).to be true + end + + it "propagates mixed feature and variable dependency updates to a fixed point" do + initial = { + schemaVersion: "2", revision: "one", segments: {}, + features: { + checkout: feature(conditions: [{ variable: "currency", operator: "equals", value: "EUR" }], hash: "checkout"), + receipt: feature(requiredFeatures: ["checkout"], hash: "receipt") + }, + variables: { + currency: { type: "string", defaultValue: "EUR", hash: "one" }, + summary: { type: "string", defaultValue: "", conditions: [{ feature: "receipt", variable: "title", operator: "equals", value: "Receipt" }], hash: "summary" }, + final: { type: "string", defaultValue: "", conditions: [{ variable: "summary", operator: "equals", value: "" }], hash: "final" } + } + } + updated = { + schemaVersion: "2", revision: "two", segments: {}, features: {}, + variables: { currency: { type: "string", defaultValue: "USD", hash: "two" } } + } + events = [] + sdk = Featurevisor.create_featurevisor(datafile: initial, log_level: "fatal") + sdk.on("datafile_set", ->(event) { events << event }) + sdk.set_datafile(updated) + + expect(events.last[:features]).to eq(%i[checkout receipt]) + expect(events.last[:variables]).to eq(%i[currency summary final]) + end + + it "keeps precomputed and runtime first-match trees equivalent through child and datafile state" do + fixture = JSON.parse( + File.read(File.expand_path("../conformance/sdk-v4-cases.json", __dir__), encoding: "UTF-8"), + symbolize_names: true + ) + names = [ + "first match precomputed parsed: child", + "first match runtime parsed: child" + ] + cases = names.map { |name| fixture[:cases].find { |item| item[:name] == name } } + expect(cases).not_to include(nil) + + child_value = { + stage: "parent", + rows: [{ id: 0, tags: [] }, { id: 1, tags: %w[parent child] }], + flags: ["a"] + } + fallback_value = { stage: "fallback", rows: [], flags: ["a"] } + sticky_value = { stage: "sticky", rows: [], flags: [nil] } + replacement_value = { stage: "replacement", rows: [], flags: [] } + + cases.each do |test_case| + datafile = JSON.parse(JSON.generate(test_case[:datafile]), symbolize_names: true) + original_variables = JSON.generate(datafile[:variables]) + sdk = Featurevisor.create_featurevisor( + datafile: datafile, + context: test_case[:context], + log_level: "fatal" + ) + child = sdk.spawn(country: "be", city: "rotterdam") + sticky_child = sdk.spawn({}, sticky_variables: { subject: sticky_value }) + + 2.times do + expect(sdk.get_variable("subject")).to eq(child_value) + expect(sdk.evaluate_variable("subject")[:variable_value]).to eq(child_value) + expect(child.get_variable("subject")).to eq(fallback_value) + expect(sticky_child.get_variable("subject")).to eq(sticky_value) + end + + sticky_child.set_sticky_variables({}, true) + expect(sticky_child.get_variable("subject")).to eq(child_value) + + replacement = { + schemaVersion: "2", revision: "replacement", segments: {}, features: {}, + variables: { subject: { type: "object", defaultValue: replacement_value } } + } + sdk.set_datafile(replacement, true) + expect(sdk.get_variable("subject")).to eq(replacement_value) + expect(child.get_variable("subject")).to eq(replacement_value) + + sdk.set_datafile(datafile) + expect(sdk.get_variable("subject")).to eq(child_value) + expect(child.get_variable("subject")).to eq(fallback_value) + expect(JSON.generate(datafile[:variables])).to eq(original_variables) + ensure + sticky_child&.close + child&.close + sdk&.close + end + end + + it "ignores the removed legacy required field" do + sdk = Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "2", revision: "legacy", segments: {}, + features: { + disabled: feature(force: [{ segments: "*", enabled: false }]), + subject: feature(required: ["disabled"], traffic: [{ key: "all", segments: "*", percentage: 100_000 }]) + } + }, + log_level: "fatal" + ) + + expect(sdk.is_enabled("subject")).to be true + end +end