From a358bd4041e9b42ec03da1250d788fa3bcc42cee Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 18 Oct 2026 20:30:55 +0200 Subject: [PATCH 1/2] feat: v4 changes --- .github/workflows/checks.yml | 2 +- .github/workflows/publish.yml | 2 +- README.md | 319 +- bench/sticky_session.exs | 87 + conformance/openfeature-provider.json | 264 + conformance/sdk-composition.json | 436 + conformance/sdk-conditions.json | 10990 ++++++++ conformance/sdk-diagnostics.json | 196 + conformance/sdk-stateful.json | 4023 +++ conformance/sdk-v3.json | 2292 +- conformance/sdk-v4-cases.json | 21153 ++++++++++++++++ conformance/semver.json | 76 + conformance/test-checks.json | 3495 +++ lib/featurevisor.ex | 1071 +- lib/featurevisor/child.ex | 287 +- lib/featurevisor/cli.ex | 152 +- lib/featurevisor/cli/assess_distribution.ex | 13 +- lib/featurevisor/cli/benchmark.ex | 12 +- lib/featurevisor/cli/project.ex | 172 +- lib/featurevisor/cli/segments.ex | 141 + lib/featurevisor/cli/test_checks.ex | 1305 + lib/featurevisor/cli/test_coverage.ex | 90 + lib/featurevisor/cli/test_runner.ex | 1443 +- lib/featurevisor/conditions.ex | 261 +- lib/featurevisor/datafile_store.ex | 237 + lib/featurevisor/dependencies.ex | 58 + lib/featurevisor/dependency_index.ex | 70 + lib/featurevisor/evaluation.ex | 30 +- lib/featurevisor/evaluation_session.ex | 292 + lib/featurevisor/evaluator.ex | 319 +- lib/featurevisor/module.ex | 4 - lib/featurevisor/mutations.ex | 214 + lib/featurevisor/normalize.ex | 224 + lib/featurevisor/semver.ex | 52 +- lib/featurevisor/server.ex | 506 +- lib/featurevisor/sticky_store.ex | 125 + mix.exs | 3 +- openfeature/README.md | 7 + .../conformance/openfeature-provider.json | 264 + .../lib/featurevisor_openfeature/provider.ex | 30 +- openfeature/mix.exs | 2 +- openfeature/test/provider_test.exs | 218 +- test/cli_cache_test.exs | 170 + test/cli_options_test.exs | 226 + test/cli_project_process_test.exs | 77 + test/cli_report_test.exs | 152 + test/cli_segments_test.exs | 192 + test/cli_selection_test.exs | 122 + test/cli_test.exs | 1212 +- test/conditions_test.exs | 107 +- test/conformance_test.exs | 68 +- test/dependency_getter_contract_test.exs | 106 + test/deprecation_diagnostics_test.exs | 57 + test/empty_modules_hot_path_test.exs | 165 + test/evaluation_session_test.exs | 974 + test/evaluation_storage_test.exs | 594 + test/events_and_concurrency_test.exs | 34 +- test/featurevisor_test.exs | 8 +- test/full_refresh_ownership_test.exs | 140 + test/group_removal_test.exs | 151 + test/includes_any_test.exs | 206 + test/incremental_dependency_index_test.exs | 293 + test/initial_loading_test.exs | 176 + test/live_modules_test.exs | 313 + test/modules_and_child_test.exs | 93 +- test/native_timestamp_test.exs | 68 + test/normalization_and_composition_test.exs | 502 + test/number_getter_test.exs | 59 + test/partial_snapshot_scaling_test.exs | 311 + test/readme_initialization_test.exs | 61 + test/review_correctness_test.exs | 601 + test/sdk_stateful_test.exs | 231 + test/semver_conformance_test.exs | 146 + test/set_datafile_test.exs | 284 + test/support/cli_npx.sh | 15 + test/v4_alignment_test.exs | 263 + test/v4_runtime_test.exs | 774 + 77 files changed, 58820 insertions(+), 1068 deletions(-) create mode 100644 bench/sticky_session.exs create mode 100644 conformance/openfeature-provider.json create mode 100644 conformance/sdk-composition.json create mode 100644 conformance/sdk-conditions.json create mode 100644 conformance/sdk-diagnostics.json create mode 100644 conformance/sdk-stateful.json create mode 100644 conformance/sdk-v4-cases.json create mode 100644 conformance/semver.json create mode 100644 conformance/test-checks.json create mode 100644 lib/featurevisor/cli/segments.ex create mode 100644 lib/featurevisor/cli/test_checks.ex create mode 100644 lib/featurevisor/cli/test_coverage.ex create mode 100644 lib/featurevisor/datafile_store.ex create mode 100644 lib/featurevisor/dependencies.ex create mode 100644 lib/featurevisor/dependency_index.ex create mode 100644 lib/featurevisor/evaluation_session.ex create mode 100644 lib/featurevisor/mutations.ex create mode 100644 lib/featurevisor/normalize.ex create mode 100644 lib/featurevisor/sticky_store.ex create mode 100644 openfeature/conformance/openfeature-provider.json create mode 100644 test/cli_cache_test.exs create mode 100644 test/cli_options_test.exs create mode 100644 test/cli_project_process_test.exs create mode 100644 test/cli_report_test.exs create mode 100644 test/cli_segments_test.exs create mode 100644 test/cli_selection_test.exs create mode 100644 test/dependency_getter_contract_test.exs create mode 100644 test/deprecation_diagnostics_test.exs create mode 100644 test/empty_modules_hot_path_test.exs create mode 100644 test/evaluation_session_test.exs create mode 100644 test/evaluation_storage_test.exs create mode 100644 test/full_refresh_ownership_test.exs create mode 100644 test/group_removal_test.exs create mode 100644 test/includes_any_test.exs create mode 100644 test/incremental_dependency_index_test.exs create mode 100644 test/initial_loading_test.exs create mode 100644 test/live_modules_test.exs create mode 100644 test/native_timestamp_test.exs create mode 100644 test/normalization_and_composition_test.exs create mode 100644 test/number_getter_test.exs create mode 100644 test/partial_snapshot_scaling_test.exs create mode 100644 test/readme_initialization_test.exs create mode 100644 test/review_correctness_test.exs create mode 100644 test/sdk_stateful_test.exs create mode 100644 test/semver_conformance_test.exs create mode 100644 test/set_datafile_test.exs create mode 100644 test/support/cli_npx.sh create mode 100644 test/v4_alignment_test.exs create mode 100644 test/v4_runtime_test.exs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7fd3393..77c9552 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -106,7 +106,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/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5c5edc3..219fc78 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,7 +41,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/README.md b/README.md index 58f191d..2baa53a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # Featurevisor SDK for Elixir -This is the official [Featurevisor](https://featurevisor.com) SDK for Elixir. It evaluates feature flags, variations, and variables in Elixir applications using Featurevisor schema version 2 datafiles. +Use the Featurevisor Elixir SDK to evaluate flags, variations and variables in Elixir applications. It consumes Featurevisor schema version 4 datafiles and retains compatibility with legacy schema version 2 datafiles. The SDK supports concurrent evaluations, structured diagnostics, lifecycle modules, child instances, and the Featurevisor project test runner. ## Table of contents - [Installation](#installation) -- [Public API](#public-api) - [Initialization](#initialization) + - [Public API](#public-api) - [Supervision](#supervision) -- [Evaluation types](#evaluation-types) + - [Evaluation types](#evaluation-types) - [Context](#context) - [Setting initial context](#setting-initial-context) - [Setting after initialization](#setting-after-initialization) @@ -18,8 +18,12 @@ The SDK supports concurrent evaluations, structured diagnostics, lifecycle modul - [Check if enabled](#check-if-enabled) - [Getting variation](#getting-variation) - [Getting variables](#getting-variables) + - [Getting feature variables](#getting-feature-variables) + - [Getting global variables](#getting-global-variables) - [Type specific getters](#type-specific-getters) -- [Getting all evaluations](#getting-all-evaluations) +- [Conditions](#conditions) +- [Composed variables](#composed-variables) +- [Getting evaluations](#getting-evaluations) - [Sticky](#sticky) - [Setting initial sticky values](#setting-initial-sticky-values) - [Updating sticky values](#updating-sticky-values) @@ -40,6 +44,7 @@ The SDK supports concurrent evaluations, structured diagnostics, lifecycle modul - [Test](#test) - [Benchmark](#benchmark) - [Assess distribution](#assess-distribution) +- [GitHub repositories](#github-repositories) - [Development](#development) - [Publishing](#publishing) - [License](#license) @@ -48,12 +53,14 @@ The SDK supports concurrent evaluations, structured diagnostics, lifecycle modul ## Installation +Upgrading an existing application? Follow the [Elixir migration guide](https://featurevisor.com/docs/migrations/v4/elixir/). + Add `featurevisor` to your dependencies in `mix.exs`: ```elixir def deps do [ - {:featurevisor, "~> 1.2"} + {:featurevisor, "~> 2.0"} ] end ``` @@ -64,47 +71,55 @@ Then install dependencies: mix deps.get ``` -## Public API - -Create instances with `Featurevisor.create_featurevisor/1`. The returned `Featurevisor` handle is the main SDK instance. - -Most applications use: - -- `Featurevisor.create_featurevisor/1` -- `Featurevisor.start_link/1` and `Featurevisor.instance/1` for supervision -- `Featurevisor.enabled?/2` -- `Featurevisor.get_variation/2` -- `Featurevisor.get_variable/3` -- `Featurevisor.close/1` -- `Featurevisor.Module` for extensions -- `Featurevisor.Diagnostic` for observability -- `Featurevisor.Evaluation` for detailed results - -The datafile and context use ordinary Elixir maps with string keys. This preserves the JSON wire format and avoids a second public reader API. - ## Initialization Initialize with a decoded datafile: ```elixir datafile = - "datafile.json" + "featurevisor-all.json" |> File.read!() |> Jason.decode!() f = Featurevisor.create_featurevisor(%{datafile: datafile}) + +try do + IO.puts(Featurevisor.enabled?(f, "checkout", %{"userId" => "123"})) +after + Featurevisor.close(f) +end ``` -Create one long lived instance for your application and share it between processes. Evaluation reads immutable ETS snapshots and does not queue through the instance process. The convenience constructor is not linked to the calling process, so its owner must call `Featurevisor.close/1` during application shutdown. +File read and JSON parsing errors raise before initialization. This example closes the instance after evaluation. + +Create one long lived instance for your application and share it between processes. The convenience constructor is not linked to the calling process, so its owner must call `Featurevisor.close/1` during application shutdown. You may pass the JSON string directly: ```elixir -f = Featurevisor.create_featurevisor(%{datafile: File.read!("datafile.json")}) +f = Featurevisor.create_featurevisor(%{datafile: File.read!("featurevisor-all.json")}) ``` Invalid datafiles do not replace the active datafile. They report an `invalid_datafile` diagnostic with the stable message `Could not parse datafile`. +### Public API + +Create instances with `Featurevisor.create_featurevisor/1`. The returned `Featurevisor` handle is the main SDK instance. + +Most applications use: + +- `Featurevisor.create_featurevisor/1` +- `Featurevisor.start_link/1` and `Featurevisor.instance/1` for supervision +- `Featurevisor.enabled?/2` +- `Featurevisor.get_variation/2` +- `Featurevisor.get_variable/3` +- `Featurevisor.close/1` +- `Featurevisor.Module` for extensions +- `Featurevisor.Diagnostic` for observability +- `Featurevisor.Evaluation` for detailed results + +The datafile and context use Elixir maps with string keys. This preserves the JSON wire format and avoids a second public reader API. + ## Supervision Use a supervised instance in long running OTP applications: @@ -124,19 +139,19 @@ Resolve its Featurevisor handle wherever it is needed: ```elixir f = Featurevisor.instance(MyApp.Featurevisor) -Featurevisor.enabled?(f, "my_feature", %{"userId" => "123"}) +Featurevisor.enabled?(f, "checkout", %{"userId" => "123"}) ``` -The supervisor owns shutdown and restart. Module close callbacks run during supervised shutdown. Resolve the handle again after a restart because the new owner process creates new ETS tables. +The supervisor owns shutdown and restart. Module close callbacks run during supervised shutdown. Resolve the handle again after a restart to use the new instance. -## Evaluation types +### Evaluation types Featurevisor evaluates four kinds of values: -- a flag answers whether a feature is enabled -- a variation returns a variation value -- a feature variable returns remote configuration owned by a feature -- a global variable returns remote configuration independently of a feature +- [**Flag**](#check-if-enabled) (`boolean()`): whether the feature is enabled or not +- [**Variation**](#getting-variation) (`String.t()`): 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 Every evaluation uses the active datafile and the effective context. @@ -144,6 +159,10 @@ Every evaluation uses the active datafile and the effective context. Contexts are maps of attributes used by conditions and bucketing. Use string keys so nested paths and datafile attribute names match exactly. +Conditions compare these attributes using the [operators](https://featurevisor.com/docs/operators/), including `empty` and `notEmpty`. + +Date comparisons accept portable timestamp strings and `DateTime` values, not `Date` or `NaiveDateTime`. Offsets must stay within 14 hours and both local and UTC years within 0001 to 9999. Fractions are truncated to milliseconds. Invalid values make both `before` and `after` return `false`. + Date conditions accept ISO 8601 strings with an explicit time zone and native `DateTime` values. ### Setting initial context @@ -169,7 +188,7 @@ Featurevisor.set_context(f, %{"device" => "mobile"}) Pass `true` to replace all stored context: ```elixir -Featurevisor.set_context(f, %{"userId" => "456"}, true) +Featurevisor.set_context(f, %{"userId" => "456"}, true) # replace ``` ### Passing context for one evaluation @@ -177,9 +196,9 @@ Featurevisor.set_context(f, %{"userId" => "456"}, true) ```elixir context = %{"country" => "de"} -Featurevisor.enabled?(f, "my_feature", context) -Featurevisor.get_variation(f, "my_feature", context) -Featurevisor.get_variable(f, "my_feature", "title", context) +Featurevisor.enabled?(f, "checkout", context) +Featurevisor.get_variation(f, "checkout", context) +Featurevisor.get_variable(f, "checkout", "title", context) ``` Evaluation context wins over stored context for matching keys. @@ -187,7 +206,7 @@ Evaluation context wins over stored context for matching keys. ## Check if enabled ```elixir -if Featurevisor.enabled?(f, "my_feature", %{"userId" => "123"}) do +if Featurevisor.enabled?(f, "checkout", %{"userId" => "123"}) do # show the enabled experience end ``` @@ -204,7 +223,7 @@ case Featurevisor.get_variation(f, "checkout_experiment", context) do end ``` -Provide an explicit fallback when no variation is selected: +Provide a fallback when no variation is selected: ```elixir Featurevisor.get_variation( @@ -217,19 +236,27 @@ Featurevisor.get_variation( ## Getting variables +### Getting feature variables + ```elixir title = Featurevisor.get_variable(f, "checkout", "title", context) ``` JSON variables are decoded before they are returned. -Global variables use explicit function names so feature ownership remains clear: +### Getting global variables + +Global variables use separate function names so feature ownership remains clear: ```elixir email = Featurevisor.get_global_variable(f, "supportEmail", context) evaluation = Featurevisor.evaluate_global_variable(f, "supportEmail", context) ``` +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, read `variable_override_paths` from `evaluate_global_variable/3`. + +Modules should return the same result for the same input during one evaluation. Pass values that change through context. + ### Type specific getters Use a typed getter when your application wants runtime type validation: @@ -238,7 +265,7 @@ Use a typed getter when your application wants runtime type validation: enabled = Featurevisor.get_variable_boolean(f, "checkout", "enabled", context) title = Featurevisor.get_variable_string(f, "checkout", "title", context) count = Featurevisor.get_variable_integer(f, "checkout", "count", context) -ratio = Featurevisor.get_variable_double(f, "checkout", "ratio", context) +ratio = Featurevisor.get_variable_number(f, "checkout", "ratio", context) items = Featurevisor.get_variable_array(f, "checkout", "items", context) config = Featurevisor.get_variable_object(f, "checkout", "config", context) json = Featurevisor.get_variable_json(f, "checkout", "json", context) @@ -246,7 +273,53 @@ json = Featurevisor.get_variable_json(f, "checkout", "json", context) Typed getters return `nil` for a mismatched value and do not coerce strings, booleans, or collections. -## Getting all evaluations +`get_variable_number/5` and `get_global_variable_number/4` read a variable of type `number`, whether it holds `3` or `4.5`. Datafiles built before v4 call that type `double`, and it reads the same way. The v3 names `get_variable_double/5` and `get_global_variable_double/4` were removed in v4. + +## Conditions + +[Conditions](https://featurevisor.com/docs/conditions/) provide one expression grammar for segments, feature and global variable root gates, force entries, traffic rules and variable overrides. Leaves compare an attribute, name a segment, check a feature or compare a global or feature variable. A bare string is a segment key. Feature checks default to enabled. Variable comparisons may include a compiled path array, with the variable key treated literally. + +Arrays imply AND. Logical groups use `and`, `or` and `not`. NOT negates the AND of its direct children. Empty new arrays and groups do not match. Evaluation preserves expression order and short circuits. The builder compiles authored paths and stringified selectors for SDK consumption. + +Sticky state wins first. Feature force entries precede root conditions. Otherwise roots must match before traffic rules. Global variable roots run before sources and overrides. An unmet root returns `:conditions_unmet` with `evaluation.conditions`. Disabled variations and feature variables keep their ordinary fallback reasons and values. + +Condition dependencies use ordinary getters, modules, effective context and sticky state without inheriting the outer getter's explicit defaults. Missing values and explicit null remain distinct during path comparisons. Cycles and more than 256 combined internal handoffs fail the complete evaluation, and the next public call starts a fresh session. + +## Composed variables + +[Composed variables](https://featurevisor.com/docs/variables/#compose-values) read other values at runtime. A string composition is an alias of a literal global variable key. A map composition collects feature enabled state, variations, feature variables and global variables under literal property names. + +For example, a generated datafile may contain: + +```elixir +variables = %{ + "policy" => %{"type" => "object", "defaultValue" => %{"limit" => 50}}, + "policyAlias" => %{"type" => "object", "compose" => "policy"}, + "screen" => %{ + "type" => "object", + "compose" => [ + %{"key" => "enabled", "feature" => "checkout"}, + %{"key" => "policy", "variable" => "policyAlias"}, + %{"key" => "variation", "feature" => "checkout", "variation" => true} + ] + } +} +``` + +Use the familiar getters after loading the datafile: + +```elixir +Featurevisor.get_global_variable(f, "policyAlias", context) +Featurevisor.get_global_variable_object(f, "screen", context) +``` + +An alias returns its source value directly, preserving false, zero, empty strings, arrays and null. JSON sources decode exactly once, even if their decoded value resembles JSON text. Map composition creates an outer map without flattening member values. Elixir values are immutable, and runtime mutations cannot change a source, sibling property or sticky value. + +Compositions have no `defaultValue` or `useDefaultWhenDisabled`. Optional root conditions can return `disabledValue`, otherwise null, without reading sources or overrides. Whole variable sticky state bypasses assembly. All sources are read before an override program runs. Nested compositions and overrides use the same getter pipeline and mutation interpreter as ordinary variables. + +Detailed results use `:variable_composed`, or `:variable_override_rule` when an override matches. Ordinary missing or failed source getters retain their normal false or null fallback. Dependency cycles and depth errors fail the whole composition. + +## Getting evaluations ```elixir features = Featurevisor.get_feature_evaluations(f, context) @@ -262,6 +335,8 @@ variables = Featurevisor.get_global_variable_evaluations(f, context, ["supportEm ## Sticky +Treat sticky values supplied at creation or through setters as read only inputs. Use a setter to change sticky state. Returned evaluation values can be used independently. + Sticky values keep selected evaluations stable for the lifetime of an instance or child instance. ### Setting initial sticky values @@ -285,7 +360,7 @@ f = Featurevisor.create_featurevisor(%{ ```elixir Featurevisor.set_sticky_features(f, sticky_features) Featurevisor.set_sticky_variables(f, sticky_variables) -Featurevisor.set_sticky_features(f, replacement, true) +Featurevisor.set_sticky_features(f, replacement, true) # replace ``` Sticky values are instance state. They are not accepted as public per evaluation options. @@ -294,12 +369,16 @@ Sticky values are instance state. They are not accepted as public per evaluation `set_datafile/3` accepts a decoded map or JSON string. +It returns `true` when the datafile is accepted, including when its revision and contents are unchanged. It returns `false` when the input is rejected or the instance is closed. Accepted input still emits `:datafile_set`, even when unchanged. Diagnostic and event callback failures do not change the return value. + +Invalid input retains the last valid datafile and reports the existing error diagnostic without emitting `:datafile_set`. The return value is a boolean, not `:ok` or `{:error, reason}`. Read `diagnostic.original_error` through your diagnostic callback or the `:error` event when you need the rejection reason. Closed instances do not emit diagnostics or events for attempted updates. + ### Merging by default -Incoming features, global variables, and segments are merged into the stored datafile. Incoming entries replace entries with the same key. +Incoming features, global variables, and segments are merged into the stored datafile. Incoming entries replace entries with the same key. Legacy schema version 2 selectors normalize eagerly before merging, without changing the supplied map. Schema version 4 entities normalize lazily on their first read. Informational schema version metadata is retained. A partial update invalidates incoming entity caches only. Complete replacement clears them all. ```elixir -Featurevisor.set_datafile(f, next_datafile) +accepted = Featurevisor.set_datafile(f, next_datafile) ``` ### Replacing @@ -307,7 +386,7 @@ Featurevisor.set_datafile(f, next_datafile) Pass `true` to replace the complete datafile: ```elixir -Featurevisor.set_datafile(f, next_datafile, true) +accepted = Featurevisor.set_datafile(f, next_datafile, true) # replace ``` ### Loading datafiles @@ -340,6 +419,8 @@ f = Featurevisor.create_featurevisor(%{ Every `Featurevisor.Diagnostic` contains `level`, `code`, `message`, and an always present `details` map. Error diagnostics also emit the `:error` event. +Malformed stringified selector JSON fails that complete selector, including through negated segment aliases, without rejecting the accepted datafile or affecting unrelated entities. A broken root reports `:conditions_unmet`. The error level `conditions_parse_error` diagnostic appears once per malformed selector per incoming entity load: during schema version 2 setting, after accepted storage, or on the first schema version 4 entity read, after caching. Its details contain exactly `conditions`, `entityType` and `entityKey`. There is no location field. Diagnostic callbacks can safely reenter getters. + Featurevisor project linting enforces the portable regular expression subset shared by all SDKs. The Elixir runtime treats the `g` flag as a compatibility no op. Invalid patterns or flags produce a `condition_match_error` diagnostic and do not match. ## Events @@ -362,7 +443,7 @@ Supported events are: - `:sticky_variables_set` - `:error` -The `:datafile_set` details include changed `features` and `variables`, including dependants affected by changed required features or segments. +The `:datafile_set` details include changed `features` and `variables`, including dependants affected by changed feature, variable and segment conditions or composition sources. Additions and removals propagate through transitive mixed dependencies. ## Evaluation details @@ -375,10 +456,14 @@ variable = Featurevisor.evaluate_variable(f, "checkout", "title", context) global_variable = Featurevisor.evaluate_global_variable(f, "supportEmail", context) ``` -Each method returns a `Featurevisor.Evaluation` struct. +Each method returns a `Featurevisor.Evaluation` struct. Common fields include `type`, `reason`, entity keys, `conditions`, selected rule and force metadata, value and schema metadata, and matched override keys, paths and indexes. Accumulation includes `variable_override_paths`. Unmet root conditions use `:conditions_unmet`. Successful compositions use `:variable_composed`. Use `Featurevisor.Evaluation.to_map/1` for camelCase wire field names. ## Modules +The before evaluation and after evaluation phases each use the module membership at the start of that phase. Where an evaluation uses bucketing, the bucket key and bucket value phases each use their own starting membership. Adding or removing a module during a phase does not change the iteration already in progress. The next phase uses the membership then installed. A module added before the after phase can receive after callbacks for evaluations already in progress, even if it did not receive their before callbacks. A module removed before the after phase receives no after callback. + +Module options expose `dependency_of` on internal reads. It identifies the immediate requesting evaluation with `type`, optional `feature_key` and optional `variable_key`. `dependency_purpose` is `:condition` or `:composition`. Independent application calls have neither marker and never receive private session state. Memoization is local to a public call and separates purposes, requester identities, context and current instance state. + Modules extend evaluation and lifecycle behaviour without changing evaluation methods. ```elixir @@ -405,9 +490,7 @@ remove = Featurevisor.add_module(f, module) remove.() ``` -Module callbacks are `setup`, `before`, `before_evaluation`, `bucket_key`, `bucket_value`, `after_evaluation`, `after`, and `close`. 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. Callback option maps use idiomatic snake case keys such as `bucket_key` and `bucket_value`. - -`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. +Module callbacks are `setup`, `before_evaluation`, `bucket_key`, `bucket_value`, `after_evaluation`, and `close`. Evaluation callbacks run in registration order and surround the complete evaluation once, including accumulating global override programs. Condition dependencies and composition sources use the same module pipeline. Callback option maps use idiomatic snake case keys such as `bucket_key` and `bucket_value`. Named duplicates are rejected with a `duplicate_module` diagnostic. A failed setup is removed, its diagnostic subscriptions are cleared, and its close callback is invoked. @@ -445,19 +528,19 @@ Featurevisor.close(f) Close invokes module close callbacks and removes listeners, diagnostic subscriptions, and caches. Calling close more than once is safe. +An evaluation already in progress can finish using its retained datafile. Its resources are released when it finishes. + Do not call `close/1` directly for a supervised instance. Stop its supervisor or remove its child instead. A closed handle evaluates against an empty datafile, so flags return `false`, variations and variables return `nil`, and the revision returns `"unknown"`. ## OpenFeature -The OpenFeature provider is published as a separate Hex package. Applications -that only use the Featurevisor SDK do not install or compile OpenFeature or the -provider code. +The OpenFeature provider is published as a separate Hex package. Applications that only use the Featurevisor SDK do not install or compile OpenFeature or the provider code. ```elixir def deps do [ - {:featurevisor, "~> 1.2"}, - {:featurevisor_openfeature, "~> 1.2"}, + {:featurevisor, "~> 2.0"}, + {:featurevisor_openfeature, "~> 2.0"}, {:open_feature, "~> 0.1.3"} ] end @@ -480,15 +563,13 @@ enabled = ) ``` -You can also pass an existing Featurevisor instance. The provider borrows it -and does not close it: +Pass an existing Featurevisor instance for the provider to borrow. The provider does not close it: ```elixir provider = FeaturevisorOpenFeature.Provider.new(featurevisor: f) ``` -OpenFeature uses one flag key while Featurevisor supports flags, variations, -feature variables, and global variables: +OpenFeature uses one flag key while Featurevisor supports flags, variations, feature variables, and global variables: | OpenFeature key | Featurevisor evaluation | | --- | --- | @@ -497,87 +578,101 @@ feature variables, and global variables: | `checkout:title` | Variable `title` inside feature `checkout` | | `variable:supportEmail` | Global variable `supportEmail` | -`targeting_key_field`, `key_separator`, `variation_key`, and -`global_variable_prefix` customize this mapping. The targeting key maps to -`userId` by default. The global variable prefix defaults to `variable` and -cannot contain the separator. +`targeting_key_field`, `key_separator`, `variation_key`, and `global_variable_prefix` customize this mapping. The targeting key maps to `userId` by default. The global variable prefix defaults to `variable` and cannot contain the separator. + +The official OpenFeature Elixir SDK represents evaluation context as a map. The provider accepts `"targetingKey"`, `"targeting_key"`, or `:targeting_key` and copies its string value to the configured Featurevisor targeting field. -The official OpenFeature Elixir SDK represents evaluation context as a map. The -provider accepts `"targetingKey"`, `"targeting_key"`, or `:targeting_key` and -copies its string value to the configured Featurevisor targeting field. +The provider implements boolean, string, number, and map resolution. Root `conditions_unmet` maps to OpenFeature `DISABLED`. Compositions and matched overrides map to `TARGETING_MATCH`. Composed values are already evaluated and are not JSON decoded again. Featurevisor evaluation reasons, variation values, revision, schema version, rule keys, bucket information, and override information are mapped to OpenFeature resolution details and flag metadata. Missing definitions, type mismatches, and invalid datafiles use standard OpenFeature errors. Replacing an invalid datafile with a valid one recovers the provider. -The provider implements boolean, string, number, and map resolution. -Featurevisor evaluation reasons, variation values, revision, schema version, -rule keys, bucket information, and override information are mapped to -OpenFeature resolution details and flag metadata. Missing definitions, type -mismatches, and invalid datafiles use standard OpenFeature errors. Replacing an -invalid datafile with a valid one recovers the provider. +The OpenFeature Elixir SDK does not currently expose provider tracking. Featurevisor modules and diagnostics continue to run inside the Featurevisor instance. -The OpenFeature Elixir SDK does not currently expose provider tracking. -Featurevisor modules and diagnostics continue to run inside the Featurevisor -instance. +An invalid initial datafile makes provider initialization return `{:error, :parse_error}`, independently of `log_level`. A valid update restores evaluation health. Initialize or register the provider again after a failed registration. Shutdown removes provider subscriptions and closes only an owned instance. See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for the shared key convention and providers for other languages. ## CLI usage -Build the escript from this repository: +Build the escript from this SDK repository: -```sh +```bash +mix deps.get mix escript.build ``` -The Elixir runner delegates project parsing, Target discovery, matrix expansion, and datafile generation to `npx featurevisor`. Evaluations and assertions run through this Elixir SDK. +The native test runner requires `@featurevisor/cli` 4.0.0 or later and Node.js 24 or later. Install the Node.js CLI in your Featurevisor project: + +```bash +npm install --save-dev @featurevisor/cli@^4.0.0 +``` + +Project parsing, test definitions, execution protocol, matrix expansion and datafile generation use `npx featurevisor`. Evaluations run through this Elixir SDK. Use the Node.js CLI directly for authoring commands. This native CLI provides only test, benchmark and distribution commands. 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. ### Test -```sh +```bash ./featurevisor test \ - --projectDirectoryPath=../featurevisor/examples/example-1 \ - --onlyFailures + --project-directory-path=/path/to/featurevisor-project \ + --only-failures ``` -Use one or more Targets when required: +See [Testing](https://featurevisor.com/docs/testing/) for authoring specs and [Checks](https://featurevisor.com/docs/testing/#checks) for check syntax and operators. -```sh -./featurevisor test \ - --projectDirectoryPath=../featurevisor/examples/example-1 \ - --target=all \ - --target=checkout -``` +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. + +Segment owners and checks evaluate through ordinary SDK gates with the selected environment, Target, fixed bucket, effective context and sticky state. Selected compiled Target segments remain authoritative. The execution protocol restores missing definitions from its compiled transitive `_segments` map. Attribute only segment chains can run without an environment, while dependent segments require the selected datafile. Check references are preflighted transitively before any getter runs. Synthetic segment helper features are excluded from coverage. + +Linting remains a separate authoring step. The native `test` command does not automatically run `lint`. + +Printed rerun commands use POSIX shell quoting. JSON reports also include raw argument arrays. ### Benchmark -```sh +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. Elixir evaluates them. Replace `checkout` with a feature defined in your project. + +```bash ./featurevisor benchmark \ - --projectDirectoryPath=../featurevisor/examples/example-1 \ - --environment=production \ - --feature=allowSignup \ - --variation \ + --project-directory-path=/path/to/featurevisor-project \ + --feature=checkout \ --context='{"country":"nl"}' \ - --n=1000000 + --n=1000 ``` -Benchmark output reports total duration and the minimum, average, and maximum duration of individual evaluations. - -Pass `--variable=supportEmail` without `--feature` to benchmark a global variable. +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 -```sh +```bash ./featurevisor assess-distribution \ - --projectDirectoryPath=../featurevisor/examples/example-1 \ - --environment=production \ - --feature=allowSignup \ - --context='{"country":"nl"}' \ - --populateUuid=userId \ - --n=10000 + --project-directory-path=/path/to/featurevisor-project \ + --feature=checkout \ + --populate-uuid=userId \ + --n=1000 ``` -Repeat `--target` and `--populateUuid` where needed. +Repeat `--populate-uuid=` or `--populate-uuid ` to generate UUID values for multiple context attributes. Add `--verbose` to print generated contexts. Both measurement commands accept `--n=1000`, `--n 1000`, `-n=1000` and `-n 1000`. Counts must be positive integers. Quote JSON context and paths containing spaces as one shell argument. See [Assess distribution](https://featurevisor.com/docs/cli/#assess-distribution). + +## GitHub repositories + +- SDK: [featurevisor/featurevisor-elixir](https://github.com/featurevisor/featurevisor-elixir) +- Example application: [featurevisor/featurevisor-example-elixir](https://github.com/featurevisor/featurevisor-example-elixir) + + +The aggregate evaluation reference has moved to [Getting evaluations](#getting-evaluations). + ## Development Install dependencies and run the complete local gate: @@ -593,9 +688,11 @@ Run the SDK against the sibling Featurevisor example project: make test-example-1 ``` -The integration target executes all expanded `example-1` assertions, including Target datafiles, through the Elixir evaluator. +The native tests cover runtime conformance and project integration. + +Run `mix test test/readme_initialization_test.exs` to execute the actual initialization snippet with an isolated local datafile. The check covers evaluation, cleanup and failures, and also runs in the ordinary SDK suite and CI. -`make check` verifies both the base SDK and OpenFeature provider. The separate `make test-example-1` target requires the sibling Featurevisor monorepo, so checks and publishing workflows run that integration explicitly after the package gate. +`make check` verifies both the base SDK and OpenFeature provider. The separate `make test-example-1` target requires the sibling Featurevisor monorepo, so checks and publishing workflows run that integration after the package gate. ## Publishing @@ -608,11 +705,9 @@ mix hex.publish --dry-run The repository publishes `featurevisor` and `featurevisor_openfeature` with the same version. The release workflow verifies the tag, package contents, documentation, and `example-1` integration, then publishes the base package before the provider package. -Hex can only resolve the provider's exact base package dependency after the -matching `featurevisor` release is visible. The workflow waits for that release -before it performs the final provider dry run and publication. +Hex can only resolve the provider's exact base package dependency after the matching `featurevisor` release is visible. The workflow waits for that release before it performs the final provider dry run and publication. -The published Hex package intentionally includes runtime source, `mix.exs`, the README, and the licence. Tests and the conformance fixture remain repository verification assets and are not shipped to consumers. +The published Hex package includes runtime source, tests, both canonical conformance fixtures, `mix.exs`, the README, and the licence so its source distribution can run the same verification. ## License diff --git a/bench/sticky_session.exs b/bench/sticky_session.exs new file mode 100644 index 0000000..3258083 --- /dev/null +++ b/bench/sticky_session.exs @@ -0,0 +1,87 @@ +# Serial, per getter benchmark with a constructed sticky handoff corpus. +# Run with: mix run bench/sticky_session.exs 5000 + +iterations = + case Enum.drop_while(System.argv(), &(&1 == "--")) do + [value | _] -> String.to_integer(value) + _ -> 5000 + end + +if iterations < 1, do: raise("positive iteration count required") + +datafile = %{ + "schemaVersion" => "2", + "revision" => "sticky-bench", + "segments" => %{}, + "features" => %{ + "gate" => %{ + "bucketBy" => "userId", + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + } + }, + "variables" => %{ + "leaf" => %{"type" => "integer", "defaultValue" => 1}, + "root" => %{ + "type" => "integer", + "defaultValue" => 1, + "disabledValue" => 0, + "requiredFeatures" => ["gate", "gate"], + "requiredVariables" => [ + %{"variable" => "leaf", "operator" => "equals", "value" => 1}, + %{"variable" => "leaf", "operator" => "equals", "value" => 1} + ] + } + } +} + +IO.puts("kind,size,scope,iterations,mean_us") + +for kind <- [:features, :variables], size <- [0, 100, 1000, 5000], scope <- [:instance, :child] do + filler = + if size <= 1, + do: %{}, + else: for(index <- 1..(size - 1), into: %{}, do: {"unrelated#{index}", index}) + + features = + if kind == :features and size > 0, + do: + Map.put(Map.new(filler, fn {key, _} -> {key, %{"enabled" => true}} end), "gate", %{ + "enabled" => true + }), + else: %{} + + variables = if kind == :variables and size > 0, do: Map.put(filler, "leaf", 1), else: %{} + sticky = %{sticky_features: features, sticky_variables: variables} + + options = + %{datafile: datafile, log_level: :fatal} + |> Map.merge(if(scope == :instance, do: sticky, else: %{})) + + f = Featurevisor.create_featurevisor(options) + child = if scope == :child, do: Featurevisor.spawn(f, %{"userId" => "bench"}, sticky), else: nil + + getter = + if child, + do: fn -> Featurevisor.Child.get_global_variable(child, "root") end, + else: fn -> Featurevisor.get_global_variable(f, "root", %{"userId" => "bench"}) end + + for _ <- 1..100 do + if getter.() != 1, do: raise("unexpected gated value") + end + + total_ns = + Enum.reduce(1..iterations, 0, fn _, total -> + started = System.monotonic_time(:nanosecond) + value = getter.() + elapsed = System.monotonic_time(:nanosecond) - started + if value != 1, do: raise("unexpected gated value") + total + elapsed + end) + + IO.puts( + "#{kind},#{size},#{scope},#{iterations},#{Float.round(total_ns / iterations / 1000, 3)}" + ) + + if child, do: Featurevisor.Child.close(child) + Featurevisor.close(f) +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/lib/featurevisor.ex b/lib/featurevisor.ex index 26cf363..a3e9352 100644 --- a/lib/featurevisor.ex +++ b/lib/featurevisor.ex @@ -3,10 +3,20 @@ defmodule Featurevisor do Feature flags, experiments, and remote configuration for Elixir. Create an isolated instance with `create_featurevisor/1`, then evaluate flags, - variations, and variables against a Featurevisor schema version 2 datafile. + variations, and variables against a Featurevisor schema version 4 or legacy schema version 2 datafile. """ - alias Featurevisor.{Conditions, Diagnostic, Evaluator, Module, Server} + alias Featurevisor.{ + Conditions, + Diagnostic, + EvaluationSession, + Evaluator, + Module, + Mutations, + Server, + StickyStore + } + require Logger @opaque t :: %__MODULE__{pid: pid(), table: :ets.tid()} @@ -79,25 +89,22 @@ defmodule Featurevisor do } end - @doc "Merges or replaces the stored datafile." - @spec set_datafile(t(), map() | String.t(), boolean()) :: :ok | {:error, term()} + @doc """ + Merges or replaces the stored datafile. + + Returns true when accepted, including unchanged input, or false when rejected or closed. + """ + @spec set_datafile(t(), map() | String.t(), boolean()) :: boolean() def set_datafile(instance, input, replace \\ false) do - if open?(instance), do: set_live_datafile(instance, input, replace), else: :ok + if open?(instance), do: set_live_datafile(instance, input, replace), else: false end defp set_live_datafile(instance, input, replace) do with {:ok, datafile} <- Server.parse_datafile(input), - true <- Server.valid_datafile?(datafile) do - details = - Server.update(instance.pid, fn snapshot -> - stored = - if replace, do: datafile, else: Server.merge_datafile(snapshot.datafile, datafile) - - :ets.delete_all_objects(snapshot.regex_cache) - - {Server.datafile_details(snapshot.datafile, stored, replace), - %{snapshot | datafile: stored}} - end) + true <- Server.valid_datafile?(datafile), + {datafile, errors} <- Featurevisor.Normalize.datafile(datafile), + {:ok, details} <- store_datafile(instance, datafile, replace) do + Enum.each(errors, &report(instance, &1)) report(instance, %{ level: :info, @@ -107,8 +114,11 @@ defmodule Featurevisor do }) trigger(instance, :datafile_set, details) - :ok + true else + :closed -> + false + error -> reason = if match?({:error, _}, error), @@ -123,10 +133,17 @@ defmodule Featurevisor do details: %{} }) - {:error, reason} + false end end + defp store_datafile(instance, datafile, replace) do + Server.set_datafile(instance.pid, datafile, replace) + catch + :exit, reason -> + if open?(instance), do: exit(reason), else: :closed + end + @doc "Merges or replaces the stored context." def set_context(instance, context, replace \\ false) when is_map(context) do if open?(instance), do: set_live_context(instance, context, replace), else: :ok @@ -225,27 +242,52 @@ defmodule Featurevisor do @doc "Returns the informational datafile schema version." def get_schema_version(instance), do: snapshot(instance).datafile["schemaVersion"] @doc "Returns a feature definition." - def get_feature(instance, key), do: get_in(snapshot(instance).datafile, ["features", key]) - @doc "Returns a segment definition." - def get_segment(instance, key) do - case get_in(snapshot(instance).datafile, ["segments", key]) do - nil -> - nil + def get_feature(instance, key), + do: + Server.with_snapshot(instance.table, fn snap -> + Featurevisor.Normalize.get( + snap.datafile, + "features", + key, + snap.regex_cache, + reporter(instance) + ) + |> Featurevisor.Normalize.public() + end) - segment -> - Map.update!(segment, "conditions", &Conditions.parse_conditions(&1, reporter(instance))) - end - end + @doc "Returns a segment definition." + def get_segment(instance, key), + do: + Server.with_snapshot(instance.table, fn snap -> + Featurevisor.Normalize.get( + snap.datafile, + "segments", + key, + snap.regex_cache, + reporter(instance) + ) + |> Featurevisor.Normalize.public() + end) @doc "Returns all feature keys. Ordering is not guaranteed." - def get_feature_keys(instance), do: Map.keys(snapshot(instance).datafile["features"]) + def get_feature_keys(instance), + do: + Server.with_snapshot( + instance.table, + &Featurevisor.EntityCollection.keys(&1.datafile["features"]) + ) + @doc "Returns variable keys for a feature." def get_variable_keys(instance, key), do: instance |> get_feature(key) |> then(&Map.keys((&1 && &1["variablesSchema"]) || %{})) @doc "Returns all global variable keys. Ordering is not guaranteed." def get_global_variable_keys(instance), - do: Map.keys(snapshot(instance).datafile["variables"] || %{}) + do: + Server.with_snapshot( + instance.table, + &Featurevisor.EntityCollection.keys(&1.datafile["variables"] || %{}) + ) @doc "Returns whether a feature defines variations." def has_variations?(instance, key), @@ -278,7 +320,8 @@ defmodule Featurevisor do evaluation = evaluate_variable(instance, feature_key, variable_key, context, options) value = evaluation.variable_value - if get_in(evaluation.variable_schema || %{}, ["type"]) == "json" and is_binary(value) do + if not evaluation.__getter_value_normalized and + get_in(evaluation.variable_schema || %{}, ["type"]) == "json" and is_binary(value) do case Jason.decode(value) do {:ok, parsed} -> parsed @@ -311,9 +354,9 @@ defmodule Featurevisor do def get_variable_integer(instance, feature, variable, context \\ %{}, options \\ %{}), do: typed(get_variable(instance, feature, variable, context, options), :integer) - @doc "Returns a finite numeric variable after runtime type checking." - def get_variable_double(instance, feature, variable, context \\ %{}, options \\ %{}), - do: typed(get_variable(instance, feature, variable, context, options), :double) + @doc "Returns a finite number variable, whole or fractional, after runtime type checking." + def get_variable_number(instance, feature, variable, context \\ %{}, options \\ %{}), + do: typed(get_variable(instance, feature, variable, context, options), :number) @doc "Returns a list variable after runtime type checking." def get_variable_array(instance, feature, variable, context \\ %{}, options \\ %{}), @@ -329,169 +372,269 @@ defmodule Featurevisor do @doc "Evaluates a global variable and returns details." def evaluate_global_variable(instance, variable_key, context \\ %{}, options \\ %{}) do - snap = snapshot(instance) - options = normalize_options(options) + Server.with_snapshot(instance.table, fn snap -> + evaluate_global_snapshot(instance, snap, variable_key, context, options) + end) + end - evaluation_options = %{ - type: :variable, - variable_key: variable_key, - context: Map.merge(snap.context, context), - default_variable_present: Map.has_key?(options, :default_variable_value), - default_variable_value: Map.get(options, :default_variable_value) - } + defp evaluate_global_snapshot(instance, snap, variable_key, context, options) do + options = normalize_options(options) + options = evaluation_sources(instance, options) - try do - evaluation_options = - Enum.reduce(snap.modules, evaluation_options, fn module, current -> - if module.before_evaluation, do: module.before_evaluation.(current), else: current - end) + with_request_session(options, fn options -> + Server.seed_request_cache(instance.table, snap) + session = options.__session - resolved_key = evaluation_options.variable_key - variable = get_in(snap.datafile, ["variables", resolved_key]) - sticky = Map.get(options, :__child_sticky_variables, snap.sticky_variables || %{}) - - evaluation = - cond do - Map.has_key?(sticky, resolved_key) -> - %Featurevisor.Evaluation{ - type: :variable, - variable_key: resolved_key, - variable: variable, - variable_value: sticky[resolved_key], - reason: :sticky - } + evaluation_options = %{ + type: :variable, + variable_key: variable_key, + dependency_purpose: options[:__dependency_purpose], + context: effective_context(snap, context, options), + default_variable_present: Map.has_key?(options, :default_variable_value), + default_variable_value: Map.get(options, :default_variable_value) + } - variable && - not required_features_match?( - instance, - variable["requiredFeatures"], - evaluation_options.context, - options - ) -> - value = - if variable["useDefaultWhenDisabled"], - do: variable["defaultValue"], - else: variable["disabledValue"] - - %Featurevisor.Evaluation{ - type: :variable, - variable_key: resolved_key, - variable: variable, - variable_value: value, - reason: :required_features_unmet - } + evaluation_options = + if snap.modules == [] do + evaluation_options + else + Map.merge(evaluation_options, %{ + sticky_features: StickyStore.materialize(options.__sticky_features_source.()), + sticky_variables: StickyStore.materialize(options.__sticky_variables_source.()) + }) + end - variable -> - case matched_global_override( - instance, - snap, - variable["overrides"] || [], - evaluation_options.context, - options - ) do - {override, index} -> - %Featurevisor.Evaluation{ - type: :variable, - variable_key: resolved_key, - variable: variable, - variable_value: override["value"], - variable_override_index: index, - variable_override_key: override["key"], - variable_override_path: override["keyPath"], - reason: :variable_override_rule - } + memo_options = + Map.merge(evaluation_options, %{ + sticky_owner: true, + sticky_features_token: options[:__sticky_features_token], + sticky_variables_token: options[:__sticky_variables_token], + regex_cache: snap.regex_cache, + report: reporter(instance), + state_signature: options.__state_signature, + child_signature: options[:__child_signature] + }) - nil -> - %Featurevisor.Evaluation{ - type: :variable, - variable_key: resolved_key, - variable: variable, - variable_value: variable["defaultValue"], - reason: :variable_default + try do + EvaluationSession.run( + session, + memo_options, + options[:__prerequisite] == true, + fn evaluation_options -> + {evaluation_options, resolved_options} = + if snap.modules == [], + do: {evaluation_options, options}, + else: + before_global_modules(snap.modules, evaluation_options, memo_options, options) + + if snap.modules != [], + do: EvaluationSession.update_current(session, evaluation_options) + + resolved_key = evaluation_options.variable_key + + variable = + read_entity(instance, "variables", resolved_key) + + sticky = resolved_options.__sticky_variables_source.() || %{} + + evaluation = + cond do + StickyStore.has_key?(sticky, resolved_key) -> + %Featurevisor.Evaluation{ + type: :variable, + variable_key: resolved_key, + variable: variable, + variable_value: sticky[resolved_key], + reason: :sticky + } + + variable -> + evaluate_global_variable_definition( + instance, + snap, + resolved_key, + variable, + evaluation_options.context, + resolved_options + ) + + true -> + %Featurevisor.Evaluation{ + type: :variable, + variable_key: resolved_key, + reason: :variable_not_found + } + end + + value_present = + case evaluation.reason do + :sticky -> + true + + reason when reason in [:variable_override_rule, :variable_composed] -> + true + + :variable_default -> + variable && Map.has_key?(variable, "defaultValue") + + :conditions_unmet -> + Map.has_key?(variable, "compose") or + Map.has_key?( + variable, + if(variable["useDefaultWhenDisabled"], + do: "defaultValue", + else: "disabledValue" + ) + ) + + _ -> + false + end + + evaluation = + if not value_present and evaluation_options.default_variable_present, + do: %{evaluation | variable_value: evaluation_options.default_variable_value}, + else: evaluation + + modules = Server.snapshot(instance.table).modules + + after_options = + if modules == [] do + evaluation_options + else + evaluation_options + |> Map.drop([ + :sticky_owner, + :sticky_features_token, + :sticky_variables_token, + :regex_cache, + :report, + :state_signature, + :child_signature + ]) + |> Map.put_new_lazy(:sticky_features, fn -> + StickyStore.materialize(resolved_options.__sticky_features_source.()) + end) + |> Map.put_new_lazy(:sticky_variables, fn -> + StickyStore.materialize(resolved_options.__sticky_variables_source.()) + end) + end + + evaluation = + Enum.reduce(modules, evaluation, fn module, current -> + if module.after_evaluation, + do: module.after_evaluation.(current, after_options), + else: current + end) + + if variable && variable["deprecated"] do + report(instance, %{ + level: :warn, + code: "deprecated_variable", + message: "Variable \"#{resolved_key}\" is deprecated", + details: %{ + variableKey: resolved_key, + evaluation: Featurevisor.Evaluation.to_map(evaluation) } + }) end - true -> - %Featurevisor.Evaluation{ - type: :variable, - variable_key: resolved_key, - reason: :variable_not_found - } - end - - value_present = - case evaluation.reason do - :sticky -> - true - - :variable_override_rule -> - true - - :variable_default -> - variable && Map.has_key?(variable, "defaultValue") - - :required_features_unmet -> - variable && - Map.has_key?( - variable, - if(variable["useDefaultWhenDisabled"], do: "defaultValue", else: "disabledValue") - ) - - _ -> - false - end - - evaluation = - if not value_present and evaluation_options.default_variable_present, - do: %{evaluation | variable_value: evaluation_options.default_variable_value}, - else: evaluation - - evaluation = - Enum.reduce(snap.modules, evaluation, fn module, current -> - if module.after_evaluation, - do: module.after_evaluation.(current, evaluation_options), - else: current - end) + report(instance, %{ + level: :debug, + code: Atom.to_string(evaluation.reason), + message: "Global variable evaluated", + details: Featurevisor.Evaluation.to_map(evaluation) + }) - if variable && variable["deprecated"] do - report(instance, %{ - level: :warn, - code: "variable_deprecated", - message: "Variable \"#{resolved_key}\" is deprecated", - details: %{ - variableKey: resolved_key, - evaluation: Featurevisor.Evaluation.to_map(evaluation) + evaluation + end + ) + rescue + error -> + if (match?(%Featurevisor.RequirementCycleError{}, error) or + match?(%Featurevisor.RequirementError{}, error)) and + options[:__prerequisite] == true, + do: reraise(error, __STACKTRACE__) + + evaluation = %Featurevisor.Evaluation{ + type: :variable, + variable_key: variable_key, + reason: :error, + error: error } - }) - end - report(instance, %{ - level: :debug, - code: Atom.to_string(evaluation.reason), - message: "Global variable evaluated", - details: Featurevisor.Evaluation.to_map(evaluation) - }) + EvaluationSession.remember( + session, + memo_options, + evaluation, + options[:__prerequisite] == true + ) - evaluation - rescue - error -> - evaluation = %Featurevisor.Evaluation{ - type: :variable, - variable_key: variable_key, - reason: :error, - error: error - } + report(instance, %{ + level: :error, + code: "evaluation_error", + message: "Global variable evaluation failed", + originalError: error, + details: Featurevisor.Evaluation.to_map(evaluation) + }) - report(instance, %{ - level: :error, - code: "evaluation_error", - message: "Global variable evaluation failed", - originalError: error, - details: Featurevisor.Evaluation.to_map(evaluation) - }) + evaluation + end + end) + end - evaluation - end + defp before_global_modules(modules, evaluation_options, memo_options, options) do + evaluation_options = + Enum.reduce( + modules, + Map.drop(evaluation_options, [ + :sticky_owner, + :sticky_features_token, + :sticky_variables_token, + :regex_cache, + :report, + :state_signature, + :child_signature + ]), + fn module, current -> + if module.before_evaluation, + do: module.before_evaluation.(current), + else: current + end + ) + + replaced_features = evaluation_options[:sticky_features] != memo_options[:sticky_features] + replaced_variables = evaluation_options[:sticky_variables] != memo_options[:sticky_variables] + + if replaced_features or replaced_variables, + do: EvaluationSession.bypass_memo(options.__session) + + resolved_options = + options + |> Map.put( + :__sticky_features_source, + if(replaced_features, + do: fn -> evaluation_options[:sticky_features] end, + else: options.__sticky_features_source + ) + ) + |> Map.put( + :__sticky_variables_source, + if(replaced_variables, + do: fn -> evaluation_options[:sticky_variables] end, + else: options.__sticky_variables_source + ) + ) + |> Map.put( + :__sticky_features_token, + if(replaced_features, do: make_ref(), else: options[:__sticky_features_token]) + ) + |> Map.put( + :__sticky_variables_token, + if(replaced_variables, do: make_ref(), else: options[:__sticky_variables_token]) + ) + + {evaluation_options, resolved_options} end @doc "Returns a global variable value or nil. JSON variables are decoded." @@ -499,10 +642,22 @@ defmodule Featurevisor do evaluation = evaluate_global_variable(instance, variable_key, context, options) value = evaluation.variable_value - if get_in(evaluation.variable || %{}, ["type"]) == "json" and is_binary(value) do + if ((not evaluation.__getter_value_normalized and is_nil(evaluation.variable["compose"])) && + get_in(evaluation.variable || %{}, ["type"]) == "json") and is_binary(value) do case Jason.decode(value) do - {:ok, parsed} -> parsed - {:error, _error} -> nil + {:ok, parsed} -> + parsed + + {:error, error} -> + report(instance, %{ + level: :error, + code: "evaluation_error", + message: "getVariable failed", + originalError: error, + details: %{variableKey: variable_key} + }) + + nil end else value @@ -521,9 +676,9 @@ defmodule Featurevisor do def get_global_variable_integer(instance, key, context \\ %{}, options \\ %{}), do: typed(get_global_variable(instance, key, context, options), :integer) - @doc "Returns a typed numeric global variable." - def get_global_variable_double(instance, key, context \\ %{}, options \\ %{}), - do: typed(get_global_variable(instance, key, context, options), :double) + @doc "Returns a typed number global variable, whole or fractional." + def get_global_variable_number(instance, key, context \\ %{}, options \\ %{}), + do: typed(get_global_variable(instance, key, context, options), :number) @doc "Returns a typed list global variable." def get_global_variable_array(instance, key, context \\ %{}, options \\ %{}), @@ -576,19 +731,6 @@ defmodule Featurevisor do Map.new(keys, &{&1, get_global_variable(instance, &1, context, options)}) end - @doc false - def segment_matches?(instance, segment_key, context \\ %{}) do - snap = snapshot(instance) - - Conditions.all_segments?( - segment_key, - Map.merge(snap.context, context), - snap.datafile["segments"], - snap.regex_cache, - reporter(instance) - ) - end - @doc "Registers an SDK module. Returns an idempotent removal function or nil." def add_module(instance, %Module{} = original) do if open?(instance), do: add_live_module(instance, original), else: nil @@ -741,80 +883,471 @@ defmodule Featurevisor do end defp evaluate(instance, type, feature_key, variable_key, context, options) do - snap = snapshot(instance) + Server.with_snapshot(instance.table, fn snap -> + evaluate_snapshot(instance, snap, type, feature_key, variable_key, context, options) + end) + end + + defp evaluate_snapshot(instance, snap, type, feature_key, variable_key, context, options) do options = normalize_options(options) + options = evaluation_sources(instance, options) + + with_request_session(options, fn options -> + Server.seed_request_cache(instance.table, snap) + session = options.__session + + evaluation_options = %{ + type: type, + feature_key: feature_key, + variable_key: variable_key, + context: effective_context(snap, context, options), + # Preserve ordinary maps in callback options. The common path without + # modules reads only the entity rows it actually needs. + datafile: + if(snap.modules == [], + do: snap.datafile, + else: Server.materialize_datafile(snap.datafile) + ), + regex_cache: snap.regex_cache, + report: reporter(instance), + modules: snap.modules, + sticky_features: + if(snap.modules == [], + do: options.__sticky_features_source.(), + else: StickyStore.materialize(options.__sticky_features_source.()) + ), + sticky_variables: + if(snap.modules == [], + do: options.__sticky_variables_source.(), + else: StickyStore.materialize(options.__sticky_variables_source.()) + ), + sticky_owner: true, + sticky_features_token: options[:__sticky_features_token], + sticky_variables_token: options[:__sticky_variables_token], + session: session, + prerequisite: options[:__prerequisite] == true, + dependency_purpose: options[:__dependency_purpose], + state_signature: options.__state_signature, + state_source: fn -> Server.snapshot(instance.table) end, + entity_source: fn kind, key -> read_entity(instance, kind, key) end, + child_signature: options[:__child_signature], + sticky_features_source: options.__sticky_features_source, + sticky_variables_source: options.__sticky_variables_source, + condition_leaf: fn condition, dependencies -> + condition_leaf?(instance, condition, dependencies) + end, + default_variation_present: Map.has_key?(options, :default_variation_value), + default_variation_value: Map.get(options, :default_variation_value), + default_variable_present: Map.has_key?(options, :default_variable_value), + default_variable_value: Map.get(options, :default_variable_value) + } + + try do + Evaluator.evaluate_with_modules(evaluation_options) + rescue + error in [Featurevisor.RequirementCycleError, Featurevisor.RequirementError] -> + if options[:__prerequisite] == true do + reraise error, __STACKTRACE__ + else + evaluation = %Featurevisor.Evaluation{ + type: type, + feature_key: feature_key, + variable_key: variable_key, + reason: :error, + error: error + } + + report(instance, %{ + level: :error, + code: "evaluation_error", + message: "Evaluation failed", + originalError: error, + details: Featurevisor.Evaluation.to_map(evaluation) + }) + + evaluation + end + end + end) + end - evaluation_options = %{ - type: type, - feature_key: feature_key, - variable_key: variable_key, - context: Map.merge(snap.context, context), + defp effective_context(snap, context, options) do + if options[:__effective_context], do: context, else: Map.merge(snap.context, context) + end + + defp read_entity(instance, kind, key) do + Server.with_snapshot(instance.table, fn snap -> + Featurevisor.Normalize.get(snap.datafile, kind, key, snap.regex_cache, reporter(instance)) + end) + end + + defp global_dependencies(instance, snap, context, options) do + %{ + context: context, datafile: snap.datafile, regex_cache: snap.regex_cache, report: reporter(instance), - modules: snap.modules, - sticky: Map.get(options, :__child_sticky_features, snap.sticky_features), - default_variation_present: Map.has_key?(options, :default_variation_value), - default_variation_value: Map.get(options, :default_variation_value), - default_variable_present: Map.has_key?(options, :default_variable_value), - default_variable_value: Map.get(options, :default_variable_value) + session: options[:__session], + entity_source: fn kind, key -> read_entity(instance, kind, key) end, + sticky_features: options.__sticky_features_source.(), + sticky_variables: options.__sticky_variables_source.(), + sticky_features_source: options.__sticky_features_source, + sticky_variables_source: options.__sticky_variables_source, + sticky_features_token: options[:__sticky_features_token], + sticky_variables_token: options[:__sticky_variables_token], + child_signature: options[:__child_signature], + condition_leaf: fn condition, dependencies -> + condition_leaf?(instance, condition, dependencies) + end } - - Evaluator.evaluate_with_modules(evaluation_options) end - defp required_features_match?(_instance, nil, _context, _options), do: true - - defp required_features_match?(instance, requirements, context, options) do - Enum.all?(requirements, fn requirement -> - {feature, enabled, variation} = - if is_binary(requirement) do - {requirement, true, nil} + defp evaluate_global_variable_definition(instance, snap, key, variable, context, options) do + if Map.has_key?(variable, "conditions") and + not Conditions.matches?( + variable["conditions"], + global_dependencies(instance, snap, context, options) + ) do + value_key = + if is_nil(variable["compose"]) && variable["useDefaultWhenDisabled"], + do: "defaultValue", + else: "disabledValue" + + %Featurevisor.Evaluation{ + type: :variable, + variable_key: key, + variable: variable, + variable_value: variable[value_key], + reason: :conditions_unmet, + conditions: Featurevisor.Normalize.public(variable["conditions"]) + } + else + initial = + if Map.has_key?(variable, "compose") do + %Featurevisor.Evaluation{ + type: :variable, + variable_key: key, + variable: variable, + variable_value: + compose( + instance, + variable["compose"], + global_dependencies(instance, snap, context, options) + ), + reason: :variable_composed + } else - {requirement["feature"] || requirement["key"], Map.get(requirement, "enabled", true), - requirement["variation"]} + default_global_variable_evaluation(key, variable) end - enabled?(instance, feature, context, options) == enabled and - (is_nil(variation) or get_variation(instance, feature, context, options) == variation) + cond do + not is_nil(variable["overrideProgram"]) -> + evaluate_override_program( + instance, + snap, + key, + variable, + variable["overrideProgram"], + context, + Map.put(options, :__initial_evaluation, initial) + ) + + variable["compose"] -> + initial + + true -> + case matched_global_override( + instance, + snap, + variable["overrides"] || [], + context, + options + ) do + {override, index} -> + %{ + initial + | variable_value: override["value"], + variable_override_index: index, + variable_override_key: override["key"], + variable_override_path: override["keyPath"], + reason: :variable_override_rule + } + + nil -> + initial + end + end + end + end + + defp compose(instance, source, dependencies) when is_binary(source) and source != "", + do: dependency_value(instance, %{"variable" => source}, dependencies, :composition) + + defp compose(instance, entries, dependencies) when is_list(entries) and entries != [] do + Map.new(entries, fn entry -> + key = entry["key"] + + if not is_binary(key) or key in ["", "__proto__", "prototype", "constructor"], + do: raise(Featurevisor.RequirementCycleError, message: "Invalid composition property") + + {key, dependency_value(instance, entry, dependencies, :composition)} end) end + defp compose(_, _, _), + do: raise(Featurevisor.RequirementCycleError, message: "Invalid composition") + + defp evaluate_override_program(instance, snap, key, variable, program, context, options) + when is_list(program) do + config = %{ + instance: instance, + snap: snap, + key: key, + variable: variable, + program: program, + context: context, + options: options + } + + evaluate_override_step(config, %{ + index: 0, + value: options.__initial_evaluation.variable_value, + paths: [], + evaluation: options.__initial_evaluation + }) + end + + defp evaluate_override_program(_instance, _snap, _key, _variable, _program, _context, _options), + do: raise(ArgumentError, "Invalid variable override program") + + defp evaluate_override_step(config, state) do + if state.index == length(config.program) do + state.evaluation + else + apply_override_step(config, state) + end + end + + defp apply_override_step(config, state) do + override = Enum.at(config.program, state.index) + + unless is_map(override), do: raise(ArgumentError, "Invalid variable override program") + + matched = + global_override_matches?( + config.instance, + config.snap, + override, + config.context, + config.options + ) + + {value, paths, evaluation} = + if matched do + next_value = Mutations.apply(state.value, override["operations"]) + next_paths = state.paths ++ [override["keyPath"] || [override["key"]]] + + {next_value, next_paths, + %Featurevisor.Evaluation{ + type: :variable, + variable_key: config.key, + variable: config.variable, + variable_value: next_value, + variable_override_index: state.index, + variable_override_key: override["key"], + variable_override_path: override["keyPath"], + variable_override_paths: next_paths, + reason: :variable_override_rule + }} + else + {state.value, state.paths, state.evaluation} + end + + next_index = override[if(matched, do: "onMatch", else: "onMiss")] + + unless is_integer(next_index) and next_index > state.index and + next_index <= length(config.program) do + raise ArgumentError, "Invalid variable override program jump" + end + + evaluate_override_step(config, %{ + index: next_index, + value: value, + paths: paths, + evaluation: evaluation + }) + end + + defp default_global_variable_evaluation(key, variable) do + %Featurevisor.Evaluation{ + type: :variable, + variable_key: key, + variable: variable, + variable_value: variable["defaultValue"], + reason: :variable_default + } + end + + defp condition_leaf?(instance, %{"variable" => _} = condition, dependencies) do + value = dependency_value(instance, condition, dependencies, :condition) + + try do + {value, present} = required_path_value(value, Map.get(condition, "path", [])) + Conditions.value_matches?(value, condition, dependencies.regex_cache, present) + rescue + error -> + reraise Featurevisor.RequirementError, [message: Exception.message(error)], __STACKTRACE__ + end + end + + defp condition_leaf?(instance, %{"feature" => _} = condition, dependencies) do + dependency_value(instance, Map.delete(condition, "variation"), dependencies, :condition) == + Map.get(condition, "enabled", true) and + (not Map.has_key?(condition, "variation") or + dependency_value( + instance, + Map.put(condition, "variation", true), + dependencies, + :condition + ) == condition["variation"]) + end + + defp dependency_value(instance, reference, dependencies, purpose) do + features_source = + if dependencies[:sticky_features_token], + do: fn -> dependencies.sticky_features end, + else: dependencies[:sticky_features_source] + + variables_source = + if dependencies[:sticky_variables_token], + do: fn -> dependencies.sticky_variables end, + else: dependencies[:sticky_variables_source] + + getter_options = %{ + __sticky_features_source: features_source, + __sticky_variables_source: variables_source, + __sticky_features_token: dependencies[:sticky_features_token], + __sticky_variables_token: dependencies[:sticky_variables_token], + __child_signature: dependencies[:child_signature], + __session: dependencies[:session], + __prerequisite: true, + __effective_context: true, + __dependency_purpose: purpose + } + + cond do + Map.has_key?(reference, "variable") and Map.has_key?(reference, "feature") -> + get_variable( + instance, + reference["feature"], + reference["variable"], + dependencies.context, + getter_options + ) + + Map.has_key?(reference, "variable") -> + get_global_variable( + instance, + reference["variable"], + dependencies.context, + getter_options + ) + + reference["variation"] == true -> + get_variation(instance, reference["feature"], dependencies.context, getter_options) + + true -> + enabled?(instance, reference["feature"], dependencies.context, getter_options) + end + end + + defp required_path_value(value, path) when is_list(path) do + Enum.reduce_while(path, {value, true}, fn part, {current, _present} -> + case required_path_part(current, part) do + {:ok, next} -> {:cont, {next, true}} + :missing -> {:halt, {nil, false}} + end + end) + end + + defp required_path_value(_value, _path), + do: raise(ArgumentError, "Variable requirement path must be compiled") + + defp required_path_part(value, part) when is_binary(part) and is_map(value) do + if part in ["__proto__", "prototype", "constructor"] do + raise ArgumentError, "Unsafe variable requirement path" + end + + Map.fetch(value, part) + |> case do + {:ok, next} -> {:ok, next} + :error -> :missing + end + end + + defp required_path_part(value, "length") when is_list(value), do: {:ok, length(value)} + + defp required_path_part(value, part) when is_binary(part) and is_list(value) do + if Regex.match?(~r/^(0|[1-9][0-9]*)$/, part) do + required_path_part(value, String.to_integer(part)) + else + :missing + end + end + + defp required_path_part(value, part) when is_number(part) do + case safe_array_index(part) do + {:ok, index} when is_list(value) -> + if index < length(value), do: {:ok, Enum.at(value, index)}, else: :missing + + {:ok, _index} -> + :missing + + :error -> + raise ArgumentError, "Invalid variable requirement path" + end + end + + defp required_path_part(_value, part) when is_binary(part), do: :missing + + defp required_path_part(_value, _part), + do: raise(ArgumentError, "Invalid variable requirement path") + + defp safe_array_index(value) + when is_integer(value) and value >= 0 and value <= 9_007_199_254_740_991, + do: {:ok, value} + + defp safe_array_index(value) when is_float(value) do + if abs(value) < 1.797_693_134_862_315_7e308 and value >= 0 and + value <= 9_007_199_254_740_991 and value == trunc(value), + do: {:ok, trunc(value)}, + else: :error + end + + defp safe_array_index(_value), do: :error + defp matched_global_override(instance, snap, overrides, context, options) do overrides |> Enum.with_index() |> Enum.find_value(fn {override, index} -> - requirements_match = - required_features_match?(instance, override["requiredFeatures"], context, options) - - conditions_match = - not Map.has_key?(override, "conditions") or - Conditions.all_conditions?( - Conditions.parse_conditions(override["conditions"], reporter(instance)), - context, - snap.regex_cache, - reporter(instance) - ) - - segments_match = - not Map.has_key?(override, "segments") or - Conditions.all_segments?( - Conditions.parse_segments(override["segments"]), - context, - snap.datafile["segments"], - snap.regex_cache, - reporter(instance) - ) - - if requirements_match and conditions_match and segments_match, do: {override, index} + if global_override_matches?(instance, snap, override, context, options), + do: {override, index} end) end + defp global_override_matches?(instance, snap, override, context, options) do + not Map.has_key?(override, "conditions") or + Conditions.matches?( + override["conditions"], + global_dependencies(instance, snap, context, options) + ) + end + defp typed(value, :boolean) when is_boolean(value), do: value defp typed(value, :string) when is_binary(value), do: value defp typed(value, :integer) when is_integer(value), do: value defp typed(value, :integer) when is_float(value) and value == trunc(value), do: trunc(value) - defp typed(value, :double) when is_number(value), do: value + defp typed(value, :number) when is_number(value), do: value defp typed(value, :array) when is_list(value), do: value defp typed(value, :object) when is_map(value), do: value defp typed(_, _), do: nil @@ -824,6 +1357,41 @@ defmodule Featurevisor do defp normalize_options(options) when is_list(options), do: Map.new(options) defp normalize_options(options) when is_map(options), do: options + defp evaluation_sources(instance, options) do + options + |> Map.put_new(:__sticky_features_source, fn -> + Server.snapshot(instance.table).sticky_features + end) + |> Map.put_new(:__sticky_variables_source, fn -> + Server.snapshot(instance.table).sticky_variables + end) + |> Map.put_new(:__state_signature, fn -> + snap = Server.snapshot(instance.table) + + {snap.regex_cache, snap.generation} + end) + end + + defp with_request_session(options, fun) do + case options[:__session] do + nil -> + session = EvaluationSession.new() + + try do + Server.with_request_cache(fn -> + Featurevisor.Child.with_request_cache(options[:__child_snapshot], fn -> + fun.(Map.put(options, :__session, session)) + end) + end) + after + EvaluationSession.close(session) + end + + _ -> + fun.(options) + end + end + defp initialize_instance(pid, options) do instance = %__MODULE__{pid: pid, table: Server.table(pid)} @@ -987,6 +1555,9 @@ defmodule Featurevisor do fun.() rescue error -> IO.puts(:stderr, "[Featurevisor] Callback failed: #{Exception.message(error)}") + catch + kind, reason -> + IO.puts(:stderr, "[Featurevisor] Callback failed: #{Exception.format_banner(kind, reason)}") end defp once(fun) do diff --git a/lib/featurevisor/child.ex b/lib/featurevisor/child.ex index cfb3c7d..b5f6e17 100644 --- a/lib/featurevisor/child.ex +++ b/lib/featurevisor/child.ex @@ -1,9 +1,10 @@ defmodule Featurevisor.Child do @moduledoc "A child Featurevisor instance with isolated context and sticky state." - alias Featurevisor.Evaluation + alias Featurevisor.{Evaluation, StickyStore} + @request_cache_key {__MODULE__, :request_cache} - @opaque t :: %__MODULE__{parent: Featurevisor.t(), agent: pid()} - defstruct [:parent, :agent] + @opaque t :: %__MODULE__{parent: Featurevisor.t(), agent: pid(), sticky_store: pid()} + defstruct [:parent, :agent, :sticky_store] @doc false @spec create(Featurevisor.t(), map(), map() | keyword()) :: t() @@ -17,25 +18,37 @@ defmodule Featurevisor.Child do context: stored, sticky_features: Map.get(options, :sticky_features, %{}), sticky_variables: Map.get(options, :sticky_variables, %{}), + sticky_table: nil, listeners: %{}, unsubs: [], + generation: 0, closed: false } end) - %__MODULE__{parent: parent, agent: agent} + {:ok, sticky_store} = StickyStore.start(agent) + + Agent.update(agent, fn state -> + Process.link(sticky_store) + table = StickyStore.create(sticky_store, state.sticky_features, state.sticky_variables) + state |> Map.put(:sticky_table, table) |> Map.put(:sticky_store, sticky_store) + end) + + %__MODULE__{parent: parent, agent: agent, sticky_store: sticky_store} end @doc "Merges or replaces child context." def set_context(child, context, replace \\ false) do - if Process.alive?(child.agent), do: set_live_context(child, context, replace), else: :ok + while_live(child, :ok, fn -> set_live_context(child, context, replace) end) end defp set_live_context(child, context, replace) do details = Agent.get_and_update(child.agent, fn state -> value = if replace, do: context, else: Map.merge(state.context, context) - {%{context: value, replaced: replace}, %{state | context: value}} + + {%{context: value, replaced: replace}, + %{state | context: value, generation: state.generation + 1}} end) trigger(child, :context_set, details) @@ -44,66 +57,82 @@ defmodule Featurevisor.Child do @doc "Returns the effective child context." def get_context(child, context \\ %{}) do - stored = if Process.alive?(child.agent), do: Agent.get(child.agent, & &1.context), else: %{} + stored = while_live(child, %{}, fn -> Agent.get(child.agent, & &1.context) end) Featurevisor.get_context(child.parent) |> Map.merge(stored) |> Map.merge(context) end @doc "Merges or replaces child sticky feature values." def set_sticky_features(child, sticky, replace \\ false) do - if Process.alive?(child.agent), - do: set_live_sticky_features(child, sticky, replace), - else: :ok + while_live(child, :ok, fn -> set_live_sticky_features(child, sticky, replace) end) end defp set_live_sticky_features(child, sticky, replace) do - details = + {details, old_table, sticky_store} = Agent.get_and_update(child.agent, fn state -> value = if replace, do: sticky, else: Map.merge(state.sticky_features, sticky) + table = StickyStore.create(state.sticky_store, value, state.sticky_variables) - {%{ - features: Enum.uniq(Map.keys(state.sticky_features) ++ Map.keys(value)), - replaced: replace - }, %{state | sticky_features: value}} + {{%{ + features: Enum.uniq(Map.keys(state.sticky_features) ++ Map.keys(value)), + replaced: replace + }, state.sticky_table, state.sticky_store}, + %{state | sticky_features: value, sticky_table: table, generation: state.generation + 1}} end) + StickyStore.retire(sticky_store, old_table) + trigger(child, :sticky_features_set, details) :ok end @doc "Merges or replaces child sticky global variable values." def set_sticky_variables(child, sticky, replace \\ false) do - if Process.alive?(child.agent) do - details = + while_live(child, :ok, fn -> + {details, old_table, sticky_store} = Agent.get_and_update(child.agent, fn state -> value = if replace, do: sticky, else: Map.merge(state.sticky_variables, sticky) - - {%{ - variables: Enum.uniq(Map.keys(state.sticky_variables) ++ Map.keys(value)), - replaced: replace - }, %{state | sticky_variables: value}} + table = StickyStore.create(state.sticky_store, state.sticky_features, value) + + {{%{ + variables: Enum.uniq(Map.keys(state.sticky_variables) ++ Map.keys(value)), + replaced: replace + }, state.sticky_table, state.sticky_store}, + %{ + state + | sticky_variables: value, + sticky_table: table, + generation: state.generation + 1 + }} end) - trigger(child, :sticky_variables_set, details) - end + StickyStore.retire(sticky_store, old_table) - :ok + trigger(child, :sticky_variables_set, details) + :ok + end) end @doc "Subscribes to a child or delegated parent event." def on(child, event, callback) when event in [:context_set, :sticky_features_set, :sticky_variables_set] do - if Process.alive?(child.agent) do + while_live(child, once(fn -> :ok end), fn -> subscribe_to_local_event(child, event, callback) - else - once(fn -> :ok end) - end + end) end def on(child, event, callback) do - if Process.alive?(child.agent) do + if live?(child) do unsubscribe = Featurevisor.on(child.parent, event, callback) - Agent.update(child.agent, fn state -> %{state | unsubs: state.unsubs ++ [unsubscribe]} end) - once(fn -> unsubscribe.() end) + + try do + Agent.update(child.agent, fn state -> %{state | unsubs: state.unsubs ++ [unsubscribe]} end) + + watch_delegated_subscription(child, unsubscribe) + catch + :exit, reason -> + unsubscribe.() + if live?(child), do: exit(reason), else: once(fn -> :ok end) + end else once(fn -> :ok end) end @@ -138,6 +167,23 @@ defmodule Featurevisor.Child do end) end + defp watch_delegated_subscription(child, unsubscribe) do + watcher = + spawn(fn -> + ref = Process.monitor(child.agent) + + receive do + {:DOWN, ^ref, :process, _, _} -> unsubscribe.() + :stop -> :ok + end + end) + + once(fn -> + unsubscribe.() + send(watcher, :stop) + end) + end + @doc "Evaluates a flag through the parent instance." @spec evaluate_flag(t(), String.t(), map(), map()) :: Evaluation.t() def evaluate_flag(child, key, context \\ %{}, options \\ %{}), @@ -207,9 +253,9 @@ defmodule Featurevisor.Child do def get_variable_integer(child, key, variable, context \\ %{}, options \\ %{}), do: typed_variable(child, :get_variable_integer, key, variable, context, options) - @doc "Returns a finite numeric variable after runtime type checking." - def get_variable_double(child, key, variable, context \\ %{}, options \\ %{}), - do: typed_variable(child, :get_variable_double, key, variable, context, options) + @doc "Returns a finite number variable, whole or fractional, after runtime type checking." + def get_variable_number(child, key, variable, context \\ %{}, options \\ %{}), + do: typed_variable(child, :get_variable_number, key, variable, context, options) @doc "Returns a list variable after runtime type checking." def get_variable_array(child, key, variable, context \\ %{}, options \\ %{}), @@ -257,7 +303,7 @@ defmodule Featurevisor.Child do boolean: :get_global_variable_boolean, string: :get_global_variable_string, integer: :get_global_variable_integer, - double: :get_global_variable_double, + number: :get_global_variable_number, array: :get_global_variable_array, object: :get_global_variable_object, json: :get_global_variable_json @@ -286,9 +332,22 @@ defmodule Featurevisor.Child do @doc "Closes child listeners and delegated subscriptions." def close(child) do if Process.alive?(child.agent) do - unsubs = Agent.get(child.agent, & &1.unsubs) + unsubs = + try do + Agent.get(child.agent, & &1.unsubs) + catch + :exit, _ -> [] + end + Enum.each(unsubs, & &1.()) - Agent.stop(child.agent) + + if Process.alive?(child.agent) do + try do + Agent.stop(child.agent) + catch + :exit, _ -> :ok + end + end end :ok @@ -296,15 +355,153 @@ defmodule Featurevisor.Child do defp child_options(child, options) do options = if is_list(options), do: Map.new(options), else: options - - {sticky_features, sticky_variables} = - if Process.alive?(child.agent), - do: Agent.get(child.agent, &{&1.sticky_features, &1.sticky_variables}), - else: {%{}, %{}} + snapshot = sticky_snapshot(child) options - |> Map.put(:__child_sticky_features, sticky_features) - |> Map.put(:__child_sticky_variables, sticky_variables) + |> Map.put(:__child_snapshot, {child.agent, snapshot}) + |> Map.put(:__sticky_features_source, fn -> cached_sticky(child).sticky_features end) + |> Map.put(:__sticky_variables_source, fn -> cached_sticky(child).sticky_variables end) + |> Map.put(:__child_signature, fn -> cached_sticky(child).generation end) + end + + @doc false + def with_request_cache(initial, fun) do + previous = Process.get(@request_cache_key) + + {cache, pinned} = + case initial do + {agent, %{sticky_table: table} = snapshot} when not is_nil(table) -> + if StickyStore.pin(table), do: {%{agent => snapshot}, [table]}, else: {%{}, []} + + _ -> + {%{}, []} + end + + cache = Map.put(cache, :pinned, pinned) + Process.put(@request_cache_key, cache) + + try do + fun.() + after + Process.get(@request_cache_key, %{}) + |> Map.get(:pinned, []) + |> Enum.each(&StickyStore.unpin/1) + + if is_nil(previous), + do: Process.delete(@request_cache_key), + else: Process.put(@request_cache_key, previous) + end + end + + defp sticky_snapshot(child) do + if live?(child) do + try do + Agent.get(child.agent, fn state -> + state + |> Map.take([:generation, :sticky_table]) + |> sticky_views() + end) + catch + :exit, _ -> sticky_views(%{generation: nil, sticky_table: nil}) + end + else + sticky_views(%{generation: nil, sticky_table: nil}) + end + end + + defp sticky_views(%{sticky_table: nil} = snapshot), + do: Map.merge(snapshot, %{sticky_features: %{}, sticky_variables: %{}}) + + defp sticky_views(%{sticky_table: table} = snapshot), + do: + Map.merge(snapshot, %{ + sticky_features: StickyStore.view(table, :features), + sticky_variables: StickyStore.view(table, :variables) + }) + + defp cached_sticky(child) do + case Process.get(@request_cache_key) do + nil -> + snapshot = sticky_snapshot(child) + + if is_nil(snapshot.sticky_table) do + snapshot + else + if StickyStore.pin(snapshot.sticky_table) do + try do + %{ + snapshot + | sticky_table: nil, + sticky_features: StickyStore.materialize(snapshot.sticky_features), + sticky_variables: StickyStore.materialize(snapshot.sticky_variables) + } + after + StickyStore.unpin(snapshot.sticky_table) + end + else + retry_sticky(child, snapshot) + end + end + + cache -> + generation = child_value(child, :generation, nil) + + case Map.get(cache, child.agent) do + %{generation: ^generation} = snapshot -> + snapshot + + _ -> + snapshot = sticky_snapshot(child) + + if is_nil(snapshot.sticky_table) or StickyStore.pin(snapshot.sticky_table) do + pinned = + if snapshot.sticky_table, + do: [snapshot.sticky_table | cache.pinned], + else: cache.pinned + + Process.put( + @request_cache_key, + cache |> Map.put(child.agent, snapshot) |> Map.put(:pinned, pinned) + ) + + snapshot + else + retry_sticky(child, snapshot) + end + end + end + end + + defp retry_sticky(child, snapshot) do + current = sticky_snapshot(child) + + if current.generation == snapshot.generation and + current.sticky_table == snapshot.sticky_table, + do: sticky_views(%{generation: nil, sticky_table: nil}), + else: cached_sticky(child) + end + + defp child_value(child, key, default) do + if live?(child) do + try do + Agent.get(child.agent, &Map.get(&1, key, default)) + catch + :exit, _ -> default + end + else + default + end + end + + defp live?(child), + do: + Process.alive?(child.agent) and is_pid(child.sticky_store) and + Process.alive?(child.sticky_store) + + defp while_live(child, fallback, fun) do + if live?(child), do: fun.(), else: fallback + catch + :exit, reason -> if live?(child), do: exit(reason), else: fallback end defp typed_variable(child, function, key, variable, context, options) do diff --git a/lib/featurevisor/cli.ex b/lib/featurevisor/cli.ex index 4f215ab..aa631e1 100644 --- a/lib/featurevisor/cli.ex +++ b/lib/featurevisor/cli.ex @@ -5,30 +5,94 @@ defmodule Featurevisor.CLI do @switches [ projectDirectoryPath: :string, + project_directory_path: :string, + rootDirectoryPath: :string, + root_directory_path: :string, environment: :string, - target: :string, + target: :keep, + set: :string, inflate: :integer, quiet: :boolean, verbose: :boolean, showDatafile: :boolean, + show_datafile: :boolean, onlyFailures: :boolean, + only_failures: :boolean, keyPattern: :string, + key_pattern: :string, assertionPattern: :string, + assertion_pattern: :string, + entityType: :string, + entity_type: :string, + json: :boolean, + pretty: :boolean, + case: :string, + coverage: :boolean, + uncovered: :boolean, feature: :string, variation: :boolean, variable: :string, context: :string, n: :integer, - populateUuid: :string, + populateUuid: :keep, + populate_uuid: :keep, with_scopes: :boolean, + withScopes: :boolean, with_tags: :boolean, + withTags: :boolean, schemaVersion: :string, schema_version: :string, help: :boolean ] + @option_aliases %{ + project_directory_path: :projectDirectoryPath, + rootDirectoryPath: :projectDirectoryPath, + root_directory_path: :projectDirectoryPath, + show_datafile: :showDatafile, + only_failures: :onlyFailures, + key_pattern: :keyPattern, + assertion_pattern: :assertionPattern, + entity_type: :entityType, + populate_uuid: :populateUuid, + withScopes: :with_scopes, + withTags: :with_tags + } + @common [ + :projectDirectoryPath, + :target, + :set, + :inflate, + :help, + :with_scopes, + :with_tags, + :schemaVersion, + :schema_version + ] + @command_options %{ + "test" => [ + :quiet, + :verbose, + :showDatafile, + :onlyFailures, + :keyPattern, + :assertionPattern, + :entityType, + :json, + :pretty, + :case, + :coverage, + :uncovered + ], + "benchmark" => [:environment, :feature, :variation, :variable, :context, :n], + "assess-distribution" => [:environment, :feature, :context, :n, :populateUuid, :verbose] + } + @doc "Escript entry point." def main(arguments) do + :io.setopts(:standard_io, encoding: :unicode) + :io.setopts(:standard_error, encoding: :unicode) + case execute(arguments) do :ok -> :ok @@ -41,11 +105,17 @@ defmodule Featurevisor.CLI do @doc "Executes CLI arguments without halting the VM." def execute([]), do: help() + def execute([flag]) when flag in ["--help", "-h"], do: help() - def execute([command | arguments]) do - case OptionParser.parse(arguments, strict: @switches) do + def execute([command | arguments]) when is_map_key(@command_options, command) do + switches = + Enum.filter(@switches, fn {key, _} -> + Map.get(@option_aliases, key, key) in (@common ++ @command_options[command]) + end) + + case OptionParser.parse(arguments, strict: switches, aliases: [n: :n, h: :help]) do {options, positionals, []} when positionals == [] -> - dispatch(command, normalize(options)) + dispatch(command, options |> canonical_options() |> normalize()) {_options, _positionals, invalid} when invalid != [] -> {:error, "Unknown or invalid option #{inspect(hd(invalid))}"} @@ -55,7 +125,35 @@ defmodule Featurevisor.CLI do end end - defp dispatch(_command, %{help: true}), do: help() + def execute([command | _]), do: {:error, "Unknown command #{inspect(command)}"} + + defp canonical_options(options) do + Enum.reduce(options, [], fn {key, value}, acc -> + key = Map.get(@option_aliases, key, key) + + if key in [:target, :populateUuid], + do: acc ++ [{key, value}], + else: Keyword.put(acc, key, value) + end) + end + + defp dispatch(command, %{help: true}), do: help(command) + + defp dispatch(command, %{feature: ""}) when command in ["benchmark", "assess-distribution"], + do: {:error, "--feature must not be empty"} + + defp dispatch("benchmark", %{variable: ""}), do: {:error, "--variable must not be empty"} + + defp dispatch(command, %{inflate: inflate}) + when command in ["test", "benchmark", "assess-distribution"] and inflate <= 0, + do: {:error, "--inflate must be a positive integer"} + + defp dispatch("test", %{pretty: true, json: false}), do: {:error, "--pretty requires --json"} + + defp dispatch("test", %{entity_type: type}) + when not is_nil(type) and type not in ["feature", "segment", "variable", "scenario"], + do: {:error, "--entity-type must be feature, segment, variable or scenario"} + defp dispatch("test", options), do: TestRunner.run(options) defp dispatch("benchmark", %{feature: nil, variable: nil}), @@ -88,8 +186,10 @@ defmodule Featurevisor.CLI do defp normalize(options) do %{ project_directory_path: Keyword.get(options, :projectDirectoryPath, "."), + project_directory_path_argument: Keyword.get(options, :projectDirectoryPath), environment: Keyword.get(options, :environment), target: Keyword.get_values(options, :target), + set: Keyword.get(options, :set), inflate: Keyword.get(options, :inflate, 1), quiet: Keyword.get(options, :quiet, false), verbose: Keyword.get(options, :verbose, false), @@ -97,6 +197,12 @@ defmodule Featurevisor.CLI do only_failures: Keyword.get(options, :onlyFailures, false), key_pattern: Keyword.get(options, :keyPattern), assertion_pattern: Keyword.get(options, :assertionPattern), + entity_type: Keyword.get(options, :entityType), + json: Keyword.get(options, :json, false), + pretty: Keyword.get(options, :pretty, false), + case_id: Keyword.get(options, :case), + coverage: Keyword.get(options, :coverage, false), + uncovered: Keyword.get(options, :uncovered, false), feature: Keyword.get(options, :feature), variation: Keyword.get(options, :variation, false), variable: Keyword.get(options, :variable), @@ -107,7 +213,7 @@ defmodule Featurevisor.CLI do } end - defp help do + defp help(command \\ nil) do IO.puts(""" Featurevisor SDK for Elixir @@ -117,14 +223,42 @@ defmodule Featurevisor.CLI do featurevisor assess-distribution --feature= [options] Common options: - --projectDirectoryPath= - --environment= + --project-directory-path= Also --root-directory-path + --set= Omit to run all Sets --target= Repeat for multiple Targets --inflate= Documentation: https://featurevisor.com/docs/sdks/elixir/ """) + case command do + "test" -> + IO.puts(""" + Test options: + --key-pattern= --assertion-pattern= + --entity-type= + --only-failures --quiet --verbose --show-datafile + --case= --coverage --uncovered --json --pretty + """) + + "benchmark" -> + IO.puts( + "Benchmark options: --feature= --variable= --variation --context= --environment= --n= (or -n)" + ) + + "assess-distribution" -> + IO.puts( + "Distribution options: --feature= --context= --environment= --n= (or -n) --populate-uuid= (repeatable) --verbose" + ) + + _ -> + :ok + end + + IO.puts( + "CamelCase aliases remain supported. Authoring and datafile generation use the Node.js CLI." + ) + :ok end end diff --git a/lib/featurevisor/cli/assess_distribution.ex b/lib/featurevisor/cli/assess_distribution.ex index 556f850..58e0762 100644 --- a/lib/featurevisor/cli/assess_distribution.ex +++ b/lib/featurevisor/cli/assess_distribution.ex @@ -3,14 +3,19 @@ defmodule Featurevisor.CLI.AssessDistribution do alias Featurevisor.CLI.Project def run(options) do - targets = if options.target == [], do: [nil], else: Enum.uniq(options.target) + with {:ok, selections} <- Project.evaluation_selections(options) do + run_selections(options, selections) + end + end - Enum.reduce_while(targets, :ok, fn target, :ok -> + defp run_selections(options, selections) do + Enum.reduce_while(selections, :ok, fn {set, target}, :ok -> case Project.build( options.project_directory_path, options.environment, target, - options.inflate + options.inflate, + set ) do {:ok, datafile} -> f = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) @@ -28,6 +33,7 @@ defmodule Featurevisor.CLI.AssessDistribution do &Map.put(&2, &1, deterministic_uuid(index, &1)) ) + if options.verbose, do: IO.puts("Context: #{Jason.encode!(context)}") enabled = Featurevisor.enabled?(f, options.feature, context) variation = Featurevisor.get_variation(f, options.feature, context) @@ -44,6 +50,7 @@ defmodule Featurevisor.CLI.AssessDistribution do IO.puts("Feature: #{options.feature}") IO.puts("Environment: #{options.environment || false}") if target, do: IO.puts("Target: #{target}") + if set, do: IO.puts("Set: #{set}") IO.puts("Iterations: #{options.n}") IO.puts("\nFlag evaluations") print_count("enabled", counts.enabled, options.n) diff --git a/lib/featurevisor/cli/benchmark.ex b/lib/featurevisor/cli/benchmark.ex index 40c40af..b0f77f4 100644 --- a/lib/featurevisor/cli/benchmark.ex +++ b/lib/featurevisor/cli/benchmark.ex @@ -3,14 +3,19 @@ defmodule Featurevisor.CLI.Benchmark do alias Featurevisor.CLI.Project def run(options) do - targets = if options.target == [], do: [nil], else: Enum.uniq(options.target) + with {:ok, selections} <- Project.evaluation_selections(options) do + run_selections(options, selections) + end + end - Enum.reduce_while(targets, :ok, fn target, :ok -> + defp run_selections(options, selections) do + Enum.reduce_while(selections, :ok, fn {set, target}, :ok -> case Project.build( options.project_directory_path, options.environment, target, - options.inflate + options.inflate, + set ) do {:ok, datafile} -> f = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) @@ -28,6 +33,7 @@ defmodule Featurevisor.CLI.Benchmark do if is_nil(options.feature), do: IO.puts("Variable: #{options.variable}") IO.puts("Environment: #{options.environment || false}") if target, do: IO.puts("Target: #{target}") + if set, do: IO.puts("Set: #{set}") IO.puts("Iterations: #{options.n}") IO.puts("Context: #{Jason.encode!(context)}") IO.puts("Evaluated value: #{Jason.encode!(value)}") diff --git a/lib/featurevisor/cli/project.ex b/lib/featurevisor/cli/project.ex index 3a1bec0..80b70bf 100644 --- a/lib/featurevisor/cli/project.ex +++ b/lib/featurevisor/cli/project.ex @@ -1,29 +1,95 @@ defmodule Featurevisor.CLI.Project do @moduledoc false + @doc false + # Node starts the project's own CLI entry directly. Without a local package, + # npx resolves the CLI as before. + def node_command(project) do + project + |> Path.expand() + |> Stream.iterate(&Path.dirname/1) + |> Enum.reduce_while(nil, fn directory, _ -> + entry = Path.join([directory, "node_modules", "@featurevisor", "cli", "bin.js"]) + + cond do + File.regular?(entry) -> {:halt, ["node", entry]} + Path.dirname(directory) == directory -> {:halt, ["npx", "featurevisor"]} + true -> {:cont, nil} + end + end) + end + def json(project, command, args \\ []) do - case System.cmd("npx", ["featurevisor", command | args ++ ["--json"]], - cd: project, - stderr_to_stdout: true - ) do - {output, 0} -> - case Jason.decode(String.trim(output)) do - {:ok, value} -> - {:ok, value} - - {:error, error} -> - {:error, "Could not parse #{command} output: #{Exception.message(error)}"} + stderr_path = + Path.join( + System.tmp_dir!(), + "featurevisor-stderr-#{System.pid()}-#{:erlang.unique_integer([:positive])}" + ) + + case File.open(stderr_path, [:write, :exclusive]) do + {:ok, file} -> + File.close(file) + + try do + File.chmod!(stderr_path, 0o600) + # Arguments remain positional, so project values never become shell syntax. + [executable | prefix] = node_command(project) + + {output, status} = + if match?({:win32, _}, :os.type()) do + # Preserve native Windows invocation without requiring a POSIX shell. + # System.cmd forwards stderr to the terminal without mixing JSON. + System.cmd(executable, prefix ++ [command | args ++ ["--json"]], cd: project) + else + System.cmd( + "sh", + [ + "-c", + "stderr_file=$1; shift; exec \"$@\" 2>\"$stderr_file\"", + "featurevisor", + stderr_path, + executable + ] ++ prefix ++ [command | args ++ ["--json"]], + cd: project + ) + end + + stderr = File.read!(stderr_path) + + case status do + 0 -> + if stderr != "", do: IO.write(:stderr, stderr) + + case Jason.decode(String.trim(output)) do + {:ok, value} -> + {:ok, value} + + {:error, error} -> + {:error, "Could not parse #{command} output: #{Exception.message(error)}"} + end + + _ -> + diagnostics = + if stderr == "" and match?({:win32, _}, :os.type()), + do: "Diagnostics were written to standard error.\n" <> output, + else: stderr <> "\n" <> output + + {:error, + "Featurevisor #{command} failed with status #{status}: #{String.trim(diagnostics)}"} + end + after + File.rm(stderr_path) end - {output, status} -> - {:error, "Featurevisor #{command} failed with status #{status}: #{String.trim(output)}"} + {:error, reason} -> + {:error, "Could not capture npx diagnostics: #{:file.format_error(reason)}"} end rescue error -> {:error, "Could not run npx featurevisor: #{Exception.message(error)}"} end - def targets(project) do - with {:ok, values} <- json(project, "list", ["--targets"]) do + def targets(project, set \\ nil) do + with {:ok, values} <- json(project, "list", ["--targets"] ++ set_args(set)) do {:ok, values |> Enum.map(& &1["key"]) |> Enum.reject(&is_nil/1) |> Enum.uniq()} end end @@ -35,14 +101,84 @@ defmodule Featurevisor.CLI.Project do end end - def build(project, environment, target, inflate \\ 1) do + def sets(project, selected) do + with {:ok, config} <- json(project, "config") do + cond do + config["sets"] == false and is_binary(selected) -> + {:error, "This project does not use Sets. Omit --set."} + + is_binary(selected) -> + {:ok, [selected]} + + config["sets"] == true -> + case File.ls(config["setsDirectoryPath"]) do + {:ok, entries} -> + sets = + entries + |> Enum.filter(&File.dir?(Path.join(config["setsDirectoryPath"], &1))) + |> Enum.sort() + + if sets == [], + do: {:error, "No sets found in #{config["setsDirectoryPath"]}."}, + else: {:ok, sets} + + {:error, reason} -> + {:error, "Could not list project sets: #{:file.format_error(reason)}"} + end + + true -> + {:ok, [nil]} + end + end + end + + def build(project, environment, target, inflate \\ 1, set \\ nil) do args = [] args = if environment, do: args ++ ["--environment=#{environment}"], else: args args = if target, do: args ++ ["--target=#{target}"], else: args args = if inflate > 1, do: args ++ ["--inflate=#{inflate}"], else: args + args = args ++ set_args(set) json(project, "build", args) end - def datafile_key(environment, nil), do: environment || "false" - def datafile_key(environment, target), do: "#{environment || "false"}-target-#{target}" + def evaluation_selections(options) do + with :ok <- validate_iterations(options), + {:ok, config} <- json(options.project_directory_path, "config"), + :ok <- validate_environment(config, options.environment), + {:ok, sets} <- sets(options.project_directory_path, options.set) do + targets = if options.target == [], do: [nil], else: Enum.uniq(options.target) + {:ok, for(set <- sets, target <- targets, do: {set, target})} + end + end + + defp validate_iterations(%{n: n, inflate: inflate}) when n > 0 and inflate > 0, do: :ok + + defp validate_iterations(_), do: {:error, "--n and --inflate must be positive integers"} + + defp validate_environment(%{"environments" => environments}, environment) + when is_list(environments) do + cond do + is_nil(environment) -> + {:error, "--environment is required for a project with configured environments"} + + environment in environments -> + :ok + + true -> + {:error, "Unknown environment #{inspect(environment)}"} + end + end + + defp validate_environment(%{"environments" => _}, _), + do: {:error, "Invalid configured environments"} + + defp validate_environment(_, nil), do: :ok + + defp validate_environment(_, _), + do: {:error, "--environment cannot be used in a project without environments"} + + def datafile_key(environment, target), do: {environment, target} + + defp set_args(nil), do: [] + defp set_args(set), do: ["--set=#{set}"] end diff --git a/lib/featurevisor/cli/segments.ex b/lib/featurevisor/cli/segments.ex new file mode 100644 index 0000000..8fdce9d --- /dev/null +++ b/lib/featurevisor/cli/segments.ex @@ -0,0 +1,141 @@ +defmodule Featurevisor.CLI.Segments do + @moduledoc false + alias Featurevisor.{Dependencies, Normalize} + + def empty do + %{ + "schemaVersion" => "4", + "revision" => "tester", + "segments" => %{}, + "features" => %{}, + "variables" => %{} + } + end + + def prepare(datafile, definitions) do + segments = Map.merge(definitions, datafile["segments"] || %{}) + + {features, keys} = + Enum.reduce(Map.keys(definitions), {datafile["features"] || %{}, %{}}, fn key, + {features, keys} -> + helper = available("__featurevisor_segment_test__" <> key, features) + + feature = %{ + "bucketBy" => "__segment_test__", + "conditions" => key, + "traffic" => [%{"key" => "segment", "conditions" => "*", "percentage" => 100_000}] + } + + {Map.put(features, helper, feature), Map.put(keys, key, helper)} + end) + + {datafile |> Map.put("segments", segments) |> Map.put("features", features), keys} + end + + def preflight(key, datafile, definitions), + do: references([{"segments", key, nil}], %{}, datafile, definitions) + + def dependent?(key, definitions) do + dependent?([key], %{}, definitions) + end + + defp dependent?([], _seen, _definitions), do: false + + defp dependent?([key | rest], seen, definitions) do + if Map.has_key?(seen, key) do + dependent?(rest, seen, definitions) + else + dependencies = Dependencies.entity("segments", definitions[key] || %{}) + + Enum.any?(dependencies, fn {kind, _} -> kind != "segments" end) or + dependent?( + rest ++ for({"segments", key} <- dependencies, do: key), + Map.put(seen, key, true), + definitions + ) + end + end + + defp references([], _seen, _datafile, _definitions), do: :ok + + defp references([{kind, key, variable} = reference | rest], seen, datafile, definitions) do + if Map.has_key?(seen, reference) do + references(rest, seen, datafile, definitions) + else + raw = + if kind == "segments", + do: definitions[key] || datafile["segments"][key], + else: datafile[kind][key] + + cond do + is_nil(raw) -> + {:error, + "#{label(kind)} #{inspect(key)} is absent from the selected datafile and test definitions"} + + kind == "features" and not is_nil(variable) and + not Map.has_key?(raw["variablesSchema"] || %{}, variable) -> + {:error, + "Feature variable #{inspect(key <> "." <> variable)} is absent from the selected datafile"} + + true -> + {entity, _errors} = Normalize.entity(kind, key, raw, datafile["schemaVersion"] == "2") + + entity = if kind == "features", do: filter_feature(entity, variable), else: entity + dependencies = Dependencies.entity(kind, entity) + + pending = Enum.map(dependencies, fn {kind, key} -> {kind, key, nil} end) + pending = pending ++ feature_variable_references(entity) + references(rest ++ pending, Map.put(seen, reference, true), datafile, definitions) + end + end + end + + defp filter_feature(feature, variable) do + feature = + if is_nil(variable) do + Map.put(feature, "variations", []) + |> Map.update("traffic", [], fn entries -> + Enum.map(entries, &Map.delete(&1, "variableOverrides")) + end) + else + Enum.reduce(["traffic", "variations"], feature, fn kind, feature -> + Map.update(feature, kind, [], fn entries -> + Enum.map(entries, fn entry -> + Map.update(entry, "variableOverrides", %{}, &Map.take(&1, [variable])) + end) + end) + end) + end + + feature + end + + defp feature_variable_references(%{"feature" => feature, "variable" => variable}), + do: [{"features", feature, variable}] + + defp feature_variable_references(value) when is_map(value) do + value + |> Map.drop([ + "value", + "defaultValue", + "disabledValue", + "operations", + "variablesSchema", + "variables" + ]) + |> Map.values() + |> Enum.flat_map(&feature_variable_references/1) + end + + defp feature_variable_references(values) when is_list(values), + do: Enum.flat_map(values, &feature_variable_references/1) + + defp feature_variable_references(_), do: [] + + defp label("segments"), do: "Segment" + defp label("features"), do: "Feature" + defp label("variables"), do: "Global variable" + + defp available(key, features), + do: if(Map.has_key?(features, key), do: available(key <> "_", features), else: key) +end diff --git a/lib/featurevisor/cli/test_checks.ex b/lib/featurevisor/cli/test_checks.ex new file mode 100644 index 0000000..7f0d06e --- /dev/null +++ b/lib/featurevisor/cli/test_checks.ex @@ -0,0 +1,1305 @@ +defmodule Featurevisor.CLI.TestChecks do + @moduledoc false + + alias Featurevisor.CLI.Segments + + @missing {:featurevisor_cli, :missing} + @max_safe_integer 9_007_199_254_740_991 + @unsafe ["__proto__", "prototype", "constructor"] + @valueless ["exists", "notExists", "isNull", "notNull", "empty", "notEmpty"] + @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 + ) + @timestamp_regex ~r/^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?\.\d{1,9})?(?Z|[+-]\d{2}:\d{2})$/ + + def evaluate(evaluator, datafile, checks, owner, diagnostic_ref, prefix \\ "checks") do + evaluate_result(evaluator, datafile, checks, owner, diagnostic_ref, prefix).failures + end + + @doc false + def preflight(datafile, checks, owner, prefix \\ "checks", segment_checks \\ %{}) + + def preflight(_datafile, checks, _owner, prefix, _segment_checks) + when not is_list(checks) or checks == [] do + message = "#{prefix}: checks must be a nonempty array" + + %{ + passed: false, + error: true, + failures: [message], + report_failures: [validation_report_failure(message, checks, nil)] + } + end + + def preflight(datafile, checks, owner, prefix, segment_checks) do + validation_failures = + case validate_sibling_keys(checks, prefix) do + :ok -> + checks + |> Enum.with_index() + |> Enum.reduce([], fn {check, index}, failures -> + path = "#{prefix}[#{index}]" + + with :ok <- validate_node(check, owner, path), + :ok <- validate_references(check, owner, datafile, segment_checks, path) do + failures + else + {:error, message} -> failures ++ [{with_path(path, message), check, index}] + end + end) + + {:error, message} -> + [{message, checks, nil}] + end + + %{ + passed: validation_failures == [], + error: validation_failures != [], + failures: Enum.map(validation_failures, &elem(&1, 0)), + report_failures: + Enum.map(validation_failures, fn {message, actual, index} -> + validation_report_failure(message, actual, index) + end) + } + end + + def evaluate_result( + evaluator, + datafile, + checks, + owner, + diagnostic_ref, + prefix \\ "checks", + segment_checks \\ %{} + ) do + preflight = preflight(datafile, checks, owner, prefix, segment_checks) + + if preflight.error do + preflight + else + {failures, report_failures, error?} = + checks + |> Enum.with_index() + |> Enum.reduce({[], [], false}, fn {check, index}, {failures, report_failures, error?} -> + path = "#{prefix}[#{index}]" + + evaluation = %{ + evaluator: evaluator, + datafile: datafile, + owner: owner, + ref: diagnostic_ref, + segment_checks: segment_checks + } + + case evaluate_node(check, evaluation, path, [index]) do + {:ok, true, _detail, _evaluated} -> + {failures, report_failures, error?} + + {:ok, false, detail, evaluated} -> + message = with_metadata(check, detail) + + {failures ++ [message], + report_failures ++ + [report_failure(check, index, message, true, false, evaluated)], error?} + + {:error, message, evaluated} -> + message = with_metadata(check, with_path(path, message)) + + {failures ++ [message], + report_failures ++ + [ + report_failure( + check, + index, + message, + "successful comparison", + message, + evaluated + ) + ], true} + end + end) + + %{ + passed: failures == [], + error: error?, + failures: failures, + report_failures: report_failures + } + end + end + + defp validate_references(node, owner, datafile, segment_checks, path) do + group = Enum.find(["and", "or", "not"], &Map.has_key?(node, &1)) + + cond do + group -> + node[group] + |> Enum.with_index() + |> Enum.reduce_while(:ok, fn {child, index}, :ok -> + case validate_references( + child, + owner, + datafile, + segment_checks, + "#{path}.#{group}[#{index}]" + ) do + :ok -> {:cont, :ok} + error -> {:halt, error} + end + end) + + is_binary(node["segment"]) -> + validate_segment_definitions(node, segment_checks, path, datafile) + + feature_check?(node) -> + if Map.has_key?(datafile["features"] || %{}, node["feature"]), + do: :ok, + else: + {:error, + "#{path}: feature #{inspect(node["feature"])} is absent from the selected datafile"} + + true -> + with :ok <- + with_reference_path(validate_reference_membership(node, owner, datafile), path) do + if Map.has_key?(node, "compareTo") do + with_reference_path( + validate_reference_membership(node["compareTo"], owner, datafile), + "#{path}.compareTo" + ) + else + :ok + end + end + end + end + + defp validate_reference_membership(reference, owner, datafile) do + variable = reference["variable"] || owner + feature = reference["feature"] + + cond do + feature && not Map.has_key?(datafile["features"] || %{}, feature) -> + {:error, "feature #{inspect(feature)} is absent from the selected datafile"} + + feature && + not Map.has_key?( + get_in(datafile, ["features", feature, "variablesSchema"]) || %{}, + variable + ) -> + {:error, + "variable #{inspect(variable)} is absent from feature #{inspect(feature)} in the selected datafile"} + + is_nil(feature) && not Map.has_key?(datafile["variables"] || %{}, variable) -> + {:error, "global variable #{inspect(variable)} is absent from the selected datafile"} + + true -> + :ok + end + end + + defp with_reference_path(:ok, _path), do: :ok + defp with_reference_path({:error, message}, path), do: {:error, "#{path}: #{message}"} + + defp validation_report_failure(message, actual, index) do + %{ + message: message, + expected: "valid checks", + actual: actual, + check_index: index, + check_key: nil, + check_description: nil, + evaluated: [] + } + end + + defp report_failure(check, index, message, expected, actual, evaluated) do + %{ + message: message, + expected: expected, + actual: actual, + check_index: index, + check_key: check["key"], + check_description: check["description"], + evaluated: evaluated + } + end + + def diagnostic_handler(target, ref) do + fn diagnostic -> send(target, {:featurevisor_cli_check_diagnostic, ref, diagnostic}) end + end + + def diagnostic_module(target, ref) do + %Featurevisor.Module{ + name: "tester-check-diagnostics", + after_evaluation: fn evaluation, _options -> + definition = evaluation.variable_schema || evaluation.variable || %{} + + if not Map.has_key?(definition, "compose") and definition["type"] == "json" and + is_binary(evaluation.variable_value) and + match?({:error, _}, Jason.decode(evaluation.variable_value)) do + send(target, { + :featurevisor_cli_check_diagnostic, + ref, + %{ + level: :error, + code: "evaluation_error", + message: "getVariable failed to decode JSON" + } + }) + end + + evaluation + end + } + end + + defp validate_node(node, _owner, path) when not is_map(node), + do: {:error, "#{path}: check must be an object"} + + defp validate_node(node, owner, path) do + groups = Enum.filter(["and", "or", "not"], &Map.has_key?(node, &1)) + + with :ok <- validate_metadata(node, path) do + validate_node_shape(node, owner, path, groups) + end + end + + defp validate_node_shape(node, owner, path, groups) when groups != [] do + group = hd(groups) + children = node[group] + + cond do + length(groups) != 1 or + Enum.any?(Map.keys(node), &(&1 not in [group, "key", "description"])) -> + {:error, "#{path}: group must contain exactly one of and, or or not"} + + not is_list(children) or children == [] -> + {:error, "#{path}.#{group}: group must be a nonempty array"} + + true -> + with :ok <- validate_sibling_keys(children, "#{path}.#{group}") do + children + |> Enum.with_index() + |> Enum.reduce_while(:ok, fn {child, index}, :ok -> + case validate_node(child, owner, "#{path}.#{group}[#{index}]") do + :ok -> {:cont, :ok} + error -> {:halt, error} + end + end) + end + end + end + + defp validate_node_shape(node, owner, path, _groups) do + cond do + segment_check?(node) -> validate_segment_check(node, path) + feature_check?(node) -> validate_feature_check(node, path) + true -> validate_leaf(node, owner, path) + end + end + + defp validate_metadata(node, path) do + cond do + Map.has_key?(node, "key") and (not is_binary(node["key"]) or node["key"] == "") -> + {:error, "#{path}.key: expected a nonempty string"} + + Map.has_key?(node, "description") and not is_binary(node["description"]) -> + {:error, "#{path}.description: expected a string"} + + true -> + :ok + end + end + + defp validate_sibling_keys(checks, path) do + checks + |> Enum.with_index() + |> Enum.reduce_while(MapSet.new(), fn {check, index}, keys -> + key = if is_map(check), do: check["key"], else: nil + + if is_binary(key) and MapSet.member?(keys, key), + do: + {:halt, {:error, "#{path}[#{index}].key: duplicate sibling check key #{inspect(key)}"}}, + else: {:cont, if(is_binary(key), do: MapSet.put(keys, key), else: keys)} + end) + |> case do + %MapSet{} -> :ok + error -> error + end + end + + defp feature_check?(node) do + Map.has_key?(node, "enabled") or Map.has_key?(node, "variation") or + (map_size(node) == 1 and Map.has_key?(node, "feature")) + end + + defp segment_check?(node), + do: Map.has_key?(node, "segment") or Map.has_key?(node, "matches") + + defp validate_segment_check(node, path) do + unknown = Enum.find(Map.keys(node), &(&1 not in ~w(segment matches key description))) + + cond do + unknown -> + {:error, "#{path}: segment check cannot contain property #{inspect(unknown)}"} + + not is_binary(node["segment"]) or node["segment"] == "" -> + {:error, "#{path}: segment must be a nonempty string"} + + not is_boolean(node["matches"]) -> + {:error, "#{path}: matches must be a boolean"} + + true -> + :ok + end + end + + defp validate_segment_definitions(node, segment_checks, path, datafile) do + case Segments.preflight( + node["segment"], + datafile, + segment_checks[:definitions] || %{} + ) do + :ok -> :ok + {:error, message} -> {:error, "#{path}: #{message}"} + end + end + + defp validate_feature_check(node, path) do + unknown = + Enum.find(Map.keys(node), &(&1 not in ~w(feature enabled variation key description))) + + cond do + unknown -> + {:error, "#{path}: feature check cannot contain property #{inspect(unknown)}"} + + not is_binary(node["feature"]) or node["feature"] == "" -> + {:error, "#{path}: feature must be a nonempty string"} + + not Map.has_key?(node, "enabled") and not Map.has_key?(node, "variation") -> + {:error, "#{path}: feature check requires enabled or variation"} + + Map.has_key?(node, "enabled") and not is_boolean(node["enabled"]) -> + {:error, "#{path}: enabled must be a boolean"} + + Map.has_key?(node, "variation") and not is_nil(node["variation"]) and + not is_binary(node["variation"]) -> + {:error, "#{path}: variation must be a string or null"} + + true -> + :ok + end + end + + defp validate_leaf(node, owner, path) do + operator = node["operator"] + allowed = ~w(variable feature path operator value compareTo regexFlags key description) + unknown = Enum.find(Map.keys(node), &(&1 not in allowed)) + supplied = count_key(node, "value") + count_key(node, "compareTo") + + cond do + unknown -> + {:error, "#{path}: unknown check property #{inspect(unknown)}"} + + operator not in @operators -> + {:error, "#{path}: unknown check operator #{inspect(operator)}"} + + operator in @valueless and supplied != 0 -> + {:error, "#{path}: operator #{inspect(operator)} accepts neither value nor compareTo"} + + operator not in @valueless and supplied != 1 -> + {:error, "#{path}: exactly one of value or compareTo is required"} + + Map.has_key?(node, "regexFlags") and operator not in ["matches", "notMatches"] -> + {:error, "#{path}: regexFlags are only allowed with matches or notMatches"} + + true -> + with :ok <- validate_reference(node, owner, path), + :ok <- validate_regex_flags(node, operator, path), + :ok <- validate_compare_to(node, owner, path) do + validate_literal(node, operator, path) + end + end + end + + defp validate_compare_to(node, _owner, _path) when not is_map_key(node, "compareTo"), do: :ok + + defp validate_compare_to(node, owner, path) do + other = node["compareTo"] + + cond do + not is_map(other) -> + {:error, "#{path}.compareTo: reference must be an object"} + + Enum.any?(Map.keys(other), &(&1 not in ~w(variable feature path))) -> + {:error, "#{path}.compareTo: unknown reference property"} + + true -> + validate_reference(other, owner, "#{path}.compareTo") + end + end + + defp validate_reference(reference, owner, path) do + cond do + Map.has_key?(reference, "variable") and + (not is_binary(reference["variable"]) or reference["variable"] == "") -> + {:error, "#{path}: variable must be a nonempty string"} + + Map.has_key?(reference, "feature") and + (not is_binary(reference["feature"]) or reference["feature"] == "") -> + {:error, "#{path}: feature must be a nonempty string"} + + Map.has_key?(reference, "feature") and not Map.has_key?(reference, "variable") -> + {:error, "#{path}: a feature reference requires an explicit variable"} + + not Map.has_key?(reference, "variable") and is_nil(owner) -> + {:error, "#{path}: variable reference requires an explicit variable"} + + Map.has_key?(reference, "path") and is_nil(reference["path"]) -> + {:error, "#{path}: path must not be null"} + + true -> + case path_parts(reference["path"]) do + {:ok, _} -> :ok + error -> error + end + end + end + + defp validate_regex_flags(node, operator, path) do + if Map.has_key?(node, "regexFlags") do + if operator in ["matches", "notMatches"] do + case portable_regex_flags(node["regexFlags"], false) do + {:ok, _} -> :ok + {:error, message} -> {:error, "#{path}: #{message}"} + end + else + {:error, "#{path}: regexFlags are only allowed with matches or notMatches"} + end + else + :ok + end + end + + defp validate_literal(node, _operator, _path) when not is_map_key(node, "value"), do: :ok + + defp validate_literal(node, operator, path) do + value = node["value"] + + result = + cond do + operator in ["equals", "notEquals"] -> + if primitive?(value), do: :ok, else: {:error, "requires a scalar literal"} + + operator in ["greaterThan", "greaterThanOrEquals", "lessThan", "lessThanOrEquals"] -> + if is_number(value), do: :ok, else: {:error, "requires a numeric literal"} + + operator in ["contains", "notContains", "startsWith", "endsWith"] -> + if is_binary(value), do: :ok, else: {:error, "requires a string literal"} + + operator in ["matches", "notMatches"] -> + if is_binary(value), + do: portable_regex(value, node["regexFlags"]), + else: {:error, "requires a string literal"} + + String.starts_with?(operator, "semver") -> + if is_binary(value) do + case Featurevisor.Semver.compare(value, value) do + compared when is_integer(compared) -> :ok + {:error, message} -> {:error, message} + end + else + {:error, "requires a string literal"} + end + + operator in ["before", "after"] -> + case portable_datetime(value) do + {:ok, _} -> :ok + error -> error + end + + operator in ["includesAny", "notIncludesAny"] -> + if is_list(value) and Enum.all?(value, &primitive?/1), + do: :ok, + else: {:error, "requires a primitive array literal"} + + operator in ["in", "notIn"] -> + if is_list(value) and Enum.all?(value, &membership_primitive?/1), + do: :ok, + else: {:error, "requires a scalar array literal"} + + operator in ["includes", "notIncludes"] -> + if membership_primitive?(value), + do: :ok, + else: {:error, "requires a scalar literal"} + + true -> + :ok + end + + case result do + :ok -> :ok + {:ok, _} -> :ok + {:error, message} -> {:error, "#{path}: operator #{inspect(operator)} #{message}"} + end + end + + defp count_key(map, key), do: if(Map.has_key?(map, key), do: 1, else: 0) + + defp path_parts(nil), do: {:ok, []} + + defp path_parts(path) when is_binary(path) do + parts = String.split(path, ".") + + if Enum.any?(parts, &(&1 == "")) do + {:error, "path must not contain empty dot components"} + else + parts + |> Enum.map(fn part -> + if Regex.match?(~r/^(0|[1-9]\d*)$/, part) do + index = String.to_integer(part) + if index <= @max_safe_integer, do: index, else: {:unsafe_index, index} + else + part + end + end) + |> validate_path_parts() + end + end + + defp path_parts(path) when is_list(path), do: validate_path_parts(path) + defp path_parts(_), do: {:error, "path must be a dot separated string or an array"} + + defp validate_path_parts(parts) do + cond do + Enum.any?(parts, fn part -> + not is_binary(part) and + not (is_integer(part) and part >= 0 and part <= @max_safe_integer) + end) -> + {:error, "path entries must be string keys or safe nonnegative integer indices"} + + Enum.any?(parts, &(&1 in @unsafe)) -> + {:error, "unsafe path segment is not allowed"} + + Enum.any?(parts, &(&1 == "*")) -> + {:error, "wildcards are not allowed in paths"} + + true -> + {:ok, parts} + end + end + + defp evaluate_node(node, evaluation, path, trace_path) do + case Enum.find(["and", "or", "not"], &Map.has_key?(node, &1)) do + nil -> + cond do + segment_check?(node) -> evaluate_segment_check(node, evaluation, path, trace_path) + feature_check?(node) -> evaluate_feature_check(node, evaluation, path, trace_path) + true -> evaluate_leaf(node, evaluation, path, trace_path) + end + + "or" -> + evaluate_or(node["or"], evaluation, path, trace_path, 0, [], []) + + group -> + evaluate_and_index( + node[group], + evaluation, + path, + trace_path, + group, + 0, + [] + ) + end + end + + defp evaluate_segment_check(node, evaluation, path, trace_path) do + segment = node["segment"] + expected = node["matches"] + segment_checks = evaluation.segment_checks + + key = segment_checks[:keys] && segment_checks.keys[segment] + + drain_diagnostics(evaluation.ref) + + result = + case evaluation.evaluator do + %Featurevisor{} = f -> Featurevisor.evaluate_flag(f, key) + child -> Featurevisor.Child.evaluate_flag(child, key) + end + + if result.reason == :error, do: raise(result.error) + + error = Enum.find(drain_diagnostics(evaluation.ref), &(to_string(&1.level) == "error")) + if error, do: raise(ArgumentError, "#{error.code}: #{error.message}") + + actual = result.enabled == true + + evidence = + %{ + "path" => trace_path, + "key" => node["key"], + "description" => node["description"], + "reference" => %{"segment" => segment}, + "property" => "matches", + "operator" => "equals", + "expected" => expected, + "actual" => actual + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + + if actual == expected do + {:ok, true, "segment #{inspect(segment)} matched", [evidence]} + else + {:ok, false, + "#{path}: segment #{inspect(segment)} expected matches=#{expected}, actual matches=#{actual}", + [evidence]} + end + rescue + error -> {:error, "#{path}: segment getter failed: #{Exception.message(error)}", []} + end + + defp evaluate_or( + [], + _evaluation, + _path, + _trace_path, + _index, + details, + evaluated + ), + do: {:ok, false, Enum.join(details, "; "), evaluated} + + defp evaluate_or( + [child | rest], + evaluation, + path, + trace_path, + index, + details, + evaluated + ) do + child_trace_path = trace_path ++ ["or", index] + + case evaluate_node( + child, + evaluation, + "#{path}.or[#{index}]", + child_trace_path + ) do + {:ok, true, detail, child_evaluated} -> + {:ok, true, detail, evaluated ++ child_evaluated} + + {:ok, false, detail, child_evaluated} -> + evaluate_or( + rest, + evaluation, + path, + trace_path, + index + 1, + details ++ [with_metadata(child, detail)], + evaluated ++ child_evaluated + ) + + {:error, message, child_evaluated} -> + {:error, with_metadata(child, message), evaluated ++ child_evaluated} + end + end + + defp evaluate_and_index( + [], + _evaluation, + _path, + _trace_path, + "not", + _index, + evaluated + ), + do: {:ok, false, "NOT group matched all children", evaluated} + + defp evaluate_and_index( + [], + _evaluation, + _path, + _trace_path, + _group, + _index, + evaluated + ), + do: {:ok, true, "AND group matched", evaluated} + + defp evaluate_and_index( + [child | rest], + evaluation, + path, + trace_path, + group, + index, + evaluated + ) do + case evaluate_node( + child, + evaluation, + "#{path}.#{group}[#{index}]", + trace_path ++ [group, index] + ) do + {:ok, false, detail, child_evaluated} when group == "not" -> + {:ok, true, detail, evaluated ++ child_evaluated} + + {:ok, false, detail, child_evaluated} -> + {:ok, false, with_metadata(child, detail), evaluated ++ child_evaluated} + + {:ok, true, _detail, child_evaluated} -> + evaluate_and_index( + rest, + evaluation, + path, + trace_path, + group, + index + 1, + evaluated ++ child_evaluated + ) + + {:error, message, child_evaluated} -> + {:error, with_metadata(child, message), evaluated ++ child_evaluated} + end + end + + defp evaluate_leaf(node, evaluation, path, trace_path) do + %{evaluator: evaluator, datafile: datafile, owner: owner, ref: ref} = evaluation + left_reference = Map.take(node, ["variable", "feature", "path"]) + trace = variable_trace(node, owner, trace_path) + + case resolve(evaluator, datafile, left_reference, owner, ref) do + {:error, message} -> + {:error, "#{path}.left(#{reference_label(left_reference, owner)}): #{message}", [trace]} + + {:ok, left} -> + trace = + trace + |> Map.put("actual", report_value(left)) + |> Map.put("actualMissing", left == @missing) + + case right_operand(node, evaluator, datafile, owner, ref) do + {:error, message} -> + label = reference_label(node["compareTo"], owner) + {:error, "#{path}.compareTo(#{label}): #{message}", [trace]} + + {:ok, right, right_label} -> + trace = + if Map.has_key?(node, "value") or Map.has_key?(node, "compareTo") do + trace + |> Map.put("expected", report_value(right)) + |> Map.put("expectedMissing", right == @missing) + else + trace + end + + case compare(node["operator"], left, right, node["regexFlags"]) do + {:error, message} -> + {:error, message, [trace]} + + {:ok, matched} -> + detail = + "#{path}: #{reference_label(left_reference, owner)} #{node["operator"]} #{right_label} failed " <> + "(left=#{json(left)}, right=#{json(right)})" + + {:ok, matched, detail, [trace]} + end + end + end + end + + defp evaluate_feature_check(node, evaluation, path, trace_path) do + %{evaluator: evaluator, datafile: datafile, ref: ref} = evaluation + feature = node["feature"] + + case get_in(datafile, ["features", feature]) do + definition when is_map(definition) -> + case feature_expectations(node, evaluator, feature, ref, trace_path) do + {:ok, actual, evaluated} -> + keys = Enum.filter(["enabled", "variation"], &Map.has_key?(node, &1)) + matched = Enum.all?(keys, &(actual[&1] === node[&1])) + + details = + Enum.map_join(keys, ", ", fn key -> + "expected #{key}=#{json(node[key])}, actual #{key}=#{json(actual[key])}" + end) + + {:ok, matched, + "#{path}: feature #{inspect(feature)} expectations failed (#{details})", evaluated} + + error -> + error + end + + _ -> + {:error, "feature #{inspect(feature)} is absent from the selected datafile", []} + end + end + + defp feature_expectations(node, evaluator, feature, ref, trace_path) do + Enum.reduce_while(["enabled", "variation"], {:ok, %{}, []}, fn key, + {:ok, actual, evaluated} -> + if Map.has_key?(node, key) do + trace = feature_trace(node, feature, key, trace_path) + + case feature_getter(evaluator, feature, key, ref) do + {:ok, value} -> + {:cont, + {:ok, Map.put(actual, key, value), + evaluated ++ [Map.put(trace, "actual", report_value(value))]}} + + {:error, message} -> + {:halt, {:error, message, evaluated ++ [trace]}} + end + else + {:cont, {:ok, actual, evaluated}} + end + end) + end + + defp feature_getter(evaluator, feature, getter, ref) do + drain_diagnostics(ref) + + try do + value = evaluator_feature_get(evaluator, getter, feature) + + case Enum.find(drain_diagnostics(ref, [], 10), &(to_string(&1.level) == "error")) do + nil -> + {:ok, value} + + diagnostic -> + {:error, + "feature #{inspect(feature)} #{getter} getter reported #{diagnostic.code}: #{diagnostic.message}"} + end + rescue + error -> + {:error, + "feature #{inspect(feature)} #{getter} getter failed: #{Exception.message(error)}"} + end + end + + defp evaluator_feature_get(%Featurevisor{} = evaluator, "enabled", feature), + do: Featurevisor.enabled?(evaluator, feature) + + defp evaluator_feature_get(%Featurevisor{} = evaluator, "variation", feature), + do: Featurevisor.get_variation(evaluator, feature) + + defp evaluator_feature_get(evaluator, "enabled", feature), + do: Featurevisor.Child.enabled?(evaluator, feature) + + defp evaluator_feature_get(evaluator, "variation", feature), + do: Featurevisor.Child.get_variation(evaluator, feature) + + defp variable_trace(node, owner, path) do + %{ + "path" => path, + "key" => node["key"], + "description" => node["description"], + "reference" => report_reference(node, owner), + "operator" => node["operator"], + "expected" => if(Map.has_key?(node, "value"), do: node["value"], else: undefined_value()), + "actual" => "", + "actualMissing" => false, + "expectedMissing" => false, + "compareTo" => + if(Map.has_key?(node, "compareTo"), + do: report_reference(node["compareTo"], owner), + else: nil + ) + } + |> reject_nil_values() + end + + defp feature_trace(node, feature, property, path) do + %{ + "path" => path, + "key" => node["key"], + "description" => node["description"], + "reference" => %{"feature" => feature}, + "property" => property, + "operator" => "equals", + "expected" => node[property], + "actual" => "" + } + |> reject_nil_values(["expected"]) + end + + defp report_reference(reference, owner) do + %{ + "variable" => reference["variable"] || owner, + "feature" => reference["feature"], + "path" => if(Map.has_key?(reference, "path"), do: reference["path"], else: nil) + } + |> reject_nil_values() + end + + defp reject_nil_values(map, retained_nil_keys \\ []) do + Map.reject(map, fn {key, value} -> is_nil(value) and key not in retained_nil_keys end) + end + + defp undefined_value, do: %{"$type" => "undefined"} + defp report_value(@missing), do: undefined_value() + defp report_value(value), do: value + + defp right_operand(node, evaluator, datafile, owner, ref) do + if Map.has_key?(node, "compareTo") do + case resolve(evaluator, datafile, node["compareTo"], owner, ref) do + {:ok, value} -> {:ok, value, reference_label(node["compareTo"], owner)} + error -> error + end + else + {:ok, Map.get(node, "value", @missing), "literal"} + end + end + + defp resolve(evaluator, datafile, reference, owner, ref) do + variable = reference["variable"] || owner + feature = reference["feature"] + + cond do + feature && not is_map(get_in(datafile, ["features", feature])) -> + {:error, "feature #{inspect(feature)} is absent from the selected datafile"} + + feature && + not Map.has_key?( + get_in(datafile, ["features", feature, "variablesSchema"]) || %{}, + variable + ) -> + {:error, + "variable #{inspect(variable)} is absent from feature #{inspect(feature)} in the selected datafile"} + + is_nil(feature) && not Map.has_key?(datafile["variables"] || %{}, variable) -> + {:error, "global variable #{inspect(variable)} is absent from the selected datafile"} + + true -> + drain_diagnostics(ref) + + try do + value = + if feature, + do: evaluator_get(evaluator, :feature, feature, variable), + else: evaluator_get(evaluator, :global, nil, variable) + + case Enum.find(drain_diagnostics(ref, [], 10), &(to_string(&1.level) == "error")) do + nil -> + with {:ok, parts} <- path_parts(reference["path"]), do: {:ok, at_path(value, parts)} + + diagnostic -> + {:error, "variable getter reported #{diagnostic.code}: #{diagnostic.message}"} + end + rescue + error -> {:error, "variable getter failed: #{Exception.message(error)}"} + end + end + end + + defp evaluator_get(%Featurevisor{} = evaluator, :feature, feature, variable), + do: Featurevisor.get_variable(evaluator, feature, variable) + + defp evaluator_get(%Featurevisor{} = evaluator, :global, _feature, variable), + do: Featurevisor.get_global_variable(evaluator, variable) + + defp evaluator_get(evaluator, :feature, feature, variable), + do: Featurevisor.Child.get_variable(evaluator, feature, variable) + + defp evaluator_get(evaluator, :global, _feature, variable), + do: Featurevisor.Child.get_global_variable(evaluator, variable) + + defp drain_diagnostics(ref, values \\ [], wait \\ 0) do + receive do + {:featurevisor_cli_check_diagnostic, ^ref, diagnostic} -> + drain_diagnostics(ref, values ++ [diagnostic], 0) + after + wait -> values + end + end + + defp at_path(value, []), do: value + + defp at_path(value, [part | rest]) when is_integer(part) and is_list(value), + do: at_path(Enum.at(value, part, @missing), rest) + + defp at_path(value, ["length" | rest]) when is_list(value), + do: at_path(length(value), rest) + + defp at_path(value, [part | rest]) when is_binary(part) and is_list(value) do + if Regex.match?(~r/^(0|[1-9]\d*)$/, part), + do: at_path(Enum.at(value, String.to_integer(part), @missing), rest), + else: @missing + end + + defp at_path(value, [part | rest]) when is_binary(part) and is_map(value), + do: at_path(Map.get(value, part, @missing), rest) + + defp at_path(_value, _parts), do: @missing + + defp compare("exists", left, _right, _flags), do: {:ok, left != @missing} + defp compare("notExists", left, _right, _flags), do: {:ok, left == @missing} + defp compare("isNull", left, _right, _flags), do: {:ok, is_nil(left)} + defp compare("notNull", left, _right, _flags), do: {:ok, left != @missing and not is_nil(left)} + + defp compare(operator, left, _right, _flags) when operator in ["empty", "notEmpty"] do + compare_empty(operator, left) + end + + defp compare(_operator, @missing, _right, _flags), do: {:ok, false} + defp compare(_operator, _left, @missing, _flags), do: {:ok, false} + + defp compare(operator, left, right, _flags) when operator in ["equals", "notEquals"] do + if primitive?(left) and primitive?(right) do + matched = strict_equal?(left, right) + {:ok, if(operator == "equals", do: matched, else: not matched)} + else + {:error, "operator #{inspect(operator)} only supports scalar values"} + end + end + + defp compare(operator, left, right, _flags) + when operator in ["greaterThan", "greaterThanOrEquals", "lessThan", "lessThanOrEquals"] do + if is_number(left) and is_number(right) do + {:ok, + %{ + "greaterThan" => left > right, + "greaterThanOrEquals" => left >= right, + "lessThan" => left < right, + "lessThanOrEquals" => left <= right + }[operator]} + else + {:error, "operator #{inspect(operator)} requires numeric operands"} + end + end + + defp compare(operator, left, right, flags) + when operator in [ + "contains", + "notContains", + "startsWith", + "endsWith", + "matches", + "notMatches" + ] do + if is_binary(left) and is_binary(right) do + case operator do + "contains" -> + {:ok, String.contains?(left, right)} + + "notContains" -> + {:ok, not String.contains?(left, right)} + + "startsWith" -> + {:ok, String.starts_with?(left, right)} + + "endsWith" -> + {:ok, String.ends_with?(left, right)} + + regex_operator -> + with {:ok, regex} <- portable_regex(right, flags) do + matched = Regex.match?(regex, left) + {:ok, if(regex_operator == "matches", do: matched, else: not matched)} + end + end + else + {:error, "operator #{inspect(operator)} requires string operands"} + end + end + + defp compare(operator, left, right, _flags) + when operator in ~w(semverEquals semverNotEquals semverGreaterThan semverGreaterThanOrEquals semverLessThan semverLessThanOrEquals) do + if is_binary(left) and is_binary(right) do + case Featurevisor.Semver.compare(left, right) do + compared when is_integer(compared) -> + {:ok, + %{ + "semverEquals" => compared == 0, + "semverNotEquals" => compared != 0, + "semverGreaterThan" => compared == 1, + "semverGreaterThanOrEquals" => compared >= 0, + "semverLessThan" => compared == -1, + "semverLessThanOrEquals" => compared <= 0 + }[operator]} + + {:error, message} -> + {:error, message} + end + else + {:error, "operator #{inspect(operator)} requires string operands"} + end + end + + defp compare(operator, left, right, _flags) when operator in ["before", "after"] do + with {:ok, left_date} <- portable_datetime(left), + {:ok, right_date} <- portable_datetime(right) do + {:ok, if(operator == "before", do: left_date < right_date, else: left_date > right_date)} + else + _ -> {:error, "date comparisons require valid ISO 8601 strings with a timezone"} + end + end + + defp compare(operator, left, right, _flags) when operator in ["in", "notIn"] do + if membership_primitive?(left) and is_list(right) and + Enum.all?(right, &membership_primitive?/1) do + matched = Enum.any?(right, &strict_equal?(left, &1)) + {:ok, if(operator == "in", do: matched, else: not matched)} + else + {:error, + "operator #{inspect(operator)} requires a scalar left operand and scalar array right operand"} + end + end + + defp compare(operator, left, right, _flags) + when operator in ["includesAny", "notIncludesAny"] do + if is_list(left) and is_list(right) and Enum.all?(right, &primitive?/1) do + matched = + Enum.any?(right, fn candidate -> Enum.any?(left, &strict_equal?(&1, candidate)) end) + + {:ok, if(operator == "includesAny", do: matched, else: not matched)} + else + {:error, + "operator #{inspect(operator)} requires an array left operand and primitive array right operand"} + end + end + + defp compare(operator, left, right, _flags) when operator in ["includes", "notIncludes"] do + if is_list(left) and membership_primitive?(right) do + matched = Enum.any?(left, &strict_equal?(&1, right)) + {:ok, if(operator == "includes", do: matched, else: not matched)} + else + {:error, + "operator #{inspect(operator)} requires a scalar array left operand and scalar right operand"} + end + end + + defp compare_empty(_operator, value) when value in [@missing, nil], do: {:ok, false} + + defp compare_empty(operator, value) when is_binary(value) or is_list(value) do + empty = value == "" or value == [] + {:ok, if(operator == "empty", do: empty, else: not empty)} + end + + defp compare_empty(operator, value) when is_map(value) and not is_struct(value) do + if Enum.all?(value, fn {key, _value} -> is_binary(key) end) do + empty = map_size(value) == 0 + {:ok, if(operator == "empty", do: empty, else: not empty)} + else + {:error, "operator #{inspect(operator)} requires a string, array, or object operand"} + end + end + + defp compare_empty(operator, _value), + do: {:error, "operator #{inspect(operator)} requires a string, array, or object operand"} + + defp primitive?(value), + do: is_nil(value) or is_binary(value) or is_boolean(value) or is_number(value) + + defp membership_primitive?(value), do: is_nil(value) or is_binary(value) or is_number(value) + + defp strict_equal?(left, right) when is_number(left) and is_number(right), do: left == right + defp strict_equal?(left, right), do: left === right + + defp portable_regex(pattern, flags) do + with {:ok, flags} <- portable_regex_flags(flags) do + if Regex.match?(~r/\(\?/, pattern) or + Regex.match?(~r/\\(?:[1-9]|k<|k'|g<|g')/, pattern) or + Regex.match?(~r/(?:[?*+]|\{\d+(?:,\d*)?\})\+/, pattern) do + {:error, "regular expression uses syntax outside the portable subset"} + else + case Regex.compile(pattern, String.replace(flags, "g", "")) do + {:ok, regex} -> {:ok, regex} + {:error, message} -> {:error, "invalid regular expression: #{inspect(message)}"} + end + end + end + end + + defp portable_regex_flags(flags, allow_nil \\ true) + defp portable_regex_flags(nil, true), do: {:ok, ""} + + defp portable_regex_flags(flags, _allow_nil) do + if is_binary(flags) and Regex.match?(~r/^[gims]*$/, flags) and + length(String.graphemes(flags)) == length(Enum.uniq(String.graphemes(flags))), + do: {:ok, flags}, + else: {:error, "regexFlags must contain unique characters from g, i, m and s"} + end + + defp portable_datetime(value) when is_binary(value) do + with captures when is_map(captures) <- Regex.named_captures(@timestamp_regex, value), + {year, ""} <- Integer.parse(captures["year"]), + true <- year in 1..9999, + {month, ""} <- Integer.parse(captures["month"]), + {day, ""} <- Integer.parse(captures["day"]), + {:ok, _date} <- Date.new(year, month, day), + {hour, ""} <- Integer.parse(captures["hour"]), + {minute, ""} <- Integer.parse(captures["minute"]), + {second, ""} <- Integer.parse(captures["second"]), + true <- hour in 0..23 and minute in 0..59 and second in 0..59, + {:ok, offset} <- portable_offset(captures["zone"]), + instant = + :calendar.datetime_to_gregorian_seconds({{year, month, day}, {hour, minute, second}}) - + offset, + true <- instant >= :calendar.datetime_to_gregorian_seconds({{1, 1, 1}, {0, 0, 0}}), + true <- + instant <= :calendar.datetime_to_gregorian_seconds({{9999, 12, 31}, {23, 59, 59}}) do + fraction = captures["fraction"] || "" + + milliseconds = + fraction |> String.trim_leading(".") |> String.slice(0, 3) |> String.pad_trailing(3, "0") + + {:ok, {instant, milliseconds}} + else + _ -> {:error, "date comparisons require valid portable timestamps"} + end + end + + defp portable_datetime(_), do: {:error, "date comparisons require valid portable timestamps"} + + defp portable_offset("Z"), do: {:ok, 0} + + defp portable_offset( + <> + ) do + with {hour, ""} <- Integer.parse(hour), + {minute, ""} <- Integer.parse(minute), + true <- hour <= 14 and minute <= 59 and (hour < 14 or minute == 0) do + seconds = hour * 3600 + minute * 60 + {:ok, if(sign == "+", do: seconds, else: -seconds)} + else + _ -> {:error, "invalid timestamp offset"} + end + end + + defp reference_label(reference, owner) do + variable = reference["variable"] || owner || "" + root = if reference["feature"], do: "#{reference["feature"]}.#{variable}", else: variable + + if not Map.has_key?(reference, "path") or reference["path"] == [], + do: root, + else: "#{root}:#{json(reference["path"])}" + end + + defp json(@missing), do: "" + defp json(value), do: Jason.encode!(value) + + defp with_path(path, message), + do: if(String.starts_with?(message, path), do: message, else: "#{path}: #{message}") + + defp with_metadata(check, message) do + metadata = + [ + if(is_binary(check["key"]), do: "key=#{inspect(check["key"])}"), + if(is_binary(check["description"]), + do: "description=#{inspect(check["description"])}" + ) + ] + |> Enum.reject(&is_nil/1) + + if metadata == [], do: message, else: "#{Enum.join(metadata, " ")}: #{message}" + end +end diff --git a/lib/featurevisor/cli/test_coverage.ex b/lib/featurevisor/cli/test_coverage.ex new file mode 100644 index 0000000..82c4b6e --- /dev/null +++ b/lib/featurevisor/cli/test_coverage.ex @@ -0,0 +1,90 @@ +defmodule Featurevisor.CLI.TestCoverage do + @moduledoc false + + alias Featurevisor.{Evaluation, Module} + + def open(false), do: nil + + def open(true) do + :ets.new(:featurevisor_cli_test_coverage, [ + :set, + :public, + read_concurrency: true, + write_concurrency: true + ]) + end + + def close(nil), do: :ok + + def close(table) do + if :ets.info(table) != :undefined, do: :ets.delete(table) + :ok + end + + def module(table, metadata, synthetic \\ []) + def module(nil, _metadata, _synthetic), do: nil + + def module(table, metadata, synthetic) do + %Module{ + name: "tester-coverage", + after_evaluation: fn evaluation, _options -> + unless evaluation.feature_key in synthetic, do: observe(table, metadata, evaluation) + evaluation + end + } + end + + def selections(nil), do: [] + + def selections(table) do + table + |> :ets.tab2list() + |> Enum.map(fn {_key, selection} -> selection end) + |> Enum.sort_by(&Jason.encode!/1) + end + + defp observe(table, metadata, %Evaluation{} = evaluation) do + paths = + cond do + is_list(evaluation.variable_override_paths) and + evaluation.variable_override_paths != [] -> + evaluation.variable_override_paths + + is_list(evaluation.variable_override_path) -> + [evaluation.variable_override_path] + + is_binary(evaluation.variable_override_key) -> + [[evaluation.variable_override_key]] + + is_integer(evaluation.variable_override_index) -> + [nil] + + true -> + [] + end + + if is_binary(evaluation.rule_key) or paths != [] do + paths = if paths == [], do: [nil], else: paths + + Enum.each(paths, fn path -> + selection = + metadata + |> maybe_put("featureKey", evaluation.feature_key) + |> maybe_put("variableKey", evaluation.variable_key) + |> maybe_put("ruleKey", evaluation.rule_key) + |> maybe_put("overridePath", path) + |> maybe_put("overrideIndex", if(is_nil(path), do: evaluation.variable_override_index)) + |> maybe_put( + "variation", + if(is_map(evaluation.variation), do: evaluation.variation["value"]) + ) + + :ets.insert(table, {Jason.encode!(selection), selection}) + end) + end + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, _key, ""), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) +end diff --git a/lib/featurevisor/cli/test_runner.ex b/lib/featurevisor/cli/test_runner.ex index 813ba90..2de67bc 100644 --- a/lib/featurevisor/cli/test_runner.ex +++ b/lib/featurevisor/cli/test_runner.ex @@ -1,19 +1,96 @@ defmodule Featurevisor.CLI.TestRunner do @moduledoc false - alias Featurevisor.CLI.Project + alias Featurevisor.CLI.{Project, Segments, TestChecks, TestCoverage} alias Featurevisor.{Evaluation, Module} def run(options) do project = options.project_directory_path + coverage = TestCoverage.open(options.coverage) - with {:ok, available_targets} <- Project.targets(project), - {:ok, targets} <- selected_targets(options.target, available_targets), - {:ok, tests} <- Project.json(project, "list", ["--tests", "--apply-matrix"]), - {:ok, environments} <- Project.environments(project), - {:ok, segments_list} <- Project.json(project, "list", ["--segments"]), - {:ok, datafiles} <- build_datafiles(project, environments, targets, options.inflate) do - segments = Map.new(segments_list, &{&1["key"], &1}) - execute(tests, datafiles, segments, targets, options) + try do + result = + with :ok <- validate_patterns(options), + {:ok, sets} <- Project.sets(project, options.set), + {:ok, tests} <- list_tests(project, sets, options), + {:ok, environments} <- Project.environments(project), + {:ok, executions} <- build_executions(project, sets, environments, options), + {:ok, report} <- execute(tests, executions, sets, coverage, options) do + output_report(report, options) + end + + case result do + {:reported_error, message} -> + {:error, message} + + {:error, message} -> + if options.json, do: output_json(error_report(message, options), options.pretty) + {:error, message} + + other -> + other + end + after + TestCoverage.close(coverage) + end + end + + defp validate_patterns(options) do + Enum.reduce_while( + [ + {"--key-pattern", options.key_pattern}, + {"--assertion-pattern", options.assertion_pattern} + ], + :ok, + fn + {_name, nil}, :ok -> + {:cont, :ok} + + {name, value}, :ok -> + case Regex.compile(value, "i") do + {:ok, _} -> {:cont, :ok} + {:error, {reason, _}} -> {:halt, {:error, "Invalid #{name}: #{reason}"}} + end + end + ) + end + + defp test_list_args(options, set, case_id) do + ["--tests", "--execution-protocol", "--apply-matrix", "--execution-metadata"] ++ + if(is_binary(options.entity_type), do: ["--entityType=#{options.entity_type}"], else: []) ++ + set_args(set) ++ + if(is_binary(case_id), do: ["--case=#{case_id}"], else: []) + end + + defp set_args(nil), do: [] + defp set_args(set), do: ["--set=#{set}"] + + @doc false + def validate_execution_protocol(%{ + "kind" => "featurevisor.test-execution", + "schemaVersion" => 1, + "tests" => tests + }) + when is_list(tests) do + if Enum.all?(tests, &is_map/1), + do: {:ok, tests}, + else: {:error, "Test execution protocol tests must contain only objects"} + end + + def validate_execution_protocol(%{"kind" => kind}) + when kind != "featurevisor.test-execution", + do: {:error, "Invalid test execution protocol kind"} + + def validate_execution_protocol(%{"schemaVersion" => version}) when version != 1, + do: {:error, "Unsupported test execution protocol schemaVersion #{inspect(version)}"} + + def validate_execution_protocol(%{"tests" => tests}) when not is_list(tests), + do: {:error, "Test execution protocol tests must be an array"} + + def validate_execution_protocol(_), do: {:error, "Invalid test execution protocol envelope"} + + defp list_protocol(project, args) do + with {:ok, envelope} <- Project.json(project, "list", args) do + validate_execution_protocol(envelope) end end @@ -28,16 +105,79 @@ defmodule Featurevisor.CLI.TestRunner do end end - defp build_datafiles(project, environments, targets, inflate) do + defp list_tests(project, sets, %{case_id: case_id} = options) when is_binary(case_id) do + if length(sets) == 1 do + list_protocol(project, test_list_args(options, hd(sets), case_id)) + else + Enum.reduce_while(sets, {:ok, nil}, fn set, {:ok, nil} -> + case list_protocol(project, test_list_args(options, set, nil)) do + {:ok, tests} -> + matched = + Enum.any?(tests, fn test -> + Enum.any?(test["assertions"] || [], fn assertion -> + get_in(assertion, ["_case", "id"]) == case_id + end) + end) + + if matched, + do: {:halt, list_protocol(project, test_list_args(options, set, case_id))}, + else: {:cont, {:ok, nil}} + + error -> + {:halt, error} + end + end) + |> case do + {:ok, nil} -> + list_protocol(project, test_list_args(options, hd(sets), case_id)) + + result -> + result + end + end + end + + defp list_tests(project, sets, options) do + Enum.reduce_while(sets, {:ok, []}, fn set, {:ok, all} -> + case list_protocol(project, test_list_args(options, set, nil)) do + {:ok, tests} when is_list(tests) -> {:cont, {:ok, all ++ tests}} + error -> {:halt, error} + end + end) + end + + defp build_executions(project, sets, environments, options) do + Enum.reduce_while(sets, {:ok, %{}}, fn set, {:ok, executions} -> + with {:ok, available_targets} <- Project.targets(project, set), + {:ok, targets} <- selected_targets(options.target, available_targets), + {:ok, segments_list} <- + Project.json(project, "list", ["--segments"] ++ set_args(set)), + {:ok, datafiles} <- + build_datafiles(project, environments, targets, options.inflate, set) do + execution = %{ + targets: targets, + segments: Map.new(segments_list, &{&1["key"], &1}), + datafiles: datafiles + } + + {:cont, {:ok, Map.put(executions, set, execution)}} + else + error -> {:halt, error} + end + end) + end + + defp build_datafiles(project, environments, targets, inflate, set) do Enum.reduce_while(environments, {:ok, %{}}, fn environment, {:ok, files} -> - with {:ok, base} <- Project.build(project, environment, nil, inflate), + with {:ok, base} <- Project.build(project, environment, nil, inflate, set), {:ok, files} <- build_target_datafiles( project, environment, targets, inflate, - Map.put(files, Project.datafile_key(environment, nil), base) + Map.put(files, Project.datafile_key(environment, nil), base), + set ) do {:cont, {:ok, files}} else @@ -46,9 +186,9 @@ defmodule Featurevisor.CLI.TestRunner do end) end - defp build_target_datafiles(project, environment, targets, inflate, files) do + defp build_target_datafiles(project, environment, targets, inflate, files, set) do Enum.reduce_while(targets, {:ok, files}, fn target, {:ok, current} -> - case Project.build(project, environment, target, inflate) do + case Project.build(project, environment, target, inflate, set) do {:ok, datafile} -> {:cont, {:ok, Map.put(current, Project.datafile_key(environment, target), datafile)}} @@ -58,131 +198,693 @@ defmodule Featurevisor.CLI.TestRunner do end) end - defp execute(tests, datafiles, segments, targets, options) do + defp execute(tests, executions, sets, coverage, options) do key_pattern = compile_pattern(options.key_pattern) assertion_pattern = compile_pattern(options.assertion_pattern) - result = - Enum.reduce( - tests, - %{passed_specs: 0, failed_specs: 0, passed_assertions: 0, failed_assertions: 0}, - fn spec, totals -> - if key_pattern && not Regex.match?(key_pattern, spec["key"] || "") do - totals + with {:ok, results} <- + Enum.reduce_while(tests, {:ok, []}, fn spec, {:ok, results} -> + if key_pattern && not Regex.match?(key_pattern, entity_key(spec)) do + {:cont, {:ok, results}} + else + with {:ok, metadata} <- test_metadata(spec), + {:ok, execution} <- fetch_execution(executions, metadata.set), + {:ok, result} <- + execute_spec( + spec, + metadata, + execution, + assertion_pattern, + coverage, + options + ) do + {:cont, {:ok, if(is_nil(result), do: results, else: results ++ [result])}} + else + error -> {:halt, error} + end + end + end) do + summary = summary(results) + + errors = + if summary["specs"]["passed"] + summary["specs"]["failed"] == 0, + do: [%{"message" => "No test specs matched the requested filters"}], + else: [] + + with {:ok, uncovered} <- maybe_uncovered(options, sets) do + report = %{ + "schemaVersion" => 1, + "runner" => "elixir", + "passed" => summary["specs"]["failed"] == 0 and errors == [], + "summary" => summary, + "results" => results, + "errors" => errors + } + + report = if is_nil(uncovered), do: report, else: Map.put(report, "uncovered", uncovered) + + report = + if options.coverage do + Map.put(report, "coverage", %{ + "mode" => "observed-selections", + "selections" => TestCoverage.selections(coverage) + }) else - assertions = - Enum.filter( - spec["assertions"] || [], - &selected_assertion?(&1, targets, assertion_pattern) - ) + report + end - errors = Enum.map(assertions, &run_assertion(spec, &1, datafiles, segments, options)) - failed = Enum.count(errors, &(&1 != [])) + {:ok, report} + end + end + rescue + error in Regex.CompileError -> {:error, "Invalid test filter: #{Exception.message(error)}"} + end - Enum.each(Enum.zip(assertions, errors), fn {assertion, messages} -> - Enum.each( - messages, - &IO.puts( - :stderr, - " #{spec["key"]} (#{assertion["description"] || "assertion"}): #{&1}" - ) - ) - end) + defp selected_assertion?(assertion, targets, pattern) do + (is_nil(assertion["target"]) or assertion["target"] in targets) and + (is_nil(pattern) or Regex.match?(pattern, assertion["description"] || "")) + end - if assertions == [] do - totals - else - success = failed == 0 + defp compile_pattern(nil), do: nil + defp compile_pattern(value), do: Regex.compile!(value, "i") - unless options.only_failures or options.quiet or not success, - do: IO.puts("PASS #{spec["key"]}") + defp entity_key(%{"_test" => %{"entityType" => entity_type}} = spec) + when is_binary(entity_type), + do: spec[entity_type] || spec["key"] || "" - unless options.quiet or success, do: IO.puts("FAIL #{spec["key"]}") + defp entity_key(spec), do: spec["key"] || "" - totals - |> Map.update!(if(success, do: :passed_specs, else: :failed_specs), &(&1 + 1)) - |> Map.update!(:passed_assertions, &(&1 + length(assertions) - failed)) - |> Map.update!(:failed_assertions, &(&1 + failed)) - end + defp test_metadata(%{"_test" => metadata}) when is_map(metadata) do + with source when is_binary(source) <- metadata["source"], + entity_type when is_binary(entity_type) <- metadata["entityType"], + set when is_nil(set) or is_binary(set) <- metadata["set"] do + {:ok, %{source: String.replace(source, "\\", "/"), set: set, entity_type: entity_type}} + else + _ -> {:error, "Node test listing returned invalid _test metadata"} + end + end + + defp test_metadata(_), + do: {:error, "Node test listing did not include requested _test metadata"} + + defp case_metadata(%{"_case" => metadata}) when is_map(metadata) do + with id when is_binary(id) and id != "" <- metadata["id"], + assertion_index when is_integer(assertion_index) and assertion_index >= 0 <- + metadata["assertionIndex"], + matrix_values when is_map(matrix_values) <- metadata["matrixValues"] do + {:ok, %{id: id, assertion_index: assertion_index, matrix_values: matrix_values}} + else + _ -> {:error, "Node test listing returned invalid _case metadata"} + end + end + + defp case_metadata(_), + do: {:error, "Node test listing did not include requested _case metadata"} + + defp fetch_execution(executions, set) do + case Map.fetch(executions, set) do + {:ok, execution} -> {:ok, execution} + :error -> {:error, "Missing native execution state for set #{inspect(set)}"} + end + end + + defp execute_spec(spec, metadata, execution, pattern, coverage, options) do + case spec["assertions"] do + assertions when is_list(assertions) -> + assertions = Enum.filter(assertions, &selected_assertion?(&1, execution.targets, pattern)) + + Enum.reduce_while(assertions, {:ok, []}, fn assertion, {:ok, cases} -> + case case_metadata(assertion) do + {:ok, case_metadata} -> + coverage_metadata = %{ + "set" => metadata.set, + "environment" => assertion["environment"], + "target" => assertion["target"] + } + + discriminators = + Enum.filter( + ["feature", "variable", "segment", "scenario"], + &Map.has_key?(spec, &1) + ) + + errors = + if length(discriminators) == 1 do + run_assertion( + spec, + assertion, + execution.datafiles, + execution.segments, + coverage, + coverage_metadata, + options, + %{ + entity_type: metadata.entity_type, + assertion_index: case_metadata.assertion_index + } + ) + else + ["test spec requires exactly one of feature, variable, segment or scenario"] + end + + rerun_args = + ["test", "--case=#{case_metadata.id}"] ++ + if(is_binary(options.project_directory_path_argument), + do: ["--projectDirectoryPath=#{options.project_directory_path_argument}"], + else: [] + ) ++ set_args(metadata.set) + + rerun_args = + rerun_args ++ + Enum.map(Enum.uniq(options.target), &"--target=#{&1}") + + result = %{ + "id" => case_metadata.id, + "description" => assertion["description"], + "environment" => assertion["environment"], + "target" => assertion["target"], + "context" => + if(is_map(assertion["context"]), do: assertion["context"], else: %{}), + "matrixValues" => case_metadata.matrix_values, + "passed" => errors == [], + "errors" => + Enum.map(errors, &normalize_report_error(&1, case_metadata.assertion_index)), + "rerun" => %{"command" => "./featurevisor", "args" => rerun_args} + } + + {:cont, {:ok, cases ++ [result]}} + + error -> + {:halt, error} end + end) + |> case do + {:ok, []} -> + {:ok, nil} + + {:ok, cases} -> + {:ok, + %{ + "type" => metadata.entity_type, + "key" => entity_key(spec), + "source" => metadata.source, + "set" => metadata.set, + "passed" => Enum.all?(cases, & &1["passed"]), + "cases" => cases + }} + + error -> + error end - ) - IO.puts("\n---") - IO.puts("Test specs: #{result.passed_specs} passed, #{result.failed_specs} failed") - IO.puts("Assertions: #{result.passed_assertions} passed, #{result.failed_assertions} failed") + _ -> + {:error, "Test #{inspect(spec["key"])} needs an assertions array"} + end + end + + defp summary(results) do + initial = %{ + "specs" => counts(), + "assertions" => counts(), + "entitySpecs" => counts(), + "entityAssertions" => counts(), + "scenarioSpecs" => counts(), + "scenarioCases" => counts() + } + + Enum.reduce(results, initial, fn result, summary -> + scenario? = result["type"] == "scenario" + spec_key = if scenario?, do: "scenarioSpecs", else: "entitySpecs" + case_key = if scenario?, do: "scenarioCases", else: "entityAssertions" + spec_outcome = if result["passed"], do: "passed", else: "failed" + passed_cases = Enum.count(result["cases"], & &1["passed"]) + failed_cases = length(result["cases"]) - passed_cases + + summary + |> increment("specs", spec_outcome, 1) + |> increment(spec_key, spec_outcome, 1) + |> increment("assertions", "passed", passed_cases) + |> increment("assertions", "failed", failed_cases) + |> increment(case_key, "passed", passed_cases) + |> increment(case_key, "failed", failed_cases) + end) + end + + defp counts, do: %{"passed" => 0, "failed" => 0} + + defp increment(summary, group, outcome, amount), + do: put_in(summary, [group, outcome], summary[group][outcome] + amount) + + defp maybe_uncovered(%{uncovered: false}, _sets), do: {:ok, nil} + + defp maybe_uncovered(options, sets) do + Enum.reduce_while(sets, {:ok, []}, fn set, {:ok, values} -> + with {:ok, features} <- + Project.json( + options.project_directory_path, + "list", + ["--features", "--without-tests"] ++ set_args(set) + ), + {:ok, variables} <- + Project.json( + options.project_directory_path, + "list", + ["--variables", "--without-tests"] ++ set_args(set) + ) do + gap = %{ + "set" => set, + "features" => sorted_keys(features), + "variables" => sorted_keys(variables) + } + + {:cont, {:ok, values ++ [gap]}} + else + error -> {:halt, error} + end + end) + end + + defp sorted_keys(values) when is_list(values) do + values |> Enum.map(& &1["key"]) |> Enum.reject(&is_nil/1) |> Enum.uniq() |> Enum.sort() + end + + defp sorted_keys(_), do: [] + + defp normalize_report_error(message, _index) when is_binary(message), + do: %{"message" => message} + + defp normalize_report_error(%{"type" => "check"} = error, _index), do: error + + defp normalize_report_error(error, index) when is_map(error), + do: Map.update(error, "path", ["assertions", index], &(["assertions", index] ++ &1)) + + defp expectation_error(type, path, expected, actual) do + %{ + "type" => type, + "path" => path, + "expected" => expected, + "actual" => actual, + "message" => "expected #{inspect(expected)}, received #{inspect(actual)}" + } + end + + defp child_errors(errors, index) do + Enum.map(errors, &Map.update!(&1, "path", fn path -> ["children", index] ++ path end)) + end + + defp check_report_errors(result, authored, child_index \\ nil) do + base = + if authored.entity_type == "scenario", + do: [], + else: ["assertions", authored.assertion_index] + + base = if is_integer(child_index), do: base ++ ["children", child_index], else: base + base = base ++ ["checks"] + + Enum.map(result.report_failures, fn failure -> + path = + if is_integer(failure.check_index), + do: base ++ [failure.check_index], + else: base + + evaluated = + Enum.map(failure.evaluated, fn leaf -> + Map.put(leaf, "path", base ++ (leaf["path"] || [])) + end) + + details = + %{ + "checkIndex" => failure.check_index, + "checkKey" => failure.check_key, + "checkDescription" => failure.check_description, + "evaluated" => evaluated + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + + %{ + "message" => failure.message, + "type" => "check", + "path" => path, + "expected" => failure.expected, + "actual" => failure.actual, + "details" => details + } + end) + end + + defp output_report(report, options) do + if options.json, do: output_json(report, options.pretty), else: output_human(report, options) + + if report["passed"], + do: :ok, + else: + {:reported_error, + get_in(report, ["errors", Access.at(0), "message"]) || + "#{report["summary"]["specs"]["failed"]} test specs failed"} + end - cond do - result.passed_specs + result.failed_specs == 0 -> - {:error, "No test specs matched the requested filters"} + defp output_json(report, pretty), + do: IO.puts(Jason.encode!(report, pretty: pretty, escape: :unicode_safe)) - result.failed_specs > 0 -> - {:error, "#{result.failed_specs} test specs failed"} + defp output_human(report, options) do + unless options.quiet do + Enum.each(report["results"], fn result -> + if not result["passed"] or not options.only_failures do + label = if result["type"] == "scenario", do: "scenario ", else: "" + set = if result["set"], do: " [set #{result["set"]}]", else: "" - true -> - :ok + IO.puts( + "#{if(result["passed"], do: "PASS", else: "FAIL")} #{label}#{result["key"]}#{set}" + ) + end + + result["cases"] + |> Enum.with_index() + |> Enum.reject(fn {test_case, _index} -> + options.only_failures and test_case["passed"] + end) + |> Enum.each(fn {test_case, index} -> + kind = "check" + description = test_case["description"] || "assertion" + label = human_case_label(test_case, index) + IO.puts(" #{if(test_case["passed"], do: "✔", else: "✘")} #{label}") + + Enum.each(test_case["errors"], fn error -> + IO.puts( + :stderr, + " #{result["key"]} (#{description}) #{kind}: #{human_check_message(error["message"])}" + ) + end) + + unless test_case["passed"] do + rerun = test_case["rerun"] + command = Enum.map_join(rerun["args"], " ", &shell_arg/1) + IO.puts(:stderr, " Rerun (POSIX shell): #{rerun["command"]} #{command}") + end + end) + end) + end + + summary = report["summary"] + IO.puts("\n---") + print_counts("Entity specs", summary["entitySpecs"]) + print_counts("Entity assertions", summary["entityAssertions"]) + print_counts("Scenario specs", summary["scenarioSpecs"]) + print_counts("Scenario cases", summary["scenarioCases"]) + print_counts("Total cases", summary["assertions"]) + + if report["uncovered"] do + IO.puts("\nDefinitions without associated specs (not evaluation coverage):") + + Enum.each(report["uncovered"], fn entry -> + if entry["set"], do: IO.puts(" Set: #{entry["set"]}") + IO.puts(" Features: #{Enum.join(entry["features"], ", ") |> none_if_empty()}") + IO.puts(" Global variables: #{Enum.join(entry["variables"], ", ") |> none_if_empty()}") + end) + end + + if report["coverage"] do + IO.puts("\nObserved rule and override selections (not branch coverage):") + selections = report["coverage"]["selections"] + Enum.each(selections, &IO.puts(" #{human_coverage_selection(&1)}")) + if selections == [], do: IO.puts(" No selections observed") end - rescue - error in Regex.CompileError -> {:error, "Invalid test filter: #{Exception.message(error)}"} end - defp selected_assertion?(assertion, targets, pattern) do - (is_nil(assertion["target"]) or assertion["target"] in targets) and - (is_nil(pattern) or Regex.match?(pattern, assertion["description"] || "")) + defp none_if_empty(""), do: "none" + defp none_if_empty(value), do: value + + defp human_check_message(message) do + Regex.replace(~r/\b(checks|children)\[(\d+)\]/, message, fn _, kind, index -> + "#{if(kind == "children", do: "Child", else: "check")} #{String.to_integer(index) + 1}" + end) end - defp compile_pattern(nil), do: nil - defp compile_pattern(value), do: Regex.compile!(value, "i") + defp human_case_label(test_case, index) do + label = test_case["description"] || "assertion #{index + 1}" + details = ["case #{index + 1}"] + matrix = test_case["matrixValues"] || %{} + + details = + if map_size(matrix) > 0 do + values = + Enum.map_join(matrix, ", ", fn {key, value} -> "#{key}=#{Jason.encode!(value)}" end) + + details ++ ["matrix #{values}"] + else + details + end + + details = + if test_case["target"], do: details ++ ["target #{test_case["target"]}"], else: details + + "#{label} (#{Enum.join(details, "; ")})" + end + + defp human_coverage_selection(selection) do + owner = + if selection["featureKey"] do + "feature #{selection["featureKey"]}" <> + if(selection["variableKey"], do: " variable #{selection["variableKey"]}", else: "") + else + "global variable #{selection["variableKey"]}" + end + + scope = + Enum.filter( + [ + selection["set"] && "set #{selection["set"]}", + selection["environment"] && "environment #{selection["environment"]}", + selection["target"] && "target #{selection["target"]}" + ], + & &1 + ) + + picks = + Enum.filter( + [ + selection["ruleKey"] && "rule #{selection["ruleKey"]}", + Map.has_key?(selection, "variation") && + "variation #{Jason.encode!(selection["variation"])}", + selection["overridePath"] && "override #{Enum.join(selection["overridePath"], " > ")}", + is_integer(selection["overrideIndex"]) && "override #{selection["overrideIndex"] + 1}" + ], + & &1 + ) + + owner <> + if(scope == [], do: "", else: " (#{Enum.join(scope, ", ")})") <> + ": #{Enum.join(picks, ", ")}" + end - defp run_assertion(%{"segment" => segment}, assertion, datafiles, segments, options) do - _ = datafiles + defp print_counts(label, counts), + do: IO.puts("#{label}: #{counts["passed"]} passed, #{counts["failed"]} failed") - datafile = %{ - "schemaVersion" => "2", - "revision" => "tester", - "segments" => segments, - "features" => %{} + defp shell_arg(value) do + if Regex.match?(~r/^[A-Za-z0-9_\-\.\/:=]+$/, value), + do: value, + else: "'#{String.replace(value, "'", "'\\''")}'" + end + + defp error_report(message, options) do + report = %{ + "schemaVersion" => 1, + "runner" => "elixir", + "passed" => false, + "summary" => summary([]), + "results" => [], + "errors" => [%{"message" => message}] } - f = - Featurevisor.create_featurevisor(%{ - datafile: datafile, - context: assertion["context"] || %{}, - log_level: log_level(options) - }) + report = if options.uncovered, do: Map.put(report, "uncovered", []), else: report + + if options.coverage, + do: + Map.put(report, "coverage", %{ + "mode" => "observed-selections", + "selections" => [] + }), + else: report + end + + defp run_assertion( + _spec, + %{"__invalidDiscriminator" => true}, + _datafiles, + _segments, + _coverage, + _coverage_metadata, + _options, + _authored + ), + do: ["test spec requires exactly one of feature, variable, segment or scenario"] + + defp run_assertion( + %{"segment" => segment} = spec, + assertion, + datafiles, + segments, + coverage, + coverage_metadata, + options, + authored + ) do + definitions = spec["_segments"] || segments + selected = datafiles[Project.datafile_key(assertion["environment"], assertion["target"])] + + needs_datafile = + not is_nil(assertion["environment"]) or not is_nil(assertion["target"]) or + Segments.dependent?(segment, definitions) + + if is_nil(selected) and needs_datafile do + [missing_datafile_message(assertion)] + else + datafile = selected || Segments.empty() + + with :ok <- Segments.preflight(segment, datafile, definitions), + [] <- preflight_assertion_tree(spec, assertion, datafile, nil, false, authored) do + {datafile, keys} = Segments.prepare(datafile, definitions) + + module = %Module{ + bucket_value: fn value -> + if is_number(assertion["at"]), + do: trunc(assertion["at"] * 1_000), + else: value.bucket_value + end + } + + diagnostic_ref = make_ref() + diagnostic_target = self() + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + context: assertion["context"] || %{}, + sticky_features: assertion["stickyFeatures"], + sticky_variables: assertion["stickyVariables"], + log_level: if(options.verbose, do: :debug, else: :error), + on_diagnostic: TestChecks.diagnostic_handler(diagnostic_target, diagnostic_ref), + modules: + modules( + [module, TestChecks.diagnostic_module(diagnostic_target, diagnostic_ref)], + coverage, + coverage_metadata, + Map.values(keys) + ) + }) + + try do + result = Featurevisor.evaluate_flag(f, keys[segment]) + errors = segment_errors(diagnostic_ref) + + cond do + errors != [] -> + errors - actual = Featurevisor.segment_matches?(f, segment) - Featurevisor.close(f) + result.reason == :error -> + [Exception.message(result.error)] - if actual == assertion["expectedToMatch"], - do: [], - else: ["expected segment match #{assertion["expectedToMatch"]}, got #{actual}"] + result.enabled == true == assertion["expectedToMatch"] -> + [] + + true -> + [ + expectation_error( + "segment", + ["expectedToMatch"], + assertion["expectedToMatch"], + result.enabled == true + ) + ] + end + after + Featurevisor.close(f) + end + else + {:error, message} -> [message] + errors -> errors + end + end end - defp run_assertion(%{"feature" => feature}, assertion, datafiles, _segments, options) do + defp run_assertion( + %{"feature" => feature}, + assertion, + datafiles, + _segments, + coverage, + coverage_metadata, + options, + _authored + ) do datafile = datafiles[Project.datafile_key(assertion["environment"], assertion["target"])] if is_nil(datafile) do [missing_datafile_message(assertion)] else - run_feature_assertion(feature, assertion, datafile, options) + run_feature_assertion(feature, assertion, datafile, coverage, coverage_metadata, options) end end - defp run_assertion(%{"variable" => variable}, assertion, datafiles, _segments, options) do + defp run_assertion( + %{"variable" => variable} = spec, + assertion, + datafiles, + _segments, + coverage, + coverage_metadata, + options, + authored + ) do datafile = datafiles[Project.datafile_key(assertion["environment"], assertion["target"])] if is_nil(datafile) do [missing_datafile_message(assertion)] else - run_variable_assertion(variable, assertion, datafile, options) + run_variable_assertion( + spec, + variable, + assertion, + datafile, + coverage, + coverage_metadata, + options, + authored + ) end end - defp run_feature_assertion(feature, assertion, datafile, options) do - if options.show_datafile, do: IO.puts(Jason.encode!(datafile, pretty: true)) + defp run_assertion( + %{"scenario" => _scenario} = spec, + assertion, + datafiles, + _segments, + coverage, + coverage_metadata, + options, + authored + ) do + datafile = datafiles[Project.datafile_key(assertion["environment"], assertion["target"])] + + if is_nil(datafile), + do: [missing_datafile_message(assertion)], + else: + run_scenario_assertion( + spec, + assertion, + datafile, + coverage, + coverage_metadata, + options, + authored + ) + end + + defp run_feature_assertion(feature, assertion, datafile, coverage, coverage_metadata, options) do + if options.show_datafile, do: IO.puts(:stderr, Jason.encode!(datafile, pretty: true)) module = %Module{ name: "tester", @@ -197,10 +899,10 @@ defmodule Featurevisor.CLI.TestRunner do Featurevisor.create_featurevisor(%{ datafile: datafile, context: assertion["context"] || %{}, - sticky_features: assertion["stickyFeatures"] || assertion["sticky"], + sticky_features: assertion["stickyFeatures"], sticky_variables: assertion["stickyVariables"], - log_level: log_level(options), - modules: [module] + log_level: check_log_level(options, assertion), + modules: modules([module], coverage, coverage_metadata) }) try do @@ -236,8 +938,45 @@ defmodule Featurevisor.CLI.TestRunner do end end - defp run_variable_assertion(variable, assertion, datafile, options) do - if options.show_datafile, do: IO.puts(Jason.encode!(datafile, pretty: true)) + defp run_variable_assertion( + spec, + variable, + assertion, + datafile, + coverage, + coverage_metadata, + options, + authored + ) do + case preflight_assertion_tree(spec, assertion, datafile, variable, false, authored) do + [] -> + do_run_variable_assertion( + spec, + variable, + assertion, + datafile, + coverage, + coverage_metadata, + options, + authored + ) + + errors -> + errors + end + end + + defp do_run_variable_assertion( + spec, + variable, + assertion, + datafile, + coverage, + coverage_metadata, + options, + authored + ) do + if options.show_datafile, do: IO.puts(:stderr, Jason.encode!(datafile, pretty: true)) module = %Module{ name: "tester", @@ -248,14 +987,27 @@ defmodule Featurevisor.CLI.TestRunner do end } + diagnostic_ref = make_ref() + diagnostic_target = self() + + {datafile, segment_keys} = Segments.prepare(datafile, spec["_segments"] || %{}) + root_context = assertion["context"] || %{} + f = Featurevisor.create_featurevisor(%{ datafile: datafile, - context: assertion["context"] || %{}, + context: root_context, sticky_features: assertion["stickyFeatures"], sticky_variables: assertion["stickyVariables"], - log_level: log_level(options), - modules: [module] + log_level: check_log_level(options, assertion), + on_diagnostic: TestChecks.diagnostic_handler(diagnostic_target, diagnostic_ref), + modules: + modules( + [module, TestChecks.diagnostic_module(diagnostic_target, diagnostic_ref)], + coverage, + coverage_metadata, + Map.values(segment_keys) + ) }) evaluation_options = @@ -283,13 +1035,51 @@ defmodule Featurevisor.CLI.TestRunner do "#{variable}: variable" ) - compare_variable_children(errors, assertion, f, variable) + errors = + if Map.has_key?(assertion, "checks") do + errors ++ + check_report_errors( + TestChecks.evaluate_result( + f, + datafile, + assertion["checks"], + variable, + diagnostic_ref, + "checks", + segment_check_options(segment_keys, spec, root_context) + ), + authored + ) + else + errors + end + + compare_variable_children( + errors, + assertion, + f, + variable, + %{ + datafile: datafile, + diagnostic_ref: diagnostic_ref, + authored: authored, + segment_evaluator: segment_keys, + spec: spec, + root_context: root_context + } + ) after Featurevisor.close(f) end end - defp compare_variable_children(errors, assertion, f, variable) do + defp compare_variable_children( + errors, + assertion, + f, + variable, + check_execution + ) do Enum.with_index(assertion["children"] || []) |> Enum.reduce(errors, fn {item, index}, current -> child = @@ -305,41 +1095,297 @@ defmodule Featurevisor.CLI.TestRunner do evaluation = Featurevisor.Child.evaluate_global_variable(child, variable, %{}, options) prefix = "#{variable}: children[#{index}]" + effective_context = Map.merge(check_execution.root_context, item["context"] || %{}) try do - current = + failures = compare_present( - current, + [], item, "expectedValue", fn -> evaluation.variable_value end, prefix ) - compare_evaluation( - current, - item["expectedEvaluation"], - wire(evaluation), - "#{prefix}.expectedEvaluation" - ) + failures = + compare_evaluation( + failures, + item["expectedEvaluation"], + wire(evaluation), + "#{prefix}.expectedEvaluation" + ) + + current = current ++ child_errors(failures, index) + + if Map.has_key?(item, "checks") do + current ++ + check_report_errors( + TestChecks.evaluate_result( + child, + check_execution.datafile, + item["checks"], + variable, + check_execution.diagnostic_ref, + "children[#{index}].checks", + segment_check_options( + check_execution.segment_evaluator, + check_execution.spec, + effective_context + ) + ), + check_execution.authored, + index + ) + else + current + end after Featurevisor.Child.close(child) end end) end + defp run_scenario_assertion( + spec, + assertion, + datafile, + coverage, + coverage_metadata, + options, + authored + ) do + case preflight_assertion_tree(spec, assertion, datafile, nil, true, authored) do + [] -> + do_run_scenario_assertion( + spec, + assertion, + datafile, + coverage, + coverage_metadata, + options, + authored + ) + + errors -> + errors + end + end + + defp do_run_scenario_assertion( + spec, + assertion, + datafile, + coverage, + coverage_metadata, + options, + authored + ) do + if options.show_datafile, do: IO.puts(:stderr, Jason.encode!(datafile, pretty: true)) + + module = %Module{ + name: "tester", + bucket_value: fn value -> + if is_number(assertion["at"]), + do: trunc(assertion["at"] * 1_000), + else: value.bucket_value + end + } + + diagnostic_ref = make_ref() + diagnostic_target = self() + + {datafile, segment_keys} = Segments.prepare(datafile, spec["_segments"] || %{}) + root_context = assertion["context"] || %{} + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + context: root_context, + sticky_features: assertion["stickyFeatures"] || %{}, + sticky_variables: assertion["stickyVariables"] || %{}, + log_level: check_log_level(options, assertion), + on_diagnostic: TestChecks.diagnostic_handler(diagnostic_target, diagnostic_ref), + modules: + modules( + [module, TestChecks.diagnostic_module(diagnostic_target, diagnostic_ref)], + coverage, + coverage_metadata, + Map.values(segment_keys) + ) + }) + + try do + errors = + cond do + assertion["__missingAssertions"] -> + ["scenario spec requires assertions"] + + Map.has_key?(assertion, "checks") -> + TestChecks.evaluate_result( + f, + datafile, + assertion["checks"], + nil, + diagnostic_ref, + "checks", + segment_check_options(segment_keys, spec, root_context) + ) + |> check_report_errors(authored) + + (assertion["children"] || []) == [] -> + ["scenario assertion requires checks or children"] + + true -> + [] + end + + Enum.with_index(assertion["children"] || []) + |> Enum.reduce(errors, fn {item, index}, current -> + effective_context = Map.merge(root_context, item["context"] || %{}) + + child = + Featurevisor.spawn(f, item["context"] || %{}, %{ + sticky_features: item["stickyFeatures"] || %{}, + sticky_variables: item["stickyVariables"] || %{} + }) + + try do + current ++ + check_report_errors( + TestChecks.evaluate_result( + child, + datafile, + item["checks"], + nil, + diagnostic_ref, + "children[#{index}].checks", + segment_check_options(segment_keys, spec, effective_context) + ), + authored, + index + ) + after + Featurevisor.Child.close(child) + end + end) + after + Featurevisor.close(f) + end + end + + defp preflight_assertion_tree(spec, assertion, datafile, owner, scenario?, authored) do + definitions = if(is_map(spec["_segments"]), do: spec["_segments"], else: %{}) + segment_checks = %{definitions: definitions} + + children_result = + case Map.get(assertion, "children", []) do + children when is_list(children) -> {:ok, children} + value -> {:error, malformed_children_error(value, authored)} + end + + root_errors = + cond do + Map.has_key?(assertion, "checks") -> + datafile + |> TestChecks.preflight(assertion["checks"], owner, "checks", segment_checks) + |> check_report_errors(authored) + + scenario? and children_result == {:ok, []} -> + [%{"message" => "scenario assertion requires checks or children"}] + + true -> + [] + end + + case children_result do + {:error, error} -> + root_errors ++ [error] + + {:ok, children} -> + children + |> Enum.with_index() + |> Enum.reduce(root_errors, fn {child, index}, errors -> + cond do + not is_map(child) -> + errors ++ [malformed_child_error(child, index, authored)] + + Map.has_key?(child, "checks") -> + errors ++ + (datafile + |> TestChecks.preflight( + child["checks"], + owner, + "children[#{index}].checks", + segment_checks + ) + |> check_report_errors(authored, index)) + + scenario? -> + errors ++ [missing_child_checks_error(index, authored)] + + true -> + errors + end + end) + end + end + + defp malformed_children_error(value, authored) do + %{ + "message" => "children must be an array", + "type" => "check", + "path" => authored_path(authored) ++ ["children"], + "expected" => "array", + "actual" => value + } + end + + defp malformed_child_error(value, index, authored) do + %{ + "message" => "children[#{index}] must be an object", + "type" => "check", + "path" => authored_path(authored) ++ ["children", index], + "expected" => "object", + "actual" => value + } + end + + defp missing_child_checks_error(index, authored) do + %{ + "message" => "children[#{index}].checks: checks must be a nonempty array", + "type" => "check", + "path" => authored_path(authored) ++ ["children", index, "checks"], + "expected" => "valid checks", + "actual" => nil + } + end + + defp authored_path(%{entity_type: "scenario"}), do: [] + defp authored_path(%{assertion_index: index}), do: ["assertions", index] + defp missing_datafile_message(assertion) do environment = assertion["environment"] || "none" target = if assertion["target"], do: " and target \"#{assertion["target"]}\"", else: "" "datafile not found for environment \"#{environment}\"#{target}" end - defp compare_present(errors, assertion, key, actual, feature) do - if Map.has_key?(assertion, key) and actual.() != assertion[key], - do: - errors ++ - ["#{feature}: #{key} expected #{inspect(assertion[key])}, got #{inspect(actual.())}"], - else: errors + defp compare_present(errors, assertion, key, getter, _feature) do + if Map.has_key?(assertion, key) do + actual = getter.() + + type = + %{ + "expectedToBeEnabled" => "flag", + "expectedVariation" => "variation", + "expectedValue" => "variable" + }[key] + + if actual == assertion[key], + do: errors, + else: errors ++ [expectation_error(type, [key], assertion[key], actual)] + else + errors + end end defp compare_variables(errors, assertion, f, feature) do @@ -368,7 +1414,7 @@ defmodule Featurevisor.CLI.TestRunner do if actual == expected, do: current, else: - current ++ ["#{feature}.#{key}: expected #{inspect(expected)}, got #{inspect(actual)}"] + current ++ [expectation_error("variable", ["expectedVariables", key], expected, actual)] end) end @@ -380,7 +1426,8 @@ defmodule Featurevisor.CLI.TestRunner do errors, expected["flag"], wire(Featurevisor.evaluate_flag(f, feature)), - "#{feature}: flag" + "#{feature}: flag", + ["expectedEvaluations", "flag"] ) errors = @@ -388,7 +1435,8 @@ defmodule Featurevisor.CLI.TestRunner do errors, expected["variation"], wire(Featurevisor.evaluate_variation(f, feature)), - "#{feature}: variation" + "#{feature}: variation", + ["expectedEvaluations", "variation"] ) Enum.reduce(expected["variables"] || %{}, errors, fn {key, value}, current -> @@ -396,71 +1444,146 @@ defmodule Featurevisor.CLI.TestRunner do current, value, wire(Featurevisor.evaluate_variable(f, feature, key)), - "#{feature}: variable #{key}" + "#{feature}: variable #{key}", + ["expectedEvaluations", "variables", key] ) end) end - defp compare_evaluation(errors, nil, _actual, _label), do: errors + defp compare_evaluation(errors, expected, actual, label, path \\ ["expectedEvaluation"]) + defp compare_evaluation(errors, nil, _actual, _label, _path), do: errors - defp compare_evaluation(errors, expected, actual, label) do + defp compare_evaluation(errors, expected, actual, _label, path) do Enum.reduce(expected, errors, fn {key, value}, current -> if actual[key] == value, do: current, - else: - current ++ ["#{label} #{key} expected #{inspect(value)}, got #{inspect(actual[key])}"] + else: current ++ [expectation_error("evaluation", path ++ [key], value, actual[key])] end) end defp compare_children(errors, assertion, f, feature) do Enum.with_index(assertion["children"] || []) |> Enum.reduce(errors, fn {item, index}, current -> + # A child owns its sticky values. It does not inherit the parent assertion's. child = Featurevisor.spawn(f, item["context"] || %{}, %{ - sticky_features: item["sticky"] || assertion["sticky"] || %{}, - sticky_variables: item["stickyVariables"] || assertion["stickyVariables"] || %{} + sticky_features: item["stickyFeatures"] || %{}, + sticky_variables: item["stickyVariables"] || %{} }) - current = - compare_present( - current, - item, - "expectedToBeEnabled", - fn -> Featurevisor.Child.enabled?(child, feature) end, - "#{feature}: child #{index + 1}" - ) - - current = - compare_present( - current, - item, - "expectedVariation", - fn -> Featurevisor.Child.get_variation(child, feature) end, - "#{feature}: child #{index + 1}" - ) - - current = - Enum.reduce(item["expectedVariables"] || %{}, current, fn {key, expected}, acc -> - actual = Featurevisor.Child.get_variable(child, feature, key) - - if actual == expected, - do: acc, - else: - acc ++ - [ - "#{feature}: child #{index + 1} variable #{key} expected #{inspect(expected)}, got #{inspect(actual)}" - ] - end) + try do + variation_options = + if Map.has_key?(item, "defaultVariationValue"), + do: %{default_variation_value: item["defaultVariationValue"]}, + else: %{} + + failures = + [] + |> compare_present( + item, + "expectedToBeEnabled", + fn -> Featurevisor.Child.enabled?(child, feature) end, + feature + ) + |> compare_present( + item, + "expectedVariation", + fn -> Featurevisor.Child.get_variation(child, feature, %{}, variation_options) end, + feature + ) - Featurevisor.Child.close(child) - current + failures = + Enum.reduce(item["expectedVariables"] || %{}, failures, fn {key, expected}, acc -> + options = + if is_map(item["defaultVariableValues"]) and + Map.has_key?(item["defaultVariableValues"], key), + do: %{default_variable_value: item["defaultVariableValues"][key]}, + else: %{} + + actual = Featurevisor.Child.get_variable(child, feature, key, %{}, options) + + if actual == expected, + do: acc, + else: + acc ++ + [expectation_error("variable", ["expectedVariables", key], expected, actual)] + end) + + expected = item["expectedEvaluations"] || %{} + + failures = + Enum.reduce(["flag", "variation"], failures, fn kind, acc -> + if is_map(expected[kind]) do + evaluation = + if kind == "flag", + do: Featurevisor.Child.evaluate_flag(child, feature), + else: Featurevisor.Child.evaluate_variation(child, feature) + + compare_evaluation(acc, expected[kind], wire(evaluation), feature, [ + "expectedEvaluations", + kind + ]) + else + acc + end + end) + + failures = + Enum.reduce(expected["variables"] || %{}, failures, fn {key, value}, acc -> + compare_evaluation( + acc, + value, + wire(Featurevisor.Child.evaluate_variable(child, feature, key)), + feature, + ["expectedEvaluations", "variables", key] + ) + end) + + current ++ child_errors(failures, index) + after + Featurevisor.Child.close(child) + end end) end defp wire(%Evaluation{} = evaluation), - do: evaluation |> Evaluation.to_map() |> Jason.encode!() |> Jason.decode!() - + do: + evaluation + |> Evaluation.to_map() + |> Map.update("error", nil, fn error -> %{"message" => Exception.message(error)} end) + |> Map.reject(fn {_key, value} -> is_nil(value) end) + |> Jason.encode!() + |> Jason.decode!() + + defp log_level(%{json: true}), do: :error defp log_level(%{verbose: true}), do: :debug defp log_level(%{quiet: true}), do: :fatal defp log_level(_), do: :error + + defp check_log_level(options, assertion) do + if Map.has_key?(assertion, "checks") or (assertion["children"] || []) != [], + do: :error, + else: log_level(options) + end + + defp segment_errors(ref) do + receive do + {:featurevisor_cli_check_diagnostic, ^ref, diagnostic} -> + errors = segment_errors(ref) + if to_string(diagnostic.level) == "error", do: [diagnostic.message | errors], else: errors + after + 0 -> [] + end + end + + defp modules(base, coverage, metadata, synthetic \\ []), + do: Enum.reject(base ++ [TestCoverage.module(coverage, metadata, synthetic)], &is_nil/1) + + defp segment_check_options(evaluator, spec, context) do + %{ + keys: evaluator, + definitions: if(is_map(spec["_segments"]), do: spec["_segments"], else: %{}), + context: context + } + end end diff --git a/lib/featurevisor/conditions.ex b/lib/featurevisor/conditions.ex index ee52b20..1a5c4d7 100644 --- a/lib/featurevisor/conditions.ex +++ b/lib/featurevisor/conditions.ex @@ -3,10 +3,11 @@ defmodule Featurevisor.Conditions do alias Featurevisor.{Bucketer, Semver} - def parse_conditions("*", _report), do: "*" + def parse_conditions(value, report, cache \\ nil) + def parse_conditions("*", _report, _cache), do: "*" - def parse_conditions(value, report) when is_binary(value) do - case Jason.decode(value) do + def parse_conditions(value, report, cache) when is_binary(value) do + case decode(value, cache) do {:ok, parsed} -> parsed @@ -23,17 +24,117 @@ defmodule Featurevisor.Conditions do end end - def parse_conditions(value, _report), do: value + def parse_conditions(value, _report, _cache), do: value - def parse_segments(value) when is_binary(value) do + def parse_segments(value, cache \\ nil) + + def parse_segments(value, cache) when is_binary(value) do if String.starts_with?(value, "{") or String.starts_with?(value, "[") do - Jason.decode!(value) + case decode(value, cache) do + {:ok, parsed} -> parsed + {:error, error} -> raise error + end else value end end - def parse_segments(value), do: value + def parse_segments(value, _cache), do: value + + defp decode(value, nil), do: Jason.decode(value) + + defp decode(value, cache) do + key = {:expression, value} + + case :ets.lookup(cache, key) do + [{^key, parsed}] -> + {:ok, parsed} + + [] -> + case Jason.decode(value) do + {:ok, parsed} = result -> + :ets.insert_new(cache, {key, parsed}) + result + + error -> + error + end + end + end + + def matches?(conditions, options) do + match_expression(conditions, options) + catch + :invalid_conditions -> false + end + + defp match_expression(:invalid_conditions, _options), do: throw(:invalid_conditions) + defp match_expression("*", _options), do: true + defp match_expression([], _options), do: false + + defp match_expression(values, options) when is_list(values), + do: Enum.all?(values, &match_expression(&1, options)) + + defp match_expression(key, options) when is_binary(key) do + case read_segment(key, options) do + nil -> + false + + segment -> + conditions = segment["conditions"] + + if attribute_only?(conditions), + do: match_expression(conditions, options), + else: + Featurevisor.EvaluationSession.segment(options.session, key, fn -> + match_expression(conditions, options) + end) + end + end + + defp match_expression(%{"variable" => _} = condition, options), + do: options.condition_leaf.(condition, options) + + defp match_expression(%{"feature" => _} = condition, options), + do: options.condition_leaf.(condition, options) + + defp match_expression(%{"attribute" => _} = condition, options), + do: all_conditions?(condition, options.context, options.regex_cache, options.report) + + defp match_expression(%{"and" => values}, options) when is_list(values), + do: values != [] and Enum.all?(values, &match_expression(&1, options)) + + defp match_expression(%{"or" => values}, options) when is_list(values), + do: Enum.any?(values, &match_expression(&1, options)) + + defp match_expression(%{"not" => values}, options) when is_list(values), + do: values != [] and not Enum.all?(values, &match_expression(&1, options)) + + defp match_expression(_, _), do: false + + defp read_segment(key, %{entity_source: read}), do: read.("segments", key) + + defp read_segment(key, options), + do: + Featurevisor.Normalize.get( + options.datafile, + "segments", + key, + options.regex_cache, + options.report + ) + + defp attribute_only?("*"), do: false + defp attribute_only?(%{"attribute" => _}), do: true + defp attribute_only?(values) when is_list(values), do: Enum.all?(values, &attribute_only?/1) + + defp attribute_only?(value) when is_map(value) do + Enum.any?(["and", "or", "not"], fn key -> + is_list(value[key]) and attribute_only?(value[key]) + end) + end + + defp attribute_only?(_), do: false def all_conditions?("*", _context, _regex_cache, _report), do: true @@ -72,12 +173,17 @@ defmodule Featurevisor.Conditions do def all_segments?("*", _context, _segments, _cache, _report), do: true def all_segments?(key, context, segments, cache, report) when is_binary(key) do - case Map.get(segments, key) do + case segments[key] do nil -> false segment -> - all_conditions?(parse_conditions(segment["conditions"], report), context, cache, report) + all_conditions?( + parse_conditions(segment["conditions"], report, cache), + context, + cache, + report + ) end end @@ -97,23 +203,44 @@ defmodule Featurevisor.Conditions do def all_segments?(_, _, _, _, _), do: false - defp condition?(%{"attribute" => attribute, "operator" => operator} = condition, context, cache) do + defp condition?( + %{"attribute" => attribute, "operator" => _operator} = condition, + context, + cache + ) do context_value = Bucketer.context_value(context, attribute) has_context = context_value != :missing + + value_matches?(if(has_context, do: context_value, else: nil), condition, cache, has_context) + end + + def value_matches?(context_value, %{"operator" => operator} = condition, cache, present \\ true) do value = Map.get(condition, "value") case operator do "equals" -> - strict_equal?(context_value, value) + present and strict_equal?(context_value, value) "notEquals" -> - not strict_equal?(context_value, value) + not present or not strict_equal?(context_value, value) "exists" -> - has_context + present "notExists" -> - not has_context + not present + + "isNull" -> + present and is_nil(context_value) + + "notNull" -> + present and not is_nil(context_value) + + "empty" -> + present and empty_value?(context_value, true) + + "notEmpty" -> + present and empty_value?(context_value, false) "before" -> compare_dates(context_value, value, :lt) @@ -122,11 +249,11 @@ defmodule Featurevisor.Conditions do compare_dates(context_value, value, :gt) "in" -> - is_list(value) and Enum.any?(value, &strict_equal?(&1, context_value)) and - primitive?(context_value) + present and is_list(value) and Enum.any?(value, &strict_equal?(&1, context_value)) and + in_value?(context_value) "notIn" -> - is_list(value) and primitive?(context_value) and + present and is_list(value) and in_value?(context_value) and not Enum.any?(value, &strict_equal?(&1, context_value)) "contains" -> @@ -169,6 +296,13 @@ defmodule Featurevisor.Conditions do is_list(context_value) and primitive?(value) and not Enum.any?(context_value, &strict_equal?(&1, value)) + operator when operator in ["includesAny", "notIncludesAny"] -> + present and is_list(context_value) and is_list(value) and + Enum.all?(value, &primitive?/1) and + Enum.any?(value, fn candidate -> + Enum.any?(context_value, &strict_equal?(&1, candidate)) + end) == (operator == "includesAny") + "semverEquals" -> semver(context_value, value, &(&1 == 0)) @@ -199,9 +333,21 @@ defmodule Featurevisor.Conditions do defp primitive?(value), do: is_binary(value) or is_number(value) or is_boolean(value) or is_nil(value) + defp in_value?(value), do: is_binary(value) or is_number(value) or is_nil(value) + defp strings?(left, right), do: is_binary(left) and is_binary(right) defp numbers?(left, right), do: is_number(left) and is_number(right) + defp empty_value?(value, expected) when is_binary(value), do: value == "" == expected + defp empty_value?(value, expected) when is_list(value), do: value == [] == expected + + defp empty_value?(value, expected) when is_map(value) and not is_struct(value) do + Enum.all?(value, fn {key, _value} -> is_binary(key) end) and + map_size(value) == 0 == expected + end + + defp empty_value?(_value, _expected), do: false + defp compare_dates(left, right, direction) do with {:ok, left_date} <- date_time(left), {:ok, right_date} <- date_time(right) do DateTime.compare(left_date, right_date) == direction @@ -210,14 +356,21 @@ defmodule Featurevisor.Conditions do end end - defp date_time(%DateTime{} = value), do: {:ok, value} + defp date_time(%DateTime{} = value), do: bounded_date(value) defp date_time(value) when is_binary(value), do: iso_date(value) defp date_time(_), do: :error defp iso_date(value) do - if Regex.match?(~r/T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/, value) do - case DateTime.from_iso8601(value) do - {:ok, date, _offset} -> {:ok, date} + pattern = + ~r/\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/ + + if Regex.match?(pattern, value) and not String.starts_with?(value, "0000-") and + valid_date_offset?(value) do + normalized = + Regex.replace(~r/(\.[0-9]{3})[0-9]{1,6}(?=Z|[+-][0-9]{2}:[0-9]{2}\z)/, value, "\\1") + + case DateTime.from_iso8601(normalized) do + {:ok, date, _offset} -> bounded_date(date) error -> error end else @@ -225,6 +378,30 @@ defmodule Featurevisor.Conditions do end end + defp valid_date_offset?(value) do + case Regex.run(~r/([+-])([0-9]{2}):([0-9]{2})\z/, value) do + nil -> + true + + [_, _, hour, minute] -> + h = String.to_integer(hour) + m = String.to_integer(minute) + (h < 14 and m <= 59) or (h == 14 and m == 0) + end + end + + defp bounded_date(value) do + milliseconds = DateTime.to_unix(value, :millisecond) + + if value.year >= 1 and value.year <= 9999 and + abs(value.utc_offset + value.std_offset) <= 14 * 3600 and + milliseconds >= -62_135_596_800_000 and milliseconds <= 253_402_300_799_999 do + {:ok, DateTime.truncate(value, :millisecond)} + else + :error + end + end + defp semver(left, right, predicate) when is_binary(left) and is_binary(right) do case Semver.compare(left, right) do value when is_integer(value) -> predicate.(value) @@ -246,7 +423,9 @@ defmodule Featurevisor.Conditions do options = flags |> String.graphemes() |> Enum.reject(&(&1 == "g")) |> Enum.map(®ex_option!/1) - {:ok, regex} = Regex.compile(pattern, Enum.join(options)) + {:ok, regex} = + Regex.compile("(*UTF)" <> normalize_portable_regex(pattern), Enum.join(options)) + :ets.insert_new(cache, {key, regex}) regex end @@ -260,4 +439,42 @@ defmodule Featurevisor.Conditions do defp regex_option!(flag), do: raise(ArgumentError, "unsupported regular expression flag #{flag}") + + defp normalize_portable_regex(pattern) do + pattern + |> String.graphemes() + |> normalize_regex_chars(false, []) + |> Enum.reverse() + |> IO.iodata_to_binary() + end + + defp normalize_regex_chars([], _in_class, result), do: result + + defp normalize_regex_chars(["\\", code | rest], in_class, result) do + replacement = + case {in_class, code} do + {true, "d"} -> "0-9" + {true, "D"} -> "\\x{0}-\\x{2F}\\x{3A}-\\x{10FFFF}" + {true, "w"} -> "A-Za-z0-9_" + {true, "W"} -> "\\x{0}-\\x{2F}\\x{3A}-\\x{40}\\x{5B}-\\x{5E}\\x{60}\\x{7B}-\\x{10FFFF}" + {false, "d"} -> "[0-9]" + {false, "D"} -> "[^0-9]" + {false, "w"} -> "(?-i:[A-Za-z0-9_])" + {false, "W"} -> "(?-i:[^A-Za-z0-9_])" + _ -> "\\" <> code + end + + normalize_regex_chars(rest, in_class, [replacement | result]) + end + + defp normalize_regex_chars([char | rest], in_class, result) do + next_class = + cond do + char == "[" and not in_class -> true + char == "]" and in_class -> false + true -> in_class + end + + normalize_regex_chars(rest, next_class, [char | result]) + end end diff --git a/lib/featurevisor/datafile_store.ex b/lib/featurevisor/datafile_store.ex new file mode 100644 index 0000000..2ee9748 --- /dev/null +++ b/lib/featurevisor/datafile_store.ex @@ -0,0 +1,237 @@ +defmodule Featurevisor.DatafileStore do + @moduledoc false + use GenServer + + # A separate owner lets an in progress evaluation finish after instance + # shutdown. BEAM releases table fixations automatically if a reader dies. + def start(owner), do: GenServer.start(__MODULE__, owner) + + def create(pid, datafile, previous \\ nil, incoming \\ nil) do + input = if previous, do: incoming, else: datafile + GenServer.call(pid, {:create, input, previous}, :infinity) + end + + def retire(pid, table), do: GenServer.cast(pid, {:retire, table}) + + # Each snapshot has a small read cache. Immutable entity versions live in a + # shared ordered table, so publishing a partial snapshot writes only its input. + def entity(table, kind, key) do + case :ets.lookup(table, {:entity, kind, key}) do + [{_, slot}] -> slot + [] -> read_entity(table, kind, key) + end + end + + defp read_entity(table, kind, key) do + [{_, {entities, generation, first}}] = :ets.lookup(table, :entities) + + case :ets.prev(entities, {kind, key, generation + 1}) do + {^kind, ^key, version} = row when version >= first -> + slot = :ets.lookup_element(entities, row, 2) + :ets.insert_new(table, {{:entity, kind, key}, slot}) + slot + + _ -> + nil + end + end + + def materialize(table, kind) do + [{_, {entities, generation, first}}] = :ets.lookup(table, :entities) + + entities + |> :ets.select([ + {{{kind, :"$1", :"$2"}, {:_, :"$3"}}, [{:>=, :"$2", first}, {:"=<", :"$2", generation}], + [{{:"$1", :"$2", :"$3"}}]} + ]) + |> Enum.reduce(%{}, fn {key, version, value}, values -> + Map.update(values, key, {version, value}, fn {old, _} = slot -> + if version > old, do: {version, value}, else: slot + end) + end) + |> Map.new(fn {key, {_, value}} -> {key, value} end) + end + + def keys(table, kind) do + [{_, {entities, generation, first}}] = :ets.lookup(table, :entities) + + entities + |> :ets.select([ + {{{kind, :"$1", :"$2"}, :_}, [{:>=, :"$2", first}, {:"=<", :"$2", generation}], [:"$1"]} + ]) + |> Enum.uniq() + end + + @impl true + def init(owner) do + Process.monitor(owner) + + normalized = + :ets.new(__MODULE__, [:set, :public, read_concurrency: true, write_concurrency: true]) + + entities = :ets.new(__MODULE__, [:ordered_set, :protected, read_concurrency: true]) + + {:ok, + %{ + tables: [], + retired: [], + owner: owner, + timer: nil, + normalized: normalized, + entities: entities, + generation: 0, + current: %{}, + obsolete: %{} + }} + end + + @impl true + def handle_call({:create, datafile, previous}, _from, state) do + table = :ets.new(__MODULE__, [:set, :public, read_concurrency: true, write_concurrency: true]) + generation = state.generation + 1 + + first = + if previous do + [{_, {_, _, first}}] = :ets.lookup(previous, :entities) + first + else + generation + end + + :ets.insert(table, {:normalization_cache, state.normalized}) + :ets.insert(table, {:entities, {state.entities, generation, first}}) + + {current, obsolete} = + if previous, + do: {state.current, state.obsolete}, + else: {%{}, retire_entities(state.current, state.obsolete, generation)} + + {current, obsolete} = + Enum.reduce(["features", "variables", "segments"], {current, obsolete}, fn kind, slots -> + Enum.reduce(datafile[kind] || %{}, slots, fn {key, value}, {current, obsolete} -> + obsolete = + case Map.fetch(current, {kind, key}) do + {:ok, version} -> Map.put(obsolete, {kind, key, version}, generation) + :error -> obsolete + end + + insert_entity(state.entities, {kind, key, generation}, value) + {Map.put(current, {kind, key}, generation), obsolete} + end) + end) + + {:reply, table, + %{ + state + | tables: [table | state.tables], + generation: generation, + current: current, + obsolete: obsolete + }, :hibernate} + end + + defp insert_entity(entities, key, value), + do: :ets.insert(entities, {key, {make_ref(), value}}) + + defp retire_entities(current, obsolete, generation) do + Enum.reduce(current, obsolete, fn {{kind, key}, version}, obsolete -> + Map.put(obsolete, {kind, key, version}, generation) + end) + end + + @impl true + def handle_cast({:retire, table}, state) do + {:noreply, schedule(%{state | retired: Enum.uniq([table | state.retired])}), :hibernate} + end + + @impl true + def handle_info({:DOWN, _ref, :process, owner, _reason}, %{owner: owner} = state) do + state = %{ + state + | owner: nil, + retired: state.tables, + current: %{}, + obsolete: retire_entities(state.current, state.obsolete, state.generation + 1) + } + + {:noreply, schedule(state), :hibernate} + end + + def handle_info(:collect, state) do + {unused, retained} = Enum.split_with(state.retired, &(:ets.info(&1, :safe_fixed) == false)) + Enum.each(unused, &:ets.delete/1) + state = %{state | tables: state.tables -- unused, retired: retained, timer: nil} + state = prune_entities(state) + + if is_nil(state.owner) and state.tables == [], + do: {:stop, :normal, state}, + else: {:noreply, schedule(state), :hibernate} + end + + defp prune_entities(state) do + snapshots = + Enum.map(state.tables, fn table -> + [{_, {_, generation, first}}] = :ets.lookup(table, :entities) + {first, generation} + end) + + # The immutable owner index holds only keys and generations. Collect only + # superseded slots, never scan unchanged entities after a partial update. + obsolete = + Enum.reduce(state.obsolete, %{}, fn {{_, _, version} = row, next}, retained -> + needed = + Enum.any?(snapshots, fn {first, generation} -> + version >= first and version <= generation and generation < next + end) + + if needed do + Map.put(retained, row, next) + else + prune_entity(state, row) + retained + end + end) + + %{state | obsolete: obsolete} + end + + defp prune_entity(state, row) do + {token, _} = :ets.lookup_element(state.entities, row, 2) + :ets.delete(state.entities, row) + :ets.delete(state.normalized, token) + end + + defp schedule(%{retired: [_ | _], timer: nil} = state), + do: %{state | timer: Process.send_after(self(), :collect, 100)} + + defp schedule(state), do: state +end + +defmodule Featurevisor.EntityCollection do + @moduledoc false + @behaviour Access + defstruct [:table, :kind] + + @impl true + def fetch(%__MODULE__{table: table, kind: kind}, key) do + case Featurevisor.DatafileStore.entity(table, kind, key) do + {_token, value} -> {:ok, value} + nil -> :error + end + end + + @impl true + def get_and_update(_collection, _key, _fun), do: raise(ArgumentError, "Read only collection") + @impl true + def pop(_collection, _key), do: raise(ArgumentError, "Read only collection") + + def keys(%__MODULE__{table: table, kind: kind}), + do: Featurevisor.DatafileStore.keys(table, kind) + + def keys(map), do: Map.keys(map) + + def materialize(%__MODULE__{table: table, kind: kind}), + do: Featurevisor.DatafileStore.materialize(table, kind) + + def materialize(map), do: map +end diff --git a/lib/featurevisor/dependencies.ex b/lib/featurevisor/dependencies.ex new file mode 100644 index 0000000..36348b2 --- /dev/null +++ b/lib/featurevisor/dependencies.ex @@ -0,0 +1,58 @@ +defmodule Featurevisor.Dependencies do + @moduledoc false + + def entity("segments", value), do: visit(value["conditions"], MapSet.new()) + + def entity("features", value) do + result = selector(value, MapSet.new()) + result = Enum.reduce(value["force"] || [], result, &selector/2) + + Enum.reduce((value["traffic"] || []) ++ (value["variations"] || []), result, fn entry, + result -> + (entry["variableOverrides"] || %{}) + |> Map.values() + |> List.flatten() + |> Enum.reduce(selector(entry, result), &selector/2) + end) + end + + def entity("variables", value) do + result = selector(value, MapSet.new()) + + result = + case value["compose"] do + key when is_binary(key) -> MapSet.put(result, {"variables", key}) + entries when is_list(entries) -> Enum.reduce(entries, result, &reference/2) + _ -> result + end + + Enum.reduce(value["overrideProgram"] || value["overrides"] || [], result, &selector/2) + end + + defp selector(entry, result), do: visit(entry["conditions"], result) + defp visit("*", result), do: result + + defp visit(value, result) when is_binary(value) do + if Featurevisor.Normalize.stringified?(value) do + case Jason.decode(value) do + {:ok, decoded} -> visit(decoded, result) + _ -> result + end + else + MapSet.put(result, {"segments", value}) + end + end + + defp visit(values, result) when is_list(values), do: Enum.reduce(values, result, &visit/2) + defp visit(%{"variable" => _} = value, result), do: reference(value, result) + defp visit(%{"feature" => _} = value, result), do: reference(value, result) + defp visit(%{"attribute" => _}, result), do: result + + defp visit(value, result) when is_map(value) do + Enum.reduce(["and", "or", "not"], result, fn name, result -> visit(value[name], result) end) + end + + defp visit(_, result), do: result + defp reference(%{"feature" => key}, result), do: MapSet.put(result, {"features", key}) + defp reference(%{"variable" => key}, result), do: MapSet.put(result, {"variables", key}) +end diff --git a/lib/featurevisor/dependency_index.ex b/lib/featurevisor/dependency_index.ex new file mode 100644 index 0000000..9699efb --- /dev/null +++ b/lib/featurevisor/dependency_index.ex @@ -0,0 +1,70 @@ +defmodule Featurevisor.DependencyIndex do + @moduledoc false + @kinds ["features", "variables", "segments"] + + def new(datafile), do: add(%{reverse: %{}, owners: %{}}, datafile) + + def update(index, previous, current, nil) do + Enum.reduce(@kinds, index, fn kind, index -> + old = previous[kind] || %{} + new = current[kind] || %{} + + index = + Enum.reduce(old, index, fn {key, _}, index -> + if Map.has_key?(new, key), do: index, else: remove(index, {kind, key}) + end) + + update_entities(index, kind, old, new) + end) + end + + def update(index, previous, _current, incoming) do + Enum.reduce(@kinds, index, fn kind, index -> + update_entities(index, kind, previous[kind] || %{}, incoming[kind] || %{}) + end) + end + + defp update_entities(index, kind, previous, incoming) do + Enum.reduce(incoming, index, fn {key, entity}, index -> + # Hashes do not identify edges. Reuse only an identical definition. + if Map.has_key?(index.owners, {kind, key}) and previous[key] === entity do + index + else + index |> remove({kind, key}) |> add_entity(kind, key, entity) + end + end) + end + + defp add(index, datafile) do + Enum.reduce(@kinds, index, fn kind, index -> + Enum.reduce(datafile[kind] || %{}, index, fn {key, entity}, index -> + add_entity(index, kind, key, entity) + end) + end) + end + + defp add_entity(index, kind, key, entity) do + owner = {kind, key} + edges = Featurevisor.Dependencies.entity(kind, entity) + + reverse = + Enum.reduce(edges, index.reverse, fn edge, reverse -> + Map.update(reverse, edge, MapSet.new([owner]), &MapSet.put(&1, owner)) + end) + + %{reverse: reverse, owners: Map.put(index.owners, owner, edges)} + end + + defp remove(index, owner) do + reverse = + Enum.reduce(Map.get(index.owners, owner, []), index.reverse, fn edge, reverse -> + remaining = MapSet.delete(Map.fetch!(reverse, edge), owner) + + if MapSet.size(remaining) == 0, + do: Map.delete(reverse, edge), + else: Map.put(reverse, edge, remaining) + end) + + %{reverse: reverse, owners: Map.delete(index.owners, owner)} + end +end diff --git a/lib/featurevisor/evaluation.ex b/lib/featurevisor/evaluation.ex index 9c8bdf0..38f3775 100644 --- a/lib/featurevisor/evaluation.ex +++ b/lib/featurevisor/evaluation.ex @@ -4,14 +4,13 @@ defmodule Featurevisor.Evaluation do @type reason :: :feature_not_found | :disabled - | :required - | :out_of_range | :no_variations | :variation_disabled | :variable_not_found | :variable_default | :variable_disabled - | :required_features_unmet + | :conditions_unmet + | :variable_composed | :variable_override_variation | :variable_override_rule | :no_match @@ -34,8 +33,8 @@ defmodule Featurevisor.Evaluation do :traffic, :force_index, :force, - :required_features, - :sticky, + :conditions, + :sticky_feature, :variation, :variation_value, :variable_key, @@ -44,7 +43,9 @@ defmodule Featurevisor.Evaluation do :variable, :variable_override_index, :variable_override_key, - :variable_override_path + :variable_override_path, + :variable_override_paths, + __getter_value_normalized: false ] @doc "Converts an evaluation to its camelCase wire representation." @@ -52,8 +53,18 @@ defmodule Featurevisor.Evaluation do def to_map(%__MODULE__{} = evaluation) do evaluation |> Map.from_struct() - |> Enum.reject(fn {_key, value} -> is_nil(value) end) - |> Map.new(fn {key, value} -> {camelize(key), value} end) + |> Enum.reject(fn {key, value} -> + key == :__getter_value_normalized or + (is_nil(value) and not (key == :conditions and evaluation.reason == :conditions_unmet)) + end) + |> Map.new(fn {key, value} -> + value = + if key in [:conditions, :traffic, :force, :variation, :variable], + do: Featurevisor.Normalize.public(value), + else: value + + {camelize(key), value} + end) end defp camelize(:feature_key), do: "featureKey" @@ -68,6 +79,7 @@ defmodule Featurevisor.Evaluation do defp camelize(:variable_override_index), do: "variableOverrideIndex" defp camelize(:variable_override_key), do: "variableOverrideKey" defp camelize(:variable_override_path), do: "variableOverridePath" - defp camelize(:required_features), do: "requiredFeatures" + defp camelize(:sticky_feature), do: "stickyFeature" + defp camelize(:variable_override_paths), do: "variableOverridePaths" defp camelize(key), do: Atom.to_string(key) end diff --git a/lib/featurevisor/evaluation_session.ex b/lib/featurevisor/evaluation_session.ex new file mode 100644 index 0000000..c23bb43 --- /dev/null +++ b/lib/featurevisor/evaluation_session.ex @@ -0,0 +1,292 @@ +defmodule Featurevisor.RequirementCycleError do + @moduledoc false + defexception message: "Circular dependency" +end + +defmodule Featurevisor.RequirementError do + @moduledoc false + defexception [:message] +end + +defmodule Featurevisor.EvaluationSession do + @moduledoc false + + # The table belongs to one public evaluation. It is passed only to internal + # prerequisite reads and is deleted when that evaluation finishes. + def new, do: :ets.new(__MODULE__, [:set, :private]) + + def close(session) do + Process.delete({__MODULE__, session, :borrowed}) + :ets.delete(session) + end + + def dependency_of(session) do + case :ets.lookup(session, :active) do + [{:active, [parent | _]}] -> parent.current + _ -> nil + end + end + + def update_current(session, options) do + current = identity(options) + [{:active, [frame | ancestors]}] = :ets.lookup(session, :active) + :ets.insert(session, {:active, [%{frame | current: current} | ancestors]}) + end + + def bypass_memo(session) do + [{:active, [frame | ancestors]}] = :ets.lookup(session, :active) + :ets.insert(session, {:active, [%{frame | memoable: false} | ancestors]}) + end + + def run(session, options, prerequisite, fun) do + identity = identity(options) + + active = + case :ets.lookup(session, :active) do + [{:active, stack}] -> stack + _ -> [] + end + + if Enum.any?(active, &(&1.identity == identity or &1.current == identity)), + do: raise(Featurevisor.RequirementCycleError) + + if length(active) + segment_depth(session) > 256, + do: + raise(Featurevisor.RequirementCycleError, message: "Evaluation dependency depth exceeded") + + requester = dependency_of(session) + key = if prerequisite, do: fingerprint(identity, requester, options), else: nil + + if key && :ets.member(session, {:memo, key}) do + [{_, value}] = :ets.lookup(session, {:memo, key}) + + if value == :borrowed, + do: Map.fetch!(Process.get({__MODULE__, session, :borrowed}), key), + else: value + else + :ets.insert( + session, + {:active, [%{identity: identity, current: identity, memoable: true} | active]} + ) + + try do + options = + if prerequisite, + do: + options + |> Map.put(:dependency_of, requester) + |> Map.put(:dependency_purpose, options[:dependency_purpose] || :condition), + else: options + + options = + if is_nil(options[:dependency_purpose]), + do: Map.delete(options, :dependency_purpose), + else: options + + result = fun.(options) + result = if prerequisite, do: normalize_getter(result, options), else: result + + [{:active, [frame | _]}] = :ets.lookup(session, :active) + + if frame.memoable && key && key == fingerprint(identity, requester, options) do + if minimal = minimal(result, options[:dependency_purpose]), + do: put_memo(session, key, minimal, options[:dependency_purpose]) + end + + result + rescue + error -> + [{:active, [frame | _]}] = :ets.lookup(session, :active) + + :ets.insert( + session, + {:pending_error, {identity, requester, if(frame.memoable, do: key, else: nil)}} + ) + + reraise error, __STACKTRACE__ + after + :ets.insert(session, {:active, active}) + end + end + end + + def remember(session, options, result, true) do + identity = identity(options) + pending = :ets.lookup(session, :pending_error) + :ets.delete(session, :pending_error) + key = fingerprint(identity, dependency_of(session), options) + + if pending == [{:pending_error, {identity, dependency_of(session), key}}] && key && + minimal(result, options[:dependency_purpose]), + do: + put_memo( + session, + key, + minimal(result, options[:dependency_purpose]), + options[:dependency_purpose] + ) + + :ok + end + + def remember(_session, _options, _result, _prerequisite), do: :ok + + # BEAM values are immutable. Keep composition values on this request's heap, + # avoiding the deep term copy which an ETS value insertion would introduce. + defp put_memo(session, key, value, :composition) do + cache_key = {__MODULE__, session, :borrowed} + Process.put(cache_key, Map.put(Process.get(cache_key, %{}), key, value)) + :ets.insert(session, {{:memo, key}, :borrowed}) + end + + defp put_memo(session, key, value, _purpose), do: :ets.insert(session, {{:memo, key}, value}) + + defp identity(options) do + %{type: options.type} + |> maybe_put(:feature_key, options[:feature_key]) + |> maybe_put(:variable_key, options[:variable_key]) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp fingerprint(identity, requester, options) do + owner_sticky = options[:sticky_owner] == true + + unless safe_json?(options[:context]) and + (owner_sticky or + (safe_json?(options[:sticky_features]) and + safe_json?(options[:sticky_variables]))), + do: raise(ArgumentError, "Unsupported memo input") + + sticky = + if owner_sticky, + do: + {options[:sticky_features_token] || :owner, options[:sticky_variables_token] || :owner}, + else: {options[:sticky_features], options[:sticky_variables]} + + :erlang.term_to_binary( + {identity, requester, options[:dependency_purpose] || :condition, options[:context], sticky, + options[:regex_cache], options[:state_signature] && options[:state_signature].(), + options[:child_signature] && options[:child_signature].()} + ) + rescue + _ -> nil + end + + def segment(session, key, fun) do + active = + case :ets.lookup(session, :segments) do + [{_, values}] -> values + _ -> [] + end + + frames = + case :ets.lookup(session, :active) do + [{_, values}] -> length(values) + _ -> 0 + end + + if key in active, do: raise(Featurevisor.RequirementCycleError) + + if frames + length(active) > 256, + do: + raise(Featurevisor.RequirementCycleError, message: "Evaluation dependency depth exceeded") + + :ets.insert(session, {:segments, [key | active]}) + + try do + fun.() + after + :ets.insert(session, {:segments, active}) + end + end + + defp segment_depth(session) do + case :ets.lookup(session, :segments) do + [{_, values}] -> length(values) + _ -> 0 + end + end + + defp minimal(%Featurevisor.Evaluation{} = evaluation, :composition), + do: minimal_json(evaluation) + + defp minimal(%Featurevisor.Evaluation{} = evaluation, _purpose) do + if not safe_json?(evaluation.variable_value) or + not safe_json?(evaluation.variation_value) or + not safe_json?(evaluation.variation), + do: nil, + else: minimal_json(evaluation) + end + + defp minimal(_, _), do: nil + + defp normalize_getter(%Featurevisor.Evaluation{type: :variable} = evaluation, options) do + schema = evaluation.variable_schema || evaluation.variable || %{} + value = evaluation.variable_value + + value = + if is_nil(schema["compose"]) and schema["type"] == "json" and is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> + decoded + + {:error, error} -> + options.report.(%{ + level: :error, + code: "evaluation_error", + message: "getVariable failed", + originalError: error, + details: %{featureKey: evaluation.feature_key, variableKey: evaluation.variable_key} + }) + + nil + end + else + value + end + + %{evaluation | variable_value: value, __getter_value_normalized: true} + end + + defp normalize_getter(evaluation, _options), do: evaluation + + defp minimal_json(evaluation) do + %Featurevisor.Evaluation{ + type: evaluation.type, + feature_key: evaluation.feature_key, + variable_key: evaluation.variable_key, + reason: evaluation.reason, + enabled: evaluation.enabled, + variation_value: evaluation.variation_value, + variation: if(evaluation.variation, do: Map.take(evaluation.variation, ["value"])), + variable_value: evaluation.variable_value, + __getter_value_normalized: evaluation.__getter_value_normalized, + variable_schema: + if(evaluation.variable_schema, do: Map.take(evaluation.variable_schema, ["type"])), + variable: if(evaluation.variable, do: Map.take(evaluation.variable, ["type", "compose"])) + } + end + + defp safe_json?(_value, depth) when depth > 64, do: false + + defp safe_json?(value, _depth) + when is_nil(value) or is_boolean(value) or is_binary(value) or is_integer(value), do: true + + defp safe_json?(value, _depth) when is_float(value), + do: abs(value) < 1.797_693_134_862_315_7e308 + + defp safe_json?(value, depth) when is_list(value), + do: Enum.all?(value, &safe_json?(&1, depth + 1)) + + defp safe_json?(value, depth) when is_map(value) do + not is_struct(value) and + Enum.all?(value, fn {key, item} -> + (is_binary(key) or is_atom(key)) and safe_json?(item, depth + 1) + end) + end + + defp safe_json?(_value, _depth), do: false + defp safe_json?(value), do: safe_json?(value, 0) +end diff --git a/lib/featurevisor/evaluator.ex b/lib/featurevisor/evaluator.ex index 9c35d49..e92125b 100644 --- a/lib/featurevisor/evaluator.ex +++ b/lib/featurevisor/evaluator.ex @@ -1,31 +1,96 @@ defmodule Featurevisor.Evaluator do @moduledoc false - alias Featurevisor.{Bucketer, Conditions, Evaluation} + alias Featurevisor.{ + Bucketer, + Conditions, + Evaluation, + EvaluationSession, + RequirementCycleError, + RequirementError, + StickyStore + } + + @private_options [ + :session, + :prerequisite, + :state_signature, + :state_source, + :entity_source, + :child_signature, + :sticky_owner, + :sticky_features_token, + :sticky_variables_token, + :sticky_features_source, + :sticky_variables_source, + :condition_leaf, + :regex_cache, + :default_variation_present, + :default_variable_present + ] def evaluate_with_modules(options) do options = - Enum.reduce(options.modules, options, fn module, current -> - if module.before, do: module.before.(current), else: current - end) + if options[:prerequisite] == true do + current = options.state_source.() + + options + |> then(fn options -> + if options[:sticky_features_token], + do: options, + else: Map.put(options, :sticky_features, options.sticky_features_source.()) + end) + |> then(fn options -> + if options[:sticky_variables_token], + do: options, + else: Map.put(options, :sticky_variables, options.sticky_variables_source.()) + end) + |> Map.put(:modules, current.modules) + else + options + end options = - Enum.reduce(options.modules, options, fn module, current -> - if module.before_evaluation, do: module.before_evaluation.(current), else: current - end) + if options.modules == [] do + options + else + options + |> Map.update!(:sticky_features, &StickyStore.materialize/1) + |> Map.update!(:sticky_variables, &StickyStore.materialize/1) + end - evaluation = evaluate(options) - evaluation = apply_default(evaluation, options) + EvaluationSession.run(options.session, options, options[:prerequisite] == true, fn options -> + options = + if options.modules == [], + do: refresh_inherited_sticky(options, options, false, false), + else: before_modules(options) - evaluation = - Enum.reduce(options.modules, evaluation, fn module, current -> - if module.after_evaluation, do: module.after_evaluation.(current, options), else: current - end) + if options.modules != [], do: EvaluationSession.update_current(options.session, options) + + evaluation = evaluate(options) + evaluation = apply_default(evaluation, options) - Enum.reduce(options.modules, evaluation, fn module, current -> - if module.after, do: module.after.(current, options), else: current + modules = options.state_source.().modules + + after_options = + if modules == [], + do: options, + else: + options + |> Map.update!(:datafile, &Featurevisor.Server.materialize_datafile/1) + |> Map.update!(:sticky_features, &StickyStore.materialize/1) + |> Map.update!(:sticky_variables, &StickyStore.materialize/1) + + Enum.reduce(modules, evaluation, fn module, current -> + if module.after_evaluation, + do: module.after_evaluation.(current, Map.drop(after_options, @private_options)), + else: current + end) end) rescue + error in [RequirementCycleError, RequirementError] -> + reraise error, __STACKTRACE__ + error -> evaluation = %Evaluation{ type: options.type, @@ -35,6 +100,13 @@ defmodule Featurevisor.Evaluator do error: error } + EvaluationSession.remember( + options.session, + options, + evaluation, + options[:prerequisite] == true + ) + report_evaluation( options.report, evaluation, @@ -46,6 +118,53 @@ defmodule Featurevisor.Evaluator do evaluation end + defp before_modules(original_options) do + module_options = + Enum.reduce( + original_options.modules, + Map.drop(original_options, @private_options), + fn module, current -> + if module.before_evaluation, do: module.before_evaluation.(current), else: current + end + ) + + replaced_features = module_options[:sticky_features] != original_options[:sticky_features] + replaced_variables = module_options[:sticky_variables] != original_options[:sticky_variables] + + if replaced_features or replaced_variables, + do: EvaluationSession.bypass_memo(original_options.session) + + module_options + |> Map.drop(@private_options) + |> Map.merge(Map.take(original_options, @private_options)) + |> refresh_inherited_sticky(original_options, replaced_features, replaced_variables) + end + + defp refresh_inherited_sticky(options, original_options, replaced_features, replaced_variables) do + options = + cond do + replaced_features -> + Map.put(options, :sticky_features_token, make_ref()) + + original_options[:sticky_features_token] -> + options + + true -> + Map.put(options, :sticky_features, original_options.sticky_features_source.()) + end + + cond do + replaced_variables -> + Map.put(options, :sticky_variables_token, make_ref()) + + original_options[:sticky_variables_token] -> + options + + true -> + Map.put(options, :sticky_variables, original_options.sticky_variables_source.()) + end + end + defp apply_default( %Evaluation{type: :variation, variation: nil, variation_value: nil} = evaluation, %{default_variation_present: true} = options @@ -95,6 +214,9 @@ defmodule Featurevisor.Evaluator do evaluation end rescue + error in [RequirementCycleError, RequirementError] -> + reraise error, __STACKTRACE__ + error -> evaluation = %Evaluation{ type: options.type, @@ -123,7 +245,7 @@ defmodule Featurevisor.Evaluator do end defp disabled_evaluation(options) do - feature = get_in(options.datafile, ["features", options.feature_key]) + feature = read_feature(options) base = %Evaluation{ type: options.type, @@ -179,14 +301,14 @@ defmodule Featurevisor.Evaluator do end defp sticky(options) do - sticky = options.sticky && options.sticky[options.feature_key] + sticky = options.sticky_features && options.sticky_features[options.feature_key] cond do is_nil(sticky) -> :continue options.type == :flag and Map.has_key?(sticky, "enabled") -> - return_sticky(options, %{enabled: sticky["enabled"], sticky: sticky}) + return_sticky(options, %{enabled: sticky["enabled"], sticky_feature: sticky}) options.type == :variation and Map.has_key?(sticky, "variation") -> return_sticky(options, %{variation_value: sticky["variation"]}) @@ -218,19 +340,32 @@ defmodule Featurevisor.Evaluator do end defp feature(options) do - case get_in(options.datafile, ["features", options.feature_key]) do + case read_feature(options) do nil -> {:error, :feature_not_found} feature -> {:ok, feature} end end + defp read_feature(%{entity_source: read} = options), + do: read.("features", options.feature_key) + + defp read_feature(options), + do: + Featurevisor.Normalize.get( + options.datafile, + "features", + options.feature_key, + options.regex_cache, + options.report + ) + defp evaluate_feature(options, feature) do maybe_report_deprecated(options, feature) with {:ok, variable_schema} <- variable_schema(options, feature), :ok <- variations_available(options, feature), :continue <- forced(options, feature, variable_schema), - :continue <- required(options, feature) do + :continue <- root_conditions(options, feature) do bucket_and_evaluate(options, feature, variable_schema) else {:return, evaluation} -> evaluation @@ -373,81 +508,31 @@ defmodule Featurevisor.Evaluator do |> Map.get("force", []) |> Enum.with_index() |> Enum.find_value(fn {force, index} -> - matched = - cond do - Map.has_key?(force, "conditions") -> - Conditions.all_conditions?( - Conditions.parse_conditions(force["conditions"], options.report), - options.context, - options.regex_cache, - options.report - ) - - Map.has_key?(force, "segments") -> - Conditions.all_segments?( - Conditions.parse_segments(force["segments"]), - options.context, - options.datafile["segments"], - options.regex_cache, - options.report - ) - - true -> - false - end + matched = Conditions.matches?(force["conditions"], options) if matched, do: {force, index} end) end - defp required(%{type: :flag} = options, feature) do - required = feature["requiredFeatures"] || feature["required"] + defp root_conditions(%{type: :flag} = options, feature) do + if Map.has_key?(feature, "conditions") and + not Conditions.matches?(feature["conditions"], options) do + evaluation = %Evaluation{ + type: :flag, + feature_key: options.feature_key, + reason: :conditions_unmet, + conditions: Featurevisor.Normalize.public(feature["conditions"]), + enabled: false + } - if is_list(required) and required != [] do - enabled = Enum.all?(required, &required_enabled?(options, &1)) - if enabled, do: :continue, else: required_return(options, required) + report_evaluation(options.report, evaluation, "root conditions did not match") + {:return, evaluation} else :continue end end - defp required(_, _), do: :continue - - defp required_enabled?(options, required) do - {key, enabled, variation} = - if is_binary(required) do - {required, true, nil} - else - {required["feature"] || required["key"], Map.get(required, "enabled", true), - required["variation"]} - end - - flag = evaluate_with_modules(%{options | type: :flag, feature_key: key, variable_key: nil}) - - flag.enabled == true == enabled and - (is_nil(variation) or - variation_value( - evaluate_with_modules(%{ - options - | type: :variation, - feature_key: key, - variable_key: nil - }) - ) == variation) - end - - defp required_return(options, required) do - evaluation = %Evaluation{ - type: :flag, - feature_key: options.feature_key, - reason: :required, - required_features: required, - enabled: false - } - - report_evaluation(options.report, evaluation, "required features not enabled") - {:return, evaluation} - end + defp root_conditions(_, _), do: :continue defp bucket_and_evaluate(options, feature, schema) do bucket_key = @@ -459,7 +544,7 @@ defmodule Featurevisor.Evaluator do ) bucket_key = - Enum.reduce(options.modules, bucket_key, fn module, value -> + Enum.reduce(options.state_source.().modules, bucket_key, fn module, value -> if module.bucket_key, do: module.bucket_key.(%{ @@ -474,7 +559,7 @@ defmodule Featurevisor.Evaluator do bucket_value = Bucketer.bucketed_number(bucket_key) bucket_value = - Enum.reduce(options.modules, bucket_value, fn module, value -> + Enum.reduce(options.state_source.().modules, bucket_value, fn module, value -> if module.bucket_value, do: module.bucket_value.(%{ @@ -493,13 +578,7 @@ defmodule Featurevisor.Evaluator do defp matched_traffic(options, traffic) do Enum.find(traffic, fn rule -> - Conditions.all_segments?( - Conditions.parse_segments(rule["segments"]), - options.context, - options.datafile["segments"], - options.regex_cache, - options.report - ) + Conditions.matches?(rule["conditions"], options) end) end @@ -518,7 +597,7 @@ defmodule Featurevisor.Evaluator do evaluation(options, :rule, key, value, traffic, %{enabled: false}) options.type == :flag -> - flag_traffic(options, feature, key, value, traffic) + flag_traffic(options, key, value, traffic) options.type == :variation -> variation_traffic(options, feature, key, value, traffic, allocation) @@ -531,18 +610,8 @@ defmodule Featurevisor.Evaluator do result end - defp flag_traffic(options, feature, key, value, traffic) do + defp flag_traffic(options, key, value, traffic) do cond do - traffic && is_list(feature["ranges"]) && feature["ranges"] != [] -> - if Enum.any?(feature["ranges"], fn [start_value, end_value] -> - value >= start_value and value < end_value - end), - do: - evaluation(options, :allocated, key, value, traffic, %{ - enabled: Map.get(traffic, "enabled", true) - }), - else: evaluation(options, :out_of_range, key, value, nil, %{enabled: false}) - traffic && Map.has_key?(traffic, "enabled") -> evaluation(options, :rule, key, value, traffic, %{enabled: traffic["enabled"]}) @@ -657,37 +726,15 @@ defmodule Featurevisor.Evaluator do overrides |> Enum.with_index() |> Enum.find_value(fn {override, index} -> - requirements_match = - Enum.all?(override["requiredFeatures"] || [], &required_enabled?(options, &1)) - - conditions_match = - not Map.has_key?(override, "conditions") or - Conditions.all_conditions?( - Conditions.parse_conditions(override["conditions"], options.report), - options.context, - options.regex_cache, - options.report - ) - - segments_match = - not Map.has_key?(override, "segments") or - Conditions.all_segments?( - Conditions.parse_segments(override["segments"]), - options.context, - options.datafile["segments"], - options.regex_cache, - options.report - ) - - matched = - requirements_match and conditions_match and segments_match and - (Map.has_key?(override, "conditions") or Map.has_key?(override, "segments") or - Map.has_key?(override, "requiredFeatures")) - - if matched, do: {:override, override, index} + if feature_override_matches?(options, override), do: {:override, override, index} end) end + defp feature_override_matches?(options, override), + do: + Map.has_key?(override, "conditions") and + Conditions.matches?(override["conditions"], options) + defp map_value(map, key) when is_map(map), do: if(Map.has_key?(map, key), do: {:value, map[key]}, else: :missing) @@ -728,12 +775,6 @@ defmodule Featurevisor.Evaluator do defp variable_value_present?(_), do: false - defp variation_value(%Evaluation{variation_value: value}) when not is_nil(value), do: value - - defp variation_value(%Evaluation{variation: variation}) when is_map(variation), - do: variation["value"] - - defp variation_value(_), do: nil defp diagnostic_message(%Evaluation{reason: reason}), do: Atom.to_string(reason) defp report_evaluation(report, evaluation, message, level \\ :debug, code \\ nil) do diff --git a/lib/featurevisor/module.ex b/lib/featurevisor/module.ex index 0aad9fb..14cf51b 100644 --- a/lib/featurevisor/module.ex +++ b/lib/featurevisor/module.ex @@ -12,11 +12,9 @@ defmodule Featurevisor.Module do @type t :: %__MODULE__{ name: String.t() | nil, setup: (api() -> any()) | nil, - before: (map() -> map()) | nil, before_evaluation: (map() -> map()) | nil, bucket_key: (map() -> String.t()) | nil, bucket_value: (map() -> non_neg_integer()) | nil, - after: (Evaluation.t(), map() -> Evaluation.t()) | nil, after_evaluation: (Evaluation.t(), map() -> Evaluation.t()) | nil, close: (-> any()) | nil } @@ -24,11 +22,9 @@ defmodule Featurevisor.Module do defstruct [ :name, :setup, - :before, :before_evaluation, :bucket_key, :bucket_value, - :after, :after_evaluation, :close, :id diff --git a/lib/featurevisor/mutations.ex b/lib/featurevisor/mutations.ex new file mode 100644 index 0000000..724b162 --- /dev/null +++ b/lib/featurevisor/mutations.ex @@ -0,0 +1,214 @@ +defmodule Featurevisor.Mutations do + @moduledoc false + + @operations ~w(set append prepend appendUnique removeValue before after remove) + @dangerous_keys ~w(__proto__ prototype constructor) + @max_safe_integer 9_007_199_254_740_991 + + def apply(value, operations) when is_list(operations) do + Enum.reduce(operations, value, fn operation, current -> + validate_operation!(operation) + update_at_path(current, operation, 0) + end) + end + + def apply(_value, _operations), do: raise(ArgumentError, "Invalid variable mutation operations") + + def primitive_equal?(left, right) when is_number(left) and is_number(right), do: left == right + def primitive_equal?(left, right), do: left === right + + defp validate_operation!(%{"op" => op, "path" => path} = operation) + when op in @operations do + if not is_list(path), do: raise(ArgumentError, "Invalid variable mutation path") + + if op != "remove" and not Map.has_key?(operation, "value") do + raise ArgumentError, "Variable mutation operation requires a value" + end + + if op in ["appendUnique", "removeValue"] do + value = operation["value"] + + if not primitive?(value) or (is_float(value) and not finite?(value)) do + raise ArgumentError, "Variable mutation operation requires a primitive value" + end + end + + Enum.each(path, &validate_path_part!/1) + end + + defp validate_operation!(_), do: raise(ArgumentError, "Invalid variable mutation operation") + + defp validate_path_part!(part) when is_binary(part) do + if part in @dangerous_keys, do: raise(ArgumentError, "Unsafe variable mutation key") + end + + defp validate_path_part!(%{ + "select" => %{"property" => property, "values" => values} + }) + when is_binary(property) and is_list(values) do + if property in @dangerous_keys or not Enum.all?(values, &primitive?/1) do + raise ArgumentError, "Invalid variable mutation path" + end + end + + defp validate_path_part!(part) do + case array_index(part) do + {:ok, _index} -> :ok + :error -> raise(ArgumentError, "Invalid variable mutation path") + end + end + + defp update_at_path(value, %{"op" => op, "path" => path} = operation, offset) + when offset == length(path) do + case op do + "set" -> + operation["value"] + + "remove" -> + raise ArgumentError, "Cannot remove the root variable value" + + op when op in ["before", "after"] -> + raise ArgumentError, "Variable mutation before/after requires an array element path" + + _ when is_nil(value) -> + value + + _ when is_list(value) -> + update_array(value, op, operation["value"]) + + _ -> + raise ArgumentError, "Variable mutation expected an array" + end + end + + defp update_at_path(nil, _operation, _offset), do: nil + + defp update_at_path(value, %{"op" => op, "path" => path} = operation, offset) do + part = Enum.at(path, offset) + final? = offset == length(path) - 1 + + if final? and op in ["before", "after"] and is_binary(part) do + raise ArgumentError, "Variable mutation before/after requires an array element path" + end + + {kind, key} = path_key!(value, part) + found? = found?(value, kind, key) + + cond do + not final? -> + if not found? or is_nil(fetch(value, kind, key)) do + value + else + put(value, kind, key, update_at_path(fetch(value, kind, key), operation, offset + 1)) + end + + op == "remove" -> + if found?, do: remove(value, kind, key), else: value + + op == "set" -> + set(value, kind, key, operation["value"]) + + op in ["before", "after"] -> + if found? do + List.insert_at(value, key + if(op == "after", do: 1, else: 0), operation["value"]) + else + value + end + + true -> + current = if found?, do: fetch(value, kind, key), else: nil + + cond do + is_nil(current) and kind == :list -> + value + + is_nil(current) and op == "removeValue" -> + value + + not is_nil(current) and not is_list(current) -> + raise ArgumentError, "Variable mutation expected an array" + + true -> + put(value, kind, key, update_array(current || [], op, operation["value"])) + end + end + end + + defp path_key!(value, part) when is_binary(part) and is_map(value), do: {:map, part} + + defp path_key!(_value, part) when is_binary(part), + do: raise(ArgumentError, "Variable mutation expected an object") + + defp path_key!(value, %{"select" => selector}) when is_list(value), + do: {:list, selected_index(value, selector)} + + defp path_key!(value, part) when is_list(value) do + case array_index(part) do + {:ok, index} -> {:list, index} + :error -> raise(ArgumentError, "Variable mutation expected an array") + end + end + + defp path_key!(_value, _part), do: raise(ArgumentError, "Variable mutation expected an array") + + defp selected_index(value, %{"property" => property, "values" => expected}) do + Enum.find_index(value, fn + candidate when is_map(candidate) -> + case Map.fetch(candidate, property) do + {:ok, actual} + when is_binary(actual) or is_number(actual) or is_boolean(actual) or is_nil(actual) -> + Enum.any?(expected, &primitive_equal?(actual, &1)) + + _ -> + false + end + + _ -> + false + end) || -1 + end + + defp found?(value, :map, key), do: Map.has_key?(value, key) + defp found?(value, :list, key), do: key >= 0 and key < length(value) + defp fetch(value, :map, key), do: Map.fetch!(value, key) + defp fetch(value, :list, key), do: Enum.at(value, key) + defp put(value, :map, key, item), do: Map.put(value, key, item) + defp put(value, :list, key, item), do: List.replace_at(value, key, item) + defp remove(value, :map, key), do: Map.delete(value, key) + defp remove(value, :list, key), do: List.delete_at(value, key) + + defp set(value, :map, key, item), do: Map.put(value, key, item) + defp set(value, :list, key, item) when key == length(value), do: value ++ [item] + + defp set(value, :list, key, item) when key >= 0 and key < length(value), + do: List.replace_at(value, key, item) + + defp set(value, :list, _key, _item), do: value + + defp update_array(value, "removeValue", item), + do: Enum.reject(value, &primitive_equal?(&1, item)) + + defp update_array(value, "appendUnique", item) do + if Enum.any?(value, &primitive_equal?(&1, item)), do: value, else: value ++ [item] + end + + defp update_array(value, "prepend", item), do: [item | value] + defp update_array(value, _op, item), do: value ++ [item] + + defp primitive?(value), + do: is_binary(value) or is_number(value) or is_boolean(value) or is_nil(value) + + defp array_index(value) + when is_integer(value) and value >= 0 and value <= @max_safe_integer, + do: {:ok, value} + + defp array_index(value) when is_float(value) do + if finite?(value) and value >= 0 and value <= @max_safe_integer and value == trunc(value), + do: {:ok, trunc(value)}, + else: :error + end + + defp array_index(_value), do: :error + + defp finite?(value), do: abs(value) < 1.797_693_134_862_315_7e308 +end diff --git a/lib/featurevisor/normalize.ex b/lib/featurevisor/normalize.ex new file mode 100644 index 0000000..c78105a --- /dev/null +++ b/lib/featurevisor/normalize.ex @@ -0,0 +1,224 @@ +defmodule Featurevisor.Normalize do + @moduledoc false + + @invalid :invalid_conditions + @kinds ["features", "segments", "variables"] + + def datafile(datafile) do + if datafile["schemaVersion"] == "2" do + Enum.reduce(@kinds, {datafile, []}, fn kind, {result, errors} -> + {definitions, errors} = + Enum.reduce(datafile[kind] || %{}, {%{}, errors}, fn {key, value}, {values, errors} -> + {value, found} = entity(kind, key, value, true) + {Map.put(values, key, value), errors ++ found} + end) + + {Map.put(result, kind, definitions), errors} + end) + else + {datafile, []} + end + end + + def get(datafile, kind, key, nil, _report), do: datafile[kind][key] + + def get(_datafile, kind, key, cache, report) do + cache_key = {:normalized, kind, key} + + case :ets.lookup(cache, cache_key) do + [{_, value}] -> + value + + [] -> + case Featurevisor.DatafileStore.entity(cache, kind, key) do + nil -> + nil + + {token, raw} -> + [{_, shared}] = :ets.lookup(cache, :normalization_cache) + + case :ets.lookup(shared, token) do + [{_, value}] -> + :ets.insert_new(cache, {cache_key, value}) + value + + [] -> + {value, errors} = entity(kind, key, raw) + first = :ets.insert_new(shared, {token, value}) + :ets.insert_new(cache, {cache_key, value}) + if first, do: Enum.each(errors, report) + value + end + end + end + end + + def entity(kind, key, value, legacy \\ false) do + parse = fn conditions -> parse(conditions, kind, key) end + + case kind do + "segments" -> + {conditions, errors} = parse.(value["conditions"]) + + {Map.put(value, "conditions", if(legacy, do: legacy(conditions), else: conditions)), + errors} + + "features" -> + {value, errors} = select(value, false, parse) + + {traffic, errors} = + entries(value["traffic"] || [], errors, fn entry -> + {entry, errors} = select(entry, false, parse) + {entry, found} = overrides(entry, legacy, parse) + {entry, errors ++ found} + end) + + {force, errors} = entries(value["force"] || [], errors, &select(&1, legacy, parse)) + + {variations, errors} = + entries(value["variations"] || [], errors, &overrides(&1, legacy, parse)) + + value = Map.put(value, "traffic", traffic) + value = if Map.has_key?(value, "force"), do: Map.put(value, "force", force), else: value + + value = + if Map.has_key?(value, "variations"), + do: Map.put(value, "variations", variations), + else: value + + {value, errors} + + "variables" -> + {value, errors} = select(value, false, parse) + + Enum.reduce( + [{"overrides", legacy}, {"overrideProgram", false}], + {value, errors}, + fn {name, old}, {value, errors} -> + if is_list(value[name]) do + {items, errors} = entries(value[name], errors, &select(&1, old, parse)) + {Map.put(value, name, items), errors} + else + {value, errors} + end + end + ) + end + end + + defp overrides(entry, old, parse) do + Enum.reduce(entry["variableOverrides"] || %{}, {entry, []}, fn {key, values}, + {entry, errors} -> + {values, errors} = entries(values, errors, &select(&1, old, parse)) + {put_in(entry, ["variableOverrides", key], values), errors} + end) + end + + defp entries(values, errors, fun) do + Enum.map_reduce(values, errors, fn entry, errors -> + {entry, found} = fun.(entry) + {entry, errors ++ found} + end) + end + + defp select(value, old, parse) do + {parts, errors} = + Enum.reduce(["conditions", "segments"], {[], []}, fn name, {parts, errors} -> + if Map.has_key?(value, name) do + {parsed, found} = parse.(value[name]) + parsed = if old or name == "segments", do: legacy(parsed), else: parsed + {parts ++ [parsed], errors ++ found} + else + {parts, errors} + end + end) + + features = value["requiredFeatures"] + + features = + if is_list(features), do: features, else: if(is_nil(features), do: [], else: [features]) + + parts = if features == [], do: parts, else: parts ++ [Enum.map(features, &legacy_feature/1)] + value = Map.drop(value, ["segments", "requiredFeatures", "requiredVariables", "required"]) + + conditions = + case parts do + [] -> + nil + + [part] -> + part + + parts -> + if @invalid in parts, + do: @invalid, + else: + Enum.flat_map(parts, fn + [] -> [%{"and" => []}] + part when is_list(part) -> part + part -> [part] + end) + end + + {if(parts == [], do: value, else: Map.put(value, "conditions", conditions)), errors} + end + + defp legacy_feature(value) when is_binary(value), do: %{"feature" => value} + defp legacy_feature(%{"feature" => _} = value), do: value + defp legacy_feature(%{"and" => []}), do: "*" + + defp legacy_feature(value) when is_map(value) do + Map.new(value, fn {key, children} -> {key, Enum.map(children, &legacy_feature/1)} end) + end + + defp legacy([]), do: "*" + defp legacy(%{"and" => []}), do: "*" + defp legacy(values) when is_list(values), do: Enum.map(values, &legacy/1) + + defp legacy(value) when is_map(value) do + Enum.reduce(["and", "or", "not"], value, fn key, value -> + if is_list(value[key]), + do: Map.update!(value, key, &Enum.map(&1, fn child -> legacy(child) end)), + else: value + end) + end + + defp legacy(value), do: value + + def public(:invalid_conditions), do: %{"or" => []} + def public(value) when is_list(value), do: Enum.map(value, &public/1) + + def public(value) when is_map(value), + do: Map.new(value, fn {key, value} -> {key, public(value)} end) + + def public(value), do: value + + def stringified?(value), do: is_binary(value) and Regex.match?(~r/^\s*[\{\[\"]/, value) + + defp parse(value, kind, key) do + if stringified?(value) do + case Jason.decode(value) do + {:ok, parsed} -> + {parsed, []} + + {:error, error} -> + {@invalid, + [ + %{ + level: :error, + code: "conditions_parse_error", + message: "Error parsing conditions", + originalError: error, + details: %{ + conditions: value, + entityType: String.trim_trailing(kind, "s"), + entityKey: key + } + } + ]} + end + else + {value, []} + end + end +end diff --git a/lib/featurevisor/semver.ex b/lib/featurevisor/semver.ex index 8ae01e8..58a05e7 100644 --- a/lib/featurevisor/semver.ex +++ b/lib/featurevisor/semver.ex @@ -27,9 +27,12 @@ defmodule Featurevisor.Semver do nil -> {:error, "Invalid argument not valid semver ('#{version}' received)"} - [_full | captures] -> + [^version | captures] -> captures = captures ++ List.duplicate("", 5 - length(captures)) {:ok, Enum.map(captures, fn value -> if value == "", do: nil, else: value end)} + + _ -> + {:error, "Invalid argument not valid semver ('#{version}' received)"} end end @@ -38,38 +41,49 @@ defmodule Featurevisor.Semver do defp compare_pre(_, nil), do: -1 defp compare_pre(left, right), - do: compare_segments(String.split(left, "."), String.split(right, ".")) + do: compare_segments(String.split(left, "."), String.split(right, "."), true) - defp compare_segments(left, right) do + defp compare_segments(left, right, prerelease \\ false) do 0..(max(length(left), length(right)) - 1) |> Enum.reduce_while(0, fn index, _ -> - result = compare_segment(Enum.at(left, index) || "0", Enum.at(right, index) || "0") + result = + if prerelease and (index == length(left) or index == length(right)) do + compare_value(length(left), length(right)) + else + compare_segment(Enum.at(left, index) || "0", Enum.at(right, index) || "0", prerelease) + end + if result == 0, do: {:cont, 0}, else: {:halt, result} end) end - defp compare_segment(left, right) do - if wildcard?(left) or wildcard?(right) do + defp compare_segment(left, right, prerelease) do + if not prerelease and (wildcard?(left) or wildcard?(right)) do 0 else - {left, right} = force_same_type(try_parse_integer(left), try_parse_integer(right)) - compare_value(left, right) - end - end + an = Regex.match?(~r/\A[0-9]+\z/, left) + bn = Regex.match?(~r/\A[0-9]+\z/, right) - defp wildcard?(value), do: value in ["*", "x", "X"] + cond do + an and bn -> + left = String.trim_leading(left, "0") + right = String.trim_leading(right, "0") - defp try_parse_integer(value) do - case Integer.parse(value) do - {integer, _rest} -> integer - :error -> value + case compare_value(byte_size(left), byte_size(right)) do + 0 -> compare_value(left, right) + result -> result + end + + prerelease and an != bn -> + if an, do: -1, else: 1 + + true -> + compare_value(left, right) + end end end - defp force_same_type(left, right) when is_integer(left) != is_integer(right), - do: {to_string(left), to_string(right)} - - defp force_same_type(left, right), do: {left, right} + defp wildcard?(value), do: value in ["*", "x", "X"] defp compare_value(left, right) when left > right, do: 1 defp compare_value(left, right) when left < right, do: -1 diff --git a/lib/featurevisor/server.ex b/lib/featurevisor/server.ex index 840480c..c147545 100644 --- a/lib/featurevisor/server.ex +++ b/lib/featurevisor/server.ex @@ -2,6 +2,7 @@ defmodule Featurevisor.Server do @moduledoc false use GenServer + alias Featurevisor.{DatafileStore, EntityCollection, StickyStore} alias Featurevisor.Module, as: FeaturevisorModule @empty_datafile %{ @@ -17,6 +18,7 @@ defmodule Featurevisor.Server do context: %{}, sticky_features: nil, sticky_variables: nil, + generation: 0, log_level: :fatal, on_diagnostic: nil, modules: [], @@ -26,6 +28,7 @@ defmodule Featurevisor.Server do regex_cache: nil, closed: true } + @request_cache_key {__MODULE__, :request_cache} def start(options) do GenServer.start(__MODULE__, options) @@ -43,19 +46,27 @@ defmodule Featurevisor.Server do Process.flag(:trap_exit, true) table = :ets.new(__MODULE__, [:set, :public, read_concurrency: true, write_concurrency: true]) - regex_cache = - :ets.new(Featurevisor.RegexCache, [ - :set, - :public, - read_concurrency: true, - write_concurrency: true - ]) + {:ok, store} = DatafileStore.start(self()) + Process.monitor(store) + {:ok, sticky_store} = StickyStore.start(self()) + Process.monitor(sticky_store) + + sticky_table = + StickyStore.create( + sticky_store, + Map.get(options, :sticky_features), + Map.get(options, :sticky_variables) + ) + + regex_cache = DatafileStore.create(store, @empty_datafile) snapshot = %{ datafile: @empty_datafile, context: Map.get(options, :context, %{}), sticky_features: Map.get(options, :sticky_features), sticky_variables: Map.get(options, :sticky_variables), + sticky_table: sticky_table, + generation: 0, log_level: Map.get(options, :log_level, :info), on_diagnostic: Map.get(options, :on_diagnostic), modules: [], @@ -66,44 +77,147 @@ defmodule Featurevisor.Server do closed: false } - :ets.insert(table, {:snapshot, snapshot}) - {:ok, %{table: table}} + publish(table, snapshot) + + {:ok, + %{ + table: table, + snapshot: snapshot, + store: store, + sticky_store: sticky_store, + dependency_index: nil + }} end @impl true def handle_call(:table, _from, state), do: {:reply, state.table, state} + def handle_call({:set_datafile, incoming, replace}, from, state) do + if state.snapshot.closed do + {:reply, :closed, state} + else + previous = state.snapshot.datafile + current = if replace, do: incoming, else: merge_datafile(previous, incoming) + + {details, index} = + datafile_details(previous, current, replace, state.dependency_index, incoming) + + handle_call( + {:update, + fn snapshot -> + {{:ok, details}, + snapshot + |> Map.put(:datafile, current) + |> Map.put(:normalization_update, {replace, incoming})} + end}, + from, + %{state | dependency_index: index} + ) + end + end + def handle_call({:update, fun}, _from, state) do - [{:snapshot, snapshot}] = :ets.lookup(state.table, :snapshot) - {reply, snapshot} = fun.(snapshot) - :ets.insert(state.table, {:snapshot, snapshot}) - {:reply, reply, state} + {reply, snapshot} = fun.(state.snapshot) + + datafile_changed = + Map.has_key?(snapshot, :normalization_update) or + snapshot.datafile !== state.snapshot.datafile + + snapshot = %{snapshot | generation: state.snapshot.generation + 1} + + snapshot = + if snapshot.sticky_features !== state.snapshot.sticky_features or + snapshot.sticky_variables !== state.snapshot.sticky_variables do + %{ + snapshot + | sticky_table: + StickyStore.create( + state.sticky_store, + snapshot.sticky_features, + snapshot.sticky_variables + ) + } + else + snapshot + end + + snapshot = + if datafile_changed do + cache = + case snapshot[:normalization_update] do + {false, incoming} -> + DatafileStore.create( + state.store, + nil, + state.snapshot.regex_cache, + incoming + ) + + _ -> + DatafileStore.create(state.store, snapshot.datafile) + end + + %{snapshot | regex_cache: cache} + else + snapshot + end + + snapshot = Map.delete(snapshot, :normalization_update) + publish(state.table, snapshot) + + if snapshot.regex_cache != state.snapshot.regex_cache, + do: DatafileStore.retire(state.store, state.snapshot.regex_cache) + + if snapshot.sticky_table != state.snapshot.sticky_table, + do: StickyStore.retire(state.sticky_store, state.snapshot.sticky_table) + + # Compact the large temporary update heap, without adding collection work + # to evaluations or ordinary context, sticky and module state changes. + {:reply, reply, %{state | snapshot: snapshot}, + if(datafile_changed, do: :hibernate, else: :infinity)} end def handle_call(:close, _from, state) do - [{:snapshot, snapshot}] = :ets.lookup(state.table, :snapshot) + snapshot = state.snapshot modules = snapshot.modules - :ets.insert( - state.table, - {:snapshot, %{snapshot | closed: true, modules: [], subscriptions: [], listeners: %{}}} - ) + snapshot = %{ + snapshot + | closed: true, + modules: [], + subscriptions: [], + listeners: %{}, + generation: snapshot.generation + 1 + } - {:reply, modules, state} + publish(state.table, snapshot) + {:reply, modules, %{state | snapshot: snapshot}} end + @impl true + def handle_info({:DOWN, _ref, :process, store, reason}, %{store: store} = state), + do: {:stop, {:datafile_store_exit, reason}, state} + + def handle_info({:DOWN, _ref, :process, store, reason}, %{sticky_store: store} = state), + do: {:stop, {:sticky_store_exit, reason}, state} + @impl true def terminate(_reason, state) do - case :ets.lookup(state.table, :snapshot) do - [{:snapshot, snapshot}] -> - :ets.insert( - state.table, - {:snapshot, %{snapshot | closed: true, modules: [], subscriptions: [], listeners: %{}}} - ) + case state.snapshot do + snapshot when is_map(snapshot) -> + publish(state.table, %{ + snapshot + | closed: true, + modules: [], + subscriptions: [], + listeners: %{} + }) + + modules = snapshot.modules task = Task.async(fn -> - Enum.each(snapshot.modules, fn module -> + Enum.each(modules, fn module -> if module.close do try do module.close.() @@ -118,7 +232,7 @@ defmodule Featurevisor.Server do Task.await(task, :infinity) - [] -> + _ -> :ok end @@ -126,10 +240,97 @@ defmodule Featurevisor.Server do end def update(pid, fun), do: GenServer.call(pid, {:update, fun}) + + def set_datafile(pid, incoming, replace), + do: GenServer.call(pid, {:set_datafile, incoming, replace}) + def table(pid), do: GenServer.call(pid, :table) def close(pid), do: GenServer.call(pid, :close) def snapshot(table) do + case Process.get(@request_cache_key) do + nil -> read_snapshot(table) + cache -> cached_snapshot(table, cache) + end + end + + def with_request_cache(fun) do + previous = Process.get(@request_cache_key) + Process.put(@request_cache_key, %{pinned: []}) + + try do + fun.() + after + Process.get(@request_cache_key, %{}) + |> Map.get(:pinned, []) + |> Enum.each(&StickyStore.unpin/1) + + if is_nil(previous), + do: Process.delete(@request_cache_key), + else: Process.put(@request_cache_key, previous) + end + end + + def seed_request_cache(table, snapshot) do + if cache = Process.get(@request_cache_key) do + cache = + if not snapshot.closed and snapshot.sticky_table not in cache.pinned and + StickyStore.pin(snapshot.sticky_table) do + Map.put(cache, :pinned, [snapshot.sticky_table | cache.pinned]) + else + cache + end + + Process.put(@request_cache_key, Map.put(cache, table, {snapshot.generation, snapshot})) + end + + :ok + end + + defp cached_snapshot(table, cache) do + generation = :ets.lookup_element(table, :generation, 2) + + case Map.get(cache, table) do + {^generation, snapshot} -> + snapshot + + _ -> + cache_and_read_snapshot(table, cache) + end + rescue + ArgumentError -> @closed_snapshot + end + + defp cache_and_read_snapshot(table, cache) do + snapshot = read_snapshot(table) + + cond do + snapshot.closed or snapshot.sticky_table in cache.pinned -> + Process.put(@request_cache_key, Map.put(cache, table, {snapshot.generation, snapshot})) + snapshot + + StickyStore.pin(snapshot.sticky_table) -> + current = read_snapshot(table) + + if current.generation == snapshot.generation and not current.closed do + cache = Map.put(cache, :pinned, [snapshot.sticky_table | cache.pinned]) + Process.put(@request_cache_key, Map.put(cache, table, {snapshot.generation, snapshot})) + snapshot + else + StickyStore.unpin(snapshot.sticky_table) + cache_and_read_snapshot(table, cache) + end + + true -> + current = read_snapshot(table) + + if current.generation == snapshot.generation, + do: @closed_snapshot, + else: cache_and_read_snapshot(table, cache) + end + end + + defp read_snapshot(table) do case :ets.lookup(table, :snapshot) do [{:snapshot, snapshot}] -> snapshot [] -> @closed_snapshot @@ -138,6 +339,90 @@ defmodule Featurevisor.Server do ArgumentError -> @closed_snapshot end + # Validate the generation after pinning it. Retirement can race with an + # unvalidated reader, but never with one already using the generation. + def with_snapshot(table, fun) do + snap = snapshot(table) + + cond do + snap.closed -> + fun.(@closed_snapshot) + + pin(snap.regex_cache) and StickyStore.pin(snap.sticky_table) -> + use_snapshot(table, snap, fun) + + true -> + unpin(snap.regex_cache) + + current = snapshot(table) + + if current.regex_cache == snap.regex_cache and current.sticky_table == snap.sticky_table, + do: fun.(@closed_snapshot), + else: with_snapshot(table, fun) + end + end + + defp use_snapshot(table, snap, fun) do + current = snapshot(table) + + if current.regex_cache == snap.regex_cache and + current.sticky_table == snap.sticky_table and not current.closed do + try do + fun.(snap) + after + unpin(snap.regex_cache) + StickyStore.unpin(snap.sticky_table) + end + else + unpin(snap.regex_cache) + StickyStore.unpin(snap.sticky_table) + with_snapshot(table, fun) + end + end + + defp pin(table) do + :ets.safe_fixtable(table, true) + rescue + ArgumentError -> false + end + + defp unpin(table) do + :ets.safe_fixtable(table, false) + rescue + ArgumentError -> :ok + end + + def materialize_datafile(datafile) do + Enum.reduce(["features", "variables", "segments"], datafile, fn kind, result -> + Map.put(result, kind, EntityCollection.materialize(datafile[kind] || %{})) + end) + end + + defp publish(table, snapshot) do + datafile = + Enum.reduce(["features", "variables", "segments"], snapshot.datafile, fn kind, result -> + Map.put(result, kind, %EntityCollection{table: snapshot.regex_cache, kind: kind}) + end) + + published = %{ + snapshot + | datafile: datafile, + sticky_features: + if(is_nil(snapshot.sticky_features), + do: nil, + else: StickyStore.view(snapshot.sticky_table, :features) + ), + sticky_variables: + if(is_nil(snapshot.sticky_variables), + do: nil, + else: StickyStore.view(snapshot.sticky_table, :variables) + ) + } + + :ets.insert(table, {:snapshot, published}) + :ets.insert(table, {:generation, snapshot.generation}) + end + def parse_datafile(datafile) when is_binary(datafile), do: Jason.decode(datafile) def parse_datafile(datafile) when is_map(datafile), do: {:ok, datafile} def parse_datafile(_), do: {:error, ArgumentError.exception("Invalid datafile")} @@ -160,123 +445,92 @@ defmodule Featurevisor.Server do } end - def datafile_details(previous, current, replace) do - previous_keys = Map.keys(previous["features"]) - current_keys = Map.keys(current["features"]) - - changed = - Enum.filter(previous_keys, fn key -> - not Map.has_key?(current["features"], key) or - is_nil(get_in(previous, ["features", key, "hash"])) or - is_nil(get_in(current, ["features", key, "hash"])) or - get_in(previous, ["features", key, "hash"]) != - get_in(current, ["features", key, "hash"]) - end) - - added = Enum.reject(current_keys, &Map.has_key?(previous["features"], &1)) + def datafile_details(previous, current, replace, index \\ nil, incoming \\ nil) do + if Enum.all?(["features", "variables", "segments"], &(map_size(previous[&1] || %{}) == 0)) do + # Every incoming entity is already announced. Defer graph traversal until + # an update can affect definitions retained from an earlier datafile. + {%{ + revision: current["revision"], + previousRevision: previous["revision"], + revisionChanged: previous["revision"] != current["revision"], + features: current["features"] |> Map.keys() |> Enum.sort(), + variables: (current["variables"] || %{}) |> Map.keys() |> Enum.sort(), + replaced: replace + }, index} + else + dependent_datafile_details(previous, current, replace, index, incoming) + end + end - previous_variable_keys = Map.keys(previous["variables"] || %{}) - current_variable_keys = Map.keys(current["variables"] || %{}) + defp dependent_datafile_details(previous, current, replace, index, incoming) do + touched = if replace or is_nil(incoming), do: nil, else: incoming + changed = changed_entities(previous, current, touched, "features") + variables = changed_entities(previous, current, touched, "variables") - changed_variables = - Enum.filter(previous_variable_keys, fn key -> - not Map.has_key?(current["variables"] || %{}, key) or - is_nil(get_in(previous, ["variables", key, "hash"])) or - is_nil(get_in(current, ["variables", key, "hash"])) or - get_in(previous, ["variables", key, "hash"]) != - get_in(current, ["variables", key, "hash"]) - end) - - added_variables = - Enum.reject(current_variable_keys, &Map.has_key?(previous["variables"] || %{}, &1)) - - changed_segments = - Map.keys(previous["segments"] || %{}) - |> Kernel.++(Map.keys(current["segments"] || %{})) - |> Enum.uniq() - |> Enum.filter(&(get_in(previous, ["segments", &1]) != get_in(current, ["segments", &1]))) - |> MapSet.new() - - {affected_features, affected_variables} = - expand_dependencies( - [previous, current], - MapSet.new(changed ++ added), - MapSet.new(changed_variables ++ added_variables), - changed_segments + segments = + Enum.filter( + touched_keys(previous, current, touched, "segments"), + &(get_in(previous, ["segments", &1]) != get_in(current, ["segments", &1])) ) - %{ - revision: current["revision"], - previousRevision: previous["revision"], - revisionChanged: previous["revision"] != current["revision"], - features: affected_features |> MapSet.to_list() |> Enum.sort(), - variables: affected_variables |> MapSet.to_list() |> Enum.sort(), - replaced: replace - } + {features, variables, index} = + if index || changed != [] || variables != [] || segments != [] do + index = index || Featurevisor.DependencyIndex.new(previous) + {features, variables} = expand_dependencies(index.reverse, changed, variables, segments) + index = Featurevisor.DependencyIndex.update(index, previous, current, touched) + {features, variables} = expand_dependencies(index.reverse, features, variables, segments) + {features, variables, index} + else + {changed, variables, index} + end + + {%{ + revision: current["revision"], + previousRevision: previous["revision"], + revisionChanged: previous["revision"] != current["revision"], + features: Enum.sort(features), + variables: Enum.sort(variables), + replaced: replace + }, index} end - defp expand_dependencies(datafiles, changed_features, changed_variables, changed_segments) do - next_features = - Enum.reduce(datafiles, changed_features, fn datafile, graph_affected -> - Enum.reduce(datafile["features"] || %{}, graph_affected, fn {key, feature}, affected -> - required = references(feature, ["requiredFeatures", "required"]) - segments = references(feature, ["segments"]) - - if Enum.any?(required, &MapSet.member?(affected, &1)) or - Enum.any?(segments, &MapSet.member?(changed_segments, &1)), - do: MapSet.put(affected, key), - else: affected - end) - end) - - next_variables = - Enum.reduce(datafiles, changed_variables, fn datafile, graph_affected -> - Enum.reduce(datafile["variables"] || %{}, graph_affected, fn {key, variable}, affected -> - required = references(variable, ["requiredFeatures"]) - segments = references(variable, ["segments"]) - - if Enum.any?(required, &MapSet.member?(next_features, &1)) or - Enum.any?(segments, &MapSet.member?(changed_segments, &1)), - do: MapSet.put(affected, key), - else: affected - end) - end) + defp touched_keys(previous, current, nil, kind), + do: Enum.uniq(Map.keys(previous[kind] || %{}) ++ Map.keys(current[kind] || %{})) - if next_features == changed_features and next_variables == changed_variables, - do: {next_features, next_variables}, - else: expand_dependencies(datafiles, next_features, next_variables, changed_segments) - end + defp touched_keys(_previous, _current, incoming, kind), do: Map.keys(incoming[kind] || %{}) - defp references(value, fields), do: collect_references(value, MapSet.new(fields), MapSet.new()) + defp changed_entities(previous, current, touched, kind) do + keys = + Enum.uniq(Map.keys(previous[kind] || %{}) ++ Map.keys((touched || current)[kind] || %{})) - defp collect_references(value, fields, result) when is_map(value) do - Enum.reduce(value, result, fn {key, child}, current -> - if MapSet.member?(fields, key), - do: collect_expression(child, current), - else: collect_references(child, fields, current) + Enum.filter(keys, fn key -> + old = get_in(previous, [kind, key, "hash"]) + new = get_in(current, [kind, key, "hash"]) + old in [nil, ""] or new in [nil, ""] or old != new end) end - defp collect_references(value, fields, result) when is_list(value), - do: Enum.reduce(value, result, &collect_references(&1, fields, &2)) + defp expand_dependencies(graph, features, variables, segments) do + roots = + Enum.map(features, &{"features", &1}) ++ + Enum.map(variables, &{"variables", &1}) ++ Enum.map(segments, &{"segments", &1}) - defp collect_references(_value, _fields, result), do: result + # Keep leaf notifications in the result without scheduling a traversal for + # each retained hashless root or each terminal dependent. + pending = Enum.filter(roots, &Map.has_key?(graph, &1)) + affected = dependency_closure(pending, MapSet.new(roots), graph) - defp collect_expression(value, result) when is_binary(value), - do: if(value == "*", do: result, else: MapSet.put(result, value)) + {for({"features", key} <- affected, do: key), for({"variables", key} <- affected, do: key)} + end - defp collect_expression(value, result) when is_list(value), - do: Enum.reduce(value, result, &collect_expression/2) + defp dependency_closure([], affected, _graph), do: affected - defp collect_expression(value, result) when is_map(value) do - case value["feature"] || value["key"] do - key when is_binary(key) -> MapSet.put(result, key) - _ -> Enum.reduce(Map.values(value), result, &collect_expression/2) - end + defp dependency_closure([key | rest], affected, graph) do + next = Map.get(graph, key, MapSet.new()) |> MapSet.difference(affected) + pending = Enum.filter(next, &Map.has_key?(graph, &1)) + dependency_closure(pending ++ rest, MapSet.union(affected, next), graph) end - defp collect_expression(_value, result), do: result - def module_id(%FeaturevisorModule{id: nil} = module), do: %{module | id: make_ref()} def module_id(%FeaturevisorModule{} = module), do: module end diff --git a/lib/featurevisor/sticky_store.ex b/lib/featurevisor/sticky_store.ex new file mode 100644 index 0000000..12ff511 --- /dev/null +++ b/lib/featurevisor/sticky_store.ex @@ -0,0 +1,125 @@ +defmodule Featurevisor.StickyStore do + @moduledoc false + use GenServer + @behaviour Access + + defmodule Generation do + @moduledoc false + defstruct [:table, :retired] + end + + defstruct [:table, :kind] + + def start(owner), do: GenServer.start(__MODULE__, owner) + + def create(pid, features, variables), + do: GenServer.call(pid, {:create, features, variables}, :infinity) + + def retire(pid, generation), do: GenServer.cast(pid, {:retire, generation}) + + def view(%Generation{table: table}, kind), do: %__MODULE__{table: table, kind: kind} + def materialize(nil), do: nil + + def materialize(%__MODULE__{table: table, kind: kind}) do + table + |> :ets.select([{{{kind, :"$1"}, :"$2"}, [], [{{:"$1", :"$2"}}]}]) + |> Map.new() + end + + def materialize(map), do: map + + @impl Access + def fetch(%__MODULE__{table: table, kind: kind}, key) do + case :ets.lookup(table, {kind, key}) do + [{_, value}] -> {:ok, value} + [] -> :error + end + end + + @impl Access + def get_and_update(_store, _key, _fun), do: raise(ArgumentError, "Read only sticky state") + @impl Access + def pop(_store, _key), do: raise(ArgumentError, "Read only sticky state") + + def has_key?(%__MODULE__{} = view, key), do: match?({:ok, _}, fetch(view, key)) + def has_key?(map, key), do: Map.has_key?(map || %{}, key) + + # The owner marks a generation retired before checking fixation. A reader + # fixes first and checks retirement before accessing rows. A late fixation + # therefore cannot become an admitted reader, while the VM releases an + # admitted reader's fixation automatically if its process dies. + def pin(%Generation{table: table, retired: retired} = generation) do + if :ets.safe_fixtable(table, true) do + if :atomics.get(retired, 1) == 0 do + true + else + unpin(generation) + false + end + else + false + end + rescue + ArgumentError -> false + end + + def unpin(%Generation{table: table}) do + :ets.safe_fixtable(table, false) + :ok + rescue + ArgumentError -> :ok + end + + defp mark_retired(%Generation{retired: retired}), do: :atomics.put(retired, 1, 1) + + @impl true + def init(owner) do + Process.flag(:trap_exit, true) + Process.monitor(owner) + {:ok, %{owner: owner, tables: [], retired: [], timer: nil}} + end + + @impl true + def handle_call({:create, features, variables}, _from, state) do + table = :ets.new(__MODULE__, [:set, :public, read_concurrency: true]) + + Enum.each([{:features, features}, {:variables, variables}], fn {kind, values} -> + Enum.each(values || %{}, fn {key, value} -> :ets.insert(table, {{kind, key}, value}) end) + end) + + generation = %Generation{table: table, retired: :atomics.new(1, [])} + {:reply, generation, %{state | tables: [generation | state.tables]}, :hibernate} + end + + @impl true + def handle_cast({:retire, generation}, state) do + mark_retired(generation) + {:noreply, schedule(%{state | retired: Enum.uniq([generation | state.retired])}), :hibernate} + end + + @impl true + def handle_info({:DOWN, _ref, :process, owner, _reason}, %{owner: owner} = state) do + Enum.each(state.tables, &mark_retired/1) + {:noreply, schedule(%{state | owner: nil, retired: state.tables}), :hibernate} + end + + def handle_info({:EXIT, owner, _reason}, %{owner: owner} = state), + do: {:noreply, state} + + def handle_info(:collect, state) do + {unused, retained} = + Enum.split_with(state.retired, &(:ets.info(&1.table, :safe_fixed) == false)) + + Enum.each(unused, &:ets.delete(&1.table)) + state = %{state | tables: state.tables -- unused, retired: retained, timer: nil} + + if is_nil(state.owner) and state.tables == [], + do: {:stop, :normal, state}, + else: {:noreply, schedule(state), :hibernate} + end + + defp schedule(%{retired: [_ | _], timer: nil} = state), + do: %{state | timer: Process.send_after(self(), :collect, 100)} + + defp schedule(state), do: state +end diff --git a/mix.exs b/mix.exs index 84d4431..f9b22bb 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Featurevisor.MixProject do use Mix.Project - @version "1.2.0" + @version "2.0.0" @source_url "https://github.com/featurevisor/featurevisor-elixir" def project do @@ -45,6 +45,7 @@ defmodule Featurevisor.MixProject do [ licenses: ["MIT"], links: %{"GitHub" => @source_url, "Featurevisor" => "https://featurevisor.com"}, + # Tests and conformance fixtures stay in the repository, not in the package. files: ["lib", "mix.exs", "README.md", "LICENSE"] ] end diff --git a/openfeature/README.md b/openfeature/README.md index b5e74dd..5f95775 100644 --- a/openfeature/README.md +++ b/openfeature/README.md @@ -16,6 +16,8 @@ end The provider is a separate package, so applications that only use `featurevisor` do not install or compile OpenFeature. +The supported upstream SDK is `open_feature` 0.1.3, the latest stable Hex +release. It requires no change to the provider's Elixir 1.15 minimum. ## Usage @@ -62,6 +64,11 @@ Featurevisor reasons and evaluation metadata are mapped to OpenFeature resolution details. Missing definitions, type mismatches, and invalid datafiles use standard OpenFeature errors. Replacing an invalid datafile with a valid one recovers the provider. +If initial registration fails, update the provider's Featurevisor instance with +a valid datafile and call `OpenFeature.set_provider` again. OpenFeature 0.1.3 +does not register a provider whose initialisation fails. Once registered, valid +updates recover evaluation without registering again, including an unchanged +accepted datafile after a rejection. Calling `shutdown/1` releases provider subscriptions. It also closes a Featurevisor instance created by the provider, but never closes a borrowed diff --git a/openfeature/conformance/openfeature-provider.json b/openfeature/conformance/openfeature-provider.json new file mode 100644 index 0000000..ef8c834 --- /dev/null +++ b/openfeature/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/openfeature/lib/featurevisor_openfeature/provider.ex b/openfeature/lib/featurevisor_openfeature/provider.ex index 4fb4a87..1a8148d 100644 --- a/openfeature/lib/featurevisor_openfeature/provider.ex +++ b/openfeature/lib/featurevisor_openfeature/provider.ex @@ -83,7 +83,11 @@ defmodule FeaturevisorOpenFeature.Provider do @doc "Initializes the provider for an OpenFeature domain." @impl true def initialize(provider, domain, _context) do - {:ok, %{provider | domain: domain, state: :ready}} + if datafile_error(provider.lifecycle) do + {:error, :parse_error} + else + {:ok, %{provider | domain: domain, state: :ready}} + end end @doc "Releases subscriptions and closes an owned Featurevisor instance." @@ -137,16 +141,18 @@ defmodule FeaturevisorOpenFeature.Provider do defp initial_datafile_error(datafile) when is_binary(datafile) do case Jason.decode(datafile) do - {:ok, %{"revision" => revision}} when is_binary(revision) and revision != "" -> nil + {:ok, decoded} -> validate_initial_datafile(decoded) _error -> "Could not parse datafile" end end - defp initial_datafile_error(%{"revision" => revision}) - when is_binary(revision) and revision != "", - do: nil + defp initial_datafile_error(datafile) do + validate_initial_datafile(datafile) + end - defp initial_datafile_error(_datafile), do: "Could not parse datafile" + defp validate_initial_datafile(datafile) do + if Featurevisor.Server.valid_datafile?(datafile), do: nil, else: "Could not parse datafile" + end defp resolve_as(provider, key, default, context, expected) do case datafile_error(provider.lifecycle) do @@ -200,7 +206,10 @@ defmodule FeaturevisorOpenFeature.Provider do {evaluation, normalize_variable( evaluation.variable_value, - get_in(evaluation.variable || %{}, ["type"]) + if(evaluation.variable["compose"], + do: nil, + else: get_in(evaluation.variable || %{}, ["type"]) + ) )} is_nil(selector) -> @@ -226,6 +235,7 @@ defmodule FeaturevisorOpenFeature.Provider do defp split_key(key, separator) do case String.split(key, separator, parts: 2) do [feature] -> {feature, nil} + [feature, ""] -> {feature, nil} [feature, selector] -> {feature, selector} end end @@ -293,12 +303,12 @@ defmodule FeaturevisorOpenFeature.Provider do defp reason_for(reason) when reason in [ - :required, :forced, :sticky, :rule, :variable_override_variation, - :variable_override_rule + :variable_override_rule, + :variable_composed ], do: :targeting_match @@ -309,7 +319,7 @@ defmodule FeaturevisorOpenFeature.Provider do :disabled, :variation_disabled, :variable_disabled, - :required_features_unmet + :conditions_unmet ], do: :disabled diff --git a/openfeature/mix.exs b/openfeature/mix.exs index 85f700b..ba00431 100644 --- a/openfeature/mix.exs +++ b/openfeature/mix.exs @@ -1,7 +1,7 @@ defmodule FeaturevisorOpenFeature.MixProject do use Mix.Project - @version "1.2.0" + @version "2.0.0" @source_url "https://github.com/featurevisor/featurevisor-elixir" def project do diff --git a/openfeature/test/provider_test.exs b/openfeature/test/provider_test.exs index b18dce7..e25693b 100644 --- a/openfeature/test/provider_test.exs +++ b/openfeature/test/provider_test.exs @@ -1,9 +1,104 @@ defmodule FeaturevisorOpenFeature.ProviderTest do use ExUnit.Case, async: false + @provider_fixture Path.expand("../conformance/openfeature-provider.json", __DIR__) + |> File.read!() + |> Jason.decode!() + alias FeaturevisorOpenFeature.Provider alias OpenFeature.ResolutionDetails + test "invalid initial shapes fail initialization without logging and recover" do + valid = @provider_fixture["datafile"] + + for invalid <- [ + "{", + "null", + "{}", + %{"schemaVersion" => "2", "revision" => "x", "features" => [], "segments" => %{}}, + Map.put(valid, "variables", []) + ] do + provider = Provider.new(featurevisor_options: %{datafile: invalid, log_level: :fatal}) + assert {:error, :parse_error} = Provider.initialize(provider, "initial", %{}) + + assert {:ok, %ResolutionDetails{error_code: :parse_error}} = + Provider.resolve_boolean_value(provider, "checkout", false, %{}) + + Featurevisor.set_datafile(provider.featurevisor, valid, true) + assert {:ok, %{state: :ready}} = Provider.initialize(provider, "initial", %{}) + Provider.shutdown(provider) + end + + provider = + Provider.new( + featurevisor_options: %{datafile: Map.put(valid, "revision", ""), log_level: :fatal} + ) + + assert {:ok, %{state: :ready}} = Provider.initialize(provider, "empty_revision", %{}) + Provider.shutdown(provider) + end + + test "shared provider conformance" do + fixture = @provider_fixture + assert fixture["kind"] == "featurevisor.openfeature-provider" + assert fixture["schemaVersion"] == 1 + assert length(fixture["cases"]) == 16 + + provider = + Provider.new(featurevisor_options: %{datafile: fixture["datafile"], log_level: :fatal}) + + Enum.each(fixture["cases"], fn item -> + source = item["context"] + + resolver = + case item["type"] do + "boolean" -> &Provider.resolve_boolean_value/4 + "string" -> &Provider.resolve_string_value/4 + "object" -> &Provider.resolve_map_value/4 + end + + assert {:ok, result} = resolver.(provider, item["flagKey"], item["default"], source) + expected = item["expected"] + assert result.value == expected["value"], item["name"] + + if expected["reason"] do + assert result.reason == expected["reason"] |> String.downcase() |> String.to_atom(), + item["name"] + end + + assert result.variant == expected["variant"], item["name"] + + expected_error = + expected["errorCode"] && expected["errorCode"] |> String.downcase() |> String.to_atom() + + assert result.error_code == expected_error, item["name"] + assert source == item["context"], item["name"] + end) + + Enum.each(fixture["targetingCases"], fn item -> + source = item["context"] + assert {:ok, result} = Provider.resolve_boolean_value(provider, "targetOnly", false, source) + assert result.value == item["expectedValue"], item["name"] + assert source == item["context"], item["name"] + end) + + Provider.shutdown(provider) + + custom = + Provider.new( + featurevisor_options: %{datafile: fixture["datafile"], log_level: :fatal}, + targeting_key_field: "accountId" + ) + + item = fixture["customTargetingCase"] + + assert {:ok, result} = + Provider.resolve_boolean_value(custom, item["flagKey"], false, item["context"]) + + assert result.value == item["expectedValue"] + Provider.shutdown(custom) + end + @datafile %{ "schemaVersion" => "2", "revision" => "openfeature-test", @@ -112,6 +207,15 @@ defmodule FeaturevisorOpenFeature.ProviderTest do } }, "variables" => %{ + "requirementSource" => %{"type" => "boolean", "defaultValue" => false}, + "requirementGate" => %{ + "type" => "boolean", + "defaultValue" => true, + "disabledValue" => false, + "conditions" => [ + %{"variable" => "requirementSource", "operator" => "equals", "value" => true} + ] + }, "supportEmail" => %{ "type" => "string", "defaultValue" => "support@example.com", @@ -202,6 +306,15 @@ defmodule FeaturevisorOpenFeature.ProviderTest do assert :ok = Provider.shutdown(provider) end + test "an empty selector resolves the bare flag" do + provider = provider() + + assert {:ok, %ResolutionDetails{value: true}} = + Provider.resolve_boolean_value(provider, "checkout:", false, %{}) + + Provider.shutdown(provider) + end + test "supports custom key grammar and targeting key spellings" do provider = provider( @@ -251,7 +364,7 @@ defmodule FeaturevisorOpenFeature.ProviderTest do %ResolutionDetails{ value: "fallback", error_code: :flag_not_found, - error_message: ~s(Global variable "" was not found) + error_message: ~s(Feature "variable" was not found) }} = Provider.resolve_string_value(provider, "variable:", "fallback", %{}) assert {:ok, %ResolutionDetails{value: "fallback", error_code: :flag_not_found}} = @@ -315,6 +428,37 @@ defmodule FeaturevisorOpenFeature.ProviderTest do %{"blocked" => true} ) + assert {:ok, + %ResolutionDetails{ + value: false, + reason: :disabled, + flag_metadata: %{ + "featurevisorReason" => "conditions_unmet" + } + }} = + Provider.resolve_boolean_value(provider, "variable:requirementGate", true, %{}) + + Provider.shutdown(provider) + end + + test "real unmet feature prerequisites map to disabled" do + datafile = + put_in(@datafile, ["features", "blockedByRequirement"], %{ + "bucketBy" => "userId", + "requiredFeatures" => ["missing"], + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + }) + + provider = provider(featurevisor_options: %{datafile: datafile, log_level: :fatal}) + + assert {:ok, + %ResolutionDetails{ + value: false, + reason: :disabled, + flag_metadata: %{"featurevisorReason" => "conditions_unmet"} + }} = + Provider.resolve_boolean_value(provider, "blockedByRequirement", true, %{}) + Provider.shutdown(provider) end @@ -324,7 +468,7 @@ defmodule FeaturevisorOpenFeature.ProviderTest do assert {:ok, %ResolutionDetails{value: false, error_code: :parse_error}} = Provider.resolve_boolean_value(provider, "checkout", false, %{}) - assert :ok = Featurevisor.set_datafile(provider.featurevisor, @datafile, true) + assert true == Featurevisor.set_datafile(provider.featurevisor, @datafile, true) assert {:ok, %ResolutionDetails{value: true}} = Provider.resolve_boolean_value( @@ -334,7 +478,7 @@ defmodule FeaturevisorOpenFeature.ProviderTest do %{"targetingKey" => "forced-user"} ) - assert {:error, _reason} = Featurevisor.set_datafile(provider.featurevisor, "{", true) + assert false == Featurevisor.set_datafile(provider.featurevisor, "{", true) assert {:ok, %ResolutionDetails{value: false, error_code: :parse_error}} = Provider.resolve_boolean_value(provider, "checkout", false, %{}) @@ -357,6 +501,74 @@ defmodule FeaturevisorOpenFeature.ProviderTest do assert details.flag_metadata["revision"] == "openfeature-test" end + test "client registration retry and unchanged data recover after rejection" do + provider = provider(featurevisor_options: %{datafile: "{", log_level: :fatal}) + on_exit(fn -> Provider.shutdown(provider) end) + assert {:error, _reason} = OpenFeature.set_provider(provider) + refute OpenFeature.get_provider().name == "Featurevisor" + assert Featurevisor.set_datafile(provider.featurevisor, @datafile, true) + assert {:ok, _registered} = OpenFeature.set_provider(provider) + client = OpenFeature.get_client() + context = %{"targetingKey" => "forced-user"} + assert OpenFeature.Client.get_boolean_value(client, "checkout", false, context: context) + + assert OpenFeature.Client.get_boolean_details(client, "missing", true).error_code == + :flag_not_found + + assert OpenFeature.Client.get_boolean_details(client, "checkout:title", false).error_code == + :type_mismatch + + refute Featurevisor.set_datafile(provider.featurevisor, "{", true) + failed = OpenFeature.Client.get_boolean_details(client, "checkout", false, context: context) + refute failed.value + assert failed.error_code == :parse_error + assert Featurevisor.set_datafile(provider.featurevisor, @datafile, true) + assert OpenFeature.Client.get_boolean_value(client, "checkout", false, context: context) + end + + test "client preserves explicitly empty targeting keys" do + fixture = @provider_fixture + + {:ok, _initialized} = + Provider.new(featurevisor_options: %{datafile: fixture["datafile"], log_level: :fatal}) + |> OpenFeature.set_provider() + + client = OpenFeature.get_client() + source = %{"userId" => "forced"} + + assert OpenFeature.Client.get_boolean_details(client, "targetOnly", false, context: source).value + + refute OpenFeature.Client.get_boolean_details(client, "targetOnly", false, + context: Map.put(source, "targetingKey", "") + ).value + + refute OpenFeature.Client.get_boolean_details(client, "targetOnly", false, + context: Map.put(source, "targetingKey", "other") + ).value + + assert source == %{"userId" => "forced"} + + {:ok, _custom} = + OpenFeature.set_provider( + "custom-targeting", + Provider.new( + featurevisor_options: %{datafile: fixture["datafile"], log_level: :fatal}, + targeting_key_field: "accountId" + ) + ) + + account = %{"targetingKey" => "", "accountId" => "old"} + + assert OpenFeature.Client.get_boolean_details( + OpenFeature.get_client("custom-targeting"), + "accountTargeted", + false, + context: account + ).value + + assert account == %{"targetingKey" => "", "accountId" => "old"} + end + test "closes owned instances and leaves borrowed instances open" do {:ok, owned_count} = Agent.start_link(fn -> 0 end) diff --git a/test/cli_cache_test.exs b/test/cli_cache_test.exs new file mode 100644 index 0000000..734a570 --- /dev/null +++ b/test/cli_cache_test.exs @@ -0,0 +1,170 @@ +defmodule Featurevisor.CLICacheTest do + use ExUnit.Case, async: false + import ExUnit.CaptureIO + + setup do + directory = + Path.join(System.tmp_dir!(), "featurevisor-cache-#{System.unique_integer([:positive])}") + + File.mkdir_p!(directory) + npx = Path.join(directory, "npx") + + File.write!(npx, """ + #!/usr/bin/env python3 + import json, sys + args = sys.argv[2:] + with open("fixture.json", encoding="utf-8") as source: + fixture = json.load(source) + with open("calls.jsonl", "a", encoding="utf-8") as log: + log.write(json.dumps(args) + "\\n") + if args[0] == "config": value = fixture["config"] + elif "--segments" in args: value = [] + elif "--targets" in args: value = [{"key": target} for target in fixture["targets"]] + elif "--tests" in args: + value = {"kind": "featurevisor.test-execution", "schemaVersion": 1, "tests": fixture["tests"]} + elif args[0] == "build": + def option(name): + return next((arg[len(name)+3:] for arg in args if arg.startswith("--"+name+"=")), None) + selection = [option("environment"), option("target")] + value = {"schemaVersion": "2", "revision": "cache", "segments": {}, "features": {}, + "variables": {"selection": {"type": "string", "defaultValue": json.dumps(selection, ensure_ascii=False, separators=(",", ":"))}}} + else: raise SystemExit("unexpected arguments: " + repr(args)) + print(json.dumps(value)) + """) + + File.chmod!(npx, 0o755) + path = System.fetch_env!("PATH") + System.put_env("PATH", directory <> ":" <> path) + + on_exit(fn -> + System.put_env("PATH", path) + File.rm_rf!(directory) + end) + + %{directory: directory} + end + + for flat <- [false, true] do + test "cache isolates every selection with flat=#{flat}", %{directory: directory} do + assert_cache_selections(unquote(flat), directory) + end + end + + defp assert_cache_selections(flat, directory) do + targets = ["web", "web-target-mobile", "mobile", "Café 東京 🧪\"\\", "[null,\"web\"]"] + + environments = + if flat, + do: [nil], + else: [ + "prod", + "prod-target-web", + "prod-target-web-target-mobile", + "false", + "__no_environment__", + "Café 東京 🧪\"\\", + "[null,\"web\"]" + ] + + selections = + for environment <- environments, target <- [nil | targets], do: {environment, target} + + assertions = + selections + |> Enum.with_index() + |> Enum.map(fn {{environment, target}, index} -> + %{ + "environment" => environment, + "target" => target, + "expectedValue" => Jason.encode!([environment, target]), + "_case" => %{ + "id" => Jason.encode!([nil, "variable", "selection", index, nil]), + "assertionIndex" => index, + "matrixIndex" => nil, + "matrixValues" => %{} + } + } + end) + + fixture = %{ + "config" => + if(flat, + do: %{"sets" => false}, + else: %{"sets" => false, "environments" => environments} + ), + "targets" => targets, + "tests" => [ + %{ + "key" => "selection", + "variable" => "selection", + "assertions" => assertions, + "_test" => %{ + "source" => "tests/selection.json", + "entityType" => "variable", + "set" => nil + } + } + ] + } + + File.write!(Path.join(directory, "fixture.json"), Jason.encode!(fixture)) + + output = + capture_io(fn -> + assert :ok = + Featurevisor.CLI.execute([ + "test", + "--json", + "--projectDirectoryPath=#{directory}" + ]) + end) + + report = Jason.decode!(output) + assert report["passed"] + cases = hd(report["results"])["cases"] + assert length(cases) == length(selections) + + Enum.zip(cases, selections) + |> Enum.each(fn {item, {environment, target}} -> + assert item["environment"] == environment + assert item["target"] == target + assert item["passed"] + end) + + builds = + File.read!(Path.join(directory, "calls.jsonl")) + |> String.split("\n", trim: true) + |> Enum.map(&Jason.decode!/1) + |> Enum.filter(&(hd(&1) == "build")) + + assert length(builds) == length(selections) + end + + test "explicit Sets including empty are rejected for non Sets projects", %{directory: directory} do + File.write!( + Path.join(directory, "fixture.json"), + Jason.encode!(%{"config" => %{"sets" => false}}) + ) + + for command <- ["test", "benchmark", "assess-distribution"], selected <- ["", "web"] do + command_options = if command == "test", do: ["--json"], else: ["--feature=flag", "--n=1"] + + capture_io(fn -> + assert {:error, message} = + Featurevisor.CLI.execute( + [command, "--set=#{selected}", "--projectDirectoryPath=#{directory}"] ++ + command_options + ) + + assert message =~ "Omit --set" + end) + end + + calls = + File.read!(Path.join(directory, "calls.jsonl")) + |> String.split("\n", trim: true) + |> Enum.map(&Jason.decode!/1) + + assert Enum.all?(calls, &(hd(&1) == "config")) + end +end diff --git a/test/cli_options_test.exs b/test/cli_options_test.exs new file mode 100644 index 0000000..a2905fa --- /dev/null +++ b/test/cli_options_test.exs @@ -0,0 +1,226 @@ +defmodule Featurevisor.CLI.OptionsTest do + use ExUnit.Case, async: false + import ExUnit.CaptureIO + + @moduletag skip: match?({:win32, _}, :os.type()) + + setup %{tmp_dir: root} do + on_exit(fn -> File.rm_rf!(root) end) + directory = Path.join(root, "project 'quoted' $path") + File.mkdir_p!(directory) + executable = Path.join(directory, "npx") + File.cp!(Path.join(__DIR__, "support/cli_npx.sh"), executable) + File.chmod!(executable, 0o755) + File.write!(Path.join(directory, "calls.log"), "") + path = System.get_env("PATH") + System.put_env("PATH", directory <> ":" <> path) + on_exit(fn -> System.put_env("PATH", path) end) + %{directory: directory} + end + + @moduletag :tmp_dir + + defp value_option(name, value, true), do: ["--#{name}=#{value}"] + defp value_option(name, value, false), do: ["--#{name}", value] + + defp run(args) do + ref = make_ref() + + stderr = + capture_io(:stderr, fn -> + stdout = capture_io(fn -> assert :ok = Featurevisor.CLI.execute(args) end) + send(self(), {ref, stdout}) + end) + + unless Enum.any?(args, &(&1 in ["--help", "-h"])), + do: assert(stderr =~ "delegated warning") + + assert_receive {^ref, stdout} + stdout + end + + defp calls(directory), do: File.read!(Path.join(directory, "calls.log")) + defp clear(directory), do: File.write!(Path.join(directory, "calls.log"), "") + + test "test aliases and directory forms preserve reports and target order", %{ + directory: directory + } do + for path <- [ + "projectDirectoryPath", + "project-directory-path", + "rootDirectoryPath", + "root-directory-path" + ], + equals <- [false, true], + camel <- [false, true] do + clear(directory) + + names = + if camel, + do: ["keyPattern", "assertionPattern", "entityType", "onlyFailures", "showDatafile"], + else: [ + "key-pattern", + "assertion-pattern", + "entity-type", + "only-failures", + "show-datafile" + ] + + [key, assertion, entity, failures, datafile] = names + + args = + [ + "test", + "--json", + "--#{failures}", + "--#{datafile}", + "--target=space target", + "--target", + "web", + "--target=space target" + ] ++ + value_option(path, directory, equals) ++ + value_option(key, "^smoke$", equals) ++ + value_option(assertion, "^ready$", equals) ++ value_option(entity, "scenario", equals) + + report = args |> run() |> Jason.decode!() + assert report["passed"] + assert report["summary"]["assertions"]["passed"] > 0 + + targets = + calls(directory) + |> String.split("\n") + |> Enum.filter(&String.starts_with?(&1, "--target=")) + + assert targets == ["--target=space target", "--target=web"] + assert calls(directory) =~ "--entityType=scenario\n" + end + end + + test "numeric forms and UUID aliases preserve context and repeated selectors", %{ + directory: directory + } do + context = %{"country" => "Café 'quoted' $HOME; space", "nested" => %{"x" => "a b"}} + + for command <- ["benchmark", "assess-distribution"], + n <- [["--n=2"], ["--n", "2"], ["-n=2"], ["-n", "2"]] do + clear(directory) + + extra = + if command == "assess-distribution", + do: [ + "--verbose", + "--populateUuid=first", + "--populate-uuid", + "second", + "--populate-uuid=third", + "--populateUuid", + "fourth" + ], + else: [] + + args = + [ + command, + "--root-directory-path", + directory, + "--feature=checkout", + "--context", + Jason.encode!(context), + "--target=space target", + "--target", + "web", + "--target=space target" + ] ++ n ++ extra + + output = run(args) + assert output =~ "Iterations: 2" + assert output =~ "Environment: false" + + contexts = + output |> String.split("\n") |> Enum.filter(&String.starts_with?(&1, "Context: ")) + + for line <- contexts do + actual = line |> String.replace_prefix("Context: ", "") |> Jason.decode!() + assert Map.take(actual, Map.keys(context)) == context + + if command == "assess-distribution" do + for key <- ["first", "second", "third", "fourth"], do: assert(is_binary(actual[key])) + end + end + + assert length(contexts) == if(command == "benchmark", do: 2, else: 4) + + targets = + calls(directory) + |> String.split("\n") + |> Enum.filter(&String.starts_with?(&1, "--target=")) + + assert targets == ["--target=space target", "--target=web"] + end + end + + test "invalid and irrelevant options do not delegate", %{directory: directory} do + for args <- [ + ["benchmark", "--feature="], + ["benchmark", "--variable="], + ["assess-distribution", "--feature="] + ] do + assert {:error, message} = Featurevisor.CLI.execute(args) + assert message =~ "empty" + end + + for args <- [ + ["test", "--entity-type=unknown"], + ["test", "--feature=ignored"], + ["test", "--environment=ignored"], + ["test", "--target"], + ["test", "--unknown"], + ["test", "--only-failures", "extra"], + ["test", "--pretty"], + ["test", "--n=2"], + ["benchmark", "--feature=flag", "--json"], + ["benchmark", "--feature=flag", "--only-failures"], + ["assess-distribution", "--feature=flag", "--variable=ignored"], + ["assess-distribution", "--feature=flag", "--populate-uuid"], + ["assess-distribution", "--feature=flag", "--populate-uuid=a", "ignored"] + ] do + assert {:error, _} = + Featurevisor.CLI.execute(args ++ ["--project-directory-path", directory]) + end + + for command <- ["benchmark", "assess-distribution"], + value <- ["0", "-1", "1.5", "invalid", ""], + equals <- [false, true] do + assert {:error, _} = + Featurevisor.CLI.execute( + [command, "--feature=flag"] ++ value_option("n", value, equals) + ) + end + + for option <- ["key-pattern", "assertion-pattern"] do + output = + capture_io(fn -> + assert {:error, message} = Featurevisor.CLI.execute(["test", "--json", "--#{option}=["]) + assert message =~ "Invalid" + end) + + assert Jason.decode!(output)["passed"] == false + end + + assert calls(directory) == "" + end + + test "root and scoped help do not delegate", %{directory: directory} do + for command <- [[], ["test"], ["benchmark"], ["assess-distribution"]], + flag <- ["--help", "-h"] do + output = run(command ++ [flag]) + assert output =~ "Usage:" + assert output =~ "--project-directory-path" + assert output =~ "Node.js" + end + + assert {:error, _} = Featurevisor.CLI.execute(["unknown", "--help"]) + assert calls(directory) == "" + end +end diff --git a/test/cli_project_process_test.exs b/test/cli_project_process_test.exs new file mode 100644 index 0000000..f243b0d --- /dev/null +++ b/test/cli_project_process_test.exs @@ -0,0 +1,77 @@ +defmodule Featurevisor.CLI.ProjectProcessTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + alias Featurevisor.CLI.Project + + @moduletag skip: match?({:win32, _}, :os.type()) + + setup %{tmp_dir: directory} do + on_exit(fn -> File.rm_rf!(directory) end) + :ok + end + + @tag :tmp_dir + test "warning stays on stderr and leaves JSON intact", %{tmp_dir: directory} do + with_fake_npx(directory, "success", fn -> + warning = + capture_io(:stderr, fn -> + send(self(), {:result, Project.json(directory, "config")}) + end) + + assert_received {:result, {:ok, %{"ok" => true}}} + assert warning == "mutation warning\n" + end) + end + + @tag :tmp_dir + test "large stderr does not block and failure retains diagnostics", %{tmp_dir: directory} do + with_fake_npx(directory, "failure", fn -> + assert {:error, message} = Project.json(directory, "build") + assert message =~ "status 17" + assert message =~ "syntax error at build staging" + assert byte_size(message) > 262_144 + end) + end + + defp with_fake_npx(directory, mode, callback) do + executable = Path.join(directory, "npx") + + File.write!( + executable, + "#!/bin/sh\n" <> + "if [ \"$FAKE_MODE\" = failure ]; then\n" <> + " head -c 262144 /dev/zero >&2\n" <> + " printf 'syntax error at build staging\\n' >&2\n" <> + " exit 17\n" <> + "fi\n" <> + "printf 'mutation warning\\n' >&2\n" <> + "printf '{\"ok\":true}\\n'\n" + ) + + File.chmod!(executable, 0o755) + old_path = System.get_env("PATH") + old_mode = System.get_env("FAKE_MODE") + System.put_env("PATH", directory <> ":" <> old_path) + System.put_env("FAKE_MODE", mode) + + try do + callback.() + after + System.put_env("PATH", old_path) + if old_mode, do: System.put_env("FAKE_MODE", old_mode), else: System.delete_env("FAKE_MODE") + end + end + + @tag :tmp_dir + test "runs the nearest local CLI entry with node, then falls back to npx", %{tmp_dir: root} do + project = Path.join([root, "packages", "flags"]) + File.mkdir_p!(project) + assert Project.node_command(project) == ["npx", "featurevisor"] + entry = Path.join([root, "node_modules", "@featurevisor", "cli", "bin.js"]) + File.mkdir_p!(Path.dirname(entry)) + File.write!(entry, "") + assert Project.node_command(project) == ["node", entry] + end +end diff --git a/test/cli_report_test.exs b/test/cli_report_test.exs new file mode 100644 index 0000000..fb6c8f8 --- /dev/null +++ b/test/cli_report_test.exs @@ -0,0 +1,152 @@ +defmodule Featurevisor.CLIReportTest do + use ExUnit.Case, async: false + import ExUnit.CaptureIO + + test "ordinary failures retain typed values and authored root and child paths" do + directory = + Path.join(System.tmp_dir!(), "featurevisor-report-#{System.unique_integer([:positive])}") + + File.mkdir_p!(directory) + npx = Path.join(directory, "npx") + + File.write!(npx, """ + #!/usr/bin/env python3 + import json, sys + with open("fixture.json", encoding="utf-8") as source: + fixture = json.load(source) + args = sys.argv[2:] + if args[0] == "config": value = {"sets": False, "environments": False} + elif "--targets" in args: value = [] + elif "--segments" in args: value = [{"key": "all", "conditions": "*"}] + elif "--tests" in args: + value = {"kind": "featurevisor.test-execution", "schemaVersion": 1, "tests": fixture["tests"]} + elif args[0] == "build": value = fixture["datafile"] + else: raise SystemExit("unexpected arguments: " + repr(args)) + print(json.dumps(value)) + """) + + File.chmod!(npx, 0o755) + path = System.fetch_env!("PATH") + System.put_env("PATH", directory <> ":" <> path) + + on_exit(fn -> + System.put_env("PATH", path) + File.rm_rf!(directory) + end) + + feature = %{ + "expectedToBeEnabled" => false, + "expectedVariation" => "treatment", + "expectedVariables" => %{"limit" => 99}, + "expectedEvaluations" => %{ + "flag" => %{"enabled" => false}, + "variation" => %{"variation" => %{"value" => "treatment"}}, + "variables" => %{"limit" => %{"variableValue" => 99}} + } + } + + variable = %{ + "expectedValue" => "wanted", + "expectedEvaluation" => %{"variableValue" => "wanted"} + } + + tests = + for {type, key, expectation} <- [ + {"feature", "subject", Map.put(feature, "children", [feature])}, + {"variable", "message", Map.put(variable, "children", [variable])}, + {"segment", "all", %{"expectedToMatch" => false}} + ] do + %{ + "key" => key, + type => key, + "_test" => %{"source" => "tests/#{key}.json", "entityType" => type, "set" => nil}, + "assertions" => [ + Map.put(expectation, "_case", %{ + "id" => key, + "assertionIndex" => 4, + "matrixIndex" => nil, + "matrixValues" => %{} + }) + ] + } + end + + datafile = %{ + "schemaVersion" => "2", + "revision" => "report", + "segments" => %{}, + "features" => %{ + "subject" => %{ + "bucketBy" => "userId", + "traffic" => [], + "force" => [%{"segments" => "*", "enabled" => true, "variation" => "control"}], + "variations" => [%{"value" => "control"}], + "variablesSchema" => %{"limit" => %{"type" => "integer", "defaultValue" => 12}} + } + }, + "variables" => %{"message" => %{"type" => "string", "defaultValue" => "actual"}} + } + + File.write!( + Path.join(directory, "fixture.json"), + Jason.encode!(%{"tests" => tests, "datafile" => datafile}) + ) + + output = + capture_io(fn -> + assert {:error, _} = + Featurevisor.CLI.execute([ + "test", + "--json", + "--projectDirectoryPath=#{directory}" + ]) + end) + + report = Jason.decode!(output) + assert report["summary"]["entityAssertions"]["failed"] == 3 + [feature_result, variable_result, segment_result] = report["results"] + feature_errors = hd(feature_result["cases"])["errors"] + assert length(feature_errors) == 12 + + for child <- [false, true] do + base = ["assertions", 4] ++ if(child, do: ["children", 0], else: []) + + for {type, suffix, expected, actual} <- [ + {"flag", ["expectedToBeEnabled"], false, true}, + {"variation", ["expectedVariation"], "treatment", "control"}, + {"variable", ["expectedVariables", "limit"], 99, 12}, + {"evaluation", ["expectedEvaluations", "flag", "enabled"], false, true}, + {"evaluation", ["expectedEvaluations", "variation", "variation"], + %{"value" => "treatment"}, %{"value" => "control"}}, + {"evaluation", ["expectedEvaluations", "variables", "limit", "variableValue"], 99, 12} + ] do + assert_error(feature_errors, type, base ++ suffix, expected, actual) + end + + variable_errors = hd(variable_result["cases"])["errors"] + assert length(variable_errors) == 4 + assert_error(variable_errors, "variable", base ++ ["expectedValue"], "wanted", "actual") + + assert_error( + variable_errors, + "evaluation", + base ++ ["expectedEvaluation", "variableValue"], + "wanted", + "actual" + ) + end + + segment_errors = hd(segment_result["cases"])["errors"] + assert length(segment_errors) == 1 + assert_error(segment_errors, "segment", ["assertions", 4, "expectedToMatch"], false, true) + end + + defp assert_error(errors, type, path, expected, actual) do + error = Enum.find(errors, &(&1["path"] == path)) + assert error, inspect(path) + assert error["type"] == type + assert error["expected"] == expected + assert error["actual"] == actual + assert is_binary(error["message"]) + end +end diff --git a/test/cli_segments_test.exs b/test/cli_segments_test.exs new file mode 100644 index 0000000..9d5c73c --- /dev/null +++ b/test/cli_segments_test.exs @@ -0,0 +1,192 @@ +defmodule Featurevisor.CLI.SegmentsTest do + use ExUnit.Case, async: true + alias Featurevisor.{Child, Module} + alias Featurevisor.CLI.{Segments, TestChecks, TestCoverage} + + defp datafile do + %{ + "schemaVersion" => "4", + "revision" => "segments", + "segments" => %{}, + "features" => %{ + "gate" => %{ + "bucketBy" => "id", + "traffic" => [%{"key" => "some", "conditions" => "*", "percentage" => 10_000}] + } + }, + "variables" => %{ + "policy" => %{"type" => "object", "defaultValue" => %{"items" => [%{"enabled" => false}]}} + } + } + end + + defp definitions do + %{ + "eligible" => %{ + "conditions" => [ + "flag", + %{ + "variable" => "policy", + "path" => ["items", 0, "enabled"], + "operator" => "equals", + "value" => true + } + ] + }, + "flag" => %{"conditions" => %{"feature" => "gate"}} + } + end + + test "SDK gates use compiled paths, at and root or child sticky state" do + original = datafile() + definitions = definitions() + assert Segments.preflight("eligible", original, definitions) == :ok + {prepared, keys} = Segments.prepare(original, definitions) + coverage = TestCoverage.open(true) + module = %Module{bucket_value: fn _ -> 5_000 end} + + f = + Featurevisor.create_featurevisor(%{ + datafile: prepared, + log_level: :fatal, + sticky_variables: %{"policy" => %{"items" => [%{"enabled" => true}]}}, + modules: [module, TestCoverage.module(coverage, %{}, Map.values(keys))] + }) + + child = + Featurevisor.spawn(f, %{"country" => "nl"}, %{ + sticky_features: %{"gate" => %{"enabled" => true}}, + sticky_variables: %{"policy" => %{"items" => [%{"enabled" => true}]}} + }) + + checks = [%{"segment" => "eligible", "matches" => true}] + options = %{definitions: definitions, keys: keys} + + assert TestChecks.evaluate_result(f, prepared, checks, nil, make_ref(), "checks", options).passed + + assert TestChecks.evaluate_result(child, prepared, checks, nil, make_ref(), "checks", options).passed + + Child.set_sticky_variables(child, %{}, true) + + refute TestChecks.evaluate_result(child, prepared, checks, nil, make_ref(), "checks", options).passed + + selections = TestCoverage.selections(coverage) + assert Enum.any?(selections, &(&1["featureKey"] == "gate")) + refute Enum.any?(selections, &(&1["featureKey"] in Map.values(keys))) + Child.close(child) + Featurevisor.close(f) + TestCoverage.close(coverage) + end + + test "selected Target segment specialization wins and helpers do not overwrite real features" do + definitions = %{"eligible" => %{"conditions" => "*"}} + + selected = + datafile() + |> put_in(["segments", "eligible"], %{"conditions" => []}) + |> put_in(["features", "__featurevisor_segment_test__eligible"], %{ + "bucketBy" => "id", + "traffic" => [] + }) + + {prepared, keys} = Segments.prepare(selected, definitions) + assert keys["eligible"] == "__featurevisor_segment_test__eligible_" + assert prepared["segments"]["eligible"]["conditions"] == [] + + assert prepared["features"]["__featurevisor_segment_test__eligible"] == + selected["features"]["__featurevisor_segment_test__eligible"] + + f = Featurevisor.create_featurevisor(%{datafile: prepared, log_level: :fatal}) + + result = + TestChecks.evaluate_result( + f, + prepared, + [%{"segment" => "eligible", "matches" => false}], + nil, + make_ref(), + "checks", + %{definitions: definitions, keys: keys} + ) + + assert result.passed + Featurevisor.close(f) + end + + test "preflight traverses skipped and stringified references, composition and feature variable schemas without getters" do + for condition <- [ + %{"or" => ["*", %{"feature" => "missing"}]}, + Jason.encode!(%{"feature" => "gate", "variable" => "missing", "operator" => "isNull"}), + %{"variable" => "alias", "operator" => "notNull"} + ] do + definitions = %{"root" => %{"conditions" => "leaf"}, "leaf" => %{"conditions" => condition}} + + selected = + datafile() + |> put_in(["variables", "alias"], %{"type" => "object", "compose" => "missing"}) + + {prepared, keys} = Segments.prepare(selected, definitions) + count = :atomics.new(1, []) + + f = + Featurevisor.create_featurevisor(%{ + datafile: prepared, + log_level: :fatal, + modules: [ + %Module{ + before_evaluation: fn options -> + :atomics.add_get(count, 1, 1) + options + end + } + ] + }) + + result = + TestChecks.evaluate_result( + f, + prepared, + [%{"not" => [%{"segment" => "root", "matches" => true}]}], + nil, + make_ref(), + "checks", + %{definitions: definitions, keys: keys} + ) + + assert result.error + assert :atomics.get(count, 1) == 0 + Featurevisor.close(f) + end + end + + test "segment cycles are errors which check NOT cannot invert and attribute chains need no datafile" do + definitions = %{"root" => %{"conditions" => "leaf"}, "leaf" => %{"conditions" => "root"}} + {prepared, keys} = Segments.prepare(Segments.empty(), definitions) + f = Featurevisor.create_featurevisor(%{datafile: prepared, log_level: :fatal}) + + result = + TestChecks.evaluate_result( + f, + prepared, + [%{"not" => [%{"segment" => "root", "matches" => true}]}], + nil, + make_ref(), + "checks", + %{definitions: definitions, keys: keys} + ) + + assert result.error + Featurevisor.close(f) + + definitions = + put_in(definitions, ["leaf", "conditions"], %{ + "attribute" => "age", + "operator" => "greaterThan", + "value" => 18 + }) + + refute Segments.dependent?("root", definitions) + assert Segments.preflight("root", Segments.empty(), definitions) == :ok + assert Segments.dependent?("eligible", definitions()) + end +end diff --git a/test/cli_selection_test.exs b/test/cli_selection_test.exs new file mode 100644 index 0000000..f0d9dac --- /dev/null +++ b/test/cli_selection_test.exs @@ -0,0 +1,122 @@ +defmodule Featurevisor.CLI.SelectionTest do + use ExUnit.Case, async: false + import ExUnit.CaptureIO + + @moduletag skip: match?({:win32, _}, :os.type()) + + @tag :tmp_dir + test "evaluation commands delegate selections and propagate failures", %{tmp_dir: directory} do + on_exit(fn -> File.rm_rf!(directory) end) + for set <- ["alpha", "beta"], do: File.mkdir_p!(Path.join([directory, "sets", set])) + + executable = Path.join(directory, "npx") + + File.write!(executable, """ + #!/bin/sh + printf '%s\\n' "$*" >> calls.log + case "$2" in + config) cat config.json ;; + list) + case "$*" in + *--tests*) printf '%s\\n' '{"kind":"featurevisor.test-execution","schemaVersion":1,"tests":[{"key":"smoke","scenario":"smoke","_test":{"source":"tests/scenarios/smoke.spec.yml","set":null,"entityType":"scenario"},"assertions":[{"_case":{"id":"case-smoke","assertionIndex":0,"matrixIndex":null,"matrixValues":{}},"checks":[{"feature":"checkout","enabled":true}]}]}]}' ;; + *) printf '%s\\n' '[]' ;; + esac ;; + build) + case "$*" in + *--target=bad*|*--set=bad*) echo 'delegated selection error' >&2; exit 7 ;; + esac + printf '%s\\n' '{"schemaVersion":"2","revision":"1","segments":{},"features":{"checkout":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]}},"variables":{"subject":{"type":"string","defaultValue":"ready"}}}' ;; + *) exit 8 ;; + esac + """) + + File.chmod!(executable, 0o755) + old_path = System.get_env("PATH") + System.put_env("PATH", directory <> ":" <> old_path) + on_exit(fn -> System.put_env("PATH", old_path) end) + sets = Jason.encode!(%{sets: true, setsDirectoryPath: Path.join(directory, "sets")}) + + for command <- ["benchmark", "assess-distribution"], + {name, config, args, success, builds, expected} <- [ + {"flat", "{}", [], true, 1, ""}, + {"configured", ~s({"environments":["production"]}), ["--environment=production"], true, + 1, "--environment=production"}, + {"missing environment", ~s({"environments":["production"]}), [], false, 0, ""}, + {"unknown environment", ~s({"environments":["production"]}), ["--environment=typo"], + false, 0, ""}, + {"unexpected environment", "{}", ["--environment=production"], false, 0, ""}, + {"sets", sets, [], true, 2, "--set=beta"}, + {"selected set", sets, ["--set=alpha"], true, 1, "--set=alpha"}, + {"targets", sets, ["--set=alpha", "--target=web", "--target=mobile", "--target=web"], + true, 2, "--target=mobile"}, + {"bad target", "{}", ["--target=bad"], false, 1, "--target=bad"}, + {"bad set", sets, ["--set=bad"], false, 1, "--set=bad"}, + {"zero n", "{}", ["--n=0"], false, 0, ""}, + {"zero inflate", "{}", ["--inflate=0"], false, 0, ""}, + {"array context", "{}", ["--context=[]"], false, 0, ""}, + {"null context", "{}", ["--context=null"], false, 0, ""}, + {"broken context", "{}", ["--context={"], false, 0, ""}, + {"broken config", "{", [], false, 0, ""} + ] do + File.write!(Path.join(directory, "config.json"), config) + File.write!(Path.join(directory, "calls.log"), "") + iterations = if Enum.any?(args, &String.starts_with?(&1, "--n=")), do: [], else: ["--n=1"] + + capture_io(fn -> + result = + Featurevisor.CLI.execute( + [command, "--projectDirectoryPath=#{directory}", "--feature=checkout"] ++ + iterations ++ args + ) + + assert result == :ok == success, "#{command}/#{name}: #{inspect(result)}" + end) + + calls = File.read!(Path.join(directory, "calls.log")) + + assert length(Regex.scan(~r/featurevisor build/, calls)) == builds, + "#{command}/#{name}: #{calls}" + + assert String.contains?(calls, expected) + if name == "flat", do: refute(String.contains?(calls, "--environment")) + end + + File.write!(Path.join(directory, "config.json"), "{}") + + arguments = + [[], ["--inflate"]] ++ + Enum.flat_map(["1", "2", "0", "-1", "1.5", "invalid", ""], fn value -> + [["--inflate=#{value}"], ["--inflate", value]] + end) + + for command <- ["benchmark", "assess-distribution", "test"], extra <- arguments do + File.write!(Path.join(directory, "calls.log"), "") + + value = + if extra == [], do: nil, else: String.replace_prefix(List.last(extra), "--inflate=", "") + + valid = value in [nil, "1", "2"] + evaluation = if command == "test", do: [], else: ["--feature=checkout", "--n=1"] + + capture_io(fn -> + result = + Featurevisor.CLI.execute( + [command, "--projectDirectoryPath=#{directory}"] ++ evaluation ++ extra + ) + + assert result == :ok == valid, "#{command}/#{inspect(extra)}: #{inspect(result)}" + if not valid, do: assert(elem(result, 1) =~ "inflate") + end) + + calls = File.read!(Path.join(directory, "calls.log")) + + if valid do + assert length(Regex.scan(~r/featurevisor build/, calls)) == 1 + if extra == [], do: refute(String.contains?(calls, "--inflate")) + if value == "2", do: assert(String.contains?(calls, "--inflate=2")) + else + assert calls == "", "invalid inflation reached Node: #{calls}" + end + end + end +end diff --git a/test/cli_test.exs b/test/cli_test.exs index 4e1019c..7f9a00b 100644 --- a/test/cli_test.exs +++ b/test/cli_test.exs @@ -1,12 +1,34 @@ defmodule Featurevisor.CLITest do - use ExUnit.Case, async: true - alias Featurevisor.CLI.Project + use ExUnit.Case, async: false + alias Featurevisor.CLI.{Project, Segments, TestChecks, TestRunner} - test "datafile cache keys match the JavaScript runner" do - assert Project.datafile_key(nil, nil) == "false" - assert Project.datafile_key("production", nil) == "production" - assert Project.datafile_key(nil, "checkout") == "false-target-checkout" - assert Project.datafile_key("production", "checkout") == "production-target-checkout" + test "datafile cache keys preserve every environment and target selection" do + names = [ + nil, + "", + "false", + "__no_environment__", + "prod", + "prod-target-web", + "web", + "web-target-mobile", + "mobile", + "Café 東京 🧪\"\\", + "[null,\"web\"]" + ] + + selections = for environment <- names, target <- names, do: {environment, target} + + cache = + Map.new(selections, fn {environment, target} = selection -> + {Project.datafile_key(environment, target), selection} + end) + + assert map_size(cache) == length(selections) + + for {environment, target} = selection <- selections do + assert cache[Project.datafile_key(environment, target)] == selection + end end test "validates commands before delegating to Node" do @@ -34,4 +56,1180 @@ defmodule Featurevisor.CLITest do assert {:error, "--feature or --variable is required"} = Featurevisor.CLI.execute(["benchmark", "--schema-version=1"]) end + + test "shared check fixture executes every case with a fresh native instance" do + fixture = + Path.expand("../conformance/test-checks.json", __DIR__) + |> File.read!() + |> Jason.decode!() + + assert fixture["version"] == 3 + assert length(fixture["cases"]) == 255 + + assert Enum.count( + fixture["cases"], + &String.contains?(&1["name"], ["includesAny", "IncludesAny"]) + ) == 36 + + assert is_map(fixture["datafile"]) + + Enum.each(fixture["cases"], fn item -> + assert is_binary(item["name"]) + assert Map.has_key?(item, "checks") + assert is_boolean(item["passed"]) + ref = make_ref() + + instance = + Featurevisor.create_featurevisor(%{ + datafile: fixture["datafile"], + log_level: :fatal, + on_diagnostic: TestChecks.diagnostic_handler(self(), ref), + modules: [TestChecks.diagnostic_module(self(), ref)] + }) + + result = + try do + TestChecks.evaluate_result( + instance, + fixture["datafile"], + item["checks"], + item["owner"], + ref + ) + after + Featurevisor.close(instance) + end + + preflight_names = [ + "or skips unreached missing feature", + "and skips unreached missing feature", + "short circuit skips unresolved ref", + "AND short circuit" + ] + + expected_passed = if item["name"] in preflight_names, do: false, else: item["passed"] + + expected_error = + if item["name"] in preflight_names, do: true, else: Map.get(item, "error", false) + + assert result.passed == expected_passed, + "#{item["name"]}: #{Enum.join(result.failures, "; ")}" + + assert result.error == expected_error, + "#{item["name"]}: #{Enum.join(result.failures, "; ")}" + end) + end + + test "execution protocol rejects malformed and future envelopes" do + valid = %{ + "kind" => "featurevisor.test-execution", + "schemaVersion" => 1, + "tests" => [%{"scenario" => "checkout"}] + } + + assert {:ok, [%{"scenario" => "checkout"}]} = + TestRunner.validate_execution_protocol(valid) + + for invalid <- [ + [], + %{}, + %{"kind" => nil, "schemaVersion" => 1, "tests" => []}, + %{"kind" => "wrong", "schemaVersion" => 1, "tests" => []}, + %{"kind" => "featurevisor.test-execution", "tests" => []}, + %{"kind" => "featurevisor.test-execution", "schemaVersion" => nil, "tests" => []}, + %{"kind" => "featurevisor.test-execution", "schemaVersion" => "1", "tests" => []}, + %{"kind" => "featurevisor.test-execution", "schemaVersion" => 2, "tests" => []}, + %{"kind" => "featurevisor.test-execution", "schemaVersion" => 1, "tests" => nil}, + %{"kind" => "featurevisor.test-execution", "schemaVersion" => 1, "tests" => [nil]} + ] do + assert {:error, _} = TestRunner.validate_execution_protocol(invalid) + end + end + + test "preflight validates all references without an evaluator" do + datafile = %{ + "features" => %{ + "checkout" => %{"variablesSchema" => %{"message" => %{"type" => "string"}}} + }, + "variables" => %{"message" => %{"type" => "string", "defaultValue" => "ok"}} + } + + cases = [ + [%{"variable" => "missing", "operator" => "exists"}], + [%{"feature" => "missing", "enabled" => false}], + [%{"feature" => "checkout", "variable" => "missing", "operator" => "isNull"}], + [ + %{ + "variable" => "message", + "operator" => "equals", + "compareTo" => %{"variable" => "missing"} + } + ], + [ + %{ + "or" => [ + %{"variable" => "message", "operator" => "equals", "value" => "ok"}, + %{"feature" => "missing", "enabled" => false} + ] + } + ], + [%{"segment" => "missing", "matches" => false}], + [%{"not" => [%{"and" => "malformed"}]}] + ] + + Enum.each(cases, fn checks -> + result = TestChecks.preflight(datafile, checks, nil, "checks", %{definitions: %{}}) + assert result.error + refute result.passed + end) + + assert TestChecks.preflight( + datafile, + [%{"variable" => "message", "operator" => "equals", "value" => "ok"}], + nil + ).passed + end + + test "quiet check collection turns malformed JSON getter diagnostics into errors" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "bad-json", + "segments" => %{}, + "features" => %{}, + "variables" => %{"broken" => %{"type" => "json", "defaultValue" => "["}} + } + + ref = make_ref() + + instance = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + log_level: :error, + on_diagnostic: TestChecks.diagnostic_handler(self(), ref), + modules: [TestChecks.diagnostic_module(self(), ref)] + }) + + result = + try do + TestChecks.evaluate_result( + instance, + datafile, + [%{"variable" => "broken", "operator" => "isNull"}], + nil, + ref + ) + after + Featurevisor.close(instance) + end + + refute result.passed + assert result.error + assert hd(result.failures) =~ "variable getter reported" + end + + test "feature checks evaluate both getters and surface native getter diagnostics" do + parent = self() + ref = make_ref() + + module = %Featurevisor.Module{ + name: "feature-check-trace", + before_evaluation: fn options -> + send(parent, {:feature_check_getter, options.type}) + if options.type == :variation, do: raise("variation exploded"), else: options + end + } + + datafile = %{ + "schemaVersion" => "2", + "revision" => "feature-check-errors", + "segments" => %{}, + "features" => %{ + "experiment" => %{ + "bucketBy" => "userId", + "variations" => [%{"value" => "treatment"}], + "traffic" => [] + } + } + } + + instance = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + log_level: :error, + on_diagnostic: TestChecks.diagnostic_handler(self(), ref), + modules: [module] + }) + + result = + try do + TestChecks.evaluate_result( + instance, + datafile, + [%{"feature" => "experiment", "enabled" => true, "variation" => "treatment"}], + nil, + ref + ) + after + Featurevisor.close(instance) + end + + assert result.error + assert hd(result.failures) =~ "variation getter reported evaluation_error" + assert hd(result.failures) =~ ~s(feature "experiment") + assert_receive {:feature_check_getter, :flag} + assert_receive {:feature_check_getter, :variation} + + exception_result = + TestChecks.evaluate_result( + %{}, + datafile, + [%{"feature" => "experiment", "enabled" => true}], + nil, + make_ref() + ) + + assert exception_result.error + assert hd(exception_result.failures) =~ "enabled getter failed" + assert hd(exception_result.failures) =~ ~s(feature "experiment") + + failure_instance = Featurevisor.create_featurevisor(%{datafile: datafile}) + + failure_result = + try do + TestChecks.evaluate_result( + failure_instance, + datafile, + [%{"feature" => "experiment", "enabled" => true, "variation" => "control"}], + nil, + make_ref() + ) + after + Featurevisor.close(failure_instance) + end + + refute failure_result.passed + refute failure_result.error + assert hd(failure_result.failures) =~ ~s(feature "experiment") + assert hd(failure_result.failures) =~ "expected enabled=true, actual enabled=false" + assert hd(failure_result.failures) =~ ~s(expected variation="control", actual variation=null) + end + + test "complete check syntax is validated before native getters run" do + parent = self() + ref = make_ref() + + module = %Featurevisor.Module{ + name: "feature-check-no-evaluation", + before_evaluation: fn options -> + send(parent, :unexpected_feature_getter) + options + end + } + + datafile = %{ + "schemaVersion" => "2", + "revision" => "feature-check-validation", + "segments" => %{}, + "features" => %{"checkout" => %{"bucketBy" => "userId", "traffic" => []}} + } + + instance = Featurevisor.create_featurevisor(%{datafile: datafile, modules: [module]}) + + result = + try do + TestChecks.evaluate_result( + instance, + datafile, + [ + %{"feature" => "checkout", "enabled" => false}, + %{"feature" => "checkout", "enabled" => "false"} + ], + nil, + ref + ) + after + Featurevisor.close(instance) + end + + assert result.error + refute_receive :unexpected_feature_getter + end + + test "named checks validate sibling keys and preserve failure evidence" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "named-checks", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "plan" => %{"type" => "string", "defaultValue" => "pro"}, + "settings" => %{"type" => "json", "defaultValue" => %{"nullable" => nil}} + } + } + + ref = make_ref() + instance = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + + try do + result = + TestChecks.evaluate_result( + instance, + datafile, + [ + %{ + "key" => "entity checks[1]", + "description" => "Plan is enterprise", + "variable" => "plan", + "operator" => "equals", + "value" => "free" + } + ], + nil, + ref + ) + + refute result.passed + assert hd(result.failures) =~ ~s(key="entity checks[1]") + assert hd(result.failures) =~ ~s(description="Plan is enterprise") + assert hd(result.failures) =~ ~s(left="pro") + failure = hd(result.report_failures) + assert failure.check_key == "entity checks[1]" + assert failure.expected == true + assert failure.actual == false + assert hd(failure.evaluated)["path"] == [0] + assert hd(failure.evaluated)["reference"] == %{"variable" => "plan"} + assert hd(failure.evaluated)["expected"] == "free" + assert hd(failure.evaluated)["actual"] == "pro" + + nullable = + TestChecks.evaluate_result( + instance, + datafile, + [%{"variable" => "settings", "path" => "nullable", "operator" => "isNull"}], + nil, + ref + ) + + assert nullable.passed + + missing = + TestChecks.evaluate_result( + instance, + datafile, + [%{"variable" => "settings", "path" => "absent", "operator" => "isNull"}], + nil, + ref + ) + + missing_leaf = hd(hd(missing.report_failures).evaluated) + assert missing_leaf["actualMissing"] == true + assert missing_leaf["actual"] == %{"$type" => "undefined"} + + duplicate = + TestChecks.evaluate_result( + instance, + datafile, + [ + %{ + "and" => [ + %{"key" => "same", "variable" => "plan", "operator" => "exists"}, + %{"key" => "same", "variable" => "plan", "operator" => "exists"} + ] + } + ], + nil, + ref + ) + + assert duplicate.error + assert hd(duplicate.failures) =~ "duplicate sibling check key" + + for checks <- [ + [%{"key" => "", "variable" => "plan", "operator" => "exists"}], + [%{"description" => 1, "variable" => "plan", "operator" => "exists"}] + ] do + assert TestChecks.evaluate_result(instance, datafile, checks, nil, ref).error + end + after + Featurevisor.close(instance) + end + end + + test "empty check operators validate and compare supported values" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "empty-checks", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "emptyString" => %{"type" => "string", "defaultValue" => ""}, + "space" => %{"type" => "string", "defaultValue" => " "}, + "emptyArray" => %{"type" => "array", "defaultValue" => []}, + "arrayWithNull" => %{"type" => "array", "defaultValue" => [nil]}, + "emptyObject" => %{"type" => "object", "defaultValue" => %{}}, + "objectWithNull" => %{ + "type" => "object", + "defaultValue" => %{"value" => nil, "nested" => []} + }, + "null" => %{"type" => "object", "defaultValue" => nil}, + "number" => %{"type" => "integer", "defaultValue" => 0} + } + } + + instance = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + ref = make_ref() + + try do + passing = [ + %{"variable" => "emptyString", "operator" => "empty"}, + %{"variable" => "space", "operator" => "notEmpty"}, + %{"variable" => "emptyArray", "operator" => "empty"}, + %{"variable" => "arrayWithNull", "operator" => "notEmpty"}, + %{"variable" => "emptyObject", "operator" => "empty"}, + %{"variable" => "objectWithNull", "operator" => "notEmpty"}, + %{ + "variable" => "objectWithNull", + "path" => ["nested"], + "operator" => "empty" + } + ] + + assert TestChecks.evaluate_result(instance, datafile, passing, nil, ref).passed + + for check <- [ + %{"variable" => "null", "operator" => "empty"}, + %{"variable" => "null", "operator" => "notEmpty"}, + %{"variable" => "objectWithNull", "path" => ["missing"], "operator" => "empty"}, + %{ + "variable" => "objectWithNull", + "path" => ["missing"], + "operator" => "notEmpty" + } + ] do + result = TestChecks.evaluate_result(instance, datafile, [check], nil, ref) + refute result.passed + refute result.error + end + + for checks <- [ + [%{"variable" => "number", "operator" => "empty"}], + [ + %{ + "or" => [ + %{"variable" => "number", "operator" => "notEmpty"}, + %{"variable" => "emptyString", "operator" => "empty"} + ] + } + ], + [%{"not" => [%{"variable" => "number", "operator" => "empty"}]}] + ] do + result = TestChecks.evaluate_result(instance, datafile, checks, nil, ref) + refute result.passed + assert result.error + assert hd(result.failures) =~ "requires a string, array, or object operand" + end + + for invalid <- [ + %{"variable" => "emptyString", "operator" => "empty", "value" => nil}, + %{ + "variable" => "emptyString", + "operator" => "notEmpty", + "compareTo" => %{"variable" => "space"} + }, + %{"variable" => "emptyString", "operator" => "empty", "regexFlags" => "i"} + ] do + result = + TestChecks.preflight( + datafile, + [ + %{ + "or" => [ + %{"variable" => "emptyString", "operator" => "empty"}, + invalid + ] + } + ], + nil + ) + + assert result.error + end + after + Featurevisor.close(instance) + end + end + + test "segment checks validate nesting and preserve boolean evidence" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "checks", + "segments" => %{}, + "features" => %{} + } + + definitions = %{ + "adult" => %{ + "conditions" => %{ + "attribute" => "age", + "operator" => "greaterThanOrEquals", + "value" => 18 + } + }, + "nl" => %{ + "conditions" => %{"attribute" => "country", "operator" => "equals", "value" => "nl"} + }, + "vip" => %{ + "conditions" => %{"attribute" => "plan", "operator" => "equals", "value" => "vip"} + } + } + + {datafile, keys} = Segments.prepare(datafile, definitions) + context = %{"age" => 21, "country" => "nl", "plan" => "free"} + instance = Featurevisor.create_featurevisor(%{datafile: datafile, context: context}) + segment_checks = %{keys: keys, definitions: definitions} + + try do + passing = + TestChecks.evaluate_result( + instance, + datafile, + [ + %{"segment" => "adult", "matches" => true}, + %{"segment" => "vip", "matches" => false}, + %{ + "and" => [ + %{"segment" => "adult", "matches" => true}, + %{ + "or" => [ + %{"segment" => "vip", "matches" => true}, + %{"not" => [%{"segment" => "nl", "matches" => false}]} + ] + } + ] + } + ], + nil, + make_ref(), + "checks", + segment_checks + ) + + assert passing.passed + + failing = + TestChecks.evaluate_result( + instance, + datafile, + [%{"key" => "adult-check", "segment" => "adult", "matches" => false}], + nil, + make_ref(), + "checks", + segment_checks + ) + + refute failing.passed + refute failing.error + assert hd(failing.failures) =~ ~s(segment "adult") + evidence = hd(hd(failing.report_failures).evaluated) + assert evidence["reference"] == %{"segment" => "adult"} + assert evidence["property"] == "matches" + assert evidence["operator"] == "equals" + assert evidence["expected"] == false + assert evidence["actual"] == true + + missing = + TestChecks.evaluate_result( + instance, + datafile, + [ + %{ + "or" => [ + %{"segment" => "adult", "matches" => true}, + %{"segment" => "missing", "matches" => false} + ] + } + ], + nil, + make_ref(), + "checks", + segment_checks + ) + + assert missing.error + assert hd(missing.failures) =~ ~s(Segment "missing") + + for leaf <- [ + %{"segment" => "", "matches" => true}, + %{"segment" => "adult"}, + %{"segment" => "adult", "matches" => "true"}, + %{"matches" => false}, + %{"segment" => "adult", "matches" => true, "feature" => "checkout"}, + %{"segment" => "adult", "matches" => true, "operator" => "equals", "value" => true} + ] do + invalid = + TestChecks.evaluate_result( + instance, + datafile, + [ + %{ + "or" => [ + %{"segment" => "adult", "matches" => true}, + leaf + ] + } + ], + nil, + make_ref(), + "checks", + segment_checks + ) + + assert invalid.error + end + after + Featurevisor.close(instance) + end + end + + test "native CLI process runs scenario children and preserves filter and failure exit" do + directory = + Path.join( + System.tmp_dir!(), + "featurevisor-elixir-cli-#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(directory) + npx = Path.join(directory, "npx") + log = Path.join(directory, "calls.log") + + datafile = %{ + "schemaVersion" => "2", + "revision" => "runner", + "segments" => %{}, + "features" => %{ + "checkout" => %{ + "bucketBy" => "userId", + "traffic" => [ + %{"key" => "all", "segments" => "*", "percentage" => 100_000, "enabled" => true} + ] + } + }, + "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", + "_segments" => %{ + "root-context" => %{ + "conditions" => %{ + "attribute" => "custom", + "operator" => "equals", + "value" => "literal custom value" + } + }, + "merged-child" => %{ + "conditions" => %{ + "and" => [ + %{ + "attribute" => "custom", + "operator" => "equals", + "value" => "literal custom value" + }, + %{"attribute" => "mode", "operator" => "equals", "value" => "child"} + ] + } + } + }, + "_test" => %{ + "source" => "sets/staging/tests/scenarios/context.spec.yml", + "set" => "staging", + "entityType" => "scenario" + }, + "assertions" => [ + %{ + "description" => "matrix expanded scenario", + "context" => %{ + "custom" => "literal custom value", + "checks" => "literal check value" + }, + "_case" => %{ + "id" => "case-context", + "assertionIndex" => 0, + "matrixIndex" => 0, + "matrixValues" => %{"mode" => "parent"} + }, + "checks" => [ + %{ + "key" => "parent-message", + "description" => "Parent message is selected", + "variable" => "message", + "operator" => "equals", + "value" => "parent" + }, + %{"key" => "checkout-ready", "feature" => "checkout", "enabled" => true}, + %{"key" => "root-segment", "segment" => "root-context", "matches" => true} + ], + "children" => [ + %{ + "context" => %{"mode" => "child"}, + "checks" => [ + %{"variable" => "message", "operator" => "equals", "value" => "child"}, + %{"segment" => "merged-child", "matches" => true} + ] + } + ] + } + ] + } + ] + + failing = [ + %{ + "key" => "scenarios/failure.spec", + "scenario" => "failure", + "_segments" => %{ + "root-context" => %{ + "conditions" => %{ + "attribute" => "custom", + "operator" => "equals", + "value" => "literal custom value" + } + } + }, + "_test" => %{ + "source" => "sets/staging/tests/scenarios/failure.spec.yml", + "set" => "staging", + "entityType" => "scenario" + }, + "assertions" => [ + %{ + "description" => "failing scenario Café 東京 🧪", + "context" => %{"custom" => "literal custom value", "caption" => "Café 東京 🧪"}, + "_case" => %{ + "id" => "case-failure", + "assertionIndex" => 0, + "matrixIndex" => nil, + "matrixValues" => %{} + }, + "checks" => [ + %{ + "key" => "wrong-segment", + "description" => "Reports segment evidence Café 東京 🧪", + "segment" => "root-context", + "matches" => false + } + ] + } + ] + } + ] + + malformed = [ + %{ + "key" => "scenarios/hybrid.spec", + "scenario" => "hybrid", + "variable" => "message", + "_test" => %{ + "source" => "sets/staging/tests/scenarios/hybrid.spec.yml", + "set" => "staging", + "entityType" => "scenario" + }, + "assertions" => [ + %{ + "description" => "hybrid", + "_case" => %{ + "id" => "case-hybrid", + "assertionIndex" => 0, + "matrixIndex" => nil, + "matrixValues" => %{} + }, + "checks" => [%{"variable" => "message", "operator" => "exists"}] + } + ] + } + ] + + variable_checks = [ + %{ + "key" => "variables.message.spec", + "variable" => "message", + "_segments" => hd(passing)["_segments"], + "_test" => %{ + "source" => "sets/staging/tests/message.yml", + "set" => "staging", + "entityType" => "variable" + }, + "assertions" => [ + %{ + "target" => "checkout", + "context" => %{"custom" => "literal custom value"}, + "_case" => %{ + "id" => "case-variable-segments", + "assertionIndex" => 0, + "matrixIndex" => nil, + "matrixValues" => %{} + }, + "checks" => [%{"segment" => "root-context", "matches" => true}], + "children" => [ + %{ + "context" => %{"mode" => "child"}, + "checks" => [%{"segment" => "merged-child", "matches" => true}] + } + ] + } + ] + } + ] + + preflight = [ + %{ + "key" => "scenarios/preflight.spec", + "scenario" => "preflight", + "_segments" => %{}, + "_test" => %{ + "source" => "sets/staging/tests/scenarios/preflight.spec.yml", + "set" => "staging", + "entityType" => "scenario" + }, + "assertions" => [ + %{ + "description" => "valid root invalid child without target", + "_case" => %{ + "id" => "preflight-child", + "assertionIndex" => 0, + "matrixIndex" => nil, + "matrixValues" => %{} + }, + "checks" => [%{"variable" => "message", "operator" => "exists"}], + "children" => [ + %{"checks" => [%{"feature" => "missing", "enabled" => false}]} + ] + }, + %{ + "description" => "malformed children", + "_case" => %{ + "id" => "preflight-malformed-child", + "assertionIndex" => 1, + "matrixIndex" => nil, + "matrixValues" => %{} + }, + "checks" => [%{"variable" => "message", "operator" => "exists"}], + "children" => "malformed" + }, + %{ + "description" => "skipped branch reference", + "_case" => %{ + "id" => "preflight-skipped", + "assertionIndex" => 2, + "matrixIndex" => nil, + "matrixValues" => %{} + }, + "checks" => [ + %{ + "or" => [ + %{"variable" => "message", "operator" => "exists"}, + %{"feature" => "missing", "enabled" => false} + ] + } + ] + } + ] + } + ] + + variable_preflight = [ + %{ + "key" => "message", + "variable" => "message", + "_segments" => %{}, + "_test" => %{ + "source" => "sets/staging/tests/message.yml", + "set" => "staging", + "entityType" => "variable" + }, + "assertions" => [ + %{ + "description" => "owner value waits for child preflight", + "expectedValue" => 5, + "_case" => %{ + "id" => "preflight-owned-variable", + "assertionIndex" => 0, + "matrixIndex" => nil, + "matrixValues" => %{} + }, + "children" => [ + %{"checks" => [%{"feature" => "missing", "enabled" => false}]} + ] + } + ] + } + ] + + File.write!( + npx, + """ + #!/usr/bin/env python3 + import json, os, sys + with open(os.environ["FAKE_NPX_LOG"], "a", encoding="utf-8") as log: + log.write(" ".join(sys.argv[1:]) + "\\n") + args = sys.argv[2:] + if args == ["config", "--json"]: value = {"environments": False} + elif args[:2] == ["list", "--targets"]: value = [{"key": "checkout"}] + elif args[:2] == ["list", "--segments"]: value = [] + elif args[:3] == ["list", "--features", "--without-tests"]: value = [{"key": "unused"}] + elif args[:3] == ["list", "--variables", "--without-tests"]: value = [{"key": "unusedValue"}] + elif args[:2] == ["list", "--tests"]: + if os.environ["FAKE_MODE"] == "protocol": value = [] + else: + fixture = "FAKE_VARIABLE_PREFLIGHT" if os.environ["FAKE_MODE"] == "variable-preflight" else ("FAKE_VARIABLE" if os.environ["FAKE_MODE"] == "variable" else ("FAKE_PREFLIGHT" if os.environ["FAKE_MODE"] == "preflight" else ("FAKE_PASSING" if os.environ["FAKE_MODE"] == "pass" else ("FAKE_MALFORMED" if os.environ["FAKE_MODE"] == "malformed" else "FAKE_FAILING")))) + value = {"kind": "featurevisor.test-execution", "schemaVersion": 1, "tests": json.loads(os.environ[fixture])} + elif args and args[0] == "build": value = json.loads(os.environ["FAKE_DATAFILE"]) + else: raise SystemExit("unexpected arguments: " + repr(args)) + print(json.dumps(value)) + """ + ) + + File.chmod!(npx, 0o755) + + base_env = [ + {"MIX_ENV", "test"}, + {"PATH", directory <> ":" <> System.get_env("PATH")}, + {"FAKE_NPX_LOG", log}, + {"FAKE_DATAFILE", Jason.encode!(datafile)}, + {"FAKE_PASSING", Jason.encode!(passing)}, + {"FAKE_FAILING", Jason.encode!(failing)}, + {"FAKE_MALFORMED", Jason.encode!(malformed)}, + {"FAKE_VARIABLE", Jason.encode!(variable_checks)}, + {"FAKE_PREFLIGHT", Jason.encode!(preflight)}, + {"FAKE_VARIABLE_PREFLIGHT", Jason.encode!(variable_preflight)} + ] + + try do + {output, status} = run_cli_process(directory, [{"FAKE_MODE", "pass"} | base_env]) + assert status == 0, output + assert output =~ "Total cases: 1 passed, 0 failed" + + assert File.read!(log) =~ "--entityType=scenario" + assert File.read!(log) =~ "--set=staging" + + {output, status} = + run_cli_process(directory, [{"FAKE_MODE", "variable"} | base_env], [], "variable") + + assert status == 0, output + assert output =~ "Total cases: 1 passed, 0 failed" + + {output, status} = + run_cli_process( + directory, + [{"FAKE_MODE", "variable"} | base_env], + ["--keyPattern=^message$", "--json"], + "variable" + ) + + assert status == 0, output + report = Jason.decode!(output) + assert get_in(report, ["summary", "specs", "passed"]) == 1 + assert hd(report["results"])["key"] == "message" + + {output, status} = run_cli_process(directory, [{"FAKE_MODE", "fail"} | base_env]) + assert status == 1, output + assert output =~ "check 1" + assert output =~ ~s(segment "root-context") + assert output =~ "wrong-segment" + assert output =~ " check: " + + assert output =~ + "Rerun (POSIX shell): ./featurevisor test --case=case-failure --projectDirectoryPath=#{directory} --set=staging" + + {output, status} = + run_cli_process( + directory, + [{"FAKE_MODE", "fail"} | base_env], + ["--json"] + ) + + assert status == 1, output + failed_report = output |> String.split("\n", trim: true) |> hd() |> Jason.decode!() + failed_case = get_in(failed_report, ["results", Access.at(0), "cases", Access.at(0)]) + failed_error = get_in(failed_case, ["errors", Access.at(0)]) + assert failed_error["path"] == ["checks", 0] + assert get_in(failed_error, ["details", "evaluated", Access.at(0), "path"]) == ["checks", 0] + assert get_in(failed_error, ["details", "checkKey"]) == "wrong-segment" + evidence = get_in(failed_error, ["details", "evaluated", Access.at(0)]) + assert evidence["reference"] == %{"segment" => "root-context"} + assert evidence["property"] == "matches" + assert evidence["operator"] == "equals" + assert evidence["expected"] == false + assert evidence["actual"] == true + + {c_locale_output, c_locale_status} = + run_cli_process( + directory, + [{"LC_ALL", "C"}, {"LC_CTYPE", "C"}, {"LANG", "C"}, {"FAKE_MODE", "fail"} | base_env], + ["--json"] + ) + + assert c_locale_status == 1, c_locale_output + + c_locale_report = + c_locale_output |> String.split("\n", trim: true) |> hd() |> Jason.decode!() + + assert c_locale_report == failed_report + + assert get_in(c_locale_report, [ + "results", + Access.at(0), + "cases", + Access.at(0), + "context", + "caption" + ]) == + "Café 東京 🧪" + + assert c_locale_output =~ "\\u00E9" + refute c_locale_output =~ "�" + + {human_output, human_status} = + run_cli_process( + directory, + [{"LC_ALL", "C"}, {"LC_CTYPE", "C"}, {"LANG", "C"}, {"FAKE_MODE", "fail"} | base_env] + ) + + assert human_status == 1, human_output + assert human_output =~ "failing scenario Café 東京 🧪" + assert human_output =~ "Reports segment evidence Café 東京 🧪" + refute human_output =~ "�" + + {output, status} = run_cli_process(directory, [{"FAKE_MODE", "malformed"} | base_env]) + assert status == 1, output + assert output =~ "requires exactly one of feature, variable, segment or scenario" + + File.write!(log, "") + + {output, status} = + run_cli_process( + directory, + [{"FAKE_MODE", "protocol"} | base_env], + ["--json", "--coverage"] + ) + + assert status == 1, output + assert output =~ "Invalid test execution protocol envelope" + refute File.read!(log) =~ "build" + + {output, status} = + run_cli_process( + directory, + [{"FAKE_MODE", "preflight"} | base_env], + ["--json", "--coverage"] + ) + + assert status == 1, output + preflight_report = output |> String.split("\n", trim: true) |> hd() |> Jason.decode!() + assert preflight_report["coverage"]["selections"] == [] + assert get_in(preflight_report, ["summary", "scenarioCases", "failed"]) == 3 + + {output, status} = + run_cli_process( + directory, + [{"FAKE_MODE", "variable-preflight"} | base_env], + ["--json", "--coverage"], + "variable" + ) + + assert status == 1, output + variable_report = output |> String.split("\n", trim: true) |> hd() |> Jason.decode!() + assert variable_report["coverage"]["selections"] == [] + assert get_in(variable_report, ["summary", "entityAssertions", "failed"]) == 1 + + {output, status} = + run_cli_process( + directory, + [{"FAKE_MODE", "pass"} | base_env], + [ + "--case=case-context", + "--json", + "--pretty", + "--coverage", + "--uncovered" + ] + ) + + assert status == 0, output + report = Jason.decode!(output) + assert report["schemaVersion"] == 1 + assert report["runner"] == "elixir" + assert report["summary"]["scenarioCases"] == %{"passed" => 1, "failed" => 0} + + assert get_in(report, ["results", Access.at(0), "cases", Access.at(0), "context"]) == %{ + "custom" => "literal custom value", + "checks" => "literal check value" + } + + assert get_in(report, ["results", Access.at(0), "cases", Access.at(0), "id"]) == + "case-context" + + assert "--projectDirectoryPath=#{directory}" in get_in(report, [ + "results", + Access.at(0), + "cases", + Access.at(0), + "rerun", + "args" + ]) + + assert report["uncovered"] == [ + %{"set" => "staging", "features" => ["unused"], "variables" => ["unusedValue"]} + ] + + assert Enum.any?(report["coverage"]["selections"], fn selection -> + selection["featureKey"] == "checkout" and selection["ruleKey"] == "all" + end) + + calls = File.read!(log) + assert calls =~ "--execution-protocol" + assert calls =~ "--execution-metadata" + assert calls =~ "--case=case-context" + after + File.rm_rf!(directory) + end + end + + defp run_cli_process(project, env, extra_args \\ [], entity_type \\ "scenario") do + System.cmd( + "mix", + [ + "run", + "--no-compile", + "--no-deps-check", + "-e", + "Featurevisor.CLI.main(System.argv())", + "--", + "test", + "--projectDirectoryPath=#{project}", + "--set=staging", + "--entityType=#{entity_type}", + "--onlyFailures" + | extra_args + ], + cd: Path.expand("..", __DIR__), + env: env, + stderr_to_stdout: true + ) + end end diff --git a/test/conditions_test.exs b/test/conditions_test.exs index d1bf9d2..71f15e9 100644 --- a/test/conditions_test.exs +++ b/test/conditions_test.exs @@ -7,6 +7,44 @@ defmodule Featurevisor.ConditionsTest do %{cache: cache, report: fn _ -> :ok end} end + test "host dates floor pre epoch milliseconds", %{cache: cache} do + {:ok, host, 0} = DateTime.from_iso8601("1969-12-31T23:59:59.999500Z") + + refute Conditions.value_matches?( + host, + %{"operator" => "before", "value" => "1969-12-31T23:59:59.999Z"}, + cache + ) + + assert Conditions.value_matches?( + host, + %{"operator" => "before", "value" => "1970-01-01T00:00:00Z"}, + cache + ) + end + + test "string dates truncate one to nine fractional digits", %{cache: cache} do + condition = %{"operator" => "before", "value" => "2024-01-01T00:00:00.124Z"} + + for digits <- ["1239", "123999", "1239999", "123999999"] do + assert Conditions.value_matches?("2024-01-01T00:00:00.#{digits}Z", condition, cache) + end + + refute Conditions.value_matches?("2024-01-01T00:00:00.1239999999Z", condition, cache) + + refute Conditions.value_matches?( + "1969-12-31T23:59:59.999999999Z", + %{"operator" => "before", "value" => "1969-12-31T23:59:59.999Z"}, + cache + ) + + assert Conditions.value_matches?( + "1969-12-31T23:59:59.999999999Z", + %{"operator" => "before", "value" => "1970-01-01T00:00:00Z"}, + cache + ) + end + test "supports every condition group and implicit AND negation", %{cache: cache, report: report} do plain = %{"attribute" => "country", "operator" => "equals", "value" => "nl"} mobile = %{"attribute" => "device", "operator" => "equals", "value" => "mobile"} @@ -124,7 +162,7 @@ defmodule Featurevisor.ConditionsTest do assert Conditions.all_conditions?( %{ "attribute" => "version", - "operator" => "semverGreaterThan", + "operator" => "semverLessThan", "value" => "1.0.0-2beta" }, Map.put(context, "version", "1.0.0-10beta"), @@ -133,6 +171,73 @@ defmodule Featurevisor.ConditionsTest do ) end + test "matches empty and notEmpty only for supported shallow values", %{ + cache: cache, + report: report + } do + context = %{ + "emptyString" => "", + "space" => " ", + "emptyArray" => [], + "arrayWithNull" => [nil], + "emptyObject" => %{}, + "objectWithNull" => %{"value" => nil}, + "nested" => %{"items" => []}, + "null" => nil, + "number" => 0, + "host" => ~D[2026-09-18], + "hostMap" => %{value: 1} + } + + for {attribute, operator} <- [ + {"emptyString", "empty"}, + {"emptyArray", "empty"}, + {"emptyObject", "empty"}, + {"nested.items", "empty"}, + {"space", "notEmpty"}, + {"arrayWithNull", "notEmpty"}, + {"objectWithNull", "notEmpty"} + ] do + assert Conditions.all_conditions?( + %{"attribute" => attribute, "operator" => operator}, + context, + cache, + report + ) + end + + for attribute <- ["missing", "null", "number", "host", "hostMap"], + operator <- ["empty", "notEmpty"] do + refute Conditions.all_conditions?( + %{"attribute" => attribute, "operator" => operator}, + context, + cache, + report + ) + end + + refute Conditions.all_conditions?( + %{"attribute" => "emptyString", "operator" => "notEmpty"}, + context, + cache, + report + ) + + refute Conditions.all_conditions?( + %{"attribute" => "emptyArray", "operator" => "notEmpty"}, + context, + cache, + report + ) + + refute Conditions.all_conditions?( + %{"attribute" => "emptyObject", "operator" => "notEmpty"}, + context, + cache, + report + ) + end + test "supports stringified wildcard segments", %{cache: cache, report: report} do segments = %{"everyone" => %{"conditions" => "*"}} assert Conditions.all_segments?("everyone", %{}, segments, cache, report) diff --git a/test/conformance_test.exs b/test/conformance_test.exs index cf0d803..7fb363a 100644 --- a/test/conformance_test.exs +++ b/test/conformance_test.exs @@ -5,7 +5,7 @@ defmodule Featurevisor.ConformanceTest do @fixture Path.expand("../conformance/sdk-v3.json", __DIR__) |> File.read!() |> Jason.decode!() test "executes the canonical fixture version and bucket boundaries" do - assert @fixture["version"] == 6 + assert @fixture["version"] == 15 assert is_binary(@fixture["description"]) assert Enum.sort(Map.keys(@fixture)) == @@ -18,6 +18,9 @@ defmodule Featurevisor.ConformanceTest do "datafile", "globalVariables", "requiredFeatures", + "requiredFeatureGroups", + "requiredFeatureGroupInputs", + "requiredVariables", "diagnostics", "numericBucketKeys", "portableConditions", @@ -109,12 +112,55 @@ defmodule Featurevisor.ConformanceTest do Featurevisor.close(f) end + test "verifies every canonical prerequisite evaluation detail" do + %{"requiredFeatures" => %{"evaluationCases" => [_ | _] = cases} = required} = @fixture + assert length(cases) == 9 + + Enum.each(cases, fn item -> + f = Featurevisor.create_featurevisor(%{datafile: required["datafile"], log_level: :fatal}) + on_exit(fn -> Featurevisor.close(f) end) + + for _ <- 1..2 do + context = Map.get(item, "context", %{}) + + evaluation = + case Map.fetch!(item, "type") do + "flag" -> + Featurevisor.evaluate_flag(f, Map.fetch!(item, "feature"), context) + + "variation" -> + Featurevisor.evaluate_variation(f, Map.fetch!(item, "feature"), context) + + "variable" -> + if Map.has_key?(item, "feature") do + Featurevisor.evaluate_variable( + f, + Map.fetch!(item, "feature"), + Map.fetch!(item, "variable"), + context + ) + else + Featurevisor.evaluate_global_variable(f, Map.fetch!(item, "variable"), context) + end + end + + actual = + evaluation |> Featurevisor.Evaluation.to_map() |> Jason.encode!() |> Jason.decode!() + + Enum.each(Map.fetch!(item, "expected"), fn {field, expected} -> + assert Map.has_key?(actual, field), item["name"] + assert actual[field] == expected, "#{item["name"]}: #{field}" + end) + end + end) + end + test "datafile events include global variables and dependency changes" do item = @fixture["globalVariables"]["dependencyUpdateCase"] parent = self() f = Featurevisor.create_featurevisor(%{datafile: item["initial"], log_level: :fatal}) Featurevisor.on(f, :datafile_set, &send(parent, {:datafile, &1})) - assert :ok = Featurevisor.set_datafile(f, item["updated"], true) + assert true == Featurevisor.set_datafile(f, item["updated"], true) assert_receive {:datafile, details} assert Enum.sort(details.features) == Enum.sort(item["expectedChangedFeatures"]) assert Enum.sort(details.variables) == Enum.sort(item["expectedChangedVariables"]) @@ -176,6 +222,13 @@ defmodule Featurevisor.ConformanceTest do cache = :ets.new(:condition_cases, [:set, :public, write_concurrency: true]) report = fn _ -> :ok end + assert length(@fixture["conditionCases"]) == 189 + + assert Enum.count( + @fixture["conditionCases"], + &String.contains?(&1["name"], ["includesAny", "IncludesAny"]) + ) == 73 + Enum.each(@fixture["conditionCases"], fn item -> assert Conditions.all_conditions?(item["condition"], item["context"], cache, report) == item["expected"], @@ -325,9 +378,14 @@ defmodule Featurevisor.ConformanceTest do value = case item["type"] do - "integer" -> Featurevisor.get_variable_integer(f, "feature", "value") - "double" -> Featurevisor.get_variable_double(f, "feature", "value") - "boolean" -> Featurevisor.get_variable_boolean(f, "feature", "value") + "integer" -> + Featurevisor.get_variable_integer(f, "feature", "value") + + type when type in ["number", "double"] -> + Featurevisor.get_variable_number(f, "feature", "value") + + "boolean" -> + Featurevisor.get_variable_boolean(f, "feature", "value") end assert not is_nil(value) == item["valid"] diff --git a/test/dependency_getter_contract_test.exs b/test/dependency_getter_contract_test.exs new file mode 100644 index 0000000..54ccb4f --- /dev/null +++ b/test/dependency_getter_contract_test.exs @@ -0,0 +1,106 @@ +defmodule Featurevisor.DependencyGetterContractTest do + use ExUnit.Case, async: true + + defp feature do + %{ + "bucketBy" => "id", + "traffic" => [%{"key" => "all", "conditions" => "*", "percentage" => 100_000}] + } + end + + defp datafile(features, variables \\ %{}) do + %{ + "schemaVersion" => "4", + "revision" => "getters", + "segments" => %{}, + "features" => features, + "variables" => variables + } + end + + test "internal flag gating preserves the outer variable and variation requester" do + for kind <- [:variable, :variation] do + parent = self() + + subject = + feature() + |> Map.merge(%{ + "conditions" => %{"feature" => "gate"}, + "variations" => [%{"value" => "on"}], + "variablesSchema" => %{"value" => %{"type" => "string", "defaultValue" => "hello"}} + }) + |> put_in(["traffic", Access.at(0), "variation"], "on") + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile(%{"gate" => feature(), "subject" => subject}), + log_level: :fatal, + modules: [ + %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:feature_key] == "gate" and options[:dependency_of], + do: send(parent, {:requester, options.dependency_of}) + + options + end + } + ] + }) + + try do + if kind == :variable, + do: Featurevisor.get_variable(f, "subject", "value"), + else: Featurevisor.get_variation(f, "subject") + + expected = %{type: kind, feature_key: "subject"} + + expected = + if kind == :variable, do: Map.put(expected, :variable_key, "value"), else: expected + + assert_receive {:requester, ^expected} + refute_receive {:requester, _} + + wire = + Featurevisor.evaluate_variable(f, "subject", "value") + |> Featurevisor.Evaluation.to_map() + + refute Map.has_key?(wire, "__getter_value_normalized") + + assert_receive {:requester, + %{type: :variable, feature_key: "subject", variable_key: "value"}} + + refute_receive {:requester, _} + after + Featurevisor.close(f) + end + end + end + + test "condition memo keeps the normalized JSON failure once per public call" do + parent = self() + leaf = %{"variable" => "source", "operator" => "isNull"} + subject = Map.put(feature(), "conditions", [leaf, leaf]) + + f = + Featurevisor.create_featurevisor(%{ + datafile: + datafile(%{"subject" => subject}, %{ + "source" => %{"type" => "json", "defaultValue" => "{oops"} + }), + log_level: :error, + on_diagnostic: fn diagnostic -> + if diagnostic.message == "getVariable failed", do: send(parent, :json_failure) + end + }) + + try do + for _ <- 1..2 do + assert Featurevisor.enabled?(f, "subject") + assert_receive :json_failure + refute_receive :json_failure + end + after + Featurevisor.close(f) + end + end +end diff --git a/test/deprecation_diagnostics_test.exs b/test/deprecation_diagnostics_test.exs new file mode 100644 index 0000000..90ef73a --- /dev/null +++ b/test/deprecation_diagnostics_test.exs @@ -0,0 +1,57 @@ +defmodule Featurevisor.DeprecationDiagnosticsTest do + use ExUnit.Case, async: true + + @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} + } + } + + test "deprecated features and variables use one code per kind" do + parent = self() + + f = + Featurevisor.create_featurevisor(%{ + datafile: @datafile, + log_level: :warn, + on_diagnostic: fn diagnostic -> + if String.contains?(diagnostic.code, "deprecated"), + do: send(parent, {:deprecated, diagnostic.code}) + end + }) + + on_exit(fn -> Featurevisor.close(f) end) + + Featurevisor.enabled?(f, "legacy") + assert MapSet.new(drain()) == MapSet.new(["deprecated_feature"]) + + Featurevisor.get_variable(f, "legacy", "old") + assert MapSet.new(drain()) == MapSet.new(["deprecated_feature", "deprecated_variable"]) + + Featurevisor.get_global_variable(f, "oldGlobal") + assert drain() == ["deprecated_variable"] + end + + defp drain(codes \\ []) do + receive do + {:deprecated, code} -> drain([code | codes]) + after + 0 -> Enum.reverse(codes) + end + end +end diff --git a/test/empty_modules_hot_path_test.exs b/test/empty_modules_hot_path_test.exs new file mode 100644 index 0000000..1fd019e --- /dev/null +++ b/test/empty_modules_hot_path_test.exs @@ -0,0 +1,165 @@ +defmodule Featurevisor.EmptyModulesHotPathTest do + use ExUnit.Case, async: false + + test "an ordinary warm read does not rebuild callback options without modules" do + sdk = + Featurevisor.create_featurevisor(%{ + log_level: :fatal, + datafile: %{ + "schemaVersion" => "4", + "revision" => "hot", + "segments" => %{}, + "features" => %{"flag" => %{"bucketBy" => "id", "traffic" => []}}, + "variables" => %{"object" => %{"type" => "object", "defaultValue" => %{"n" => 1}}} + } + }) + + on_exit(fn -> Featurevisor.close(sdk) end) + Featurevisor.enabled?(sdk, "flag") + Featurevisor.get_global_variable(sdk, "object") + + targets = [ + {Featurevisor.Evaluator, :before_modules, 1}, + {Featurevisor, :before_global_modules, 4}, + {Featurevisor.EvaluationSession, :update_current, 2}, + {Featurevisor, :global_dependencies, 4} + ] + + for target <- targets, do: assert(:erlang.trace_pattern(target, true, [:call_count]) == 1) + + try do + Featurevisor.enabled?(sdk, "flag") + assert Featurevisor.get_global_variable(sdk, "object") == %{"n" => 1} + + for target <- targets, + do: assert(:erlang.trace_info(target, :call_count) == {:call_count, 0}) + after + for target <- targets, do: :erlang.trace_pattern(target, false, [:call_count]) + end + end + + test "global reads without modules retain current parent and child sticky values and defaults" do + sdk = create_global_sdk() + child = Featurevisor.spawn(sdk) + on_exit(fn -> Featurevisor.close(sdk) end) + + assert Featurevisor.get_global_variable(sdk, "alias") == %{"n" => 1} + assert Featurevisor.Child.get_global_variable(child, "alias") == %{"n" => 1} + + Featurevisor.set_sticky_variables(sdk, %{"object" => %{"n" => 2}}) + Featurevisor.Child.set_sticky_variables(child, %{"object" => %{"n" => 3}}) + assert Featurevisor.get_global_variable(sdk, "alias") == %{"n" => 2} + assert Featurevisor.Child.get_global_variable(child, "alias") == %{"n" => 3} + + Featurevisor.set_sticky_variables(sdk, %{}, true) + Featurevisor.Child.set_sticky_variables(child, %{}, true) + assert Featurevisor.get_global_variable(sdk, "alias") == %{"n" => 1} + assert Featurevisor.Child.get_global_variable(child, "alias") == %{"n" => 1} + + assert Featurevisor.get_global_variable(sdk, "missing", %{}, default_variable_value: 7) == 7 + assert Featurevisor.get_global_variable(sdk, "gated") == 0 + Featurevisor.set_sticky_features(sdk, %{"flag" => %{"enabled" => true}}) + assert Featurevisor.get_global_variable(sdk, "gated") == 1 + end + + test "global modules retain private boundaries, redirected identity and sticky replacements" do + sdk = create_global_sdk() + on_exit(fn -> Featurevisor.close(sdk) end) + parent = self() + + module = %Featurevisor.Module{ + name: "global-boundary", + before_evaluation: fn options -> + send(parent, {:before_global, options}) + + if options[:variable_key] == "redirect" do + options + |> Map.put(:variable_key, "gated_alias") + |> Map.put(:sticky_features, %{"flag" => %{"enabled" => true}}) + |> Map.put(:sticky_variables, %{"object" => %{"n" => 9}}) + else + options + end + end, + after_evaluation: fn evaluation, options -> + send(parent, {:after_global, options}) + evaluation + end + } + + remove = Featurevisor.add_module(sdk, module) + target = {Featurevisor, :before_global_modules, 4} + assert :erlang.trace_pattern(target, true, [:call_count]) == 1 + + try do + assert Featurevisor.get_global_variable(sdk, "redirect") == %{"n" => 9} + assert :erlang.trace_info(target, :call_count) == {:call_count, 2} + + for phase <- [:before_global, :after_global] do + for _ <- 1..3 do + assert_receive {^phase, options} + + refute Enum.any?(Map.keys(options), fn key -> + key in [ + :sticky_owner, + :sticky_features_token, + :sticky_variables_token, + :regex_cache, + :state_signature, + :child_signature, + :session, + :state_source, + :entity_source, + :sticky_features_source, + :sticky_variables_source + ] or String.starts_with?(Atom.to_string(key), "__") + end) + + assert is_map(options.sticky_features) + assert is_map(options.sticky_variables) + if is_nil(options[:feature_key]), do: refute(Map.has_key?(options, :report)) + + if options[:feature_key] == "flag" or options[:variable_key] == "object" do + assert options.dependency_of == %{type: :variable, variable_key: "gated_alias"} + end + end + end + + remove.() + assert Featurevisor.get_global_variable(sdk, "gated_alias") == nil + assert Featurevisor.get_global_variable(sdk, "alias") == %{"n" => 1} + assert :erlang.trace_info(target, :call_count) == {:call_count, 2} + after + :erlang.trace_pattern(target, false, [:call_count]) + end + end + + defp create_global_sdk do + Featurevisor.create_featurevisor(%{ + log_level: :fatal, + sticky_features: %{}, + sticky_variables: %{}, + datafile: %{ + "schemaVersion" => "4", + "revision" => "global-hot", + "segments" => %{}, + "features" => %{"flag" => %{"bucketBy" => "id", "traffic" => []}}, + "variables" => %{ + "object" => %{"type" => "object", "defaultValue" => %{"n" => 1}}, + "alias" => %{"type" => "object", "compose" => "object"}, + "gated_alias" => %{ + "type" => "object", + "compose" => "object", + "conditions" => %{"feature" => "flag", "enabled" => true} + }, + "gated" => %{ + "type" => "integer", + "defaultValue" => 1, + "disabledValue" => 0, + "conditions" => %{"feature" => "flag", "enabled" => true} + } + } + } + }) + end +end diff --git a/test/evaluation_session_test.exs b/test/evaluation_session_test.exs new file mode 100644 index 0000000..1fffebf --- /dev/null +++ b/test/evaluation_session_test.exs @@ -0,0 +1,974 @@ +defmodule Featurevisor.EvaluationSessionTest do + use ExUnit.Case, async: true + + defp requirement(key), do: %{"variable" => key, "operator" => "equals", "value" => 1} + + defp variable(requirements \\ nil) do + %{ + "type" => "integer", + "defaultValue" => 1, + "disabledValue" => 0, + "conditions" => requirements || "*" + } + end + + defp feature(requirements \\ nil) do + %{ + "bucketBy" => "userId", + "requiredFeatures" => requirements, + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + } + end + + test "completed diamonds are memoised per public read with immediate requester metadata" do + {variables, previous} = + Enum.reduce(0..11, {%{"leaf" => variable()}, "leaf"}, fn index, {variables, previous} -> + key = "level#{index}" + {Map.put(variables, key, variable([requirement(previous), requirement(previous)])), key} + end) + + parent = self() + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + send(parent, {:before, options[:variable_key], options[:dependency_of]}) + Map.new(options) + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "session", + "segments" => %{}, + "features" => %{}, + "variables" => variables + }, + modules: [module], + log_level: :fatal + }) + + assert Featurevisor.get_global_variable(f, previous) == 1 + seen = for _ <- 1..13, do: assert_receive({:before, _, _}) + assert hd(seen) == {:before, previous, nil} + assert Enum.at(seen, 1) == {:before, "level10", %{type: :variable, variable_key: previous}} + refute_receive {:before, _, _} + + assert Featurevisor.get_global_variable(f, previous) == 1 + for _ <- 1..13, do: assert_receive({:before, _, _}) + refute_receive {:before, _, _} + Featurevisor.close(f) + end + + test "a root sticky replacement does not disable memo reuse in its dependency diamond" do + {variables, root_key} = + Enum.reduce(0..9, {%{"leaf" => variable()}, "leaf"}, fn index, {variables, previous} -> + key = "level#{index}" + {Map.put(variables, key, variable([requirement(previous), requirement(previous)])), key} + end) + + parent = self() + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:variable_key] == root_key do + Map.put(options, :sticky_variables, %{"leaf" => 1}) + else + send(parent, {:diamond_read, options[:variable_key]}) + options + end + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "replacement-diamond", + "segments" => %{}, + "features" => %{}, + "variables" => variables + }, + modules: [module], + log_level: :fatal + }) + + assert Featurevisor.get_global_variable(f, root_key) == 1 + for _ <- 1..10, do: assert_receive({:diamond_read, _}) + refute_receive {:diamond_read, _} + Featurevisor.close(f) + end + + test "replacement ownership flows to descendants and still memoises duplicate edges" do + parent = self() + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + cond do + options[:type] == :variable and options[:variable_key] == "root" -> + Map.put(options, :sticky_features, %{"gate" => %{"enabled" => true}}) + + options[:type] == :flag and options[:feature_key] == "gate" -> + send(parent, :replaced_gate_read) + options + + true -> + options + end + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "replacement-descendants", + "segments" => %{}, + "features" => %{"gate" => %{}}, + "variables" => %{ + "root" => variable() |> Map.put("requiredFeatures", ["gate", "gate"]) + } + }, + modules: [module], + log_level: :fatal + }) + + assert Featurevisor.get_global_variable(f, "root", %{"userId" => "test"}) == 1 + assert_receive :replaced_gate_read + refute_receive :replaced_gate_read + Featurevisor.close(f) + end + + test "a hook replacement with the same map size and a different leaf is not reused" do + count = :atomics.new(1, []) + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:type] == :flag and options[:feature_key] == "gate" do + visit = :atomics.add_get(count, 1, 1) + Map.put(options, :sticky_features, %{"gate" => %{"enabled" => visit == 1}}) + else + options + end + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "replacement-leaf", + "segments" => %{}, + "features" => %{"gate" => feature()}, + "variables" => %{ + "root" => variable() |> Map.put("requiredFeatures", ["gate", "gate"]) + } + }, + modules: [module], + log_level: :fatal + }) + + assert Featurevisor.get_global_variable(f, "root", %{"userId" => "test"}) == 0 + assert :atomics.get(count, 1) == 2 + Featurevisor.close(f) + end + + test "cycles surface as errors while missing operands keep ordinary fallback" do + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "cycle", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "root" => variable([requirement("root")]), + "outer" => variable([requirement("root")]), + "ordinary" => variable([requirement("missing")]) + } + }, + log_level: :fatal + }) + + assert Featurevisor.evaluate_global_variable(f, "root").error.message =~ "Circular" + assert Featurevisor.evaluate_global_variable(f, "outer").error.message =~ "Circular" + + assert Featurevisor.evaluate_global_variable(f, "ordinary").reason == + :conditions_unmet + + Featurevisor.close(f) + end + + test "requester dependent hooks do not share values across different parents" do + parent = self() + + module = %Featurevisor.Module{ + after_evaluation: fn evaluation, options -> + if options[:variable_key] == "leaf" do + requester = options[:dependency_of][:variable_key] + send(parent, {:leaf, requester}) + %{evaluation | variable_value: if(requester == "right", do: 2, else: 1)} + else + evaluation + end + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "parents", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "leaf" => variable(), + "left" => variable([requirement("leaf"), requirement("leaf")]), + "right" => variable([%{"variable" => "leaf", "operator" => "equals", "value" => 2}]), + "root" => variable([requirement("left"), requirement("right")]) + } + }, + modules: [module], + log_level: :fatal + }) + + assert Featurevisor.get_global_variable(f, "root") == 1 + assert_receive {:leaf, "left"} + assert_receive {:leaf, "right"} + refute_receive {:leaf, _} + Featurevisor.close(f) + end + + test "sticky changes inside a hook invalidate earlier prerequisite reads" do + parent = self() + + module = %Featurevisor.Module{ + after_evaluation: fn evaluation, _options -> + if evaluation.variable_key == "leaf", do: send(parent, :leaf_read) + if evaluation.variable_key == "mutator", do: send(parent, :mutate_sticky) + evaluation + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "state", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "leaf" => variable(), + "mutator" => variable(), + "root" => + variable([ + requirement("leaf"), + requirement("mutator"), + %{"variable" => "leaf", "operator" => "equals", "value" => 2} + ]) + } + }, + modules: [module], + log_level: :fatal + }) + + # The callback runs in the evaluating process. Apply the setter during the + # second prerequisite's after hook, without using a persistent session. + mutate = %Featurevisor.Module{ + after_evaluation: fn evaluation, _options -> + if evaluation.variable_key == "mutator", + do: Featurevisor.set_sticky_variables(f, %{"leaf" => 2}) + + evaluation + end + } + + Featurevisor.add_module(f, mutate) + assert Featurevisor.get_global_variable(f, "root") == 1 + assert_receive :leaf_read + assert_receive :mutate_sticky + assert_receive :leaf_read + refute_receive :leaf_read + Featurevisor.close(f) + end + + test "datafile replacement inside a hook invalidates an earlier prerequisite read" do + root = + variable([ + requirement("leaf"), + requirement("mutator"), + %{"variable" => "leaf", "operator" => "equals", "value" => 2} + ]) + + datafile = %{ + "schemaVersion" => "2", + "revision" => "first", + "segments" => %{}, + "features" => %{}, + "variables" => %{"leaf" => variable(), "mutator" => variable(), "root" => root} + } + + f = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + parent = self() + + module = %Featurevisor.Module{ + after_evaluation: fn evaluation, _options -> + if evaluation.variable_key == "leaf", do: send(parent, :leaf_read) + + if evaluation.variable_key == "mutator" do + next = + datafile + |> Map.put("revision", "second") + |> put_in(["variables", "leaf", "defaultValue"], 2) + + Featurevisor.set_datafile(f, next, true) + end + + evaluation + end + } + + Featurevisor.add_module(f, module) + assert Featurevisor.get_global_variable(f, "root") == 1 + assert_receive :leaf_read + assert_receive :leaf_read + refute_receive :leaf_read + Featurevisor.close(f) + end + + test "ordinary nil operands from failing modules are memoised" do + parent = self() + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:variable_key] == "broken" do + send(parent, :broken_read) + raise "ordinary module failure" + end + + options + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "errors", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "broken" => variable(), + "root" => + variable([ + %{"variable" => "broken", "operator" => "isNull"}, + %{"variable" => "broken", "operator" => "isNull"} + ]) + } + }, + modules: [module], + log_level: :fatal + }) + + assert Featurevisor.get_global_variable(f, "root") == 1 + assert_receive :broken_read + refute_receive :broken_read + Featurevisor.close(f) + end + + test "transformed identities participate in cycle detection" do + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:variable_key] in ["root", "alias"], + do: %{options | variable_key: "actual"}, + else: options + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "aliases", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "actual" => variable([requirement("alias")]), + "alias" => variable() + } + }, + modules: [module], + log_level: :fatal + }) + + assert Featurevisor.evaluate_global_variable(f, "root").error.message =~ "Circular" + Featurevisor.close(f) + end + + test "a redirected prerequisite may finish before a recursive entry is attempted" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "redirect", + "segments" => %{}, + "features" => %{"a" => feature(["b"]), "b" => feature()}, + "variables" => %{} + } + + build = fn sticky -> + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options.feature_key == "b" do + redirected = %{options | feature_key: "a"} + + if sticky, + do: Map.put(redirected, :sticky_features, %{"a" => %{"enabled" => true}}), + else: redirected + else + options + end + end + } + + Featurevisor.create_featurevisor(%{ + datafile: datafile, + modules: [module], + log_level: :fatal + }) + end + + safe = build.(true) + recursive = build.(false) + assert Featurevisor.evaluate_flag(safe, "a").enabled == true + assert Featurevisor.evaluate_flag(recursive, "a").error.message =~ "Circular" + Featurevisor.close(safe) + Featurevisor.close(recursive) + end + + test "module options omit private session and state plumbing" do + parent = self() + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + send(parent, {:options, options}) + Map.new(options) + end, + after_evaluation: fn evaluation, options -> + send(parent, {:options, options}) + evaluation + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "private", + "segments" => %{}, + "features" => %{"a" => feature(["b"]), "b" => feature()}, + "variables" => %{} + }, + modules: [module], + log_level: :fatal + }) + + assert Featurevisor.enabled?(f, "a") + + seen = + for _ <- 1..4, + do: + ( + assert_receive {:options, options} + options + ) + + assert Enum.any?(seen, & &1[:dependency_of]) + + Enum.each(seen, fn options -> + refute Map.has_key?(options, :session) + refute Map.has_key?(options, :state_source) + refute Map.has_key?(options, :state_signature) + refute Map.has_key?(options, :sticky_features_source) + refute Map.has_key?(options, :sticky_variables_source) + refute Map.has_key?(options, :required_variables_match) + end) + + Featurevisor.close(f) + end + + test "feature before hooks refresh inherited sticky but keep explicit replacement" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "sticky-before", + "segments" => %{}, + "features" => %{"a" => feature()}, + "variables" => %{} + } + + first = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + + Featurevisor.add_module(first, %Featurevisor.Module{ + before_evaluation: fn options -> + Featurevisor.set_sticky_features(first, %{"a" => %{"enabled" => false}}, true) + options + end + }) + + assert Featurevisor.evaluate_flag(first, "a").enabled == false + Featurevisor.close(first) + + second = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + + Featurevisor.add_module(second, %Featurevisor.Module{ + before_evaluation: fn options -> + Featurevisor.set_sticky_features(second, %{"a" => %{"enabled" => false}}, true) + Map.put(options, :sticky_features, %{"a" => %{"enabled" => true}}) + end + }) + + assert Featurevisor.evaluate_flag(second, "a").enabled == true + Featurevisor.close(second) + end + + test "public reentrant callback reads start independent sessions" do + parent = self() + {:ok, guard} = Agent.start_link(fn -> false end) + + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "reentrant", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "leaf" => variable(), + "root" => variable([requirement("leaf"), requirement("leaf")]) + } + }, + log_level: :fatal + }) + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:variable_key] == "root", + do: send(parent, {:root_marker, options[:dependency_of]}) + + if options[:variable_key] == "leaf" do + send(parent, :leaf_read) + first = Agent.get_and_update(guard, fn entered -> {not entered, true} end) + if first, do: assert(Featurevisor.get_global_variable(f, "root") == 1) + end + + options + end + } + + Featurevisor.add_module(f, module) + assert Featurevisor.get_global_variable(f, "root") == 1 + assert_receive {:root_marker, nil} + assert_receive {:root_marker, nil} + assert_receive :leaf_read + assert_receive :leaf_read + refute_receive :leaf_read + Featurevisor.close(f) + Agent.stop(guard) + end + + test "nested requirement path errors are not converted into nil operands" do + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "path", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "source" => %{"type" => "string", "defaultValue" => "abc"}, + "broken" => + variable([ + %{"variable" => "source", "path" => "uncompiled", "operator" => "isNull"} + ]), + "outer" => variable([%{"variable" => "broken", "operator" => "isNull"}]) + } + }, + log_level: :fatal + }) + + assert Featurevisor.evaluate_global_variable(f, "outer").reason == :error + assert Featurevisor.evaluate_global_variable(f, "outer").error.message =~ "path" + Featurevisor.close(f) + end + + test "failed reads are not remembered after a hook changes state" do + f = + Featurevisor.create_featurevisor(%{ + datafile: %{ + "schemaVersion" => "2", + "revision" => "failed-state", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "leaf" => variable(), + "root" => + variable([ + %{"variable" => "leaf", "operator" => "isNull"}, + %{"variable" => "leaf", "operator" => "isNull"} + ]) + } + }, + log_level: :fatal + }) + + parent = self() + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:variable_key] == "leaf" do + send(parent, :failed_read) + Featurevisor.set_sticky_variables(f, %{"unrelated" => System.unique_integer()}) + raise "ordinary failure" + end + + options + end + } + + Featurevisor.add_module(f, module) + assert Featurevisor.get_global_variable(f, "root") == 1 + assert_receive :failed_read + assert_receive :failed_read + Featurevisor.close(f) + end + + test "memo keys keep nested object, array and tag shaped contexts distinct" do + session = Featurevisor.EvaluationSession.new() + parent = self() + root = %{type: :variable, variable_key: "root", context: %{}} + + contexts = [ + %{"nested" => %{}}, + %{"nested" => []}, + %{"nested" => %{"array" => []}}, + %{"nested" => ["object", []]} + ] + + Featurevisor.EvaluationSession.run(session, root, false, fn _ -> + Enum.each(contexts, fn context -> + options = %{type: :variable, variable_key: "leaf", context: context} + + Enum.each(1..2, fn _ -> + result = + Featurevisor.EvaluationSession.run(session, options, true, fn _ -> + send(parent, {:read, context}) + + %Featurevisor.Evaluation{ + type: :variable, + variable_key: "leaf", + reason: :variable_default, + variable_value: 1 + } + end) + + assert result.variable_value == 1 + end) + end) + + :ok + end) + + assert Enum.sort( + for _ <- 1..4, + do: + ( + assert_receive {:read, context} + context + ) + ) == + Enum.sort(contexts) + + refute_receive {:read, _} + Featurevisor.EvaluationSession.close(session) + end + + test "unsupported memo inputs and results are evaluated each time" do + session = Featurevisor.EvaluationSession.new() + parent = self() + root = %{type: :variable, variable_key: "root", context: %{}} + + Featurevisor.EvaluationSession.run(session, root, false, fn _ -> + Enum.each( + [%{"host" => {1, 2}}, %{"deep" => Enum.reduce(1..70, [], fn _, acc -> [acc] end)}], + fn context -> + options = %{type: :variable, variable_key: "leaf", context: context} + + Enum.each(1..2, fn _ -> + Featurevisor.EvaluationSession.run(session, options, true, fn _ -> + send(parent, :read) + %Featurevisor.Evaluation{type: :variable, variable_value: 1} + end) + end) + end + ) + + options = %{type: :variable, variable_key: "leaf", context: %{"host" => "result"}} + + Enum.each(1..2, fn _ -> + Featurevisor.EvaluationSession.run(session, options, true, fn _ -> + send(parent, :read) + %Featurevisor.Evaluation{type: :variable, variable_value: {1, 2}} + end) + end) + + :ok + end) + + for _ <- 1..6, do: assert_receive(:read) + refute_receive :read + Featurevisor.EvaluationSession.close(session) + end + + test "sticky handoff keys stay bounded and keep owner and child generations" do + for size <- [0, 100, 1000, 5000], + kind <- [:features, :variables], + scope <- [:instance, :child] do + session = Featurevisor.EvaluationSession.new() + root = %{type: :variable, variable_key: "root", context: %{}} + + filler = + if size == 0, + do: %{}, + else: + for(index <- 1..size, into: %{}, do: {"unrelated#{index}", %{"nested" => [index]}}) + + Process.put(:sticky_owner_generation, 0) + Process.put(:sticky_child_generation, 0) + + options = %{ + type: :variable, + variable_key: "leaf", + context: %{}, + sticky_features: if(kind == :features, do: filler, else: %{}), + sticky_variables: if(kind == :variables, do: filler, else: %{}), + sticky_owner: true, + state_signature: fn -> Process.get(:sticky_owner_generation) end, + child_signature: fn -> + if(scope == :child, do: Process.get(:sticky_child_generation), else: nil) + end + } + + Featurevisor.EvaluationSession.run(session, root, false, fn _ -> + read = fn input -> + Featurevisor.EvaluationSession.run(session, input, true, fn _ -> + send(self(), :sticky_read) + %Featurevisor.Evaluation{type: :variable, variable_key: "leaf", variable_value: 1} + end) + end + + assert read.(options).variable_value == 1 + assert read.(options).variable_value == 1 + assert_receive :sticky_read + refute_receive :sticky_read + + Process.put(:sticky_owner_generation, 1) + assert read.(options).variable_value == 1 + assert_receive :sticky_read + + if scope == :child do + Process.put(:sticky_child_generation, 1) + assert read.(options).variable_value == 1 + assert_receive :sticky_read + end + + assert read.(%{options | context: %{"changed" => true}}).variable_value == 1 + assert_receive :sticky_read + :ok + end) + + keys = for {{:memo, key}, _} <- :ets.tab2list(session), do: key + assert length(keys) == if(scope == :child, do: 4, else: 3) + assert Enum.all?(keys, &(byte_size(&1) < 512)) + Featurevisor.EvaluationSession.close(session) + end + end + + test "explicit replacement maps with equal size and different leaf values stay distinct" do + session = Featurevisor.EvaluationSession.new() + root = %{type: :variable, variable_key: "root", context: %{}} + + Featurevisor.EvaluationSession.run(session, root, false, fn _ -> + for value <- [1, 2, 1] do + options = %{ + type: :variable, + variable_key: "leaf", + context: %{}, + sticky_features: %{"gate" => %{"enabled" => true}}, + sticky_variables: %{"leaf" => value} + } + + assert Featurevisor.EvaluationSession.run(session, options, true, fn _ -> + send(self(), {:replacement_read, value}) + + %Featurevisor.Evaluation{ + type: :variable, + variable_key: "leaf", + variable_value: value + } + end).variable_value == value + end + end) + + assert_receive {:replacement_read, 1} + assert_receive {:replacement_read, 2} + refute_receive {:replacement_read, _} + Featurevisor.EvaluationSession.close(session) + end + + test "replacement ownership tokens keep large map keys bounded" do + session = Featurevisor.EvaluationSession.new() + root = %{type: :variable, variable_key: "root", context: %{}} + replacement = for index <- 1..5000, into: %{}, do: {"unrelated#{index}", index} + first_token = make_ref() + second_token = make_ref() + + Featurevisor.EvaluationSession.run(session, root, false, fn _ -> + for {token, value} <- [{first_token, 1}, {first_token, 1}, {second_token, 2}] do + options = %{ + type: :variable, + variable_key: "leaf", + context: %{}, + sticky_owner: true, + sticky_features: replacement, + sticky_variables: %{}, + sticky_features_token: token + } + + assert Featurevisor.EvaluationSession.run(session, options, true, fn _ -> + send(self(), {:token_read, value}) + + %Featurevisor.Evaluation{ + type: :variable, + variable_key: "leaf", + variable_value: value + } + end).variable_value == value + end + end) + + assert_receive {:token_read, 1} + assert_receive {:token_read, 2} + refute_receive {:token_read, _} + keys = for {{:memo, key}, _} <- :ets.tab2list(session), do: key + assert length(keys) == 2 + assert Enum.all?(keys, &(byte_size(&1) < 512)) + Featurevisor.EvaluationSession.close(session) + end + + test "request snapshot cache refreshes setters and restores nested and exceptional scopes" do + f = Featurevisor.create_featurevisor(%{sticky_variables: %{"leaf" => 1}, log_level: :fatal}) + table = f.table + + Featurevisor.Server.with_request_cache(fn -> + first = Featurevisor.Server.snapshot(table) + assert first.sticky_variables["leaf"] == 1 + Featurevisor.set_sticky_variables(f, %{"leaf" => 2}) + assert Featurevisor.Server.snapshot(table).sticky_variables["leaf"] == 2 + + assert_raise RuntimeError, "nested", fn -> + Featurevisor.Server.with_request_cache(fn -> + Featurevisor.set_sticky_variables(f, %{"leaf" => 3}) + assert Featurevisor.Server.snapshot(table).sticky_variables["leaf"] == 3 + raise "nested" + end) + end + + assert Featurevisor.Server.snapshot(table).sticky_variables["leaf"] == 3 + end) + + assert Process.get({Featurevisor.Server, :request_cache}) == nil + + child = Featurevisor.spawn(f, %{}, %{sticky_variables: %{"leaf" => 1}}) + + initial = + Agent.get(child.agent, &Map.take(&1, [:generation, :sticky_features, :sticky_variables])) + + assert_raise RuntimeError, "child", fn -> + Featurevisor.Child.with_request_cache({child.agent, initial}, fn -> + Featurevisor.Child.set_sticky_variables(child, %{"leaf" => 2}) + raise "child" + end) + end + + assert Process.get({Featurevisor.Child, :request_cache}) == nil + assert Featurevisor.Child.get_global_variable(child, "leaf") == 2 + Featurevisor.Child.close(child) + Featurevisor.close(f) + end + + test "large sticky maps preserve repeated prerequisite reuse in parent and child" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "sticky-scale", + "segments" => %{}, + "features" => %{"gate" => feature()}, + "variables" => %{ + "leaf" => variable(), + "root" => + variable([requirement("leaf"), requirement("leaf")]) + |> Map.put("requiredFeatures", ["gate", "gate"]) + } + } + + parent = self() + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:dependency_of], + do: + send( + parent, + {:sticky_dependency, options.type, options[:feature_key], options[:variable_key]} + ) + + options + end + } + + for size <- [0, 100, 1000, 5000], + kind <- [:features, :variables], + scope <- [:instance, :child] do + filler = + if size == 0, + do: %{}, + else: for(index <- 1..size, into: %{}, do: {"unrelated#{index}", index}) + + features = + if(kind == :features, do: Map.put(filler, "gate", %{"enabled" => true}), else: %{}) + + variables = if(kind == :variables, do: Map.put(filler, "leaf", 1), else: %{}) + sticky = %{sticky_features: features, sticky_variables: variables} + + f = + Featurevisor.create_featurevisor( + %{datafile: datafile, modules: [module], log_level: :fatal} + |> Map.merge(if(scope == :instance, do: sticky, else: %{})) + ) + + child = + if scope == :child, do: Featurevisor.spawn(f, %{"userId" => "test"}, sticky), else: nil + + getter = + if child, + do: fn -> Featurevisor.Child.get_global_variable(child, "root") end, + else: fn -> Featurevisor.get_global_variable(f, "root", %{"userId" => "test"}) end + + assert getter.() == 1 + assert_receive {:sticky_dependency, :flag, "gate", _} + assert_receive {:sticky_dependency, :variable, _, "leaf"} + refute_receive {:sticky_dependency, _, _, _} + + if child, do: Featurevisor.Child.close(child) + Featurevisor.close(f) + end + end +end diff --git a/test/evaluation_storage_test.exs b/test/evaluation_storage_test.exs new file mode 100644 index 0000000..a000c33 --- /dev/null +++ b/test/evaluation_storage_test.exs @@ -0,0 +1,594 @@ +defmodule Featurevisor.EvaluationStorageTest do + use ExUnit.Case, async: true + alias Featurevisor.{Conditions, Module, Server, StickyStore} + + defp datafile(value \\ "first") do + %{ + "schemaVersion" => "2", + "revision" => value, + "features" => %{ + "flag" => %{ + "bucketBy" => "userId", + "requiredFeatures" => [%{"feature" => "dependency", "variation" => value}], + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + }, + "dependency" => %{ + "bucketBy" => "userId", + "variations" => [%{"value" => value}], + "force" => [%{"conditions" => "*", "enabled" => true, "variation" => value}] + } + }, + "segments" => %{ + "nl" => %{ + "conditions" => + Jason.encode!(%{"attribute" => "country", "operator" => "equals", "value" => "nl"}) + } + }, + "variables" => %{ + "value" => %{ + "type" => "string", + "defaultValue" => value, + "overrides" => [%{"key" => "local", "segments" => "nl", "value" => "local"}] + } + } + } + end + + defp instance(input \\ datafile()) do + f = Featurevisor.create_featurevisor(datafile: input, log_level: :fatal) + on_exit(fn -> Featurevisor.close(f) end) + f + end + + defp eventually(fun, attempts \\ 100) + defp eventually(fun, 0), do: assert(fun.()) + + defp eventually(fun, attempts) do + unless fun.() do + Process.sleep(10) + eventually(fun, attempts - 1) + end + end + + test "snapshot size does not grow with unrelated project entities" do + source = datafile() + + features = + Enum.reduce(1..500, source["features"], fn n, features -> + Map.put(features, "padding#{n}", %{"unused" => Enum.to_list(1..1000)}) + end) + + f = instance(%{source | "features" => features}) + snap = Server.snapshot(f.table) + assert :erts_debug.flat_size(snap) < 1000 + assert length(Featurevisor.get_feature_keys(f)) == 502 + assert Featurevisor.get_global_variable_keys(f) == ["value"] + assert Featurevisor.enabled?(f, "flag") + end + + test "primary and child sticky handoffs stay bounded while modules receive ordinary maps" do + sticky = Map.new(1..5_000, &{"padding#{&1}", %{"enabled" => true}}) + sticky = Map.put(sticky, "flag", %{"enabled" => true}) + values = Map.new(1..5_000, &{"padding#{&1}", &1}) + values = Map.put(values, "value", "sticky") + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile(), + sticky_features: sticky, + sticky_variables: values, + log_level: :fatal + }) + + on_exit(fn -> Featurevisor.close(f) end) + child = Featurevisor.spawn(f, %{}, %{sticky_features: sticky, sticky_variables: values}) + on_exit(fn -> Featurevisor.Child.close(child) end) + + assert :erts_debug.flat_size(Server.snapshot(f.table)) < 300 + + child_handoff = + Agent.get(child.agent, &Map.take(&1, [:generation, :sticky_table])) + + assert :erts_debug.flat_size(child_handoff) < 30 + assert Featurevisor.get_global_variable(f, "value") == "sticky" + assert Featurevisor.Child.get_global_variable(child, "value") == "sticky" + assert Featurevisor.enabled?(f, "flag") + assert Featurevisor.Child.enabled?(child, "flag") + + module = %Module{ + before_evaluation: fn options -> + assert is_map(options.sticky_features) + refute is_struct(options.sticky_features) + assert map_size(options.sticky_features) == 5_001 + assert is_map(options.sticky_variables) + refute is_struct(options.sticky_variables) + assert map_size(options.sticky_variables) == 5_001 + options + end + } + + unsubscribe = Featurevisor.add_module(f, module) + assert Featurevisor.get_global_variable(f, "value") == "sticky" + assert Featurevisor.Child.get_global_variable(child, "value") == "sticky" + assert Featurevisor.enabled?(f, "flag") + assert Featurevisor.Child.enabled?(child, "flag") + unsubscribe.() + end + + test "sticky generations survive replacement and close while a reader is pinned" do + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile(), + sticky_variables: %{"value" => "old"}, + log_level: :fatal + }) + + on_exit(fn -> Featurevisor.close(f) end) + parent = self() + + reader = + Task.async(fn -> + Server.with_snapshot(f.table, fn snap -> + old = snap.sticky_variables + send(parent, {:sticky_pinned, old.table}) + + receive do + :continue -> old["value"] + end + end) + end) + + assert_receive {:sticky_pinned, table} + Featurevisor.set_sticky_variables(f, %{"value" => "new"}, true) + Featurevisor.close(f) + assert :ets.info(table) != :undefined + send(reader.pid, :continue) + assert Task.await(reader) == "old" + eventually(fn -> :ets.info(table) == :undefined end) + refute StickyStore.has_key?(Server.snapshot(f.table).sticky_variables, "value") + end + + test "an outer snapshot pin protects a retired generation when cache seeding cannot repin" do + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile(), + sticky_variables: %{"value" => "old"}, + log_level: :fatal + }) + + on_exit(fn -> Featurevisor.close(f) end) + store = :sys.get_state(f.pid).sticky_store + + Server.with_snapshot(f.table, fn snap -> + Featurevisor.set_sticky_variables(f, %{"value" => "new"}, true) + :sys.get_state(store) + refute StickyStore.pin(snap.sticky_table) + + Server.with_request_cache(fn -> + Server.seed_request_cache(f.table, snap) + assert snap.sticky_variables["value"] == "old" + assert Server.snapshot(f.table).sticky_variables["value"] == "new" + end) + end) + end + + test "retired sticky tables reject new pins before reclamation" do + {:ok, store} = StickyStore.start(self()) + generation = StickyStore.create(store, %{"flag" => %{"enabled" => true}}, %{}) + assert StickyStore.pin(generation) + assert StickyStore.view(generation, :features)["flag"] == %{"enabled" => true} + + StickyStore.retire(store, generation) + :sys.get_state(store) + refute StickyStore.pin(generation) + send(store, :collect) + :sys.get_state(store) + assert :ets.info(generation.table) != :undefined + + StickyStore.unpin(generation) + eventually(fn -> :ets.info(generation.table) == :undefined end) + refute StickyStore.pin(generation) + end + + test "a killed sticky reader releases its fixation and the retired table" do + {:ok, store} = StickyStore.start(self()) + generation = StickyStore.create(store, %{"flag" => %{"enabled" => true}}, %{}) + parent = self() + + reader = + spawn(fn -> + assert StickyStore.pin(generation) + send(parent, :sticky_reader_pinned) + + receive do + :never -> :ok + end + end) + + assert_receive :sticky_reader_pinned + StickyStore.retire(store, generation) + :sys.get_state(store) + assert :ets.info(generation.table) != :undefined + Process.exit(reader, :kill) + eventually(fn -> :ets.info(generation.table) == :undefined end) + end + + test "primary and child sticky updates reclaim old generations" do + f = instance() + child = Featurevisor.spawn(f) + on_exit(fn -> Featurevisor.Child.close(child) end) + + primary_old = + for n <- 1..30 do + table = Server.snapshot(f.table).sticky_table.table + Featurevisor.set_sticky_variables(f, %{"value" => n}, true) + table + end + + child_old = + for n <- 1..30 do + table = Agent.get(child.agent, & &1.sticky_table.table) + Featurevisor.Child.set_sticky_variables(child, %{"value" => n}, true) + table + end + + eventually(fn -> Enum.all?(primary_old ++ child_old, &(:ets.info(&1) == :undefined)) end) + assert Featurevisor.get_global_variable(f, "value") == 30 + assert Featurevisor.Child.get_global_variable(child, "value") == 30 + end + + test "child close retains a pinned sticky generation until its reader exits" do + f = instance() + child = Featurevisor.spawn(f, %{}, %{sticky_variables: %{"value" => "child"}}) + + snapshot = + Agent.get(child.agent, fn state -> + %{generation: state.generation, sticky_table: state.sticky_table} + end) + + store = Agent.get(child.agent, & &1.sticky_store) + parent = self() + + reader = + Task.async(fn -> + Featurevisor.Child.with_request_cache({child.agent, snapshot}, fn -> + send(parent, :child_sticky_pinned) + + receive do + :continue -> StickyStore.view(snapshot.sticky_table, :variables)["value"] + end + end) + end) + + assert_receive :child_sticky_pinned + Featurevisor.Child.close(child) + :sys.get_state(store) + assert :ets.info(snapshot.sticky_table.table) != :undefined + send(reader.pid, :continue) + assert Task.await(reader) == "child" + eventually(fn -> :ets.info(snapshot.sticky_table.table) == :undefined end) + eventually(fn -> not Process.alive?(store) end) + end + + test "readers retain their generation across replacement, merge, and close" do + for action <- [:replace, :merge, :close] do + f = instance() + parent = self() + + reader = + Task.async(fn -> + Server.with_snapshot(f.table, fn snap -> + send(parent, {:pinned, snap.regex_cache}) + + receive do + :continue -> get_in(snap.datafile, ["variables", "value", "defaultValue"]) + end + end) + end) + + assert_receive {:pinned, old} + + if action == :close, + do: Featurevisor.close(f), + else: Featurevisor.set_datafile(f, datafile("next"), action == :replace) + + Process.sleep(150) + assert :ets.info(old) != :undefined + send(reader.pid, :continue) + assert Task.await(reader) == "first" + eventually(fn -> :ets.info(old) == :undefined end) + end + end + + test "dead readers release retired generations automatically" do + f = instance() + parent = self() + + reader = + spawn(fn -> + Server.with_snapshot(f.table, fn snap -> + send(parent, {:pinned, snap.regex_cache}) + + receive do + :never -> :ok + end + end) + end) + + assert_receive {:pinned, old} + Featurevisor.set_datafile(f, datafile("next"), true) + Process.exit(reader, :kill) + eventually(fn -> :ets.info(old) == :undefined end) + assert Featurevisor.get_global_variable(f, "value") == "next" + end + + test "owner failure cleans up the store and its current generation" do + f = instance() + state = :sys.get_state(f.pid) + store = state.store + sticky_store = state.sticky_store + table = Server.snapshot(f.table).regex_cache + sticky_table = Server.snapshot(f.table).sticky_table.table + Process.exit(f.pid, :kill) + eventually(fn -> not Process.alive?(store) end) + eventually(fn -> not Process.alive?(sticky_store) end) + assert :ets.info(table) == :undefined + assert :ets.info(sticky_table) == :undefined + assert Featurevisor.get_global_variable(f, "value") == nil + end + + test "an exception releases the caller's generation pin" do + f = instance() + table = Server.snapshot(f.table).regex_cache + + assert_raise RuntimeError, fn -> + Server.with_snapshot(f.table, fn _ -> raise "test failure" end) + end + + assert :ets.info(table, :safe_fixed) == false + end + + @tag capture_log: true + test "store failure terminates its owner instead of leaving a broken instance" do + f = instance() + store = :sys.get_state(f.pid).store + Process.exit(store, :kill) + eventually(fn -> not Process.alive?(f.pid) end) + assert Featurevisor.get_global_variable(f, "value") == nil + refute Featurevisor.enabled?(f, "flag") + end + + @tag capture_log: true + test "sticky store failure terminates its owner instead of exposing stale handles" do + f = instance() + store = :sys.get_state(f.pid).sticky_store + Process.exit(store, :kill) + eventually(fn -> not Process.alive?(f.pid) end) + assert Featurevisor.get_global_variable(f, "value") == nil + end + + @tag capture_log: true + test "child sticky store failure gives closed child fallbacks without affecting its parent" do + f = instance() + + child = + Featurevisor.spawn(f, %{"country" => "nl"}, %{sticky_variables: %{"value" => "child"}}) + + assert Featurevisor.Child.get_global_variable(child, "value") == "child" + caller = self() + Featurevisor.Child.on(child, :datafile_set, fn _ -> send(caller, :delegated_datafile) end) + assert length(Server.snapshot(f.table).listeners[:datafile_set]) == 1 + store = child.sticky_store + table = Agent.get(child.agent, & &1.sticky_table.table) + + Process.exit(store, :kill) + assert Featurevisor.Child.set_sticky_variables(child, %{"value" => "other"}) == :ok + eventually(fn -> not Process.alive?(child.agent) end) + eventually(fn -> Server.snapshot(f.table).listeners[:datafile_set] in [nil, []] end) + assert :ets.info(table) == :undefined + assert Featurevisor.Child.get_global_variable(child, "value") == "first" + assert Featurevisor.Child.get_context(child)["country"] == nil + assert Featurevisor.Child.set_context(child, %{"country" => "de"}) == :ok + assert Featurevisor.Child.set_sticky_features(child, %{"flag" => %{"enabled" => true}}) == :ok + assert Featurevisor.Child.set_sticky_variables(child, %{"value" => "other"}) == :ok + unsubscribe = Featurevisor.Child.on(child, :context_set, fn _ -> :ok end) + assert unsubscribe.() == :ok + assert Featurevisor.Child.close(child) == :ok + assert Featurevisor.get_global_variable(f, "value") == "first" + Featurevisor.set_datafile(f, datafile("next"), true) + refute_receive :delegated_datafile + end + + test "variable requirement comparisons tolerate concurrent cache replacement" do + source = datafile() + + dependent = %{ + "type" => "string", + "defaultValue" => "matched", + "conditions" => [ + %{"variable" => "value", "operator" => "matches", "value" => "^first$"} + ] + } + + source = put_in(source, ["variables", "dependent"], dependent) + f = instance(source) + + readers = + for _ <- 1..8 do + Task.async(fn -> + Enum.each(1..200, fn _ -> + assert Featurevisor.get_global_variable(f, "dependent") == "matched" + end) + end) + end + + Enum.each(1..30, fn n -> + Featurevisor.set_datafile(f, %{source | "revision" => "version#{n}"}, true) + end) + + Enum.each(readers, &Task.await(&1, 10_000)) + end + + test "concurrent dependency reads use complete accepted states and tolerate generation changes" do + f = instance() + + unsubscribe = + Featurevisor.add_module(f, %Module{ + before_evaluation: fn options -> + datafile = options.datafile + revision = datafile["revision"] + + assert get_in(datafile, ["features", "dependency", "variations", Access.at(0), "value"]) == + revision + + assert get_in(datafile, ["features", "flag", "conditions"]) == [ + %{"feature" => "dependency", "variation" => revision} + ] + + options + end + }) + + readers = + for _ <- 1..8 do + Task.async(fn -> + Enum.each(1..200, fn _ -> + evaluation = Featurevisor.evaluate_flag(f, "flag") + assert evaluation.reason in [:rule, :conditions_unmet] + assert evaluation.enabled == (evaluation.reason == :rule) + end) + end) + end + + Enum.each(1..30, fn n -> Featurevisor.set_datafile(f, datafile("version#{n}"), true) end) + Enum.each(readers, &Task.await(&1, 10_000)) + assert Featurevisor.enabled?(f, "flag") + assert Featurevisor.get_variation(f, "dependency") == "version30" + unsubscribe.() + assert Featurevisor.get_global_variable(f, "value") == "version30" + end + + test "module callback options still contain full maps and keep their starting datafile" do + f = instance() + parent = self() + + unsubscribe = + Featurevisor.add_module(f, %Module{ + before_evaluation: fn options -> + assert is_map(options.datafile["features"]) + refute is_struct(options.datafile["features"]) + assert Map.keys(options.datafile["features"]) |> Enum.sort() == ["dependency", "flag"] + refute Map.has_key?(options, :entity_source) + before = options.datafile + + send( + parent, + {:callback_revision, options.feature_key, options.type, before["revision"]} + ) + + Featurevisor.set_datafile(f, datafile("next"), true) + assert options.datafile == before + options + end + }) + + assert Featurevisor.enabled?(f, "flag") + assert_receive {:callback_revision, "flag", :flag, "first"} + assert_receive {:callback_revision, "dependency", :flag, "next"} + assert_receive {:callback_revision, "dependency", :variation, "next"} + refute_receive {:callback_revision, _, _, _} + unsubscribe.() + assert Featurevisor.get_global_variable(f, "value") == "next" + end + + test "closing callbacks retain selected definitions but prevent later definition reads" do + for phase <- [:before, :after] do + f = instance() + + module = + if phase == :before do + %Module{ + before_evaluation: fn options -> + Featurevisor.close(f) + options + end + } + else + %Module{ + after_evaluation: fn evaluation, _options -> + Featurevisor.close(f) + evaluation + end + } + end + + Featurevisor.add_module(f, module) + + assert Featurevisor.get_global_variable(f, "value") == + if(phase == :before, do: nil, else: "first") + + assert Featurevisor.get_global_variable(f, "value") == nil + end + end + + test "parsed expressions cache null and false but never context decisions" do + f = instance() + table = Server.snapshot(f.table).regex_cache + + for source <- ["null", "false"] do + expected = Jason.decode!(source) + assert Conditions.parse_conditions(source, fn _ -> :ok end, table) == expected + assert :ets.lookup(table, {:expression, source}) == [{{:expression, source}, expected}] + end + + for _ <- 1..10 do + assert Featurevisor.get_global_variable(f, "value", %{"country" => "nl"}) == "local" + assert Featurevisor.get_global_variable(f, "value", %{"country" => "de"}) == "first" + end + + assert Featurevisor.get_segment(f, "nl")["conditions"]["attribute"] == "country" + end + + test "parse failures repeat their diagnostics and invalid updates keep the current generation" do + f = instance() + table = Server.snapshot(f.table).regex_cache + parent = self() + + for _ <- 1..2 do + assert Conditions.parse_conditions("bad JSON", &send(parent, {:diagnostic, &1}), table) == + "bad JSON" + + assert_receive {:diagnostic, %{code: "conditions_parse_error"}} + assert_raise Jason.DecodeError, fn -> Conditions.parse_segments("[", table) end + end + + assert :ets.lookup(table, {:expression, "bad JSON"}) == [] + Featurevisor.set_datafile(f, "bad JSON", true) + assert Server.snapshot(f.table).regex_cache == table + assert Featurevisor.enabled?(f, "flag") + end + + test "repeated updates reclaim generations and close releases the remaining tables" do + f = instance() + state = :sys.get_state(f.pid) + store = state.store + sticky_store = state.sticky_store + + old = + for n <- 1..30 do + table = Server.snapshot(f.table).regex_cache + Featurevisor.set_datafile(f, datafile("version#{n}"), true) + table + end + + eventually(fn -> Enum.all?(old, &(:ets.info(&1) == :undefined)) end) + current = Server.snapshot(f.table).regex_cache + sticky_table = Server.snapshot(f.table).sticky_table.table + Featurevisor.close(f) + Featurevisor.close(f) + eventually(fn -> not Process.alive?(store) end) + eventually(fn -> not Process.alive?(sticky_store) end) + assert :ets.info(current) == :undefined + assert :ets.info(sticky_table) == :undefined + end +end diff --git a/test/events_and_concurrency_test.exs b/test/events_and_concurrency_test.exs index fcde808..7908728 100644 --- a/test/events_and_concurrency_test.exs +++ b/test/events_and_concurrency_test.exs @@ -54,7 +54,14 @@ defmodule Featurevisor.EventsAndConcurrencyTest do options = %{ name: name, datafile: Featurevisor.TestFixtures.datafile(), - log_level: :fatal, + log_level: :info, + on_diagnostic: fn + %{code: "sdk_initialized"} -> + send(parent, {:sdk_initialized, Featurevisor.instance(name).pid}) + + _ -> + :ok + end, modules: [%Module{name: "lifecycle", close: fn -> send(parent, :module_closed) end}] } @@ -62,11 +69,17 @@ defmodule Featurevisor.EventsAndConcurrencyTest do {:ok, supervisor} = Supervisor.start_link([{Featurevisor, options}], strategy: :one_for_one) f = Featurevisor.instance(name) + first_pid = f.pid + assert_receive {:sdk_initialized, ^first_pid}, 5_000 assert Featurevisor.enabled?(f, "flag", %{"country" => "nl"}) - first_pid = f.pid + monitor = Process.monitor(first_pid) Process.exit(first_pid, :kill) - restarted_pid = wait_for_restart(name, first_pid, 50) + assert_receive {:DOWN, ^monitor, :process, ^first_pid, :killed}, 5_000 + + # Name registration precedes datafile loading in the child start callback. + # Wait for SDK initialization rather than treating a new PID as ready. + assert_receive {:sdk_initialized, restarted_pid}, 5_000 assert restarted_pid != first_pid restarted = Featurevisor.instance(name) @@ -74,23 +87,10 @@ defmodule Featurevisor.EventsAndConcurrencyTest do assert Featurevisor.enabled?(restarted, "flag", %{"country" => "nl"}) Supervisor.stop(supervisor) - assert_receive :module_closed + assert_receive :module_closed, 5_000 assert_raise ArgumentError, "Featurevisor instance is not running", fn -> Featurevisor.instance(name) end end - - defp wait_for_restart(_name, _previous, 0), do: flunk("Featurevisor child did not restart") - - defp wait_for_restart(name, previous, attempts) do - case GenServer.whereis(name) do - pid when is_pid(pid) and pid != previous -> - pid - - _ -> - Process.sleep(10) - wait_for_restart(name, previous, attempts - 1) - end - end end diff --git a/test/featurevisor_test.exs b/test/featurevisor_test.exs index e6ed775..2644f29 100644 --- a/test/featurevisor_test.exs +++ b/test/featurevisor_test.exs @@ -47,7 +47,7 @@ defmodule FeaturevisorTest do end test "merges and replaces datafiles", %{f: f} do - assert :ok = + assert true == Featurevisor.set_datafile(f, %{ "schemaVersion" => "2", "revision" => "next", @@ -61,7 +61,7 @@ defmodule FeaturevisorTest do assert Featurevisor.get_feature(f, "extra") assert Featurevisor.get_revision(f) == "next" - assert :ok = + assert true == Featurevisor.set_datafile( f, Jason.encode!(%{ @@ -83,7 +83,7 @@ defmodule FeaturevisorTest do send(parent, {:error, diagnostic}) end) - assert {:error, _} = Featurevisor.set_datafile(f, "{") + assert false == Featurevisor.set_datafile(f, "{") assert Featurevisor.get_revision(f) == "test" assert_receive {:error, %{message: "Could not parse datafile", code: "invalid_datafile"}} end @@ -143,7 +143,7 @@ defmodule FeaturevisorTest do refute Process.alive?(f.pid) assert :ets.info(f.table) == :undefined - assert Featurevisor.set_datafile(f, Featurevisor.TestFixtures.datafile(), true) == :ok + assert Featurevisor.set_datafile(f, Featurevisor.TestFixtures.datafile(), true) == false assert Featurevisor.set_context(f, %{"country" => "nl"}) == :ok assert Featurevisor.set_sticky_features(f, %{"flag" => %{"enabled" => true}}) == :ok assert Featurevisor.set_log_level(f, :debug) == :ok diff --git a/test/full_refresh_ownership_test.exs b/test/full_refresh_ownership_test.exs new file mode 100644 index 0000000..d8326c8 --- /dev/null +++ b/test/full_refresh_ownership_test.exs @@ -0,0 +1,140 @@ +defmodule Featurevisor.FullRefreshOwnershipTest do + use ExUnit.Case, async: false + + for schema <- ["2", "4"], replace <- [false, true] do + test "nested values and pinned cold readers survive schema #{schema}, replace #{replace}" do + schema = unquote(schema) + replace = unquote(replace) + parent = self() + + 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" + } + } + } + + sdk = + Featurevisor.create_featurevisor(%{ + datafile: source, + log_level: :error, + on_diagnostic: fn item -> + if item.code == "conditions_parse_error", do: send(parent, :parse_error) + end + }) + + try do + if schema == "2", do: assert_receive(:parse_error), else: refute_receive(:parse_error) + for _ <- 1..2, do: assert(is_nil(Featurevisor.get_global_variable(sdk, "broken"))) + if schema == "4", do: assert_receive(:parse_error) + refute_receive :parse_error + assert Featurevisor.get_global_variable(sdk, "value") == payload + + reader = + Task.async(fn -> + Featurevisor.Server.with_snapshot(sdk.table, fn snapshot -> + send(parent, :pinned) + + receive do + :read -> + Featurevisor.Normalize.get( + snapshot.datafile, + "variables", + "cold", + snapshot.regex_cache, + fn _ -> :ok end + ) + end + end) + end) + + assert_receive :pinned + + updated = + source + |> Map.put("revision", "next") + |> update_in(["variables"], &Map.delete(&1, "removed")) + + updated = + Enum.reduce(["value", "cold"], updated, fn key, data -> + data + |> put_in(["variables", key, "defaultValue", "rows"], [%{"label" => "new"}]) + |> put_in(["variables", key, "metadata", "nested"], ["new"]) + end) + + assert Featurevisor.set_datafile(sdk, updated, replace) + + for key <- ["value", "cold"] do + assert Featurevisor.get_global_variable(sdk, key)["rows"] == [%{"label" => "new"}] + end + + Featurevisor.Server.with_snapshot(sdk.table, fn snapshot -> + current = + Featurevisor.Normalize.get( + snapshot.datafile, + "variables", + "cold", + snapshot.regex_cache, + fn _ -> :ok end + ) + + assert current["metadata"] == %{"nested" => ["new"]} + end) + + for _ <- 1..2, do: assert(is_nil(Featurevisor.get_global_variable(sdk, "broken"))) + assert_receive :parse_error + refute_receive :parse_error + assert "removed" in Featurevisor.get_global_variable_keys(sdk) == not replace + + Featurevisor.on(sdk, :datafile_set, &send(parent, {:change, &1})) + + assert Featurevisor.set_datafile(sdk, %{ + source + | "revision" => "payload", + "variables" => %{ + "ignored" => %{"type" => "integer", "defaultValue" => 1, "hash" => "new"} + } + }) + + assert_receive {:change, %{variables: ["ignored"]}} + + assert Featurevisor.set_datafile( + sdk, + %{source | "revision" => "empty", "variables" => %{}}, + true + ) + + assert Featurevisor.get_global_variable_keys(sdk) == [] + send(reader.pid, :read) + old = Task.await(reader) + assert old["metadata"] == %{"nested" => ["old"]} + assert old["defaultValue"] == payload + after + Featurevisor.close(sdk) + end + end + end +end diff --git a/test/group_removal_test.exs b/test/group_removal_test.exs new file mode 100644 index 0000000..c0782f7 --- /dev/null +++ b/test/group_removal_test.exs @@ -0,0 +1,151 @@ +defmodule Featurevisor.GroupRemovalTest do + use ExUnit.Case, async: true + + defp datafile do + Jason.decode!(~s''' + { + "schemaVersion":"2","revision":"exclusion","segments":{},"features":{ + "priority":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":50000}]}, + "fallback":{"bucketBy":"userId","requiredFeatures":[{"feature":"priority","enabled":false}],"traffic":[{"key":"all","segments":"*","percentage":100000}]}, + "allocator":{"bucketBy":"userId","variations":[{"value":"a","variables":{"label":"A"}},{"value":"b","variables":{"label":"B"}}],"variablesSchema":{"label":{"type":"string","defaultValue":"none"}},"traffic":[{"key":"all","segments":"*","percentage":100000,"allocation":[{"variation":"a","range":[0,50000]},{"variation":"b","range":[50000,100000]}]}]}, + "experimentA":{"bucketBy":"userId","requiredFeatures":[{"feature":"allocator","variation":"a"}],"traffic":[{"key":"all","segments":"*","percentage":100000}]}, + "experimentB":{"bucketBy":"userId","requiredFeatures":[{"feature":"allocator","variation":"b"}],"traffic":[{"key":"all","segments":"*","percentage":100000}]}, + "off":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":0}]}, + "explicitOff":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":false}]}, + "explicitOn":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":50000,"enabled":true}]} + } + } + ''') + end + + defp instance(datafile) do + parent = self() + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + log_level: :warn, + on_diagnostic: fn diagnostic -> send(parent, {:unexpected_diagnostic, diagnostic}) end + }) + + on_exit(fn -> Featurevisor.close(f) end) + f + end + + test "legacy feature ranges are ignored without warnings or parsing" do + baseline = datafile() + expected = instance(baseline) + + for ranges <- [[[0, 1]], [[0, 100_000]], [], nil, "removed", [["invalid", %{}]]] do + features = + Map.new(baseline["features"], fn {key, feature} -> + {key, Map.put(feature, "ranges", ranges)} + end) + + actual = instance(Jason.encode!(%{baseline | "features" => features})) + + for index <- 1..40 do + context = %{"userId" => "user-#{index}"} + + for key <- Map.keys(features) do + assert Featurevisor.evaluate_flag(actual, key, context) == + Featurevisor.evaluate_flag(expected, key, context) + end + + assert Featurevisor.get_variation(actual, "allocator", context) == + Featurevisor.get_variation(expected, "allocator", context) + + assert Featurevisor.get_variable(actual, "allocator", "label", context) == + Featurevisor.get_variable(expected, "allocator", "label", context) + end + end + + refute_received {:unexpected_diagnostic, _} + end + + test "priority requirements exclude overlapping features across ordinary hash cohorts" do + for holdout <- [false, true] do + f = instance(population_datafile(holdout)) + reverse = instance(population_datafile(holdout)) + + winners = + for index <- 1..20_000 do + context = %{"userId" => "user-#{index}"} + priority = Featurevisor.enabled?(f, "priority", context) + fallback = Featurevisor.enabled?(f, "fallback", context) + reverse_fallback = Featurevisor.enabled?(reverse, "fallback", context) + reverse_priority = Featurevisor.enabled?(reverse, "priority", context) + refute priority and fallback + refute reverse_priority and reverse_fallback + assert {priority, fallback} == {reverse_priority, reverse_fallback} + {priority, fallback} + end + + assert_population(winners, {true, false}, 10_000) + assert_population(winners, {false, true}, if(holdout, do: 5000, else: 10_000)) + assert_population(winners, {false, false}, if(holdout, do: 5000, else: 0)) + refute Enum.any?(winners, &(&1 == {true, true})) + end + + refute_received {:unexpected_diagnostic, _} + end + + test "shared variation allocation gates exclusive features across ordinary hash cohorts" do + for holdout <- [false, true] do + f = instance(population_datafile(holdout)) + reverse = instance(population_datafile(holdout)) + + variations = + for index <- 1..20_000 do + context = %{"userId" => "user-#{index}"} + a = Featurevisor.enabled?(f, "experimentA", context) + b = Featurevisor.enabled?(f, "experimentB", context) + reverse_b = Featurevisor.enabled?(reverse, "experimentB", context) + reverse_a = Featurevisor.enabled?(reverse, "experimentA", context) + refute a and b + refute reverse_a and reverse_b + assert {a, b} == {reverse_a, reverse_b} + variation = Featurevisor.get_variation(f, "allocator", context) + assert variation in ["a", "b", "holdout"] + assert a == (variation == "a") + assert b == (variation == "b") + + assert Featurevisor.get_variable(f, "allocator", "label", context) == + String.upcase(variation) + + assert Featurevisor.evaluate_variation(f, "allocator", context).reason == :allocated + variation + end + + assert_population(variations, "a", if(holdout, do: 9000, else: 10_000)) + assert_population(variations, "b", if(holdout, do: 9000, else: 10_000)) + assert_population(variations, "holdout", if(holdout, do: 2000, else: 0)) + end + + refute_received {:unexpected_diagnostic, _} + end + + defp population_datafile(false), do: datafile() + + defp population_datafile(true) do + datafile() + |> put_in(["features", "fallback", "traffic", Access.at(0), "percentage"], 50_000) + |> put_in(["features", "allocator", "variations"], [ + %{"value" => "a", "variables" => %{"label" => "A"}}, + %{"value" => "b", "variables" => %{"label" => "B"}}, + %{"value" => "holdout", "variables" => %{"label" => "HOLDOUT"}} + ]) + |> put_in(["features", "allocator", "traffic", Access.at(0), "allocation"], [ + %{"variation" => "a", "range" => [0, 45_000]}, + %{"variation" => "b", "range" => [45_000, 90_000]}, + %{"variation" => "holdout", "range" => [90_000, 100_000]} + ]) + end + + defp assert_population(values, value, 0), do: refute(Enum.any?(values, &(&1 == value))) + + defp assert_population(values, value, expected) do + # Allow two percentage points of sampling variation across 20,000 users. + assert abs(Enum.count(values, &(&1 == value)) - expected) <= 400 + end +end diff --git a/test/includes_any_test.exs b/test/includes_any_test.exs new file mode 100644 index 0000000..8ed82b3 --- /dev/null +++ b/test/includes_any_test.exs @@ -0,0 +1,206 @@ +defmodule Featurevisor.IncludesAnyTest do + use ExUnit.Case, async: true + alias Featurevisor.{CLI.TestChecks, Conditions} + + @cases [ + {["admin", 2, true, nil], ["missing", 2.0], true, true}, + {["admin", 2, true, nil], [true], true, true}, + {["admin", 2, true, nil], [nil], true, true}, + {["admin"], ["admin", "admin"], true, true}, + {["admin", "admin"], ["other", "admin"], true, true}, + {["admin"], ["admin", "other"], true, true}, + {[1], [true], false, true}, + {[true], [1], false, true}, + {[false], [0], false, true}, + {["1"], [1], false, true}, + {[1], ["1"], false, true}, + {["true"], [true], false, true}, + {[%{}, [1], "admin"], ["admin"], true, true}, + {[%{}, [1]], [nil, 1], false, true}, + {[], [nil], false, true}, + {["admin"], [], false, true}, + {[], [], false, true}, + {nil, [], false, false}, + {"admin", ["admin"], false, false}, + {1, [1], false, false}, + {true, [true], false, false}, + {%{}, [], false, false}, + {["admin"], nil, false, false}, + {["admin"], "admin", false, false}, + {["admin"], 1, false, false}, + {["admin"], true, false, false}, + {["admin"], %{}, false, false}, + {["admin"], ["admin", %{}], false, false}, + {["admin"], [[], "admin"], false, false} + ] + + test "conditions and feature and global requirements share strict membership" do + cache = :ets.new(:includes_any, [:set, :public]) + + for {actual, candidates, found, valid} <- @cases, + operator <- ["includesAny", "notIncludesAny"], + source <- [ + %{"variable" => "source", "path" => ["items"]}, + %{"feature" => "sourceFeature", "variable" => "items"}, + %{"variable" => "source", "path" => ["missing"]} + ] do + expected = valid and if(operator == "includesAny", do: found, else: not found) + requirement = Map.merge(source, %{"operator" => operator, "value" => candidates}) + requirement_expected = expected and source["path"] != ["missing"] + condition = %{"attribute" => "profile.items", "operator" => operator, "value" => candidates} + context = %{"profile" => %{"items" => actual}} + assert Conditions.all_conditions?(condition, context, cache, fn _ -> :ok end) == expected + datafile = datafile(actual, candidates, requirement) + instance = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + + try do + for _ <- 1..2 do + assert Featurevisor.enabled?(instance, "gate") == requirement_expected + assert Featurevisor.get_global_variable(instance, "gate") == requirement_expected + assert Featurevisor.get_variable(instance, "override", "result") == requirement_expected + end + after + Featurevisor.close(instance) + end + end + end + + test "missing paths and invalid runtime comparisons preserve NOT and OR semantics" do + cache = :ets.new(:includes_any_groups, [:set, :public]) + report = fn _ -> :ok end + + for operator <- ["includesAny", "notIncludesAny"] do + invalid = %{ + "attribute" => "profile.items", + "operator" => operator, + "value" => ["admin", %{}] + } + + for context <- [%{}, %{"profile" => %{"items" => ["admin"]}}] do + refute Conditions.all_conditions?(invalid, context, cache, report) + assert Conditions.all_conditions?(%{"not" => [invalid]}, context, cache, report) + assert Conditions.all_conditions?(%{"or" => [invalid, "*"]}, context, cache, report) + end + end + end + + test "CLI literal and compareTo checks validate operands and report structured errors" do + for {actual, candidates, found, valid} <- @cases, + operator <- ["includesAny", "notIncludesAny"] do + datafile = datafile(actual, candidates, nil) + instance = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + + try do + for operand <- [%{"value" => candidates}, %{"compareTo" => %{"variable" => "candidates"}}] do + check = + Map.merge( + %{"variable" => "source", "path" => ["items"], "operator" => operator}, + operand + ) + + result = TestChecks.evaluate_result(instance, datafile, [check], nil, make_ref()) + + assert result.passed == + (valid and if(operator == "includesAny", do: found, else: not found)) + + assert result.error == not valid + + if not valid do + assert length(result.report_failures) == 1 + + for group <- ["not", "or"] do + checks = [%{group => [check, %{"variable" => "source", "operator" => "exists"}]}] + grouped = TestChecks.evaluate_result(instance, datafile, checks, nil, make_ref()) + refute grouped.passed + assert grouped.error + end + end + end + after + Featurevisor.close(instance) + end + end + end + + test "CLI missing paths retain nonerror comparison and OR skips incompatible values" do + datafile = datafile(["admin"], nil, nil) + instance = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + + try do + for operator <- ["includesAny", "notIncludesAny"] do + missing = %{ + "variable" => "source", + "path" => ["missing"], + "operator" => operator, + "value" => [] + } + + result = TestChecks.evaluate_result(instance, datafile, [missing], nil, make_ref()) + refute result.passed + refute result.error + + checks = [ + %{ + "or" => [ + %{"variable" => "source", "operator" => "exists"}, + %{ + "variable" => "source", + "path" => ["items"], + "operator" => operator, + "compareTo" => %{"variable" => "candidates"} + } + ] + } + ] + + assert TestChecks.evaluate_result(instance, datafile, checks, nil, make_ref()).passed + end + after + Featurevisor.close(instance) + end + end + + defp datafile(actual, candidates, requirement) do + traffic = [%{"key" => "all", "segments" => "*", "percentage" => 100_000, "enabled" => true}] + requirements = if is_nil(requirement), do: [], else: [requirement] + + %{ + "schemaVersion" => "2", + "revision" => "includes-any", + "segments" => %{}, + "features" => %{ + "sourceFeature" => %{ + "bucketBy" => "userId", + "variablesSchema" => %{"items" => %{"type" => "object", "defaultValue" => actual}}, + "traffic" => traffic + }, + "gate" => %{ + "bucketBy" => "userId", + "conditions" => requirements, + "traffic" => traffic + }, + "override" => %{ + "bucketBy" => "userId", + "variablesSchema" => %{"result" => %{"type" => "boolean", "defaultValue" => false}}, + "traffic" => [ + Map.put(hd(traffic), "variableOverrides", %{ + "result" => [ + %{"key" => "matched", "conditions" => requirements, "value" => true} + ] + }) + ] + } + }, + "variables" => %{ + "source" => %{"type" => "object", "defaultValue" => %{"items" => actual}}, + "candidates" => %{"type" => "object", "defaultValue" => candidates}, + "gate" => %{ + "type" => "boolean", + "defaultValue" => true, + "disabledValue" => false, + "conditions" => requirements + } + } + } + end +end diff --git a/test/incremental_dependency_index_test.exs b/test/incremental_dependency_index_test.exs new file mode 100644 index 0000000..c865774 --- /dev/null +++ b/test/incremental_dependency_index_test.exs @@ -0,0 +1,293 @@ +defmodule Featurevisor.IncrementalDependencyIndexTest do + use ExUnit.Case, async: false + + test "identical full merges and replacements reuse edges but still announce hashless entities" do + target = {Featurevisor.Dependencies, :entity, 2} + Code.ensure_loaded!(Featurevisor.Dependencies) + :erlang.trace_pattern(target, true, [:call_count]) + + try do + for replace <- [false, true] do + :erlang.trace_pattern(target, true, [:call_count]) + + source = %{ + datafile() + | "features" => %{ + "root" => %{"hash" => "same", "conditions" => "gate"}, + "hashless" => %{"conditions" => "*"} + }, + "segments" => %{"gate" => %{"conditions" => "*"}}, + "variables" => %{ + "source" => %{"hash" => "1", "type" => "integer", "defaultValue" => 1}, + "alias" => %{"hash" => "same", "type" => "integer", "compose" => "source"} + } + } + + sdk = Featurevisor.create_featurevisor(%{log_level: :fatal, datafile: source}) + parent = self() + Featurevisor.on(sdk, :datafile_set, &send(parent, {:change, &1})) + + try do + for revision <- ["first", "second"] do + assert Featurevisor.set_datafile(sdk, %{source | "revision" => revision}, replace) + assert_receive {:change, %{features: ["hashless"], variables: [], replaced: ^replace}} + assert :erlang.trace_info(target, :call_count) == {:call_count, 5} + end + after + Featurevisor.close(sdk) + end + end + after + :erlang.trace_pattern(target, false, [:call_count]) + end + end + + test "full updates refresh same hash edges and replacement removes obsolete owners" do + for replace <- [false, true] do + source = %{ + datafile() + | "features" => %{"root" => %{"hash" => "same", "conditions" => "gate"}}, + "segments" => %{ + "gate" => %{"conditions" => %{"variable" => "source", "operator" => "notNull"}} + }, + "variables" => %{ + "source" => %{"hash" => "1", "type" => "integer", "defaultValue" => 1}, + "alias" => %{"hash" => "same", "type" => "integer", "compose" => "source"} + } + } + + sdk = Featurevisor.create_featurevisor(%{log_level: :fatal, datafile: source}) + parent = self() + Featurevisor.on(sdk, :datafile_set, &send(parent, {:change, &1})) + + try do + assert Featurevisor.set_datafile(sdk, source, replace) + assert_receive {:change, %{features: [], variables: []}} + + changed = %{ + source + | "segments" => %{"gate" => %{"conditions" => "*"}}, + "variables" => %{ + "source" => source["variables"]["source"], + "alias" => %{"hash" => "same", "type" => "integer", "compose" => "other"} + } + } + + assert Featurevisor.set_datafile(sdk, changed, replace) + assert_receive {:change, %{features: ["root"], variables: []}} + + patch = %{ + datafile() + | "variables" => %{ + "source" => %{"hash" => "2", "type" => "integer", "defaultValue" => 2} + } + } + + assert Featurevisor.set_datafile(sdk, patch) + assert_receive {:change, %{features: [], variables: ["source"]}} + + patch = %{ + datafile() + | "variables" => %{ + "other" => %{"hash" => "3", "type" => "integer", "defaultValue" => 3} + } + } + + assert Featurevisor.set_datafile(sdk, patch) + assert_receive {:change, %{features: [], variables: ["alias", "other"]}} + assert Featurevisor.set_datafile(sdk, datafile(), true) + assert_receive {:change, %{features: ["root"], variables: ["alias", "other", "source"]}} + assert :sys.get_state(sdk.pid).dependency_index == %{reverse: %{}, owners: %{}} + after + Featurevisor.close(sdk) + end + end + end + + test "construction is lazy and cached updates only extract incoming entities" do + target = {Featurevisor.Dependencies, :entity, 2} + Code.ensure_loaded!(Featurevisor.Dependencies) + assert :erlang.trace_pattern(target, true, [:call_count]) == 1 + + try do + for size <- [10, 500] do + :erlang.trace_pattern(target, true, [:call_count]) + datafile = datafile() + + features = + Map.new( + 1..size, + &{"f#{&1}", + %{ + "hash" => "f", + "conditions" => "{\"variable\":\"source\",\"operator\":\"equals\",\"value\":1}" + }} + ) + + segments = + Map.new( + 1..size, + &{"s#{&1}", + %{"conditions" => "{\"attribute\":\"id\",\"operator\":\"equals\",\"value\":1}"}} + ) + + sdk = + Featurevisor.create_featurevisor(%{ + log_level: :fatal, + datafile: %{datafile | "features" => features, "segments" => segments} + }) + + assert :erlang.trace_info(target, :call_count) == {:call_count, 0} + + for revision <- 1..2 do + patch = %{ + datafile + | "revision" => "#{revision}", + "variables" => %{ + "source" => %{ + "hash" => "#{revision}", + "type" => "integer", + "defaultValue" => revision + } + } + } + + assert Featurevisor.set_datafile(sdk, patch) + expected = 2 * size + revision + assert :erlang.trace_info(target, :call_count) == {:call_count, expected} + end + + Featurevisor.close(sdk) + end + after + :erlang.trace_pattern(target, false, [:call_count]) + end + end + + test "replacement removes owners and the index is never published to readers" do + sdk = + Featurevisor.create_featurevisor(%{ + log_level: :fatal, + datafile: %{ + datafile() + | "variables" => %{ + "alias" => %{"hash" => "a", "type" => "integer", "compose" => "source"} + } + } + }) + + assert Featurevisor.set_datafile(sdk, %{ + datafile() + | "variables" => %{ + "source" => %{"hash" => "b", "type" => "integer", "defaultValue" => 2} + } + }) + + refute Map.has_key?(Featurevisor.Server.snapshot(sdk.table), :dependency_index) + assert Featurevisor.set_datafile(sdk, datafile(), true) + assert :sys.get_state(sdk.pid).dependency_index == %{reverse: %{}, owners: %{}} + Featurevisor.close(sdk) + end + + test "old then new traversal never follows a new edge back into the old graph" do + previous = %{ + datafile() + | "features" => %{ + "bridge" => %{"hash" => "same", "conditions" => "*"}, + "unaffected" => %{"hash" => "same", "conditions" => %{"feature" => "bridge"}} + } + } + + incoming = %{ + datafile() + | "features" => %{ + "bridge" => %{ + "hash" => "same", + "conditions" => %{"variable" => "source", "operator" => "equals", "value" => 1} + }, + "unaffected" => %{"hash" => "same", "conditions" => "*"} + }, + "variables" => %{"source" => %{"hash" => "new", "type" => "integer", "defaultValue" => 1}} + } + + sdk = Featurevisor.create_featurevisor(%{log_level: :fatal, datafile: previous}) + parent = self() + Featurevisor.on(sdk, :datafile_set, &send(parent, {:change, &1})) + assert Featurevisor.set_datafile(sdk, incoming) + assert_receive {:change, %{features: ["bridge"], variables: ["source"]}} + Featurevisor.close(sdk) + end + + test "concurrent writers serialize graph updates and preserve retained normalization slots" do + parent = self() + + input = %{ + datafile() + | "features" => %{"broken" => %{"hash" => "broken", "conditions" => "[broken"}}, + "variables" => %{ + "alias" => %{"hash" => "alias", "type" => "integer", "compose" => "source"} + } + } + + sdk = + Featurevisor.create_featurevisor(%{ + log_level: :error, + datafile: input, + on_diagnostic: fn diagnostic -> + if diagnostic.code == "conditions_parse_error", do: send(parent, :parse) + end + }) + + assert Featurevisor.evaluate_flag(sdk, "broken").reason == :conditions_unmet + assert_receive :parse + + writers = + for index <- 1..30 do + Task.async(fn -> + patch = %{ + datafile() + | "revision" => "#{index}", + "variables" => %{ + "v#{index}" => %{"hash" => "#{index}", "type" => "integer", "compose" => "source"} + } + } + + Featurevisor.set_datafile(sdk, patch) + end) + end + + assert Enum.all?(Task.await_many(writers, 10_000)) + for index <- 1..30, do: assert("v#{index}" in Featurevisor.get_global_variable_keys(sdk)) + assert Featurevisor.evaluate_flag(sdk, "broken").reason == :conditions_unmet + refute_receive :parse + + Featurevisor.on(sdk, :datafile_set, &send(parent, {:change, &1})) + + source = %{ + datafile() + | "variables" => %{ + "source" => %{"hash" => "source", "type" => "integer", "defaultValue" => 7} + } + } + + assert Featurevisor.set_datafile(sdk, source) + assert_receive {:change, details} + + assert Enum.sort(details.variables) == + Enum.sort(["source", "alias" | Enum.map(1..30, &"v#{&1}")]) + + for key <- details.variables, do: assert(Featurevisor.get_global_variable(sdk, key) == 7) + refute_receive :parse + Featurevisor.close(sdk) + end + + defp datafile do + %{ + "schemaVersion" => "4", + "revision" => "test", + "features" => %{}, + "variables" => %{}, + "segments" => %{} + } + end +end diff --git a/test/initial_loading_test.exs b/test/initial_loading_test.exs new file mode 100644 index 0000000..ccd9692 --- /dev/null +++ b/test/initial_loading_test.exs @@ -0,0 +1,176 @@ +defmodule Featurevisor.InitialLoadingTest do + use ExUnit.Case, async: false + + defp datafile do + %{ + "schemaVersion" => "4", + "revision" => "one", + "features" => %{}, + "variables" => %{}, + "segments" => %{} + } + end + + defp variable(hash, source) do + %{"hash" => hash, "type" => "integer", "compose" => source} + end + + defp feature(hash, conditions) do + %{ + "hash" => hash, + "bucketBy" => "id", + "conditions" => conditions, + "traffic" => [%{"key" => "all", "conditions" => "*", "percentage" => 100_000}] + } + end + + defp trace_dependencies(f) do + Code.ensure_loaded!(Featurevisor.Dependencies) + :erlang.trace_pattern({Featurevisor.Dependencies, :entity, 2}, true, []) + :erlang.trace(f.pid, true, [:call]) + + on_exit(fn -> + :erlang.trace_pattern({Featurevisor.Dependencies, :entity, 2}, false, []) + Featurevisor.close(f) + end) + end + + defp flush_trace(f) do + ref = :erlang.trace_delivered(f.pid) + assert_receive {:trace_delivered, _, ^ref} + end + + for replace <- [false, true] do + @replace replace + test "initial load skips dependency indexing then partial updates retain current dependencies replace=#{replace}" do + parent = self() + + f = + Featurevisor.create_featurevisor(%{ + on_diagnostic: fn diagnostic -> send(parent, {:diagnostic, diagnostic}) end + }) + + trace_dependencies(f) + Featurevisor.on(f, :datafile_set, fn details -> send(parent, {:set, details}) end) + + initial = %{ + datafile() + | "features" => %{ + "watcher" => + feature("watcher", ~s({"variable":"final","operator":"equals","value":3})) + }, + "variables" => %{ + "source" => %{"hash" => "one", "type" => "integer", "defaultValue" => 1}, + "middle" => variable("middle", "unused"), + "tail" => variable("tail", "middle"), + "final" => variable("final", "unused"), + "unused" => %{"hash" => "unused", "type" => "integer", "defaultValue" => 0} + } + } + + assert Featurevisor.set_datafile(f, initial, @replace) + flush_trace(f) + refute_received {:trace, _, :call, {Featurevisor.Dependencies, :entity, _}} + + assert_receive {:set, + %{ + features: ["watcher"], + variables: ["final", "middle", "source", "tail", "unused"], + replaced: @replace + }} + + update = %{ + datafile() + | "revision" => "two", + "variables" => %{ + "source" => %{"hash" => "two", "type" => "integer", "defaultValue" => 2}, + "middle" => variable("middle-two", "source"), + "tail" => %{"hash" => "tail-two", "type" => "integer", "defaultValue" => 3}, + "final" => variable("final-two", "tail") + } + } + + assert Featurevisor.set_datafile(f, update) + flush_trace(f) + assert_received {:trace, _, :call, {Featurevisor.Dependencies, :entity, _}} + + assert_receive {:set, + %{ + features: ["watcher"], + variables: ["final", "middle", "source", "tail"] + }} + + Featurevisor.Server.with_snapshot(f.table, fn snapshot -> + assert :ets.match_object(snapshot.regex_cache, {{:normalized, :_, :_}, :_}) == [] + end) + + refute_received {:diagnostic, %{code: "conditions_parse_error"}} + + third = put_in(datafile(), ["variables", "source"], update["variables"]["source"]) + third = put_in(third, ["variables", "source", "hash"], "three") + third = put_in(third, ["variables", "source", "defaultValue"], 4) + assert Featurevisor.set_datafile(f, third) + assert_receive {:set, %{features: [], variables: ["middle", "source"]}} + assert Featurevisor.get_global_variable(f, "middle") == 4 + assert Featurevisor.get_global_variable(f, "final") == 3 + assert Featurevisor.enabled?(f, "watcher") + end + + test "a previous segment alone prevents the initial shortcut replace=#{replace}" do + f = Featurevisor.create_featurevisor(%{log_level: :fatal}) + trace_dependencies(f) + initial = put_in(datafile(), ["segments", "retained"], %{"conditions" => "*"}) + assert Featurevisor.set_datafile(f, initial) + flush_trace(f) + refute_received {:trace, _, :call, {Featurevisor.Dependencies, :entity, _}} + + next = + initial + |> put_in(["features", "watcher"], feature("watcher", "retained")) + + assert Featurevisor.set_datafile(f, next, @replace) + flush_trace(f) + assert_received {:trace, _, :call, {Featurevisor.Dependencies, :entity, _}} + end + + test "raw indexing keeps malformed selector caches and diagnostics cold replace=#{replace}" do + parent = self() + + f = + Featurevisor.create_featurevisor(%{ + on_diagnostic: fn diagnostic -> send(parent, {:diagnostic, diagnostic}) end + }) + + on_exit(fn -> Featurevisor.close(f) end) + + input = + datafile() + |> put_in(["features", "broken"], feature("broken", "[")) + |> put_in(["segments", "broken"], %{"conditions" => "["}) + + assert Featurevisor.set_datafile(f, input, @replace) + + assert Featurevisor.set_datafile( + f, + put_in(datafile(), ["variables", "source"], %{ + "hash" => "source", + "type" => "integer", + "defaultValue" => 1 + }) + ) + + Featurevisor.Server.with_snapshot(f.table, fn snapshot -> + assert :ets.match_object(snapshot.regex_cache, {{:normalized, :_, :_}, :_}) == [] + end) + + refute_received {:diagnostic, %{code: "conditions_parse_error"}} + assert Featurevisor.get_feature(f, "broken")["conditions"] == %{"or" => []} + assert_receive {:diagnostic, %{code: "conditions_parse_error"}} + Featurevisor.get_feature(f, "broken") + refute_received {:diagnostic, %{code: "conditions_parse_error"}} + Featurevisor.get_segment(f, "broken") + assert_receive {:diagnostic, %{code: "conditions_parse_error"}} + assert input["features"]["broken"]["conditions"] == "[" + end + end +end diff --git a/test/live_modules_test.exs b/test/live_modules_test.exs new file mode 100644 index 0000000..829622c --- /dev/null +++ b/test/live_modules_test.exs @@ -0,0 +1,313 @@ +defmodule Featurevisor.LiveModulesTest do + use ExUnit.Case, async: true + + test "a diagnostic installs after hooks for the in flight dependency and both root types" do + for global <- [false, true], disable_gate <- [false, true] do + parent = self() + {:ok, holder} = Agent.start_link(fn -> nil end) + + sdk = + Featurevisor.create_featurevisor(%{ + log_level: :error, + datafile: datafile(), + on_diagnostic: fn diagnostic -> + if diagnostic.code == "conditions_parse_error" do + Featurevisor.add_module(Agent.get(holder, & &1), module(parent, disable_gate)) + end + end + }) + + Agent.update(holder, fn _ -> sdk end) + + evaluation = + if global, + do: Featurevisor.evaluate_global_variable(sdk, "subject"), + else: Featurevisor.evaluate_flag(sdk, "subject") + + if global do + assert evaluation.variable_value == if(disable_gate, do: nil, else: "matched") + else + assert evaluation.enabled == not disable_gate + end + + if disable_gate, do: assert(evaluation.reason == :conditions_unmet) + assert trace() == ["after:broken", "before:gate", "after:gate", "after:subject"] + Featurevisor.close(sdk) + Agent.stop(holder) + end + end + + test "a diagnostic removes hooks before the after phase for both root types" do + for global <- [false, true] do + parent = self() + {:ok, holder} = Agent.start_link(fn -> nil end) + + sdk = + Featurevisor.create_featurevisor(%{ + log_level: :error, + datafile: datafile(), + on_diagnostic: fn diagnostic -> + if diagnostic.code == "conditions_parse_error", do: Agent.get(holder, & &1).() + end + }) + + remove = Featurevisor.add_module(sdk, module(parent)) + Agent.update(holder, fn _ -> remove end) + assert evaluate(sdk, global) == if(global, do: "matched", else: true) + assert trace() == ["before:subject", "before:broken"] + Featurevisor.close(sdk) + Agent.stop(holder) + end + end + + test "before and after phases retain their starting membership for feature and global roots" do + for global <- [false, true], + phase <- [:before, :after], + action <- [:add, :remove, :remove_add] do + parent = self() + {:ok, once} = Agent.start_link(fn -> true end) + sdk = Featurevisor.create_featurevisor(%{datafile: phase_datafile(), log_level: :fatal}) + + mutate = fn -> + if Agent.get_and_update(once, &{&1, false}) do + if action in [:remove, :remove_add], do: Featurevisor.remove_module(sdk, "other") + + if action in [:add, :remove_add], + do: Featurevisor.add_module(sdk, phase_module(parent, "late")) + end + end + + Featurevisor.add_module(sdk, phase_module(parent, "adder", phase, mutate)) + remove_other = Featurevisor.add_module(sdk, phase_module(parent, "other")) + assert phase_evaluate(sdk, global) == if(global, do: 7, else: true) + + before = ["before:adder", "before:other"] + closing = if action in [:remove, :remove_add], do: ["close:other"], else: [] + + after_names = + case action do + :add -> ["adder", "other", "late"] + :remove -> ["adder"] + :remove_add -> ["adder", "late"] + end + + expected = + if phase == :before do + ["before:adder"] ++ closing ++ ["before:other"] ++ Enum.map(after_names, &"after:#{&1}") + else + before ++ ["after:adder"] ++ closing ++ ["after:other"] + end + + assert trace() == expected + assert phase_evaluate(sdk, global) == if(global, do: 7, else: true) + + assert trace() == + Enum.map(after_names, &"before:#{&1}") ++ Enum.map(after_names, &"after:#{&1}") + + remove_other.() + remove_other.() + assert trace() == if(action == :add, do: ["close:other"], else: []) + Featurevisor.close(sdk) + Featurevisor.close(sdk) + assert trace() == Enum.map(List.delete(after_names, "other"), &"close:#{&1}") + Agent.stop(once) + end + end + + test "bucket key and value phases refresh membership but keep their own immutable lists" do + for phase <- [:before, :bucket_key, :bucket_value], action <- [:add, :remove, :remove_add] do + parent = self() + {:ok, once} = Agent.start_link(fn -> true end) + sdk = Featurevisor.create_featurevisor(%{datafile: phase_datafile(), log_level: :fatal}) + + mutate = fn -> + if Agent.get_and_update(once, &{&1, false}) do + if action in [:remove, :remove_add], do: Featurevisor.remove_module(sdk, "other") + + if action in [:add, :remove_add], + do: Featurevisor.add_module(sdk, bucket_module(parent, "late")) + end + end + + Featurevisor.add_module(sdk, bucket_module(parent, "adder", phase, mutate)) + Featurevisor.add_module(sdk, bucket_module(parent, "other")) + evaluation = Featurevisor.evaluate_flag(sdk, "plain", %{"id" => "test"}) + assert evaluation.enabled + assert evaluation.bucket_value == 42 + + starting = ["adder", "other"] + + following = + case action do + :add -> ["adder", "other", "late"] + :remove -> ["adder"] + :remove_add -> ["adder", "late"] + end + + {expected, _} = + Enum.map_reduce([:before, :bucket_key, :bucket_value, :after], starting, fn current, + names -> + values = Enum.map(names, &"#{current}:#{&1}") + {values, if(current == phase, do: following, else: names)} + end) + + assert trace() == List.flatten(expected) + assert Featurevisor.enabled?(sdk, "plain", %{"id" => "test"}) + + assert trace() == + for( + current <- [:before, :bucket_key, :bucket_value, :after], + name <- following, + do: "#{current}:#{name}" + ) + + Featurevisor.close(sdk) + Agent.stop(once) + end + end + + test "membership changes survive callback errors without continuing the failed phase" do + for global <- [false, true], phase <- [:before, :after] do + parent = self() + {:ok, once} = Agent.start_link(fn -> true end) + sdk = Featurevisor.create_featurevisor(%{datafile: phase_datafile(), log_level: :fatal}) + + mutate = fn -> + if Agent.get_and_update(once, &{&1, false}) do + Featurevisor.remove_module(sdk, "other") + Featurevisor.add_module(sdk, phase_module(parent, "late")) + raise "phase failure" + end + end + + Featurevisor.add_module(sdk, phase_module(parent, "adder", phase, mutate)) + Featurevisor.add_module(sdk, phase_module(parent, "other")) + + evaluation = + if global, + do: Featurevisor.evaluate_global_variable(sdk, "plain"), + else: Featurevisor.evaluate_flag(sdk, "plain", %{"id" => "test"}) + + assert evaluation.reason == :error + assert Exception.message(evaluation.error) == "phase failure" + + assert trace() == + if(phase == :before, + do: ["before:adder", "close:other"], + else: ["before:adder", "before:other", "after:adder", "close:other"] + ) + + assert phase_evaluate(sdk, global) == if(global, do: 7, else: true) + assert trace() == ["before:adder", "before:late", "after:adder", "after:late"] + Featurevisor.close(sdk) + assert trace() == ["close:adder", "close:late"] + Agent.stop(once) + end + end + + defp phase_evaluate(sdk, true), do: Featurevisor.get_global_variable(sdk, "plain") + defp phase_evaluate(sdk, false), do: Featurevisor.enabled?(sdk, "plain", %{"id" => "test"}) + + defp phase_module(parent, name, phase \\ nil, mutate \\ fn -> :ok end) do + %Featurevisor.Module{ + name: name, + before_evaluation: fn options -> + send(parent, {:trace, "before:#{name}"}) + if phase == :before, do: mutate.() + options + end, + after_evaluation: fn evaluation, _options -> + send(parent, {:trace, "after:#{name}"}) + if phase == :after, do: mutate.() + evaluation + end, + close: fn -> send(parent, {:trace, "close:#{name}"}) end + } + end + + defp bucket_module(parent, name, phase \\ nil, mutate \\ fn -> :ok end) do + module = phase_module(parent, name, phase, mutate) + + %{ + module + | close: nil, + bucket_key: fn options -> + send(parent, {:trace, "bucket_key:#{name}"}) + if phase == :bucket_key, do: mutate.() + options.bucket_key <> ":#{name}" + end, + bucket_value: fn _options -> + send(parent, {:trace, "bucket_value:#{name}"}) + if phase == :bucket_value, do: mutate.() + 42 + end + } + end + + defp phase_datafile do + %{ + datafile() + | "features" => %{"plain" => datafile()["features"]["gate"]}, + "variables" => %{"plain" => %{"type" => "integer", "defaultValue" => 7}} + } + end + + defp module(parent, disable_gate \\ false) do + %Featurevisor.Module{ + name: "live", + before_evaluation: fn options -> + send(parent, {:trace, "before:#{options[:feature_key] || options[:variable_key]}"}) + options + end, + after_evaluation: fn evaluation, options -> + send(parent, {:trace, "after:#{options[:feature_key] || options[:variable_key]}"}) + refute Map.has_key?(options, :sticky_owner) + assert is_map(options.sticky_features) or is_nil(options.sticky_features) + assert is_map(options.sticky_variables) or is_nil(options.sticky_variables) + if options[:feature_key], do: refute(is_struct(options.datafile["features"])) + + if disable_gate and options[:feature_key] == "gate", + do: %{evaluation | enabled: false}, + else: evaluation + end + } + end + + defp trace do + receive do + {:trace, value} -> [value | trace()] + after + 0 -> [] + end + end + + defp evaluate(sdk, true), do: Featurevisor.get_global_variable(sdk, "subject") + defp evaluate(sdk, false), do: Featurevisor.enabled?(sdk, "subject") + + defp datafile do + feature = %{ + "bucketBy" => "id", + "traffic" => [%{"key" => "all", "conditions" => "*", "percentage" => 100_000}] + } + + conditions = %{"or" => [%{"feature" => "broken"}, %{"feature" => "gate"}]} + + %{ + "schemaVersion" => "4", + "revision" => "live", + "segments" => %{}, + "features" => %{ + "broken" => Map.put(feature, "conditions", "[broken"), + "gate" => feature, + "subject" => Map.put(feature, "conditions", conditions) + }, + "variables" => %{ + "subject" => %{ + "type" => "string", + "defaultValue" => "matched", + "conditions" => conditions + } + } + } + end +end diff --git a/test/modules_and_child_test.exs b/test/modules_and_child_test.exs index f05dd41..8e788c4 100644 --- a/test/modules_and_child_test.exs +++ b/test/modules_and_child_test.exs @@ -23,10 +23,12 @@ defmodule Featurevisor.ModulesAndChildTest do api.report_diagnostic.(%{level: :info, code: "ready", message: "ready", details: %{}}) end, - before: fn options -> %{options | context: Map.put(options.context, "country", "nl")} end, + before_evaluation: fn options -> + %{options | context: Map.put(options.context, "country", "nl")} + end, bucket_key: fn options -> options.bucket_key <> ".module" end, bucket_value: fn _ -> 75_000 end, - after: fn evaluation, _ -> evaluation end, + after_evaluation: fn evaluation, _ -> evaluation end, close: fn -> send(parent, :closed) end } @@ -51,10 +53,6 @@ defmodule Featurevisor.ModulesAndChildTest do Enum.map(["first", "second"], fn name -> %Module{ name: name, - before: fn options -> - send(parent, "before:#{name}") - options - end, before_evaluation: fn options -> send(parent, "beforeEvaluation:#{name}") options @@ -62,10 +60,6 @@ defmodule Featurevisor.ModulesAndChildTest do after_evaluation: fn evaluation, _ -> send(parent, "afterEvaluation:#{name}") evaluation - end, - after: fn evaluation, _ -> - send(parent, "after:#{name}") - evaluation end } end) @@ -79,17 +73,15 @@ defmodule Featurevisor.ModulesAndChildTest do Featurevisor.enabled?(f, "flag", %{"userId" => "1"}) - assert_receive "before:first" - assert_receive "before:second" assert_receive "beforeEvaluation:first" assert_receive "beforeEvaluation:second" assert_receive "afterEvaluation:first" assert_receive "afterEvaluation:second" - assert_receive "after:first" - assert_receive "after:second" end test "required feature evaluations use modules" do + parent = self() + datafile = %{ "schemaVersion" => "2", "revision" => "required", @@ -121,10 +113,31 @@ defmodule Featurevisor.ModulesAndChildTest do Featurevisor.create_featurevisor(%{ datafile: datafile, modules: [module], - log_level: :fatal + log_level: :debug, + on_diagnostic: fn diagnostic -> send(parent, {:prerequisite, diagnostic}) end }) refute Featurevisor.enabled?(f, "dependent") + evaluation = Featurevisor.evaluate_flag(f, "dependent") + assert evaluation.reason == :conditions_unmet + assert evaluation.enabled == false + assert evaluation.conditions == [%{"feature" => "enabled"}] + + wire = evaluation |> Featurevisor.Evaluation.to_map() |> Jason.encode!() |> Jason.decode!() + assert wire["reason"] == "conditions_unmet" + assert wire["conditions"] == [%{"feature" => "enabled"}] + + assert_receive {:prerequisite, + %{ + code: "conditions_unmet", + details: %{ + reason: "conditions_unmet", + evaluation: %{"reason" => :conditions_unmet} + } + }} + + refute_receive {:prerequisite, %{code: "required"}} + Featurevisor.close(f) end test "explicit null feature variable beats the caller default" do @@ -213,7 +226,9 @@ defmodule Featurevisor.ModulesAndChildTest do "schemaVersion" => "2", "revision" => "global", "segments" => %{}, - "features" => %{}, + "features" => %{ + "disabled" => %{"bucketBy" => "userId", "traffic" => []} + }, "variables" => %{ "message" => %{ "type" => "string", @@ -240,6 +255,7 @@ defmodule Featurevisor.ModulesAndChildTest do f = Featurevisor.create_featurevisor(%{ datafile: datafile, + sticky_features: %{"disabled" => %{"enabled" => true}}, sticky_variables: %{"message" => "parent"}, modules: [module], log_level: :fatal @@ -252,12 +268,57 @@ defmodule Featurevisor.ModulesAndChildTest do assert Featurevisor.Child.get_global_variable(plain, "message") == "after" Featurevisor.remove_module(f, "global") + assert Featurevisor.enabled?(f, "disabled") + refute Featurevisor.Child.enabled?(plain, "disabled") assert Featurevisor.get_global_variable(f, "message") == "parent" assert Featurevisor.Child.get_global_variable(child, "message") == "child" assert Featurevisor.Child.get_global_variable(plain, "message") == "default" Featurevisor.close(f) end + test "required features treat a source module error as disabled" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "module-error-requirement", + "segments" => %{}, + "features" => %{ + "source" => %{ + "bucketBy" => "userId", + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + }, + "explicitFalse" => %{ + "bucketBy" => "userId", + "requiredFeatures" => [%{"feature" => "source", "enabled" => false}], + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + }, + "negated" => %{ + "bucketBy" => "userId", + "requiredFeatures" => %{"not" => ["source"]}, + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + } + } + } + + module = %Module{ + name: "source-error", + before_evaluation: fn options -> + if options.feature_key == "source", do: raise("source failed"), else: options + end + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + modules: [module], + log_level: :fatal + }) + + refute Featurevisor.enabled?(f, "source") + assert Featurevisor.enabled?(f, "explicitFalse") + assert Featurevisor.enabled?(f, "negated") + Featurevisor.close(f) + end + test "concurrent module registration reserves names and returned unsubscribers survive close" do parent = self() f = Featurevisor.create_featurevisor(%{log_level: :fatal}) diff --git a/test/native_timestamp_test.exs b/test/native_timestamp_test.exs new file mode 100644 index 0000000..535b395 --- /dev/null +++ b/test/native_timestamp_test.exs @@ -0,0 +1,68 @@ +defmodule Featurevisor.NativeTimestampTest do + use ExUnit.Case, async: true + alias Featurevisor.Conditions + + setup do + %{cache: :ets.new(:native_dates, [:set, :public])} + end + + test "native values truncate rather than round around the epoch", %{cache: cache} do + for stamp <- ["2026-01-01T00:00:00Z", "1969-12-31T23:59:59.999Z"] do + {:ok, boundary, 0} = DateTime.from_iso8601(stamp) + fractional = DateTime.add(boundary, 999, :microsecond) + + for op <- ["before", "after"] do + refute matches(fractional, op, stamp, cache) + + assert matches( + fractional, + op, + DateTime.to_iso8601(DateTime.add(boundary, 1, :millisecond)), + cache + ) == (op == "before") + + assert matches( + fractional, + op, + DateTime.to_iso8601(DateTime.add(boundary, -1, :millisecond)), + cache + ) == (op == "after") + end + end + end + + test "native ranges and types reject invalid operands", %{cache: cache} do + assert matches(~U[0001-01-01 00:00:00Z], "before", "2026-01-01T00:00:00Z", cache) + assert matches(~U[9999-12-31 23:59:59.999999Z], "after", "2026-01-01T00:00:00Z", cache) + host = ~U[2026-01-01 00:00:00Z] + + for offset <- [-50_400, 50_400] do + assert matches(%{host | utc_offset: offset}, "before", "2026-01-02T00:00:00Z", cache) + end + + invalid = [ + ~D[2026-01-01], + ~N[2026-01-01 00:00:00], + %{host | year: 0}, + %{host | year: 10_000}, + %{host | utc_offset: 50_401}, + %{host | utc_offset: -50_401}, + %{host | utc_offset: 50_400, std_offset: 3600}, + %{~U[0001-01-01 00:00:00Z] | utc_offset: 3600}, + %{~U[9999-12-31 23:59:59Z] | utc_offset: -3600}, + "2026-01-01", + "2026-01-01T00:00:00", + 1_767_225_600_000, + nil + ] + + for actual <- invalid, op <- ["before", "after"] do + refute matches(actual, op, "2026-01-01T00:00:00Z", cache) + refute matches(host, op, actual, cache) + end + end + + defp matches(actual, op, expected, cache) do + Conditions.value_matches?(actual, %{"operator" => op, "value" => expected}, cache) + end +end diff --git a/test/normalization_and_composition_test.exs b/test/normalization_and_composition_test.exs new file mode 100644 index 0000000..6413eb4 --- /dev/null +++ b/test/normalization_and_composition_test.exs @@ -0,0 +1,502 @@ +defmodule Featurevisor.NormalizationAndCompositionTest do + use ExUnit.Case, async: true + alias Featurevisor.{Child, Module} + + defp datafile(version \\ "4"), + do: %{ + "schemaVersion" => version, + "revision" => "one", + "segments" => %{}, + "features" => %{}, + "variables" => %{} + } + + defp feature(conditions), + do: %{ + "bucketBy" => "id", + "conditions" => conditions, + "traffic" => [%{"key" => "all", "conditions" => "*", "percentage" => 100_000}] + } + + defp variable(value), do: %{"type" => "object", "defaultValue" => value} + + test "normalization diagnoses once per entity load, caches before callbacks and retains unrelated caches" do + for version <- ["2", "4"] do + parent = self() + handle = :ets.new(:handle, [:set, :public]) + + f = + Featurevisor.create_featurevisor(%{ + log_level: :error, + on_diagnostic: fn diagnostic -> + if diagnostic.code == "conditions_parse_error" do + [{_, f}] = :ets.lookup(handle, :instance) + assert Featurevisor.get_revision(f) == "one" + assert Featurevisor.evaluate_flag(f, "broken").reason == :conditions_unmet + send(parent, {:parse, diagnostic}) + end + end + }) + + :ets.insert(handle, {:instance, f}) + input = datafile(version) |> put_in(["features", "broken"], feature("[")) + assert Featurevisor.set_datafile(f, input) + if version == "4", do: refute_receive({:parse, _}) + assert Featurevisor.evaluate_flag(f, "broken").reason == :conditions_unmet + assert_receive {:parse, diagnostic} + assert diagnostic.level == :error + assert diagnostic.details == %{conditions: "[", entityType: "feature", entityKey: "broken"} + assert Featurevisor.evaluate_flag(f, "broken").reason == :conditions_unmet + refute_receive {:parse, _} + assert Featurevisor.set_datafile(f, datafile("4")) + Featurevisor.evaluate_flag(f, "broken") + refute_receive {:parse, _} + assert Featurevisor.set_datafile(f, input) + Featurevisor.evaluate_flag(f, "broken") + assert_receive {:parse, _} + assert Featurevisor.set_datafile(f, input, true) + Featurevisor.evaluate_flag(f, "broken") + assert_receive {:parse, _} + assert input["features"]["broken"]["conditions"] == "[" + Featurevisor.close(f) + end + end + + test "mixed selector arrays have the exact public flattened shape and invalid roots are ordinary wire shapes" do + expression = %{"attribute" => "country", "operator" => "equals", "value" => "nl"} + + for version <- ["2", "3", "4"] do + input = + datafile(version) + |> put_in( + ["features", "mixed"], + Map.merge(feature([expression]), %{ + "segments" => ["unknown"], + "requiredFeatures" => ["gate"] + }) + ) + |> put_in(["features", "empty"], Map.put(feature([]), "segments", "*")) + |> put_in(["features", "broken"], feature("[")) + |> put_in(["variables", "broken"], Map.put(variable("default"), "conditions", "[")) + + f = Featurevisor.create_featurevisor(%{datafile: input, log_level: :fatal}) + + assert Featurevisor.evaluate_flag(f, "mixed").conditions == [ + expression, + "unknown", + %{"feature" => "gate"} + ] + + assert Featurevisor.evaluate_flag(f, "empty").conditions == [%{"and" => []}, "*"] + assert Featurevisor.evaluate_flag(f, "broken").conditions == %{"or" => []} + assert Featurevisor.evaluate_global_variable(f, "broken").conditions == %{"or" => []} + assert Featurevisor.get_feature(f, "broken")["conditions"] == %{"or" => []} + Featurevisor.close(f) + end + end + + test "a cold lazy read retained across a partial update diagnoses only once for that entity load" do + alias Featurevisor.{Normalize, Server} + parent = self() + input = datafile() |> put_in(["features", "broken"], feature("[")) + + report = fn diagnostic -> + if diagnostic.code == "conditions_parse_error", do: send(parent, :parse) + end + + f = + Featurevisor.create_featurevisor(%{ + datafile: input, + log_level: :error, + on_diagnostic: report + }) + + Server.with_snapshot(f.table, fn previous -> + assert Featurevisor.set_datafile( + f, + datafile() |> put_in(["variables", "unrelated"], variable(true)) + ) + + Normalize.get(previous.datafile, "features", "broken", previous.regex_cache, report) + assert_receive :parse + assert Featurevisor.evaluate_flag(f, "broken").reason == :conditions_unmet + refute_receive :parse + end) + + assert Featurevisor.set_datafile(f, input) + assert Featurevisor.evaluate_flag(f, "broken").reason == :conditions_unmet + assert_receive :parse + Featurevisor.close(f) + end + + test "invalid segment aliases cannot be inverted and malformed selectors stay local" do + for version <- ["2", "4"] do + input = + datafile(version) + |> put_in(["segments", "broken"], %{"conditions" => "["}) + |> put_in(["segments", "alias"], %{"conditions" => "broken"}) + |> put_in(["features", "broken"], feature(%{"not" => ["alias"]})) + |> put_in(["features", "healthy"], feature("*")) + + f = Featurevisor.create_featurevisor(%{datafile: input, log_level: :fatal}) + assert Featurevisor.evaluate_flag(f, "broken").reason == :conditions_unmet + assert Featurevisor.enabled?(f, "healthy") + Featurevisor.close(f) + end + end + + test "mixed version partial merges retain eager legacy semantics without per entity provenance" do + legacy = + datafile("2") + |> put_in(["features", "legacy"], %{ + "bucketBy" => "id", + "force" => [%{"conditions" => [], "enabled" => true}] + }) + |> put_in( + ["variables", "legacy"], + Map.put(variable("legacy"), "overrides", [%{"conditions" => [], "value" => "old"}]) + ) + + modern = datafile("4") |> put_in(["features", "modern"], feature([])) + f = Featurevisor.create_featurevisor(%{datafile: legacy, log_level: :fatal}) + assert Featurevisor.set_datafile(f, modern) + assert Featurevisor.get_schema_version(f) == "4" + assert Featurevisor.enabled?(f, "legacy") + refute Featurevisor.enabled?(f, "modern") + assert Featurevisor.get_global_variable(f, "legacy") == "old" + assert Featurevisor.set_datafile(f, datafile("2")) + refute Featurevisor.enabled?(f, "modern") + assert Featurevisor.set_datafile(f, modern, true) + refute Featurevisor.enabled?(f, "legacy") + assert legacy["features"]["legacy"]["force"] |> hd() |> Map.fetch!("conditions") == [] + Featurevisor.close(f) + end + + test "removed unreleased requirement selectors are ignored, not retained as evaluation mechanisms" do + input = + datafile() + |> put_in( + ["features", "subject"], + Map.merge(feature("*"), %{ + "required" => ["missing"], + "requiredVariables" => [%{"variable" => "subject", "operator" => "isNull"}] + }) + ) + |> put_in( + ["variables", "subject"], + Map.merge(variable(true), %{ + "requiredVariables" => [%{"variable" => "subject", "operator" => "isNull"}] + }) + ) + + f = Featurevisor.create_featurevisor(%{datafile: input, log_level: :fatal}) + assert Featurevisor.enabled?(f, "subject") + assert Featurevisor.get_global_variable(f, "subject") == true + refute Map.has_key?(Featurevisor.get_feature(f, "subject"), "requiredVariables") + Featurevisor.close(f) + end + + test "historical empty expressions are limited to released selector sites" do + for version <- ["2", "4"] do + input = + datafile(version) + |> put_in(["segments", "empty"], %{"conditions" => []}) + |> put_in(["features", "segment"], feature("empty")) + |> put_in(["features", "root"], feature([])) + |> put_in(["features", "mixed"], Map.put(feature([]), "segments", "*")) + |> put_in(["features", "traffic"], %{ + "bucketBy" => "id", + "traffic" => [%{"conditions" => [], "percentage" => 100_000}] + }) + |> put_in( + ["variables", "program"], + Map.put(variable("default"), "overrideProgram", [ + %{ + "conditions" => [], + "operations" => [%{"op" => "set", "path" => [], "value" => "wrong"}], + "onMatch" => 1, + "onMiss" => 1 + } + ]) + ) + + f = Featurevisor.create_featurevisor(%{datafile: input, log_level: :fatal}) + assert Featurevisor.enabled?(f, "segment") == (version == "2") + for key <- ["root", "mixed", "traffic"], do: refute(Featurevisor.enabled?(f, key)) + assert Featurevisor.get_global_variable(f, "program") == "default" + Featurevisor.close(f) + end + end + + test "conditions and compositions use separate purpose memo entries and private sessions" do + parent = self() + + input = + datafile() + |> put_in(["variables", "source"], %{"type" => "json", "defaultValue" => "\"null\""}) + |> put_in(["variables", "alias"], %{"type" => "json", "compose" => "source"}) + |> put_in(["variables", "view"], %{ + "type" => "object", + "conditions" => %{"variable" => "alias", "operator" => "equals", "value" => "null"}, + "compose" => [ + %{"key" => "one", "variable" => "alias"}, + %{"key" => "two", "variable" => "alias"} + ] + }) + + module = %Module{ + before_evaluation: fn options -> + refute Map.has_key?(options, :session) + refute Map.has_key?(options, :__session) + + if options[:dependency_purpose] do + send( + parent, + {:read, options.variable_key, options.dependency_purpose, options.dependency_of} + ) + else + refute Map.has_key?(options, :dependency_of) + refute Map.has_key?(options, :dependency_purpose) + end + + options + end + } + + f = Featurevisor.create_featurevisor(%{datafile: input, modules: [module], log_level: :fatal}) + + for _ <- 1..2 do + assert Featurevisor.get_global_variable(f, "view") == %{"one" => "null", "two" => "null"} + assert_receive {:read, "alias", :condition, %{type: :variable, variable_key: "view"}} + assert_receive {:read, "alias", :composition, %{type: :variable, variable_key: "view"}} + refute_receive {:read, "alias", _, _} + + assert_receive {:read, "source", :composition, %{type: :variable, variable_key: "alias"}} + end + + Featurevisor.close(f) + end + + test "composed sources use child context, modules and isolated sticky state without inheriting defaults" do + source = %{"nested" => %{"limit" => 5}, "list" => ["card"]} + + input = + datafile() + |> put_in(["variables", "source"], variable(source)) + |> put_in(["variables", "alias"], %{"type" => "object", "compose" => "source"}) + |> put_in(["variables", "view"], %{ + "type" => "object", + "compose" => [ + %{"key" => "one", "variable" => "source"}, + %{"key" => "two", "variable" => "source"} + ], + "overrideProgram" => [ + %{ + "key" => "edit", + "conditions" => "*", + "operations" => [ + %{"op" => "set", "path" => ["one", "nested", "limit"], "value" => 10} + ], + "onMatch" => 1, + "onMiss" => 1 + } + ] + }) + |> put_in(["variables", "missing"], %{"type" => "object", "compose" => "absent"}) + |> put_in(["variables", "gated"], %{ + "type" => "object", + "compose" => "source", + "conditions" => [] + }) + + module = %Module{ + after_evaluation: fn evaluation, options -> + if options[:variable_key] == "source" and options.context["mode"] == "module", + do: %{evaluation | variable_value: source |> put_in(["nested", "limit"], 7)}, + else: evaluation + end + } + + f = Featurevisor.create_featurevisor(%{datafile: input, log_level: :fatal, modules: [module]}) + + child = + Featurevisor.spawn(f, %{"mode" => "module"}, %{sticky_variables: %{"source" => source}}) + + assert Child.get_global_variable(child, "alias")["nested"]["limit"] == 7 + assert Featurevisor.get_global_variable(f, "view")["one"]["nested"]["limit"] == 10 + assert Featurevisor.get_global_variable(f, "view")["two"] == source + assert Featurevisor.get_global_variable(f, "source") == source + + assert Featurevisor.get_global_variable(f, "missing", %{}, %{default_variable_value: "caller"}) == + nil + + assert Featurevisor.get_global_variable(f, "gated", %{}, %{default_variable_value: "caller"}) == + nil + + assert Featurevisor.evaluate_global_variable(f, "gated").reason == :conditions_unmet + + assert Child.set_sticky_variables(child, %{"view" => source}, true) == :ok + assert Child.get_global_variable(child, "view") == source + assert source["nested"]["limit"] == 5 + Child.close(child) + Featurevisor.close(f) + end + + test "closed detailed getters keep ordinary missing reasons and root dependency errors report diagnostics" do + parent = self() + input = datafile() |> put_in(["features", "loop"], feature(%{"feature" => "loop"})) + + f = + Featurevisor.create_featurevisor(%{ + datafile: input, + log_level: :error, + on_diagnostic: fn diagnostic -> send(parent, {:diagnostic, diagnostic}) end + }) + + assert Featurevisor.evaluate_flag(f, "loop").reason == :error + assert_receive {:diagnostic, %{code: "evaluation_error", level: :error}} + Featurevisor.close(f) + assert Featurevisor.evaluate_flag(f, "loop").reason == :feature_not_found + assert Featurevisor.evaluate_global_variable(f, "missing").reason == :variable_not_found + assert Featurevisor.get_feature(f, "loop") == nil + assert Featurevisor.get_segment(f, "missing") == nil + end + + test "composition memo values remain on the request heap and are released on every exit" do + alias Featurevisor.{Evaluation, EvaluationSession} + session = EvaluationSession.new() + root = %{type: :variable, variable_key: "root", context: %{}} + source = for index <- 1..5000, do: %{"nested" => [index, index + 1]} + + options = %{ + type: :variable, + variable_key: "source", + context: %{}, + dependency_purpose: :composition + } + + EvaluationSession.run(session, root, false, fn _ -> + for _ <- 1..2 do + result = + EvaluationSession.run(session, options, true, fn _ -> + send(self(), :composed_source_read) + %Evaluation{type: :variable, variable_value: source} + end) + + assert result.variable_value == source + end + end) + + assert_receive :composed_source_read + refute_receive :composed_source_read + assert :ets.info(session, :memory) < 2000 + EvaluationSession.close(session) + assert Process.get({EvaluationSession, session, :borrowed}) == nil + end + + test "dependency events propagate raw stringified mixed graphs, additions and removals without warming caches" do + parent = self() + + input = + datafile() + |> put_in(["variables", "source"], Map.put(variable(true), "hash", "source-one")) + |> put_in(["segments", "leaf"], %{ + "conditions" => + Jason.encode!(%{"variable" => "source", "operator" => "equals", "value" => true}) + }) + |> put_in(["segments", "outer"], %{"conditions" => "leaf"}) + |> put_in(["features", "dependent"], Map.put(feature("outer"), "hash", "dependent")) + |> put_in(["segments", "feature"], %{"conditions" => %{"feature" => "dependent"}}) + |> put_in(["variables", "alias"], %{ + "hash" => "alias", + "type" => "object", + "compose" => "source" + }) + |> put_in(["variables", "view"], %{ + "hash" => "view", + "type" => "object", + "conditions" => "feature", + "compose" => [%{"key" => "source", "variable" => "alias"}] + }) + |> put_in(["features", "broken"], Map.put(feature("["), "hash", "broken")) + + f = + Featurevisor.create_featurevisor(%{ + datafile: input, + log_level: :error, + on_diagnostic: fn diagnostic -> + if diagnostic.code == "conditions_parse_error", do: send(parent, :parse) + end + }) + + Featurevisor.on(f, :datafile_set, &send(parent, {:change, &1})) + refute_receive :parse + + update = + datafile() + |> put_in(["variables", "source"], Map.put(variable(false), "hash", "source-two")) + + assert Featurevisor.set_datafile(f, update) + assert_receive {:change, details} + assert "dependent" in details.features + for key <- ["source", "alias", "view"], do: assert(key in details.variables) + refute_receive :parse + + removed = + update + |> Map.put("features", input["features"]) + |> Map.put("segments", input["segments"]) + |> Map.put("variables", Map.delete(input["variables"], "source")) + + assert Featurevisor.set_datafile(f, removed, true) + assert_receive {:change, removed_details} + assert "dependent" in removed_details.features + for key <- ["source", "alias", "view"], do: assert(key in removed_details.variables) + assert Featurevisor.set_datafile(f, input, true) + assert_receive {:change, added_details} + assert "dependent" in added_details.features + for key <- ["source", "alias", "view"], do: assert(key in added_details.variables) + refute_receive :parse + assert Featurevisor.evaluate_flag(f, "broken").reason == :conditions_unmet + assert_receive :parse + Featurevisor.close(f) + end + + test "mixed segment getter cycles and the combined depth limit fail whole evaluations and clean up" do + input = + datafile() + |> put_in(["segments", "loop"], %{ + "conditions" => %{"variable" => "view", "operator" => "notNull"} + }) + |> put_in(["variables", "view"], %{ + "type" => "object", + "compose" => "source", + "conditions" => %{"not" => ["loop"]} + }) + |> put_in(["variables", "source"], variable("healthy")) + + f = Featurevisor.create_featurevisor(%{datafile: input, log_level: :fatal}) + + for _ <- 1..2 do + assert Featurevisor.evaluate_global_variable(f, "view").reason == :error + assert Featurevisor.get_global_variable(f, "source") == "healthy" + end + + deep = + Enum.reduce(0..259, input, fn index, input -> + input + |> put_in(["segments", "s#{index}"], %{ + "conditions" => %{"variable" => "v#{index}", "operator" => "notNull"} + }) + |> put_in( + ["variables", "v#{index}"], + Map.put(variable(true), "conditions", if(index == 259, do: "*", else: "s#{index + 1}")) + ) + end) + + assert Featurevisor.set_datafile(f, deep, true) + assert Featurevisor.evaluate_global_variable(f, "v0").reason == :error + assert Featurevisor.get_global_variable(f, "source") == "healthy" + Featurevisor.close(f) + end +end diff --git a/test/number_getter_test.exs b/test/number_getter_test.exs new file mode 100644 index 0000000..27168e4 --- /dev/null +++ b/test/number_getter_test.exs @@ -0,0 +1,59 @@ +defmodule Featurevisor.NumberGetterTest do + use ExUnit.Case, async: true + + @datafile %{ + "schemaVersion" => "4", + "revision" => "1", + "segments" => %{}, + "features" => %{ + "pricing" => %{ + "bucketBy" => "userId", + "variablesSchema" => %{ + "rate" => %{"type" => "number", "defaultValue" => 2.5}, + "ratio" => %{"type" => "double", "defaultValue" => 1.5} + }, + "traffic" => [%{"key" => "all", "conditions" => "*", "percentage" => 100_000}] + } + }, + "variables" => %{ + "rate" => %{"type" => "number", "defaultValue" => 2.5}, + "ratio" => %{"type" => "double", "defaultValue" => 1.5} + } + } + + setup do + f = Featurevisor.create_featurevisor(%{datafile: @datafile, log_level: :fatal}) + on_exit(fn -> Featurevisor.close(f) end) + %{f: f} + end + + test "reads number variables and earlier double variables the same way", %{f: f} do + context = %{"userId" => "u"} + assert Featurevisor.get_variable_number(f, "pricing", "rate", context) == 2.5 + assert Featurevisor.get_variable_number(f, "pricing", "ratio", context) == 1.5 + assert Featurevisor.get_global_variable_number(f, "rate") == 2.5 + assert Featurevisor.get_global_variable_number(f, "ratio") == 1.5 + + child = Featurevisor.spawn(f, context) + assert Featurevisor.Child.get_variable_number(child, "pricing", "rate") == 2.5 + assert Featurevisor.Child.get_global_variable_number(child, "rate") == 2.5 + end + + test "no longer has the double getters removed in v4" do + for {module, name, arity} <- [ + {Featurevisor, :get_variable_double, 5}, + {Featurevisor, :get_global_variable_double, 4}, + {Featurevisor.Child, :get_variable_double, 5}, + {Featurevisor.Child, :get_global_variable_double, 4} + ] do + Code.ensure_loaded!(module) + refute function_exported?(module, name, arity) + + assert function_exported?( + module, + String.to_atom(String.replace(Atom.to_string(name), "double", "number")), + arity + ) + end + end +end diff --git a/test/partial_snapshot_scaling_test.exs b/test/partial_snapshot_scaling_test.exs new file mode 100644 index 0000000..318ff77 --- /dev/null +++ b/test/partial_snapshot_scaling_test.exs @@ -0,0 +1,311 @@ +defmodule Featurevisor.PartialSnapshotScalingTest do + use ExUnit.Case, async: false + + alias Featurevisor.{DatafileStore, Dependencies, Normalize, Server} + + test "partial messages contain only prepared input regardless of retained project size" do + for version <- ["2", "4"] do + sizes = + for size <- [1, 1500] do + retained = Map.new(1..size, &{"f#{&1}", Map.put(feature(), "hash", "retained")}) + + sdk = + Featurevisor.create_featurevisor(%{ + datafile: %{datafile(version) | "features" => retained}, + log_level: :fatal + }) + + store = :sys.get_state(sdk.pid).store + server = sdk.pid + previous = Server.snapshot(sdk.table).regex_cache + + incoming = + put_in( + patch(version, 1), + ["features", "changed"], + Map.put( + feature(), + "conditions", + "{\"attribute\":\"id\",\"operator\":\"equals\",\"value\":1}" + ) + ) + + {prepared, []} = Normalize.datafile(incoming) + :erlang.trace(store, true, [:receive, {:tracer, self()}]) + :erlang.trace(server, true, [:receive, {:tracer, self()}]) + + try do + assert Featurevisor.set_datafile(sdk, incoming) + + assert_receive {:trace, ^server, :receive, + {:"$gen_call", _, {:set_datafile, ^prepared, false}}} + + assert_receive {:trace, ^store, :receive, + {:"$gen_call", _, {:create, ^prepared, ^previous} = request}} + + assert Featurevisor.get_feature(sdk, "changed")["conditions"] == %{ + "attribute" => "id", + "operator" => "equals", + "value" => 1 + } + + assert Featurevisor.set_datafile(sdk, incoming, true) + + assert_receive {:trace, ^store, :receive, + {:"$gen_call", _, {:create, ^prepared, nil}}} + + assert Featurevisor.get_feature_keys(sdk) == ["changed"] + :erts_debug.flat_size(request) + after + :erlang.trace(store, false, [:receive]) + :erlang.trace(server, false, [:receive]) + Featurevisor.close(sdk) + end + end + + assert Enum.at(sizes, 0) == Enum.at(sizes, 1) + end + end + + test "cached partial snapshots write and extract only incoming entities at both retained sizes" do + Code.ensure_loaded!(DatafileStore) + Code.ensure_loaded!(Dependencies) + Code.ensure_loaded!(Server) + + targets = [ + {DatafileStore, :insert_entity, 3}, + {Dependencies, :entity, 2}, + {Server, :dependency_closure, 3} + ] + + for target <- targets, do: assert(:erlang.trace_pattern(target, true, [:call_count]) == 1) + + try do + for version <- ["2", "4"], size <- [100, 1500], dependent <- [false, true] do + # Hashless roots are all announced, but leaves need no graph traversal. + definition = + if dependent, + do: + Map.put(feature(), "conditions", %{ + "variable" => "value", + "operator" => "equals", + "value" => 2 + }), + else: feature() + + features = Map.new(1..size, &{"f#{&1}", definition}) + sdk = Featurevisor.create_featurevisor(%{log_level: :fatal}) + + try do + assert Featurevisor.set_datafile(sdk, %{datafile(version) | "features" => features}) + assert Featurevisor.set_datafile(sdk, patch(version, 1)) + parent = self() + Featurevisor.on(sdk, :datafile_set, &send(parent, {:changed, &1})) + + for target <- targets, do: :erlang.trace_pattern(target, true, [:call_count]) + + assert Featurevisor.set_datafile(sdk, patch(version, 2)) + assert :erlang.trace_info(Enum.at(targets, 0), :call_count) == {:call_count, 1} + assert :erlang.trace_info(Enum.at(targets, 1), :call_count) == {:call_count, 1} + + assert :erlang.trace_info(Enum.at(targets, 2), :call_count) == + {:call_count, if(dependent, do: 4, else: 2)} + + assert_receive {:changed, details} + assert Enum.sort(details.features) == Enum.sort(Map.keys(features)) + assert details.variables == ["value"] + + snapshot = Server.snapshot(sdk.table) + assert :ets.info(snapshot.regex_cache, :size) == 2 + store = :sys.get_state(sdk.pid).store + state = :sys.get_state(store) + assert map_size(state.current) == size + 1 + assert map_size(state.obsolete) <= 1 + send(store, :collect) + state = :sys.get_state(store) + assert state.obsolete == %{} + assert :ets.info(state.entities, :size) == size + 1 + assert Featurevisor.get_global_variable(sdk, "value") == 2 + assert length(Featurevisor.get_feature_keys(sdk)) == size + after + Featurevisor.close(sdk) + end + end + after + for target <- targets, do: :erlang.trace_pattern(target, false, [:call_count]) + end + end + + test "warm reads stay in the small ETS cache rather than rereading entity versions" do + sdk = + Featurevisor.create_featurevisor(%{ + datafile: put_in(patch("4", 7), ["features", "plain"], feature()), + log_level: :fatal + }) + + on_exit(fn -> Featurevisor.close(sdk) end) + assert Featurevisor.get_global_variable(sdk, "value") == 7 + assert Featurevisor.enabled?(sdk, "plain", %{"id" => "test"}) + + targets = [{DatafileStore, :entity, 3}, {DatafileStore, :materialize, 2}] + for target <- targets, do: assert(:erlang.trace_pattern(target, true, [:call_count]) == 1) + + try do + for _ <- 1..50 do + assert Featurevisor.get_global_variable(sdk, "value") == 7 + assert Featurevisor.enabled?(sdk, "plain", %{"id" => "test"}) + end + + for target <- targets, + do: assert(:erlang.trace_info(target, :call_count) == {:call_count, 0}) + after + for target <- targets, do: :erlang.trace_pattern(target, false, [:call_count]) + end + end + + test "shutdown tasks capture modules rather than the complete owner snapshot" do + target = {Task, :async, 1} + assert :erlang.trace_pattern(target, true, [:local]) == 1 + + try do + for size <- [1, 1500] do + parent = self() + retained = Map.new(1..size, &{"f#{&1}", feature()}) + + sdk = + Featurevisor.create_featurevisor(%{ + datafile: %{datafile() | "features" => retained}, + log_level: :fatal, + modules: [%Featurevisor.Module{close: fn -> send(parent, :closed_module) end}] + }) + + server = sdk.pid + modules = Server.snapshot(sdk.table).modules + :erlang.trace(server, true, [:call, {:tracer, self()}]) + GenServer.stop(server) + assert_receive {:trace, ^server, :call, {Task, :async, [callback]}}, 1000 + assert :erlang.fun_info(callback, :env) == {:env, [modules]} + assert_receive :closed_module + refute_receive :closed_module + end + after + :erlang.trace_pattern(target, false, [:local]) + end + end + + test "retained cold slots share diagnostics across many updates and concurrent first reads" do + parent = self() + + report = fn diagnostic -> + if diagnostic.code == "conditions_parse_error", do: send(parent, :parse) + end + + sdk = + Featurevisor.create_featurevisor(%{ + datafile: + put_in(datafile(), ["features", "broken"], Map.put(feature(), "conditions", "[")), + log_level: :error, + on_diagnostic: report + }) + + on_exit(fn -> Featurevisor.close(sdk) end) + + Server.with_snapshot(sdk.table, fn old -> + for index <- 1..30, do: assert(Featurevisor.set_datafile(sdk, patch("4", index))) + + current = Server.snapshot(sdk.table) + + assert DatafileStore.entity(old.regex_cache, "features", "broken") == + DatafileStore.entity(current.regex_cache, "features", "broken") + + readers = + for index <- 1..40 do + Task.async(fn -> + if rem(index, 2) == 0 do + old.datafile + |> Normalize.get("features", "broken", old.regex_cache, report) + |> Normalize.public() + else + Featurevisor.get_feature(sdk, "broken") + end + end) + end + + for value <- Task.await_many(readers, 10_000), + do: assert(value["conditions"] == %{"or" => []}) + + assert_receive :parse + refute_receive :parse + end) + + assert Featurevisor.set_datafile(sdk, %{ + datafile() + | "features" => %{"broken" => Map.put(feature(), "conditions", "[")} + }) + + assert Featurevisor.get_feature(sdk, "broken")["conditions"] == %{"or" => []} + assert_receive :parse + refute_receive :parse + end + + test "replacement boundaries and reclamation retain only versions needed by pinned readers" do + sdk = + Featurevisor.create_featurevisor(%{ + datafile: put_in(patch("4", 0), ["features", "old"], feature()), + log_level: :fatal + }) + + store = :sys.get_state(sdk.pid).store + %{entities: entities, normalized: normalized} = :sys.get_state(store) + + Server.with_snapshot(sdk.table, fn old -> + for index <- 1..30 do + assert Featurevisor.set_datafile(sdk, patch("4", index), rem(index, 5) == 0) + assert Featurevisor.get_global_variable(sdk, "value") == index + end + + send(store, :collect) + :sys.get_state(store) + assert :ets.info(entities, :size) == 3 + assert Featurevisor.get_feature_keys(sdk) == [] + assert Server.materialize_datafile(old.datafile)["features"] == %{"old" => feature()} + + assert Normalize.get(old.datafile, "variables", "value", old.regex_cache, fn _ -> :ok end)[ + "defaultValue" + ] == 0 + end) + + send(store, :collect) + :sys.get_state(store) + assert :ets.info(entities, :size) == 1 + assert :ets.info(normalized, :size) == 1 + monitor = Process.monitor(store) + Featurevisor.close(sdk) + assert_receive {:DOWN, ^monitor, :process, ^store, :normal}, 2000 + assert :ets.info(entities) == :undefined + assert :ets.info(normalized) == :undefined + end + + defp feature, + do: %{ + "bucketBy" => "id", + "traffic" => [%{"key" => "all", "conditions" => "*", "percentage" => 100_000}] + } + + defp patch(version, value), + do: + put_in(datafile(version), ["variables", "value"], %{ + "hash" => "#{value}", + "type" => "integer", + "defaultValue" => value + }) + + defp datafile(version \\ "4"), + do: %{ + "schemaVersion" => version, + "revision" => "snapshot", + "features" => %{}, + "variables" => %{}, + "segments" => %{} + } +end diff --git a/test/readme_initialization_test.exs b/test/readme_initialization_test.exs new file mode 100644 index 0000000..749b870 --- /dev/null +++ b/test/readme_initialization_test.exs @@ -0,0 +1,61 @@ +defmodule Featurevisor.ReadmeInitializationTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureIO + + setup do + readme = File.read!(Path.expand("../README.md", __DIR__)) + [_, source] = Regex.run(~r/## Initialization\n.*?```elixir\n(.*?)\n```/s, readme) + assert length(String.split(source, "featurevisor-all.json")) == 2 + + directory = + Path.join(System.tmp_dir!(), "featurevisor-readme-#{System.unique_integer([:positive])}") + + File.mkdir_p!(directory) + on_exit(fn -> File.rm_rf!(directory) end) + path = Path.join(directory, "featurevisor-all.json") + source = String.replace(source, ~s("featurevisor-all.json"), inspect(path)) + %{source: source, path: path} + end + + test "evaluates the extracted example and closes its instance", %{source: source, path: path} do + for enabled <- [true, false] do + datafile = %{ + "schemaVersion" => "2", + "revision" => "readme", + "segments" => %{}, + "variables" => %{}, + "features" => %{ + "checkout" => %{ + "key" => "checkout", + "bucketBy" => "userId", + "traffic" => [ + %{"key" => "all", "segments" => "*", "percentage" => 100_000, "enabled" => enabled} + ] + } + } + } + + File.write!(path, Jason.encode!(datafile)) + + output = + capture_io(fn -> + {_, bindings} = Code.eval_string(source, [], file: "README.md") + instance = Keyword.fetch!(bindings, :f) + on_exit(fn -> Featurevisor.close(instance) end) + refute Process.alive?(instance.pid) + end) + + assert to_string(enabled) in String.split(output, "\n") + end + end + + test "raises on a missing datafile", %{source: source} do + assert_raise File.Error, fn -> Code.eval_string(source, [], file: "README.md") end + end + + test "raises on malformed JSON", %{source: source, path: path} do + File.write!(path, "{") + assert_raise Jason.DecodeError, fn -> Code.eval_string(source, [], file: "README.md") end + end +end diff --git a/test/review_correctness_test.exs b/test/review_correctness_test.exs new file mode 100644 index 0000000..c32e516 --- /dev/null +++ b/test/review_correctness_test.exs @@ -0,0 +1,601 @@ +defmodule Featurevisor.ReviewCorrectnessTest do + use ExUnit.Case, async: true + + defp feature do + %{ + "bucketBy" => "id", + "traffic" => [ + %{"key" => "all", "conditions" => "*", "percentage" => 100_000} + ] + } + end + + test "invalid selector sentinels do not escape in detailed definition metadata" do + for version <- ["2", "4"] do + data = %{ + "schemaVersion" => version, + "revision" => "wire", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "broken" => %{ + "type" => "string", + "defaultValue" => "unused", + "conditions" => "[", + "overrides" => [%{"conditions" => "[", "value" => "unused"}] + } + } + } + + f = Featurevisor.create_featurevisor(%{datafile: data, log_level: :fatal}) + + try do + wire = + f |> Featurevisor.evaluate_global_variable("broken") |> Featurevisor.Evaluation.to_map() + + assert wire["conditions"] == %{"or" => []} + assert wire["variable"]["conditions"] == %{"or" => []} + assert hd(wire["variable"]["overrides"])["conditions"] == %{"or" => []} + refute String.contains?(Jason.encode!(wire), "invalid_conditions") + after + Featurevisor.close(f) + end + end + end + + test "nonarray NOT getters are safe and composed null gates ignore explicit defaults" do + for operand <- [nil, "missing", %{}, false, 1] do + data = %{ + "schemaVersion" => "4", + "revision" => "not", + "segments" => %{"s" => %{"conditions" => %{"not" => operand}}}, + "features" => %{ + "direct" => Map.put(feature(), "conditions", %{"not" => operand}), + "segment" => Map.put(feature(), "conditions", "s") + } + } + + f = Featurevisor.create_featurevisor(%{datafile: data, log_level: :fatal}) + + try do + refute Featurevisor.enabled?(f, "direct") + refute Featurevisor.enabled?(f, "segment") + after + Featurevisor.close(f) + end + end + + data = %{ + "schemaVersion" => "4", + "revision" => "null-gates", + "features" => %{}, + "segments" => %{}, + "variables" => %{ + "gated" => %{"type" => "string", "compose" => "neverRead", "conditions" => nil}, + "disabled" => %{ + "type" => "string", + "compose" => "neverRead", + "conditions" => nil, + "disabledValue" => "disabled" + } + } + } + + parent = self() + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:variable_key] == "neverRead", do: send(parent, :unexpected_gate_source) + + options + |> Map.put(:default_variable_value, "module default") + |> Map.put(:default_variable_present, true) + end + } + + f = Featurevisor.create_featurevisor(%{datafile: data, modules: [module], log_level: :fatal}) + + try do + assert Featurevisor.get_global_variable(f, "gated", %{}, %{ + default_variable_value: "caller default" + }) == nil + + assert Featurevisor.get_global_variable(f, "disabled", %{}, %{ + default_variable_value: "caller default" + }) == "disabled" + + wire = + f |> Featurevisor.evaluate_global_variable("gated") |> Featurevisor.Evaluation.to_map() + + assert Map.has_key?(wire, "conditions") + assert wire["conditions"] == nil + refute_receive :unexpected_gate_source + after + Featurevisor.close(f) + end + end + + test "feature dependencies observe datafile replacement between repeated reads" do + for source <- [:flag, :variable] do + leaf = + if source == :flag, + do: %{"feature" => "leaf"}, + else: %{ + "feature" => "leaf", + "variable" => "value", + "operator" => "equals", + "value" => 1 + } + + changed = + if source == :flag, + do: %{"feature" => "leaf", "enabled" => false}, + else: Map.put(leaf, "value", 2) + + data = %{ + "schemaVersion" => "4", + "revision" => "first", + "segments" => %{}, + "features" => %{ + "leaf" => + Map.put(feature(), "variablesSchema", %{ + "value" => %{"type" => "integer", "defaultValue" => 1} + }), + "root" => + Map.put(feature(), "conditions", [ + leaf, + %{"variable" => "mutator", "operator" => "equals", "value" => true}, + changed, + changed + ]) + }, + "variables" => %{"mutator" => %{"type" => "boolean", "defaultValue" => true}} + } + + f = Featurevisor.create_featurevisor(%{datafile: data, log_level: :fatal}) + parent = self() + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:feature_key] == "leaf", do: send(parent, {:leaf, options[:dependency_of]}) + options + end, + after_evaluation: fn evaluation, _options -> + if evaluation.variable_key == "mutator" do + next = + if source == :flag, + do: put_in(data, ["features", "leaf", "traffic"], []), + else: + put_in( + data, + ["features", "leaf", "variablesSchema", "value", "defaultValue"], + 2 + ) + + assert Featurevisor.set_datafile(f, Map.put(next, "revision", "second"), true) + end + + evaluation + end + } + + Featurevisor.add_module(f, module) + + try do + assert Featurevisor.enabled?(f, "root") + assert_receive {:leaf, %{type: :flag, feature_key: "root"}} + assert_receive {:leaf, %{type: :flag, feature_key: "root"}} + refute_receive {:leaf, _} + assert Featurevisor.evaluate_flag(f, "root").reason == :conditions_unmet + assert_receive {:leaf, %{type: :flag, feature_key: "root"}} + + if source == :flag, + do: assert(Featurevisor.evaluate_flag(f, "leaf").reason == :no_match), + else: assert(Featurevisor.get_variable(f, "leaf", "value") == 2) + + assert_receive {:leaf, nil} + + assert Featurevisor.set_datafile(f, data, true) + assert Featurevisor.enabled?(f, "root") + assert_receive {:leaf, %{type: :flag, feature_key: "root"}} + assert_receive {:leaf, %{type: :flag, feature_key: "root"}} + refute_receive {:leaf, _} + after + Featurevisor.close(f) + end + end + end + + test "segment depth counts terminal expressions consistently and recovers after failure" do + for terminal <- [ + "*", + %{"not" => [nil]}, + %{"attribute" => "country", "operator" => "equals", "value" => "nl"} + ], + count <- [255, 256, 257, 258] do + limit = if is_map(terminal) and Map.has_key?(terminal, "attribute"), do: 257, else: 256 + + segments = + Map.new(0..(count - 1), fn index -> + {"s#{index}", + %{"conditions" => if(index == count - 1, do: terminal, else: "s#{index + 1}")}} + end) + + data = %{ + "schemaVersion" => "4", + "revision" => "terminal", + "segments" => segments, + "features" => %{"subject" => Map.put(feature(), "conditions", "s0"), "safe" => feature()} + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: data, + context: %{"country" => "nl"}, + log_level: :fatal + }) + + try do + evaluation = Featurevisor.evaluate_flag(f, "subject") + assert evaluation.reason == if(count > limit, do: :error, else: :rule) + assert Featurevisor.enabled?(f, "safe") + after + Featurevisor.close(f) + end + end + end + + test "getter projection follows callbacks without leaking its private marker or losing metadata" do + for feature_source <- [false, true] do + parent = self() + + reference = + if feature_source, + do: %{"key" => "one", "feature" => "sourceFeature", "variable" => "source"}, + else: %{"key" => "one", "variable" => "source"} + + schema = %{"type" => "json", "defaultValue" => ~s("null")} + + data = %{ + "schemaVersion" => "4", + "revision" => "projection", + "segments" => %{}, + "features" => %{ + "sourceFeature" => Map.put(feature(), "variablesSchema", %{"source" => schema}) + }, + "variables" => %{ + "source" => schema, + "view" => %{ + "type" => "object", + "compose" => [reference, Map.put(reference, "key", "two")] + } + } + } + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + refute Map.has_key?(options, :__getter_value_normalized) + refute Map.has_key?(options, :session) + options + end, + after_evaluation: fn evaluation, options -> + refute Map.has_key?(options, :__getter_value_normalized) + refute Map.has_key?(options, :session) + + refute Map.has_key?( + Featurevisor.Evaluation.to_map(evaluation), + "__getter_value_normalized" + ) + + if evaluation.variable_key == "source" do + assert evaluation.variable_value == ~s("null") + assert evaluation.reason == :variable_default + assert (evaluation.variable_schema || evaluation.variable) == schema + refute evaluation.__getter_value_normalized + send(parent, :projected_source) + %{evaluation | variable_value: ~s("123")} + else + evaluation + end + end + } + + f = + Featurevisor.create_featurevisor(%{datafile: data, modules: [module], log_level: :fatal}) + + try do + for detailed <- [false, true] do + value = + if detailed do + evaluation = Featurevisor.evaluate_global_variable(f, "view") + wire = Featurevisor.Evaluation.to_map(evaluation) + refute String.contains?(Jason.encode!(wire), "__getter_value_normalized") + evaluation.variable_value + else + Featurevisor.get_global_variable(f, "view") + end + + assert value == %{"one" => "123", "two" => "123"} + assert_receive :projected_source + refute_receive :projected_source + end + after + Featurevisor.close(f) + end + end + end + + test "live sources keep the selected owner, effective context and fresh root recovery" do + for feature_source <- [false, true] do + source = + if feature_source, + do: %{"feature" => "source", "variable" => "leaf"}, + else: %{"variable" => "leaf"} + + member = fn key -> Map.put(source, "key", key) end + + initial = %{ + "schemaVersion" => "4", + "revision" => "old", + "segments" => %{}, + "features" => %{ + "source" => + Map.put(feature(), "variablesSchema", %{ + "leaf" => %{"type" => "integer", "defaultValue" => 1} + }) + }, + "variables" => %{ + "leaf" => %{"type" => "integer", "defaultValue" => 1}, + "mutator" => %{"type" => "boolean", "defaultValue" => true}, + "context" => %{ + "type" => "string", + "conditions" => [ + %{"attribute" => "country", "operator" => "equals", "value" => "nl"}, + %{"attribute" => "added", "operator" => "notExists"} + ], + "defaultValue" => "preserved", + "disabledValue" => "changed" + }, + "view" => %{ + "type" => "object", + "compose" => [ + member.("before"), + %{"key" => "mutator", "variable" => "mutator"}, + member.("after"), + member.("again"), + %{"key" => "context", "variable" => "context"} + ] + } + } + } + + replacement = + initial + |> Map.put("revision", "new") + |> put_in(["variables", "leaf", "defaultValue"], 2) + |> put_in(["features", "source", "variablesSchema", "leaf", "defaultValue"], 2) + |> put_in(["variables", "view", "compose"], [member.("fresh")]) + + f = + Featurevisor.create_featurevisor(%{ + datafile: initial, + context: %{"country" => "nl"}, + log_level: :fatal + }) + + Featurevisor.add_module(f, %Featurevisor.Module{ + after_evaluation: fn evaluation, _ -> + if evaluation.variable_key == "mutator" do + assert Featurevisor.set_datafile(f, replacement, true) + Featurevisor.set_context(f, %{"country" => "de", "added" => true}, true) + end + + evaluation + end + }) + + try do + assert Featurevisor.get_global_variable(f, "view") == %{ + "before" => 1, + "mutator" => true, + "after" => 2, + "again" => 2, + "context" => "preserved" + } + + assert Featurevisor.get_global_variable(f, "view") == %{"fresh" => 2} + assert Featurevisor.get_global_variable(f, "context") == "changed" + after + Featurevisor.close(f) + end + end + end + + test "later segments use new definitions while the loaded owner finishes its old rule" do + initial = %{ + "schemaVersion" => "4", + "revision" => "old", + "segments" => %{"s" => %{"conditions" => "*"}}, + "features" => %{ + "subject" => + feature() + |> Map.put("conditions", [ + "s", + %{"variable" => "mutator", "operator" => "equals", "value" => true}, + %{"not" => ["s"]} + ]) + |> put_in(["traffic", Access.at(0), "key"], "old") + }, + "variables" => %{"mutator" => %{"type" => "boolean", "defaultValue" => true}} + } + + replacement = + initial + |> Map.put("revision", "new") + |> put_in(["segments", "s", "conditions"], []) + |> put_in(["features", "subject", "conditions"], "*") + |> put_in(["features", "subject", "traffic", Access.at(0), "key"], "new") + + f = Featurevisor.create_featurevisor(%{datafile: initial, log_level: :fatal}) + + Featurevisor.add_module(f, %Featurevisor.Module{ + after_evaluation: fn evaluation, _ -> + if evaluation.variable_key == "mutator", + do: assert(Featurevisor.set_datafile(f, replacement, true)) + + evaluation + end + }) + + try do + first = Featurevisor.evaluate_flag(f, "subject") + assert first.reason == :rule + assert first.rule_key == "old" + assert first.traffic["key"] == "old" + assert Featurevisor.evaluate_flag(f, "subject").rule_key == "new" + assert Featurevisor.get_segment(f, "s")["conditions"] == [] + after + Featurevisor.close(f) + end + end + + test "composition keeps exact getter fallbacks without outer defaults" do + values = %{"zero" => 0, "false" => false, "empty" => "", "json" => ~s("null")} + + variables = + Map.new(values, fn {key, value} -> + type = + %{"zero" => "integer", "false" => "boolean", "empty" => "string", "json" => "json"}[key] + + {key, %{"type" => type, "defaultValue" => value}} + end) + + entries = + Enum.map(["zero", "false", "empty", "json", "missing"], &%{"key" => &1, "variable" => &1}) ++ + [ + %{"key" => "disabled", "feature" => "off", "variable" => "value"}, + %{"key" => "flag", "feature" => "off"}, + %{"key" => "variation", "feature" => "off", "variation" => true} + ] + + data = %{ + "schemaVersion" => "4", + "revision" => "fallbacks", + "segments" => %{}, + "features" => %{ + "off" => %{ + "bucketBy" => "id", + "traffic" => [], + "disabledVariationValue" => "", + "variablesSchema" => %{ + "value" => %{ + "type" => "string", + "defaultValue" => "wrong", + "disabledValue" => nil, + "useDefaultWhenDisabled" => true + } + } + } + }, + "variables" => Map.put(variables, "view", %{"type" => "object", "compose" => entries}) + } + + f = Featurevisor.create_featurevisor(%{datafile: data, log_level: :fatal}) + + try do + for _ <- 1..2 do + assert Featurevisor.get_global_variable(f, "view", %{}, %{default_variable_value: "outer"}) == + %{ + "zero" => 0, + "false" => false, + "empty" => "", + "json" => "null", + "missing" => nil, + "disabled" => nil, + "flag" => false, + "variation" => "" + } + end + after + Featurevisor.close(f) + end + end + + test "module driven context state invalidates repeated composed feature source reads" do + parent = self() + + source = + feature() + |> Map.put("variablesSchema", %{"value" => %{"type" => "integer", "defaultValue" => 1}}) + |> put_in(["traffic", Access.at(0), "variableOverrides"], %{ + "value" => [ + %{ + "conditions" => %{"attribute" => "country", "operator" => "equals", "value" => "nl"}, + "value" => 2 + } + ] + }) + + ref = %{"feature" => "source", "variable" => "value"} + + data = %{ + "schemaVersion" => "4", + "revision" => "context", + "segments" => %{}, + "features" => %{"source" => source}, + "variables" => %{ + "mutator" => %{"type" => "boolean", "defaultValue" => true}, + "view" => %{ + "type" => "object", + "compose" => [ + Map.put(ref, "key", "before"), + %{"key" => "mutator", "variable" => "mutator"}, + Map.put(ref, "key", "after"), + Map.put(ref, "key", "again") + ] + } + } + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: data, + context: %{"country" => "us"}, + log_level: :fatal + }) + + module = %Featurevisor.Module{ + before_evaluation: fn options -> + if options[:feature_key] == "source" do + send(parent, :source_read) + Map.put(options, :context, Featurevisor.get_context(f)) + else + options + end + end, + after_evaluation: fn evaluation, _ -> + if evaluation.variable_key == "mutator", + do: Featurevisor.set_context(f, %{"country" => "nl"}) + + evaluation + end + } + + Featurevisor.add_module(f, module) + + try do + assert Featurevisor.get_global_variable(f, "view") == %{ + "before" => 1, + "mutator" => true, + "after" => 2, + "again" => 2 + } + + assert_receive :source_read + assert_receive :source_read + refute_receive :source_read + after + Featurevisor.close(f) + end + end +end diff --git a/test/sdk_stateful_test.exs b/test/sdk_stateful_test.exs new file mode 100644 index 0000000..b7a765d --- /dev/null +++ b/test/sdk_stateful_test.exs @@ -0,0 +1,231 @@ +defmodule Featurevisor.SDKStatefulTest do + use ExUnit.Case, async: true + + @fixture Path.expand("../conformance/sdk-stateful.json", __DIR__) + |> File.read!() + |> Jason.decode!() + + test "executes every canonical stateful SDK case" do + assert @fixture["kind"] == "featurevisor.sdk-stateful" + assert @fixture["schemaVersion"] == 1 + assert [_ | _] = @fixture["cases"] + + Enum.each(@fixture["cases"], &run_case/1) + end + + defp run_case(item) do + test_pid = self() + + root = + Featurevisor.create_featurevisor(%{ + datafile: item["initial"]["datafile"], + context: item["initial"]["context"] || %{}, + log_level: :fatal + }) + + subscribe(root, "root", test_pid) + + try do + Enum.reduce(item["operations"], %{"root" => root}, fn operation, targets -> + targets = execute_operation(operation, targets, test_pid) + assert_operation(operation, targets, item["name"]) + targets + end) + after + Process.get({__MODULE__, :children}, []) + |> Enum.each(&Featurevisor.Child.close/1) + + Process.delete({__MODULE__, :children}) + Featurevisor.close(root) + end + end + + defp subscribe(root, target, test_pid) do + for event <- [:datafile_set, :sticky_features_set, :sticky_variables_set] do + Featurevisor.on(root, event, fn details -> + send(test_pid, {:sdk_stateful_event, target, Atom.to_string(event), details}) + end) + end + + Featurevisor.on(root, :error, fn %{diagnostic: diagnostic} -> + send(test_pid, {:sdk_stateful_error, target, diagnostic}) + end) + end + + defp subscribe_child(child, target, test_pid) do + for event <- [:sticky_features_set, :sticky_variables_set] do + Featurevisor.Child.on(child, event, fn details -> + send(test_pid, {:sdk_stateful_event, target, Atom.to_string(event), details}) + end) + end + end + + defp execute_operation(%{"action" => "setDatafile"} = operation, targets, _test_pid) do + result = + Featurevisor.set_datafile( + Map.fetch!(targets, operation["target"]), + operation["datafile"], + operation["replace"] + ) + + Process.put({__MODULE__, :operation_result}, result) + targets + end + + defp execute_operation(%{"action" => "spawn"} = operation, targets, test_pid) do + child = + Featurevisor.spawn( + Map.fetch!(targets, operation["parent"]), + operation["context"] || %{}, + spawn_options(operation["options"] || %{}) + ) + + subscribe_child(child, operation["target"], test_pid) + Process.put({__MODULE__, :children}, [child | Process.get({__MODULE__, :children}, [])]) + Process.put({__MODULE__, :operation_result}, :ok) + Map.put(targets, operation["target"], child) + end + + defp execute_operation(%{"action" => "setStickyFeatures"} = operation, targets, _test_pid) do + target = Map.fetch!(targets, operation["target"]) + + result = + if operation["target"] == "root" do + Featurevisor.set_sticky_features( + target, + operation["stickyFeatures"], + operation["replace"] + ) + else + Featurevisor.Child.set_sticky_features( + target, + operation["stickyFeatures"], + operation["replace"] + ) + end + + Process.put({__MODULE__, :operation_result}, result) + targets + end + + defp execute_operation(%{"action" => "setStickyVariables"} = operation, targets, _test_pid) do + target = Map.fetch!(targets, operation["target"]) + + result = + if operation["target"] == "root" do + Featurevisor.set_sticky_variables( + target, + operation["stickyVariables"], + operation["replace"] + ) + else + Featurevisor.Child.set_sticky_variables( + target, + operation["stickyVariables"], + operation["replace"] + ) + end + + Process.put({__MODULE__, :operation_result}, result) + targets + end + + defp assert_operation(operation, targets, case_name) do + expected = operation["expect"] + + assert_error(expected["error"], operation["action"], case_name) + assert_events(expected["events"], case_name) + + Enum.each(expected["results"], fn result -> + assert invoke(result, targets) == result["value"], + "#{case_name}: #{result["target"]}.#{result["method"]}" + end) + end + + defp assert_error(nil, "setDatafile", case_name) do + assert Process.get({__MODULE__, :operation_result}) === true, case_name + refute_receive {:sdk_stateful_error, _, _} + end + + defp assert_error(nil, _action, case_name) do + assert Process.get({__MODULE__, :operation_result}) == :ok, case_name + refute_receive {:sdk_stateful_error, _, _} + end + + defp assert_error(%{"code" => code}, "setDatafile", case_name) do + assert Process.get({__MODULE__, :operation_result}) === false, case_name + assert_receive {:sdk_stateful_error, "root", diagnostic} + assert diagnostic.code == code, case_name + end + + defp assert_events(expected, case_name) do + actual = + Enum.map(expected, fn _ -> + assert_receive {:sdk_stateful_event, target, name, details} + + %{ + "target" => target, + "name" => name, + "details" => normalize_details(details) + } + end) + + expected = + Enum.map(expected, fn event -> + Map.update!(event, "details", &normalize_details/1) + end) + + assert actual == expected, case_name + refute_receive {:sdk_stateful_event, _, _, _} + end + + defp invoke(%{"target" => target, "method" => "getRevision"}, targets) do + Featurevisor.get_revision(Map.fetch!(targets, target)) + end + + defp invoke(%{"target" => target, "method" => "isEnabled", "args" => [feature]}, targets) do + instance = Map.fetch!(targets, target) + + if target == "root", + do: Featurevisor.enabled?(instance, feature), + else: Featurevisor.Child.enabled?(instance, feature) + end + + defp invoke(%{"target" => target, "method" => "getVariable", "args" => [variable]}, targets) do + instance = Map.fetch!(targets, target) + + if target == "root", + do: Featurevisor.get_global_variable(instance, variable), + else: Featurevisor.Child.get_global_variable(instance, variable) + end + + defp invoke( + %{"target" => target, "method" => "getVariable", "args" => [feature, variable]}, + targets + ) do + instance = Map.fetch!(targets, target) + + if target == "root", + do: Featurevisor.get_variable(instance, feature, variable), + else: Featurevisor.Child.get_variable(instance, feature, variable) + end + + defp spawn_options(options) do + %{ + sticky_features: options["stickyFeatures"] || %{}, + sticky_variables: options["stickyVariables"] || %{} + } + end + + defp normalize_details(details) do + details + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Map.new() + |> normalize_keyset("features") + |> normalize_keyset("variables") + end + + defp normalize_keyset(details, key) do + if Map.has_key?(details, key), do: Map.update!(details, key, &Enum.sort/1), else: details + end +end diff --git a/test/semver_conformance_test.exs b/test/semver_conformance_test.exs new file mode 100644 index 0000000..c3c2be0 --- /dev/null +++ b/test/semver_conformance_test.exs @@ -0,0 +1,146 @@ +defmodule Featurevisor.SemverConformanceTest do + use ExUnit.Case, async: true + @fixture Path.expand("../conformance/semver.json", __DIR__) |> File.read!() |> Jason.decode!() + alias Featurevisor.CLI.TestChecks + + test "executes every portable version comparison in both directions" do + assert @fixture["kind"] == "featurevisor.semver" + assert @fixture["version"] == 1 + assert length(@fixture["cases"]) == 44 + + for entry <- @fixture["cases"], reverse <- [false, true] do + {left, right} = + if reverse, do: {entry["right"], entry["left"]}, else: {entry["left"], entry["right"]} + + actual = Featurevisor.Semver.compare(left, right) + + if is_nil(entry["order"]) do + assert match?({:error, _}, actual), entry["name"] + else + expected = if reverse, do: -entry["order"], else: entry["order"] + assert actual == expected, entry["name"] + end + + order = if reverse && entry["order"], do: -entry["order"], else: entry["order"] + + for {operator, expected} <- [ + {"semverEquals", order == 0}, + {"semverNotEquals", not is_nil(order) and order != 0}, + {"semverGreaterThan", not is_nil(order) and order > 0}, + {"semverGreaterThanOrEquals", not is_nil(order) and order >= 0}, + {"semverLessThan", not is_nil(order) and order < 0}, + {"semverLessThanOrEquals", not is_nil(order) and order <= 0} + ] do + 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]}] + } + } + } + + ref = make_ref() + + sdk = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + log_level: :fatal, + on_diagnostic: TestChecks.diagnostic_handler(self(), ref), + modules: [TestChecks.diagnostic_module(self(), ref)] + }) + + label = "#{entry["name"]} reverse=#{reverse} #{operator}" + + try do + assert Featurevisor.enabled?(sdk, "condition", %{"version" => left}) == expected, label + + for {variable, value} <- [ + {"subject", expected}, + {"negated", not expected}, + {"featureSubject", expected}, + {"featureNegated", not expected} + ] do + evaluation = Featurevisor.evaluate_global_variable(sdk, variable) + + if is_nil(order) do + assert evaluation.reason == :error, label + else + assert evaluation.variable_value == value, label + end + end + + check = %{ + "variable" => "source", + "operator" => operator, + "compareTo" => %{"variable" => "other"} + } + + for {node, matched} <- [{check, expected}, {%{"not" => [check]}, not expected}] do + result = TestChecks.evaluate_result(sdk, datafile, [node], nil, ref) + assert result.error == is_nil(order), label + assert result.passed == (not is_nil(order) and matched), label + end + after + Featurevisor.close(sdk) + end + end + end + end +end diff --git a/test/set_datafile_test.exs b/test/set_datafile_test.exs new file mode 100644 index 0000000..de7588c --- /dev/null +++ b/test/set_datafile_test.exs @@ -0,0 +1,284 @@ +defmodule Featurevisor.SetDatafileTest do + use ExUnit.Case, async: true + + defp datafile(revision, key) do + %{ + "schemaVersion" => "2", + "revision" => revision, + "features" => %{key => %{"hash" => key, "bucketBy" => "id", "traffic" => []}}, + "segments" => %{key => %{"conditions" => "*"}}, + "variables" => %{key => %{"hash" => key, "type" => "string", "defaultValue" => key}} + } + end + + defp encode(datafile, true), do: Jason.encode!(datafile) + defp encode(datafile, false), do: datafile + + for encoded <- [false, true], mode <- [:default, false, true] do + @encoded encoded + @mode mode + test "accepts initial, unchanged and changed input encoded=#{encoded} mode=#{mode}" do + parent = self() + + f = + Featurevisor.create_featurevisor(%{ + on_diagnostic: fn diagnostic -> send(parent, {:diagnostic, diagnostic}) end + }) + + on_exit(fn -> Featurevisor.close(f) end) + Featurevisor.on(f, :datafile_set, fn details -> send(parent, {:set, details}) end) + + set = fn value -> + value = encode(value, @encoded) + + if @mode == :default, + do: Featurevisor.set_datafile(f, value), + else: Featurevisor.set_datafile(f, value, @mode) + end + + assert set.(datafile("one", "original")) === true + + assert_receive {:set, + %{revision: "one", previousRevision: "unknown", revisionChanged: true}} + + assert set.(datafile("one", "original")) === true + replaced = @mode == true + + assert_receive {:set, + %{ + revision: "one", + previousRevision: "one", + revisionChanged: false, + features: [], + variables: [], + replaced: ^replaced + }} + + assert set.(datafile("one", "next")) === true + assert_receive {:set, %{revisionChanged: false}} + assert Featurevisor.get_feature(f, "original") != nil == not replaced + assert Featurevisor.get_segment(f, "original") != nil == not replaced + assert "original" in Featurevisor.get_global_variable_keys(f) == not replaced + assert Featurevisor.get_feature(f, "next") + assert Featurevisor.get_segment(f, "next") + assert "next" in Featurevisor.get_global_variable_keys(f) + updated = put_in(datafile("two", "next"), ["features", "next", "hash"], "changed") + assert set.(updated) === true + assert Featurevisor.get_revision(f) == "two" + + assert_receive {:set, + %{ + previousRevision: "one", + revision: "two", + revisionChanged: true, + features: ["next"] + }} + + for _ <- 1..4, do: assert_receive({:diagnostic, %{code: "datafile_set"}}) + refute_receive {:set, _} + refute_receive {:diagnostic, %{code: "datafile_set"}} + end + end + + for replace <- [false, true] do + @replace replace + test "rejects invalid input without changing the last valid datafile replace=#{replace}" do + parent = self() + original = datafile("valid", "original") + + f = + Featurevisor.create_featurevisor(%{ + datafile: original, + on_diagnostic: fn diagnostic -> send(parent, {:diagnostic, diagnostic}) end + }) + + on_exit(fn -> Featurevisor.close(f) end) + Featurevisor.on(f, :datafile_set, fn details -> send(parent, {:set, details}) end) + Featurevisor.on(f, :error, fn details -> send(parent, {:error, details.diagnostic}) end) + + invalid = ["{", "null", "[]", "true", "7", ~s("text"), "{}", nil, [], true, 7, %{}] + + invalid = + invalid ++ + for field <- ["schemaVersion", "revision", "features", "segments", "variables"], + encoded <- [false, true], + do: encode(Map.put(original, field, 17), encoded) + + for value <- invalid do + assert Featurevisor.set_datafile(f, value, @replace) === false + assert Featurevisor.get_revision(f) == "valid" + assert Featurevisor.get_feature(f, "original") + assert Featurevisor.get_segment(f, "original") + assert Featurevisor.get_global_variable_keys(f) == ["original"] + + assert_receive {:diagnostic, + %{ + code: "invalid_datafile", + message: "Could not parse datafile", + original_error: reason + }} + + assert reason != nil + assert_receive {:error, %{code: "invalid_datafile"}} + end + + refute_receive {:set, _} + assert Featurevisor.set_datafile(f, original, @replace) === true + + assert_receive {:set, + %{ + previousRevision: "valid", + revisionChanged: false, + features: [], + variables: [] + }} + end + end + + test "preserves acceptance of empty revisions and optional variables" do + f = Featurevisor.create_featurevisor(%{log_level: :fatal}) + on_exit(fn -> Featurevisor.close(f) end) + + for value <- [datafile("", "flag"), Map.delete(datafile("next", "flag"), "variables")], + encoded <- [false, true], + replace <- [false, true] do + assert Featurevisor.set_datafile(f, encode(value, encoded), replace) === true + end + end + + test "callback exceptions, throws and exits do not change acceptance or stop listeners" do + parent = self() + + for fail <- [ + fn -> raise "callback" end, + fn -> throw(:callback) end, + fn -> exit(:callback) end + ] do + f = + Featurevisor.create_featurevisor(%{ + on_diagnostic: fn _ -> fail.() end, + modules: [ + %Featurevisor.Module{ + name: "diagnostics", + setup: fn api -> + api.on_diagnostic.(fn _ -> fail.() end, %{log_level: :debug}) + end + } + ] + }) + + for event <- [:datafile_set, :error] do + Featurevisor.on(f, event, fn _ -> fail.() end) + Featurevisor.on(f, event, fn _ -> send(parent, {event, Featurevisor.get_revision(f)}) end) + end + + for replace <- [false, true] do + assert Featurevisor.set_datafile(f, datafile("accepted", "flag"), replace) === true + assert_receive {:datafile_set, "accepted"} + assert Featurevisor.set_datafile(f, "bad", replace) === false + assert_receive {:error, "accepted"} + end + + Featurevisor.close(f) + end + end + + test "accepted update stays true when a listener closes the instance" do + f = Featurevisor.create_featurevisor(%{log_level: :fatal}) + Featurevisor.on(f, :datafile_set, fn _ -> Featurevisor.close(f) end) + assert Featurevisor.set_datafile(f, datafile("accepted", "flag")) === true + assert Featurevisor.set_datafile(f, datafile("late", "flag")) === false + end + + test "rejected update stays false when an error listener recovers" do + parent = self() + f = Featurevisor.create_featurevisor(%{log_level: :fatal}) + on_exit(fn -> Featurevisor.close(f) end) + + Featurevisor.on(f, :error, fn _ -> + send( + parent, + {:recovered, Featurevisor.set_datafile(f, datafile("recovered", "flag"), true)} + ) + end) + + assert Featurevisor.set_datafile(f, "bad", true) === false + assert_receive {:recovered, true} + assert Featurevisor.get_revision(f) == "recovered" + end + + test "closed instances reject valid and invalid inputs without callbacks" do + parent = self() + f = Featurevisor.create_featurevisor(%{log_level: :fatal}) + + for event <- [:datafile_set, :error] do + Featurevisor.on(f, event, fn details -> send(parent, {:event, details}) end) + end + + Featurevisor.close(f) + + for value <- [ + datafile("late", "flag"), + Jason.encode!(datafile("late", "flag")), + "bad", + %{}, + nil + ], + replace <- [false, true] do + assert Featurevisor.set_datafile(f, value, replace) === false + assert Featurevisor.set_datafile(f, value) === false + end + + refute_receive {:event, _} + assert Featurevisor.get_feature_keys(f) == [] + end + + test "closure queued before an update rejects it at the owner" do + for replace <- [false, true] do + f = Featurevisor.create_featurevisor(%{log_level: :fatal}) + parent = self() + Featurevisor.on(f, :datafile_set, fn _ -> send(parent, :unexpected_update) end) + :ok = :sys.suspend(f.pid) + closing = Task.async(fn -> Featurevisor.Server.close(f.pid) end) + await_calls(f.pid, 1) + + updating = + Task.async(fn -> Featurevisor.set_datafile(f, datafile("late", "flag"), replace) end) + + await_calls(f.pid, 2) + :ok = :sys.resume(f.pid) + Task.await(closing) + assert Task.await(updating) === false + refute_receive :unexpected_update + GenServer.stop(f.pid) + end + end + + test "owner stopping while an update is queued returns false" do + for replace <- [false, true] do + f = Featurevisor.create_featurevisor(%{log_level: :fatal}) + :ok = :sys.suspend(f.pid) + + updating = + Task.async(fn -> Featurevisor.set_datafile(f, datafile("late", "flag"), replace) end) + + await_calls(f.pid, 1) + :ok = :sys.terminate(f.pid, :normal) + assert Task.await(updating) === false + end + end + + defp await_calls(pid, count, attempts \\ 1_000) + defp await_calls(_pid, _count, 0), do: flunk("owner did not receive the expected calls") + + defp await_calls(pid, count, attempts) do + {:messages, messages} = Process.info(pid, :messages) + + if Enum.count(messages, &match?({:"$gen_call", _, _}, &1)) >= count do + :ok + else + Process.sleep(1) + await_calls(pid, count, attempts - 1) + end + end +end diff --git a/test/support/cli_npx.sh b/test/support/cli_npx.sh new file mode 100644 index 0000000..c84aee7 --- /dev/null +++ b/test/support/cli_npx.sh @@ -0,0 +1,15 @@ +#!/bin/sh +printf '%s\n' CALL "$@" >> calls.log +case "$2" in + config) printf '%s\n' '{"sets":false}' ;; + list) + case "$*" in + *--tests*) printf '%s\n' '{"kind":"featurevisor.test-execution","schemaVersion":1,"tests":[{"key":"smoke","scenario":"smoke","_test":{"source":"tests/smoke.spec.yml","set":null,"entityType":"scenario"},"assertions":[{"description":"ready","_case":{"id":"case-smoke","assertionIndex":0,"matrixIndex":null,"matrixValues":{}},"checks":[{"variable":"marker","operator":"equals","value":"ready"}]}]}]}' ;; + *--targets*) printf '%s\n' '[{"key":"web"},{"key":"space target"}]' ;; + *) printf '%s\n' '[]' ;; + esac ;; + build) + printf '%s\n' 'delegated warning' >&2 + printf '%s\n' '{"schemaVersion":"2","revision":"1","segments":{},"features":{"checkout":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]}},"variables":{"marker":{"type":"string","defaultValue":"ready"}}}' ;; + *) exit 9 ;; +esac diff --git a/test/v4_alignment_test.exs b/test/v4_alignment_test.exs new file mode 100644 index 0000000..e51c0bc --- /dev/null +++ b/test/v4_alignment_test.exs @@ -0,0 +1,263 @@ +defmodule Featurevisor.V4AlignmentTest do + use ExUnit.Case, async: true + alias Featurevisor.{Evaluation, Module} + + @conditions Path.expand("../conformance/sdk-conditions.json", __DIR__) + |> File.read!() + |> Jason.decode!() + @composition Path.expand("../conformance/sdk-composition.json", __DIR__) + |> File.read!() + |> Jason.decode!() + + test "shared diagnostics fixture checks revision cache and reentry" do + fixture = + Path.expand("../conformance/sdk-diagnostics.json", __DIR__) + |> File.read!() + |> Jason.decode!() + + assert fixture["kind"] == "featurevisor.sdk-diagnostics" + assert fixture["version"] == 1 + assert is_list(fixture["cases"]) and fixture["cases"] != [] + + for item <- fixture["cases"], _repetition <- 1..2 do + handle = :ets.new(:diagnostics_fixture, [:set, :public]) + + f = + Featurevisor.create_featurevisor(%{ + log_level: :error, + on_diagnostic: fn diagnostic -> + if diagnostic.code == "conditions_parse_error" do + [{:instance, owner}] = :ets.lookup(handle, :instance) + + details = + Map.new(diagnostic.details, fn {key, value} -> {to_string(key), value} end) + + entry = %{ + "code" => diagnostic.code, + "level" => Atom.to_string(diagnostic.level), + "details" => details, + "revision" => Featurevisor.get_revision(owner) + } + + :ets.insert(handle, {{:diagnostic, make_ref()}, entry}) + + if details["entityType"] == "feature" and + :ets.insert_new(handle, {:reentering, true}) do + try do + before = length(:ets.match_object(handle, {{:diagnostic, :_}, :_})) + Featurevisor.enabled?(owner, Map.fetch!(details, "entityKey")) + + if length(:ets.match_object(handle, {{:diagnostic, :_}, :_})) != before, + do: :ets.insert(handle, {:reentry_failed, true}) + after + :ets.delete(handle, :reentering) + end + end + end + end + }) + + :ets.insert(handle, {:instance, f}) + + try do + assert is_list(item["operations"]) and item["operations"] != [] + + for operation <- item["operations"] do + :ets.match_delete(handle, {{:diagnostic, :_}, :_}) + + result = + case operation["action"] do + "setDatafile" -> + Featurevisor.set_datafile( + f, + Map.fetch!(operation, "datafile"), + Map.fetch!(operation, "replace") + ) + + "isEnabled" -> + Featurevisor.enabled?(f, Map.fetch!(operation, "feature")) + + other -> + flunk("unsupported diagnostics action #{inspect(other)}") + end + + assert is_boolean(operation["expectedResult"]) + assert result === operation["expectedResult"], item["name"] + seen = :ets.match_object(handle, {{:diagnostic, :_}, :_}) |> Enum.map(&elem(&1, 1)) + + assert Enum.frequencies(seen) === Enum.frequencies(Map.fetch!(operation, "diagnostics")), + item["name"] + + refute :ets.member(handle, :reentry_failed) + end + after + Featurevisor.close(f) + :ets.delete(handle) + end + end + end + + test "runs every conditions capability case twice, including dependency callback order" do + assert @conditions["version"] == 1 + assert @conditions["capability"] == "unified-conditions" + assert is_list(@conditions["cases"]) and @conditions["cases"] != [] + + executed = + Enum.map(@conditions["cases"], fn item -> + assert item["type"] in ["flag", "variable"] + parent = self() + + module = %Module{ + before_evaluation: fn options -> + if options[:dependency_purpose] do + entry = %{"dependencyPurpose" => Atom.to_string(options.dependency_purpose)} + + entry = + if options[:feature_key], + do: Map.put(entry, "feature", options.feature_key), + else: entry + + entry = + if options[:variable_key], + do: Map.put(entry, "variable", options.variable_key), + else: entry + + send(parent, {:dependency, entry}) + end + + options + end + } + + options = + Map.new(item["options"] || %{}, fn + {"stickyFeatures", value} -> {:sticky_features, value} + {"stickyVariables", value} -> {:sticky_variables, value} + {"context", value} -> {:context, value} + end) + + f = + Featurevisor.create_featurevisor( + Map.merge(options, %{datafile: item["datafile"], modules: [module], log_level: :fatal}) + ) + + try do + for attempt <- 1..2 do + drain() + + evaluation = + if item["type"] == "flag", + do: Featurevisor.evaluate_flag(f, item["feature"], item["context"] || %{}), + else: + Featurevisor.evaluate_global_variable( + f, + item["variable"], + item["context"] || %{} + ) + + wire = Evaluation.to_map(evaluation) |> Map.update!("reason", &Atom.to_string/1) + + Enum.each(Map.delete(item["expected"], "value"), fn {key, expected} -> + assert Map.has_key?(wire, key), "#{item["name"]}: missing #{key}" + assert wire[key] == expected, "#{item["name"]}: #{key}, attempt #{attempt}" + end) + + seen = drain() + + if Map.has_key?(item, "dependencies"), + do: assert(seen == item["dependencies"], item["name"]) + + if Map.has_key?(item["expected"], "value"), + do: + assert( + Featurevisor.get_global_variable(f, item["variable"], item["context"] || %{}) == + item["expected"]["value"], + item["name"] + ) + + if item["type"] == "flag", + do: + assert( + Featurevisor.enabled?(f, item["feature"], item["context"] || %{}) == + (item["expected"]["enabled"] == true), + item["name"] + ) + + attempt + end + after + Featurevisor.close(f) + drain() + end + end) + + assert Enum.frequencies(List.flatten(executed)) == %{ + 1 => length(@conditions["cases"]), + 2 => length(@conditions["cases"]) + } + end + + test "runs all composition cases twice and checks every expected evaluation field" do + assert @composition["version"] == 2 + assert @composition["capability"] == "composition-and-overrides" + assert is_list(@composition["cases"]) and @composition["cases"] != [] + + executed = + Enum.map(@composition["cases"], fn item -> + datafile = @composition["datafile"] + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + log_level: :fatal, + sticky_features: item["stickyFeatures"], + sticky_variables: item["stickyVariables"] + }) + + try do + attempts = + for attempt <- 1..2 do + assert Featurevisor.get_global_variable(f, item["variable"], item["context"]) == + item["expectedValue"], + item["name"] + + evaluation = + Featurevisor.evaluate_global_variable(f, item["variable"], item["context"]) + + wire = Evaluation.to_map(evaluation) |> Map.update!("reason", &Atom.to_string/1) + + Enum.each(item["expectedEvaluation"], fn {key, expected} -> + assert Map.has_key?(wire, key), "#{item["name"]}: missing #{key}" + assert wire[key] == expected, "#{item["name"]}: #{key}" + end) + + expected = + if item["stickyVariables"] && Map.has_key?(item["stickyVariables"], "policy"), + do: item["stickyVariables"]["policy"], + else: %{"limit" => 50, "methods" => ["card"]} + + assert Featurevisor.get_global_variable(f, "policy") == expected + attempt + end + + assert datafile == @composition["datafile"] + attempts + after + Featurevisor.close(f) + end + end) + + assert Enum.frequencies(List.flatten(executed)) == %{ + 1 => length(@composition["cases"]), + 2 => length(@composition["cases"]) + } + end + + defp drain do + receive do + {:dependency, entry} -> [entry | drain()] + after + 0 -> [] + end + end +end diff --git a/test/v4_runtime_test.exs b/test/v4_runtime_test.exs new file mode 100644 index 0000000..c5ee981 --- /dev/null +++ b/test/v4_runtime_test.exs @@ -0,0 +1,774 @@ +defmodule Featurevisor.V4RuntimeTest do + use ExUnit.Case, async: true + + alias Featurevisor.{Conditions, Evaluation, Module, Mutations} + + @v3 Path.expand("../conformance/sdk-v3.json", __DIR__) |> File.read!() |> Jason.decode!() + @v4 Path.expand("../conformance/sdk-v4-cases.json", __DIR__) + |> File.read!() + |> Jason.decode!() + + test "executes all portable v4 cases through one persistent instance twice" do + assert @v4["version"] == 1 + assert length(@v4["cases"]) == 255 + + Enum.each(@v4["cases"], fn item -> + input = Jason.decode!(Jason.encode!(item["datafile"])) + original = Jason.decode!(Jason.encode!(input)) + + instance_options = + item["options"] + |> instance_options() + |> Map.put_new(:log_level, :fatal) + |> Map.put(:datafile, input) + + f = Featurevisor.create_featurevisor(instance_options) + + for attempt <- 1..2 do + assert Featurevisor.get_global_variable(f, "subject", item["context"] || %{}) == + item["expected"]["value"], + "#{item["name"]} getter attempt #{attempt}" + + evaluation = + Featurevisor.evaluate_global_variable(f, "subject", item["context"] || %{}) + + assert evaluation.variable_value == item["expected"]["value"], + "#{item["name"]} value attempt #{attempt}" + + wire = + evaluation + |> Evaluation.to_map() + |> Map.update("reason", nil, &Atom.to_string/1) + + Enum.each(Map.drop(item["expected"], ["value"]), fn {key, expected} -> + assert wire[key] == expected, "#{item["name"]} #{key} attempt #{attempt}" + end) + end + + assert input == original, "#{item["name"]} input datafile changed" + Featurevisor.close(f) + end) + end + + test "executes canonical grouped feature and variable requirements" do + Enum.each(@v3["requiredFeatureGroups"], fn group -> + Enum.with_index(@v3["requiredFeatureGroupInputs"]) + |> Enum.each(fn {input, index} -> + datafile = %{ + "schemaVersion" => "2", + "revision" => "groups", + "segments" => %{}, + "features" => %{ + "a" => %{"bucketBy" => "userId", "traffic" => []}, + "b" => %{"bucketBy" => "userId", "traffic" => []}, + "subject" => %{ + "bucketBy" => "userId", + "requiredFeatures" => group["requirements"], + "traffic" => [ + %{"key" => "all", "segments" => "*", "percentage" => 100_000} + ] + } + } + } + + sticky = + Map.new(input, fn {key, enabled} -> {key, %{"enabled" => enabled}} end) + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + sticky_features: sticky, + log_level: :fatal + }) + + assert Featurevisor.enabled?(f, "subject") == Enum.at(group["results"], index), + group["name"] + + Featurevisor.close(f) + end) + end) + + Enum.each(@v3["requiredVariables"]["groups"], fn group -> + Enum.with_index(@v3["requiredVariables"]["groupInputs"]) + |> Enum.each(fn {input, index} -> + datafile = %{ + "schemaVersion" => "2", + "revision" => "variable-groups", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "a" => %{"type" => "boolean", "defaultValue" => false}, + "b" => %{"type" => "boolean", "defaultValue" => false}, + "subject" => %{ + "type" => "string", + "defaultValue" => "matched", + "disabledValue" => "miss", + "conditions" => variable_requirements(group["requirements"]) + } + } + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + sticky_variables: input, + log_level: :fatal + }) + + expected = if Enum.at(group["results"], index), do: "matched", else: "miss" + assert Featurevisor.get_global_variable(f, "subject") == expected, group["name"] + Featurevisor.close(f) + end) + end) + end + + test "executes canonical sticky requirements and accumulation" do + contract = @v3["requiredVariables"] + + Enum.each(contract["stickyCases"], fn item -> + f = + Featurevisor.create_featurevisor(%{ + datafile: contract["datafile"], + sticky_features: item["stickyFeatures"], + sticky_variables: item["stickyVariables"], + log_level: :fatal + }) + + assert Featurevisor.enabled?(f, "gated") == item["matched"], item["name"] + + assert Featurevisor.get_global_variable(f, "root") == + if(item["matched"], do: "matched", else: "default"), + item["name"] + + assert Featurevisor.get_variable(f, "featureOverride", "message") == + if(item["matched"], do: "matched", else: "default"), + item["name"] + + assert Featurevisor.get_global_variable(f, "accumulated") == item["accumulated"], + item["name"] + + Featurevisor.close(f) + end) + end + + test "preserves numeric equality and ordinary null presence semantics" do + assert Mutations.apply([1], [%{"op" => "appendUnique", "path" => [], "value" => 1.0}]) == [ + 1 + ] + + assert Mutations.apply([true], [%{"op" => "appendUnique", "path" => [], "value" => 1}]) == + [true, 1] + + assert Mutations.apply([1.0, 1], [%{"op" => "removeValue", "path" => [], "value" => 1}]) == + [] + + assert Mutations.apply( + [%{"id" => 1.0, "value" => "old"}], + [ + %{ + "op" => "set", + "path" => [ + %{"select" => %{"property" => "id", "values" => [1]}}, + "value" + ], + "value" => "matched" + } + ] + ) == [%{"id" => 1.0, "value" => "matched"}] + + cache = :ets.new(:v4_conditions, [:set, :public]) + report = fn _ -> :ok end + + refute Conditions.all_conditions?( + %{"attribute" => "missing", "operator" => "isNull"}, + %{}, + cache, + report + ) + + refute Conditions.all_conditions?( + %{"attribute" => "missing", "operator" => "notNull"}, + %{}, + cache, + report + ) + + assert Conditions.all_conditions?( + %{"attribute" => "present", "operator" => "isNull"}, + %{"present" => nil}, + cache, + report + ) + end + + test "required variables share empty and notEmpty condition semantics" do + cases = [ + {"empty string", "", [], "empty", true}, + {"whitespace", " ", [], "notEmpty", true}, + {"empty array", [], [], "empty", true}, + {"array containing null", [nil], [], "notEmpty", true}, + {"empty object", %{}, [], "empty", true}, + {"object containing null", %{"value" => nil}, [], "notEmpty", true}, + {"nested empty array", %{"nested" => []}, ["nested"], "empty", true}, + {"null is neither", nil, [], "empty", false}, + {"null is not notEmpty", nil, [], "notEmpty", false}, + {"missing is neither", %{}, ["missing"], "empty", false}, + {"missing is not notEmpty", %{}, ["missing"], "notEmpty", false}, + {"number is unsupported", 0, [], "empty", false}, + {"number is not notEmpty", 0, [], "notEmpty", false} + ] + + Enum.each(cases, fn {name, value, path, operator, matched} -> + source_type = + cond do + is_binary(value) -> "string" + is_list(value) -> "array" + is_map(value) -> "object" + is_number(value) -> "integer" + true -> "object" + end + + datafile = %{ + "schemaVersion" => "2", + "revision" => "empty-requirement", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "source" => %{"type" => source_type, "defaultValue" => value}, + "subject" => %{ + "type" => "string", + "defaultValue" => "matched", + "disabledValue" => "miss", + "conditions" => [ + %{ + "variable" => "source", + "path" => path, + "operator" => operator + } + ] + } + } + } + + f = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + expected = if(matched, do: "matched", else: "miss") + assert Featurevisor.get_global_variable(f, "subject") == expected, name + Featurevisor.close(f) + end) + end + + test "accepts safe integral numeric path components and rejects invalid ones" do + assert Mutations.apply(["zero", "one"], [ + %{"op" => "set", "path" => [1.0], "value" => "changed"} + ]) == ["zero", "changed"] + + assert Mutations.apply(["zero"], [ + %{"op" => "set", "path" => [9_007_199_254_740_991.0], "value" => "ignored"} + ]) == ["zero"] + + Enum.each([-1, 1.5, 9_007_199_254_740_992, 9_007_199_254_740_992.0, true], fn part -> + assert_raise ArgumentError, fn -> + Mutations.apply(["zero", "one"], [ + %{"op" => "set", "path" => [part], "value" => "invalid"} + ]) + end + end) + + datafile = %{ + "schemaVersion" => "2", + "revision" => "numeric-paths", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "source" => %{"type" => "array", "defaultValue" => ["zero", "one"]}, + "mutated" => %{ + "type" => "array", + "defaultValue" => ["zero", "one"], + "overrideProgram" => [ + %{ + "key" => "raw", + "segments" => "*", + "operations" => [%{"op" => "set", "path" => [1.0], "value" => "changed"}], + "onMatch" => 1, + "onMiss" => 1 + } + ] + }, + "gate" => %{ + "type" => "string", + "defaultValue" => "matched", + "disabledValue" => "miss", + "conditions" => [ + %{"variable" => "source", "path" => [1.0], "operator" => "equals", "value" => "one"} + ] + } + } + } + + raw = Jason.encode!(datafile) + assert raw =~ ~s("path":[1.0]) + f = Featurevisor.create_featurevisor(%{datafile: raw, log_level: :fatal}) + assert Featurevisor.get_global_variable(f, "mutated") == ["zero", "changed"] + assert Featurevisor.get_global_variable(f, "gate") == "matched" + Featurevisor.close(f) + + Enum.each([-1, 1.5, 9_007_199_254_740_992, 9_007_199_254_740_992.0, true], fn part -> + invalid = + put_in(datafile, ["variables", "gate", "conditions", Access.at(0), "path"], [part]) + + f = Featurevisor.create_featurevisor(%{datafile: invalid, log_level: :fatal}) + assert Featurevisor.evaluate_global_variable(f, "gate").reason == :error + Featurevisor.close(f) + end) + end + + test "source getter callback errors become null while comparator errors still abort" do + parent = self() + + module = %Module{ + name: "throw-source", + before_evaluation: fn options -> + if options[:variable_key] == "source" do + send(parent, :source_called) + raise "source callback failed" + end + + options + end + } + + datafile = %{ + "schemaVersion" => "2", + "revision" => "source-error", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "source" => %{"type" => "string", "defaultValue" => "value"}, + "subject" => %{ + "type" => "string", + "defaultValue" => "matched", + "disabledValue" => "miss", + "conditions" => [%{"variable" => "source", "operator" => "isNull"}] + }, + "badComparator" => %{ + "type" => "string", + "defaultValue" => "wrong", + "conditions" => [ + %{ + "variable" => "subject", + "operator" => "semverEquals", + "value" => "not-semver" + } + ] + } + } + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + modules: [module], + log_level: :fatal + }) + + assert Featurevisor.get_global_variable(f, "subject") == "matched" + assert_receive :source_called + assert Featurevisor.evaluate_global_variable(f, "badComparator").reason == :error + Featurevisor.close(f) + end + + test "empty JSON getters remain values and legacy required is ignored" do + datafile = %{ + "schemaVersion" => "2", + "revision" => "empty-json", + "segments" => %{}, + "features" => %{ + "subject" => %{ + "bucketBy" => "userId", + "required" => ["missing"], + "traffic" => [%{"key" => "all", "segments" => "*", "percentage" => 100_000}] + } + }, + "variables" => %{ + "array" => %{"type" => "json", "defaultValue" => "[]"}, + "object" => %{"type" => "json", "defaultValue" => "{}"} + } + } + + f = Featurevisor.create_featurevisor(%{datafile: datafile, log_level: :fatal}) + assert Featurevisor.enabled?(f, "subject") + assert Featurevisor.get_global_variable(f, "array") == [] + assert Featurevisor.get_global_variable(f, "object") == %{} + refute Map.has_key?(Map.from_struct(%Module{}), :before) + refute Map.has_key?(Map.from_struct(%Module{}), :after) + Featurevisor.close(f) + end + + test "dependency events traverse mixed feature and variable graphs but honour empty programs" do + parent = self() + + initial = %{ + "schemaVersion" => "2", + "revision" => "one", + "segments" => %{}, + "features" => %{ + "featureRoot" => %{ + "hash" => "feature-root", + "conditions" => [ + %{"variable" => "source", "operator" => "equals", "value" => true} + ] + }, + "owner" => %{ + "hash" => "owner", + "variablesSchema" => %{ + "message" => %{"type" => "string", "defaultValue" => "default"} + }, + "traffic" => [ + %{ + "variableOverrides" => %{ + "message" => [ + %{ + "conditions" => [ + %{"variable" => "source", "operator" => "equals", "value" => true} + ], + "value" => "matched" + } + ] + } + } + ] + } + }, + "variables" => %{ + "source" => %{"hash" => "source-one", "type" => "boolean", "defaultValue" => true}, + "programDependent" => %{ + "hash" => "program", + "type" => "string", + "defaultValue" => "default", + "overrideProgram" => [ + %{ + "conditions" => [ + %{"variable" => "source", "operator" => "equals", "value" => true} + ], + "operations" => [%{"op" => "set", "path" => [], "value" => "matched"}], + "onMatch" => 1, + "onMiss" => 1 + } + ] + }, + "emptyProgram" => %{ + "hash" => "empty", + "type" => "string", + "defaultValue" => "default", + "overrideProgram" => [], + "overrides" => [ + %{ + "conditions" => [ + %{"variable" => "source", "operator" => "equals", "value" => true} + ], + "value" => "wrong" + } + ] + }, + "featureDependent" => %{ + "hash" => "feature-dependent", + "type" => "string", + "defaultValue" => "default", + "conditions" => [ + %{ + "feature" => "owner", + "variable" => "message", + "operator" => "equals", + "value" => "matched" + } + ] + } + } + } + + f = Featurevisor.create_featurevisor(%{datafile: initial, log_level: :fatal}) + Featurevisor.on(f, :datafile_set, &send(parent, {:datafile_set, &1})) + + updated = %{ + initial + | "revision" => "two", + "variables" => + Map.put(initial["variables"], "source", %{ + "hash" => "source-two", + "type" => "boolean", + "defaultValue" => false + }) + } + + assert true == Featurevisor.set_datafile(f, updated, true) + assert_receive {:datafile_set, details} + assert Enum.sort(details.features) == ["featureRoot", "owner"] + assert Enum.sort(details.variables) == ["featureDependent", "programDependent", "source"] + Featurevisor.close(f) + end + + test "definitions without usable hashes are always reported as changed" do + parent = self() + + initial = %{ + "schemaVersion" => "2", + "revision" => "one", + "segments" => %{}, + "features" => %{ + "missingHash" => %{"bucketBy" => "userId", "traffic" => []}, + "emptyHash" => %{"hash" => "", "bucketBy" => "userId", "traffic" => []} + }, + "variables" => %{ + "missingHash" => %{"type" => "string", "defaultValue" => "same"}, + "emptyHash" => %{"hash" => "", "type" => "string", "defaultValue" => "same"} + } + } + + f = Featurevisor.create_featurevisor(%{datafile: initial, log_level: :fatal}) + Featurevisor.on(f, :datafile_set, &send(parent, {:hashless_datafile_set, &1})) + + assert true == Featurevisor.set_datafile(f, %{initial | "revision" => "two"}, true) + assert_receive {:hashless_datafile_set, details} + assert Enum.sort(details.features) == ["emptyHash", "missingHash"] + assert Enum.sort(details.variables) == ["emptyHash", "missingHash"] + Featurevisor.close(f) + end + + test "precomputed and runtime first-match trees stay equivalent through child and datafile state" do + names = [ + "first match precomputed parsed: child", + "first match runtime parsed: child" + ] + + child_value = %{ + "stage" => "parent", + "rows" => [ + %{"id" => 0, "tags" => []}, + %{"id" => 1, "tags" => ["parent", "child"]} + ], + "flags" => ["a"] + } + + fallback_value = %{"stage" => "fallback", "rows" => [], "flags" => ["a"]} + sticky_value = %{"stage" => "sticky", "rows" => [], "flags" => [nil]} + replacement_value = %{"stage" => "replacement", "rows" => [], "flags" => []} + + Enum.each(names, fn name -> + item = Enum.find(@v4["cases"], &(&1["name"] == name)) + assert item + datafile = Jason.decode!(Jason.encode!(item["datafile"])) + original_variables = Jason.encode!(datafile["variables"]) + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + context: item["context"], + log_level: :fatal + }) + + child = Featurevisor.spawn(f, %{"country" => "be", "city" => "rotterdam"}) + sticky_child = Featurevisor.spawn(f, %{}, %{sticky_variables: %{"subject" => sticky_value}}) + + for _attempt <- 1..2 do + assert Featurevisor.get_global_variable(f, "subject") == child_value + assert Featurevisor.evaluate_global_variable(f, "subject").variable_value == child_value + assert Featurevisor.Child.get_global_variable(child, "subject") == fallback_value + assert Featurevisor.Child.get_global_variable(sticky_child, "subject") == sticky_value + end + + assert :ok = Featurevisor.Child.set_sticky_variables(sticky_child, %{}, true) + assert Featurevisor.Child.get_global_variable(sticky_child, "subject") == child_value + + replacement = %{ + "schemaVersion" => "2", + "revision" => "replacement", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "subject" => %{"type" => "object", "defaultValue" => replacement_value} + } + } + + assert true == Featurevisor.set_datafile(f, replacement, true) + assert Featurevisor.get_global_variable(f, "subject") == replacement_value + assert Featurevisor.Child.get_global_variable(child, "subject") == replacement_value + + assert true == Featurevisor.set_datafile(f, datafile) + assert Featurevisor.get_global_variable(f, "subject") == child_value + assert Featurevisor.Child.get_global_variable(child, "subject") == fallback_value + assert Jason.encode!(datafile["variables"]) == original_variables + + Featurevisor.Child.close(sticky_child) + Featurevisor.Child.close(child) + Featurevisor.close(f) + end) + end + + test "accumulation invokes callbacks once and preserves nested metadata" do + parent = self() + + module = %Module{ + name: "accumulation", + before_evaluation: fn options -> + send(parent, {:before, options.variable_key}) + options + end, + after_evaluation: fn evaluation, _options -> + send(parent, {:after, evaluation.variable_override_paths}) + evaluation + end + } + + datafile = %{ + "schemaVersion" => "2", + "revision" => "callbacks", + "segments" => %{}, + "features" => %{}, + "variables" => %{ + "subject" => %{ + "type" => "object", + "defaultValue" => %{}, + "overrideProgram" => [ + %{ + "key" => "parent", + "keyPath" => ["parent"], + "segments" => "*", + "operations" => [%{"op" => "set", "path" => ["parent"], "value" => true}], + "onMatch" => 1, + "onMiss" => 2 + }, + %{ + "key" => "child", + "keyPath" => ["parent", "child"], + "segments" => "*", + "operations" => [%{"op" => "set", "path" => ["child"], "value" => true}], + "onMatch" => 2, + "onMiss" => 2 + } + ] + } + } + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + modules: [module], + log_level: :fatal + }) + + evaluation = Featurevisor.evaluate_global_variable(f, "subject") + assert evaluation.variable_value == %{"child" => true, "parent" => true} + assert evaluation.variable_override_paths == [["parent"], ["parent", "child"]] + assert evaluation.variable_override_key == "child" + assert_receive {:before, "subject"} + assert_receive {:after, [["parent"], ["parent", "child"]]} + refute_receive {:before, "subject"} + refute_receive {:after, _} + Featurevisor.close(f) + end + + test "feature variable overrides evaluate prerequisites before false selectors" do + parent = self() + + module = %Module{ + name: "prerequisite-order", + before_evaluation: fn options -> + if options[:variable_key] == "source", do: send(parent, :source_prerequisite) + options + end + } + + requirement = %{"variable" => "source", "operator" => "equals", "value" => true} + false_condition = %{"attribute" => "country", "operator" => "equals", "value" => "never"} + + datafile = %{ + "schemaVersion" => "2", + "revision" => "feature-override-order", + "segments" => %{}, + "features" => %{ + "owner" => %{ + "bucketBy" => "userId", + "variablesSchema" => %{ + "ruleValue" => %{"type" => "string", "defaultValue" => "default"}, + "variationValue" => %{"type" => "string", "defaultValue" => "default"} + }, + "variations" => [ + %{ + "value" => "on", + "variableOverrides" => %{ + "variationValue" => [ + %{ + "key" => "variation", + "conditions" => [false_condition, requirement], + "value" => "wrong" + } + ] + } + } + ], + "traffic" => [ + %{ + "key" => "all", + "segments" => "*", + "percentage" => 100_000, + "variation" => "on", + "variableOverrides" => %{ + "ruleValue" => [ + %{ + "key" => "rule", + "conditions" => [false_condition, requirement], + "value" => "wrong" + } + ] + } + } + ] + } + }, + "variables" => %{"source" => %{"type" => "boolean", "defaultValue" => true}} + } + + f = + Featurevisor.create_featurevisor(%{ + datafile: datafile, + modules: [module], + log_level: :fatal + }) + + assert Featurevisor.get_variable(f, "owner", "ruleValue", %{"country" => "other"}) == + "default" + + refute_receive :source_prerequisite + + assert Featurevisor.get_variable(f, "owner", "variationValue", %{"country" => "other"}) == + "default" + + refute_receive :source_prerequisite + Featurevisor.close(f) + end + + defp instance_options(options) do + keys = %{ + "context" => :context, + "logLevel" => :log_level, + "stickyFeatures" => :sticky_features, + "stickyVariables" => :sticky_variables + } + + Map.new(options || %{}, fn {key, value} -> {Map.fetch!(keys, key), value} end) + end + + defp variable_requirements(value) when is_binary(value), + do: %{"variable" => value, "operator" => "equals", "value" => true} + + defp variable_requirements(value) when is_list(value), + do: Enum.map(value, &variable_requirements/1) + + defp variable_requirements(value) when is_map(value) do + Enum.find_value(["and", "or", "not"], value, fn operator -> + if Map.has_key?(value, operator), + do: %{operator => Enum.map(value[operator], &variable_requirements/1)} + end) + end +end From cc3c5e5ddb3fab79ef168bc3253eced00d7caefd Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Mon, 19 Oct 2026 01:07:51 +0200 Subject: [PATCH 2/2] fix: support Elixir 1.15 and Linux C locales in checks --- lib/featurevisor/conditions.ex | 8 +++++--- test/cli_test.exs | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/featurevisor/conditions.ex b/lib/featurevisor/conditions.ex index 1a5c4d7..8f3a59b 100644 --- a/lib/featurevisor/conditions.ex +++ b/lib/featurevisor/conditions.ex @@ -390,11 +390,11 @@ defmodule Featurevisor.Conditions do end end - defp bounded_date(value) do + # The year is checked first. Before Elixir 1.17, converting a year beyond 9999 raises. + defp bounded_date(%DateTime{year: year} = value) when year >= 1 and year <= 9999 do milliseconds = DateTime.to_unix(value, :millisecond) - if value.year >= 1 and value.year <= 9999 and - abs(value.utc_offset + value.std_offset) <= 14 * 3600 and + if abs(value.utc_offset + value.std_offset) <= 14 * 3600 and milliseconds >= -62_135_596_800_000 and milliseconds <= 253_402_300_799_999 do {:ok, DateTime.truncate(value, :millisecond)} else @@ -402,6 +402,8 @@ defmodule Featurevisor.Conditions do end end + defp bounded_date(_value), do: :error + defp semver(left, right, predicate) when is_binary(left) and is_binary(right) do case Semver.compare(left, right) do value when is_integer(value) -> predicate.(value) diff --git a/test/cli_test.exs b/test/cli_test.exs index 7f9a00b..788cf3c 100644 --- a/test/cli_test.exs +++ b/test/cli_test.exs @@ -1080,14 +1080,30 @@ defmodule Featurevisor.CLITest do {c_locale_output, c_locale_status} = run_cli_process( directory, - [{"LC_ALL", "C"}, {"LC_CTYPE", "C"}, {"LANG", "C"}, {"FAKE_MODE", "fail"} | base_env], + [ + # Linux gives the VM latin1 file names in a C locale and macOS never does. + # Forcing it tests the same thing everywhere. + {"ELIXIR_ERL_OPTIONS", "+fnl"}, + {"LC_ALL", "C"}, + {"LC_CTYPE", "C"}, + {"LANG", "C"}, + {"FAKE_MODE", "fail"} + | base_env + ], ["--json"] ) assert c_locale_status == 1, c_locale_output + # The VM running mix warns about latin1 file names on stderr, which is merged here. c_locale_report = - c_locale_output |> String.split("\n", trim: true) |> hd() |> Jason.decode!() + c_locale_output + |> String.split("\n", trim: true) + |> Enum.reject( + &String.starts_with?(&1, "warning: the VM is running with native name encoding") + ) + |> hd() + |> Jason.decode!() assert c_locale_report == failed_report