diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d0f93e5..fe5ba8f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -28,9 +28,16 @@ jobs: with: toolchain: ${{ matrix.rust }} - name: Build + # Keep transitive compatibility constraints out of the library manifest. + if: matrix.rust != '1.74.0' run: cargo build -p featurevisor --all-features + - name: Build on the minimum compiler + if: matrix.rust == '1.74.0' + run: | + cargo update -p psm --precise 0.1.26 + cargo build --locked -p featurevisor --all-features - name: Test - run: cargo test -p featurevisor --all-features + run: cargo test --locked -p featurevisor --all-features - name: Format and lint if: matrix.rust == 'stable' run: | @@ -53,8 +60,8 @@ jobs: toolchain: ${{ matrix.rust }} - name: Build and test provider run: | - cargo build -p featurevisor-openfeature - cargo test -p featurevisor-openfeature + cargo build --locked -p featurevisor-openfeature + cargo test --locked -p featurevisor-openfeature - name: Format, lint, and document if: matrix.rust == 'stable' run: | @@ -62,6 +69,26 @@ jobs: cargo clippy -p featurevisor-openfeature --all-targets -- -D warnings cargo doc -p featurevisor-openfeature --no-deps + dependency-errors: + name: Silent dependency errors and callback thread affinity + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - name: Check unwind and abort profile subprocesses + run: cargo test --locked -p featurevisor --test dependency_failures + + consumer: + name: Consumer with current stacker + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - name: Resolve and build outside the repository workspace + run: bash scripts/check-stacker-consumer.sh + example: name: Featurevisor example 1 runs-on: ubuntu-latest @@ -78,7 +105,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 d4cc9df..818e02a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,7 +24,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/Cargo.lock b/Cargo.lock index c355358..671bafb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,16 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -162,19 +172,20 @@ checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" [[package]] name = "featurevisor" -version = "1.2.0" +version = "2.0.0" dependencies = [ "chrono", "clap", "regex", "serde", "serde_json", + "stacker", "uuid", ] [[package]] name = "featurevisor-openfeature" -version = "1.2.0" +version = "2.0.0" dependencies = [ "featurevisor", "open-feature", @@ -183,6 +194,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "fragile" version = "2.1.0" @@ -390,6 +407,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" +dependencies = [ + "cc", +] + [[package]] name = "quote" version = "1.0.47" @@ -483,12 +509,31 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index a73900b..a4925a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "featurevisor" -version = "1.2.0" +version = "2.0.0" edition = "2021" rust-version = "1.74.0" description = "Featurevisor SDK for Rust: feature flags, experiments, and remote configuration" @@ -12,7 +12,7 @@ repository = "https://github.com/featurevisor/featurevisor-rust" authors = ["Fahad Heylaal"] keywords = ["feature-flags", "featurevisor", "experimentation", "ab-testing", "remote-config"] categories = ["config", "development-tools"] -exclude = ["/.github", "/Makefile", "/monorepo", "/example-1", "/featurevisor", "/openfeature"] +exclude = ["/.github", "/Makefile", "/monorepo", "/example-1", "/featurevisor", "/openfeature", "/tests", "/conformance", "/specs", "/scripts"] [workspace] members = ["openfeature"] @@ -26,6 +26,7 @@ cli = ["dep:clap", "dep:uuid"] serde = { version = "1", features = ["derive"] } serde_json = "1" regex = "1" +stacker = "0.1.24" chrono = { version = "0.4", default-features = false, features = ["std", "alloc"] } clap = { version = "4.5", features = ["derive"], optional = true } uuid = { version = "1.18", features = ["v4"], optional = true } @@ -34,3 +35,8 @@ uuid = { version = "1.18", features = ["v4"], optional = true } name = "featurevisor" path = "src/bin/featurevisor.rs" required-features = ["cli"] + +[profile.dependency-abort] +# Used only by this repository's dependency smoke subprocess tests. +inherits = "dev" +panic = "abort" diff --git a/README.md b/README.md index 44a3bf1..b612275 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Featurevisor Rust SDK -This is a port of the Featurevisor [JavaScript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to Rust. It evaluates feature flags, variations, and variables in Rust applications. +Use the Featurevisor Rust SDK to evaluate flags, variations and variables in Rust applications. -The SDK supports Featurevisor v3 projects and schema version 2 datafiles. The library is synchronous, thread safe, and compatible with Rust 1.74 or newer. +The library is synchronous, thread safe, and compatible with Rust 1.74 or newer. ## Table of contents - [Installation](#installation) -- [Public API](#public-api) - [Initialization](#initialization) -- [Evaluation types](#evaluation-types) + - [Public API](#public-api) + - [Evaluation types](#evaluation-types) - [Context](#context) - [Setting initial context](#setting-initial-context) - [Setting after initialization](#setting-after-initialization) @@ -18,8 +18,12 @@ The SDK supports Featurevisor v3 projects and schema version 2 datafiles. The li - [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 methods](#type-specific-methods) -- [Getting all evaluations](#getting-all-evaluations) + - [Conditions and overrides](#conditions-and-overrides) + - [Composed variables](#composed-variables) +- [Getting evaluations](#getting-evaluations) - [Sticky](#sticky) - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) @@ -48,6 +52,7 @@ The SDK supports Featurevisor v3 projects and schema version 2 datafiles. The li - [Test](#test) - [Benchmark](#benchmark) - [Assess distribution](#assess-distribution) +- [GitHub repositories](#github-repositories) - [Development of this package](#development-of-this-package) - [License](#license) @@ -55,61 +60,77 @@ The SDK supports Featurevisor v3 projects and schema version 2 datafiles. The li ## Installation +Upgrading an existing application? Follow the [Rust migration guide](https://featurevisor.com/docs/migrations/v4/rust/). + Add the SDK to `Cargo.toml`: ```toml [dependencies] -featurevisor = "1" +featurevisor = "2.0" ``` +On Rust 1.74, run `cargo update -p psm --precise 0.1.26` if dependency resolution selects a `psm` release that requires a newer compiler. + The optional command line runner is available with the `cli` feature: ```toml -featurevisor = { version = "1", features = ["cli"] } +featurevisor = { version = "2.0", features = ["cli"] } ``` -## Public API +## Initialization -The main API is `create_featurevisor`. Most applications need that function, the `Featurevisor` instance, `FeaturevisorOptions`, and the datafile types. +Read a built datafile from `featurevisor-all.json` in your working directory: ```rust -use featurevisor::{create_featurevisor, FeaturevisorOptions}; +use featurevisor::{context, create_featurevisor, DatafileInput, FeaturevisorOptions}; -let f = create_featurevisor(FeaturevisorOptions::default()); +fn main() -> Result<(), Box> { + let datafile = std::fs::read_to_string("featurevisor-all.json")?; + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile)), + ..Default::default() + }); + let context = context! { "userId" => "123" }; + println!("{}", f.is_enabled("checkout", Some(&context))); + f.close(); + Ok(()) +} ``` -`Featurevisor` is cheaply cloneable and can be shared between threads. Evaluation takes a snapshot of the instance state before invoking module, diagnostic, or event callbacks. +File read errors propagate from `main`. This example closes the instance after evaluation. In a long running application, keep it alive for subsequent evaluations and close it during shutdown. -## Initialization +Initialization also accepts `DatafileInput::Content(datafile_content)`. Invalid datafiles are rejected without replacing the previous datafile and report a diagnostic with the message `Could not parse datafile`. + +### Public API -Initialize with a decoded datafile: +The main API is `create_featurevisor`. Most applications need that function, the `Featurevisor` instance, `FeaturevisorOptions`, and the datafile types. ```rust -use featurevisor::{create_featurevisor, DatafileInput, FeaturevisorOptions}; +use featurevisor::{create_featurevisor, FeaturevisorOptions}; -let datafile = std::fs::read_to_string("datafile.json")?; -let f = create_featurevisor(FeaturevisorOptions { - datafile: Some(DatafileInput::Json(datafile)), - ..Default::default() -}); +let f = create_featurevisor(FeaturevisorOptions::default()); ``` -Initialization also accepts `DatafileInput::Content(datafile_content)`. Invalid datafiles are rejected without replacing the previous datafile and report a diagnostic with the message `Could not parse datafile`. +`Featurevisor` is cheaply cloneable and can be shared between threads. Accepted state is published atomically and callbacks run outside mutation locks. A definition already selected can finish using it, while later dependency reads can observe datafile updates made by callbacks. An evaluation is not a transaction pinned to one datafile version. -## Evaluation types +### Evaluation types -Featurevisor evaluates three kinds of values: +Featurevisor evaluates four kinds of values: -* a flag, which answers whether a feature is enabled -* a variation, which returns a variation value -* a feature variable, which returns remote configuration owned by a feature -* a global variable, which returns remote configuration independently of a feature +- [**Flag**](#check-if-enabled) (`bool`): whether the feature is enabled or not +- [**Variation**](#getting-variation) (`String`): the variation of the feature (if any) +- [**Feature variable**](#getting-feature-variables): a configuration value owned by a feature +- [**Global variable**](#getting-global-variables): a configuration value evaluated independently of a feature -All evaluations use a context and the feature rules in the active datafile. +Every evaluation uses the active datafile and the effective context. ## Context -Contexts are maps of attributes used by conditions and bucketing. `AttributeValue` supports strings, integers, doubles, booleans, dates, arrays, objects, and null. +Contexts are maps of attributes used by conditions and bucketing. `AttributeValue` supports strings, integers, doubles, booleans, dates, arrays, objects, and `null`. + +Conditions compare these attributes using the [operators](https://featurevisor.com/docs/operators/), including `empty` and `notEmpty`. + +Date comparisons accept portable timestamp strings and `AttributeValue::Date` instants. Offsets must stay within 14 hours and both local and UTC years within 0001 to 9999. Fractions are truncated to milliseconds. Native leap seconds are not accepted. ```rust use featurevisor::{context, AttributeValue, Context}; @@ -147,9 +168,9 @@ f.set_context(context.clone(), true); ### Manually passing context ```rust -let is_enabled = f.is_enabled("my_feature", Some(&context)); -let variation = f.get_variation("my_feature", Some(&context), None); -let value = f.get_variable("my_feature", "my_variable", Some(&context), None); +let is_enabled = f.is_enabled("checkout", Some(&context)); +let variation = f.get_variation("checkout", Some(&context), None); +let value = f.get_variable("checkout", "bgColor", Some(&context), None); ``` Context passed for one evaluation wins over stored context for matching keys. @@ -157,7 +178,7 @@ Context passed for one evaluation wins over stored context for matching keys. ## Check if enabled ```rust -let is_enabled = f.is_enabled("my_feature", None); +let is_enabled = f.is_enabled("checkout", None); if is_enabled { // show the enabled experience } @@ -166,7 +187,7 @@ if is_enabled { ## Getting variation ```rust -if let Some(variation) = f.get_variation("my_feature", None, None) { +if let Some(variation) = f.get_variation("checkout", None, None) { println!("variation: {variation}"); } ``` @@ -177,7 +198,7 @@ To provide a fallback when no variation is selected: use featurevisor::OverrideOptions; let variation = f.get_variation( - "my_feature", + "checkout", None, Some(&OverrideOptions { default_variation_value: Some("control".to_string()), @@ -188,11 +209,15 @@ let variation = f.get_variation( ## Getting variables +### Getting feature variables + ```rust -let value = f.get_variable("my_feature", "backgroundColour", None, None); +let value = f.get_variable("checkout", "bgColor", None, None); ``` -Global variables use explicit method names because Rust does not overload functions: +### Getting global variables + +Global variables use separate method names because Rust does not overload functions: ```rust let email = f.get_global_variable("supportEmail", None, None); @@ -204,18 +229,76 @@ let evaluation = f.evaluate_global_variable("supportEmail", None, None); The SDK also provides typed getters: ```rust -let enabled = f.get_variable_boolean("my_feature", "enabled", None, None); -let title = f.get_variable_string("my_feature", "title", None, None); -let count = f.get_variable_integer("my_feature", "count", None, None); -let ratio = f.get_variable_double("my_feature", "ratio", None, None); -let items = f.get_variable_array("my_feature", "items", None, None); -let object = f.get_variable_object("my_feature", "object", None, None); -let json = f.get_variable_json("my_feature", "json", None, None); +let enabled = f.get_variable_boolean("checkout", "enabled", None, None); +let title = f.get_variable_string("checkout", "title", None, None); +let count = f.get_variable_integer("checkout", "count", None, None); +let ratio = f.get_variable_double("checkout", "ratio", None, None); +let items = f.get_variable_array("checkout", "items", None, None); +let object = f.get_variable_object("checkout", "object", None, None); +let json = f.get_variable_json("checkout", "json", None, None); +``` + +Typed getters do not coerce strings, booleans, or unrelated collections. Integer getters accept finite whole numbers written with a decimal point, such as `1.0`. + +### Conditions and overrides + +Conditions and overrides are applied for you. See [Conditions](https://featurevisor.com/docs/conditions/) and [Variables](https://featurevisor.com/docs/variables/#global-variables) for authoring guidance. Ordinary getters keep the same arguments. + +All selectors share one engine: segments, feature and global variable root gates, force entries, traffic rules and variable overrides. Leaves compare attributes, reference a segment by its literal key, check a feature getter, or compare a variable getter. Variable leaves can name a feature owner and a compiled path array. A feature check defaults to enabled, with an optional variation. + +```json +{ + "and": [ + "customers", + { "feature": "checkout", "enabled": true }, + { "variable": "deliveryPolicy", "path": ["limit"], "operator": "greaterThan", "value": 0 } + ] +} +``` + +Arrays imply AND. `not` negates the AND of its direct children. Evaluation preserves order and stops when the result is known. New empty expressions are false. Feature evaluation checks sticky state, force, the root gate, then traffic. Global variables check sticky state and the root gate before sources or overrides. An unmet root reports `conditions_unmet` and its normalized expression in `Evaluation.conditions`. Existing variation and feature variable disabled value policies still apply. + +Modules should return the same result for the same input during one evaluation. Pass values that change through context. + +To see which overrides changed a value, inspect `variable_override_paths` in the detailed evaluation: + +```rust +let evaluation = f.evaluate_global_variable("runtimePolicy", None, None); +let paths = evaluation.variable_override_paths; ``` -Typed getters do not coerce strings, booleans, or unrelated collections. Integer getters accept finite whole doubles because JavaScript has one number type. +### Composed variables -## Getting all evaluations +Compositions use the normal global variable getter: + +```rust +let screen = f.get_global_variable("screen", None, None); +let policy = f.get_global_variable("policyAlias", None, None); +``` + +A compiled map composition declares a nonempty list of getter references. Each `key` becomes a literal property in a fresh outer object: + +```json +{ + "type": "object", + "compose": [ + { "key": "enabled", "feature": "checkout" }, + { "key": "layout", "feature": "checkout", "variation": true }, + { "key": "label", "feature": "checkout", "variable": "label" }, + { "key": "policy", "variable": "deliveryPolicy" } + ] +} +``` + +The alias form uses a literal global variable key, such as `"compose": "deliveryPolicy"`, and returns that source value directly. Sources use ordinary getter behaviour, effective context, sticky values and modules. False, zero, empty strings, arrays and null remain values. JSON sources are decoded exactly once, even when the decoded value resembles JSON. An explicit default on the outer getter is not passed to sources. + +Compositions have no `defaultValue` or `useDefaultWhenDisabled`. Optional root conditions run before all sources. A missed gate skips sources and overrides and returns `disabledValue`, or null when absent. Whole variable sticky state bypasses sources and overrides. All members are read before the existing override program runs. Mutations do not change sources, sibling properties or sticky objects. Rust returns owned values. Composition memo entries share their internal source values instead of copying large results merely for caching. + +Detailed results report `variable_composed`, or `variable_override_rule` when an override matches. Cycles, more than 256 internal dependency handoffs and invalid compositions fail the whole evaluation with reason `error`, without returning a partial composition. Ordinary getters return their usual false or absent value on failure. An ordinary completed false or null source result is not a dependency error. Request state is released on every exit. + +SDK dependency failures are propagated as values, not panics. They do not invoke the panic hook or write to standard error, including with `panic = "abort"`. Configured diagnostic handlers and error event listeners still receive evaluation errors. Panics from application callbacks follow the application's panic policy: unwinding panics are caught around callbacks, but aborting panics cannot be recovered. + +## Getting evaluations ```rust let features = f.get_feature_evaluations(None, &[], None); @@ -228,6 +311,8 @@ Pass a list of keys to limit either result. An empty list evaluates every entity Sticky values keep selected evaluations stable for the lifetime of an instance or child instance. +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. + ### Initialize with sticky ```rust @@ -235,7 +320,7 @@ use std::collections::HashMap; use featurevisor::{EvaluatedFeature, FeaturevisorOptions}; let mut sticky = HashMap::new(); -sticky.insert("my_feature".to_string(), EvaluatedFeature { +sticky.insert("checkout".to_string(), EvaluatedFeature { enabled: true, variation: Some("treatment".to_string()), variables: None, @@ -260,12 +345,18 @@ f.set_sticky_features(HashMap::new(), true); // replace all sticky feature value ## Setting datafile +`set_datafile` 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 `bool`, not a `Result`. Rejection details remain available through diagnostics and the error event. Closed instances do not emit diagnostics or events for attempted updates. + ### Merging by default `set_datafile` merges incoming features, global variables, and segments into the stored datafile. Incoming entries replace entries with the same key. +New generated datafiles use schema version `"4"`. Schema `"2"` selectors normalize eagerly before merging, retaining their informational version metadata. Schema 4 entities normalize lazily on first read. A partial update invalidates only incoming entity caches. Replacement clears them all. Caller data is never modified. Compatible legacy selectors are converted to conditions rather than evaluated by a separate runtime. + ```rust -f.set_datafile(featurevisor::DatafileInput::Json(datafile_json), false); +let accepted = f.set_datafile(featurevisor::DatafileInput::Json(datafile_json), false); ``` ### Replacing @@ -273,7 +364,7 @@ f.set_datafile(featurevisor::DatafileInput::Json(datafile_json), false); Pass `true` to replace the complete stored datafile: ```rust -f.set_datafile(featurevisor::DatafileInput::Json(datafile_json), true); +let accepted = f.set_datafile(featurevisor::DatafileInput::Json(datafile_json), true); // replace ``` ### Loading datafiles on demand @@ -282,7 +373,7 @@ Fetch a datafile using the HTTP client used by your application, then pass its b ### Updating datafile -Call `set_datafile` whenever your application receives a newer datafile. A `datafile_set` event contains changed feature and global variable keys, including dependants affected by changed requirements or segments, plus revision information. +Call `set_datafile` whenever your application receives a newer datafile. A `datafile_set` event contains changed feature and global variable keys, including transitive dependants affected by conditions, segments or composition sources, plus revision information. Both the previous and next dependency graphs participate, including added and removed references. ### Interval based update @@ -290,7 +381,7 @@ The SDK does not start a background timer. Use your application's scheduler to f ## Diagnostics -Diagnostics are the only SDK observability API. The SDK does not expose a separate logger handler. +Diagnostics are the SDK observability API. ### Levels @@ -311,14 +402,12 @@ let f = create_featurevisor(FeaturevisorOptions { }); ``` -Regular expression conditions use Rust's `regex` crate and therefore support -Featurevisor's portable regular expression subset. Lookaround and -backreferences cannot be evaluated by this engine. They produce a -`condition_match_error` diagnostic instead of being evaluated, so lint the -project before shipping its datafiles. +Regular expression conditions use Rust's `regex` crate and therefore support Featurevisor's portable regular expression subset. Lookaround and backreferences cannot be evaluated by this engine. They produce a `condition_match_error` diagnostic instead of being evaluated, so lint the project before shipping its datafiles. Module diagnostics can be subscribed to through `ModuleApi`. Diagnostic details are always an object, and error diagnostics also emit the `error` event. +Malformed stringified selector JSON fails that selector, including through negated segment references, without rejecting the datafile or disabling unrelated entities. It reports `conditions_parse_error` at error level once per selector per incoming entity load. Details contain exactly `conditions`, `entityType` and `entityKey`. Schema 2 loading reports after the accepted revision is stored. Schema 4 reports on first read, after caching the normalized entity. Callbacks run without internal locks and can safely reenter the instance. + ## Events Register an event callback with `f.on(event_name, callback)`. The returned unsubscribe function is safe to call more than once. @@ -346,18 +435,22 @@ Emitted for error diagnostics. The details include the complete `diagnostic`. Use the detailed methods when you need the reason and rule information: ```rust -let evaluation = f.evaluate_flag("my_feature", None); +let evaluation = f.evaluate_flag("checkout", None); println!("{evaluation:?}"); -let variation_evaluation = f.evaluate_variation("my_feature", None, None); -let variable_evaluation = f.evaluate_variable("my_feature", "my_variable", None, None); +let variation_evaluation = f.evaluate_variation("checkout", None, None); +let variable_evaluation = f.evaluate_variable("checkout", "bgColor", None, None); let global_evaluation = f.evaluate_global_variable("supportEmail", None, None); ``` An evaluation includes the type, feature key, reason, and when applicable the bucket key, bucket value, rule, traffic, variation, variable, or diagnostic error. +Root misses include `conditions` and reason `conditions_unmet`. Global composition reports `variable_composed`. Override provenance uses `variable_override_index`, `variable_override_key`, `variable_override_path` and `variable_override_paths`. Their JSON names use camel case. + ## 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. + Modules extend evaluation and lifecycle behaviour without changing the public evaluation methods. ### Defining a module @@ -399,9 +492,11 @@ f.remove_module("audit"); drop(unsubscribe); ``` -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. Bucket key and bucket value callbacks run during feature bucketing. Duplicate names are reported and ignored. +All `before_evaluation` callbacks run in registration order before evaluation. Caller defaults are applied next, followed by all `after_evaluation` callbacks in registration order. Condition and composition getters use the same module pipeline. Bucket key and bucket value callbacks run during feature bucketing. Duplicate module names are reported and ignored. -`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. +`EvaluateOptions.dependency_of` and `dependency_purpose` are `None` for an application call. An internal read identifies the immediate requesting evaluation through `evaluation_type`, optional `feature_key` and optional `variable_key`. Its purpose is `DependencyPurpose::Condition` or `DependencyPurpose::Composition`. Tracking modules can distinguish eligibility checks from consumed source values instead of dropping every dependency read. Memoization separates purposes, requesters, context and state, and lasts only for the current public call. Callbacks never receive the private session. + +Evaluation remains synchronous on the calling thread at every dependency depth. Module callbacks retain thread identity and thread local context. Deep getter and segment chains use `stacker` to grow the stack on that same thread when necessary, without spawning worker threads or changing the 256 handoff bound. Stack growth depends on the native platform support provided by `stacker`. WebAssembly is unsupported because stack growth is unavailable there and deep dependency chains can trap. Modules implement `Send + Sync` because applications can share instances between threads. Callbacks run outside internal mutation locks. ## Child instance @@ -409,7 +504,7 @@ A child keeps its own context, sticky values, and listeners while evaluating thr ```rust let child = f.spawn(context! { "userId" => "child-1" }, Default::default()); -let enabled = child.is_enabled("my_feature", None); +let enabled = child.is_enabled("checkout", None); child.set_context(context! { "country" => "nl" }, false); child.close(); ``` @@ -422,21 +517,22 @@ The child snapshots parent context keys available at creation. Parent keys added f.close(); ``` -Close is idempotent. It closes modules, clears diagnostic subscriptions, clears event listeners, and makes later state changes no ops. +Close is idempotent. It closes modules, clears diagnostic subscriptions and event listeners, releases parsed condition and regex caches, and makes later state changes no ops. The instance diagnostic handler remains available for module shutdown errors and is then released. + +Call `close()` when you finish using an instance, and `child.close()` when you finish using a child. This is especially important when your callbacks capture their own instance or child: Rust reference counting does not collect ownership cycles. Dropping a cleanup function does not unsubscribe it. Call the function to unsubscribe. Cleanup functions use weak references to their SDK owner, so retaining one does not by itself keep that owner alive. + +Closing does not invalidate evaluations already in progress. Their datafile snapshots are released when those evaluations finish. Datafile and context values remain owned by any instance handles you retain. Closing is not a promise to return every allocation to the operating system immediately. ## OpenFeature -The OpenFeature provider is published as a separate crate. Applications that -only use the Featurevisor SDK do not compile or link OpenFeature, Tokio, or the -provider code. +The OpenFeature provider is published as a separate crate. Applications that only use the Featurevisor SDK do not compile or link OpenFeature, Tokio, or the provider code. -The official OpenFeature Rust SDK currently requires Rust 1.80.1 or newer. The -base Featurevisor crate continues to support Rust 1.74 or newer. +The provider supports the official OpenFeature Rust SDK 0.3.0 and requires Rust 1.80.1 or newer. The base Featurevisor crate continues to support Rust 1.74 or newer. ```toml [dependencies] -featurevisor = "1.2" -featurevisor-openfeature = "1.2" +featurevisor = "2.0" +featurevisor-openfeature = "2.0" open-feature = "0.3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` @@ -475,8 +571,7 @@ async fn configure(datafile: String) -> Result<(), Box> { } ``` -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: ```rust use featurevisor_openfeature::FeaturevisorProvider; @@ -484,8 +579,7 @@ use featurevisor_openfeature::FeaturevisorProvider; let provider = FeaturevisorProvider::from_featurevisor(f.clone())?; ``` -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 | | --- | --- | @@ -494,76 +588,107 @@ 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 provider implements boolean, integer, float, string, and structure resolution. The OpenFeature Rust SDK represents top level object values with `StructValue`. Arrays can be nested in those objects, but its provider contract does not expose a separate top level array resolver. + +The upstream structure value type cannot represent null. Resolving a null structure reports a type mismatch so the OpenFeature client can use its supplied default. Null object properties and array members are omitted during structure conversion, including in composed values. -The provider implements boolean, integer, float, string, and structure -resolution. The OpenFeature Rust SDK represents top level object values with -`StructValue`. Arrays can be nested in those objects, but its provider contract -does not expose a separate top level array resolver. +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, invalid contexts, and invalid datafiles use standard OpenFeature errors. Replacing an invalid datafile with a valid one recovers the provider. -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, invalid contexts, and invalid datafiles use standard OpenFeature -errors. Replacing an invalid datafile with a valid one recovers the provider. +An unmet root condition keeps the Featurevisor reason `conditions_unmet` and maps to OpenFeature `DISABLED`. Composed values map to `TARGETING_MATCH`, including matched composed overrides. The provider does not decode composed JSON values again. -The OpenFeature Rust SDK does not currently expose provider tracking or -provider event callbacks. Featurevisor modules and diagnostics continue to -work inside the Featurevisor instance. +The OpenFeature Rust SDK does not currently expose provider tracking or provider event callbacks. Featurevisor modules and diagnostics continue to work inside the Featurevisor instance. + +The provider status accessor reports `Error` for an invalid initial datafile, independently of the diagnostic log level, and `Ready` after a valid update. The upstream Rust initialization method has no error return value. Evaluations report `ParseError` while the datafile is unhealthy. Borrowed instances remain owned by the caller. See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for the shared key convention and providers for other languages. ## CLI usage -The optional CLI delegates project discovery and datafile generation to the Node.js Featurevisor CLI, then evaluates through this Rust SDK. Install Rust and use the `cli` feature to build it: +Install the optional CLI with Cargo: + +```bash +cargo install featurevisor --version "^2.0" --features cli +``` + +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 -cargo install featurevisor --features cli +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 Rust 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 ```bash -featurevisor test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures +featurevisor test \ + --project-directory-path=/path/to/featurevisor-project \ + --only-failures ``` -`--keyPattern` filters test keys with a case insensitive regular expression. -`--assertionPattern` does the same for assertion descriptions. A successful run -prints totals for test specs and assertions. If filters select no specs, the -command fails instead of reporting a silent success. +See [Testing](https://featurevisor.com/docs/testing/) for authoring specs and [Checks](https://featurevisor.com/docs/testing/#checks) for check syntax and operators. + +Useful test options: + +- `--key-pattern=` and `--assertion-pattern=` filter tests. +- `--entity-type=scenario` selects scenarios. `--set=` selects one project set. Omitting `--set` runs all sets. +- `--case=` reruns one expanded case. Copy the opaque ID or rerun command from a failure report. +- `--json --pretty` produces an indented JSON report. +- `--coverage` reports observed rule and override selections. `--uncovered` lists features and global variables without associated specs. +- `--only-failures`, `--quiet`, `--verbose` and `--show-datafile` control diagnostic output. ### Benchmark +The examples below use a project without environments. Add `--environment=` if your project configures environments. In a sets project, omit `--set` to run every set or pass `--set=`. Repeat `--target=` to evaluate specific target datafiles. The Node.js CLI validates these options and builds the datafiles. Rust evaluates them. Replace `checkout` with a feature defined in your project. + ```bash -featurevisor benchmark --projectDirectoryPath=../featurevisor/examples/example-1 --feature=foo --n=1000000 +featurevisor benchmark \ + --project-directory-path=/path/to/featurevisor-project \ + --feature=checkout \ + --context='{"country":"nl"}' \ + --n=1000 ``` -Use either `--variation` or `--variable=` when benchmarking. They cannot be -used together. Pass `--variable=` without `--feature` to benchmark a global variable. The command reports minimum, average, maximum, and total -durations in fractional milliseconds for the individual SDK evaluations. +Use `--variation` for a variation or `--variable=` for a feature variable. Omit `--feature` to benchmark a global variable. Do not combine `--variation` with `--variable`. See [Benchmarking](https://featurevisor.com/docs/cli/#benchmarking). ### Assess distribution ```bash -featurevisor assess-distribution --projectDirectoryPath=../featurevisor/examples/example-1 --feature=foo --n=100000 +featurevisor assess-distribution \ + --project-directory-path=/path/to/featurevisor-project \ + --feature=checkout \ + --populate-uuid=userId \ + --n=1000 ``` -The output includes separate flag and variation sections with counts and -percentages. Pass `--target` more than once to assess multiple target -datafiles. +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). + +The legacy `--with-scopes`, `--with-tags`, `--schemaVersion` and `--schema-version` options are accepted and ignored. + +## GitHub repositories -The legacy `--with-scopes`, `--with-tags`, `--schemaVersion`, and `--schema-version` options are accepted and ignored. Targets can be passed more than once. +- SDK: [featurevisor/featurevisor-rust](https://github.com/featurevisor/featurevisor-rust) +- Example application: [featurevisor/featurevisor-example-rust](https://github.com/featurevisor/featurevisor-example-rust) + + +See [Overrides and requirements](#overrides-and-requirements). + + + +The aggregate evaluation reference has moved to [Getting evaluations](#getting-evaluations). + ## Development of this package -The base SDK supports Rust 1.74 or newer. The complete workspace requires Rust -1.80.1 or newer because it includes the OpenFeature provider. With a current -stable toolchain, run: +The base SDK supports Rust 1.74 or newer. The complete workspace requires Rust 1.80.1 or newer because it includes the OpenFeature provider. With a current stable toolchain, run: ```bash make check @@ -572,6 +697,10 @@ make test-example-1 The package uses `cargo fmt`, `cargo clippy`, and `cargo test`. `Cargo.lock` is committed so library, CLI, and provider dependency resolution stays reproducible. +The native tests cover runtime conformance and project integration. + +Run `cargo test --offline -p featurevisor --test readme_initialization` to compile and execute the actual initialization snippet in an isolated consumer using cached dependencies. The check also runs in the ordinary SDK suite and CI. + The repository publishes two crates with the same version: - `featurevisor` @@ -579,10 +708,7 @@ The repository publishes two crates with the same version: To release, update both versions, run the checks, merge the change, and tag the matching version. The release workflow publishes the base crate first and the provider second so the provider's exact Featurevisor dependency is available on crates.io. -Cargo can only package the provider after that exact base crate version is -visible on crates.io. Pull request checks therefore build, test, lint, and -document the provider. The tagged release performs the final provider package -inspection after publishing the base crate. +Cargo can only package the provider after that exact base crate version is visible on crates.io. Pull request checks therefore build, test, lint, and document the provider. The tagged release performs the final provider package inspection after publishing the base crate. ## License 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/examples/dependency_smoke.rs b/examples/dependency_smoke.rs new file mode 100644 index 0000000..34f4f6f --- /dev/null +++ b/examples/dependency_smoke.rs @@ -0,0 +1,9 @@ +#[path = "../tests/fixtures/dependency_smoke.rs"] +mod dependency_smoke; + +fn main() { + if let Some(profile) = std::env::args().nth(1) { + assert_eq!(cfg!(panic = "abort"), profile == "dependency-abort"); + } + dependency_smoke::run(); +} diff --git a/examples/sticky_session_bench.rs b/examples/sticky_session_bench.rs new file mode 100644 index 0000000..5acc043 --- /dev/null +++ b/examples/sticky_session_bench.rs @@ -0,0 +1,136 @@ +//! Serial, per getter benchmark for the exported sticky handoff path. +//! Run with: cargo run --release --example sticky_session_bench -- 5000 + +use featurevisor::{ + create_featurevisor, AttributeValue, Context, DatafileInput, EvaluatedFeature, + FeaturevisorOptions, SpawnOptions, StickyFeatures, StickyVariables, VariableValue, +}; +use serde_json::json; +use std::hint::black_box; +use std::time::Instant; + +fn datafile() -> DatafileInput { + DatafileInput::Json( + json!({ + "schemaVersion": "2", "revision": "sticky-bench", "segments": {}, + "features": {"gate": {"bucketBy": "userId", "traffic": [ + {"key": "all", "segments": "*", "percentage": 100000} + ]}}, + "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} + ]} + } + }) + .to_string(), + ) +} + +fn sticky(kind: &str, size: usize) -> (StickyFeatures, StickyVariables) { + let mut features = StickyFeatures::new(); + let mut variables = StickyVariables::new(); + if size > 0 { + if kind == "features" { + features.insert( + "gate".into(), + EvaluatedFeature { + enabled: true, + variation: None, + variables: None, + }, + ); + for index in 1..size { + features.insert( + format!("unrelated{index}"), + EvaluatedFeature { + enabled: true, + variation: None, + variables: None, + }, + ); + } + } else { + variables.insert("leaf".into(), VariableValue::Integer(1)); + for index in 1..size { + variables.insert( + format!("unrelated{index}"), + VariableValue::Integer(index as i64), + ); + } + } + } + (features, variables) +} + +fn main() { + let iterations = std::env::args() + .nth(1) + .map(|value| value.parse::().expect("positive iteration count")) + .unwrap_or(5000); + assert!(iterations > 0); + println!("kind,size,scope,iterations,mean_us"); + + for kind in ["features", "variables"] { + for size in [0, 100, 1000, 5000] { + for scope in ["instance", "child"] { + let (features, variables) = sticky(kind, size); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(datafile()), + sticky_features: if scope == "instance" { + Some(features.clone()) + } else { + None + }, + sticky_variables: if scope == "instance" { + Some(variables.clone()) + } else { + None + }, + ..Default::default() + }); + let mut context = Context::new(); + context.insert("userId".into(), AttributeValue::String("bench".into())); + let child = if scope == "child" { + Some(f.spawn( + context.clone(), + SpawnOptions { + sticky_features: Some(features), + sticky_variables: Some(variables), + }, + )) + } else { + None + }; + let get = || { + if let Some(child) = &child { + child.get_global_variable("root", None, None) + } else { + f.get_global_variable("root", Some(&context), None) + } + }; + for _ in 0..100 { + assert_eq!(get(), Some(VariableValue::Integer(1))); + } + let mut total_ns = 0_u128; + for _ in 0..iterations { + let started = Instant::now(); + let value = black_box(get()); + total_ns += started.elapsed().as_nanos(); + assert_eq!(value, Some(VariableValue::Integer(1))); + } + println!( + "{kind},{size},{scope},{iterations},{:.3}", + total_ns as f64 / iterations as f64 / 1000.0 + ); + if let Some(child) = child { + child.close(); + } + f.close(); + } + } + } +} diff --git a/openfeature/Cargo.toml b/openfeature/Cargo.toml index 995fcb3..a588ad0 100644 --- a/openfeature/Cargo.toml +++ b/openfeature/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "featurevisor-openfeature" -version = "1.2.0" +version = "2.0.0" edition = "2021" rust-version = "1.80.1" description = "OpenFeature provider for the Featurevisor Rust SDK" @@ -14,7 +14,7 @@ keywords = ["feature-flags", "featurevisor", "openfeature", "experimentation"] categories = ["config", "development-tools"] [dependencies] -featurevisor = { path = "..", version = "=1.2.0" } +featurevisor = { path = "..", version = "=2.0.0" } open-feature = { version = "0.3.0", features = ["serde_json"] } serde_json = "1" time = { version = "=0.3.36", features = ["formatting"] } diff --git a/openfeature/README.md b/openfeature/README.md index b80222c..69aae92 100644 --- a/openfeature/README.md +++ b/openfeature/README.md @@ -6,14 +6,17 @@ This crate adapts the Featurevisor Rust SDK to the official OpenFeature Rust SDK ```toml [dependencies] -featurevisor = "1.2" -featurevisor-openfeature = "1.2" -open-feature = "0.3" +featurevisor = "2.0" +featurevisor-openfeature = "2.0" +open-feature = "0.3.0" ``` The provider is a separate crate, so applications that only use `featurevisor` do not compile or link OpenFeature. +The provider supports OpenFeature Rust SDK 0.3.0 and requires Rust 1.80.1 or +newer. The base Featurevisor crate continues to support Rust 1.74 or newer. + ## Usage Create a provider that owns its Featurevisor instance: 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/src/lib.rs b/openfeature/src/lib.rs index 84e9e7f..e2add1b 100644 --- a/openfeature/src/lib.rs +++ b/openfeature/src/lib.rs @@ -25,7 +25,9 @@ use featurevisor::{ EvaluationReason as FeaturevisorReason, EventDetails, EventName, Featurevisor, FeaturevisorOptions, Unsubscribe, VariableValue, }; -use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails}; +use open_feature::provider::{ + FeatureProvider, ProviderMetadata, ProviderStatus, ResolutionDetails, +}; use open_feature::{ async_trait, EvaluationContext, EvaluationContextFieldValue, EvaluationError, EvaluationErrorCode, EvaluationReason, EvaluationResult, FlagMetadata, StructValue, Value, @@ -191,7 +193,8 @@ impl FeaturevisorProvider { let context = featurevisor_context(context, &self.targeting_key_field)?; let (feature_key, selector) = split_key(flag_key, &self.key_separator); - let evaluation = if feature_key == self.global_variable_prefix && selector.is_some() { + let global = feature_key == self.global_variable_prefix && selector.is_some(); + let evaluation = if global { self.featurevisor.evaluate_global_variable( selector.unwrap_or_default(), Some(&context), @@ -199,7 +202,7 @@ impl FeaturevisorProvider { ) } else if selector.is_none() { self.featurevisor.evaluate_flag(feature_key, Some(&context)) - } else if selector == Some(self.variation_key.as_str()) { + } else if !global && selector == Some(self.variation_key.as_str()) { self.featurevisor .evaluate_variation(feature_key, Some(&context), None) } else { @@ -217,7 +220,7 @@ impl FeaturevisorProvider { let value = if selector.is_none() { evaluation.enabled.map(ResolvedValue::Bool) - } else if selector == Some(self.variation_key.as_str()) { + } else if !global && selector == Some(self.variation_key.as_str()) { evaluation .variation_value .clone() @@ -268,6 +271,13 @@ impl Drop for FeaturevisorProvider { #[async_trait] impl FeatureProvider for FeaturevisorProvider { + fn status(&self) -> ProviderStatus { + match self.datafile_error.lock() { + Ok(error) if error.is_none() => ProviderStatus::Ready, + _ => ProviderStatus::Error, + } + } + fn metadata(&self) -> &ProviderMetadata { &self.metadata } @@ -418,6 +428,13 @@ fn split_key<'a>(key: &'a str, separator: &str) -> (&'a str, Option<&'a str>) { } fn variable_type(evaluation: &Evaluation) -> Option<&str> { + if evaluation + .variable + .as_ref() + .is_some_and(|v| v.compose.is_some()) + { + return None; + } evaluation .variable_schema .as_ref() @@ -488,17 +505,17 @@ fn reason_for(reason: FeaturevisorReason) -> EvaluationReason { | FeaturevisorReason::VariableNotFound | FeaturevisorReason::NoVariations | FeaturevisorReason::Error => EvaluationReason::Error, - FeaturevisorReason::Required - | FeaturevisorReason::Forced + FeaturevisorReason::Forced | FeaturevisorReason::Sticky | FeaturevisorReason::Rule | FeaturevisorReason::VariableOverrideVariation | FeaturevisorReason::VariableOverrideRule => EvaluationReason::TargetingMatch, + FeaturevisorReason::VariableComposed => EvaluationReason::TargetingMatch, FeaturevisorReason::Allocated => EvaluationReason::Split, FeaturevisorReason::Disabled | FeaturevisorReason::VariationDisabled | FeaturevisorReason::VariableDisabled - | FeaturevisorReason::RequiredFeaturesUnmet => EvaluationReason::Disabled, + | FeaturevisorReason::ConditionsUnmet => EvaluationReason::Disabled, _ => EvaluationReason::Default, } } @@ -658,12 +675,12 @@ mod tests { #[test] fn maps_every_current_featurevisor_reason() { for reason in [ - FeaturevisorReason::Required, FeaturevisorReason::Forced, FeaturevisorReason::Sticky, FeaturevisorReason::Rule, FeaturevisorReason::VariableOverrideVariation, FeaturevisorReason::VariableOverrideRule, + FeaturevisorReason::VariableComposed, ] { assert_eq!(reason_for(reason), EvaluationReason::TargetingMatch); } @@ -677,7 +694,7 @@ mod tests { FeaturevisorReason::Disabled, FeaturevisorReason::VariationDisabled, FeaturevisorReason::VariableDisabled, - FeaturevisorReason::RequiredFeaturesUnmet, + FeaturevisorReason::ConditionsUnmet, ] { assert_eq!(reason_for(reason), EvaluationReason::Disabled); } @@ -692,7 +709,6 @@ mod tests { } for reason in [ - FeaturevisorReason::OutOfRange, FeaturevisorReason::NoMatch, FeaturevisorReason::VariableDefault, ] { diff --git a/openfeature/tests/provider.rs b/openfeature/tests/provider.rs index 8693b15..67fc38a 100644 --- a/openfeature/tests/provider.rs +++ b/openfeature/tests/provider.rs @@ -53,6 +53,237 @@ const DATAFILE: &str = r#"{ } }"#; +#[tokio::test] +async fn shared_provider_conformance() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../conformance/openfeature-provider.json")).unwrap(); + assert_eq!(fixture["kind"], "featurevisor.openfeature-provider"); + assert_eq!(fixture["schemaVersion"], 1); + let cases = fixture["cases"].as_array().unwrap(); + assert!(!cases.is_empty()); + let make_provider = |field: &str| { + FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + datafile: Some(DatafileInput::Json(fixture["datafile"].to_string())), + log_level: Some(LogLevel::Fatal), + ..Default::default() + }, + targeting_key_field: field.to_string(), + ..Default::default() + }) + .unwrap() + }; + let provider = make_provider("userId"); + fn portable_value(value: &open_feature::Value) -> serde_json::Value { + use open_feature::Value; + match value { + Value::Bool(v) => serde_json::json!(v), + Value::Int(v) => serde_json::json!(v), + Value::Float(v) => serde_json::json!(v), + Value::String(v) => serde_json::json!(v), + Value::Array(v) => serde_json::Value::Array(v.iter().map(portable_value).collect()), + Value::Struct(v) => serde_json::Value::Object( + v.fields + .iter() + .map(|(k, v)| (k.clone(), portable_value(v))) + .collect(), + ), + } + } + let context_for = |source: &serde_json::Value| { + let mut context = EvaluationContext::default(); + if let Some(key) = source + .get("targetingKey") + .and_then(serde_json::Value::as_str) + { + context = context.with_targeting_key(key); + } + for (key, value) in source.as_object().unwrap() { + if key != "targetingKey" { + if let Some(text) = value.as_str() { + context = context.with_custom_field(key, text); + } else if let Some(boolean) = value.as_bool() { + context = context.with_custom_field(key, boolean); + } + } + } + context + }; + let mut executed = 0; + for case in cases { + let name = case["name"].as_str().unwrap(); + let source = case["context"].clone(); + let context = context_for(&source); + let key = case["flagKey"].as_str().unwrap(); + let expected = &case["expected"]; + if name == "feature JSON null" || name == "global JSON null" { + // The upstream Rust structure type has no null representation. + // Exercise this accepted native limitation instead of skipping it. + let error = provider + .resolve_struct_value(key, &context) + .await + .unwrap_err(); + assert_eq!(error.code, EvaluationErrorCode::TypeMismatch, "{name}"); + assert_eq!(case["context"], source, "{name} input mutated"); + executed += 1; + continue; + } + match case["type"].as_str().unwrap() { + "boolean" => match provider.resolve_bool_value(key, &context).await { + Ok(result) => { + assert_eq!(result.value, expected["value"].as_bool().unwrap(), "{name}"); + if let Some(reason) = expected["reason"].as_str() { + assert_eq!(result.reason.unwrap().to_string(), reason, "{name}"); + } + assert!(expected.get("errorCode").is_none(), "{name}"); + } + Err(error) => assert_eq!( + error.code.to_string(), + expected["errorCode"] + .as_str() + .unwrap_or_else(|| panic!("{name}: unexpected {error:?}")), + "{name}" + ), + }, + "string" => match provider.resolve_string_value(key, &context).await { + Ok(result) => { + assert_eq!(result.value, expected["value"].as_str().unwrap(), "{name}"); + if let Some(reason) = expected["reason"].as_str() { + assert_eq!(result.reason.unwrap().to_string(), reason, "{name}"); + } + assert_eq!( + result.variant.as_deref(), + expected["variant"].as_str(), + "{name}" + ); + assert!(expected.get("errorCode").is_none(), "{name}"); + } + Err(error) => assert_eq!( + error.code.to_string(), + expected["errorCode"] + .as_str() + .unwrap_or_else(|| panic!("{name}: unexpected {error:?}")), + "{name}" + ), + }, + "object" => match provider.resolve_struct_value(key, &context).await { + Ok(result) => { + assert!( + expected.get("errorCode").is_none(), + "{name}: unexpected success" + ); + let actual = portable_value(&open_feature::Value::Struct(result.value)); + assert_eq!(actual, expected["value"], "{name}"); + if let Some(reason) = expected["reason"].as_str() { + assert_eq!(result.reason.unwrap().to_string(), reason, "{name}"); + } + } + Err(error) => assert_eq!( + error.code.to_string(), + expected["errorCode"] + .as_str() + .unwrap_or_else(|| panic!("{name}: unexpected {error:?}")), + "{name}" + ), + }, + other => panic!("unknown case type {other}"), + } + assert_eq!(case["context"], source, "{name} input mutated"); + executed += 1; + } + assert_eq!(executed, cases.len()); + for case in fixture["targetingCases"].as_array().unwrap() { + let source = case["context"].clone(); + let result = provider + .resolve_bool_value("targetOnly", &context_for(&source)) + .await; + assert_eq!( + result.map(|details| details.value).unwrap_or(false), + case["expectedValue"].as_bool().unwrap(), + "{}", + case["name"] + ); + assert_eq!(case["context"], source); + } + let custom = make_provider("accountId"); + let case = &fixture["customTargetingCase"]; + assert_eq!( + custom + .resolve_bool_value( + case["flagKey"].as_str().unwrap(), + &context_for(&case["context"]) + ) + .await + .unwrap() + .value, + case["expectedValue"].as_bool().unwrap() + ); +} + +#[tokio::test] +async fn client_preserves_explicit_empty_targeting_key() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../conformance/openfeature-provider.json")).unwrap(); + let make_provider = |field: &str| { + FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + datafile: Some(DatafileInput::Json(fixture["datafile"].to_string())), + log_level: Some(LogLevel::Fatal), + ..Default::default() + }, + targeting_key_field: field.to_string(), + ..Default::default() + }) + .unwrap() + }; + let mut api = OpenFeature::default(); + api.set_provider(make_provider("userId")).await; + let client = api.create_client(); + let source = EvaluationContext::default().with_custom_field("userId", "forced"); + assert!( + client + .get_bool_details("targetOnly", Some(&source), None) + .await + .unwrap() + .value + ); + let empty = source.clone().with_targeting_key(""); + assert!(!client + .get_bool_details("targetOnly", Some(&empty), None) + .await + .map(|details| details.value) + .unwrap_or(false)); + let other = source.clone().with_targeting_key("other"); + assert!(!client + .get_bool_details("targetOnly", Some(&other), None) + .await + .map(|details| details.value) + .unwrap_or(false)); + assert_eq!( + source.custom_fields.get("userId"), + EvaluationContext::default() + .with_custom_field("userId", "forced") + .custom_fields + .get("userId") + ); + api.shutdown().await; + + let mut custom_api = OpenFeature::default(); + custom_api.set_provider(make_provider("accountId")).await; + let account = EvaluationContext::default() + .with_targeting_key("") + .with_custom_field("accountId", "old"); + assert!( + custom_api + .create_client() + .get_bool_details("accountTargeted", Some(&account), None) + .await + .unwrap() + .value + ); + custom_api.shutdown().await; +} + fn provider() -> FeaturevisorProvider { FeaturevisorProvider::new(FeaturevisorProviderOptions { featurevisor_options: FeaturevisorOptions { @@ -160,6 +391,27 @@ async fn resolves_flags_variations_variables_and_global_variables() { ); } +#[tokio::test] +async fn global_variable_name_can_equal_the_variation_selector() { + let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + datafile: Some(DatafileInput::Json(DATAFILE.to_string())), + ..Default::default() + }, + variation_key: "supportEmail".to_string(), + ..Default::default() + }) + .expect("valid provider"); + assert_eq!( + provider + .resolve_string_value("variable:supportEmail", &EvaluationContext::default()) + .await + .unwrap() + .value, + "support@example.com" + ); +} + #[tokio::test] async fn maps_context_targeting_key_and_custom_configuration() { let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions { @@ -320,6 +572,79 @@ async fn maps_targeting_split_and_disabled_reasons() { assert_eq!(disabled_variation.reason, Some(EvaluationReason::Disabled)); } +#[tokio::test] +async fn maps_feature_conditions_unmet_to_disabled() { + let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + datafile: Some(DatafileInput::Json( + serde_json::json!({ + "schemaVersion": "2", "revision": "reason", "segments": {}, + "features": { + "dependent": { + "bucketBy": "userId", "requiredFeatures": ["missing"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + } + }) + .to_string(), + )), + log_level: Some(LogLevel::Fatal), + ..Default::default() + }, + ..Default::default() + }) + .expect("provider"); + + let result = provider + .resolve_bool_value("dependent", &EvaluationContext::default()) + .await + .expect("disabled value"); + assert!(!result.value); + assert_eq!(result.reason, Some(EvaluationReason::Disabled)); + assert_eq!( + result + .flag_metadata + .as_ref() + .and_then(|metadata| metadata.values.get("featurevisorReason")), + Some(&FlagMetadataValue::String("conditions_unmet".to_string())) + ); +} + +#[tokio::test] +async fn maps_conditions_unmet_to_disabled() { + let datafile = r#"{ + "schemaVersion":"2","revision":"required-variable-provider","segments":{},"features":{}, + "variables":{ + "gate":{"type":"boolean","defaultValue":false}, + "subject":{"type":"string","defaultValue":"available","disabledValue":"blocked", + "conditions":[{"variable":"gate","operator":"equals","value":true}]} + } + }"#; + let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + log_level: Some(LogLevel::Fatal), + ..Default::default() + }, + ..Default::default() + }) + .expect("provider"); + + let result = provider + .resolve_string_value("variable:subject", &EvaluationContext::default()) + .await + .expect("disabled value"); + assert_eq!(result.value, "blocked"); + assert_eq!(result.reason, Some(EvaluationReason::Disabled)); + assert_eq!( + result + .flag_metadata + .as_ref() + .and_then(|metadata| metadata.values.get("featurevisorReason")), + Some(&FlagMetadataValue::String("conditions_unmet".to_string())) + ); +} + #[tokio::test] async fn exposes_metadata_and_works_through_openfeature_client() { let provider = provider(); @@ -358,6 +683,10 @@ async fn reports_parse_errors_and_recovers_after_valid_datafile() { }) .expect("provider configuration"); + assert_eq!( + provider.status(), + open_feature::provider::ProviderStatus::Error + ); assert_eq!( provider .resolve_bool_value("checkout", &EvaluationContext::default()) @@ -366,9 +695,13 @@ async fn reports_parse_errors_and_recovers_after_valid_datafile() { .code, EvaluationErrorCode::ParseError ); - provider + assert!(provider .featurevisor() - .set_datafile(DatafileInput::Json(DATAFILE.to_string()), true); + .set_datafile(DatafileInput::Json(DATAFILE.to_string()), true)); + assert_eq!( + provider.status(), + open_feature::provider::ProviderStatus::Ready + ); assert!( provider .resolve_bool_value( @@ -380,9 +713,13 @@ async fn reports_parse_errors_and_recovers_after_valid_datafile() { .value ); - provider + assert!(!provider .featurevisor() - .set_datafile(DatafileInput::Json("{".to_string()), true); + .set_datafile(DatafileInput::Json("{".to_string()), true)); + assert_eq!( + provider.status(), + open_feature::provider::ProviderStatus::Error + ); assert_eq!( provider .resolve_bool_value("checkout", &EvaluationContext::default()) @@ -395,6 +732,93 @@ async fn reports_parse_errors_and_recovers_after_valid_datafile() { struct CloseModule(Arc); +#[tokio::test] +async fn client_updates_errors_recovery_and_shutdown_ownership() { + for borrowed in [false, true] { + let closed = Arc::new(AtomicUsize::new(0)); + let options = FeaturevisorOptions { + datafile: Some(DatafileInput::Json( + if borrowed { DATAFILE } else { "{" }.to_string(), + )), + log_level: Some(LogLevel::Fatal), + modules: vec![Arc::new(CloseModule(Arc::clone(&closed)))], + ..Default::default() + }; + let provider = if borrowed { + FeaturevisorProvider::from_featurevisor(create_featurevisor(options)).unwrap() + } else { + FeaturevisorProvider::new(FeaturevisorProviderOptions { + featurevisor_options: options, + ..Default::default() + }) + .unwrap() + }; + let instance = provider.featurevisor().clone(); + let mut api = OpenFeature::default(); + api.set_provider(provider).await; + let client = api.create_client(); + let context = EvaluationContext::default().with_targeting_key("forced-user"); + if !borrowed { + assert_eq!( + client + .get_bool_value("checkout", Some(&context), None) + .await + .unwrap_err() + .code, + EvaluationErrorCode::ParseError + ); + } + assert!(instance.set_datafile(DatafileInput::Json(DATAFILE.to_string()), true)); + assert_eq!( + client + .get_bool_value("missing", Some(&context), None) + .await + .unwrap_err() + .code, + EvaluationErrorCode::FlagNotFound + ); + assert_eq!( + client + .get_string_value("checkout:count", Some(&context), None) + .await + .unwrap_err() + .code, + EvaluationErrorCode::TypeMismatch + ); + let updated = DATAFILE.replace("support@example.com", "updated@example.com"); + assert!(instance.set_datafile(DatafileInput::Json(updated.clone()), true)); + assert_eq!( + client + .get_string_value("variable:supportEmail", Some(&context), None) + .await + .unwrap(), + "updated@example.com" + ); + assert!(!instance.set_datafile(DatafileInput::Json("{".to_string()), true)); + assert_eq!( + client + .get_bool_value("checkout", Some(&context), None) + .await + .unwrap_err() + .code, + EvaluationErrorCode::ParseError + ); + assert!(instance.set_datafile(DatafileInput::Json(updated), true)); + assert!(client + .get_bool_value("checkout", Some(&context), None) + .await + .unwrap()); + drop(client); + api.shutdown().await; + assert_eq!(closed.load(Ordering::SeqCst), usize::from(!borrowed)); + if borrowed { + assert!(instance.is_enabled("checkout", None)); + } + instance.close(); + assert_eq!(closed.load(Ordering::SeqCst), 1); + } +} + impl FeaturevisorModule for CloseModule { fn close(&self) { self.0.fetch_add(1, Ordering::SeqCst); diff --git a/scripts/check-stacker-consumer.sh b/scripts/check-stacker-consumer.sh new file mode 100644 index 0000000..6ba7dd7 --- /dev/null +++ b/scripts/check-stacker-consumer.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +repository=$(cd "$(dirname "$0")/.." && pwd) +consumer=$(mktemp -d) +trap 'rm -rf "$consumer"' EXIT +cp -R "$repository/tests/fixtures/stacker-consumer/." "$consumer/" +cargo add --manifest-path "$consumer/Cargo.toml" featurevisor --path "$repository" +cargo update --manifest-path "$consumer/Cargo.toml" -p stacker +cargo tree --manifest-path "$consumer/Cargo.toml" -i psm +cargo run --locked --manifest-path "$consumer/Cargo.toml" diff --git a/src/bin/featurevisor.rs b/src/bin/featurevisor.rs index d832565..17cba8a 100644 --- a/src/bin/featurevisor.rs +++ b/src/bin/featurevisor.rs @@ -2,10 +2,7 @@ use clap::Parser; use featurevisor::cli::{run, Cli}; fn main() { - let cli = Cli::try_parse().unwrap_or_else(|error| { - eprintln!("{error}"); - std::process::exit(2); - }); + let cli = Cli::try_parse().unwrap_or_else(|error| error.exit()); if let Err(error) = run(cli) { eprintln!("{error}"); std::process::exit(1); diff --git a/src/child.rs b/src/child.rs index ac1d7fb..23e97d0 100644 --- a/src/child.rs +++ b/src/child.rs @@ -1,22 +1,32 @@ use crate::emitter::Emitter; -use crate::evaluate::{Evaluation, EvaluationType}; +use crate::evaluate::{Evaluation, EvaluationType, LiveState}; use crate::events::{ ContextSetDetails, EventDetails, EventHandler, EventName, StickyFeaturesSetDetails, }; -use crate::instance::{Featurevisor, OverrideOptions}; +use crate::instance::{ChildEvaluationState, Featurevisor, OverrideOptions}; use crate::types::{ Context, EvaluatedFeatures, EvaluatedVariables, StickyFeatures, StickyVariables, VariableValue, }; use crate::Unsubscribe; use std::sync::{Arc, Mutex}; +fn unsubscribe_parent(shared: &Mutex>) { + let unsubscribe = shared.lock().ok().and_then(|mut slot| slot.take()); + // Releasing a callback can run captured destructors. Keep both the child + // and subscription locks out of that call path. + if let Some(unsubscribe) = unsubscribe { + unsubscribe(); + } +} + struct ChildInner { context: Context, - sticky_features: StickyFeatures, - sticky_variables: StickyVariables, + sticky_features: Arc, + sticky_variables: Arc, emitter: Emitter, - parent_unsubscribers: Vec, + parent_unsubscribers: Vec>>>, closed: bool, + state_generation: u64, } #[derive(Clone)] @@ -27,6 +37,21 @@ pub struct FeaturevisorChild { } impl FeaturevisorChild { + #[cfg(feature = "cli")] + pub(crate) fn segment_matches( + &self, + key: &str, + fallback: &std::collections::HashMap, + context: &Context, + ) -> Result { + let (_, features, variables) = self.options(); + self.parent.segment_matches( + key, + fallback, + context, + Some(self.evaluation_state(features, variables, Some(context))), + ) + } pub(crate) fn new( parent: Featurevisor, context: Context, @@ -37,16 +62,17 @@ impl FeaturevisorChild { parent, inner: Arc::new(Mutex::new(ChildInner { context, - sticky_features, - sticky_variables, + sticky_features: Arc::new(sticky_features), + sticky_variables: Arc::new(sticky_variables), emitter: Emitter::default(), parent_unsubscribers: Vec::new(), closed: false, + state_generation: 0, })), } } - fn options(&self) -> (Context, StickyFeatures, StickyVariables) { + fn options(&self) -> (Context, Arc, Arc) { self.inner .lock() .map(|inner| { @@ -59,6 +85,37 @@ impl FeaturevisorChild { .unwrap_or_default() } + fn live_state(&self) -> LiveState { + let mut parent = self.parent.live_state(); + let inner = self.inner.lock().unwrap_or_else(|error| error.into_inner()); + parent.generation = + parent.generation.wrapping_mul(0x9e3779b97f4a7c15) ^ inner.state_generation; + parent.stored_context.extend(inner.context.clone()); + parent.sticky_features = inner.sticky_features.clone(); + parent.sticky_variables = inner.sticky_variables.clone(); + parent + } + + fn refresh_callback(&self) -> Arc LiveState + Send + Sync> { + let child = self.clone(); + Arc::new(move || child.live_state()) + } + + fn evaluation_state( + &self, + sticky_features: Arc, + sticky_variables: Arc, + context: Option<&Context>, + ) -> ChildEvaluationState { + ChildEvaluationState { + sticky_features, + sticky_variables, + live: self.refresh_callback(), + call_context: context.cloned().unwrap_or_default(), + plain_getter: false, + } + } + /// Updates the child context, either merging with or replacing its stored context. pub fn set_context(&self, context: Context, replace: bool) { let (context, emitter) = { @@ -74,6 +131,7 @@ impl FeaturevisorChild { } else { inner.context.extend(context); } + inner.state_generation += 1; (inner.context.clone(), inner.emitter.clone()) }; emitter.emit( @@ -104,10 +162,11 @@ impl FeaturevisorChild { return; } if replace { - inner.sticky_features = sticky; + inner.sticky_features = Arc::new(sticky); } else { - inner.sticky_features.extend(sticky); + Arc::make_mut(&mut inner.sticky_features).extend(sticky); } + inner.state_generation += 1; ( inner.sticky_features.keys().cloned().collect(), inner.emitter.clone(), @@ -133,10 +192,11 @@ impl FeaturevisorChild { return; } if replace { - inner.sticky_variables = sticky; + inner.sticky_variables = Arc::new(sticky); } else { - inner.sticky_variables.extend(sticky); + Arc::make_mut(&mut inner.sticky_variables).extend(sticky); } + inner.state_generation += 1; ( inner.sticky_variables.keys().cloned().collect(), inner.emitter.clone(), @@ -157,43 +217,45 @@ impl FeaturevisorChild { event, EventName::ContextSet | EventName::StickyFeaturesSet | EventName::StickyVariablesSet ) { - return self - .inner - .lock() - .map(|inner| inner.emitter.on(event, callback)) - .unwrap_or_else(|_| Box::new(|| {})); + let inner = match self.inner.lock() { + Ok(inner) => inner, + Err(_) => return Box::new(|| {}), + }; + if inner.closed { + drop(inner); + return Box::new(|| {}); + } + return inner.emitter.on(event, callback); } let unsubscribe = self.parent.parent_on(event, callback); let shared = Arc::new(Mutex::new(Some(unsubscribe))); - let shared_for_child = Arc::clone(&shared); - let shared_for_user = Arc::clone(&shared); - let child_cleanup: Unsubscribe = Box::new(move || { - if let Ok(mut unsubscribe) = shared_for_child.lock() { - if let Some(unsubscribe) = unsubscribe.take() { - unsubscribe(); - } - } - }); if let Ok(mut inner) = self.inner.lock() { if inner.closed { drop(inner); - child_cleanup(); + unsubscribe_parent(&shared); return Box::new(|| {}); } - inner.parent_unsubscribers.push(child_cleanup); + inner.parent_unsubscribers.push(Arc::clone(&shared)); + } else { + unsubscribe_parent(&shared); + return Box::new(|| {}); } + let child = Arc::downgrade(&self.inner); Box::new(move || { - if let Ok(mut unsubscribe) = shared_for_user.lock() { - if let Some(unsubscribe) = unsubscribe.take() { - unsubscribe(); + if let Some(child) = child.upgrade() { + if let Ok(mut inner) = child.lock() { + inner + .parent_unsubscribers + .retain(|entry| !Arc::ptr_eq(entry, &shared)); } } + unsubscribe_parent(&shared); }) } /// Closes the child and removes its event subscriptions. pub fn close(&self) { - let unsubscribers = { + let (unsubscribers, emitter) = { let mut inner = match self.inner.lock() { Ok(inner) => inner, Err(_) => return, @@ -202,11 +264,15 @@ impl FeaturevisorChild { return; } inner.closed = true; - inner.emitter.clear(); - std::mem::take(&mut inner.parent_unsubscribers) + inner.state_generation += 1; + ( + std::mem::take(&mut inner.parent_unsubscribers), + inner.emitter.clone(), + ) }; + emitter.clear(); for unsubscribe in unsubscribers { - unsubscribe(); + unsubscribe_parent(&unsubscribe); } } @@ -217,28 +283,40 @@ impl FeaturevisorChild { variable_key: Option<&str>, context: Option<&Context>, options: Option<&OverrideOptions>, + plain_getter: bool, ) -> Evaluation { - let (stored, sticky, _) = self.options(); + let (stored, sticky_features, sticky_variables) = self.options(); let mut merged = stored; if let Some(context) = context { merged.extend(context.clone()); } + let mut child = self.evaluation_state(sticky_features, sticky_variables, context); + child.plain_getter = plain_getter; self.parent.evaluate_child( evaluation_type, feature_key, variable_key, Some(&merged), options, - sticky, + child, ) } /// Evaluates a feature as a flag and returns evaluation details. pub fn evaluate_flag(&self, feature_key: &str, context: Option<&Context>) -> Evaluation { - self.evaluate(EvaluationType::Flag, feature_key, None, context, None) + self.evaluate( + EvaluationType::Flag, + feature_key, + None, + context, + None, + false, + ) } /// Returns whether a feature is enabled for the supplied context. pub fn is_enabled(&self, feature_key: &str, context: Option<&Context>) -> bool { - self.evaluate_flag(feature_key, context).enabled == Some(true) + self.evaluate(EvaluationType::Flag, feature_key, None, context, None, true) + .enabled + == Some(true) } /// Evaluates a feature variation and returns evaluation details. pub fn evaluate_variation( @@ -253,6 +331,7 @@ impl FeaturevisorChild { None, context, options, + false, ) } /// Returns the selected variation value, if one is available. @@ -262,7 +341,14 @@ impl FeaturevisorChild { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> Option { - let evaluation = self.evaluate_variation(feature_key, context, options); + let evaluation = self.evaluate( + EvaluationType::Variation, + feature_key, + None, + context, + options, + true, + ); evaluation .variation_value .or_else(|| evaluation.variation.map(|variation| variation.value)) @@ -281,6 +367,7 @@ impl FeaturevisorChild { Some(variable_key), context, options, + false, ) } /// Returns a feature variable value, if one is available. @@ -291,12 +378,13 @@ impl FeaturevisorChild { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> Option { + let (_, sticky_features, sticky_variables) = self.options(); self.parent.get_variable_with_sticky( feature_key, variable_key, self.get_context(context), options, - self.options().1, + self.evaluation_state(sticky_features, sticky_variables, context), ) } /// Returns a variable as a boolean when its value has that type. @@ -398,7 +486,7 @@ impl FeaturevisorChild { feature_keys: &[String], options: Option<&OverrideOptions>, ) -> EvaluatedFeatures { - let (stored, sticky, _) = self.options(); + let (stored, sticky_features, sticky_variables) = self.options(); let mut merged = stored; if let Some(context) = context { merged.extend(context.clone()); @@ -407,7 +495,8 @@ impl FeaturevisorChild { Some(&merged), feature_keys, options, - sticky, + sticky_features, + sticky_variables, ) } @@ -427,8 +516,7 @@ impl FeaturevisorChild { variable_key, Some(&merged), options, - sticky_features, - sticky_variables, + self.evaluation_state(sticky_features, sticky_variables, context), ) } @@ -440,20 +528,7 @@ impl FeaturevisorChild { options: Option<&OverrideOptions>, ) -> Option { let evaluation = self.evaluate_global_variable(variable_key, context, options); - let is_json = evaluation - .variable - .as_ref() - .map(|variable| variable.variable_type == "json") - .unwrap_or(false); - let value = evaluation.variable_value?; - if is_json { - if let VariableValue::String(string) = &value { - return serde_json::from_str::(string) - .ok() - .map(VariableValue::from_json); - } - } - Some(value) + self.parent.normalized_getter_value(&evaluation) } /// Returns a global variable as a boolean when its value has that type. @@ -562,3 +637,66 @@ impl FeaturevisorChild { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{create_featurevisor, FeaturevisorOptions, SpawnOptions}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct ReenterOnDrop(FeaturevisorChild, Arc); + impl Drop for ReenterOnDrop { + fn drop(&mut self) { + assert!(self.0.inner.try_lock().is_ok()); + let _ = self.0.get_context(None); + self.0.close(); + self.1.fetch_add(1, Ordering::SeqCst); + } + } + + #[test] + fn unsubscribed_parent_events_do_not_accumulate_child_bookkeeping() { + let f = create_featurevisor(FeaturevisorOptions::default()); + let child = f.spawn(Context::new(), SpawnOptions::default()); + for _ in 0..1000 { + let cleanup = child.on(EventName::DatafileSet, Arc::new(|_| {})); + assert_eq!(child.inner.lock().unwrap().parent_unsubscribers.len(), 1); + cleanup(); + assert!(child.inner.lock().unwrap().parent_unsubscribers.is_empty()); + } + let cleanup = child.on(EventName::DatafileSet, Arc::new(|_| {})); + let weak = Arc::downgrade(&child.inner); + child.close(); + drop(child); + assert!(weak.upgrade().is_none()); + cleanup(); + } + + #[test] + fn child_callback_destruction_can_reenter_close_and_parent_unsubscribe() { + for event in [EventName::ContextSet, EventName::DatafileSet] { + for explicit_close in [false, true] { + let f = create_featurevisor(FeaturevisorOptions::default()); + let child = f.spawn(Context::new(), SpawnOptions::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let callback = || { + let probe = ReenterOnDrop(child.clone(), Arc::clone(&drops)); + Arc::new(move |_: &EventDetails| { + let _ = &probe; + }) as EventHandler + }; + let cleanup = child.on(event, callback()); + if explicit_close { + child.close(); + cleanup(); + } else { + cleanup(); + } + assert_eq!(drops.load(Ordering::SeqCst), 1); + let _ = child.on(event, callback()); + assert_eq!(drops.load(Ordering::SeqCst), 2); + assert!(child.inner.lock().unwrap().parent_unsubscribers.is_empty()); + } + } + } +} diff --git a/src/cli/assess_distribution.rs b/src/cli/assess_distribution.rs index 5d85e9c..071dda1 100644 --- a/src/cli/assess_distribution.rs +++ b/src/cli/assess_distribution.rs @@ -1,5 +1,5 @@ use super::options::AssessDistributionOptions; -use super::project::{build_datafile, input, list_targets, project_path, unique_targets}; +use super::project::{build_datafile_for_set, evaluation_selections, input, project_path}; use crate::FeaturevisorOptions; use serde_json::Value as JsonValue; use std::collections::BTreeMap; @@ -11,37 +11,27 @@ fn print_count(label: &str, count: u64, total: u64) { } pub fn run(options: AssessDistributionOptions) -> Result<(), String> { - let project = project_path(&options.common.project_directory_path); - let targets: Vec> = if options.common.target.is_empty() { - vec![None] - } else { - let available = list_targets(&project)?; - for target in &options.common.target { - if !available.contains(target) { - return Err(format!("Unknown target \"{target}\"")); - } - } - unique_targets(options.common.target.clone()) - .into_iter() - .map(Some) - .collect() - }; + let project = project_path(options.common.project_directory_path()); let context_value: JsonValue = serde_json::from_str(&options.context).map_err(|error| error.to_string())?; let context = crate::helpers::json_to_context(&context_value) .ok_or_else(|| "Context must be a JSON object".to_string())?; - for target in targets { - let datafile = build_datafile( + let selections = + evaluation_selections(&project, &options.common, options.set.as_deref(), options.n)?; + for selection in selections { + let target = selection.target; + let datafile = build_datafile_for_set( &project, options.common.environment.as_deref(), target.as_deref(), options.common.inflate, + selection.set.as_deref(), )?; let f = crate::create_featurevisor(FeaturevisorOptions { datafile: Some(input(datafile)), ..Default::default() }); - let iterations = options.n.max(1); + let iterations = options.n; let has_variations = f.has_variations(&options.feature); let mut flags = BTreeMap::::new(); let mut variations = BTreeMap::::new(); @@ -53,6 +43,12 @@ pub fn run(options: AssessDistributionOptions) -> Result<(), String> { crate::AttributeValue::String(Uuid::new_v4().to_string()), ); } + if options.common.verbose { + println!( + "Context: {}", + serde_json::to_string(&iteration_context).map_err(|error| error.to_string())? + ); + } let enabled = f.is_enabled(&options.feature, Some(&iteration_context)); *flags.entry(enabled).or_default() += 1; if has_variations { @@ -73,6 +69,9 @@ pub fn run(options: AssessDistributionOptions) -> Result<(), String> { if let Some(target) = target { println!(" Target: {target}"); } + if let Some(set) = selection.set { + println!(" Set: {set}"); + } println!(" Iterations: {iterations}"); println!(" Context: {}", options.context); println!(); diff --git a/src/cli/benchmark.rs b/src/cli/benchmark.rs index 28bf608..eba5ff5 100644 --- a/src/cli/benchmark.rs +++ b/src/cli/benchmark.rs @@ -1,5 +1,5 @@ use super::options::BenchmarkOptions; -use super::project::{build_datafile, input, list_targets, project_path, unique_targets}; +use super::project::{build_datafile_for_set, evaluation_selections, input, project_path}; use crate::FeaturevisorOptions; use serde_json::Value as JsonValue; use std::path::Path; @@ -15,19 +15,25 @@ fn duration_ms(nanoseconds: u128) -> String { format!("{:.6}ms", nanoseconds as f64 / 1_000_000.0) } -fn one(options: &BenchmarkOptions, project: &Path, target: Option<&str>) -> Result<(), String> { - let datafile = build_datafile( +fn one( + options: &BenchmarkOptions, + project: &Path, + target: Option<&str>, + set: Option<&str>, +) -> Result<(), String> { + let datafile = build_datafile_for_set( project, options.common.environment.as_deref(), target, options.common.inflate, + set, )?; let context = context(&options.context)?; let f = crate::create_featurevisor(FeaturevisorOptions { datafile: Some(input(datafile)), ..Default::default() }); - let n = options.n.max(1); + let n = options.n; let mut durations = Vec::with_capacity(n as usize); for _ in 0..n { let start = Instant::now(); @@ -86,6 +92,9 @@ fn one(options: &BenchmarkOptions, project: &Path, target: Option<&str>) -> Resu if let Some(target) = target { println!(" Target: {target}"); } + if let Some(set) = set { + println!(" Set: {set}"); + } println!(" Iterations: {n}"); println!(" Context: {}", options.context); println!(" Evaluation: {evaluation}"); @@ -97,23 +106,17 @@ fn one(options: &BenchmarkOptions, project: &Path, target: Option<&str>) -> Resu } pub fn run(options: BenchmarkOptions) -> Result<(), String> { - let project = project_path(&options.common.project_directory_path); - let targets: Vec> = if options.common.target.is_empty() { - vec![None] - } else { - let available = list_targets(&project)?; - for target in &options.common.target { - if !available.contains(target) { - return Err(format!("Unknown target \"{target}\"")); - } - } - unique_targets(options.common.target.clone()) - .into_iter() - .map(Some) - .collect() - }; - for target in targets { - one(&options, &project, target.as_deref())?; + let project = project_path(options.common.project_directory_path()); + context(&options.context)?; + let selections = + evaluation_selections(&project, &options.common, options.set.as_deref(), options.n)?; + for selection in selections { + one( + &options, + &project, + selection.target.as_deref(), + selection.set.as_deref(), + )?; } Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 04fd804..cea6ea8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2,7 +2,10 @@ mod assess_distribution; mod benchmark; mod options; mod project; +#[cfg(test)] +mod semver_tests; mod test; +mod test_checks; pub use options::{ AssessDistributionOptions, BenchmarkOptions, Cli, Command, CommonOptions, TestOptions, @@ -10,12 +13,33 @@ pub use options::{ pub fn run(cli: Cli) -> Result<(), String> { match cli.command { - Some(options::Command::Test(options)) => test::run(options), - Some(options::Command::Benchmark(options)) => benchmark::run(options), - Some(options::Command::AssessDistribution(options)) => assess_distribution::run(options), + Some(options::Command::Test(options)) => { + if options.common.environment.is_some() { + return Err( + "--environment is not supported by test; environments come from assertions" + .into(), + ); + } + test::run(options) + } + Some(options::Command::Benchmark(options)) => { + validate_output_options(&options.common, false)?; + benchmark::run(options) + } + Some(options::Command::AssessDistribution(options)) => { + validate_output_options(&options.common, true)?; + assess_distribution::run(options) + } None => { println!("Learn more at https://featurevisor.com/docs/sdks/rust/"); Ok(()) } } } + +fn validate_output_options(options: &CommonOptions, distribution: bool) -> Result<(), String> { + if options.quiet || options.show_datafile || (options.verbose && !distribution) { + return Err("--quiet and --show-datafile are test options; --verbose is supported by test and assess-distribution".into()); + } + Ok(()) +} diff --git a/src/cli/options.rs b/src/cli/options.rs index 3e0e9da..9d697a5 100644 --- a/src/cli/options.rs +++ b/src/cli/options.rs @@ -1,7 +1,10 @@ use clap::{Args, Parser, Subcommand}; #[derive(Parser, Debug)] -#[command(name = "featurevisor", disable_help_flag = true)] +#[command( + name = "featurevisor", + about = "Native test, benchmark and distribution runner. Authoring and datafile generation use the Node.js CLI." +)] pub struct Cli { #[command(subcommand)] pub command: Option, @@ -16,11 +19,11 @@ pub enum Command { #[derive(Args, Debug, Clone)] pub struct CommonOptions { - #[arg(long = "projectDirectoryPath", default_value = ".")] - pub project_directory_path: String, - #[arg(long, hide = true)] + #[arg(long = "project-directory-path", aliases = ["projectDirectoryPath", "rootDirectoryPath", "root-directory-path"])] + pub project_directory_path: Option, + #[arg(long, alias = "withScopes", hide = true)] pub with_scopes: bool, - #[arg(long, hide = true)] + #[arg(long, alias = "withTags", hide = true)] pub with_tags: bool, #[arg(long = "schema-version", alias = "schemaVersion", hide = true)] pub schema_version: Option, @@ -28,41 +31,63 @@ pub struct CommonOptions { pub quiet: bool, #[arg(long, action = clap::ArgAction::SetTrue)] pub verbose: bool, - #[arg(long = "showDatafile", action = clap::ArgAction::SetTrue)] + #[arg(long = "show-datafile", alias = "showDatafile", action = clap::ArgAction::SetTrue)] pub show_datafile: bool, #[arg(long, value_delimiter = ',')] pub target: Vec, - #[arg(long, default_value_t = 1)] + #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u32).range(1..))] pub inflate: u32, #[arg(long = "environment")] pub environment: Option, } +impl CommonOptions { + pub(crate) fn project_directory_path(&self) -> &str { + self.project_directory_path.as_deref().unwrap_or(".") + } +} + #[derive(Args, Debug, Clone)] pub struct TestOptions { #[command(flatten)] pub common: CommonOptions, - #[arg(long = "keyPattern")] + #[arg(long = "key-pattern", alias = "keyPattern")] pub key_pattern: Option, - #[arg(long = "assertionPattern")] + #[arg(long = "assertion-pattern", alias = "assertionPattern")] pub assertion_pattern: Option, - #[arg(long = "onlyFailures", action = clap::ArgAction::SetTrue)] + #[arg(long = "only-failures", alias = "onlyFailures", action = clap::ArgAction::SetTrue)] pub only_failures: bool, + #[arg(long = "entity-type", alias = "entityType", value_parser = ["feature", "segment", "variable", "scenario"])] + pub entity_type: Option, + #[arg(long)] + pub set: Option, + #[arg(long, action = clap::ArgAction::SetTrue)] + pub json: bool, + #[arg(long, action = clap::ArgAction::SetTrue, requires = "json")] + pub pretty: bool, + #[arg(long = "case")] + pub case_id: Option, + #[arg(long, action = clap::ArgAction::SetTrue)] + pub coverage: bool, + #[arg(long, action = clap::ArgAction::SetTrue)] + pub uncovered: bool, } #[derive(Args, Debug, Clone)] pub struct BenchmarkOptions { #[command(flatten)] pub common: CommonOptions, - #[arg(long, required_unless_present = "variable")] + #[arg(long)] + pub set: Option, + #[arg(long, required_unless_present = "variable", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub feature: Option, #[arg(long, action = clap::ArgAction::SetTrue, conflicts_with = "variable")] pub variation: bool, - #[arg(long, conflicts_with = "variation")] + #[arg(long, conflicts_with = "variation", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub variable: Option, #[arg(long, default_value = "{}")] pub context: String, - #[arg(long, default_value_t = 1000)] + #[arg(long, short = 'n', default_value_t = 1000, value_parser = clap::value_parser!(u64).range(1..))] pub n: u64, } @@ -71,11 +96,13 @@ pub struct AssessDistributionOptions { #[command(flatten)] pub common: CommonOptions, #[arg(long)] + pub set: Option, + #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())] pub feature: String, #[arg(long, default_value = "{}")] pub context: String, - #[arg(long, default_value_t = 1000)] + #[arg(long, short = 'n', default_value_t = 1000, value_parser = clap::value_parser!(u64).range(1..))] pub n: u64, - #[arg(long = "populateUuid", action = clap::ArgAction::Append)] + #[arg(long = "populate-uuid", alias = "populateUuid", action = clap::ArgAction::Append)] pub populate_uuid: Vec, } diff --git a/src/cli/project.rs b/src/cli/project.rs index e1108fb..7389fb3 100644 --- a/src/cli/project.rs +++ b/src/cli/project.rs @@ -1,19 +1,81 @@ +use super::options::CommonOptions; use crate::{DatafileContent, DatafileInput}; use serde_json::Value as JsonValue; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::Command; +pub(crate) fn project_sets( + project: &Path, + selected: Option<&str>, +) -> Result>, String> { + if let Some(selected) = selected { + return Ok(vec![Some(selected.to_string())]); + } + let config = json_command(project, "config", Vec::new())?; + if config.get("sets").and_then(JsonValue::as_bool) != Some(true) { + return Ok(vec![None]); + } + let directory = config + .get("setsDirectoryPath") + .and_then(JsonValue::as_str) + .ok_or_else(|| { + "Project config needs setsDirectoryPath when sets are enabled".to_string() + })?; + let mut sets = std::fs::read_dir(directory) + .map_err(|error| format!("Could not list project sets: {error}"))? + .filter_map(Result::ok) + .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) + .filter_map(|entry| entry.file_name().into_string().ok()) + .map(Some) + .collect::>(); + sets.sort(); + if sets.is_empty() { + return Err(format!("No sets found in {directory}.")); + } + Ok(sets) +} + +/// The project's own Node CLI. Node starts the local package entry directly, which +/// works the same on every platform. Without a local package, npx resolves the CLI +/// as before. Windows needs its npx.cmd wrapper, because Command only adds .exe. +pub(crate) fn node_command(project: &Path) -> (String, String) { + let start = if project.is_absolute() { + project.to_path_buf() + } else { + std::env::current_dir() + .map(|directory| directory.join(project)) + .unwrap_or_else(|_| project.to_path_buf()) + }; + for directory in start.ancestors() { + let entry = directory.join("node_modules/@featurevisor/cli/bin.js"); + if entry.is_file() { + return ("node".into(), entry.to_string_lossy().into_owned()); + } + } + let npx = if cfg!(windows) { "npx.cmd" } else { "npx" }; + (npx.into(), "featurevisor".into()) +} + pub(crate) fn run_node(project: &Path, command: &str, args: &[String]) -> Result { - let mut process = Command::new("npx"); + let (program, first) = node_command(project); + let mut process = Command::new(program); process - .arg("featurevisor") + .arg(first) .arg(command) .args(args) .current_dir(project); let output = process.output().map_err(|error| error.to_string())?; if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + return Err(format!( + "npx featurevisor {command} failed ({}): {}\n{}", + output.status, + String::from_utf8_lossy(&output.stderr).trim(), + String::from_utf8_lossy(&output.stdout).trim(), + )); + } + if !output.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } @@ -33,8 +95,15 @@ pub(crate) fn project_path(value: &str) -> PathBuf { PathBuf::from(value) } -pub(crate) fn list_targets(project: &Path) -> Result, String> { - let value = json_command(project, "list", vec!["--targets".to_string()])?; +pub(crate) fn list_targets_for_set( + project: &Path, + set: Option<&str>, +) -> Result, String> { + let mut args = vec!["--targets".to_string()]; + if let Some(set) = set { + args.push(format!("--set={set}")); + } + let value = json_command(project, "list", args)?; Ok(unique_targets( value .as_array() @@ -57,11 +126,69 @@ pub(crate) fn unique_targets(targets: Vec) -> Vec { .collect() } -pub(crate) fn build_datafile( +pub(crate) struct EvaluationSelection { + pub set: Option, + pub target: Option, +} + +pub(crate) fn evaluation_selections( + project: &Path, + options: &CommonOptions, + set: Option<&str>, + n: u64, +) -> Result, String> { + if n == 0 || options.inflate == 0 { + return Err("--n and --inflate must be positive integers".to_string()); + } + let config = json_command(project, "config", Vec::new())?; + match (config.get("environments"), options.environment.as_deref()) { + (Some(JsonValue::Array(environments)), Some(environment)) => { + if !environments + .iter() + .any(|value| value.as_str() == Some(environment)) + { + return Err(format!("Unknown environment \"{environment}\"")); + } + } + (Some(JsonValue::Array(_)), None) => { + return Err( + "--environment is required for a project with configured environments".to_string(), + ); + } + (None, Some(_)) => { + return Err( + "--environment cannot be used in a project without environments".to_string(), + ); + } + (None, None) => {} + _ => return Err("Invalid configured environments".to_string()), + } + let sets = project_sets(project, set)?; + let targets = if options.target.is_empty() { + vec![None] + } else { + unique_targets(options.target.clone()) + .into_iter() + .map(Some) + .collect() + }; + Ok(sets + .into_iter() + .flat_map(|set| { + targets.iter().map(move |target| EvaluationSelection { + set: set.clone(), + target: target.clone(), + }) + }) + .collect()) +} + +pub(crate) fn build_datafile_for_set( project: &Path, environment: Option<&str>, target: Option<&str>, inflate: u32, + set: Option<&str>, ) -> Result { let mut args = Vec::new(); if let Some(environment) = environment { @@ -73,17 +200,16 @@ pub(crate) fn build_datafile( if inflate > 1 { args.push(format!("--inflate={inflate}")); } + if let Some(set) = set { + args.push(format!("--set={set}")); + } let value = json_command(project, "build", args)?; serde_json::from_value(value) .map_err(|error| format!("Could not parse generated datafile: {error}")) } pub(crate) fn datafile_key(environment: Option<&str>, target: Option<&str>) -> String { - // The "false" key is intentional and keeps no-environment datafiles compatible with other SDK runners. - match target { - Some(target) => format!("{}-target-{target}", environment.unwrap_or("false")), - None => environment.unwrap_or("false").to_string(), - } + serde_json::json!([environment, target]).to_string() } pub(crate) fn input(datafile: DatafileContent) -> DatafileInput { @@ -95,19 +221,46 @@ mod tests { use super::{datafile_key, unique_targets}; #[test] - fn datafile_keys_match_the_javascript_runner_shape() { - assert_eq!(datafile_key(None, None), "false"); - assert_eq!(datafile_key(Some("production"), None), "production"); + fn datafile_keys_encode_nullable_selections() { + assert_eq!(datafile_key(None, None), "[null,null]"); assert_eq!( - datafile_key(None, Some("checkout")), - "false-target-checkout" + datafile_key(Some("production"), None), + r#"["production",null]"# ); + assert_eq!(datafile_key(None, Some("checkout")), r#"[null,"checkout"]"#); assert_eq!( datafile_key(Some("production"), Some("checkout")), - "production-target-checkout" + r#"["production","checkout"]"# ); } + #[test] + fn datafile_keys_round_trip_without_collisions() { + let values = [ + None, + Some(""), + Some("false"), + Some("__no_environment__"), + Some("prod"), + Some("prod-target-web"), + Some("web"), + Some("web-target-mobile"), + Some("mobile"), + Some("東京\"\\🦀"), + ]; + let mut cache = std::collections::HashMap::new(); + for environment in values { + for target in values { + let key = datafile_key(environment, target); + let decoded: [Option; 2] = serde_json::from_str(&key).unwrap(); + assert_eq!(decoded[0].as_deref(), environment); + assert_eq!(decoded[1].as_deref(), target); + assert!(cache.insert(key, (environment, target)).is_none()); + } + } + assert_eq!(cache.len(), values.len() * values.len()); + } + #[test] fn target_values_are_deduplicated_without_reordering() { assert_eq!( @@ -120,3 +273,26 @@ mod tests { ); } } + +#[cfg(test)] +mod node_command_tests { + use super::node_command; + + #[test] + fn runs_the_nearest_local_cli_entry_with_node_then_falls_back_to_npx() { + let root = + std::env::temp_dir().join(format!("featurevisor-node-cli-{}", std::process::id())); + let project = root.join("packages").join("flags"); + std::fs::create_dir_all(&project).unwrap(); + let npx = if cfg!(windows) { "npx.cmd" } else { "npx" }; + assert_eq!(node_command(&project), (npx.into(), "featurevisor".into())); + let entry = root.join("node_modules/@featurevisor/cli/bin.js"); + std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); + std::fs::write(&entry, "").unwrap(); + assert_eq!( + node_command(&project), + ("node".into(), entry.to_string_lossy().into_owned()) + ); + std::fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/src/cli/semver_tests.rs b/src/cli/semver_tests.rs new file mode 100644 index 0000000..15446b2 --- /dev/null +++ b/src/cli/semver_tests.rs @@ -0,0 +1,133 @@ +use super::test_checks::{ + evaluate_assertion_checks_detailed, CheckDiagnosticCollector, NativeCheckResolver, +}; +use crate::{ + create_featurevisor, AttributeValue, Context, DatafileContent, DatafileInput, EvaluationReason, + FeaturevisorOptions, LogLevel, VariableValue, +}; +use serde_json::{json, Value}; + +#[test] +fn portable_semver_conditions_requirements_and_checks() { + let fixture: Value = + serde_json::from_str(include_str!("../../conformance/semver.json")).unwrap(); + assert_eq!(fixture["kind"], "featurevisor.semver"); + assert_eq!(fixture["version"], 1); + let cases = fixture["cases"].as_array().unwrap(); + assert!(!cases.is_empty()); + let mut executed = 0; + for case in cases { + for reverse in [false, true] { + let (left, right) = if reverse { + (&case["right"], &case["left"]) + } else { + (&case["left"], &case["right"]) + }; + let order = case["order"].as_i64().map(|n| if reverse { -n } else { n }); + for (operator, expected) in [ + ("semverEquals", order == Some(0)), + ("semverNotEquals", order.is_some_and(|n| n != 0)), + ("semverGreaterThan", order.is_some_and(|n| n > 0)), + ("semverGreaterThanOrEquals", order.is_some_and(|n| n >= 0)), + ("semverLessThan", order.is_some_and(|n| n < 0)), + ("semverLessThanOrEquals", order.is_some_and(|n| n <= 0)), + ] { + let label = format!("{} reverse={reverse} {operator}", case["name"]); + let requirement = json!({"variable":"source","operator":operator,"value":right}); + let feature_requirement = json!({"feature":"sourceFeature","variable":"version","operator":operator,"value":right}); + let datafile: DatafileContent = serde_json::from_value(json!({ + "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}, + "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]}]} + } + })).unwrap(); + let diagnostics = CheckDiagnosticCollector::default(); + let sdk = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile.clone())), + log_level: Some(LogLevel::Fatal), + on_diagnostic: Some(diagnostics.handler()), + ..Default::default() + }); + let context: Context = [( + "version".into(), + AttributeValue::String(left.as_str().unwrap().into()), + )] + .into(); + assert_eq!( + sdk.is_enabled("condition", Some(&context)), + expected, + "{label}" + ); + for (variable, value) in [ + ("subject", expected), + ("negated", !expected), + ("featureSubject", expected), + ("featureNegated", !expected), + ] { + let evaluation = sdk.evaluate_global_variable(variable, None, None); + if order.is_none() { + assert_eq!( + evaluation.reason, + EvaluationReason::Error, + "{label} {variable}" + ); + } else { + assert_eq!( + evaluation.variable_value, + Some(VariableValue::Boolean(value)), + "{label} {variable}" + ); + } + } + let check = json!({"variable":"source","operator":operator,"compareTo":{"variable":"other"}}); + // Resolve the right operand through another evaluated variable as well. + let mut check_datafile = serde_json::to_value(&datafile).unwrap(); + check_datafile["variables"]["other"] = + json!({"type":"string","defaultValue":right}); + let check_datafile: DatafileContent = + serde_json::from_value(check_datafile).unwrap(); + let checker = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(check_datafile.clone())), + log_level: Some(LogLevel::Fatal), + ..Default::default() + }); + for (node, matched) in [ + (check.clone(), expected), + (json!({"not":[check]}), !expected), + ] { + let outcome = evaluate_assertion_checks_detailed( + &json!({"checks":[node]}), + None, + &NativeCheckResolver::parent(&checker, diagnostics.clone()), + &check_datafile, + "", + ); + assert_eq!( + outcome.had_error, + order.is_none(), + "{label}: {:?}", + outcome.failures + ); + assert_eq!( + outcome.failures.is_empty(), + order.is_some() && matched, + "{label}" + ); + } + checker.close(); + sdk.close(); + } + } + executed += 1; + } + assert_eq!(executed, cases.len()); +} diff --git a/src/cli/test.rs b/src/cli/test.rs index 43a5086..38713a6 100644 --- a/src/cli/test.rs +++ b/src/cli/test.rs @@ -1,6 +1,11 @@ use super::options::TestOptions; use super::project::{ - build_datafile, datafile_key, input, json_command, list_targets, project_path, unique_targets, + build_datafile_for_set, datafile_key, input, json_command, list_targets_for_set, project_path, + project_sets, unique_targets, +}; +use super::test_checks::{ + evaluate_assertion_checks_detailed, preflight_assertion_checks, CheckAssertionOutcome, + CheckDiagnosticCollector, NativeCheckResolver, }; use crate::modules::ConfigureBucketValueOptions; use crate::{ @@ -8,16 +13,21 @@ use crate::{ OverrideOptions, Segment, SpawnOptions, }; use regex::{Regex, RegexBuilder}; -use serde_json::{Map, Value as JsonValue}; -use std::collections::HashMap; +use serde::Serialize; +use serde_json::{json, Map, Value as JsonValue}; +use std::collections::{BTreeSet, HashMap}; use std::path::Path; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; -struct AtModule { +struct TestModule { at: Option, + coverage: Option>>>, + set: Option, + environment: Option, + target: Option, } -impl FeaturevisorModule for AtModule { +impl FeaturevisorModule for TestModule { fn name(&self) -> Option<&str> { Some("tester") } @@ -27,6 +37,216 @@ impl FeaturevisorModule for AtModule { .map(|at| (at * (crate::MAX_BUCKETED_NUMBER as f64 / 100.0)) as u32) .unwrap_or(options.bucket_value) } + + fn after_evaluation( + &self, + evaluation: crate::Evaluation, + _options: &crate::evaluate::EvaluateOptions, + ) -> crate::Evaluation { + if _options.synthetic_segment_gate { + return evaluation; + } + if let Some(coverage) = &self.coverage { + let mut paths = evaluation + .variable_override_paths + .clone() + .unwrap_or_default(); + if paths.is_empty() { + if let Some(path) = evaluation.variable_override_path.clone() { + paths.push(path); + } else if let Some(key) = evaluation.variable_override_key.clone() { + paths.push(vec![key]); + } + } + if evaluation.rule_key.is_some() + || !paths.is_empty() + || evaluation.variable_override_index.is_some() + { + if paths.is_empty() { + paths.push(Vec::new()); + } + if let Ok(mut selections) = coverage.lock() { + for path in paths { + selections.insert(CoverageSelection { + set: self.set.clone(), + environment: self.environment.clone(), + target: self.target.clone(), + feature_key: (!evaluation.feature_key.is_empty()) + .then(|| evaluation.feature_key.clone()), + variable_key: evaluation.variable_key.clone(), + rule_key: evaluation.rule_key.clone(), + variation: evaluation + .variation + .as_ref() + .map(|variation| variation.value.clone()), + override_path: (!path.is_empty()).then_some(path), + override_index: if evaluation + .variable_override_paths + .as_ref() + .is_some_and(|paths| !paths.is_empty()) + || evaluation.variable_override_path.is_some() + || evaluation.variable_override_key.is_some() + { + None + } else { + evaluation.variable_override_index + }, + }); + } + } + } + } + evaluation + } +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoverageSelection { + set: Option, + environment: Option, + target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + feature_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + variable_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rule_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + variation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + override_path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + override_index: Option, +} + +#[derive(Clone, Debug, Default, Serialize)] +struct Counts { + passed: usize, + failed: usize, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct Summary { + specs: Counts, + assertions: Counts, + entity_specs: Counts, + entity_assertions: Counts, + scenario_specs: Counts, + scenario_cases: Counts, +} + +#[derive(Clone, Debug, Serialize)] +struct ReportError { + message: String, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + error_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + expected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + actual: Option, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, +} + +impl ReportError { + fn expectation( + kind: &str, + path: Vec, + expected: JsonValue, + actual: JsonValue, + ) -> Self { + Self { + message: format!("expected {expected}, received {actual}"), + error_type: Some(kind.to_string()), + path: Some(path), + expected: Some(expected), + actual: Some(actual), + details: None, + } + } + + fn prefixed(mut self, prefix: &[JsonValue]) -> Self { + if self.error_type.as_deref() != Some("check") { + if let Some(path) = self.path.take() { + self.path = Some(prefix.iter().cloned().chain(path).collect()); + } + } + self + } + + fn message(message: impl Into) -> Self { + Self { + message: message.into(), + error_type: None, + path: None, + expected: None, + actual: None, + details: None, + } + } +} + +#[derive(Clone, Debug, Serialize)] +struct Rerun { + command: String, + args: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CaseResult { + id: String, + description: Option, + environment: Option, + target: Option, + context: JsonValue, + matrix_values: JsonValue, + passed: bool, + errors: Vec, + rerun: Rerun, +} + +#[derive(Clone, Debug, Serialize)] +struct SpecResult { + #[serde(rename = "type")] + entity_type: String, + key: String, + source: String, + set: Option, + passed: bool, + cases: Vec, +} + +#[derive(Clone, Debug, Serialize)] +struct Uncovered { + set: Option, + features: Vec, + variables: Vec, +} + +#[derive(Clone, Debug, Serialize)] +struct Coverage { + mode: &'static str, + selections: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct TestReport { + schema_version: u8, + runner: &'static str, + passed: bool, + summary: Summary, + results: Vec, + errors: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + uncovered: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + coverage: Option, } fn context_from_json(value: Option<&JsonValue>) -> crate::Context { @@ -56,6 +276,13 @@ fn compile_pattern(option: &str, pattern: Option<&str>) -> Result, .transpose() } +fn authored_test_key<'a>(test: &'a JsonValue, entity_type: &str) -> &'a str { + test.get(entity_type) + .and_then(JsonValue::as_str) + .or_else(|| test.get("key").and_then(JsonValue::as_str)) + .unwrap_or("unknown") +} + fn json_equal( actual: Option<&crate::VariableValue>, expected: &JsonValue, @@ -76,31 +303,33 @@ fn json_equal( } fn compare_evaluation( - errors: &mut Vec, - feature: &str, - kind: &str, + errors: &mut Vec, + _feature: &str, + path: &[JsonValue], expected: &Map, actual: &JsonValue, ) { for (key, expected_value) in expected { if actual.get(key) != Some(expected_value) { - errors.push(format!( - "{feature}: {kind} evaluation {key} expected {expected_value}, got {}", - actual.get(key).cloned().unwrap_or(JsonValue::Null) + errors.push(ReportError::expectation( + "evaluation", + path.iter().cloned().chain([json!(key)]).collect(), + expected_value.clone(), + actual.get(key).cloned().unwrap_or(JsonValue::Null), )); } } } fn compare_global_variable_evaluation( - errors: &mut Vec, - variable_key: &str, + errors: &mut Vec, + _variable_key: &str, assertion: &JsonValue, evaluation: &crate::Evaluation, child_index: Option, ) { let prefix = child_index - .map(|index| format!("children[{index}].")) + .map(|index| vec![json!("children"), json!(index)]) .unwrap_or_default(); if let Some(expected) = assertion.get("expectedValue") { let actual = evaluation @@ -109,9 +338,15 @@ fn compare_global_variable_evaluation( .map(crate::VariableValue::to_json) .unwrap_or(JsonValue::Null); if actual != *expected { - errors.push(format!( - "{variable_key}: {prefix}expectedValue expected {expected}, got {actual}" - )); + errors.push( + ReportError::expectation( + "variable", + vec![json!("expectedValue")], + expected.clone(), + actual, + ) + .prefixed(&prefix), + ); } } if let Some(expected) = assertion @@ -122,16 +357,22 @@ fn compare_global_variable_evaluation( for (key, expected_value) in expected { let actual_value = actual.get(key).cloned().unwrap_or(JsonValue::Null); if actual_value != *expected_value { - errors.push(format!( - "{variable_key}: {prefix}expectedEvaluation.{key} expected {expected_value}, got {actual_value}" - )); + errors.push( + ReportError::expectation( + "evaluation", + vec![json!("expectedEvaluation"), json!(key)], + expected_value.clone(), + actual_value, + ) + .prefixed(&prefix), + ); } } } } fn compare_evaluations( - errors: &mut Vec, + errors: &mut Vec, feature_key: &str, assertion: &JsonValue, sdk: &Featurevisor, @@ -149,7 +390,13 @@ fn compare_evaluations( { let actual = serde_json::to_value(sdk.evaluate_flag(feature_key, None)).unwrap_or(JsonValue::Null); - compare_evaluation(errors, feature_key, "flag", expected, &actual); + compare_evaluation( + errors, + feature_key, + &[json!("expectedEvaluations"), json!("flag")], + expected, + &actual, + ); } if let Some(expected) = expected_evaluations .get("variation") @@ -157,7 +404,13 @@ fn compare_evaluations( { let actual = serde_json::to_value(sdk.evaluate_variation(feature_key, None, None)) .unwrap_or(JsonValue::Null); - compare_evaluation(errors, feature_key, "variation", expected, &actual); + compare_evaluation( + errors, + feature_key, + &[json!("expectedEvaluations"), json!("variation")], + expected, + &actual, + ); } if let Some(expected_variables) = expected_evaluations .get("variables") @@ -175,7 +428,11 @@ fn compare_evaluations( compare_evaluation( errors, feature_key, - &format!("variable {variable_key}"), + &[ + json!("expectedEvaluations"), + json!("variables"), + json!(variable_key), + ], expected, &actual, ); @@ -185,7 +442,7 @@ fn compare_evaluations( } fn compare_child_evaluations( - errors: &mut Vec, + errors: &mut Vec, feature_key: &str, assertion: &JsonValue, child: &FeaturevisorChild, @@ -203,7 +460,13 @@ fn compare_child_evaluations( { let actual = serde_json::to_value(child.evaluate_flag(feature_key, None)).unwrap_or(JsonValue::Null); - compare_evaluation(errors, feature_key, "child flag", expected, &actual); + compare_evaluation( + errors, + feature_key, + &[json!("expectedEvaluations"), json!("flag")], + expected, + &actual, + ); } if let Some(expected) = expected_evaluations .get("variation") @@ -211,7 +474,13 @@ fn compare_child_evaluations( { let actual = serde_json::to_value(child.evaluate_variation(feature_key, None, None)) .unwrap_or(JsonValue::Null); - compare_evaluation(errors, feature_key, "child variation", expected, &actual); + compare_evaluation( + errors, + feature_key, + &[json!("expectedEvaluations"), json!("variation")], + expected, + &actual, + ); } if let Some(expected_variables) = expected_evaluations .get("variables") @@ -229,7 +498,11 @@ fn compare_child_evaluations( compare_evaluation( errors, feature_key, - &format!("child variable {variable_key}"), + &[ + json!("expectedEvaluations"), + json!("variables"), + json!(variable_key), + ], expected, &actual, ); @@ -239,7 +512,7 @@ fn compare_child_evaluations( } fn compare_variables( - errors: &mut Vec, + errors: &mut Vec, feature_key: &str, assertion: &JsonValue, sdk: &Featurevisor, @@ -258,8 +531,11 @@ fn compare_variables( .and_then(|feature| feature.variables_schema.as_ref()) .and_then(|schemas| schemas.get(variable_key)) else { - errors.push(format!( - "{feature_key}.{variable_key}: variable schema not found" + errors.push(ReportError::expectation( + "variable", + vec![json!("expectedVariables"), json!(variable_key)], + expected.clone(), + JsonValue::Null, )); continue; }; @@ -277,18 +553,20 @@ fn compare_variables( }), ); if !json_equal(actual.as_ref(), expected, &schema.variable_type) { - errors.push(format!( - "{feature_key}.{variable_key}: expected {expected}, got {}", + errors.push(ReportError::expectation( + "variable", + vec![json!("expectedVariables"), json!(variable_key)], + expected.clone(), actual .map(|value| value.to_json()) - .unwrap_or(JsonValue::Null) + .unwrap_or(JsonValue::Null), )); } } } fn compare_child_variables( - errors: &mut Vec, + errors: &mut Vec, feature_key: &str, assertion: &JsonValue, child: &FeaturevisorChild, @@ -319,18 +597,20 @@ fn compare_child_variables( .unwrap_or(JsonValue::Null) != *expected { - errors.push(format!( - "{feature_key}.{variable_key}: child expected {expected}, got {}", + errors.push(ReportError::expectation( + "variable", + vec![json!("expectedVariables"), json!(variable_key)], + expected.clone(), actual .map(|value| value.to_json()) - .unwrap_or(JsonValue::Null) + .unwrap_or(JsonValue::Null), )); } } } fn compare_children( - errors: &mut Vec, + errors: &mut Vec, feature_key: &str, assertion: &JsonValue, sdk: &Featurevisor, @@ -345,49 +625,169 @@ fn compare_children( child_context, SpawnOptions { sticky_features: child - .get("sticky") + .get("stickyFeatures") .and_then(|value| serde_json::from_value(value.clone()).ok()), sticky_variables: child .get("stickyVariables") .and_then(|value| serde_json::from_value(value.clone()).ok()), }, ); + let error_start = errors.len(); if let Some(expected) = child .get("expectedToBeEnabled") .and_then(JsonValue::as_bool) { let actual = child_sdk.is_enabled(feature_key, None); if actual != expected { - errors.push(format!( - "{feature_key}: child {index} expected enabled {expected}, got {actual}" + errors.push(ReportError::expectation( + "flag", + vec![json!("expectedToBeEnabled")], + json!(expected), + json!(actual), )); } } - if let Some(expected) = child.get("expectedVariation").and_then(JsonValue::as_str) { + if let Some(expected) = child.get("expectedVariation") { let actual = child_sdk.get_variation(feature_key, None, None); - if actual.as_deref() != Some(expected) { - errors.push(format!( - "{feature_key}: child {index} expected variation {expected}, got {actual:?}" + if json!(actual) != *expected { + errors.push(ReportError::expectation( + "variation", + vec![json!("expectedVariation")], + expected.clone(), + json!(actual), )); } } compare_child_variables(errors, feature_key, child, &child_sdk); compare_child_evaluations(errors, feature_key, child, &child_sdk); + for error in &mut errors[error_start..] { + *error = error.clone().prefixed(&[json!("children"), json!(index)]); + } child_sdk.close(); } } +struct AssertionExecution<'a> { + options: &'a TestOptions, + set: Option<&'a str>, + target_keys: &'a [String], + datafiles: &'a HashMap, + segments: &'a HashMap, + coverage: Option<&'a Arc>>>, + assertion_index: usize, +} + +fn message_errors(messages: Vec) -> Vec { + messages.into_iter().map(ReportError::message).collect() +} + +fn authored_check_base( + scenario: bool, + assertion_index: usize, + child_index: Option, +) -> Vec { + let mut path = if scenario { + Vec::new() + } else { + vec![ + JsonValue::String("assertions".to_string()), + JsonValue::from(assertion_index), + ] + }; + if let Some(child_index) = child_index { + path.push(JsonValue::String("children".to_string())); + path.push(JsonValue::from(child_index)); + } + path.push(JsonValue::String("checks".to_string())); + path +} + +fn check_report_errors( + outcome: CheckAssertionOutcome, + authored_base: &[JsonValue], +) -> Vec { + let CheckAssertionOutcome { + failures, + report_failures, + had_error, + } = outcome; + let _ = (failures, had_error); + report_failures + .into_iter() + .map(|failure| { + let path = failure.check_index.map(|index| { + let mut path = authored_base.to_vec(); + path.push(JsonValue::from(index)); + path + }); + let evaluated = failure + .evaluated + .into_iter() + .map(|leaf| { + let mut value = serde_json::to_value(leaf).unwrap_or(JsonValue::Null); + if let Some(object) = value.as_object_mut() { + let relative = object + .get("path") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + let mut full_path = authored_base.to_vec(); + full_path.extend(relative); + object.insert("path".to_string(), JsonValue::Array(full_path)); + } + value + }) + .collect::>(); + let mut details = Map::new(); + if let Some(index) = failure.check_index { + details.insert("checkIndex".to_string(), JsonValue::from(index)); + } + if let Some(key) = failure.check_key { + details.insert("checkKey".to_string(), JsonValue::String(key)); + } + if let Some(description) = failure.check_description { + details.insert( + "checkDescription".to_string(), + JsonValue::String(description), + ); + } + details.insert("evaluated".to_string(), JsonValue::Array(evaluated)); + ReportError { + message: failure.message, + error_type: Some("check".to_string()), + path: path.or_else(|| Some(authored_base.to_vec())), + expected: Some(failure.expected), + actual: Some(failure.actual), + details: Some(JsonValue::Object(details)), + } + }) + .collect() +} + fn run_assertion( test: &JsonValue, assertion: &JsonValue, - options: &TestOptions, - target_keys: &[String], - datafiles: &HashMap, - segments: &HashMap, -) -> Result, String> { + execution: &AssertionExecution<'_>, +) -> Result, String> { + let options = execution.options; + let set = execution.set; + let target_keys = execution.target_keys; + let datafiles = execution.datafiles; + let segments = execution.segments; + let coverage = execution.coverage; + let discriminator_count = ["feature", "segment", "variable", "scenario"] + .into_iter() + .filter(|key| test.get(*key).is_some()) + .count(); + if discriminator_count != 1 { + return Ok(vec![ReportError::message( + "test must contain exactly one of feature, segment, variable, or scenario", + )]); + } let feature_key = test.get("feature").and_then(JsonValue::as_str); let segment_key = test.get("segment").and_then(JsonValue::as_str); let variable_key = test.get("variable").and_then(JsonValue::as_str); + let scenario_key = test.get("scenario").and_then(JsonValue::as_str); let environment = assertion.get("environment").and_then(JsonValue::as_str); let target = assertion.get("target").and_then(JsonValue::as_str); @@ -399,53 +799,94 @@ fn run_assertion( if let Some(segment_key) = segment_key { let context = context_from_json(assertion.get("context")); - let segment_datafile = crate::DatafileContent { + let compiled = authored_check_segments(test)?; + let fallback = if test.get("_segments").is_some() { + &compiled + } else { + segments + }; + let selected = datafiles.get(&datafile_key(environment, target)); + if selected.is_none() && (environment.is_some() || target.is_some()) { + return Err("No selected datafile available for segment assertion".into()); + } + let segment_datafile = selected.cloned().unwrap_or_else(|| crate::DatafileContent { + schema_version: "4".into(), revision: "tester".to_string(), - segments: segments.clone(), ..Default::default() - }; + }); + super::test_checks::validate_segment_dependencies( + segment_key, + &segment_datafile, + fallback, + )?; let f = crate::create_featurevisor(FeaturevisorOptions { datafile: Some(input(segment_datafile)), context: Some(context.clone()), log_level: Some(log_level(options)), + sticky_features: assertion + .get("stickyFeatures") + .map(|v| serde_json::from_value(v.clone()).unwrap_or_default()), + sticky_variables: assertion + .get("stickyVariables") + .map(|v| serde_json::from_value(v.clone()).unwrap_or_default()), + modules: vec![Arc::new(TestModule { + at: assertion.get("at").and_then(JsonValue::as_f64), + coverage: coverage.cloned(), + set: set.map(str::to_string), + environment: environment.map(str::to_string), + target: target.map(str::to_string), + })], ..Default::default() }); - if f.get_segment(segment_key).is_none() { - return Ok(vec![format!("{segment_key}: segment not found")]); - } - let actual = f.segment_matches(segment_key, &context); + let actual = f.segment_matches(segment_key, fallback, &context, None)?; let expected = assertion .get("expectedToMatch") .and_then(JsonValue::as_bool) .unwrap_or(false); let result = if actual == expected { - Ok(Vec::new()) + Vec::new() } else { - Ok(vec![format!( - "{segment_key}: expected segment match {expected}, got {actual}" - )]) + vec![ReportError::expectation( + "segment", + vec![ + json!("assertions"), + json!(execution.assertion_index), + json!("expectedToMatch"), + ], + json!(expected), + json!(actual), + )] }; f.close(); - return result; + return Ok(result); } - if let Some(variable_key) = variable_key { + if variable_key.is_some() || scenario_key.is_some() { let selected_key = datafile_key(environment, target); let datafile = datafiles.get(&selected_key); let Some(datafile) = datafile else { return Err(format!( - "No datafile available for variable assertion {variable_key}" + "No datafile available for {} assertion {}", + if scenario_key.is_some() { + "scenario" + } else { + "variable" + }, + scenario_key.or(variable_key).unwrap_or("unknown") )); }; if options.common.show_datafile { - println!( + eprintln!( "{}", serde_json::to_string_pretty(datafile).unwrap_or_default() ); } + let diagnostics = CheckDiagnosticCollector::default(); + let root_context = context_from_json(assertion.get("context")); + let segment_evaluator = create_check_segment_evaluator(test)?; let f = crate::create_featurevisor(FeaturevisorOptions { datafile: Some(input(datafile.clone())), - context: Some(context_from_json(assertion.get("context"))), + context: Some(root_context.clone()), sticky_variables: assertion .get("stickyVariables") .and_then(|value| serde_json::from_value(value.clone()).ok()), @@ -453,23 +894,96 @@ fn run_assertion( .get("stickyFeatures") .and_then(|value| serde_json::from_value(value.clone()).ok()), log_level: Some(log_level(options)), - modules: vec![Arc::new(AtModule { + on_diagnostic: Some(diagnostics.handler()), + modules: vec![Arc::new(TestModule { at: assertion.get("at").and_then(JsonValue::as_f64), + coverage: coverage.cloned(), + set: set.map(str::to_string), + environment: environment.map(str::to_string), + target: target.map(str::to_string), })], - ..Default::default() }); - let evaluation_options = OverrideOptions { - default_variable_value: assertion - .get("defaultVariableValue") - .cloned() - .map(crate::VariableValue::from_json), - ..Default::default() - }; - let evaluation = f.evaluate_global_variable(variable_key, None, Some(&evaluation_options)); + let root_resolver = NativeCheckResolver::parent(&f, diagnostics.clone()) + .with_segments(&segment_evaluator, &root_context); + let preflight_errors = preflight_assertion_tree( + assertion, + variable_key, + &root_resolver, + datafile, + scenario_key.is_some(), + execution.assertion_index, + ); + if !preflight_errors.is_empty() { + drop(root_resolver); + f.close(); + segment_evaluator.close(); + return Ok(preflight_errors); + } let mut errors = Vec::new(); - compare_global_variable_evaluation(&mut errors, variable_key, assertion, &evaluation, None); + let mut messages = Vec::new(); + let scenario = scenario_key.is_some(); + let scenario_children: &[JsonValue] = if scenario_key.is_some() { + match assertion.get("children") { + Some(value) => match value.as_array() { + Some(children) => children, + None => { + messages.push("scenario children must be an array".to_string()); + &[] + } + }, + None => &[], + } + } else { + &[] + }; + if scenario_key.is_some() + && assertion.get("checks").is_none() + && scenario_children.is_empty() + { + messages.push("scenario assertion needs checks or children".to_string()); + } + if let Some(variable_key) = variable_key { + let evaluation_options = OverrideOptions { + default_variable_value: assertion + .get("defaultVariableValue") + .cloned() + .map(crate::VariableValue::from_json), + ..Default::default() + }; + let evaluation = + f.evaluate_global_variable(variable_key, None, Some(&evaluation_options)); + compare_global_variable_evaluation( + &mut errors, + variable_key, + assertion, + &evaluation, + None, + ); + } + errors.extend(message_errors(std::mem::take(&mut messages))); + errors.extend(check_report_errors( + evaluate_assertion_checks_detailed( + assertion, + variable_key, + &root_resolver, + datafile, + "", + ), + &authored_check_base(scenario, execution.assertion_index, None), + )); if let Some(children) = assertion.get("children").and_then(JsonValue::as_array) { for (child_index, child_assertion) in children.iter().enumerate() { + if !child_assertion.is_object() { + errors.push(ReportError::message(format!( + "children[{child_index}]: child assertion must be an object" + ))); + continue; + } + if scenario_key.is_some() && child_assertion.get("checks").is_none() { + errors.push(ReportError::message(format!( + "children[{child_index}].checks: expected a nonempty array" + ))); + } let child = f.spawn( context_from_json(child_assertion.get("context")), SpawnOptions { @@ -483,31 +997,53 @@ fn run_assertion( .or_else(|| Some(Default::default())), }, ); - let child_options = OverrideOptions { - default_variable_value: child_assertion - .get("defaultVariableValue") - .cloned() - .map(crate::VariableValue::from_json), - ..Default::default() - }; - let child_evaluation = - child.evaluate_global_variable(variable_key, None, Some(&child_options)); - compare_global_variable_evaluation( - &mut errors, - variable_key, - child_assertion, - &child_evaluation, - Some(child_index), - ); + let mut child_context = root_context.clone(); + child_context.extend(context_from_json(child_assertion.get("context"))); + if let Some(variable_key) = variable_key { + let child_options = OverrideOptions { + default_variable_value: child_assertion + .get("defaultVariableValue") + .cloned() + .map(crate::VariableValue::from_json), + ..Default::default() + }; + let child_evaluation = + child.evaluate_global_variable(variable_key, None, Some(&child_options)); + compare_global_variable_evaluation( + &mut errors, + variable_key, + child_assertion, + &child_evaluation, + Some(child_index), + ); + errors.extend(message_errors(std::mem::take(&mut messages))); + } + errors.extend(check_report_errors( + evaluate_assertion_checks_detailed( + child_assertion, + variable_key, + &NativeCheckResolver::child(&child, diagnostics.clone()) + .with_segments(&segment_evaluator, &child_context), + datafile, + &format!("children[{child_index}]."), + ), + &authored_check_base(scenario, execution.assertion_index, Some(child_index)), + )); child.close(); } } f.close(); - return Ok(errors); + segment_evaluator.close(); + return Ok(errors + .into_iter() + .map(|error| error.prefixed(&[json!("assertions"), json!(execution.assertion_index)])) + .collect()); } let Some(feature_key) = feature_key else { - return Ok(vec!["test has no feature, segment, or variable".to_string()]); + return Ok(vec![ReportError::message( + "test has no feature, segment, or variable", + )]); }; let selected_key = datafile_key(environment, target); let datafile = datafiles.get(&selected_key); @@ -517,7 +1053,7 @@ fn run_assertion( )); }; if options.common.show_datafile { - println!( + eprintln!( "{}", serde_json::to_string_pretty(datafile).unwrap_or_default() ); @@ -525,11 +1061,15 @@ fn run_assertion( let context = context_from_json(assertion.get("context")); let sticky = assertion - .get("sticky") + .get("stickyFeatures") .and_then(|value| serde_json::from_value(value.clone()).ok()) .unwrap_or_default(); - let module = Arc::new(AtModule { + let module = Arc::new(TestModule { at: assertion.get("at").and_then(JsonValue::as_f64), + coverage: coverage.cloned(), + set: set.map(str::to_string), + environment: environment.map(str::to_string), + target: target.map(str::to_string), }); let f = crate::create_featurevisor(FeaturevisorOptions { datafile: Some(input(datafile.clone())), @@ -550,15 +1090,15 @@ fn run_assertion( { let actual = f.is_enabled(feature_key, None); if actual != expected { - errors.push(format!( - "{feature_key}: expected enabled {expected}, got {actual}" + errors.push(ReportError::expectation( + "flag", + vec![json!("expectedToBeEnabled")], + json!(expected), + json!(actual), )); } } - if let Some(expected) = assertion - .get("expectedVariation") - .and_then(JsonValue::as_str) - { + if let Some(expected) = assertion.get("expectedVariation") { let options = OverrideOptions { default_variation_value: assertion .get("defaultVariationValue") @@ -567,10 +1107,12 @@ fn run_assertion( default_variable_value: None, }; let actual = f.get_variation(feature_key, None, Some(&options)); - if actual.as_deref() != Some(expected) { - errors.push(format!( - "{feature_key}: expected variation {expected}, got {:?}", - actual + if json!(actual) != *expected { + errors.push(ReportError::expectation( + "variation", + vec![json!("expectedVariation")], + expected.clone(), + json!(actual), )); } } @@ -578,7 +1120,63 @@ fn run_assertion( compare_evaluations(&mut errors, feature_key, assertion, &f); compare_children(&mut errors, feature_key, assertion, &f); f.close(); - Ok(errors) + Ok(errors + .into_iter() + .map(|error| error.prefixed(&[json!("assertions"), json!(execution.assertion_index)])) + .collect()) +} + +fn preflight_assertion_tree( + assertion: &JsonValue, + owner_variable: Option<&str>, + resolver: &dyn super::test_checks::CheckResolver, + datafile: &crate::DatafileContent, + scenario: bool, + assertion_index: usize, +) -> Vec { + let mut errors = Vec::new(); + let children = match assertion.get("children") { + Some(JsonValue::Array(children)) => children.as_slice(), + Some(_) => { + errors.push(ReportError::message("children: expected an array")); + &[] + } + None => &[], + }; + if scenario && assertion.get("checks").is_none() && children.is_empty() { + errors.push(ReportError::message( + "scenario assertion needs checks or children", + )); + } + errors.extend(check_report_errors( + preflight_assertion_checks(assertion, owner_variable, resolver, datafile, ""), + &authored_check_base(scenario, assertion_index, None), + )); + for (child_index, child) in children.iter().enumerate() { + if !child.is_object() { + errors.push(ReportError::message(format!( + "children[{child_index}]: child assertion must be an object" + ))); + continue; + } + if scenario && child.get("checks").is_none() { + errors.push(ReportError::message(format!( + "children[{child_index}].checks: expected a nonempty array" + ))); + continue; + } + errors.extend(check_report_errors( + preflight_assertion_checks( + child, + owner_variable, + resolver, + datafile, + &format!("children[{child_index}]."), + ), + &authored_check_base(scenario, assertion_index, Some(child_index)), + )); + } + errors } fn build_test_datafiles( @@ -586,13 +1184,20 @@ fn build_test_datafiles( environments: &[Option], target_keys: &[String], inflate: u32, + set: Option<&str>, ) -> Result, String> { let mut datafiles = HashMap::new(); for environment in environments { - let base = build_datafile(project, environment.as_deref(), None, inflate)?; + let base = build_datafile_for_set(project, environment.as_deref(), None, inflate, set)?; datafiles.insert(datafile_key(environment.as_deref(), None), base); for target in target_keys { - let datafile = build_datafile(project, environment.as_deref(), Some(target), inflate)?; + let datafile = build_datafile_for_set( + project, + environment.as_deref(), + Some(target), + inflate, + set, + )?; datafiles.insert(datafile_key(environment.as_deref(), Some(target)), datafile); } } @@ -620,8 +1225,12 @@ fn project_environments(project: &Path) -> Result>, String> { } } -fn project_segments(project: &Path) -> Result, String> { - let value = json_command(project, "list", vec!["--segments".to_string()])?; +fn project_segments(project: &Path, set: Option<&str>) -> Result, String> { + let mut args = vec!["--segments".to_string()]; + if let Some(set) = set { + args.push(format!("--set={set}")); + } + let value = json_command(project, "list", args)?; let items = value .as_array() .ok_or_else(|| "Expected segments JSON array".to_string())?; @@ -637,132 +1246,1066 @@ fn project_segments(project: &Path) -> Result, String> Ok(segments) } -pub fn run(options: TestOptions) -> Result<(), String> { - let project = project_path(&options.common.project_directory_path); - let key_pattern = compile_pattern("--keyPattern", options.key_pattern.as_deref())?; - let assertion_pattern = - compile_pattern("--assertionPattern", options.assertion_pattern.as_deref())?; - let target_keys = if options.common.target.is_empty() { - list_targets(&project)? - } else { - let available = list_targets(&project)?; - let selected = unique_targets(options.common.target.clone()); - for target in &selected { - if !available.contains(target) { - return Err(format!("Unknown target \"{target}\"")); +fn authored_check_segments(test: &JsonValue) -> Result, String> { + let Some(value) = test.get("_segments") else { + return Ok(HashMap::new()); + }; + let object = value + .as_object() + .ok_or_else(|| "Test _segments must be an object".to_string())?; + object + .iter() + .map(|(key, value)| { + let mut segment = serde_json::from_value::(value.clone()) + .map_err(|error| format!("Could not parse authored segment {key:?}: {error}"))?; + if segment.key.is_none() { + segment.key = Some(key.clone()); } - } - selected + Ok((key.clone(), segment)) + }) + .collect() +} + +fn create_check_segment_evaluator(test: &JsonValue) -> Result { + Ok(crate::create_featurevisor(FeaturevisorOptions { + datafile: Some(input(crate::DatafileContent { + schema_version: "4".to_string(), + revision: "tester-check-segments".to_string(), + segments: authored_check_segments(test)?, + ..Default::default() + })), + log_level: Some(LogLevel::Error), + ..Default::default() + })) +} + +struct SetExecution { + target_keys: Vec, + datafiles: HashMap, + segments: HashMap, +} + +fn metadata(test: &JsonValue) -> Result<(String, Option, String), String> { + let value = test + .get("_test") + .and_then(JsonValue::as_object) + .ok_or_else(|| "Node test listing did not include requested _test metadata".to_string())?; + let source = value + .get("source") + .and_then(JsonValue::as_str) + .ok_or_else(|| "Test _test.source must be a string".to_string())? + .replace('\\', "/"); + let set = match value.get("set") { + None | Some(JsonValue::Null) => None, + Some(value) => Some( + value + .as_str() + .ok_or_else(|| "Test _test.set must be a string or null".to_string())? + .to_string(), + ), }; - let value = json_command( - &project, - "list", - vec!["--tests".to_string(), "--apply-matrix".to_string()], - )?; - let tests = value + let entity_type = value + .get("entityType") + .and_then(JsonValue::as_str) + .ok_or_else(|| "Test _test.entityType must be a string".to_string())? + .to_string(); + Ok((source, set, entity_type)) +} + +fn case_metadata(assertion: &JsonValue) -> Result<(String, JsonValue, usize), String> { + let value = assertion + .get("_case") + .and_then(JsonValue::as_object) + .ok_or_else(|| "Node test listing did not include requested _case metadata".to_string())?; + let id = value + .get("id") + .and_then(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Test _case.id must be a nonempty string".to_string())?; + let matrix_values = value + .get("matrixValues") + .cloned() + .filter(JsonValue::is_object) + .ok_or_else(|| "Test _case.matrixValues must be an object".to_string())?; + let assertion_index = value + .get("assertionIndex") + .and_then(JsonValue::as_u64) + .and_then(|index| usize::try_from(index).ok()) + .ok_or_else(|| "Test _case.assertionIndex must be a nonnegative integer".to_string())?; + Ok((id.to_string(), matrix_values, assertion_index)) +} + +const CARGO_RERUN_COMMAND: &str = "cargo run --features cli --bin featurevisor --"; + +fn native_rerun_command() -> String { + std::env::current_exe() + .ok() + .and_then(|path| path.into_os_string().into_string().ok()) + .filter(|path| !path.is_empty()) + .unwrap_or_else(|| CARGO_RERUN_COMMAND.to_string()) +} + +fn rerun(id: &str, project: Option<&str>, set: Option<&str>, targets: &[String]) -> Rerun { + let mut args = vec!["test".to_string(), format!("--case={id}")]; + if let Some(project) = project { + args.push(format!("--projectDirectoryPath={project}")); + } + if let Some(set) = set { + args.push(format!("--set={set}")); + } + args.extend(targets.iter().map(|target| format!("--target={target}"))); + Rerun { + command: native_rerun_command(), + args, + } +} + +fn entity_keys(value: JsonValue, kind: &str) -> Result, String> { + let items = value .as_array() - .ok_or_else(|| "Expected tests JSON array".to_string())?; + .ok_or_else(|| format!("Expected uncovered {kind} JSON array"))?; + let mut keys = items + .iter() + .filter_map(|item| item.get("key").and_then(JsonValue::as_str)) + .map(str::to_string) + .collect::>(); + keys.sort(); + keys.dedup(); + Ok(keys) +} + +fn uncovered_for_sets( + project: &Path, + sets: &BTreeSet>, +) -> Result, String> { + let mut gaps = Vec::new(); + for set in sets { + let mut feature_args = vec!["--features".to_string(), "--without-tests".to_string()]; + let mut variable_args = vec!["--variables".to_string(), "--without-tests".to_string()]; + if let Some(set) = set { + feature_args.push(format!("--set={set}")); + variable_args.push(format!("--set={set}")); + } + gaps.push(Uncovered { + set: set.clone(), + features: entity_keys(json_command(project, "list", feature_args)?, "features")?, + variables: entity_keys(json_command(project, "list", variable_args)?, "variables")?, + }); + } + Ok(gaps) +} + +fn list_tests_for_set( + project: &Path, + options: &TestOptions, + set: Option<&str>, + case_id: Option<&str>, +) -> Result, String> { + let mut args = vec![ + "--tests".to_string(), + "--execution-protocol".to_string(), + "--apply-matrix".to_string(), + "--execution-metadata".to_string(), + ]; + if let Some(entity_type) = &options.entity_type { + args.push(format!("--entityType={entity_type}")); + } + if let Some(set) = set { + args.push(format!("--set={set}")); + } + if let Some(case_id) = case_id { + args.push(format!("--case={case_id}")); + } + parse_test_execution_protocol(json_command(project, "list", args)?) +} + +fn parse_test_execution_protocol(value: JsonValue) -> Result, String> { + let envelope = value + .as_object() + .ok_or_else(|| "Test execution protocol must be an object".to_string())?; + if envelope.get("kind").and_then(JsonValue::as_str) != Some("featurevisor.test-execution") { + return Err("Invalid test execution protocol kind".to_string()); + } + if envelope.get("schemaVersion").and_then(JsonValue::as_u64) != Some(1) { + return Err(format!( + "Unsupported test execution protocol schemaVersion {}", + envelope.get("schemaVersion").unwrap_or(&JsonValue::Null) + )); + } + let tests = envelope + .get("tests") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Test execution protocol tests must be an array".to_string())?; + if let Some((index, _)) = tests.iter().enumerate().find(|(_, test)| !test.is_object()) { + return Err(format!( + "Test execution protocol tests[{index}] must be an object" + )); + } + Ok(tests.clone()) +} + +fn list_tests( + project: &Path, + options: &TestOptions, + sets: &[Option], +) -> Result, String> { + if let Some(case_id) = options.case_id.as_deref() { + if sets.len() == 1 { + return list_tests_for_set(project, options, sets[0].as_deref(), Some(case_id)); + } + for set in sets { + let tests = list_tests_for_set(project, options, set.as_deref(), None)?; + let matched = tests.iter().any(|test| { + test.get("assertions") + .and_then(JsonValue::as_array) + .into_iter() + .flatten() + .any(|assertion| { + assertion + .get("_case") + .and_then(|value| value.get("id")) + .and_then(JsonValue::as_str) + == Some(case_id) + }) + }); + if matched { + return list_tests_for_set(project, options, set.as_deref(), Some(case_id)); + } + } + return list_tests_for_set(project, options, sets[0].as_deref(), Some(case_id)); + } + let mut tests = Vec::new(); + for set in sets { + tests.extend(list_tests_for_set(project, options, set.as_deref(), None)?); + } + Ok(tests) +} + +fn execute(options: &TestOptions) -> Result { + let project = project_path(options.common.project_directory_path()); + let key_pattern = compile_pattern("--keyPattern", options.key_pattern.as_deref())?; + let assertion_pattern = + compile_pattern("--assertionPattern", options.assertion_pattern.as_deref())?; + let requested_sets = project_sets(&project, options.set.as_deref())?; + let tests = list_tests(&project, options, &requested_sets)?; + let sets = requested_sets.into_iter().collect::>(); + let environments = project_environments(&project)?; - let segments = project_segments(&project)?; - let datafiles = build_test_datafiles( - &project, - &environments, - &target_keys, - options.common.inflate, - )?; - let mut passed_specs = 0usize; - let mut failed_specs = 0usize; - let mut passed_assertions = 0usize; - let mut failed_assertions = 0usize; + let mut executions = HashMap::new(); + for set in &sets { + let available = list_targets_for_set(&project, set.as_deref())?; + let target_keys = if options.common.target.is_empty() { + available + } else { + let selected = unique_targets(options.common.target.clone()); + for target in &selected { + if !available.contains(target) { + return Err(format!("Unknown target \"{target}\"")); + } + } + selected + }; + executions.insert( + set.clone(), + SetExecution { + segments: project_segments(&project, set.as_deref())?, + datafiles: build_test_datafiles( + &project, + &environments, + &target_keys, + options.common.inflate, + set.as_deref(), + )?, + target_keys, + }, + ); + } + + let coverage = options + .coverage + .then(|| Arc::new(Mutex::new(BTreeSet::::new()))); + let mut summary = Summary::default(); + let mut results = Vec::new(); for test in tests { - let key = test - .get("key") - .and_then(JsonValue::as_str) - .unwrap_or("unknown"); + let (source, set, entity_type) = metadata(&test)?; + let key = authored_test_key(&test, &entity_type); if key_pattern .as_ref() .is_some_and(|pattern| !pattern.is_match(key)) { continue; } - let mut test_failed = false; - let mut assertions_run = 0usize; - if let Some(assertions) = test.get("assertions").and_then(JsonValue::as_array) { - for assertion in assertions { - if assertion + let execution = executions + .get(&set) + .ok_or_else(|| format!("Missing native execution state for set {set:?}"))?; + let mut cases = Vec::new(); + let assertions = test + .get("assertions") + .and_then(JsonValue::as_array) + .ok_or_else(|| format!("Test {key:?} needs an assertions array"))?; + + for assertion in assertions { + if assertion + .get("target") + .and_then(JsonValue::as_str) + .is_some_and(|target| !execution.target_keys.iter().any(|key| key == target)) + { + continue; + } + if assertion_pattern.as_ref().is_some_and(|pattern| { + !pattern.is_match( + assertion + .get("description") + .and_then(JsonValue::as_str) + .unwrap_or(""), + ) + }) { + continue; + } + let (id, matrix_values, assertion_index) = case_metadata(assertion)?; + let errors = run_assertion( + &test, + assertion, + &AssertionExecution { + options, + set: set.as_deref(), + target_keys: &execution.target_keys, + datafiles: &execution.datafiles, + segments: &execution.segments, + coverage: coverage.as_ref(), + assertion_index, + }, + )?; + let passed = errors.is_empty(); + cases.push(CaseResult { + id: id.clone(), + description: assertion + .get("description") + .and_then(JsonValue::as_str) + .map(str::to_string), + environment: assertion + .get("environment") + .and_then(JsonValue::as_str) + .map(str::to_string), + target: assertion .get("target") .and_then(JsonValue::as_str) - .is_some_and(|target| !target_keys.iter().any(|key| key == target)) - { - continue; - } - if assertion_pattern.as_ref().is_some_and(|pattern| { - !pattern.is_match( - assertion - .get("description") - .and_then(JsonValue::as_str) - .unwrap_or(""), - ) - }) { + .map(str::to_string), + context: assertion + .get("context") + .cloned() + .filter(JsonValue::is_object) + .unwrap_or_else(|| JsonValue::Object(Map::new())), + matrix_values, + passed, + errors, + rerun: rerun( + &id, + options.common.project_directory_path.as_deref(), + set.as_deref(), + &unique_targets(options.common.target.clone()), + ), + }); + } + if cases.is_empty() { + continue; + } + let passed = cases.iter().all(|case| case.passed); + let passed_cases = cases.iter().filter(|case| case.passed).count(); + let failed_cases = cases.len() - passed_cases; + let spec_counts = if entity_type == "scenario" { + &mut summary.scenario_specs + } else { + &mut summary.entity_specs + }; + if passed { + spec_counts.passed += 1; + summary.specs.passed += 1; + } else { + spec_counts.failed += 1; + summary.specs.failed += 1; + } + let case_counts = if entity_type == "scenario" { + &mut summary.scenario_cases + } else { + &mut summary.entity_assertions + }; + case_counts.passed += passed_cases; + case_counts.failed += failed_cases; + summary.assertions.passed += passed_cases; + summary.assertions.failed += failed_cases; + results.push(SpecResult { + key: key.to_string(), + entity_type, + source, + set, + passed, + cases, + }); + } + + let mut errors = Vec::new(); + if summary.specs.passed + summary.specs.failed == 0 { + errors.push(ReportError::message( + "No test specs matched the requested filters", + )); + } + let selections = coverage.as_ref().map(|values| { + values + .lock() + .map(|values| values.iter().cloned().collect()) + .unwrap_or_default() + }); + let passed = summary.specs.failed == 0 && errors.is_empty(); + Ok(TestReport { + schema_version: 1, + runner: "rust", + passed, + summary, + results, + errors, + uncovered: if options.uncovered { + Some(uncovered_for_sets(&project, &sets)?) + } else { + None + }, + coverage: selections.map(|selections| Coverage { + mode: "observed-selections", + selections, + }), + }) +} + +fn empty_report(message: &str, options: &TestOptions) -> TestReport { + TestReport { + schema_version: 1, + runner: "rust", + passed: false, + summary: Summary::default(), + results: Vec::new(), + errors: vec![ReportError::message(message)], + uncovered: options.uncovered.then(Vec::new), + coverage: options.coverage.then(|| Coverage { + mode: "observed-selections", + selections: Vec::new(), + }), + } +} + +fn shell_arg(value: &str) -> String { + if value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_-./=:".contains(character)) + { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +#[cfg(windows)] +fn power_shell_arg(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn format_human_path(path: &[JsonValue]) -> String { + path.iter() + .map(|part| { + part.as_u64() + .map(|number| (number + 1).to_string()) + .or_else(|| part.as_str().map(str::to_string)) + .unwrap_or_else(|| part.to_string()) + }) + .collect::>() + .join(".") +} + +fn humanise_child_message(message: &str) -> String { + let Some(start) = message.find("children[") else { + return message.to_string(); + }; + let index_start = start + "children[".len(); + let Some(offset) = message[index_start..].find(']') else { + return message.to_string(); + }; + let end = index_start + offset; + let Ok(index) = message[index_start..end].parse::() else { + return message.to_string(); + }; + let rest = &message[end + 1..]; + let rest = rest + .strip_prefix('.') + .map(|value| format!(", {value}")) + .unwrap_or_else(|| rest.to_string()); + format!("{}Child {}{rest}", &message[..start], index + 1) +} + +fn format_case_label(case: &CaseResult, index: usize) -> String { + let mut label = case + .description + .clone() + .unwrap_or_else(|| format!("assertion {}", index + 1)); + let mut details = Vec::new(); + if let Some(values) = case.matrix_values.as_object() { + if !values.is_empty() { + details.push(format!( + "matrix {}", + values + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join(", ") + )); + } + } + if let Some(target) = &case.target { + details.push(format!("target {target}")); + } + if !details.is_empty() { + label.push_str(&format!(" ({})", details.join("; "))); + } + label +} + +fn format_coverage_selection(selection: &CoverageSelection) -> String { + let mut owner = if let Some(feature) = &selection.feature_key { + format!("feature {feature}") + } else { + format!( + "global variable {}", + selection.variable_key.as_deref().unwrap_or("") + ) + }; + if selection.feature_key.is_some() { + if let Some(variable) = &selection.variable_key { + owner.push_str(&format!(" variable {variable}")); + } + } + let scope = [ + selection.set.as_ref().map(|value| format!("set {value}")), + selection + .environment + .as_ref() + .map(|value| format!("environment {value}")), + selection + .target + .as_ref() + .map(|value| format!("target {value}")), + ] + .into_iter() + .flatten() + .collect::>(); + if !scope.is_empty() { + owner.push_str(&format!(" ({})", scope.join(", "))); + } + let mut parts = Vec::new(); + if let Some(rule) = &selection.rule_key { + parts.push(format!("rule {rule}")); + } + if let Some(variation) = &selection.variation { + parts.push(format!("variation {}", json!(variation))); + } + if let Some(path) = &selection.override_path { + parts.push(format!("override {}", path.join(" > "))); + } else if let Some(index) = selection.override_index { + parts.push(format!("override {}", index + 1)); + } + format!("{owner}: {}", parts.join(", ")) +} + +fn shell_rerun_command(command: &str) -> String { + if command == CARGO_RERUN_COMMAND { + command.to_string() + } else { + shell_arg(command) + } +} + +fn print_human(report: &TestReport, options: &TestOptions) { + if !options.common.quiet { + for result in &report.results { + if !result.passed || !options.only_failures { + let label = if result.entity_type == "scenario" { + "scenario " + } else { + "" + }; + println!( + "{} {label}{}{}", + if result.passed { "PASS" } else { "FAIL" }, + result.key, + result + .set + .as_ref() + .map(|set| format!(" [set {set}]")) + .unwrap_or_default() + ); + } + for (index, case) in result.cases.iter().enumerate() { + if options.only_failures && case.passed { continue; } - let errors = run_assertion( - test, - assertion, - &options, - &target_keys, - &datafiles, - &segments, - )?; - assertions_run += 1; - if !errors.is_empty() { - test_failed = true; - failed_assertions += 1; - if !options.common.quiet { - let description = assertion - .get("description") - .and_then(JsonValue::as_str) - .unwrap_or("assertion"); - for error in errors { - eprintln!(" {key} ({description}): {error}"); - } + let mark = if case.passed { "✔" } else { "✘" }; + eprintln!(" {mark} {}", format_case_label(case, index)); + for error in &case.errors { + let check = error + .details + .as_ref() + .and_then(|details| details.get("checkKey")) + .and_then(JsonValue::as_str); + let path = error.path.as_ref().map(|path| format_human_path(path)); + if error.error_type.as_deref() == Some("check") { + let label = check + .map(|key| format!("check \"{key}\"")) + .unwrap_or_else(|| "check".to_string()); + eprintln!(" => {label}: {}", humanise_child_message(&error.message)); + } else { + eprintln!(" => {}", humanise_child_message(&error.message)); } - } else { - passed_assertions += 1; + if let Some(path) = path { + eprintln!(" at {path}"); + } + } + if !case.passed { + #[cfg(windows)] + eprintln!( + " Rerun (PowerShell): {} {}", + power_shell_arg(&case.rerun.command), + case.rerun + .args + .iter() + .map(|arg| power_shell_arg(arg)) + .collect::>() + .join(" ") + ); + #[cfg(not(windows))] + eprintln!( + " Rerun (POSIX shell): {} {}", + shell_rerun_command(&case.rerun.command), + case.rerun + .args + .iter() + .map(|arg| shell_arg(arg)) + .collect::>() + .join(" ") + ); } } } - if assertions_run == 0 { - continue; + } + println!("\n---"); + println!( + "Entity specs: {} passed, {} failed", + report.summary.entity_specs.passed, report.summary.entity_specs.failed + ); + println!( + "Entity assertions: {} passed, {} failed", + report.summary.entity_assertions.passed, report.summary.entity_assertions.failed + ); + println!( + "Scenario specs: {} passed, {} failed", + report.summary.scenario_specs.passed, report.summary.scenario_specs.failed + ); + println!( + "Scenario cases: {} passed, {} failed", + report.summary.scenario_cases.passed, report.summary.scenario_cases.failed + ); + println!( + "Total cases: {} passed, {} failed", + report.summary.assertions.passed, report.summary.assertions.failed + ); + if let Some(uncovered) = &report.uncovered { + println!("\nDefinitions without associated specs (not evaluation coverage):"); + for entry in uncovered { + if let Some(set) = &entry.set { + println!(" Set: {set}"); + } + println!( + " Features: {}", + if entry.features.is_empty() { + "none".to_string() + } else { + entry.features.join(", ") + } + ); + println!( + " Global variables: {}", + if entry.variables.is_empty() { + "none".to_string() + } else { + entry.variables.join(", ") + } + ); } - if test_failed { - failed_specs += 1; - if !options.only_failures && !options.common.quiet { - println!("FAIL {key}"); + } + if let Some(coverage) = &report.coverage { + println!("\nObserved rule and override selections (not branch coverage):"); + if coverage.selections.is_empty() { + println!(" No selections observed"); + } + for selection in &coverage.selections { + println!(" {}", format_coverage_selection(selection)); + } + } +} + +pub fn run(options: TestOptions) -> Result<(), String> { + let result = execute(&options); + let report = match result { + Ok(report) => report, + Err(error) => { + if options.json { + let report = empty_report(&error, &options); + let output = if options.pretty { + serde_json::to_string_pretty(&report) + } else { + serde_json::to_string(&report) + } + .map_err(|serialization| serialization.to_string())?; + println!("{output}"); } - } else if !options.only_failures && !options.common.quiet { - println!("PASS {key}"); - passed_specs += 1; + return Err(error); + } + }; + if options.json { + let output = if options.pretty { + serde_json::to_string_pretty(&report) } else { - passed_specs += 1; + serde_json::to_string(&report) } + .map_err(|error| error.to_string())?; + println!("{output}"); + } else { + print_human(&report, &options); } - println!("\n---"); - println!("Test specs: {passed_specs} passed, {failed_specs} failed"); - println!("Assertions: {passed_assertions} passed, {failed_assertions} failed"); - if passed_specs + failed_specs == 0 { - return Err("No test specs matched the requested filters".to_string()); - } - if failed_specs > 0 { - return Err(format!("{failed_specs} test specs failed")); + if report.passed { + Ok(()) + } else { + Err(report + .errors + .first() + .map(|error| error.message.clone()) + .unwrap_or_else(|| format!("{} test specs failed", report.summary.specs.failed))) } - Ok(()) } #[cfg(test)] mod tests { - use super::compile_pattern; + use super::{ + authored_check_base, authored_test_key, check_report_errors, compile_pattern, + format_case_label, format_coverage_selection, format_human_path, humanise_child_message, + native_rerun_command, parse_test_execution_protocol, preflight_assertion_tree, + shell_rerun_command, CaseResult, CoverageSelection, Rerun, CARGO_RERUN_COMMAND, + }; + use crate::cli::test_checks::{ + CheckAssertionOutcome, CheckFailure, CheckResolver, EvaluatedCheck, + }; + use crate::{DatafileContent, VariableValue}; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct NoGetterResolver(AtomicUsize); + + #[test] + fn segment_owners_use_sdk_dependencies_compiled_paths_target_specialization_and_real_coverage() + { + use super::{datafile_key, run_assertion, AssertionExecution, CoverageSelection}; + use clap::Parser; + use std::collections::{BTreeSet, HashMap}; + use std::sync::{Arc, Mutex}; + let cli = crate::cli::Cli::try_parse_from(["featurevisor", "test"]).unwrap(); + let Some(crate::cli::Command::Test(options)) = cli.command else { + panic!("test options"); + }; + let datafile: DatafileContent = serde_json::from_value(json!({ + "schemaVersion":"4","revision":"segment-sdk","segments":{"specialized":{"conditions":"*"}}, + "features":{"gate":{"bucketBy":"userId","traffic":[{"key":"half","conditions":"*","percentage":50000}]}}, + "variables":{"policy":{"type":"object","defaultValue":{"limit":1}}} + })).unwrap(); + let datafiles = [( + datafile_key(Some("production"), Some("web")), + datafile.clone(), + )] + .into(); + let fallback = HashMap::new(); + let coverage = Arc::new(Mutex::new(BTreeSet::::new())); + let execution = AssertionExecution { + options: &options, + set: None, + target_keys: &[], + datafiles: &datafiles, + segments: &fallback, + coverage: Some(&coverage), + assertion_index: 0, + }; + let spec = json!({"segment":"eligible","_segments":{ + "eligible":{"conditions":["alias",{"variable":"policy","path":["limit"],"operator":"equals","value":1}]}, + "alias":{"conditions":{"feature":"gate"}}, + "specialized":{"conditions":{"attribute":"country","operator":"equals","value":"nl"}} + }}); + let mut assertion = json!({"environment":"production","target":"web","context":{"country":"us"},"at":25,"expectedToMatch":true}); + assert!(run_assertion(&spec, &assertion, &execution) + .unwrap() + .is_empty()); + assertion["at"] = json!(75); + assertion["expectedToMatch"] = json!(false); + assert!(run_assertion(&spec, &assertion, &execution) + .unwrap() + .is_empty()); + assertion["stickyFeatures"] = json!({"gate":{"enabled":true}}); + assertion["expectedToMatch"] = json!(true); + assert!(run_assertion(&spec, &assertion, &execution) + .unwrap() + .is_empty()); + let mut specialized = spec.clone(); + specialized["segment"] = json!("specialized"); + assert!(run_assertion(&specialized, &assertion, &execution) + .unwrap() + .is_empty()); + assert!(coverage + .lock() + .unwrap() + .iter() + .all(|s| s.feature_key.as_deref() == Some("gate"))); + assert!(!coverage.lock().unwrap().is_empty()); + let mut missing = spec; + missing["_segments"]["alias"]["conditions"] = json!({"feature":"missing"}); + assert!(run_assertion(&missing, &assertion, &execution) + .unwrap_err() + .contains("absent from the selected datafile")); + } + + #[test] + fn ordinary_assertions_report_typed_values_and_authored_paths() { + use super::{datafile_key, run_assertion, AssertionExecution, TestOptions}; + use clap::Parser; + let cli = crate::cli::Cli::try_parse_from(["featurevisor", "test"]).unwrap(); + let Some(crate::cli::Command::Test(options)) = cli.command else { + panic!("test options") + }; + let options: TestOptions = options; + let datafile: DatafileContent = serde_json::from_value(json!({ + "schemaVersion":"2", "revision":"report", "segments":{"all":{"conditions":"*"}}, + "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"}} + })) + .unwrap(); + let datafiles = [(datafile_key(None, None), datafile.clone())].into(); + let execution = AssertionExecution { + options: &options, + set: None, + target_keys: &[], + datafiles: &datafiles, + segments: &datafile.segments, + coverage: None, + assertion_index: 4, + }; + let expectation = json!({ + "expectedToBeEnabled":false, "expectedVariation":"treatment", + "expectedVariables":{"limit":99}, + "expectedEvaluations":{"flag":{"enabled":false},"variation":{"variation":{"value":"treatment"}}, + "variables":{"limit":{"variableValue":99}}} + }); + let mut assertion = expectation.clone(); + assertion["children"] = json!([expectation]); + let errors = run_assertion(&json!({"feature":"subject"}), &assertion, &execution).unwrap(); + assert_eq!(errors.len(), 12); + for (child, offset) in [(false, 0), (true, 6)] { + let mut base = vec![json!("assertions"), json!(4)]; + if child { + base.extend([json!("children"), json!(0)]); + } + for (index, kind, suffix, expected, actual) in [ + ( + 0, + "flag", + json!(["expectedToBeEnabled"]), + json!(false), + json!(true), + ), + ( + 1, + "variation", + json!(["expectedVariation"]), + json!("treatment"), + json!("control"), + ), + ( + 2, + "variable", + json!(["expectedVariables", "limit"]), + json!(99), + json!(12), + ), + ( + 3, + "evaluation", + json!(["expectedEvaluations", "flag", "enabled"]), + json!(false), + json!(true), + ), + ( + 4, + "evaluation", + json!(["expectedEvaluations", "variation", "variation"]), + json!({"value":"treatment"}), + json!({"value":"control"}), + ), + ( + 5, + "evaluation", + json!(["expectedEvaluations", "variables", "limit", "variableValue"]), + json!(99), + json!(12), + ), + ] { + let error = serde_json::to_value(&errors[offset + index]).unwrap(); + assert_eq!(error["type"], kind); + assert_eq!(error["expected"], expected); + assert_eq!(error["actual"], actual); + let path: Vec<_> = base + .iter() + .chain(suffix.as_array().unwrap()) + .cloned() + .collect(); + assert_eq!(error["path"], json!(path)); + } + } + let errors = run_assertion( + &json!({"segment":"all"}), + &json!({"expectedToMatch":false}), + &execution, + ) + .unwrap(); + let error = serde_json::to_value(&errors[0]).unwrap(); + assert_eq!(error["type"], "segment"); + assert_eq!(error["path"], json!(["assertions", 4, "expectedToMatch"])); + assert_eq!( + (error["expected"].clone(), error["actual"].clone()), + (json!(false), json!(true)) + ); + + let expectation = + json!({"expectedValue":"wanted","expectedEvaluation":{"variableValue":"wanted"}}); + let mut assertion = expectation.clone(); + assertion["children"] = json!([expectation]); + let errors = run_assertion(&json!({"variable":"message"}), &assertion, &execution).unwrap(); + assert_eq!(errors.len(), 4); + for (index, error) in errors.iter().enumerate() { + let error = serde_json::to_value(error).unwrap(); + let mut path = vec![json!("assertions"), json!(4)]; + if index >= 2 { + path.extend([json!("children"), json!(0)]); + } + if index % 2 == 0 { + path.push(json!("expectedValue")); + } else { + path.extend([json!("expectedEvaluation"), json!("variableValue")]); + } + assert_eq!(error["path"], json!(path)); + assert_eq!( + error["type"], + if index % 2 == 0 { + "variable" + } else { + "evaluation" + } + ); + assert_eq!(error["expected"], "wanted"); + assert_eq!(error["actual"], "actual"); + } + } + + impl CheckResolver for NoGetterResolver { + fn global_variable(&self, _variable: &str) -> Result, String> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(None) + } + fn feature_variable( + &self, + _feature: &str, + _variable: &str, + ) -> Result, String> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(None) + } + fn feature_enabled(&self, _feature: &str) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(false) + } + fn feature_variation(&self, _feature: &str) -> Result, String> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(None) + } + } + + #[test] + fn execution_protocol_rejects_unversioned_and_malformed_envelopes() { + assert_eq!( + parse_test_execution_protocol(json!({ + "kind":"featurevisor.test-execution", "schemaVersion":1, "tests":[{}] + })) + .unwrap() + .len(), + 1 + ); + for invalid in [ + json!([]), + json!({}), + json!({"kind":null,"schemaVersion":1,"tests":[]}), + json!({"kind":"wrong","schemaVersion":1,"tests":[]}), + json!({"kind":"featurevisor.test-execution","tests":[]}), + json!({"kind":"featurevisor.test-execution","schemaVersion":null,"tests":[]}), + json!({"kind":"featurevisor.test-execution","schemaVersion":"1","tests":[]}), + json!({"kind":"featurevisor.test-execution","schemaVersion":2,"tests":[]}), + json!({"kind":"featurevisor.test-execution","schemaVersion":1,"tests":null}), + json!({"kind":"featurevisor.test-execution","schemaVersion":1,"tests":[null]}), + ] { + assert!(parse_test_execution_protocol(invalid).is_err()); + } + } + + #[test] + fn assertion_tree_preflight_rejects_children_and_skipped_references_without_getters() { + let datafile: DatafileContent = serde_json::from_value(json!({ + "schemaVersion":"2", "revision":"preflight", "segments":{}, + "features":{"checkout":{"bucketBy":"userId","traffic":[]}}, + "variables":{"subject":{"type":"string","defaultValue":"ready"}} + })) + .unwrap(); + let assertions = [ + json!({ + "expectedValue":"ready", + "checks":[{"operator":"exists"}], + "children":[{"checks":[{"feature":"missing","enabled":false}]}] + }), + json!({"checks":[{"feature":"omitted","enabled":false}]}), + json!({"checks":[{"operator":"equals","compareTo":{"variable":"missing"}}]}), + json!({"checks":[{"or":[ + {"operator":"exists"}, + {"not":[{"feature":"missing","enabled":false}]} + ]}]}), + json!({"checks":[{"operator":"exists"}],"children":["invalid"]}), + ]; + for assertion in assertions { + let resolver = NoGetterResolver(AtomicUsize::new(0)); + let errors = preflight_assertion_tree( + &assertion, + Some("subject"), + &resolver, + &datafile, + false, + 0, + ); + assert!(!errors.is_empty(), "{assertion}"); + assert_eq!(resolver.0.load(Ordering::SeqCst), 0); + } + } #[test] fn test_filters_are_case_insensitive_regular_expressions() { @@ -770,9 +2313,140 @@ mod tests { assert!(pattern.unwrap().is_match("FEATURES.PRICING")); } + #[test] + fn key_pattern_matches_authored_entity_key_instead_of_transport_key() { + let test = json!({ + "key": "variables.mutationConformance.spec", + "variable": "mutationConformance" + }); + let key = authored_test_key(&test, "variable"); + let pattern = compile_pattern("--keyPattern", Some(r"^mutationConformance$")) + .unwrap() + .unwrap(); + + assert_eq!(key, "mutationConformance"); + assert!(pattern.is_match(key)); + assert!(!pattern.is_match(test["key"].as_str().unwrap())); + } + #[test] fn invalid_test_filter_is_reported_before_running_the_command() { let error = compile_pattern("--keyPattern", Some("[")).unwrap_err(); assert!(error.starts_with("Invalid --keyPattern:")); } + + #[test] + fn reruns_use_an_unambiguous_shell_safe_native_launcher() { + assert_ne!(native_rerun_command(), "featurevisor"); + assert_eq!( + shell_rerun_command("/tmp/native runner/featurevisor"), + "'/tmp/native runner/featurevisor'" + ); + assert_eq!( + shell_rerun_command(CARGO_RERUN_COMMAND), + CARGO_RERUN_COMMAND + ); + } + + #[test] + fn human_report_uses_one_based_paths_and_readable_labels() { + assert_eq!( + humanise_child_message("children[0].expectedValue expected 1, got 2"), + "Child 1, expectedValue expected 1, got 2" + ); + let case = CaseResult { + id: "opaque".to_string(), + description: Some("runs".to_string()), + environment: None, + target: Some("beta".to_string()), + context: json!({}), + matrix_values: json!({"country": "nl"}), + passed: false, + errors: vec![], + rerun: Rerun { + command: "featurevisor".to_string(), + args: vec![], + }, + }; + assert_eq!( + format_case_label(&case, 0), + "runs (matrix country=\"nl\"; target beta)" + ); + assert_eq!( + format_human_path(&[json!("assertions"), json!(0), json!("checks"), json!(2)]), + "assertions.1.checks.3" + ); + let selection = CoverageSelection { + set: None, + environment: None, + target: None, + feature_key: Some("checkout".to_string()), + variable_key: Some("label".to_string()), + rule_key: Some("all".to_string()), + variation: None, + override_path: None, + override_index: Some(1), + }; + assert_eq!( + format_coverage_selection(&selection), + "feature checkout variable label: rule all, override 2" + ); + } + + #[test] + fn structured_check_paths_do_not_depend_on_literal_names() { + let outcome = || CheckAssertionOutcome { + failures: vec!["entity checks[1] failed".to_string()], + report_failures: vec![CheckFailure { + message: "entity checks[1] failed".to_string(), + expected: json!(true), + actual: json!(false), + check_index: Some(0), + check_key: Some("named checks[1]".to_string()), + check_description: None, + evaluated: vec![EvaluatedCheck { + path: vec![json!(0), json!("and"), json!(1)], + key: None, + description: None, + reference: json!({"variable":"minimum"}), + property: None, + operator: "lessThanOrEquals".to_string(), + expected: json!(10), + actual: json!(12), + actual_missing: Some(false), + expected_missing: Some(false), + compare_to: Some(json!({"variable":"maximum"})), + }], + }], + had_error: false, + }; + let errors = check_report_errors(outcome(), &authored_check_base(false, 3, None)); + assert_eq!( + errors[0].path, + Some(vec![ + json!("assertions"), + json!(3), + json!("checks"), + json!(0) + ]) + ); + assert_eq!( + errors[0].details.as_ref().unwrap()["evaluated"][0]["path"], + json!(["assertions", 3, "checks", 0, "and", 1]) + ); + + let scenario_errors = check_report_errors(outcome(), &authored_check_base(true, 3, None)); + assert_eq!( + scenario_errors[0].path, + Some(vec![json!("checks"), json!(0)]) + ); + assert_eq!( + scenario_errors[0].details.as_ref().unwrap()["evaluated"][0]["path"], + json!(["checks", 0, "and", 1]) + ); + assert_eq!( + scenario_errors[0].details.as_ref().unwrap()["checkKey"], + json!("named checks[1]") + ); + } } diff --git a/src/cli/test_checks.rs b/src/cli/test_checks.rs new file mode 100644 index 0000000..250677a --- /dev/null +++ b/src/cli/test_checks.rs @@ -0,0 +1,2751 @@ +use crate::compare_versions::compare_versions; +use crate::{ + DatafileContent, Diagnostic, DiagnosticHandler, Featurevisor, FeaturevisorChild, LogLevel, + VariableValue, +}; +use chrono::{DateTime, Datelike, FixedOffset, Timelike}; +use regex::RegexBuilder; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::cmp::Ordering; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::{Arc, Mutex}; + +pub(super) trait CheckResolver { + fn global_variable(&self, variable: &str) -> Result, String>; + fn feature_variable( + &self, + feature: &str, + variable: &str, + ) -> Result, String>; + fn feature_enabled(&self, feature: &str) -> Result; + fn feature_variation(&self, feature: &str) -> Result, String>; + fn segment_defined(&self, _segment: &str) -> bool { + false + } + fn validate_segment(&self, segment: &str, _datafile: &DatafileContent) -> Result<(), String> { + if self.segment_defined(segment) { + Ok(()) + } else { + Err(format!( + "segment {segment:?} is missing from authored definitions" + )) + } + } + fn segment_matches(&self, segment: &str) -> Result { + Err(format!( + "segment {segment:?} is missing from authored definitions" + )) + } +} + +#[derive(Clone, Default)] +pub(super) struct CheckDiagnosticCollector(Arc>>); + +impl CheckDiagnosticCollector { + pub fn handler(&self) -> DiagnosticHandler { + let collector = self.clone(); + Arc::new(move |diagnostic: &Diagnostic| { + if matches!(diagnostic.level, LogLevel::Error | LogLevel::Fatal) { + collector + .0 + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(format!("{}: {}", diagnostic.code, diagnostic.message)); + } + }) + } + fn clear(&self) { + self.0 + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + } + fn take(&self) -> Result<(), String> { + let mut errors = self.0.lock().unwrap_or_else(|error| error.into_inner()); + if errors.is_empty() { + Ok(()) + } else { + let message = errors.join("; "); + errors.clear(); + Err(message) + } + } +} + +enum NativeCheckTarget<'a> { + Parent(&'a Featurevisor), + Child(&'a FeaturevisorChild), +} +pub(super) struct NativeCheckResolver<'a> { + target: NativeCheckTarget<'a>, + diagnostics: CheckDiagnosticCollector, + segment_evaluator: Option<&'a Featurevisor>, + segment_context: Option<&'a crate::Context>, +} +impl<'a> NativeCheckResolver<'a> { + pub fn parent(sdk: &'a Featurevisor, diagnostics: CheckDiagnosticCollector) -> Self { + Self { + target: NativeCheckTarget::Parent(sdk), + diagnostics, + segment_evaluator: None, + segment_context: None, + } + } + pub fn child(child: &'a FeaturevisorChild, diagnostics: CheckDiagnosticCollector) -> Self { + Self { + target: NativeCheckTarget::Child(child), + diagnostics, + segment_evaluator: None, + segment_context: None, + } + } + pub fn with_segments( + mut self, + evaluator: &'a Featurevisor, + context: &'a crate::Context, + ) -> Self { + self.segment_evaluator = Some(evaluator); + self.segment_context = Some(context); + self + } +} +impl CheckResolver for NativeCheckResolver<'_> { + fn global_variable(&self, variable: &str) -> Result, String> { + self.diagnostics.clear(); + let value = match self.target { + NativeCheckTarget::Parent(sdk) => sdk.get_global_variable(variable, None, None), + NativeCheckTarget::Child(child) => child.get_global_variable(variable, None, None), + }; + self.diagnostics.take()?; + Ok(value) + } + fn feature_variable( + &self, + feature: &str, + variable: &str, + ) -> Result, String> { + self.diagnostics.clear(); + let value = match self.target { + NativeCheckTarget::Parent(sdk) => sdk.get_variable(feature, variable, None, None), + NativeCheckTarget::Child(child) => child.get_variable(feature, variable, None, None), + }; + self.diagnostics.take()?; + Ok(value) + } + fn feature_enabled(&self, feature: &str) -> Result { + self.diagnostics.clear(); + let value = catch_unwind(AssertUnwindSafe(|| match self.target { + NativeCheckTarget::Parent(sdk) => sdk.is_enabled(feature, None), + NativeCheckTarget::Child(child) => child.is_enabled(feature, None), + })) + .map_err(panic_message)?; + self.diagnostics.take()?; + Ok(value) + } + fn feature_variation(&self, feature: &str) -> Result, String> { + self.diagnostics.clear(); + let value = catch_unwind(AssertUnwindSafe(|| match self.target { + NativeCheckTarget::Parent(sdk) => sdk.get_variation(feature, None, None), + NativeCheckTarget::Child(child) => child.get_variation(feature, None, None), + })) + .map_err(panic_message)?; + self.diagnostics.take()?; + Ok(value) + } + fn segment_defined(&self, segment: &str) -> bool { + self.segment_evaluator.is_some_and(|evaluator| { + evaluator + .live_state() + .data + .datafile + .segments + .contains_key(segment) + }) + } + fn validate_segment(&self, segment: &str, datafile: &DatafileContent) -> Result<(), String> { + let fallback = self.segment_evaluator.map(|e| e.live_state()); + validate_segment_dependencies( + segment, + datafile, + fallback + .as_ref() + .map(|s| &s.data.datafile.segments) + .unwrap_or(&datafile.segments), + ) + } + fn segment_matches(&self, segment: &str) -> Result { + let evaluator = self + .segment_evaluator + .ok_or_else(|| format!("segment {segment:?} is missing from authored definitions"))?; + let state = evaluator.live_state(); + let context = self.segment_context.cloned().unwrap_or_default(); + self.diagnostics.clear(); + let result = match self.target { + NativeCheckTarget::Parent(sdk) => { + sdk.segment_matches(segment, &state.data.datafile.segments, &context, None) + } + NativeCheckTarget::Child(child) => { + child.segment_matches(segment, &state.data.datafile.segments, &context) + } + }; + self.diagnostics.take()?; + result + } +} + +fn panic_message(panic: Box) -> String { + if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = panic.downcast_ref::() { + message.clone() + } else { + "unknown panic".to_string() + } +} + +#[derive(Clone, Debug, PartialEq)] +enum ResolvedValue { + Missing, + Value(JsonValue), +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct EvaluatedCheck { + pub path: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub reference: JsonValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub property: Option, + pub operator: String, + pub expected: JsonValue, + pub actual: JsonValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub actual_missing: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_missing: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compare_to: Option, +} + +#[derive(Clone, Debug)] +pub(super) struct CheckFailure { + pub message: String, + pub expected: JsonValue, + pub actual: JsonValue, + pub check_index: Option, + pub check_key: Option, + pub check_description: Option, + pub evaluated: Vec, +} + +#[cfg(test)] +pub(super) fn evaluate_assertion_checks( + assertion: &JsonValue, + owner_variable: Option<&str>, + resolver: &dyn CheckResolver, + datafile: &DatafileContent, + prefix: &str, +) -> Vec { + let outcome = + evaluate_assertion_checks_detailed(assertion, owner_variable, resolver, datafile, prefix); + let _had_error = outcome.had_error; + outcome.failures +} + +pub(super) struct CheckAssertionOutcome { + pub failures: Vec, + pub report_failures: Vec, + pub had_error: bool, +} + +pub(super) fn preflight_assertion_checks( + assertion: &JsonValue, + owner_variable: Option<&str>, + resolver: &dyn CheckResolver, + datafile: &DatafileContent, + prefix: &str, +) -> CheckAssertionOutcome { + let Some(raw_checks) = assertion.get("checks") else { + return CheckAssertionOutcome { + failures: Vec::new(), + report_failures: Vec::new(), + had_error: false, + }; + }; + let Some(checks) = raw_checks.as_array() else { + return CheckAssertionOutcome { + failures: vec![format!("{prefix}checks: expected a nonempty array")], + report_failures: vec![validation_failure( + format!("{prefix}checks: expected a nonempty array"), + raw_checks.clone(), + None, + )], + had_error: true, + }; + }; + if checks.is_empty() { + return CheckAssertionOutcome { + failures: vec![format!("{prefix}checks: expected a nonempty array")], + report_failures: vec![validation_failure( + format!("{prefix}checks: expected a nonempty array"), + raw_checks.clone(), + None, + )], + had_error: true, + }; + } + if let Err(error) = validate_sibling_keys(checks, &format!("{prefix}checks")) { + return CheckAssertionOutcome { + failures: vec![error.clone()], + report_failures: vec![validation_failure(error, raw_checks.clone(), None)], + had_error: true, + }; + } + for (index, check) in checks.iter().enumerate() { + if let Err(error) = + validate_check_shape(check, owner_variable, &format!("{prefix}checks[{index}]")) + { + return CheckAssertionOutcome { + failures: vec![error.clone()], + report_failures: vec![validation_failure(error, check.clone(), Some(index))], + had_error: true, + }; + } + } + for (index, check) in checks.iter().enumerate() { + if let Err(error) = validate_check_references( + check, + owner_variable, + resolver, + datafile, + &format!("{prefix}checks[{index}]"), + ) { + return CheckAssertionOutcome { + failures: vec![error.clone()], + report_failures: vec![validation_failure(error, check.clone(), Some(index))], + had_error: true, + }; + } + } + CheckAssertionOutcome { + failures: Vec::new(), + report_failures: Vec::new(), + had_error: false, + } +} + +pub(super) fn evaluate_assertion_checks_detailed( + assertion: &JsonValue, + owner_variable: Option<&str>, + resolver: &dyn CheckResolver, + datafile: &DatafileContent, + prefix: &str, +) -> CheckAssertionOutcome { + let preflight = + preflight_assertion_checks(assertion, owner_variable, resolver, datafile, prefix); + if preflight.had_error { + return preflight; + } + let Some(checks) = assertion.get("checks").and_then(JsonValue::as_array) else { + return preflight; + }; + let mut failures = Vec::new(); + let mut report_failures = Vec::new(); + let mut had_error = false; + for (index, check) in checks.iter().enumerate() { + let path = format!("{prefix}checks[{index}]"); + let mut evaluated = Vec::new(); + match evaluate_check( + check, + owner_variable, + resolver, + datafile, + &path, + &[JsonValue::from(index)], + &mut evaluated, + ) { + Ok((true, _)) => {} + Ok((false, failure)) => { + let message = with_check_metadata(check, failure); + failures.push(message.clone()); + report_failures.push(check_failure( + check, + index, + message, + JsonValue::Bool(true), + JsonValue::Bool(false), + evaluated, + )); + } + Err(error) => { + let message = with_check_metadata(check, error); + failures.push(message.clone()); + report_failures.push(check_failure( + check, + index, + message.clone(), + JsonValue::String("successful comparison".to_string()), + JsonValue::String(message), + evaluated, + )); + had_error = true; + } + } + } + CheckAssertionOutcome { + failures, + report_failures, + had_error, + } +} + +fn validate_check_references( + check: &JsonValue, + owner_variable: Option<&str>, + resolver: &dyn CheckResolver, + datafile: &DatafileContent, + path: &str, +) -> Result<(), String> { + let object = check.as_object().expect("check shape was validated"); + for group in ["and", "or", "not"] { + if let Some(children) = object.get(group).and_then(JsonValue::as_array) { + for (index, child) in children.iter().enumerate() { + validate_check_references( + child, + owner_variable, + resolver, + datafile, + &format!("{path}.{group}[{index}]"), + )?; + } + return Ok(()); + } + } + if let Some(segment) = object.get("segment").and_then(JsonValue::as_str) { + resolver + .validate_segment(segment, datafile) + .map_err(|error| format!("{path}: {error}"))?; + return Ok(()); + } + if object.contains_key("enabled") || object.contains_key("variation") { + let feature = object["feature"] + .as_str() + .expect("feature shape was validated"); + if !datafile.features.contains_key(feature) { + return Err(format!( + "{path}: feature {feature:?} is absent from the selected datafile" + )); + } + return Ok(()); + } + validate_reference_membership(check, owner_variable, datafile) + .map_err(|error| format!("{path}: {error}"))?; + if let Some(compare_to) = object.get("compareTo") { + validate_reference_membership(compare_to, owner_variable, datafile) + .map_err(|error| format!("{path}.compareTo: {error}"))?; + } + Ok(()) +} + +pub(super) fn validate_segment_dependencies( + key: &str, + datafile: &DatafileContent, + segments: &std::collections::HashMap, +) -> Result<(), String> { + // Membership is checked across the complete graph before any getter runs. + let mut pending = vec![serde_json::json!({"segment":key})]; + let mut seen = std::collections::HashSet::new(); + fn leaves(value: &JsonValue, pending: &mut Vec, features: bool) { + match value { + JsonValue::String(key) if key != "*" => { + if matches!(key.trim_start().chars().next(), Some('{' | '[' | '"')) { + if let Ok(value) = serde_json::from_str::(key) { + leaves(&value, pending, features); + } + } else { + pending.push(if features { + serde_json::json!({"feature":key}) + } else { + serde_json::json!({"segment":key}) + }); + } + } + JsonValue::Array(values) => { + for value in values { + leaves(value, pending, features); + } + } + JsonValue::Object(object) => { + if object.contains_key("variable") || object.contains_key("feature") { + pending.push(value.clone()); + } else if !object.contains_key("attribute") { + for group in ["and", "or", "not"] { + if let Some(value) = object.get(group) { + leaves(value, pending, features); + } + } + } + } + _ => {} + } + } + fn selectors(value: &JsonValue, pending: &mut Vec) { + for name in ["conditions", "segments", "requiredFeatures"] { + if let Some(value) = value.get(name) { + leaves(value, pending, name == "requiredFeatures"); + } + } + } + while let Some(reference) = pending.pop() { + let segment = reference.get("segment").and_then(JsonValue::as_str); + let feature = reference.get("feature").and_then(JsonValue::as_str); + let variable = reference.get("variable").and_then(JsonValue::as_str); + if !seen.insert(( + segment.map(str::to_string), + feature.map(str::to_string), + variable.map(str::to_string), + )) { + continue; + } + if let Some(key) = segment { + let segment = segments.get(key).or_else(|| datafile.segments.get(key)).ok_or_else(|| format!("segment {key:?} is missing from authored definitions and selected datafile"))?; + leaves(&segment.conditions, &mut pending, false); + } else if let Some(key) = feature { + let feature = datafile + .features + .get(key) + .ok_or_else(|| format!("feature {key:?} is absent from the selected datafile"))?; + if let Some(variable) = variable { + if !feature + .variables_schema + .as_ref() + .is_some_and(|s| s.contains_key(variable)) + { + return Err(format!("feature variable {key:?}.{variable:?} is absent from the selected datafile")); + } + } + let value = serde_json::to_value(feature).expect("feature"); + selectors(&value, &mut pending); + for name in ["force", "traffic", "variations"] { + for entry in value[name].as_array().into_iter().flatten() { + selectors(entry, &mut pending); + if let Some(variable) = variable { + for entry in entry["variableOverrides"][variable] + .as_array() + .into_iter() + .flatten() + { + selectors(entry, &mut pending); + } + } + } + } + } else if let Some(key) = variable { + let variable = datafile.variables.get(key).ok_or_else(|| { + format!("global variable {key:?} is absent from the selected datafile") + })?; + let value = serde_json::to_value(variable).expect("variable"); + selectors(&value, &mut pending); + for name in ["overrides", "overrideProgram"] { + for entry in value[name].as_array().into_iter().flatten() { + selectors(entry, &mut pending); + } + } + if let Some(key) = value["compose"].as_str() { + pending.push(serde_json::json!({"variable":key})); + } else { + pending.extend(value["compose"].as_array().into_iter().flatten().cloned()); + } + } + } + Ok(()) +} + +fn validate_reference_membership( + reference: &JsonValue, + owner_variable: Option<&str>, + datafile: &DatafileContent, +) -> Result<(), String> { + let object = reference + .as_object() + .expect("reference shape was validated"); + let feature = object.get("feature").and_then(JsonValue::as_str); + let variable = object + .get("variable") + .and_then(JsonValue::as_str) + .or(owner_variable) + .expect("variable shape was validated"); + if let Some(feature_key) = feature { + let feature = datafile.features.get(feature_key).ok_or_else(|| { + format!("feature {feature_key:?} is absent from the selected datafile") + })?; + if !feature + .variables_schema + .as_ref() + .is_some_and(|variables| variables.contains_key(variable)) + { + return Err(format!( + "feature variable {feature_key:?}.{variable:?} is absent from the selected datafile" + )); + } + } else if !datafile.variables.contains_key(variable) { + return Err(format!( + "global variable {variable:?} is absent from the selected datafile" + )); + } + Ok(()) +} + +fn validation_failure( + message: String, + actual: JsonValue, + check_index: Option, +) -> CheckFailure { + CheckFailure { + message, + expected: JsonValue::String("valid checks".to_string()), + actual, + check_index, + check_key: None, + check_description: None, + evaluated: Vec::new(), + } +} + +fn check_failure( + check: &JsonValue, + check_index: usize, + message: String, + expected: JsonValue, + actual: JsonValue, + evaluated: Vec, +) -> CheckFailure { + CheckFailure { + message, + expected, + actual, + check_index: Some(check_index), + check_key: check + .get("key") + .and_then(JsonValue::as_str) + .map(str::to_string), + check_description: check + .get("description") + .and_then(JsonValue::as_str) + .map(str::to_string), + evaluated, + } +} + +fn validate_sibling_keys(checks: &[JsonValue], path: &str) -> Result<(), String> { + let mut keys = std::collections::HashSet::new(); + for (index, check) in checks.iter().enumerate() { + let Some(object) = check.as_object() else { + continue; + }; + if let Some(key) = object.get("key").and_then(JsonValue::as_str) { + if !keys.insert(key) { + return Err(format!( + "{path}[{index}].key: duplicate sibling check key {key:?}" + )); + } + } + for group in ["and", "or", "not"] { + if let Some(children) = object.get(group).and_then(JsonValue::as_array) { + validate_sibling_keys(children, &format!("{path}[{index}].{group}"))?; + } + } + } + Ok(()) +} + +fn validate_metadata( + object: &serde_json::Map, + path: &str, +) -> Result<(), String> { + if object.contains_key("key") + && object + .get("key") + .and_then(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .is_none() + { + return Err(format!("{path}.key: expected a nonempty string")); + } + if object.contains_key("description") && !object["description"].is_string() { + return Err(format!("{path}.description: expected a string")); + } + Ok(()) +} + +fn with_check_metadata(check: &JsonValue, failure: String) -> String { + let Some(object) = check.as_object() else { + return failure; + }; + let mut metadata = Vec::new(); + if let Some(key) = object.get("key").and_then(JsonValue::as_str) { + metadata.push(format!("key={key:?}")); + } + if let Some(description) = object.get("description").and_then(JsonValue::as_str) { + metadata.push(format!("description={description:?}")); + } + if metadata.is_empty() { + failure + } else { + format!("{}: {failure}", metadata.join(" ")) + } +} + +fn validate_check_shape( + check: &JsonValue, + owner_variable: Option<&str>, + path: &str, +) -> Result<(), String> { + let object = check + .as_object() + .ok_or_else(|| format!("{path}: check must be an object"))?; + validate_metadata(object, path)?; + let groups = ["and", "or", "not"] + .into_iter() + .filter(|key| object.contains_key(*key)) + .collect::>(); + if !groups.is_empty() { + if groups.len() != 1 + || object + .keys() + .any(|key| ![groups[0], "key", "description"].contains(&key.as_str())) + { + return Err(format!( + "{path}: group must contain exactly one of and, or, or not" + )); + } + let group = groups[0]; + let children = object[group] + .as_array() + .filter(|items| !items.is_empty()) + .ok_or_else(|| format!("{path}.{group}: expected a nonempty array"))?; + validate_sibling_keys(children, &format!("{path}.{group}"))?; + for (index, child) in children.iter().enumerate() { + validate_check_shape(child, owner_variable, &format!("{path}.{group}[{index}]"))?; + } + return Ok(()); + } + if object.contains_key("segment") || object.contains_key("matches") { + return validate_segment_check_shape(object, path); + } + if object.contains_key("enabled") || object.contains_key("variation") { + return validate_feature_check_shape(object, path); + } + if object.contains_key("feature") + && !object.contains_key("variable") + && !object.contains_key("operator") + { + return Err(format!( + "{path}: feature check requires enabled or variation" + )); + } + let allowed = [ + "variable", + "feature", + "path", + "operator", + "value", + "compareTo", + "regexFlags", + "key", + "description", + ]; + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(format!("{path}: unknown property {key:?}")); + } + let operator = object + .get("operator") + .and_then(JsonValue::as_str) + .filter(|value| known_operator(value)) + .ok_or_else(|| { + format!( + "{path}: unknown operator {}", + object.get("operator").unwrap_or(&JsonValue::Null) + ) + })?; + validate_reference_shape(check, false, owner_variable) + .map_err(|error| format!("{path}: {error}"))?; + let has_value = object.contains_key("value"); + let has_compare = object.contains_key("compareTo"); + let value_less = matches!( + operator, + "exists" | "notExists" | "isNull" | "notNull" | "empty" | "notEmpty" + ); + if value_less && (has_value || has_compare) { + return Err(format!( + "{path}: {operator} accepts neither value nor compareTo" + )); + } + if !value_less && has_value == has_compare { + return Err(format!( + "{path}: exactly one of value or compareTo is required" + )); + } + if has_compare { + let reference = object + .get("compareTo") + .filter(|value| value.is_object()) + .ok_or_else(|| format!("{path}.compareTo: reference must be an object"))?; + validate_reference_shape(reference, true, owner_variable) + .map_err(|error| format!("{path}.compareTo: {error}"))?; + } else if has_value { + validate_literal( + operator, + object.get("value").unwrap_or(&JsonValue::Null), + object.get("regexFlags").and_then(JsonValue::as_str), + ) + .map_err(|error| format!("{path}: {error}"))?; + } + if let Some(flags) = object.get("regexFlags") { + if !matches!(operator, "matches" | "notMatches") { + return Err(format!( + "{path}: regexFlags are only allowed with regex operators" + )); + } + let flags = flags + .as_str() + .ok_or_else(|| format!("{path}: regexFlags must be a string"))?; + let mut seen = Vec::new(); + for flag in flags.chars() { + if !"gims".contains(flag) || seen.contains(&flag) { + return Err(format!("{path}: invalid or duplicate regex flag {flag:?}")); + } + seen.push(flag); + } + } + Ok(()) +} + +fn validate_segment_check_shape( + object: &serde_json::Map, + path: &str, +) -> Result<(), String> { + let allowed = ["segment", "matches", "key", "description"]; + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(format!( + "{path}: segment check has unexpected property {key:?}" + )); + } + if object + .get("segment") + .and_then(JsonValue::as_str) + .filter(|segment| !segment.is_empty()) + .is_none() + { + return Err(format!("{path}: segment must be a nonempty string")); + } + if !object.get("matches").is_some_and(JsonValue::is_boolean) { + return Err(format!("{path}: matches must be a boolean")); + } + Ok(()) +} + +fn validate_feature_check_shape( + object: &serde_json::Map, + path: &str, +) -> Result<(), String> { + let allowed = ["feature", "enabled", "variation", "key", "description"]; + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(format!( + "{path}: feature check has unexpected property {key:?}" + )); + } + if object + .get("feature") + .and_then(JsonValue::as_str) + .filter(|feature| !feature.is_empty()) + .is_none() + { + return Err(format!("{path}: feature must be a nonempty string")); + } + if !object.contains_key("enabled") && !object.contains_key("variation") { + return Err(format!( + "{path}: feature check requires enabled or variation" + )); + } + if object.contains_key("enabled") && !object["enabled"].is_boolean() { + return Err(format!("{path}: enabled must be a boolean")); + } + if object.contains_key("variation") + && !object["variation"].is_string() + && !object["variation"].is_null() + { + return Err(format!("{path}: variation must be a string or null")); + } + Ok(()) +} + +fn validate_reference_shape( + reference: &JsonValue, + nested: bool, + owner_variable: Option<&str>, +) -> Result<(), String> { + let object = reference + .as_object() + .ok_or_else(|| "reference must be an object".to_string())?; + let leaf_allowed = [ + "variable", + "feature", + "path", + "operator", + "value", + "compareTo", + "regexFlags", + "key", + "description", + ]; + let nested_allowed = ["variable", "feature", "path"]; + let allowed = if nested { + &nested_allowed[..] + } else { + &leaf_allowed[..] + }; + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(format!("unknown reference property {key:?}")); + } + if let Some(feature) = object.get("feature") { + if feature.as_str().filter(|value| !value.is_empty()).is_none() { + return Err("feature must be a nonempty string".to_string()); + } + if object + .get("variable") + .and_then(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .is_none() + { + return Err("variable is required for a feature reference".to_string()); + } + } + if object.contains_key("variable") + && object + .get("variable") + .and_then(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .is_none() + { + return Err("variable must be a nonempty string".to_string()); + } + if !object.contains_key("variable") + && (owner_variable.is_none() || object.contains_key("feature")) + { + return Err( + "an explicit variable is required in a scenario or feature reference".to_string(), + ); + } + parse_path(object.get("path"))?; + Ok(()) +} + +fn validate_literal( + operator: &str, + value: &JsonValue, + regex_flags: Option<&str>, +) -> Result<(), String> { + match operator { + "greaterThan" | "greaterThanOrEquals" | "lessThan" | "lessThanOrEquals" => { + if !value.is_number() { + return Err("numeric comparison requires a finite number".to_string()); + } + } + "contains" + | "notContains" + | "startsWith" + | "endsWith" + | "semverEquals" + | "semverNotEquals" + | "semverGreaterThan" + | "semverGreaterThanOrEquals" + | "semverLessThan" + | "semverLessThanOrEquals" => { + if !value.is_string() { + return Err("string comparison requires a string".to_string()); + } + } + "matches" | "notMatches" => { + let pattern = value + .as_str() + .ok_or_else(|| "string comparison requires a string".to_string())?; + portable_regex(pattern, regex_flags.unwrap_or(""))?; + } + "before" | "after" => { + let value = value.as_str().ok_or_else(|| { + "date comparison requires a timestamp string with a timezone".to_string() + })?; + parse_portable_date(value)?; + } + "in" | "notIn" | "includesAny" | "notIncludesAny" => { + if !value + .as_array() + .is_some_and(|items| items.iter().all(primitive)) + { + return Err( + "membership comparison requires an array of primitive values".to_string(), + ); + } + } + _ if !primitive(value) => { + return Err("comparison requires a primitive value".to_string()); + } + _ => {} + } + Ok(()) +} + +fn portable_regex(pattern: &str, flags: &str) -> Result { + let unsupported_reference = regex::Regex::new(r"\\(?:[1-9]|k<|k'|g<|g')").unwrap(); + let unsupported_possessive = regex::Regex::new(r"(?:[?*+]|\{\d+(?:,\d*)?\})\+").unwrap(); + if pattern.contains("(?") + || unsupported_reference.is_match(pattern) + || unsupported_possessive.is_match(pattern) + { + return Err("unsupported portable regular expression".to_string()); + } + let mut builder = RegexBuilder::new(pattern); + for flag in flags.chars() { + match flag { + 'g' => {} + 'i' => { + builder.case_insensitive(true); + } + 'm' => { + builder.multi_line(true); + } + 's' => { + builder.dot_matches_new_line(true); + } + _ => return Err(format!("unsupported regex flag {flag:?}")), + } + } + builder + .build() + .map_err(|error| format!("invalid regular expression: {error}")) +} + +fn parse_portable_date(value: &str) -> Result, String> { + let pattern = regex::Regex::new( + r"^([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}))$", + ) + .unwrap(); + let captures = pattern + .captures(value) + .ok_or_else(|| "date comparison requires a portable timestamp".to_string())?; + let year = captures[1].parse::().unwrap_or_default(); + if !(1..=9999).contains(&year) { + return Err("date comparison year is out of range".to_string()); + } + let hour = captures[4].parse::().unwrap_or(u32::MAX); + let minute = captures[5].parse::().unwrap_or(u32::MAX); + let second = captures[6].parse::().unwrap_or(u32::MAX); + if hour > 23 || minute > 59 || second > 59 { + return Err("date comparison time is out of range".to_string()); + } + if &captures[7] != "Z" { + let hours = captures[9].parse::().unwrap_or(u32::MAX); + let minutes = captures[10].parse::().unwrap_or(u32::MAX); + if hours > 14 || minutes > 59 || (hours == 14 && minutes != 0) { + return Err("date comparison timezone offset is out of range".to_string()); + } + } + let parsed = DateTime::parse_from_rfc3339(value) + .map_err(|_| "date comparison requires a real Gregorian timestamp".to_string())?; + let utc_year = parsed.to_utc().year(); + if !(1..=9999).contains(&utc_year) { + return Err("date comparison UTC instant is out of range".to_string()); + } + Ok(parsed + .with_nanosecond((parsed.nanosecond() / 1_000_000) * 1_000_000) + .unwrap()) +} + +fn known_operator(operator: &str) -> bool { + matches!( + operator, + "equals" + | "notEquals" + | "before" + | "after" + | "in" + | "notIn" + | "contains" + | "notContains" + | "startsWith" + | "endsWith" + | "greaterThan" + | "greaterThanOrEquals" + | "lessThan" + | "lessThanOrEquals" + | "semverEquals" + | "semverNotEquals" + | "semverGreaterThan" + | "semverGreaterThanOrEquals" + | "semverLessThan" + | "semverLessThanOrEquals" + | "matches" + | "notMatches" + | "exists" + | "notExists" + | "includes" + | "notIncludes" + | "includesAny" + | "notIncludesAny" + | "isNull" + | "notNull" + | "empty" + | "notEmpty" + ) +} + +fn evaluate_check( + check: &JsonValue, + owner_variable: Option<&str>, + resolver: &dyn CheckResolver, + datafile: &DatafileContent, + check_path: &str, + trace_path: &[JsonValue], + evaluated: &mut Vec, +) -> Result<(bool, String), String> { + let object = check + .as_object() + .ok_or_else(|| format!("{check_path}: check must be an object"))?; + let groups = ["and", "or", "not"] + .into_iter() + .filter(|key| object.contains_key(*key)) + .collect::>(); + if !groups.is_empty() { + if groups.len() != 1 + || object + .keys() + .any(|key| ![groups[0], "key", "description"].contains(&key.as_str())) + { + return Err(format!( + "{check_path}: group must contain exactly one of and, or, or not" + )); + } + let operator = groups[0]; + let children = object[operator] + .as_array() + .filter(|children| !children.is_empty()) + .ok_or_else(|| format!("{check_path}.{operator}: expected a nonempty array"))?; + return match operator { + "and" => { + for (index, child) in children.iter().enumerate() { + let result = evaluate_check( + child, + owner_variable, + resolver, + datafile, + &format!("{check_path}.and[{index}]"), + &trace_child_path(trace_path, "and", index), + evaluated, + ) + .map_err(|error| with_check_metadata(child, error))?; + if !result.0 { + return Ok((false, with_check_metadata(child, result.1))); + } + } + Ok((true, String::new())) + } + "or" => { + let mut failures = Vec::new(); + for (index, child) in children.iter().enumerate() { + let (matched, failure) = evaluate_check( + child, + owner_variable, + resolver, + datafile, + &format!("{check_path}.or[{index}]"), + &trace_child_path(trace_path, "or", index), + evaluated, + ) + .map_err(|error| with_check_metadata(child, error))?; + if matched { + return Ok((true, String::new())); + } + failures.push(with_check_metadata(child, failure)); + } + Ok(( + false, + format!( + "{check_path}: no OR branch matched ({})", + failures.join("; ") + ), + )) + } + "not" => { + for (index, child) in children.iter().enumerate() { + let (matched, _) = evaluate_check( + child, + owner_variable, + resolver, + datafile, + &format!("{check_path}.not[{index}]"), + &trace_child_path(trace_path, "not", index), + evaluated, + ) + .map_err(|error| with_check_metadata(child, error))?; + if !matched { + return Ok((true, String::new())); + } + } + Ok((false, format!("{check_path}: NOT group matched"))) + } + _ => unreachable!(), + }; + } + + if object.contains_key("segment") || object.contains_key("matches") { + return evaluate_segment_check(object, resolver, check_path, trace_path, evaluated); + } + + if object.contains_key("enabled") || object.contains_key("variation") { + return evaluate_feature_check( + object, resolver, datafile, check_path, trace_path, evaluated, + ); + } + if object.contains_key("feature") && !object.contains_key("variable") { + return Err(format!( + "{check_path}: feature check requires enabled or variation" + )); + } + + let operator = object + .get("operator") + .and_then(JsonValue::as_str) + .filter(|operator| !operator.is_empty()) + .ok_or_else(|| format!("{check_path}: leaf is missing operator"))?; + let trace_index = evaluated.len(); + evaluated.push(EvaluatedCheck { + path: trace_path.to_vec(), + key: string_field(object, "key"), + description: string_field(object, "description"), + reference: report_reference(check, owner_variable), + property: None, + operator: operator.to_string(), + expected: object.get("value").cloned().unwrap_or_else(undefined_value), + actual: JsonValue::String("".to_string()), + actual_missing: Some(false), + expected_missing: Some(false), + compare_to: object + .get("compareTo") + .map(|reference| report_reference(reference, owner_variable)), + }); + let left_reference = reference_label(check, owner_variable); + let left = resolve_reference(check, owner_variable, resolver, datafile) + .map_err(|error| format!("{check_path} left {left_reference}: {error}"))?; + evaluated[trace_index].actual = report_resolved_value(&left); + evaluated[trace_index].actual_missing = Some(matches!(left, ResolvedValue::Missing)); + let has_value = object.contains_key("value"); + let has_compare_to = object.contains_key("compareTo"); + let value_less = matches!( + operator, + "exists" | "notExists" | "isNull" | "notNull" | "empty" | "notEmpty" + ); + if value_less && (has_value || has_compare_to) { + return Err(format!( + "{check_path}: {operator} accepts neither value nor compareTo" + )); + } + if !value_less && has_value == has_compare_to { + return Err(format!( + "{check_path}: exactly one of value or compareTo is required" + )); + } + let (right, right_reference) = if has_compare_to { + let reference = object + .get("compareTo") + .filter(|value| value.is_object()) + .ok_or_else(|| format!("{check_path}.compareTo: reference must be an object"))?; + let label = reference_label(reference, owner_variable); + let value = resolve_reference(reference, owner_variable, resolver, datafile) + .map_err(|error| format!("{check_path} right {label}: {error}"))?; + (value, label) + } else { + ( + ResolvedValue::Value(object.get("value").cloned().unwrap_or(JsonValue::Null)), + "literal".to_string(), + ) + }; + if has_value || has_compare_to { + evaluated[trace_index].expected = report_resolved_value(&right); + evaluated[trace_index].expected_missing = Some(matches!(right, ResolvedValue::Missing)); + } + let matched = match_values( + &left, + operator, + &right, + object.get("regexFlags").and_then(JsonValue::as_str), + ) + .map_err(|error| { + format!( + "{check_path}: {left_reference} against {right_reference} failed: {error} (left={}, right={})", + display_value(&left), + display_value(&right) + ) + })?; + if matched { + Ok((true, String::new())) + } else { + Ok(( + false, + format!( + "{check_path}: {left_reference} {operator} {right_reference} did not match (left={}, right={})", + display_value(&left), + display_value(&right) + ), + )) + } +} + +fn evaluate_segment_check( + object: &serde_json::Map, + resolver: &dyn CheckResolver, + check_path: &str, + trace_path: &[JsonValue], + evaluated: &mut Vec, +) -> Result<(bool, String), String> { + let segment = object + .get("segment") + .and_then(JsonValue::as_str) + .filter(|segment| !segment.is_empty()) + .ok_or_else(|| format!("{check_path}: segment must be a nonempty string"))?; + let expected = object + .get("matches") + .and_then(JsonValue::as_bool) + .ok_or_else(|| format!("{check_path}: matches must be a boolean"))?; + let actual = resolver.segment_matches(segment)?; + evaluated.push(EvaluatedCheck { + path: trace_path.to_vec(), + key: string_field(object, "key"), + description: string_field(object, "description"), + reference: serde_json::json!({"segment": segment}), + property: Some("matches".to_string()), + operator: "equals".to_string(), + expected: JsonValue::Bool(expected), + actual: JsonValue::Bool(actual), + actual_missing: None, + expected_missing: None, + compare_to: None, + }); + if actual == expected { + Ok((true, String::new())) + } else { + Ok(( + false, + format!( + "{check_path}: segment {segment:?} expected matches={expected}, actual matches={actual}" + ), + )) + } +} + +fn evaluate_feature_check( + object: &serde_json::Map, + resolver: &dyn CheckResolver, + datafile: &DatafileContent, + check_path: &str, + trace_path: &[JsonValue], + evaluated: &mut Vec, +) -> Result<(bool, String), String> { + let feature = object + .get("feature") + .and_then(JsonValue::as_str) + .filter(|feature| !feature.is_empty()) + .ok_or_else(|| format!("{check_path}: feature must be a nonempty string"))?; + if !datafile.features.contains_key(feature) { + return Err(format!( + "{check_path}: feature {feature:?} is absent from the selected datafile" + )); + } + let mut matched = true; + let mut details = Vec::new(); + if let Some(expected) = object.get("enabled") { + let expected = expected + .as_bool() + .ok_or_else(|| format!("{check_path}: enabled must be a boolean"))?; + let trace_index = evaluated.len(); + evaluated.push(feature_trace( + object, + trace_path, + feature, + "enabled", + JsonValue::Bool(expected), + )); + let actual = resolver.feature_enabled(feature).map_err(|error| { + format!("{check_path}: feature {feature:?} enabled getter failed: {error}") + })?; + evaluated[trace_index].actual = JsonValue::Bool(actual); + details.push(format!( + "expected enabled={expected}, actual enabled={actual}" + )); + if actual != expected { + matched = false; + } + } + if let Some(expected) = object.get("variation") { + let trace_index = evaluated.len(); + evaluated.push(feature_trace( + object, + trace_path, + feature, + "variation", + expected.clone(), + )); + let actual = resolver.feature_variation(feature).map_err(|error| { + format!("{check_path}: feature {feature:?} variation getter failed: {error}") + })?; + evaluated[trace_index].actual = actual + .as_ref() + .map(|value| JsonValue::String(value.clone())) + .unwrap_or(JsonValue::Null); + let variation_matches = match expected.as_str() { + Some(expected) => actual.as_deref() == Some(expected), + None if expected.is_null() => actual.is_none(), + None => false, + }; + details.push(format!( + "expected variation={}, actual variation={}", + expected, + actual + .as_ref() + .map(|value| JsonValue::String(value.clone()).to_string()) + .unwrap_or_else(|| "null".to_string()) + )); + if !variation_matches { + matched = false; + } + } + if matched { + Ok((true, String::new())) + } else { + Ok(( + false, + format!("{check_path}: feature {feature:?} {}", details.join("; ")), + )) + } +} + +fn trace_child_path(path: &[JsonValue], group: &str, index: usize) -> Vec { + let mut child = path.to_vec(); + child.push(JsonValue::String(group.to_string())); + child.push(JsonValue::from(index)); + child +} + +fn string_field(object: &serde_json::Map, key: &str) -> Option { + object + .get(key) + .and_then(JsonValue::as_str) + .map(str::to_string) +} + +fn undefined_value() -> JsonValue { + serde_json::json!({"$type": "undefined"}) +} + +fn report_resolved_value(value: &ResolvedValue) -> JsonValue { + match value { + ResolvedValue::Missing => undefined_value(), + ResolvedValue::Value(value) => value.clone(), + } +} + +fn report_reference(reference: &JsonValue, owner_variable: Option<&str>) -> JsonValue { + let mut reported = serde_json::Map::new(); + if let Some(variable) = reference + .get("variable") + .and_then(JsonValue::as_str) + .or(owner_variable) + { + reported.insert( + "variable".to_string(), + JsonValue::String(variable.to_string()), + ); + } + for key in ["feature", "path"] { + if let Some(value) = reference.get(key) { + reported.insert(key.to_string(), value.clone()); + } + } + JsonValue::Object(reported) +} + +fn feature_trace( + object: &serde_json::Map, + path: &[JsonValue], + feature: &str, + property: &str, + expected: JsonValue, +) -> EvaluatedCheck { + EvaluatedCheck { + path: path.to_vec(), + key: string_field(object, "key"), + description: string_field(object, "description"), + reference: serde_json::json!({"feature": feature}), + property: Some(property.to_string()), + operator: "equals".to_string(), + expected, + actual: JsonValue::String("".to_string()), + actual_missing: None, + expected_missing: None, + compare_to: None, + } +} + +fn resolve_reference( + reference: &JsonValue, + owner_variable: Option<&str>, + resolver: &dyn CheckResolver, + datafile: &DatafileContent, +) -> Result { + let object = reference + .as_object() + .ok_or_else(|| "reference must be an object".to_string())?; + let feature = object.get("feature").and_then(JsonValue::as_str); + let variable = object + .get("variable") + .and_then(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .or(owner_variable) + .ok_or_else(|| "variable is required".to_string())?; + if feature.is_some() && object.get("variable").and_then(JsonValue::as_str).is_none() { + return Err("variable is required for a feature reference".to_string()); + } + let value = if let Some(feature_key) = feature { + let feature = datafile.features.get(feature_key).ok_or_else(|| { + format!("feature {feature_key:?} is absent from the selected datafile") + })?; + if !feature + .variables_schema + .as_ref() + .is_some_and(|variables| variables.contains_key(variable)) + { + return Err(format!( + "feature variable {feature_key:?}.{variable:?} is absent from the selected datafile" + )); + } + resolver.feature_variable(feature_key, variable)? + } else { + if !datafile.variables.contains_key(variable) { + return Err(format!( + "global variable {variable:?} is absent from the selected datafile" + )); + } + resolver.global_variable(variable)? + }; + let mut value = ResolvedValue::Value( + value + .map(|value| value.to_json()) + .unwrap_or(JsonValue::Null), + ); + for part in parse_path(object.get("path"))? { + value = match (value, part) { + (ResolvedValue::Value(JsonValue::Object(object)), PathPart::Property(key)) => object + .get(&key) + .cloned() + .map(ResolvedValue::Value) + .unwrap_or(ResolvedValue::Missing), + (ResolvedValue::Value(JsonValue::Array(array)), PathPart::Property(key)) => { + if key == "length" { + ResolvedValue::Value(JsonValue::from(array.len())) + } else if let Some(index) = canonical_index(&key) { + array + .get(index) + .cloned() + .map(ResolvedValue::Value) + .unwrap_or(ResolvedValue::Missing) + } else { + ResolvedValue::Missing + } + } + (ResolvedValue::Value(JsonValue::Array(array)), PathPart::Index(index)) => array + .get(index) + .cloned() + .map(ResolvedValue::Value) + .unwrap_or(ResolvedValue::Missing), + _ => ResolvedValue::Missing, + }; + if value == ResolvedValue::Missing { + break; + } + } + Ok(value) +} + +enum PathPart { + Property(String), + Index(usize), +} + +fn parse_path(raw: Option<&JsonValue>) -> Result, String> { + let unsafe_key = |value: &str| matches!(value, "__proto__" | "prototype" | "constructor"); + let Some(raw) = raw else { + return Ok(Vec::new()); + }; + if let Some(path) = raw.as_str() { + if path.is_empty() { + return Err("path must not be empty".to_string()); + } + return path + .split('.') + .map(|part| { + if part.is_empty() || part.contains('*') || unsafe_key(part) { + Err(format!("path contains invalid component {part:?}")) + } else if canonical_index_syntax(part) { + let index = part + .parse::() + .map_err(|_| "path index must be a safe integer".to_string())?; + if index > 9_007_199_254_740_991 { + Err("path index must be a safe integer".to_string()) + } else { + Ok(PathPart::Index(index as usize)) + } + } else { + Ok(PathPart::Property(part.to_string())) + } + }) + .collect(); + } + let values = raw + .as_array() + .ok_or_else(|| "path must be a string or array".to_string())?; + values + .iter() + .map(|value| { + if let Some(property) = value.as_str() { + if property.contains('*') || unsafe_key(property) { + Err(format!("path contains invalid component {property:?}")) + } else { + Ok(PathPart::Property(property.to_string())) + } + } else if let Some(index) = value.as_u64() { + if index > 9_007_199_254_740_991 { + return Err(format!("path contains invalid array index {index}")); + } + usize::try_from(index) + .map(PathPart::Index) + .map_err(|_| format!("path contains invalid array index {index}")) + } else { + Err(format!("path contains unsupported component {value}")) + } + }) + .collect() +} + +fn canonical_index(value: &str) -> Option { + if !canonical_index_syntax(value) { + return None; + } + value + .parse::() + .ok() + .filter(|index| *index <= 9_007_199_254_740_991) + .and_then(|index| usize::try_from(index).ok()) +} + +fn canonical_index_syntax(value: &str) -> bool { + value == "0" + || (!value.starts_with('0') + && !value.is_empty() + && value.chars().all(|character| character.is_ascii_digit())) +} + +fn match_values( + left: &ResolvedValue, + operator: &str, + right: &ResolvedValue, + regex_flags: Option<&str>, +) -> Result { + if operator == "exists" { + return Ok(!matches!(left, ResolvedValue::Missing)); + } + if operator == "notExists" { + return Ok(matches!(left, ResolvedValue::Missing)); + } + if operator == "isNull" { + return Ok(matches!(left, ResolvedValue::Value(JsonValue::Null))); + } + if operator == "notNull" { + return Ok(matches!(left, ResolvedValue::Value(value) if !value.is_null())); + } + if matches!(operator, "empty" | "notEmpty") { + let is_empty = match left { + ResolvedValue::Missing | ResolvedValue::Value(JsonValue::Null) => return Ok(false), + ResolvedValue::Value(JsonValue::String(value)) => value.is_empty(), + ResolvedValue::Value(JsonValue::Array(value)) => value.is_empty(), + ResolvedValue::Value(JsonValue::Object(value)) => value.is_empty(), + ResolvedValue::Value(_) => { + return Err(format!( + "{operator} requires a string, array, or object operand" + )); + } + }; + return Ok(if operator == "empty" { + is_empty + } else { + !is_empty + }); + } + let (ResolvedValue::Value(left), ResolvedValue::Value(right)) = (left, right) else { + return Ok(false); + }; + match operator { + "equals" => { + if !primitive(left) || !primitive(right) { + return Err("equals requires scalar operands".to_string()); + } + Ok(scalar_equal(left, right)) + } + "notEquals" => { + if !primitive(left) || !primitive(right) { + return Err("notEquals requires scalar operands".to_string()); + } + Ok(!scalar_equal(left, right)) + } + "in" | "notIn" => { + if !string_number_or_null(left) { + return Err(format!( + "{operator} requires a string, number, or null left operand" + )); + } + let values = right + .as_array() + .ok_or_else(|| format!("{operator} requires an array right operand"))?; + let found = values.iter().any(|value| scalar_equal(left, value)); + Ok(if operator == "in" { found } else { !found }) + } + "includesAny" | "notIncludesAny" => { + let actual = left + .as_array() + .ok_or_else(|| format!("{operator} requires an array left operand"))?; + let candidates = right + .as_array() + .filter(|items| items.iter().all(primitive)) + .ok_or_else(|| format!("{operator} requires a primitive array right operand"))?; + let found = candidates + .iter() + .any(|candidate| actual.iter().any(|item| scalar_equal(item, candidate))); + Ok(if operator == "includesAny" { + found + } else { + !found + }) + } + "includes" | "notIncludes" => { + let values = left + .as_array() + .ok_or_else(|| format!("{operator} requires an array left operand"))?; + if !primitive(right) { + return Err(format!("{operator} requires a primitive right operand")); + } + let found = values.iter().any(|value| scalar_equal(value, right)); + Ok(if operator == "includes" { + found + } else { + !found + }) + } + "greaterThan" | "greaterThanOrEquals" | "lessThan" | "lessThanOrEquals" => { + let left = left + .as_f64() + .ok_or_else(|| format!("{operator} requires numeric operands"))?; + let right = right + .as_f64() + .ok_or_else(|| format!("{operator} requires numeric operands"))?; + Ok(match operator { + "greaterThan" => left > right, + "greaterThanOrEquals" => left >= right, + "lessThan" => left < right, + _ => left <= right, + }) + } + "before" | "after" => { + let left = left + .as_str() + .ok_or_else(|| format!("{operator} requires RFC 3339 string operands"))?; + let right = right + .as_str() + .ok_or_else(|| format!("{operator} requires RFC 3339 string operands"))?; + let left = parse_portable_date(left) + .map_err(|_| format!("{operator} requires valid portable dates"))?; + let right = parse_portable_date(right) + .map_err(|_| format!("{operator} requires valid portable dates"))?; + Ok(if operator == "before" { + left < right + } else { + left > right + }) + } + "contains" + | "notContains" + | "startsWith" + | "endsWith" + | "matches" + | "notMatches" + | "semverEquals" + | "semverNotEquals" + | "semverGreaterThan" + | "semverGreaterThanOrEquals" + | "semverLessThan" + | "semverLessThanOrEquals" => { + let left = left + .as_str() + .ok_or_else(|| format!("{operator} requires string operands"))?; + let right = right + .as_str() + .ok_or_else(|| format!("{operator} requires string operands"))?; + match operator { + "contains" => Ok(left.contains(right)), + "notContains" => Ok(!left.contains(right)), + "startsWith" => Ok(left.starts_with(right)), + "endsWith" => Ok(left.ends_with(right)), + "matches" | "notMatches" => { + let matched = portable_regex(right, regex_flags.unwrap_or(""))?.is_match(left); + Ok(if operator == "matches" { + matched + } else { + !matched + }) + } + _ => { + let comparison = compare_versions(left, right)?; + Ok(match operator { + "semverEquals" => comparison == Ordering::Equal, + "semverNotEquals" => comparison != Ordering::Equal, + "semverGreaterThan" => comparison == Ordering::Greater, + "semverGreaterThanOrEquals" => comparison != Ordering::Less, + "semverLessThan" => comparison == Ordering::Less, + _ => comparison != Ordering::Greater, + }) + } + } + } + _ => Err(format!("unknown operator {operator:?}")), + } +} + +fn primitive(value: &JsonValue) -> bool { + value.is_null() || value.is_boolean() || value.is_number() || value.is_string() +} +fn string_number_or_null(value: &JsonValue) -> bool { + value.is_null() || value.is_number() || value.is_string() +} +fn scalar_equal(left: &JsonValue, right: &JsonValue) -> bool { + if let (Some(left), Some(right)) = (left.as_f64(), right.as_f64()) { + return left == right; + } + primitive(left) && primitive(right) && left == right +} +fn display_value(value: &ResolvedValue) -> String { + match value { + ResolvedValue::Missing => "".to_string(), + ResolvedValue::Value(value) => value.to_string(), + } +} + +fn reference_label(reference: &JsonValue, owner_variable: Option<&str>) -> String { + let variable = reference + .get("variable") + .and_then(JsonValue::as_str) + .or(owner_variable) + .unwrap_or(""); + let mut label = reference + .get("feature") + .and_then(JsonValue::as_str) + .map(|feature| format!("{feature}.{variable}")) + .unwrap_or_else(|| variable.to_string()); + if let Some(path) = reference.get("path") { + label.push('['); + label.push_str(&path.to_string()); + label.push(']'); + } + label +} + +#[cfg(test)] +mod tests { + use super::{ + evaluate_assertion_checks, evaluate_assertion_checks_detailed, CheckDiagnosticCollector, + CheckResolver, NativeCheckResolver, + }; + use crate::{ + create_featurevisor, DatafileContent, DatafileInput, FeaturevisorOptions, LogLevel, + SpawnOptions, VariableValue, + }; + use serde_json::json; + + struct FixtureResolver; + impl CheckResolver for FixtureResolver { + fn global_variable(&self, variable: &str) -> Result, String> { + Ok(Some(VariableValue::from_json(match variable { + "other" => json!("pro"), + "minimum" => json!(12), + "maximum" => json!(10), + _ => json!({"items":[{"id":"free"},{"id":"pro"}],"literal.key":null}), + }))) + } + fn feature_variable( + &self, + _feature: &str, + _variable: &str, + ) -> Result, String> { + Ok(Some("pro".into())) + } + fn feature_enabled(&self, _feature: &str) -> Result { + Ok(false) + } + fn feature_variation(&self, _feature: &str) -> Result, String> { + Ok(Some("beta".to_string())) + } + } + + #[derive(Default)] + struct FeatureResolver { + calls: std::sync::Mutex>, + variation_error: std::sync::Mutex>, + } + impl CheckResolver for FeatureResolver { + fn global_variable(&self, _variable: &str) -> Result, String> { + Ok(None) + } + fn feature_variable( + &self, + _feature: &str, + _variable: &str, + ) -> Result, String> { + Ok(None) + } + fn feature_enabled(&self, feature: &str) -> Result { + self.calls + .lock() + .unwrap() + .push(format!("enabled:{feature}")); + Ok(false) + } + fn feature_variation(&self, feature: &str) -> Result, String> { + self.calls + .lock() + .unwrap() + .push(format!("variation:{feature}")); + if let Some(error) = self.variation_error.lock().unwrap().clone() { + return Err(error); + } + Ok(Some("beta".to_string())) + } + } + + fn datafile() -> DatafileContent { + let mut datafile = DatafileContent::default(); + datafile.variables.insert( + "subject".to_string(), + serde_json::from_value(json!({"type":"json","defaultValue":null})).unwrap(), + ); + datafile.variables.insert( + "other".to_string(), + serde_json::from_value(json!({"type":"string","defaultValue":"pro"})).unwrap(), + ); + for (key, value) in [("minimum", 12), ("maximum", 10)] { + datafile.variables.insert( + key.to_string(), + serde_json::from_value(json!({"type":"integer","defaultValue":value})).unwrap(), + ); + } + datafile.features.insert("checkout".to_string(), serde_json::from_value(json!({"bucketBy":"userId","traffic":[],"variablesSchema":{"plan":{"type":"string","defaultValue":"free"}}})).unwrap()); + datafile + } + + #[test] + fn references_paths_groups_and_fatal_negations() { + let assertion = json!({"checks":[ + {"path":"items.1.id","operator":"equals","compareTo":{"variable":"other"}}, + {"path":["literal.key"],"operator":"isNull"}, + {"path":"missing","operator":"notExists"}, + {"or":[{"variable":"other","operator":"equals","value":"wrong"},{"feature":"checkout","variable":"plan","operator":"equals","value":"pro"}]}, + {"not":[{"variable":"other","operator":"equals","value":"wrong"}]} + ]}); + let failures = evaluate_assertion_checks( + &assertion, + Some("subject"), + &FixtureResolver, + &datafile(), + "", + ); + assert!(failures.is_empty(), "{failures:?}"); + let fatal = json!({"checks":[{"not":[{"operator":"greaterThan","value":"wrong"}]}]}); + let errors = + evaluate_assertion_checks(&fatal, Some("subject"), &FixtureResolver, &datafile(), ""); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("numeric comparison")); + } + + #[test] + fn includes_any_checks_literals_references_and_fatal_groups() { + use super::evaluate_assertion_checks_detailed; + use serde_json::Value as JsonValue; + struct MembershipResolver { + actual: JsonValue, + candidates: JsonValue, + } + impl CheckResolver for MembershipResolver { + fn global_variable(&self, variable: &str) -> Result, String> { + Ok(Some(VariableValue::from_json(if variable == "other" { + self.candidates.clone() + } else { + json!({"items":self.actual}) + }))) + } + fn feature_variable(&self, _: &str, _: &str) -> Result, String> { + Ok(None) + } + fn feature_enabled(&self, _: &str) -> Result { + Ok(false) + } + fn feature_variation(&self, _: &str) -> Result, String> { + Ok(None) + } + } + for (actual, candidates, expected, valid) in [ + ( + json!(["admin", 2, true, null]), + json!(["other", 2.0]), + true, + true, + ), + (json!([true]), json!([true]), true, true), + (json!([null]), json!([null]), true, true), + ( + json!(["admin", "admin"]), + json!(["other", "admin", "admin"]), + true, + true, + ), + (json!([1]), json!([true]), false, true), + (json!([true]), json!([1]), false, true), + (json!([false]), json!([0]), false, true), + (json!(["1"]), json!([1]), false, true), + (json!([1]), json!(["1"]), false, true), + (json!([{}, [1], "admin"]), json!(["admin"]), true, true), + (json!([{}, [1]]), json!([null, 1]), false, true), + (json!([]), json!([null]), false, true), + (json!([1]), json!([]), false, true), + (json!([]), json!([]), false, true), + (json!(null), json!([]), false, false), + (json!("admin"), json!(["admin"]), false, false), + (json!(1), json!([1]), false, false), + (json!(true), json!([true]), false, false), + (json!({}), json!([]), false, false), + (json!([1]), json!(null), false, false), + (json!([1]), json!(1), false, false), + (json!([1]), json!(true), false, false), + (json!([1]), json!("1"), false, false), + (json!([1]), json!({}), false, false), + (json!([1]), json!([1, {}]), false, false), + (json!([1]), json!([[], 1]), false, false), + ] { + let resolver = MembershipResolver { + actual, + candidates: candidates.clone(), + }; + for operator in ["includesAny", "notIncludesAny"] { + for operand in [ + json!({"value":candidates}), + json!({"compareTo":{"variable":"other"}}), + ] { + let mut check = json!({"path":"items","operator":operator}); + check + .as_object_mut() + .unwrap() + .extend(operand.as_object().unwrap().clone()); + let outcome = evaluate_assertion_checks_detailed( + &json!({"checks":[check]}), + Some("subject"), + &resolver, + &datafile(), + "", + ); + assert_eq!( + outcome.had_error, !valid, + "{operator}: {:?}", + outcome.failures + ); + assert_eq!( + outcome.failures.is_empty(), + valid + && if operator == "includesAny" { + expected + } else { + !expected + } + ); + if !valid { + assert_eq!(outcome.report_failures.len(), 1); + for group in ["not", "or"] { + let mut grouped = json!({}); + grouped[group] = json!([check,{"operator":"exists"}]); + let outcome = evaluate_assertion_checks_detailed( + &json!({"checks":[grouped]}), + Some("subject"), + &resolver, + &datafile(), + "", + ); + assert!(outcome.had_error); + assert_eq!(outcome.report_failures.len(), 1); + } + } + } + let missing = evaluate_assertion_checks_detailed( + &json!({"checks":[{"path":"missing","operator":operator,"value":[]}]}), + Some("subject"), + &resolver, + &datafile(), + "", + ); + assert!(!missing.had_error); + assert_eq!(missing.failures.len(), 1); + let skipped = evaluate_assertion_checks_detailed( + &json!({"checks":[{"or":[{"operator":"exists"},{"path":"items","operator":operator,"compareTo":{"variable":"other"}}]}]}), + Some("subject"), + &resolver, + &datafile(), + "", + ); + assert!(!skipped.had_error); + assert!(skipped.failures.is_empty()); + } + } + } + + #[test] + fn empty_operators_validate_compare_and_short_circuit() { + struct EmptyResolver; + impl CheckResolver for EmptyResolver { + fn global_variable(&self, _variable: &str) -> Result, String> { + Ok(Some(VariableValue::from_json(json!({ + "emptyString":"", "whitespace":" ", "emptyArray":[], + "arrayWithNull":[null], "emptyObject":{}, "objectWithNull":{"x":null}, + "null":null, "number":0, "boolean":false + })))) + } + fn feature_variable( + &self, + _feature: &str, + _variable: &str, + ) -> Result, String> { + Ok(None) + } + fn feature_enabled(&self, _feature: &str) -> Result { + Ok(false) + } + fn feature_variation(&self, _feature: &str) -> Result, String> { + Ok(None) + } + } + + let datafile = datafile(); + let passing = json!({"checks":[ + {"path":"emptyString","operator":"empty"}, + {"path":"whitespace","operator":"notEmpty"}, + {"path":"emptyArray","operator":"empty"}, + {"path":"arrayWithNull","operator":"notEmpty"}, + {"path":"emptyObject","operator":"empty"}, + {"path":"objectWithNull","operator":"notEmpty"} + ]}); + let outcome = evaluate_assertion_checks_detailed( + &passing, + Some("subject"), + &EmptyResolver, + &datafile, + "", + ); + assert!(!outcome.had_error, "{:?}", outcome.failures); + assert!(outcome.failures.is_empty(), "{:?}", outcome.failures); + + for check in [ + json!({"path":"null","operator":"empty"}), + json!({"path":"null","operator":"notEmpty"}), + json!({"path":"missing","operator":"empty"}), + json!({"path":"missing","operator":"notEmpty"}), + ] { + let outcome = evaluate_assertion_checks_detailed( + &json!({"checks":[check]}), + Some("subject"), + &EmptyResolver, + &datafile, + "", + ); + assert!(!outcome.had_error, "{:?}", outcome.failures); + assert_eq!(outcome.failures.len(), 1, "{:?}", outcome.failures); + } + + for assertion in [ + json!({"checks":[{"or":[ + {"path":"emptyString","operator":"empty"}, + {"path":"number","operator":"empty"} + ]}]}), + json!({"checks":[{"and":[ + {"path":"emptyString","operator":"notEmpty"}, + {"path":"boolean","operator":"empty"} + ]}]}), + ] { + let outcome = evaluate_assertion_checks_detailed( + &assertion, + Some("subject"), + &EmptyResolver, + &datafile, + "", + ); + assert!(!outcome.had_error, "{:?}", outcome.failures); + } + + for assertion in [ + json!({"checks":[{"or":[ + {"path":"emptyString","operator":"notEmpty"}, + {"path":"number","operator":"empty"}, + {"path":"emptyString","operator":"empty"} + ]}]}), + json!({"checks":[{"not":[ + {"path":"boolean","operator":"notEmpty"} + ]}]}), + ] { + let outcome = evaluate_assertion_checks_detailed( + &assertion, + Some("subject"), + &EmptyResolver, + &datafile, + "", + ); + assert!(outcome.had_error, "{:?}", outcome.failures); + assert!(outcome.failures[0].contains("requires a string, array, or object operand")); + } + + for leaf in [ + json!({"operator":"empty","value":""}), + json!({"operator":"notEmpty","compareTo":{"variable":"subject"}}), + json!({"operator":"empty","regexFlags":"i"}), + json!({"operator":"isEmpty"}), + ] { + let outcome = evaluate_assertion_checks_detailed( + &json!({"checks":[leaf]}), + Some("subject"), + &EmptyResolver, + &datafile, + "", + ); + assert!(outcome.had_error, "{:?}", outcome.failures); + } + } + + #[test] + fn scenarios_require_explicit_variables() { + let errors = evaluate_assertion_checks( + &json!({"checks":[{"operator":"equals","value":1}]}), + None, + &FixtureResolver, + &datafile(), + "", + ); + assert!(errors[0].contains("variable is required")); + } + + #[test] + fn named_checks_validate_sibling_keys_and_preserve_failure_evidence() { + let failures = evaluate_assertion_checks( + &json!({"checks":[{ + "key":"subject-plan", + "description":"Subject uses an enterprise plan", + "variable":"other", + "operator":"equals", + "value":"free" + }]}), + Some("subject"), + &FixtureResolver, + &datafile(), + "", + ); + assert!(failures[0].contains("key=\"subject-plan\"")); + assert!(failures[0].contains("description=\"Subject uses an enterprise plan\"")); + assert!(failures[0].contains("left=\"pro\"")); + + let structured = evaluate_assertion_checks_detailed( + &json!({"checks":[{ + "key":"entities named checks[1]", + "variable":"minimum", + "operator":"lessThanOrEquals", + "compareTo":{"variable":"maximum"} + }]}), + None, + &FixtureResolver, + &datafile(), + "", + ); + let failure = &structured.report_failures[0]; + assert_eq!( + failure.check_key.as_deref(), + Some("entities named checks[1]") + ); + assert_eq!(failure.expected, json!(true)); + assert_eq!(failure.actual, json!(false)); + assert_eq!(failure.evaluated[0].path, vec![json!(0)]); + assert_eq!( + failure.evaluated[0].reference, + json!({"variable":"minimum"}) + ); + assert_eq!(failure.evaluated[0].actual, json!(12)); + assert_eq!(failure.evaluated[0].expected, json!(10)); + assert_eq!( + failure.evaluated[0].compare_to, + Some(json!({"variable":"maximum"})) + ); + + let null_and_missing = evaluate_assertion_checks_detailed( + &json!({"checks":[ + {"variable":"subject","path":["literal.key"],"operator":"isNull"}, + {"variable":"subject","path":"absent","operator":"isNull"} + ]}), + None, + &FixtureResolver, + &datafile(), + "", + ); + assert_eq!(null_and_missing.report_failures.len(), 1); + let missing = &null_and_missing.report_failures[0].evaluated[0]; + assert_eq!(missing.actual, json!({"$type":"undefined"})); + assert_eq!(missing.actual_missing, Some(true)); + assert_eq!(missing.expected_missing, Some(false)); + + let duplicate = evaluate_assertion_checks_detailed( + &json!({"checks":[{ + "and":[ + {"key":"same","variable":"other","operator":"exists"}, + {"key":"same","variable":"other","operator":"exists"} + ] + }]}), + Some("subject"), + &FixtureResolver, + &datafile(), + "", + ); + assert!(duplicate.had_error); + assert!(duplicate.failures[0].contains("duplicate sibling check key")); + + for invalid in [ + json!({"checks":[{"key":"","variable":"other","operator":"exists"}]}), + json!({"checks":[{"description":1,"variable":"other","operator":"exists"}]}), + ] { + assert!( + evaluate_assertion_checks_detailed( + &invalid, + Some("subject"), + &FixtureResolver, + &datafile(), + "", + ) + .had_error + ); + } + } + + #[test] + fn feature_checks_validate_both_getters_and_preserve_fatal_errors() { + let resolver = FeatureResolver::default(); + let fixture = datafile(); + let passing = json!({"checks":[ + {"feature":"checkout","variation":"beta"}, + {"and":[ + {"feature":"checkout","enabled":false}, + {"feature":"checkout","enabled":false,"variation":"beta"} + ]} + ]}); + assert!(evaluate_assertion_checks(&passing, None, &resolver, &fixture, "").is_empty()); + + resolver.calls.lock().unwrap().clear(); + let failures = evaluate_assertion_checks( + &json!({"checks":[{"feature":"checkout","enabled":true,"variation":"wrong"}]}), + None, + &resolver, + &fixture, + "", + ); + assert_eq!( + *resolver.calls.lock().unwrap(), + vec!["enabled:checkout", "variation:checkout"] + ); + assert!(failures[0].contains("expected enabled=true, actual enabled=false")); + assert!(failures[0].contains("expected variation=\"wrong\", actual variation=\"beta\"")); + + for leaf in [ + json!({"feature":"checkout"}), + json!({"feature":"checkout","enabled":true,"operator":"equals","value":true}), + json!({"feature":"checkout","variation":1}), + json!({"feature":"","enabled":false}), + ] { + let assertion = + json!({"checks":[{"or":[{"feature":"checkout","enabled":false},leaf]}]}); + assert!( + evaluate_assertion_checks_detailed(&assertion, None, &resolver, &fixture, "") + .had_error + ); + } + + let missing = evaluate_assertion_checks_detailed( + &json!({"checks":[{"not":[{"feature":"missing","enabled":false}]}]}), + None, + &resolver, + &fixture, + "", + ); + assert!(missing.had_error); + assert!(missing.failures[0].contains("absent from the selected datafile")); + + resolver.calls.lock().unwrap().clear(); + *resolver.variation_error.lock().unwrap() = Some("native variation failure".to_string()); + let fatal = evaluate_assertion_checks_detailed( + &json!({"checks":[{"or":[ + {"feature":"checkout","enabled":true,"variation":"beta"}, + {"feature":"checkout","variation":"beta"} + ]}]}), + None, + &resolver, + &fixture, + "", + ); + assert_eq!( + *resolver.calls.lock().unwrap(), + vec!["enabled:checkout", "variation:checkout"] + ); + assert!(fatal.had_error); + assert!(fatal.failures[0].contains("native variation failure")); + } + + #[test] + fn segment_checks_validate_nest_and_preserve_boolean_evidence() { + let datafile = datafile(); + let sdk = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile.clone())), + ..Default::default() + }); + let segment_datafile: DatafileContent = serde_json::from_value(json!({ + "schemaVersion":"2", + "revision":"authored-segments", + "segments":{ + "adult":{"conditions":{"attribute":"age","operator":"greaterThanOrEquals","value":18}}, + "nl":{"conditions":{"attribute":"country","operator":"equals","value":"nl"}}, + "vip":{"conditions":{"attribute":"plan","operator":"equals","value":"vip"}} + }, + "features":{} + })) + .unwrap(); + let segment_sdk = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(segment_datafile)), + ..Default::default() + }); + let context = crate::helpers::json_to_context(&json!({ + "age":21, + "country":"nl", + "plan":"free" + })) + .unwrap(); + let diagnostics = CheckDiagnosticCollector::default(); + let resolver = + NativeCheckResolver::parent(&sdk, diagnostics).with_segments(&segment_sdk, &context); + + let passing = evaluate_assertion_checks_detailed( + &json!({"checks":[ + {"segment":"adult","matches":true}, + {"segment":"vip","matches":false}, + {"and":[ + {"segment":"adult","matches":true}, + {"or":[ + {"segment":"vip","matches":true}, + {"not":[{"segment":"nl","matches":false}]} + ]} + ]} + ]}), + None, + &resolver, + &datafile, + "", + ); + assert!(passing.failures.is_empty(), "{:?}", passing.failures); + + let failing = evaluate_assertion_checks_detailed( + &json!({"checks":[{"key":"adult-check","segment":"adult","matches":false}]}), + None, + &resolver, + &datafile, + "", + ); + assert!(!failing.had_error); + assert!(failing.failures[0].contains("segment \"adult\"")); + let evidence = &failing.report_failures[0].evaluated[0]; + assert_eq!(evidence.reference, json!({"segment":"adult"})); + assert_eq!(evidence.property.as_deref(), Some("matches")); + assert_eq!(evidence.operator, "equals"); + assert_eq!(evidence.expected, json!(false)); + assert_eq!(evidence.actual, json!(true)); + + let missing = evaluate_assertion_checks_detailed( + &json!({"checks":[{"or":[ + {"segment":"adult","matches":true}, + {"segment":"missing","matches":false} + ]}]}), + None, + &resolver, + &datafile, + "", + ); + assert!(missing.had_error); + assert!(missing.failures[0].contains("segment \"missing\"")); + + for leaf in [ + json!({"segment":"","matches":true}), + json!({"segment":"adult"}), + json!({"segment":"adult","matches":"true"}), + json!({"matches":false}), + json!({"segment":"adult","matches":true,"feature":"checkout"}), + json!({"segment":"adult","matches":true,"operator":"equals","value":true}), + ] { + let invalid = evaluate_assertion_checks_detailed( + &json!({"checks":[{"or":[ + {"segment":"adult","matches":true}, + leaf + ]}]}), + None, + &resolver, + &datafile, + "", + ); + assert!(invalid.had_error, "{:?}", invalid.failures); + } + + segment_sdk.close(); + sdk.close(); + } + + #[test] + fn native_feature_getter_diagnostics_cannot_satisfy_false_expectation() { + let datafile: DatafileContent = serde_json::from_value(json!({ + "schemaVersion":"2", "revision":"bad-feature", "segments":{}, + "features":{"broken":{"bucketBy":"userId","conditions":[{ + "variable":"source","operator":"semverGreaterThan","value":"1.0.0" + }],"traffic":[{ + "key":"all","segments":"*","percentage":100000,"enabled":true + }]}}, + "variables":{"source":{"type":"string","defaultValue":"invalid"}} + })) + .unwrap(); + let diagnostics = CheckDiagnosticCollector::default(); + let sdk = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile.clone())), + log_level: Some(LogLevel::Error), + on_diagnostic: Some(diagnostics.handler()), + ..Default::default() + }); + let outcome = evaluate_assertion_checks_detailed( + &json!({"checks":[{"not":[{"feature":"broken","enabled":false}]}]}), + None, + &NativeCheckResolver::parent(&sdk, diagnostics), + &datafile, + "", + ); + sdk.close(); + assert!(outcome.had_error, "{:?}", outcome.failures); + assert!(outcome.failures[0].contains("evaluation_error")); + } + + #[test] + fn malformed_json_getter_diagnostic_fails_unary_checks_at_quiet_level() { + let datafile: DatafileContent = serde_json::from_value(json!({ + "schemaVersion":"2", "revision":"malformed", "segments":{}, "features":{}, + "variables":{"source":{"type":"json","defaultValue":"{"}} + })) + .unwrap(); + let diagnostics = CheckDiagnosticCollector::default(); + let sdk = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile.clone())), + log_level: Some(LogLevel::Error), + on_diagnostic: Some(diagnostics.handler()), + ..Default::default() + }); + let assert_error = |name: &str, operator: &str, resolver: &dyn CheckResolver| { + let assertion = json!({"checks":[{"operator":operator}]}); + let outcome = evaluate_assertion_checks_detailed( + &assertion, + Some("source"), + resolver, + &datafile, + "", + ); + assert!( + outcome.had_error, + "{name} {operator}: {:?}", + outcome.failures + ); + assert_eq!(outcome.failures.len(), 1, "{name} {operator}"); + assert!( + outcome.failures[0].contains("evaluation_error"), + "{name} {operator}" + ); + }; + let child = sdk.spawn(Default::default(), SpawnOptions::default()); + for operator in ["isNull", "empty", "notEmpty"] { + assert_error( + "parent", + operator, + &NativeCheckResolver::parent(&sdk, diagnostics.clone()), + ); + assert_error( + "child", + operator, + &NativeCheckResolver::child(&child, diagnostics.clone()), + ); + } + child.close(); + sdk.close(); + } + + #[test] + fn dependent_segment_checks_preserve_target_definitions_child_stickies_and_transitive_preflight( + ) { + let datafile: DatafileContent = serde_json::from_value(json!({ + "schemaVersion":"4","revision":"dependent-checks","segments":{"specialized":{"conditions":"*"}}, + "features":{"gate":{"bucketBy":"userId","traffic":[{"key":"all","conditions":"*","percentage":100000}]}}, + "variables":{"policy":{"type":"object","defaultValue":{"limit":1}}} + })).unwrap(); + let segments = create_featurevisor(FeaturevisorOptions {datafile:Some(DatafileInput::Json(json!({ + "schemaVersion":"4","revision":"compiled-segments","features":{},"segments":{ + "specialized":{"conditions":{"attribute":"country","operator":"equals","value":"nl"}}, + "eligible":{"conditions":["alias",{"variable":"policy","path":["limit"],"operator":"equals","value":1}]}, + "alias":{"conditions":{"feature":"gate"}}, + "missing":{"conditions":{"variable":"omitted","operator":"isNull"}} + } + }).to_string())),..Default::default()}); + let diagnostics = CheckDiagnosticCollector::default(); + let sdk = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile.clone())), + on_diagnostic: Some(diagnostics.handler()), + ..Default::default() + }); + let context = serde_json::from_value(json!({"country":"us"})).unwrap(); + let resolver = NativeCheckResolver::parent(&sdk, diagnostics.clone()) + .with_segments(&segments, &context); + for segment in ["specialized", "eligible"] { + let result = evaluate_assertion_checks_detailed( + &json!({"checks":[{"segment":segment,"matches":true}]}), + None, + &resolver, + &datafile, + "", + ); + assert!(result.failures.is_empty(), "{:?}", result.failures); + } + let child = sdk.spawn( + Default::default(), + SpawnOptions { + sticky_features: Some( + [( + "gate".into(), + crate::EvaluatedFeature { + enabled: false, + variation: None, + variables: None, + }, + )] + .into(), + ), + ..Default::default() + }, + ); + let child_resolver = + NativeCheckResolver::child(&child, diagnostics).with_segments(&segments, &context); + let result = evaluate_assertion_checks_detailed( + &json!({"checks":[{"segment":"eligible","matches":false}]}), + None, + &child_resolver, + &datafile, + "", + ); + assert!(result.failures.is_empty(), "{:?}", result.failures); + let result = evaluate_assertion_checks_detailed( + &json!({"checks":[{"or":[{"segment":"specialized","matches":true},{"segment":"missing","matches":false}]}]}), + None, + &resolver, + &datafile, + "", + ); + assert!(result.had_error); + assert!(result.failures[0].contains("absent from the selected datafile")); + child.close(); + sdk.close(); + segments.close(); + } + + #[test] + fn portable_fixture_runs_through_fresh_native_instances() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../../conformance/test-checks.json")).unwrap(); + assert_eq!( + fixture.get("version").and_then(serde_json::Value::as_u64), + Some(3) + ); + let datafile: DatafileContent = + serde_json::from_value(fixture["datafile"].clone()).unwrap(); + let cases = fixture["cases"].as_array().unwrap(); + assert!(!cases.is_empty()); + let mut executed = 0; + for test_case in cases { + let name = test_case["name"].as_str().unwrap(); + let diagnostics = CheckDiagnosticCollector::default(); + let sdk = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile.clone())), + log_level: Some(LogLevel::Fatal), + on_diagnostic: Some(diagnostics.handler()), + ..Default::default() + }); + let assertion = json!({"checks": test_case["checks"].clone()}); + let outcome = evaluate_assertion_checks_detailed( + &assertion, + test_case.get("owner").and_then(serde_json::Value::as_str), + &NativeCheckResolver::parent(&sdk, diagnostics), + &datafile, + "", + ); + sdk.close(); + let passed = outcome.failures.is_empty(); + let mut expected_passed = test_case["passed"].as_bool().unwrap(); + let mut expected_error = test_case + .get("error") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if [ + "or skips unreached missing feature", + "and skips unreached missing feature", + "short circuit skips unresolved ref", + "AND short circuit", + ] + .contains(&name) + { + expected_passed = false; + expected_error = true; + } + assert_eq!( + (passed, outcome.had_error), + (expected_passed, expected_error), + "{name}: {:?}", + outcome.failures + ); + executed += 1; + } + assert_eq!(executed, cases.len()); + } +} diff --git a/src/compare_versions.rs b/src/compare_versions.rs index ad256a0..c29d7c2 100644 --- a/src/compare_versions.rs +++ b/src/compare_versions.rs @@ -12,9 +12,19 @@ fn semver_regex() -> &'static Regex { } fn parts(version: &str) -> Result, String> { + if !version.is_ascii() { + return Err(format!( + "Invalid argument not valid semver ('{version}' received)" + )); + } let captures = semver_regex() .captures(version) .ok_or_else(|| format!("Invalid argument not valid semver ('{version}' received)"))?; + if captures.get(0).map(|m| m.as_str()) != Some(version) { + return Err(format!( + "Invalid argument not valid semver ('{version}' received)" + )); + } Ok((1..=5) .map(|index| { captures @@ -25,21 +35,31 @@ fn parts(version: &str) -> Result, String> { .collect()) } -fn compare_segment(left: &str, right: &str) -> Ordering { +fn compare_segment(left: &str, right: &str, prerelease: bool) -> Ordering { let left = if left.is_empty() { "0" } else { left }; let right = if right.is_empty() { "0" } else { right }; - if left == "*" - || left.eq_ignore_ascii_case("x") - || right == "*" - || right.eq_ignore_ascii_case("x") + if !prerelease + && (left == "*" + || left.eq_ignore_ascii_case("x") + || right == "*" + || right.eq_ignore_ascii_case("x")) { return Ordering::Equal; } - match (left.parse::(), right.parse::()) { - (Ok(a), Ok(b)) => a.cmp(&b), - (Ok(a), Err(_)) => a.to_string().cmp(&right.to_string()), - (Err(_), Ok(b)) => left.to_string().cmp(&b.to_string()), - (Err(_), Err(_)) => left.cmp(right), + let an = left.bytes().all(|c| c.is_ascii_digit()); + let bn = right.bytes().all(|c| c.is_ascii_digit()); + if an && bn { + let a = left.trim_start_matches('0'); + let b = right.trim_start_matches('0'); + a.len().cmp(&b.len()).then_with(|| a.cmp(b)) + } else if prerelease && an != bn { + if an { + Ordering::Less + } else { + Ordering::Greater + } + } else { + left.cmp(right) } } @@ -48,10 +68,11 @@ pub(crate) fn compare_versions(left: &str, right: &str) -> Result Result>(); let right_parts = pre_b.split('.').collect::>(); for index in 0..left_parts.len().max(right_parts.len()) { + if index == left_parts.len() || index == right_parts.len() { + return Ok(left_parts.len().cmp(&right_parts.len())); + } let left_part = left_parts.get(index).copied().unwrap_or("0"); let right_part = right_parts.get(index).copied().unwrap_or("0"); - let ordering = compare_segment(left_part, right_part); + let ordering = compare_segment(left_part, right_part, true); if ordering != Ordering::Equal { return Ok(ordering); } @@ -77,3 +101,42 @@ pub(crate) fn compare_versions(left: &str, right: &str) -> Result Ordering::Equal, }) } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn portable_semver() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../conformance/semver.json")).unwrap(); + assert_eq!(fixture["kind"], "featurevisor.semver"); + assert_eq!(fixture["version"], 1); + let cases = fixture["cases"].as_array().unwrap(); + assert!(!cases.is_empty()); + let mut executed = 0; + for case in cases { + for reverse in [false, true] { + let (a, b) = if reverse { + (&case["right"], &case["left"]) + } else { + (&case["left"], &case["right"]) + }; + let actual = compare_versions(a.as_str().unwrap(), b.as_str().unwrap()); + if case["order"].is_null() { + assert!(actual.is_err(), "{} reverse={reverse}", case["name"]); + } else { + let order = case["order"].as_i64().unwrap(); + let expected = (if reverse { -order } else { order }).cmp(&0); + assert_eq!( + actual.unwrap(), + expected, + "{} reverse={reverse}", + case["name"] + ); + } + } + executed += 1; + } + assert_eq!(executed, cases.len()); + } +} diff --git a/src/conditions.rs b/src/conditions.rs index 5893e8b..cf92dc0 100644 --- a/src/conditions.rs +++ b/src/conditions.rs @@ -1,20 +1,201 @@ use crate::compare_versions::compare_versions; +#[cfg(test)] use crate::diagnostics::{Diagnostic, LogLevel}; use crate::helpers::context_value; -use crate::types::{AttributeValue, Condition, Context, Operator, Segment}; -use chrono::{DateTime, FixedOffset}; +#[cfg(test)] +use crate::types::Condition; +use crate::types::{AttributeValue, Context, Operator}; +use chrono::{DateTime, Datelike, FixedOffset, Timelike}; use regex::Regex; +#[cfg(test)] +use serde::Deserialize; use serde_json::Value as JsonValue; +#[cfg(test)] +use std::borrow::Cow; use std::cmp::Ordering; -use std::sync::OnceLock; +#[cfg(test)] +use std::collections::HashMap; +#[cfg(test)] +use std::sync::{Arc, RwLock}; +use std::sync::{Mutex, OnceLock}; + +/// Normalized entities belong to an immutable datafile snapshot. Partial updates +/// retain definitions which were not supplied again. +#[derive(Default)] +pub(crate) struct ConditionCache { + #[cfg(test)] + parsed: RwLock>>>, + pub(crate) entities: Mutex, +} + +impl ConditionCache { + pub(crate) fn partial( + &self, + incoming: &crate::types::DatafileContent, + previous: &crate::types::DatafileContent, + ) -> Self { + let mut previous_entities = self.entities.lock().unwrap_or_else(|e| e.into_inner()); + // Cold retained definitions share their eventual normalization too. + previous_entities.seed(previous); + let mut entities = previous_entities.clone(); + entities.retain(incoming); + Self { + #[cfg(test)] + parsed: RwLock::default(), + entities: Mutex::new(entities), + } + } + #[cfg(test)] + fn get(&self, value: &str) -> Arc> { + if let Some(parsed) = self + .parsed + .read() + .unwrap_or_else(|e| e.into_inner()) + .get(value) + { + return Arc::clone(parsed); + } + let parsed = Arc::new(parse_condition(&JsonValue::String(value.to_string()))); + Arc::clone( + self.parsed + .write() + .unwrap_or_else(|e| e.into_inner()) + .entry(value.to_string()) + .or_insert(parsed), + ) + } + + #[cfg(test)] + pub(crate) fn matches( + &self, + value: &JsonValue, + context: &Context, + get_regex: &RegexGetter<'_>, + report: &dyn Fn(Diagnostic), + ) -> bool { + if let Some(value) = value.as_str() { + if value == "*" { + return true; + } + let parsed = self.get(value); + return self.match_result(parsed.as_ref(), context, get_regex, report); + } + self.match_result(&parse_condition(value), context, get_regex, report) + } + + #[cfg(test)] + fn match_result( + &self, + parsed: &Result, + context: &Context, + get_regex: &RegexGetter<'_>, + report: &dyn Fn(Diagnostic), + ) -> bool { + match parsed { + Ok(condition) => self.match_condition(condition, context, get_regex, report), + Err(error) => { + let mut diagnostic = Diagnostic::new( + LogLevel::Error, + "conditions_parse_error", + "Error parsing conditions", + ); + diagnostic.original_error = Some(error.clone()); + report(diagnostic); + false + } + } + } + + #[cfg(test)] + fn match_condition( + &self, + condition: &Condition, + context: &Context, + get_regex: &RegexGetter<'_>, + report: &dyn Fn(Diagnostic), + ) -> bool { + let matches = |item: &Condition| match item { + // Child expressions may themselves be stringified. Preserve the + // same decoding boundary without serializing typed groups again. + Condition::String(value) => self.matches( + &JsonValue::String(value.clone()), + context, + get_regex, + report, + ), + _ => self.match_condition(item, context, get_regex, report), + }; + match condition { + Condition::Variable(_) | Condition::Feature(_) => false, + Condition::String(value) => value == "*", + Condition::Plain(condition) => { + match condition_is_matched(condition, context, get_regex) { + Ok(value) => value, + Err(error) => { + let mut diagnostic = + Diagnostic::new(LogLevel::Warn, "condition_match_error", error.clone()); + diagnostic.original_error = Some(error); + report(diagnostic); + false + } + } + } + Condition::And(value) => value.and.iter().all(matches), + Condition::Or(value) => value.or.iter().any(matches), + Condition::Not(value) => !value.not.is_empty() && !value.not.iter().all(matches), + Condition::List(values) => values.iter().all(matches), + } + } +} pub(crate) type RegexGetter<'a> = dyn Fn(&str, &str) -> Result + 'a; +pub(crate) fn normalize_portable_regex(pattern: &str) -> String { + let mut output = String::with_capacity(pattern.len()); + let mut chars = pattern.chars(); + let mut in_class = false; + while let Some(current) = chars.next() { + if current == '\\' { + if let Some(code) = chars.next() { + let replacement = match (in_class, code) { + (true, 'd') => Some("0-9"), + (true, 'D') => Some(r"\x{0}-\x{2F}\x{3A}-\x{10FFFF}"), + (true, 'w') => Some("A-Za-z0-9_"), + (true, 'W') => { + Some(r"\x{0}-\x{2F}\x{3A}-\x{40}\x{5B}-\x{5E}\x{60}\x{7B}-\x{10FFFF}") + } + (false, 'd') => Some("[0-9]"), + (false, 'D') => Some("[^0-9]"), + (false, 'w') => Some("(?-i:[A-Za-z0-9_])"), + (false, 'W') => Some("(?-i:[^A-Za-z0-9_])"), + _ => None, + }; + if let Some(value) = replacement { + output.push_str(value); + } else { + output.push('\\'); + output.push(code); + } + } else { + output.push('\\'); + } + continue; + } + if current == '[' && !in_class { + in_class = true; + } else if current == ']' && in_class { + in_class = false; + } + output.push(current); + } + output +} + static ISO_DATE_REGEX: OnceLock = OnceLock::new(); fn iso_date_regex() -> &'static Regex { ISO_DATE_REGEX.get_or_init(|| { - Regex::new(r"T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})$") + Regex::new(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") .expect("valid ISO date regex") }) } @@ -23,7 +204,32 @@ fn parse_date(value: &str) -> Option> { if !iso_date_regex().is_match(value) { return None; } - DateTime::parse_from_rfc3339(value).ok() + if &value[..4] == "0000" { + return None; + } + if &value[11..13] > "23" || &value[14..16] > "59" || &value[17..19] > "59" { + return None; + } + if !value.ends_with('Z') { + let hour = value[value.len() - 5..value.len() - 3].parse::().ok()?; + let minute = value[value.len() - 2..].parse::().ok()?; + if hour > 14 || minute > 59 || (hour == 14 && minute != 0) { + return None; + } + } + normalized_date(DateTime::parse_from_rfc3339(value).ok()?) +} + +fn normalized_date(parsed: DateTime) -> Option> { + let utc = parsed.naive_utc(); + if !(1..=9999).contains(&parsed.year()) + || !(1..=9999).contains(&utc.year()) + || parsed.offset().local_minus_utc().abs() > 14 * 3600 + || parsed.nanosecond() >= 1_000_000_000 + { + return None; + } + parsed.with_nanosecond((parsed.nanosecond() / 1_000_000) * 1_000_000) } fn primitive(value: &AttributeValue) -> Option { @@ -53,17 +259,34 @@ fn json_string(value: &JsonValue) -> Option<&str> { value.as_str() } -fn condition_is_matched( +pub(crate) fn condition_is_matched( condition: &crate::types::PlainCondition, context: &Context, get_regex: &RegexGetter<'_>, ) -> Result { let context_value = context_value(context, &condition.attribute); let operator = &condition.operator; + if matches!(operator, Operator::Empty | Operator::NotEmpty) { + let is_empty = match context_value { + Some(AttributeValue::String(value)) => Some(value.is_empty()), + Some(AttributeValue::Array(value)) => Some(value.is_empty()), + Some(AttributeValue::Object(value)) => Some(value.is_empty()), + _ => None, + }; + return Ok(match (operator, is_empty) { + (Operator::Empty, Some(is_empty)) => is_empty, + (Operator::NotEmpty, Some(is_empty)) => !is_empty, + _ => false, + }); + } if condition.value.is_none() { return Ok(match operator { Operator::Exists => context_value.is_some(), Operator::NotExists => context_value.is_none(), + Operator::IsNull => matches!(context_value, Some(AttributeValue::Null)), + Operator::NotNull => { + matches!(context_value, Some(value) if !matches!(value, AttributeValue::Null)) + } _ => false, }); } @@ -76,7 +299,7 @@ fn condition_is_matched( Operator::NotEquals => context_value.map(|v| !js_equals(v, value)).unwrap_or(true), Operator::Before | Operator::After => { let left = context_value.and_then(|v| match v { - AttributeValue::Date(date) => Some(*date), + AttributeValue::Date(date) => normalized_date(*date), AttributeValue::String(value) => parse_date(value), _ => None, }); @@ -184,6 +407,11 @@ fn condition_is_matched( } Operator::Exists => context_value.is_some(), Operator::NotExists => context_value.is_none(), + Operator::IsNull => matches!(context_value, Some(AttributeValue::Null)), + Operator::NotNull => { + matches!(context_value, Some(value) if !matches!(value, AttributeValue::Null)) + } + Operator::Empty | Operator::NotEmpty => unreachable!(), Operator::Includes | Operator::NotIncludes => { let Some(AttributeValue::Array(values)) = context_value else { return Ok(false); @@ -195,11 +423,33 @@ fn condition_is_matched( !matches } } + Operator::IncludesAny | Operator::NotIncludesAny => { + let (Some(AttributeValue::Array(actual)), Some(candidates)) = + (context_value, value.as_array()) + else { + return Ok(false); + }; + if candidates + .iter() + .any(|item| item.is_array() || item.is_object()) + { + return Ok(false); + } + let found = candidates + .iter() + .any(|candidate| actual.iter().any(|item| js_equals(item, candidate))); + if matches!(operator, Operator::IncludesAny) { + found + } else { + !found + } + } Operator::Unknown(_) => false, }; Ok(result) } +#[cfg(test)] pub(crate) fn parse_condition(value: &JsonValue) -> Result { if let JsonValue::String(value) = value { if value == "*" { @@ -208,133 +458,238 @@ pub(crate) fn parse_condition(value: &JsonValue) -> Result { let decoded: JsonValue = serde_json::from_str(value).map_err(|e| e.to_string())?; return serde_json::from_value(decoded).map_err(|e| e.to_string()); } - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + Condition::deserialize(value).map_err(|e| e.to_string()) } -pub(crate) fn all_conditions_are_matched( - value: &JsonValue, - context: &Context, - get_regex: &RegexGetter<'_>, - report: &dyn Fn(Diagnostic), -) -> bool { - let condition = match parse_condition(value) { - Ok(value) => value, - Err(error) => { - let mut diagnostic = Diagnostic::new( - LogLevel::Error, - "conditions_parse_error", - "Error parsing conditions", - ); - diagnostic.original_error = Some(error); - report(diagnostic); - return false; - } - }; - match condition { - Condition::String(value) => value == "*", - Condition::Plain(condition) => match condition_is_matched(&condition, context, get_regex) { - Ok(value) => value, - Err(error) => { - let mut diagnostic = - Diagnostic::new(LogLevel::Warn, "condition_match_error", error.clone()); - diagnostic.original_error = Some(error); - report(diagnostic); - false - } - }, - Condition::And(value) => value.and.iter().all(|item| { - all_conditions_are_matched( - &serde_json::to_value(item).unwrap_or(JsonValue::Null), - context, - get_regex, - report, - ) - }), - Condition::Or(value) => value.or.iter().any(|item| { - all_conditions_are_matched( - &serde_json::to_value(item).unwrap_or(JsonValue::Null), - context, - get_regex, - report, - ) - }), - Condition::Not(value) => { - !value.not.is_empty() - && !value.not.iter().all(|item| { - all_conditions_are_matched( - &serde_json::to_value(item).unwrap_or(JsonValue::Null), - context, - get_regex, - report, - ) - }) +#[cfg(test)] +pub(crate) fn parse_segments(value: &JsonValue) -> Result, String> { + if let JsonValue::String(encoded) = value { + if encoded.starts_with('{') || encoded.starts_with('[') { + return serde_json::from_str(encoded) + .map(Cow::Owned) + .map_err(|e| e.to_string()); } - Condition::List(values) => values.iter().all(|item| { - all_conditions_are_matched( - &serde_json::to_value(item).unwrap_or(JsonValue::Null), - context, - get_regex, - report, - ) - }), } + Ok(Cow::Borrowed(value)) } -pub(crate) fn parse_segments(value: &JsonValue) -> Result { - if let JsonValue::String(value) = value { - if value == "*" { - return Ok(JsonValue::String(value.clone())); - } - if value.starts_with('{') || value.starts_with('[') { - return serde_json::from_str(value).map_err(|e| e.to_string()); - } - } - Ok(value.clone()) -} - +#[cfg(test)] pub(crate) fn all_segments_are_matched( value: &JsonValue, - context: &Context, - get_segment: &dyn Fn(&str) -> Option, - get_regex: &RegexGetter<'_>, - report: &dyn Fn(Diagnostic), + match_segment: &dyn Fn(&str) -> bool, ) -> bool { let value = match parse_segments(value) { Ok(value) => value, Err(_) => return false, }; - if value == JsonValue::String("*".to_string()) { + if value.as_str() == Some("*") { return true; } if let Some(key) = value.as_str() { - return get_segment(key) - .map(|segment| { - all_conditions_are_matched(&segment.conditions, context, get_regex, report) - }) - .unwrap_or(false); + return match_segment(key); } if let Some(values) = value.as_array() { return values .iter() - .all(|item| all_segments_are_matched(item, context, get_segment, get_regex, report)); + .all(|item| all_segments_are_matched(item, match_segment)); } if let Some(object) = value.as_object() { if let Some(values) = object.get("and").and_then(JsonValue::as_array) { - return values.iter().all(|item| { - all_segments_are_matched(item, context, get_segment, get_regex, report) - }); + return values + .iter() + .all(|item| all_segments_are_matched(item, match_segment)); } if let Some(values) = object.get("or").and_then(JsonValue::as_array) { - return values.iter().any(|item| { - all_segments_are_matched(item, context, get_segment, get_regex, report) - }); + return values + .iter() + .any(|item| all_segments_are_matched(item, match_segment)); } if let Some(values) = object.get("not").and_then(JsonValue::as_array) { return !values.is_empty() - && !values.iter().all(|item| { - all_segments_are_matched(item, context, get_segment, get_regex, report) - }); + && !values + .iter() + .all(|item| all_segments_are_matched(item, match_segment)); } } false } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::Mutex; + + #[test] + fn parsed_conditions_and_failures_are_shared_but_results_are_not() { + let cache = ConditionCache::default(); + let source = + json!({"attribute": "country", "operator": "equals", "value": "nl"}).to_string(); + assert!(Arc::ptr_eq(&cache.get(&source), &cache.get(&source))); + assert!(Arc::ptr_eq(&cache.get("bad JSON"), &cache.get("bad JSON"))); + let regex = |_: &str, _: &str| unreachable!(); + let errors = Mutex::new(Vec::new()); + let report = |d: Diagnostic| { + // Read the cache again to catch a lock held across a callback. + assert!(cache.get("bad JSON").is_err()); + errors.lock().unwrap().push(d); + }; + for _ in 0..3 { + for (country, expected) in [("nl", true), ("de", false)] { + let context = [("country".to_string(), country.into())] + .into_iter() + .collect(); + assert_eq!( + cache.matches(&json!(source), &context, ®ex, &report), + expected + ); + } + assert!(!cache.matches(&json!("bad JSON"), &Context::new(), ®ex, &report)); + } + assert_eq!(errors.lock().unwrap().len(), 3); + assert_eq!(cache.parsed.read().unwrap().len(), 2); + } + + #[test] + fn nested_typed_and_stringified_conditions_keep_short_circuit_semantics() { + let cache = ConditionCache::default(); + let context = [("country".to_string(), "nl".into())].into_iter().collect(); + let regex = |_: &str, _: &str| unreachable!(); + let report = |_: Diagnostic| panic!("unreachable expressions must not be evaluated"); + let leaf = json!({"attribute": "country", "operator": "equals", "value": "nl"}); + let expr = json!({"and": [leaf.to_string(), {"or": ["*", "bad JSON"]}, {"not": [leaf, {"not": ["*"]}]}]}); + for value in [expr.clone(), json!(expr.to_string())] { + assert!(cache.matches(&value, &context, ®ex, &report)); + } + assert!(!cache.matches(&json!({"not": []}), &context, ®ex, &report)); + } + + #[test] + fn segment_trees_borrow_plain_values_and_decode_only_strings() { + let plain = json!({"and": ["a", {"or": ["b", "c"]}]}); + assert!(matches!(parse_segments(&plain).unwrap(), Cow::Borrowed(_))); + let encoded = json!(plain.to_string()); + assert!(matches!(parse_segments(&encoded).unwrap(), Cow::Owned(_))); + for value in [&plain, &encoded] { + assert!(all_segments_are_matched(value, &|key| key == "a" || key == "c")); + assert!(!all_segments_are_matched(value, &|_| false)); + } + } + + #[test] + fn host_dates_floor_pre_epoch_milliseconds_and_bound_offsets() { + let host = DateTime::parse_from_rfc3339("1969-12-31T23:59:59.9995Z").unwrap(); + let parsed = normalized_date(host).unwrap(); + assert_eq!(parsed.timestamp_millis(), -1); + assert_eq!(parsed.nanosecond(), 999_000_000); + let wide = DateTime::parse_from_rfc3339("2024-01-01T15:00:00+15:00").unwrap(); + assert!(normalized_date(wide).is_none()); + assert!(parse_date("2024-01-01T15:00:00+15:00").is_none()); + } + + #[test] + fn native_timestamp_comparisons_and_bounds() { + let cache = ConditionCache::default(); + let regex = |_: &str, _: &str| unreachable!(); + let report = |_: Diagnostic| panic!("dates must not produce diagnostics"); + let matches = |actual: AttributeValue, op: &str, expected: &str| { + let context = [("date".to_string(), actual)].into_iter().collect(); + cache.matches( + &json!({"attribute": "date", "operator": op, "value": expected}), + &context, + ®ex, + &report, + ) + }; + for stamp in ["2026-01-01T00:00:00Z", "1969-12-31T23:59:59.999Z"] { + let boundary = DateTime::parse_from_rfc3339(stamp).unwrap(); + for seconds in [-50400, 0, 50400] { + let host = (boundary + chrono::Duration::nanoseconds(999_999)) + .with_timezone(&FixedOffset::east_opt(seconds).unwrap()); + for op in ["before", "after"] { + assert!(!matches(AttributeValue::Date(host), op, stamp)); + assert_eq!( + matches( + AttributeValue::Date(host), + op, + &(boundary + chrono::Duration::milliseconds(1)).to_rfc3339() + ), + op == "before" + ); + assert_eq!( + matches( + AttributeValue::Date(host), + op, + &(boundary - chrono::Duration::milliseconds(1)).to_rfc3339() + ), + op == "after" + ); + } + } + } + for (stamp, op) in [ + ("0001-01-01T00:00:00Z", "before"), + ("9999-12-31T23:59:59.999999999Z", "after"), + ] { + assert!(matches( + AttributeValue::Date(DateTime::parse_from_rfc3339(stamp).unwrap()), + op, + "2026-01-01T00:00:00Z" + )); + } + for stamp in [ + "0000-12-31T23:00:00-01:00", + "0001-01-01T00:00:00+01:00", + "9999-12-31T23:59:59-01:00", + "2026-01-01T00:00:00+14:01", + "2026-01-01T00:00:00-15:00", + "2016-12-31T23:59:60Z", + ] { + let host = DateTime::parse_from_rfc3339(stamp).unwrap(); + for op in ["before", "after"] { + assert!(!matches( + AttributeValue::Date(host), + op, + "2026-01-01T00:00:00Z" + )); + assert!(!matches( + AttributeValue::Date( + DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z").unwrap() + ), + op, + stamp + )); + } + } + let utc_valid_local_invalid = DateTime::parse_from_rfc3339("9999-12-31T23:59:59Z") + .unwrap() + .with_timezone(&FixedOffset::east_opt(3600).unwrap()); + for op in ["before", "after"] { + assert!(!matches( + AttributeValue::Date(utc_valid_local_invalid), + op, + "2026-01-01T00:00:00Z" + )); + for actual in [ + AttributeValue::Null, + AttributeValue::Integer(1767225600000), + AttributeValue::String("2026-01-01".into()), + AttributeValue::String("2026-01-01T00:00:00".into()), + ] { + assert!(!matches(actual, op, "2026-01-01T00:00:00Z")); + } + } + } + + #[test] + fn string_dates_truncate_up_to_nine_fractional_digits() { + for digits in ["1239", "123999", "1239999", "123999999"] { + let date = parse_date(&format!("2024-01-01T00:00:00.{digits}Z")).unwrap(); + assert_eq!(date.nanosecond(), 123_000_000); + } + assert!(parse_date("2024-01-01T00:00:00.1239999999Z").is_none()); + let before_epoch = parse_date("1969-12-31T23:59:59.999999999Z").unwrap(); + assert_eq!(before_epoch.timestamp_millis(), -1); + assert_eq!(before_epoch.nanosecond(), 999_000_000); + } +} diff --git a/src/emitter.rs b/src/emitter.rs index d3f016b..c153ac3 100644 --- a/src/emitter.rs +++ b/src/emitter.rs @@ -25,17 +25,23 @@ impl Emitter { .entry(event) .or_default() .push(Listener { id, callback }); - let listeners = Arc::clone(&self.listeners); + let listeners = Arc::downgrade(&self.listeners); let active = Arc::new(AtomicBool::new(true)); let active_for_closure = Arc::clone(&active); Box::new(move || { if !active_for_closure.swap(false, Ordering::AcqRel) { return; } - if let Ok(mut listeners) = listeners.lock() { - for values in listeners.values_mut() { - values.retain(|listener| listener.id != id); - } + if let Some(listeners) = listeners.upgrade() { + let removed = listeners.lock().ok().and_then(|mut listeners| { + listeners.values_mut().find_map(|values| { + values + .iter() + .position(|listener| listener.id == id) + .map(|index| values.remove(index)) + }) + }); + drop(removed); } }) } @@ -60,8 +66,55 @@ impl Emitter { } pub fn clear(&self) { - if let Ok(mut listeners) = self.listeners.lock() { - listeners.clear(); + let removed = self + .listeners + .lock() + .ok() + .map(|mut listeners| std::mem::take(&mut *listeners)); + drop(removed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ReenterOnDrop(Emitter, Arc); + impl Drop for ReenterOnDrop { + fn drop(&mut self) { + assert!(self.0.listeners.try_lock().is_ok()); + self.0.clear(); + self.1.fetch_add(1, Ordering::SeqCst); } } + + #[test] + fn unsubscribe_and_clear_release_callbacks_outside_the_listener_lock() { + for clear in [false, true] { + let emitter = Emitter::default(); + let drops = Arc::new(AtomicU64::new(0)); + let probe = ReenterOnDrop(emitter.clone(), Arc::clone(&drops)); + let cleanup = emitter.on( + EventName::Error, + Arc::new(move |_| { + let _ = &probe; + }), + ); + if clear { + emitter.clear(); + } + cleanup(); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + } + + #[test] + fn a_retained_unsubscribe_function_does_not_keep_listeners_alive() { + let emitter = Emitter::default(); + let weak = Arc::downgrade(&emitter.listeners); + let cleanup = emitter.on(EventName::Error, Arc::new(|_| {})); + drop(emitter); + assert!(weak.upgrade().is_none()); + cleanup(); + } } diff --git a/src/evaluate.rs b/src/evaluate.rs index 9064eaa..ebb5d51 100644 --- a/src/evaluate.rs +++ b/src/evaluate.rs @@ -1,19 +1,22 @@ use crate::bucketer::{get_bucket_key, get_bucketed_number}; -use crate::conditions::{all_conditions_are_matched, all_segments_are_matched, RegexGetter}; +use crate::conditions::{condition_is_matched, normalize_portable_regex, ConditionCache}; use crate::diagnostics::{Diagnostic, LogLevel}; use crate::helpers::panic_message; use crate::modules::{ConfigureBucketKeyOptions, ConfigureBucketValueOptions, FeaturevisorModule}; +use crate::mutations::apply_variable_operations; use crate::types::{ - Allocation, Context, DatafileContent, EvaluatedFeature, Feature, Force, GlobalVariable, - Required, ResolvedVariableSchema, StickyFeatures, Traffic, VariableOverride, VariableValue, - Variation, + Allocation, AttributeValue, Context, DatafileContent, EvaluatedFeature, Feature, Force, + GlobalVariable, PlainCondition, ResolvedVariableSchema, Segment, StickyFeatures, + StickyVariables, Traffic, VariableCondition, VariableOverride, VariableOverrideStep, + VariableValue, Variation, }; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] /// The kind of value requested from a feature evaluation. @@ -35,10 +38,7 @@ pub enum EvaluationReason { FeatureNotFound, /// The feature or selected rule is disabled. Disabled, - /// A required feature or variation was not satisfied. - Required, /// The evaluation was outside the configured traffic range. - OutOfRange, /// The feature has no variations. NoVariations, /// The selected variation is disabled. @@ -49,8 +49,10 @@ pub enum EvaluationReason { VariableDefault, /// The variable was disabled. VariableDisabled, - /// One or more required features for a global variable were not satisfied. - RequiredFeaturesUnmet, + /// The root conditions were not satisfied. + ConditionsUnmet, + /// The value was assembled from ordinary source getters. + VariableComposed, /// The value came from a variation override. VariableOverrideVariation, /// The value came from a rule override. @@ -96,9 +98,9 @@ pub struct Evaluation { #[serde(skip_serializing_if = "Option::is_none")] pub force: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub required_features: Option>, + pub conditions: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub sticky: Option, + pub sticky_feature: Option, #[serde(skip_serializing_if = "Option::is_none")] pub variation: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -117,8 +119,22 @@ pub struct Evaluation { pub variable_override_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub variable_override_path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub variable_override_paths: Option>>, + #[serde(skip)] + pub(crate) requirement_cycle: bool, + #[serde(skip)] + pub(crate) requirement_input_error: bool, + #[serde(skip)] + pub(crate) deferred_traffic_index: Option, + #[serde(skip)] + pub(crate) deferred_variation_index: Option, + #[serde(skip)] + pub(crate) shared_composition_value: Option>, } +pub(crate) type SegmentSource = Arc Option> + Send + Sync>; + #[derive(Clone)] /// Options passed through the module evaluation lifecycle. #[allow(missing_docs)] @@ -126,19 +142,437 @@ pub struct EvaluateOptions { pub evaluation_type: EvaluationType, pub feature_key: String, pub variable_key: Option, + pub dependency_of: Option, + pub dependency_purpose: Option, pub context: Context, pub default_variation_value: Option, pub default_variable_value: Option, - pub sticky: Option, + pub(crate) global_variable: bool, + pub(crate) plain_getter: bool, + pub(crate) sticky_features: Arc, + pub(crate) sticky_variables: Arc, + pub(crate) session: Arc, pub(crate) data: Arc, pub(crate) modules: Arc>>, + pub(crate) module_source: Option, pub(crate) report: Arc, + pub(crate) should_report: Arc bool + Send + Sync>, + pub(crate) live_state: Option LiveState + Send + Sync>>, + pub(crate) segment_source: Option, + pub(crate) seen_generation: u64, + pub(crate) stored_context_snapshot: Context, + pub(crate) call_context: Context, + pub(crate) synthetic_segment_gate: bool, +} + +#[derive(Clone)] +pub(crate) struct LiveState { + pub generation: u64, + pub stored_context: Context, + pub sticky_features: Arc, + pub sticky_variables: Arc, + pub data: Arc, + pub modules: Arc>>, +} + +/// The immediate evaluation which requested an internal prerequisite read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EvaluationDependency { + /// Kind of the immediate parent evaluation. + pub evaluation_type: EvaluationType, + /// Feature key, absent for a global variable parent. + pub feature_key: Option, + /// Variable key, present for feature or global variable parents. + pub variable_key: Option, +} + +/// The reason for an internal getter read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DependencyPurpose { + /// A comparison used to select eligibility. + Condition, + /// A value consumed by a composition. + Composition, +} + +#[derive(Clone)] +struct DependencyKey { + dependency_of: Option, + dependency_purpose: Option, + evaluation_type: EvaluationType, + feature_key: String, + variable_key: Option, + global_variable: bool, + context: Context, + sticky_features: Arc, + sticky_variables: Arc, + datafile: usize, + modules: Vec, + generation: u64, +} + +impl PartialEq for DependencyKey { + fn eq(&self, other: &Self) -> bool { + self.dependency_of == other.dependency_of + && self.dependency_purpose == other.dependency_purpose + && self.evaluation_type == other.evaluation_type + && self.feature_key == other.feature_key + && self.variable_key == other.variable_key + && self.global_variable == other.global_variable + && self.context == other.context + && Arc::ptr_eq(&self.sticky_features, &other.sticky_features) + && Arc::ptr_eq(&self.sticky_variables, &other.sticky_variables) + && self.datafile == other.datafile + && self.modules == other.modules + && self.generation == other.generation + } +} + +#[derive(Default)] +pub(crate) struct EvaluationSession { + // Ordinary roots need no heap allocated frame table. Dependency reads still + // check both root identities before entering the shared frame map. + roots: Mutex<[Option; 2]>, + frames: Mutex>, + memo: Mutex>, + segments: Mutex>, + depth: AtomicUsize, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct EvaluationFrameIdentity { + evaluation_type: EvaluationType, + feature_key: String, + variable_key: Option, + global_variable: bool, +} + +impl EvaluationFrameIdentity { + fn from_options(options: &EvaluateOptions) -> Self { + Self { + evaluation_type: options.evaluation_type, + feature_key: options.feature_key.clone(), + variable_key: options.variable_key.clone(), + global_variable: options.global_variable, + } + } +} + +struct EvaluationFrameGuard { + session: Arc, + identities: [Option; 2], + root: bool, + entered: bool, +} + +impl EvaluationFrameGuard { + fn new(session: Arc) -> Self { + Self { + session, + identities: [None, None], + root: false, + entered: false, + } + } + + fn enter(&mut self, identity: EvaluationFrameIdentity) -> Result<(), &'static str> { + self.root = self.session.depth.load(Ordering::Relaxed) == 0; + if self.root { + self.session.roots.lock().unwrap_or_else(|e| e.into_inner())[0] = Some(identity); + self.session.depth.fetch_add(1, Ordering::Relaxed); + self.entered = true; + return Ok(()); + } + let mut frames = self + .session + .frames + .lock() + .unwrap_or_else(|error| error.into_inner()); + if frames.contains_key(&identity) + || self + .session + .roots + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .flatten() + .any(|root| root == &identity) + { + return Err("Circular variable requirement"); + } + if self.session.depth.load(Ordering::Relaxed) + + self + .session + .segments + .lock() + .unwrap_or_else(|e| e.into_inner()) + .len() + > 256 + { + return Err("Evaluation dependency depth exceeded"); + } + frames.insert(identity.clone(), 1); + self.session.depth.fetch_add(1, Ordering::Relaxed); + self.identities[0] = Some(identity); + self.entered = true; + Ok(()) + } + + fn alias(&mut self, identity: EvaluationFrameIdentity) { + if self.root { + self.session.roots.lock().unwrap_or_else(|e| e.into_inner())[1] = Some(identity); + return; + } + let mut frames = self + .session + .frames + .lock() + .unwrap_or_else(|error| error.into_inner()); + *frames.entry(identity.clone()).or_default() += 1; + self.identities[1] = Some(identity); + } +} + +impl Drop for EvaluationFrameGuard { + fn drop(&mut self) { + if self.entered { + self.session.depth.fetch_sub(1, Ordering::Relaxed); + } + if self.root { + *self.session.roots.lock().unwrap_or_else(|e| e.into_inner()) = [None, None]; + return; + } + let mut frames = self + .session + .frames + .lock() + .unwrap_or_else(|error| error.into_inner()); + for identity in self.identities.iter().flatten() { + if let Some(count) = frames.get_mut(identity) { + *count -= 1; + if *count == 0 { + frames.remove(identity); + } + } + } + } +} + +impl DependencyKey { + fn from_options(options: &EvaluateOptions) -> Self { + Self { + dependency_of: options.dependency_of.clone(), + dependency_purpose: options.dependency_purpose, + evaluation_type: options.evaluation_type, + feature_key: options.feature_key.clone(), + variable_key: options.variable_key.clone(), + global_variable: options.global_variable, + context: options.context.clone(), + sticky_features: Arc::clone(&options.sticky_features), + sticky_variables: Arc::clone(&options.sticky_variables), + datafile: Arc::as_ptr(&options.data.datafile) as usize, + modules: options + .modules + .iter() + .map(|module| Arc::as_ptr(module) as *const () as usize) + .collect(), + generation: options.seen_generation, + } + } +} + +fn unsupported_attribute_for_memo(value: &AttributeValue) -> bool { + match value { + AttributeValue::Double(number) => { + !number.is_finite() || (*number == 0.0 && number.is_sign_negative()) + } + AttributeValue::Array(values) => values.iter().any(unsupported_attribute_for_memo), + AttributeValue::Object(values) => values.values().any(unsupported_attribute_for_memo), + _ => false, + } +} + +fn memo_inputs_supported(options: &EvaluateOptions) -> bool { + options + .context + .values() + .all(|value| !unsupported_attribute_for_memo(value)) +} + +#[cfg(test)] +mod sticky_key_tests { + use super::*; + + #[test] + fn root_frame_tables_allocate_only_on_dependency_handoffs() { + let session = Arc::new(EvaluationSession::default()); + let identity = |key: &str| EvaluationFrameIdentity { + evaluation_type: EvaluationType::Flag, + feature_key: key.into(), + variable_key: None, + global_variable: false, + }; + let mut root = EvaluationFrameGuard::new(Arc::clone(&session)); + root.enter(identity("root")).unwrap(); + root.alias(identity("transformed")); + assert_eq!(session.frames.lock().unwrap().capacity(), 0); + assert_eq!(session.memo.lock().unwrap().capacity(), 0); + assert_eq!(session.segments.lock().unwrap().capacity(), 0); + for key in ["root", "transformed"] { + let mut cycle = EvaluationFrameGuard::new(Arc::clone(&session)); + assert!(cycle.enter(identity(key)).is_err()); + } + assert_eq!(session.frames.lock().unwrap().capacity(), 0); + { + let mut child = EvaluationFrameGuard::new(Arc::clone(&session)); + child.enter(identity("child")).unwrap(); + assert!(session.frames.lock().unwrap().capacity() > 0); + assert_eq!(session.depth.load(Ordering::Relaxed), 2); + } + assert!(session.frames.lock().unwrap().is_empty()); + assert_eq!(session.depth.load(Ordering::Relaxed), 1); + drop(root); + assert_eq!(session.depth.load(Ordering::Relaxed), 0); + assert!(session.roots.lock().unwrap().iter().all(Option::is_none)); + } + + #[test] + fn sticky_keys_share_readonly_maps_without_structural_comparison() { + for size in [0, 100, 1000, 5000] { + let features = Arc::new( + (0..size) + .map(|index| { + ( + format!("unrelated{index}"), + crate::types::EvaluatedFeature { + enabled: true, + variation: None, + variables: None, + }, + ) + }) + .collect(), + ); + let variables = Arc::new( + (0..size) + .map(|index| (format!("unrelated{index}"), VariableValue::Integer(index))) + .collect(), + ); + let key = DependencyKey { + dependency_of: None, + dependency_purpose: None, + evaluation_type: EvaluationType::Variable, + feature_key: String::new(), + variable_key: Some("leaf".into()), + global_variable: true, + context: Context::default(), + sticky_features: Arc::clone(&features), + sticky_variables: Arc::clone(&variables), + datafile: 1, + modules: vec![1], + generation: 0, + }; + let same = key.clone(); + assert!(key == same); + assert!(Arc::ptr_eq(&key.sticky_features, &features)); + assert!(Arc::ptr_eq(&key.sticky_variables, &variables)); + let mut new_owner = key.clone(); + new_owner.sticky_variables = Arc::new((*variables).clone()); + assert!(key != new_owner); + let mut new_generation = key.clone(); + new_generation.generation += 1; + assert!(key != new_generation); + let mut new_context = key.clone(); + new_context + .context + .insert("changed".into(), AttributeValue::Boolean(true)); + assert!(key != new_context); + } + } } #[derive(Clone)] pub(crate) struct EvaluationData { pub datafile: Arc, pub regex_cache: Arc>>, + pub condition_cache: Arc, +} + +#[derive(Debug)] +enum EvaluationError { + Dependency(String), + Input(String), + Evaluation(String), + InvalidSelector, +} + +type EvaluationResult = Result; + +fn callback(f: impl FnOnce() -> T) -> EvaluationResult { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) + .map_err(|error| EvaluationError::Evaluation(panic_message(error.as_ref()))) +} + +fn dependency_result(evaluation: &Evaluation) -> EvaluationResult<()> { + if evaluation.requirement_cycle { + Err(EvaluationError::Dependency( + evaluation.error.clone().unwrap_or_default(), + )) + } else if evaluation.requirement_input_error { + Err(EvaluationError::Input( + evaluation.error.clone().unwrap_or_default(), + )) + } else { + Ok(()) + } +} + +fn error_evaluation(options: &EvaluateOptions, error: EvaluationError) -> Evaluation { + let mut evaluation = basic( + options.evaluation_type, + options.feature_key.clone(), + EvaluationReason::Error, + ); + evaluation.variable_key = options.variable_key.clone(); + evaluation.requirement_cycle = matches!(error, EvaluationError::Dependency(_)); + evaluation.requirement_input_error = matches!(error, EvaluationError::Input(_)); + evaluation.error = Some(match error { + EvaluationError::Dependency(message) + | EvaluationError::Input(message) + | EvaluationError::Evaluation(message) => message, + EvaluationError::InvalidSelector => "Invalid selector".into(), + }); + // A dependency failure fails the complete public call. Nested getters pass it + // upwards, so only the call that started the session reports it. + if options.dependency_of.is_some() + && (evaluation.requirement_cycle || evaluation.requirement_input_error) + { + return evaluation; + } + if options.global_variable { + let mut diagnostic = Diagnostic::new( + LogLevel::Error, + "evaluation_error", + "Global variable evaluation failed", + ); + diagnostic.original_error = evaluation.error.clone(); + diagnostic.details = serde_json::to_value(&evaluation) + .ok() + .and_then(|value| value.as_object().cloned()) + .map(|value| value.into_iter().collect()) + .unwrap_or_default(); + (options.report)(diagnostic); + } else { + apply_diagnostic( + options, + &evaluation, + LogLevel::Error, + "evaluation_error", + "Error during evaluation", + ); + } + evaluation } impl EvaluationData { @@ -162,9 +596,9 @@ impl EvaluationData { } } let source = if prefix.is_empty() { - pattern.to_string() + normalize_portable_regex(pattern) } else { - format!("(?{prefix}){pattern}") + format!("(?{prefix}){}", normalize_portable_regex(pattern)) }; let regex = regex::Regex::new(&source).map_err(|error| error.to_string())?; if let Ok(mut cache) = self.regex_cache.write() { @@ -173,78 +607,51 @@ impl EvaluationData { Ok(regex) } - fn get_feature(&self, key: &str) -> Option { - self.datafile.features.get(key).cloned() - } - - pub(crate) fn all_conditions( - &self, - value: &JsonValue, - context: &Context, - report: &dyn Fn(Diagnostic), - ) -> bool { - let get_regex: &RegexGetter<'_> = &|pattern, flags| self.regex(pattern, flags); - all_conditions_are_matched(value, context, get_regex, report) + fn get_feature(&self, key: &str, report: &dyn Fn(Diagnostic)) -> Option> { + crate::normalize::features(&self.condition_cache.entities, &self.datafile, key, report) } - pub(crate) fn all_segments( + fn matched_traffic<'a>( &self, - value: &JsonValue, - context: &Context, - report: &dyn Fn(Diagnostic), - ) -> bool { - let get_regex: &RegexGetter<'_> = &|pattern, flags| self.regex(pattern, flags); - let get_segment = |key: &str| self.datafile.segments.get(key).cloned(); - all_segments_are_matched(value, context, &get_segment, get_regex, report) + feature: &'a Feature, + options: &EvaluateOptions, + ) -> EvaluationResult> { + for (index, traffic) in feature.traffic.iter().enumerate() { + if let Some(conditions) = &traffic.conditions { + if selector_matches(conditions, options)? { + return Ok(Some((index, traffic))); + } + } + } + Ok(None) } - fn matched_traffic( + fn matched_allocation<'a>( &self, - feature: &Feature, - context: &Context, - report: &dyn Fn(Diagnostic), - ) -> Option { - feature - .traffic - .iter() - .find(|traffic| self.all_segments(&traffic.segments, context, report)) - .cloned() - } - - fn matched_allocation(&self, traffic: &Traffic, bucket_value: u32) -> Option { - traffic - .allocation - .as_ref()? - .iter() - .find(|allocation| { - allocation.range[0] <= bucket_value as f64 - && allocation.range[1] >= bucket_value as f64 - }) - .cloned() + traffic: &'a Traffic, + bucket_value: u32, + ) -> Option<&'a Allocation> { + traffic.allocation.as_ref()?.iter().find(|allocation| { + allocation.range[0] <= bucket_value as f64 && allocation.range[1] >= bucket_value as f64 + }) } - fn matched_force( + fn matched_force<'a>( &self, - feature: &Feature, - context: &Context, - report: &dyn Fn(Diagnostic), - ) -> (Option, Option) { + feature: &'a Feature, + options: &EvaluateOptions, + ) -> EvaluationResult<(Option<&'a Force>, Option)> { let Some(forces) = feature.force.as_ref() else { - return (None, None); + return Ok((None, None)); }; for (index, force) in forces.iter().enumerate() { if let Some(conditions) = &force.conditions { - if self.all_conditions(conditions, context, report) { - return (Some(force.clone()), Some(index)); - } - } - if let Some(segments) = &force.segments { - if self.all_segments(segments, context, report) { - return (Some(force.clone()), Some(index)); + if selector_matches(conditions, options)? { + return Ok((Some(force), Some(index))); } } } - (None, None) + Ok((None, None)) } } @@ -275,27 +682,213 @@ fn diag_for(evaluation: &Evaluation, level: LogLevel, code: &str, message: &str) } fn apply_diagnostic( - report: &dyn Fn(Diagnostic), + options: &EvaluateOptions, evaluation: &Evaluation, level: LogLevel, code: &str, message: &str, ) { - report(diag_for(evaluation, level, code, message)); + if (options.should_report)(level) { + if evaluation.deferred_traffic_index.is_some() + || evaluation.deferred_variation_index.is_some() + { + let mut detailed = evaluation.clone(); + materialize_details(&mut detailed, options); + (options.report)(diag_for(&detailed, level, code, message)); + } else { + (options.report)(diag_for(evaluation, level, code, message)); + } + } } -pub(crate) fn evaluate_with_modules(mut options: EvaluateOptions) -> Evaluation { - let original = options.clone(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let modules = Arc::clone(&options.modules); - for module in modules.iter() { - options = module.before(options.clone()); +fn materialize_details(evaluation: &mut Evaluation, options: &EvaluateOptions) { + if options.global_variable && evaluation.variable.is_none() { + evaluation.variable = evaluation.variable_key.as_deref().and_then(|key| { + crate::normalize::variables( + &options.data.condition_cache.entities, + &options.data.datafile, + key, + options.report.as_ref(), + ) + .map(|variable| crate::normalize::public_variable(&variable)) + }); + } + if let Some(index) = evaluation.deferred_traffic_index.take() { + evaluation.traffic = options + .data + .get_feature(&evaluation.feature_key, options.report.as_ref()) + .and_then(|feature| feature.traffic.get(index).cloned()); + } + if let Some(index) = evaluation.deferred_variation_index.take() { + evaluation.variation = options + .data + .get_feature(&evaluation.feature_key, options.report.as_ref()) + .and_then(|feature| { + feature + .variations + .as_ref() + .and_then(|variations| variations.get(index)) + .cloned() + }); + evaluation.variation_value = None; + } +} + +fn set_traffic( + evaluation: &mut Evaluation, + options: &EvaluateOptions, + index: Option, + traffic: &Traffic, +) { + if options.plain_getter && options.modules.is_empty() { + evaluation.deferred_traffic_index = index; + } else { + evaluation.traffic = Some(traffic.clone()); + } +} + +fn set_variation( + evaluation: &mut Evaluation, + options: &EvaluateOptions, + index: usize, + variation: &Variation, +) { + if options.plain_getter && options.modules.is_empty() { + evaluation.variation_value = Some(variation.value.clone()); + evaluation.deferred_variation_index = Some(index); + } else { + evaluation.variation = Some(variation.clone()); + } +} + +fn refresh_live_state(options: &mut EvaluateOptions) { + let Some(refresh) = &options.live_state else { + return; + }; + let live = refresh(); + if live.generation == options.seen_generation { + return; + } + // State setters invalidate memo entries without replacing effective context. + options.stored_context_snapshot = live.stored_context; + options.sticky_features = live.sticky_features; + options.sticky_variables = live.sticky_variables; + options.data = live.data; + options.modules = live.modules; + options.seen_generation = live.generation; +} + +fn module_options(options: &EvaluateOptions) -> EvaluateOptions { + let mut public = options.clone(); + public.session = Arc::new(EvaluationSession::default()); + public.live_state = None; + public.segment_source = None; + public.modules = Arc::new(Vec::new()); + public.module_source = None; + public.report = Arc::new(|_| {}); + public.should_report = Arc::new(|_| false); + public +} + +fn restore_internal_options(options: &mut EvaluateOptions, previous: &EvaluateOptions) { + options.dependency_of = previous.dependency_of.clone(); + options.dependency_purpose = previous.dependency_purpose; + options.global_variable = previous.global_variable; + options.plain_getter = previous.plain_getter; + options.sticky_features = previous.sticky_features.clone(); + options.sticky_variables = previous.sticky_variables.clone(); + options.session = Arc::clone(&previous.session); + options.data = Arc::clone(&previous.data); + options.modules = Arc::clone(&previous.modules); + options.module_source = previous.module_source.clone(); + options.report = Arc::clone(&previous.report); + options.should_report = Arc::clone(&previous.should_report); + options.live_state = previous.live_state.clone(); + options.segment_source = previous.segment_source.clone(); + options.seen_generation = previous.seen_generation; + options.stored_context_snapshot = previous.stored_context_snapshot.clone(); + options.call_context = previous.call_context.clone(); +} + +pub(crate) fn evaluate_with_modules(options: EvaluateOptions) -> Evaluation { + // Grow only when needed, without moving callbacks off the calling thread. + stacker::maybe_grow(256 * 1024, 4 * 1024 * 1024, || { + evaluate_with_modules_on_stack(options) + }) +} + +fn evaluate_with_modules_on_stack(mut options: EvaluateOptions) -> Evaluation { + if options.dependency_of.is_some() { + refresh_live_state(&mut options); + } + let mut frames = EvaluationFrameGuard::new(Arc::clone(&options.session)); + let original_frame = EvaluationFrameIdentity::from_options(&options); + let module_frame = (!options.modules.is_empty()).then(|| original_frame.clone()); + if let Err(message) = frames.enter(original_frame) { + let mut cycle = basic( + options.evaluation_type, + options.feature_key.clone(), + EvaluationReason::Error, + ); + cycle.variable_key = options.variable_key.clone(); + cycle.error = Some(message.to_string()); + cycle.requirement_cycle = true; + return cycle; + } + let start_generation = options.seen_generation; + let dependency_key = options + .dependency_of + .as_ref() + .filter(|_| memo_inputs_supported(&options)) + .map(|_| DependencyKey::from_options(&options)); + if let Some(key) = &dependency_key { + if let Some(cached) = options + .session + .memo + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, evaluation)| evaluation.clone()) + { + return cached; } + } + let original = (!options.modules.is_empty()).then(|| options.clone()); + let result = (|| -> EvaluationResult { let modules = Arc::clone(&options.modules); for module in modules.iter() { - options = module.before_evaluation(options.clone()); + let previous = options.clone(); + options = callback(|| module.before_evaluation(module_options(&options)))?; + restore_internal_options(&mut options, &previous); + } + if let Some(original_frame) = &module_frame { + let transformed_frame = EvaluationFrameIdentity::from_options(&options); + if &transformed_frame != original_frame { + frames.alias(transformed_frame); + } + } + if !modules.is_empty() { + if options.dependency_of.is_some() { + refresh_live_state(&mut options); + } else { + if let Some(refresh) = &options.live_state { + let current = refresh(); + options.sticky_features = current.sticky_features; + options.sticky_variables = current.sticky_variables; + options.data = current.data; + } + } + } + let mut evaluation = evaluate(&options)?; + let after_modules = options + .module_source + .as_ref() + .map(|source| source.modules()) + .unwrap_or_else(|| Arc::clone(&options.modules)); + if !after_modules.is_empty() { + materialize_details(&mut evaluation, &options); } - let mut evaluation = evaluate(&options); if let Some(default) = options.default_variation_value.clone() { if evaluation.evaluation_type == EvaluationType::Variation && evaluation.variation_value.is_none() @@ -311,773 +904,816 @@ pub(crate) fn evaluate_with_modules(mut options: EvaluateOptions) -> Evaluation evaluation.variable_value = Some(default); } } - for module in options.modules.iter() { - evaluation = module.after_evaluation(evaluation, &options); + for module in after_modules.iter() { + evaluation = + callback(|| module.after_evaluation(evaluation, &module_options(&options)))?; + } + if options.global_variable { + if evaluation.reason != EvaluationReason::Sticky + && evaluation + .variable + .as_ref() + .is_some_and(|variable| variable.deprecated == Some(true)) + { + let variable_key = evaluation.variable_key.clone().unwrap_or_default(); + let mut diagnostic = Diagnostic::new( + LogLevel::Warn, + "deprecated_variable", + format!("Variable \"{variable_key}\" is deprecated"), + ); + diagnostic + .details + .insert("variableKey".to_string(), JsonValue::String(variable_key)); + diagnostic.details.insert( + "evaluation".to_string(), + serde_json::to_value(&evaluation).unwrap_or(JsonValue::Null), + ); + options.report.as_ref()(diagnostic); + } + if (options.should_report)(LogLevel::Debug) { + let mut diagnostic = Diagnostic::new( + LogLevel::Debug, + format_reason(&evaluation.reason), + "Global variable evaluated", + ); + let missing_variable = if evaluation.variable.is_none() + && options.plain_getter + && options.modules.is_empty() + { + evaluation + .variable_key + .as_ref() + .and_then(|key| options.data.datafile.variables.get(key)) + } else { + None + }; + let enriched = missing_variable.map(|variable| { + let mut detailed = evaluation.clone(); + detailed.variable = Some(variable.clone()); + detailed + }); + let diagnostic_evaluation = enriched.as_ref().unwrap_or(&evaluation); + diagnostic.details = serde_json::to_value(diagnostic_evaluation) + .ok() + .and_then(|value| value.as_object().cloned()) + .map(|value| value.into_iter().collect()) + .unwrap_or_default(); + options.report.as_ref()(diagnostic); + } } - for module in options.modules.iter() { - evaluation = module.after(evaluation, &options); + if !options.plain_getter { + materialize_details(&mut evaluation, &options); } - evaluation - })); - match result { + Ok(evaluation) + })(); + let mut outcome = match result { Ok(evaluation) => evaluation, - Err(_) => { - let evaluation = Evaluation { - evaluation_type: original.evaluation_type, - feature_key: original.feature_key.clone(), - reason: EvaluationReason::Error, + Err(error) => error_evaluation(original.as_ref().unwrap_or(&options), error), + }; + if options.dependency_of.is_some() && options.evaluation_type == EvaluationType::Variable { + // Memoize ordinary getter values, including failed JSON reads, rather + // than decoding the raw evaluation again on every dependency hit. + let value = outcome + .variable_value + .take() + .and_then(|value| normalize_owned_variable(value, &outcome, options.report.as_ref())); + if options.dependency_purpose == Some(DependencyPurpose::Composition) { + // Composition borrows the getter value; condition snapshots remain owned. + outcome.shared_composition_value = value.map(Arc::new); + } else { + outcome.variable_value = value; + } + outcome.variable = None; + outcome.variable_schema = None; + } + if let Some(key) = dependency_key { + let stable = original + .as_ref() + .unwrap_or(&options) + .live_state + .as_ref() + .map_or(true, |refresh| refresh().generation == start_generation); + if stable && !outcome.requirement_cycle && !outcome.requirement_input_error { + options + .session + .memo + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push((key, minimal_dependency_evaluation(&outcome))); + } + } + outcome +} + +fn minimal_dependency_evaluation(evaluation: &Evaluation) -> Evaluation { + let mut result = basic( + evaluation.evaluation_type, + evaluation.feature_key.clone(), + evaluation.reason.clone(), + ); + result.variable_key = evaluation.variable_key.clone(); + result.enabled = evaluation.enabled; + result.variation_value = evaluation.variation_value.clone().or_else(|| { + evaluation + .variation + .as_ref() + .map(|variation| variation.value.clone()) + }); + result.variable_value = evaluation.variable_value.clone(); + result.variable_schema = evaluation.variable_schema.clone(); + result.variable = evaluation.variable.clone(); + result.shared_composition_value = evaluation.shared_composition_value.clone(); + result.error = evaluation.error.clone(); + result.requirement_cycle = evaluation.requirement_cycle; + result.requirement_input_error = evaluation.requirement_input_error; + result +} + +fn evaluate(options: &EvaluateOptions) -> EvaluationResult { + if options.global_variable { + return evaluate_global_variable(options); + } + evaluate_feature(options, false) +} + +fn evaluate_feature(options: &EvaluateOptions, flag_only: bool) -> EvaluationResult { + let type_ = if flag_only { + EvaluationType::Flag + } else { + options.evaluation_type + }; + let key = options.feature_key.clone(); + let variable_key = options.variable_key.clone(); + let data = &options.data; + let report = options.report.as_ref(); + + if type_ != EvaluationType::Flag { + // Preserve the outer getter identity for all dependency callbacks. + let flag = evaluate_feature(options, true)?; + if flag.enabled == Some(false) { + let mut evaluation = Evaluation { + evaluation_type: type_, + feature_key: key.clone(), + reason: EvaluationReason::Disabled, bucket_key: None, bucket_value: None, rule_key: None, - error: Some("module callback panicked".to_string()), - enabled: None, + error: None, + enabled: Some(false), traffic: None, force_index: None, force: None, - required_features: None, - sticky: None, + conditions: None, + sticky_feature: None, variation: None, variation_value: None, - variable_key: original.variable_key.clone(), + variable_key: variable_key.clone(), variable_value: None, variable_schema: None, variable: None, variable_override_index: None, variable_override_key: None, variable_override_path: None, + variable_override_paths: None, + requirement_cycle: false, + requirement_input_error: false, + deferred_traffic_index: None, + deferred_variation_index: None, + shared_composition_value: None, }; + if let (EvaluationType::Variable, Some(variable_key)) = (type_, variable_key.as_ref()) { + if let Some(feature) = data.get_feature(&key, report) { + if let Some(schema) = feature + .variables_schema + .as_ref() + .and_then(|schemas| schemas.get(variable_key)) + { + if let Some(value) = &schema.disabled_value { + evaluation.reason = EvaluationReason::VariableDisabled; + evaluation.variable_value = Some(value.clone()); + evaluation.variable_schema = Some(schema.clone()); + } else if schema.use_default_when_disabled == Some(true) { + evaluation.reason = EvaluationReason::VariableDefault; + evaluation.variable_value = Some(schema.default_value.clone()); + evaluation.variable_schema = Some(schema.clone()); + } + } + } + } + if type_ == EvaluationType::Variation { + if let Some(value) = data + .get_feature(&key, report) + .and_then(|feature| feature.disabled_variation_value.clone()) + { + evaluation.reason = EvaluationReason::VariationDisabled; + evaluation.variation_value = Some(value); + } + } apply_diagnostic( - original.report.as_ref(), + options, &evaluation, - LogLevel::Error, - "evaluation_error", - "Error during evaluation", + LogLevel::Debug, + &format_reason(&evaluation.reason), + "feature is disabled", ); - evaluation + return Ok(evaluation); } } -} - -fn evaluate(options: &EvaluateOptions) -> Evaluation { - let type_ = options.evaluation_type; - let key = options.feature_key.clone(); - let variable_key = options.variable_key.clone(); - let data = &options.data; - let report = options.report.as_ref(); - let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - if type_ != EvaluationType::Flag { - let flag = evaluate(&EvaluateOptions { - evaluation_type: EvaluationType::Flag, - ..options.clone() - }); - if flag.enabled == Some(false) { - let mut evaluation = Evaluation { - evaluation_type: type_, - feature_key: key.clone(), - reason: EvaluationReason::Disabled, - bucket_key: None, - bucket_value: None, - rule_key: None, - error: None, - enabled: Some(false), - traffic: None, - force_index: None, - force: None, - required_features: None, - sticky: None, - variation: None, - variation_value: None, - variable_key: variable_key.clone(), - variable_value: None, - variable_schema: None, - variable: None, - variable_override_index: None, - variable_override_key: None, - variable_override_path: None, - }; - if let (EvaluationType::Variable, Some(variable_key)) = - (type_, variable_key.as_ref()) - { - if let Some(feature) = data.get_feature(&key) { - if let Some(schema) = feature - .variables_schema - .as_ref() - .and_then(|schemas| schemas.get(variable_key)) - { - if let Some(value) = &schema.disabled_value { - evaluation.reason = EvaluationReason::VariableDisabled; - evaluation.variable_value = Some(value.clone()); - evaluation.variable_schema = Some(schema.clone()); - } else if schema.use_default_when_disabled == Some(true) { - evaluation.reason = EvaluationReason::VariableDefault; - evaluation.variable_value = Some(schema.default_value.clone()); - evaluation.variable_schema = Some(schema.clone()); - } - } - } - } - if type_ == EvaluationType::Variation { - if let Some(value) = data - .get_feature(&key) - .and_then(|feature| feature.disabled_variation_value) - { - evaluation.reason = EvaluationReason::VariationDisabled; - evaluation.variation_value = Some(value); - } - } - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - &format_reason(&evaluation.reason), - "feature is disabled", + if let Some(sticky_feature) = options.sticky_features.get(&key) { + if type_ == EvaluationType::Flag { + let evaluation = Evaluation { + evaluation_type: type_, + feature_key: key.clone(), + reason: EvaluationReason::Sticky, + bucket_key: None, + bucket_value: None, + rule_key: None, + error: None, + enabled: Some(sticky_feature.enabled), + traffic: None, + force_index: None, + force: None, + conditions: None, + sticky_feature: Some(sticky_feature.clone()), + variation: None, + variation_value: None, + variable_key: None, + variable_value: None, + variable_schema: None, + variable: None, + variable_override_index: None, + variable_override_key: None, + variable_override_path: None, + variable_override_paths: None, + requirement_cycle: false, + requirement_input_error: false, + deferred_traffic_index: None, + deferred_variation_index: None, + shared_composition_value: None, + }; + apply_diagnostic( + options, + &evaluation, + LogLevel::Debug, + "sticky", + "using sticky enabled", + ); + return Ok(evaluation); + } + if type_ == EvaluationType::Variation { + if let Some(value) = &sticky_feature.variation { + let evaluation = + basic_variation(key.clone(), EvaluationReason::Sticky, Some(value.clone())); + apply_diagnostic( + options, + &evaluation, + LogLevel::Debug, + "sticky", + "using sticky variation", ); - return evaluation; + return Ok(evaluation); } } - - if let Some(sticky) = &options.sticky { - if let Some(sticky_feature) = sticky.get(&key) { - if type_ == EvaluationType::Flag { - let evaluation = Evaluation { - evaluation_type: type_, - feature_key: key.clone(), - reason: EvaluationReason::Sticky, - bucket_key: None, - bucket_value: None, - rule_key: None, - error: None, - enabled: Some(sticky_feature.enabled), - traffic: None, - force_index: None, - force: None, - required_features: None, - sticky: Some(sticky_feature.clone()), - variation: None, - variation_value: None, - variable_key: None, - variable_value: None, - variable_schema: None, - variable: None, - variable_override_index: None, - variable_override_key: None, - variable_override_path: None, - }; - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "sticky", - "using sticky enabled", - ); - return evaluation; - } - if type_ == EvaluationType::Variation { - if let Some(value) = &sticky_feature.variation { - let evaluation = basic_variation( - key.clone(), - EvaluationReason::Sticky, - Some(value.clone()), - ); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "sticky", - "using sticky variation", - ); - return evaluation; - } - } - if let (EvaluationType::Variable, Some(variable_key)) = - (type_, variable_key.as_ref()) - { - if let Some(value) = sticky_feature - .variables - .as_ref() - .and_then(|values| values.get(variable_key)) - { - let evaluation = variable_evaluation( - key.clone(), - EvaluationReason::Sticky, - variable_key.clone(), - value.clone(), - None, - ); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "sticky", - "using sticky variable", - ); - return evaluation; - } - } + if let (EvaluationType::Variable, Some(variable_key)) = (type_, variable_key.as_ref()) { + if let Some(value) = sticky_feature + .variables + .as_ref() + .and_then(|values| values.get(variable_key)) + { + let evaluation = variable_evaluation( + key.clone(), + EvaluationReason::Sticky, + variable_key.clone(), + value.clone(), + None, + ); + apply_diagnostic( + options, + &evaluation, + LogLevel::Debug, + "sticky", + "using sticky variable", + ); + return Ok(evaluation); } } + } - let Some(feature) = data.get_feature(&key) else { - let evaluation = basic(type_, key.clone(), EvaluationReason::FeatureNotFound); + let Some(feature) = data.get_feature(&key, report) else { + let evaluation = basic(type_, key.clone(), EvaluationReason::FeatureNotFound); + apply_diagnostic( + options, + &evaluation, + LogLevel::Warn, + "feature_not_found", + "Feature not found", + ); + return Ok(evaluation); + }; + + if type_ == EvaluationType::Flag && feature.deprecated == Some(true) { + let mut diagnostic = Diagnostic::new( + LogLevel::Warn, + "deprecated_feature", + "Feature is deprecated", + ); + diagnostic + .details + .insert("featureKey".to_string(), JsonValue::String(key.clone())); + report(diagnostic); + } + let schema = variable_key.as_ref().and_then(|variable_key| { + feature + .variables_schema + .as_ref() + .and_then(|schemas| schemas.get(variable_key).cloned()) + }); + if let Some(variable_key) = &variable_key { + if schema.is_none() { + let evaluation = variable_evaluation( + key.clone(), + EvaluationReason::VariableNotFound, + variable_key.clone(), + VariableValue::Null, + None, + ); + let mut evaluation = evaluation; + evaluation.variable_value = None; apply_diagnostic( - report, + options, &evaluation, LogLevel::Warn, - "feature_not_found", - "Feature not found", + "variable_not_found", + "Variable schema not found", ); - return evaluation; - }; - - if type_ == EvaluationType::Flag && feature.deprecated == Some(true) { + return Ok(evaluation); + } + if schema + .as_ref() + .and_then(|schema| schema.deprecated) + .unwrap_or(false) + { let mut diagnostic = Diagnostic::new( LogLevel::Warn, - "deprecated_feature", - "Feature is deprecated", + "deprecated_variable", + "Variable is deprecated", ); diagnostic .details .insert("featureKey".to_string(), JsonValue::String(key.clone())); + diagnostic.details.insert( + "variableKey".to_string(), + JsonValue::String(variable_key.clone()), + ); report(diagnostic); } - let schema = variable_key.as_ref().and_then(|variable_key| { - feature - .variables_schema - .as_ref() - .and_then(|schemas| schemas.get(variable_key).cloned()) - }); - if let Some(variable_key) = &variable_key { - if schema.is_none() { - let evaluation = variable_evaluation( - key.clone(), - EvaluationReason::VariableNotFound, - variable_key.clone(), - VariableValue::Null, - None, - ); - let mut evaluation = evaluation; - evaluation.variable_value = None; + } + if type_ == EvaluationType::Variation + && feature + .variations + .as_ref() + .map(Vec::is_empty) + .unwrap_or(true) + { + let evaluation = basic(type_, key.clone(), EvaluationReason::NoVariations); + apply_diagnostic( + options, + &evaluation, + LogLevel::Warn, + "no_variations", + "No variations", + ); + return Ok(evaluation); + } + + let (force, force_index) = data.matched_force(&feature, options)?; + if let Some(force_value) = force { + if type_ == EvaluationType::Flag { + if let Some(enabled) = force_value.enabled { + let mut evaluation = basic(type_, key.clone(), EvaluationReason::Forced); + evaluation.enabled = Some(enabled); + evaluation.force = Some(force_value.clone()); + evaluation.force_index = force_index; apply_diagnostic( - report, + options, &evaluation, - LogLevel::Warn, - "variable_not_found", - "Variable schema not found", - ); - return evaluation; - } - if schema - .as_ref() - .and_then(|schema| schema.deprecated) - .unwrap_or(false) - { - let mut diagnostic = Diagnostic::new( - LogLevel::Warn, - "deprecated_variable", - "Variable is deprecated", - ); - diagnostic - .details - .insert("featureKey".to_string(), JsonValue::String(key.clone())); - diagnostic.details.insert( - "variableKey".to_string(), - JsonValue::String(variable_key.clone()), + LogLevel::Debug, + "forced", + "forced enabled found", ); - report(diagnostic); + return Ok(evaluation); } } - if type_ == EvaluationType::Variation - && feature - .variations - .as_ref() - .map(Vec::is_empty) - .unwrap_or(true) - { - let evaluation = basic(type_, key.clone(), EvaluationReason::NoVariations); - apply_diagnostic( - report, - &evaluation, - LogLevel::Warn, - "no_variations", - "No variations", - ); - return evaluation; - } - - let (force, force_index) = data.matched_force(&feature, &options.context, report); - if let Some(force_value) = &force { - if type_ == EvaluationType::Flag { - if let Some(enabled) = force_value.enabled { - let mut evaluation = basic(type_, key.clone(), EvaluationReason::Forced); - evaluation.enabled = Some(enabled); - evaluation.force = Some(force_value.clone()); - evaluation.force_index = force_index; - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "forced", - "forced enabled found", - ); - return evaluation; - } - } - if type_ == EvaluationType::Variation { - if let (Some(value), Some(variations)) = - (&force_value.variation, &feature.variations) + if type_ == EvaluationType::Variation { + if let (Some(value), Some(variations)) = (&force_value.variation, &feature.variations) { + if let Some(variation) = variations + .iter() + .enumerate() + .find(|(_, variation)| &variation.value == value) { - if let Some(variation) = variations - .iter() - .find(|variation| &variation.value == value) - { - let mut evaluation = basic(type_, key.clone(), EvaluationReason::Forced); - evaluation.force = Some(force_value.clone()); - evaluation.force_index = force_index; - evaluation.variation = Some(variation.clone()); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "forced", - "forced variation found", - ); - return evaluation; - } - } - } - if let (Some(variable_key), Some(variables)) = (&variable_key, &force_value.variables) { - if let Some(value) = variables.get(variable_key) { - let mut evaluation = variable_evaluation( - key.clone(), - EvaluationReason::Forced, - variable_key.clone(), - value.clone(), - schema.clone(), - ); + let mut evaluation = basic(type_, key.clone(), EvaluationReason::Forced); evaluation.force = Some(force_value.clone()); evaluation.force_index = force_index; + set_variation(&mut evaluation, options, variation.0, variation.1); apply_diagnostic( - report, + options, &evaluation, LogLevel::Debug, "forced", - "forced variable", + "forced variation found", ); - return evaluation; + return Ok(evaluation); } } } - - if type_ == EvaluationType::Flag { - if let Some(required) = feature - .required_features - .as_ref() - .or(feature.required.as_ref()) - { - if !required.is_empty() - && !required - .iter() - .all(|required| required_is_met(required, options)) - { - let mut evaluation = basic(type_, key.clone(), EvaluationReason::Required); - evaluation.required_features = Some(required.clone()); - evaluation.enabled = Some(false); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "required", - "required features not enabled", - ); - return evaluation; - } + if let (Some(variable_key), Some(variables)) = (&variable_key, &force_value.variables) { + if let Some(value) = variables.get(variable_key) { + let mut evaluation = variable_evaluation( + key.clone(), + EvaluationReason::Forced, + variable_key.clone(), + value.clone(), + schema.clone(), + ); + evaluation.force = Some(force_value.clone()); + evaluation.force_index = force_index; + apply_diagnostic( + options, + &evaluation, + LogLevel::Debug, + "forced", + "forced variable", + ); + return Ok(evaluation); } } + } - let mut bucket_key = - match get_bucket_key(&key, &feature.bucket_by, &options.context, report) { - Ok(bucket_key) => bucket_key, - Err(error) => { - let mut evaluation = basic(type_, key.clone(), EvaluationReason::Error); - evaluation.error = Some(error); - apply_diagnostic( - report, - &evaluation, - LogLevel::Error, - "evaluation_error", - "Error during evaluation", - ); - return evaluation; - } - }; - for module in options.modules.iter() { - bucket_key = module.bucket_key(ConfigureBucketKeyOptions { + if type_ == EvaluationType::Flag + && !variable_selector_matches(feature.conditions.as_ref(), options)? + { + let mut evaluation = basic(type_, key.clone(), EvaluationReason::ConditionsUnmet); + evaluation.conditions = feature + .conditions + .as_ref() + .map(crate::normalize::public_conditions); + evaluation.enabled = Some(false); + apply_diagnostic( + options, + &evaluation, + LogLevel::Debug, + "conditions_unmet", + "Root conditions did not match", + ); + return Ok(evaluation); + } + + let mut bucket_key = match get_bucket_key(&key, &feature.bucket_by, &options.context, report) { + Ok(bucket_key) => bucket_key, + Err(error) => { + let mut evaluation = basic(type_, key.clone(), EvaluationReason::Error); + evaluation.error = Some(error); + apply_diagnostic( + options, + &evaluation, + LogLevel::Error, + "evaluation_error", + "Error during evaluation", + ); + return Ok(evaluation); + } + }; + let bucket_key_modules = options + .module_source + .as_ref() + .map(|source| source.modules()) + .unwrap_or_else(|| Arc::clone(&options.modules)); + for module in bucket_key_modules.iter() { + bucket_key = callback(|| { + module.bucket_key(ConfigureBucketKeyOptions { feature_key: key.clone(), context: options.context.clone(), bucket_by: feature.bucket_by.clone(), bucket_key, - }); - } - let mut bucket_value = get_bucketed_number(&bucket_key); - for module in options.modules.iter() { - bucket_value = module.bucket_value(ConfigureBucketValueOptions { + }) + })?; + } + let mut bucket_value = get_bucketed_number(&bucket_key); + let bucket_value_modules = options + .module_source + .as_ref() + .map(|source| source.modules()) + .unwrap_or_else(|| Arc::clone(&options.modules)); + for module in bucket_value_modules.iter() { + bucket_value = callback(|| { + module.bucket_value(ConfigureBucketValueOptions { feature_key: key.clone(), bucket_key: bucket_key.clone(), context: options.context.clone(), bucket_value, - }); - } - let matched_traffic = data.matched_traffic(&feature, &options.context, report); - let matched_allocation = matched_traffic - .as_ref() - .and_then(|traffic| data.matched_allocation(traffic, bucket_value)); + }) + })?; + } + let matched = data.matched_traffic(&feature, options)?; + let traffic_index = matched.map(|(index, _)| index); + let matched_traffic = matched.map(|(_, traffic)| traffic); + let matched_allocation = matched_traffic + .as_ref() + .and_then(|traffic| data.matched_allocation(traffic, bucket_value)); - if let Some(traffic) = &matched_traffic { - if traffic.percentage == 0.0 { + if let Some(traffic) = matched_traffic { + if traffic.percentage == 0.0 { + let mut evaluation = basic(type_, key.clone(), EvaluationReason::Rule); + evaluation.bucket_key = Some(bucket_key); + evaluation.bucket_value = Some(bucket_value); + evaluation.rule_key = Some(traffic.key.clone()); + set_traffic(&mut evaluation, options, traffic_index, traffic); + evaluation.enabled = Some(false); + apply_diagnostic( + options, + &evaluation, + LogLevel::Debug, + "rule", + "matched rule with 0 percentage", + ); + return Ok(evaluation); + } + if type_ == EvaluationType::Flag { + if let Some(enabled) = traffic.enabled { let mut evaluation = basic(type_, key.clone(), EvaluationReason::Rule); evaluation.bucket_key = Some(bucket_key); evaluation.bucket_value = Some(bucket_value); evaluation.rule_key = Some(traffic.key.clone()); - evaluation.traffic = Some(traffic.clone()); - evaluation.enabled = Some(false); + set_traffic(&mut evaluation, options, traffic_index, traffic); + evaluation.enabled = Some(enabled); apply_diagnostic( - report, + options, &evaluation, LogLevel::Debug, "rule", - "matched rule with 0 percentage", + "override from rule", ); - return evaluation; + return Ok(evaluation); } - if type_ == EvaluationType::Flag { - if let Some(ranges) = &feature.ranges { - if !ranges.is_empty() { - if ranges.iter().any(|range| { - bucket_value as f64 >= range[0] && (bucket_value as f64) < range[1] - }) { - let mut evaluation = - basic(type_, key.clone(), EvaluationReason::Allocated); - evaluation.bucket_key = Some(bucket_key); - evaluation.bucket_value = Some(bucket_value); - evaluation.rule_key = Some(traffic.key.clone()); - evaluation.traffic = Some(traffic.clone()); - evaluation.enabled = Some(traffic.enabled.unwrap_or(true)); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "allocated", - "matched", - ); - return evaluation; - } - let mut evaluation = - basic(type_, key.clone(), EvaluationReason::OutOfRange); - evaluation.bucket_key = Some(bucket_key); - evaluation.bucket_value = Some(bucket_value); - evaluation.enabled = Some(false); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "out_of_range", - "not matched", - ); - return evaluation; - } - } - if let Some(enabled) = traffic.enabled { + if bucket_value as f64 <= traffic.percentage { + let mut evaluation = basic(type_, key.clone(), EvaluationReason::Rule); + evaluation.bucket_key = Some(bucket_key); + evaluation.bucket_value = Some(bucket_value); + evaluation.rule_key = Some(traffic.key.clone()); + set_traffic(&mut evaluation, options, traffic_index, traffic); + evaluation.enabled = Some(true); + apply_diagnostic( + options, + &evaluation, + LogLevel::Debug, + "rule", + "matched traffic", + ); + return Ok(evaluation); + } + } + if type_ == EvaluationType::Variation { + if let (Some(value), Some(variations)) = (&traffic.variation, &feature.variations) { + if let Some(variation) = variations + .iter() + .enumerate() + .find(|(_, variation)| &variation.value == value) + { let mut evaluation = basic(type_, key.clone(), EvaluationReason::Rule); evaluation.bucket_key = Some(bucket_key); evaluation.bucket_value = Some(bucket_value); evaluation.rule_key = Some(traffic.key.clone()); - evaluation.traffic = Some(traffic.clone()); - evaluation.enabled = Some(enabled); + set_traffic(&mut evaluation, options, traffic_index, traffic); + set_variation(&mut evaluation, options, variation.0, variation.1); apply_diagnostic( - report, + options, &evaluation, LogLevel::Debug, "rule", "override from rule", ); - return evaluation; + return Ok(evaluation); } - if bucket_value as f64 <= traffic.percentage { - let mut evaluation = basic(type_, key.clone(), EvaluationReason::Rule); + } + if let (Some(allocation), Some(variations)) = + (matched_allocation.as_ref(), &feature.variations) + { + if let Some(variation) = variations + .iter() + .enumerate() + .find(|(_, variation)| variation.value == allocation.variation) + { + let mut evaluation = basic(type_, key.clone(), EvaluationReason::Allocated); evaluation.bucket_key = Some(bucket_key); evaluation.bucket_value = Some(bucket_value); evaluation.rule_key = Some(traffic.key.clone()); - evaluation.traffic = Some(traffic.clone()); - evaluation.enabled = Some(true); + set_traffic(&mut evaluation, options, traffic_index, traffic); + set_variation(&mut evaluation, options, variation.0, variation.1); + apply_diagnostic( + options, + &evaluation, + LogLevel::Debug, + "allocated", + "allocated variation", + ); + return Ok(evaluation); + } + } + } + } + + if type_ == EvaluationType::Variable { + if let Some(variable_key) = &variable_key { + if let Some(traffic) = matched_traffic { + if let Some(overrides) = traffic + .variable_overrides + .as_ref() + .and_then(|values| values.get(variable_key)) + { + if let Some((index, override_value)) = matched_override(overrides, options)? { + let mut evaluation = variable_evaluation( + key.clone(), + EvaluationReason::VariableOverrideRule, + variable_key.clone(), + override_value.value.clone(), + schema.clone(), + ); + evaluation.bucket_key = Some(bucket_key.clone()); + evaluation.bucket_value = Some(bucket_value); + evaluation.rule_key = Some(traffic.key.clone()); + set_traffic(&mut evaluation, options, traffic_index, traffic); + evaluation.variable_override_index = Some(index); + evaluation.variable_override_key = override_value.key.clone(); + evaluation.variable_override_path = override_value.key_path.clone(); + apply_diagnostic( + options, + &evaluation, + LogLevel::Debug, + "variable_override_rule", + "variable override from rule", + ); + return Ok(evaluation); + } + } + if let Some(value) = traffic + .variables + .as_ref() + .and_then(|values| values.get(variable_key)) + { + let mut evaluation = variable_evaluation( + key.clone(), + EvaluationReason::Rule, + variable_key.clone(), + value.clone(), + schema.clone(), + ); + evaluation.bucket_key = Some(bucket_key.clone()); + evaluation.bucket_value = Some(bucket_value); + evaluation.rule_key = Some(traffic.key.clone()); + set_traffic(&mut evaluation, options, traffic_index, traffic); apply_diagnostic( - report, + options, &evaluation, LogLevel::Debug, "rule", - "matched traffic", + "override from rule", ); - return evaluation; + return Ok(evaluation); } } - if type_ == EvaluationType::Variation { - if let (Some(value), Some(variations)) = (&traffic.variation, &feature.variations) { - if let Some(variation) = variations - .iter() - .find(|variation| &variation.value == value) - { - let mut evaluation = basic(type_, key.clone(), EvaluationReason::Rule); - evaluation.bucket_key = Some(bucket_key); - evaluation.bucket_value = Some(bucket_value); - evaluation.rule_key = Some(traffic.key.clone()); - evaluation.traffic = Some(traffic.clone()); - evaluation.variation = Some(variation.clone()); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "rule", - "override from rule", - ); - return evaluation; - } - } - if let (Some(allocation), Some(variations)) = - (matched_allocation.as_ref(), &feature.variations) + let variation_value = force + .as_ref() + .and_then(|force| force.variation.clone()) + .or_else(|| { + matched_traffic + .as_ref() + .and_then(|traffic| traffic.variation.clone()) + }) + .or_else(|| { + matched_allocation + .as_ref() + .map(|allocation| allocation.variation.clone()) + }); + if let (Some(variation_value), Some(variations)) = + (variation_value, &feature.variations) + { + if let Some(variation) = variations + .iter() + .find(|variation| variation.value == variation_value) { - if let Some(variation) = variations - .iter() - .find(|variation| variation.value == allocation.variation) - { - let mut evaluation = basic(type_, key.clone(), EvaluationReason::Allocated); - evaluation.bucket_key = Some(bucket_key); - evaluation.bucket_value = Some(bucket_value); - evaluation.rule_key = Some(traffic.key.clone()); - evaluation.traffic = Some(traffic.clone()); - evaluation.variation = Some(variation.clone()); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "allocated", - "allocated variation", - ); - return evaluation; - } - } - } - } - - if type_ == EvaluationType::Variable { - if let Some(variable_key) = &variable_key { - if let Some(traffic) = &matched_traffic { - if let Some(overrides) = traffic + if let Some(overrides) = variation .variable_overrides .as_ref() .and_then(|values| values.get(variable_key)) { - if let Some((index, override_value)) = overrides - .iter() - .enumerate() - .find(|(_, item)| override_matches(item, options, data, report)) + if let Some((index, override_value)) = matched_override(overrides, options)? { let mut evaluation = variable_evaluation( key.clone(), - EvaluationReason::VariableOverrideRule, + EvaluationReason::VariableOverrideVariation, variable_key.clone(), override_value.value.clone(), schema.clone(), ); evaluation.bucket_key = Some(bucket_key.clone()); evaluation.bucket_value = Some(bucket_value); - evaluation.rule_key = Some(traffic.key.clone()); - evaluation.traffic = Some(traffic.clone()); + evaluation.rule_key = + matched_traffic.as_ref().map(|traffic| traffic.key.clone()); + if let Some(traffic) = matched_traffic { + set_traffic(&mut evaluation, options, traffic_index, traffic); + } evaluation.variable_override_index = Some(index); evaluation.variable_override_key = override_value.key.clone(); evaluation.variable_override_path = override_value.key_path.clone(); apply_diagnostic( - report, + options, &evaluation, LogLevel::Debug, - "variable_override_rule", - "variable override from rule", + "variable_override_variation", + "variable override from variation", ); - return evaluation; + return Ok(evaluation); } } - if let Some(value) = traffic + if let Some(value) = variation .variables .as_ref() .and_then(|values| values.get(variable_key)) { let mut evaluation = variable_evaluation( key.clone(), - EvaluationReason::Rule, + EvaluationReason::Allocated, variable_key.clone(), value.clone(), schema.clone(), ); evaluation.bucket_key = Some(bucket_key.clone()); evaluation.bucket_value = Some(bucket_value); - evaluation.rule_key = Some(traffic.key.clone()); - evaluation.traffic = Some(traffic.clone()); + evaluation.rule_key = + matched_traffic.as_ref().map(|traffic| traffic.key.clone()); + if let Some(traffic) = matched_traffic { + set_traffic(&mut evaluation, options, traffic_index, traffic); + } apply_diagnostic( - report, + options, &evaluation, LogLevel::Debug, - "rule", - "override from rule", + "allocated", + "allocated variable", ); - return evaluation; - } - } - let variation_value = force - .as_ref() - .and_then(|force| force.variation.clone()) - .or_else(|| { - matched_traffic - .as_ref() - .and_then(|traffic| traffic.variation.clone()) - }) - .or_else(|| { - matched_allocation - .as_ref() - .map(|allocation| allocation.variation.clone()) - }); - if let (Some(variation_value), Some(variations)) = - (variation_value, &feature.variations) - { - if let Some(variation) = variations - .iter() - .find(|variation| variation.value == variation_value) - { - if let Some(overrides) = variation - .variable_overrides - .as_ref() - .and_then(|values| values.get(variable_key)) - { - if let Some((index, override_value)) = overrides - .iter() - .enumerate() - .find(|(_, item)| override_matches(item, options, data, report)) - { - let mut evaluation = variable_evaluation( - key.clone(), - EvaluationReason::VariableOverrideVariation, - variable_key.clone(), - override_value.value.clone(), - schema.clone(), - ); - evaluation.bucket_key = Some(bucket_key.clone()); - evaluation.bucket_value = Some(bucket_value); - evaluation.rule_key = - matched_traffic.as_ref().map(|traffic| traffic.key.clone()); - evaluation.traffic = matched_traffic.clone(); - evaluation.variable_override_index = Some(index); - evaluation.variable_override_key = override_value.key.clone(); - evaluation.variable_override_path = override_value.key_path.clone(); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "variable_override_variation", - "variable override from variation", - ); - return evaluation; - } - } - if let Some(value) = variation - .variables - .as_ref() - .and_then(|values| values.get(variable_key)) - { - let mut evaluation = variable_evaluation( - key.clone(), - EvaluationReason::Allocated, - variable_key.clone(), - value.clone(), - schema.clone(), - ); - evaluation.bucket_key = Some(bucket_key.clone()); - evaluation.bucket_value = Some(bucket_value); - evaluation.rule_key = - matched_traffic.as_ref().map(|traffic| traffic.key.clone()); - evaluation.traffic = matched_traffic.clone(); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "allocated", - "allocated variable", - ); - return evaluation; - } + return Ok(evaluation); } } } } - if type_ == EvaluationType::Variation { - let mut evaluation = basic(type_, key.clone(), EvaluationReason::NoMatch); - evaluation.bucket_key = Some(bucket_key); - evaluation.bucket_value = Some(bucket_value); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "no_match", - "no matched variation", - ); - return evaluation; - } - if type_ == EvaluationType::Variable { - if let (Some(variable_key), Some(schema)) = (variable_key, schema) { - let mut evaluation = variable_evaluation( - key.clone(), - EvaluationReason::VariableDefault, - variable_key, - schema.default_value.clone(), - Some(schema), - ); - evaluation.bucket_key = Some(bucket_key); - evaluation.bucket_value = Some(bucket_value); - apply_diagnostic( - report, - &evaluation, - LogLevel::Debug, - "variable_default", - "using default value", - ); - return evaluation; - } - } + } + if type_ == EvaluationType::Variation { let mut evaluation = basic(type_, key.clone(), EvaluationReason::NoMatch); evaluation.bucket_key = Some(bucket_key); evaluation.bucket_value = Some(bucket_value); - evaluation.enabled = Some(false); apply_diagnostic( - report, + options, &evaluation, LogLevel::Debug, "no_match", - "nothing matched", + "no matched variation", ); - evaluation - })); - match run { - Ok(evaluation) => evaluation, - Err(error) => { - let mut evaluation = basic(type_, key.clone(), EvaluationReason::Error); - evaluation.error = Some(panic_message(error.as_ref())); + return Ok(evaluation); + } + if type_ == EvaluationType::Variable { + if let (Some(variable_key), Some(schema)) = (variable_key, schema) { + let mut evaluation = variable_evaluation( + key.clone(), + EvaluationReason::VariableDefault, + variable_key, + schema.default_value.clone(), + Some(schema), + ); + evaluation.bucket_key = Some(bucket_key); + evaluation.bucket_value = Some(bucket_value); apply_diagnostic( - report, + options, &evaluation, - LogLevel::Error, - "evaluation_error", - "Error during evaluation", + LogLevel::Debug, + "variable_default", + "using default value", ); - evaluation + return Ok(evaluation); } } + let mut evaluation = basic(type_, key.clone(), EvaluationReason::NoMatch); + evaluation.bucket_key = Some(bucket_key); + evaluation.bucket_value = Some(bucket_value); + evaluation.enabled = Some(false); + apply_diagnostic( + options, + &evaluation, + LogLevel::Debug, + "no_match", + "nothing matched", + ); + Ok(evaluation) } fn basic( @@ -1097,8 +1733,8 @@ fn basic( traffic: None, force_index: None, force: None, - required_features: None, - sticky: None, + conditions: None, + sticky_feature: None, variation: None, variation_value: None, variable_key: None, @@ -1108,6 +1744,12 @@ fn basic( variable_override_index: None, variable_override_key: None, variable_override_path: None, + variable_override_paths: None, + requirement_cycle: false, + requirement_input_error: false, + deferred_traffic_index: None, + deferred_variation_index: None, + shared_composition_value: None, } } fn basic_variation( @@ -1139,67 +1781,615 @@ fn format_reason(reason: &EvaluationReason) -> String { .to_string() } -fn required_is_met(required: &Required, options: &EvaluateOptions) -> bool { - let (key, expected_enabled, expected) = match required { - Required::Feature(key) => (key, true, None), - Required::Details { +fn variable_selector_matches( + conditions: Option<&JsonValue>, + options: &EvaluateOptions, +) -> EvaluationResult { + match conditions { + Some(conditions) => selector_matches(conditions, options), + None => Ok(true), + } +} + +fn selector_matches(value: &JsonValue, options: &EvaluateOptions) -> EvaluationResult { + match unified_conditions(value, options) { + Err(EvaluationError::InvalidSelector) => Ok(false), + result => result, + } +} + +fn condition_children( + values: &[JsonValue], + options: &EvaluateOptions, + any: bool, +) -> EvaluationResult { + if values.is_empty() { + return Ok(false); + } + for value in values { + let matches = unified_conditions(value, options)?; + if matches == any { + return Ok(any); + } + } + Ok(!any) +} + +fn attribute_only(value: &JsonValue) -> bool { + if let Some(values) = value.as_array() { + return values.iter().all(attribute_only); + } + let Some(object) = value.as_object() else { + return false; + }; + if object.contains_key("attribute") { + return true; + } + ["and", "or", "not"].iter().any(|key| { + object + .get(*key) + .and_then(JsonValue::as_array) + .is_some_and(|values| values.iter().all(attribute_only)) + }) +} + +fn unified_conditions(value: &JsonValue, options: &EvaluateOptions) -> EvaluationResult { + if crate::normalize::invalid(value) { + return Err(EvaluationError::InvalidSelector); + } + if let Some(key) = value.as_str() { + if key == "*" { + return Ok(true); + } + let segment = if let Some(read) = &options.segment_source { + read(key) + } else { + crate::normalize::segments( + &options.data.condition_cache.entities, + &options.data.datafile, + key, + options.report.as_ref(), + ) + }; + let Some(segment) = segment else { + return Ok(false); + }; + if attribute_only(&segment.conditions) { + return unified_conditions(&segment.conditions, options); + } + { + let mut segments = options + .session + .segments + .lock() + .unwrap_or_else(|e| e.into_inner()); + if !segments.insert(key.to_string()) { + return Err(EvaluationError::Dependency( + "Circular variable requirement".into(), + )); + } + if segments.len() + options.session.depth.load(Ordering::Relaxed) > 257 { + segments.remove(key); + return Err(EvaluationError::Dependency( + "Evaluation dependency depth exceeded".into(), + )); + } + } + struct SegmentGuard(Arc, String); + impl Drop for SegmentGuard { + fn drop(&mut self) { + self.0 + .segments + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&self.1); + } + } + let _guard = SegmentGuard(Arc::clone(&options.session), key.to_string()); + return stacker::maybe_grow(256 * 1024, 4 * 1024 * 1024, || { + unified_conditions(&segment.conditions, options) + }); + } + if let Some(values) = value.as_array() { + return condition_children(values, options, false); + } + let Some(object) = value.as_object() else { + return Ok(false); + }; + for group in ["and", "or", "not"] { + if let Some(values) = object.get(group) { + let Some(values) = values.as_array().filter(|v| !v.is_empty()) else { + return Ok(false); + }; + let result = condition_children(values, options, group == "or")?; + return Ok(if group == "not" { !result } else { result }); + } + } + // Feature variable leaves must be dispatched before feature flag leaves. + if object.contains_key("variable") { + let required = serde_json::from_value::(value.clone()) + .map_err(|error| EvaluationError::Input(error.to_string()))?; + return required_variable_is_matched(&required, options); + } + if let Some(feature) = object.get("feature").and_then(JsonValue::as_str) { + return required_feature_leaf( feature, - enabled, - variation, - } => (feature, enabled.unwrap_or(true), variation.as_deref()), - Required::LegacyVariation { key, variation } => (key, true, Some(variation.as_str())), + object + .get("enabled") + .and_then(JsonValue::as_bool) + .unwrap_or(true), + object.get("variation").and_then(JsonValue::as_str), + options, + ); + } + let Ok(condition) = PlainCondition::deserialize(value) else { + return Ok(false); + }; + Ok( + condition_is_matched(&condition, &options.context, &|pattern, flags| { + options.data.regex(pattern, flags) + }) + .unwrap_or_else(|error| { + let mut diagnostic = + Diagnostic::new(LogLevel::Warn, "condition_match_error", error.clone()); + diagnostic.original_error = Some(error); + (options.report)(diagnostic); + false + }), + ) +} + +fn composed_value( + compose: &JsonValue, + options: &EvaluateOptions, +) -> EvaluationResult { + if let Some(key) = compose.as_str().filter(|s| !s.is_empty()) { + return composition_source(None, Some(key), false, options); + } + let values = compose + .as_array() + .filter(|v| !v.is_empty()) + .ok_or_else(|| EvaluationError::Dependency("Invalid compose definition".into()))?; + let mut result = HashMap::new(); + for value in values { + let key = value + .get("key") + .and_then(JsonValue::as_str) + .filter(|s| !s.is_empty() && !["__proto__", "constructor", "prototype"].contains(s)) + .ok_or_else(|| EvaluationError::Dependency("Invalid composition property".into()))?; + let feature = value.get("feature").and_then(JsonValue::as_str); + let variable = value.get("variable").and_then(JsonValue::as_str); + let variation = value + .get("variation") + .and_then(JsonValue::as_bool) + .unwrap_or(false); + if feature.is_none() && variable.is_none() { + return Err(EvaluationError::Dependency( + "Invalid composition source".into(), + )); + } + result.insert( + key.to_string(), + composition_source(feature, variable, variation, options)?, + ); + } + Ok(VariableValue::Object(result)) +} + +fn composition_source( + feature: Option<&str>, + variable: Option<&str>, + variation: bool, + options: &EvaluateOptions, +) -> EvaluationResult { + let mut clean = clean_requirement_options(options); + clean.dependency_purpose = Some(DependencyPurpose::Composition); + let evaluation_type = if variable.is_some() { + EvaluationType::Variable + } else if variation { + EvaluationType::Variation + } else { + EvaluationType::Flag + }; + let evaluation = evaluate_with_modules(EvaluateOptions { + evaluation_type, + global_variable: feature.is_none(), + feature_key: feature.unwrap_or_default().to_string(), + variable_key: variable.map(str::to_string), + ..clean + }); + dependency_result(&evaluation)?; + Ok(match evaluation_type { + EvaluationType::Flag => VariableValue::Boolean(evaluation.enabled.unwrap_or(false)), + EvaluationType::Variation => evaluation + .variation_value + .or_else(|| evaluation.variation.map(|v| v.value)) + .map(VariableValue::String) + .unwrap_or(VariableValue::Null), + EvaluationType::Variable => normalized_variable_value(&evaluation, options.report.as_ref()) + .unwrap_or(VariableValue::Null), + }) +} + +fn evaluate_global_variable(options: &EvaluateOptions) -> EvaluationResult { + let variable_key = options.variable_key.clone().unwrap_or_default(); + let mut evaluation = basic( + EvaluationType::Variable, + String::new(), + EvaluationReason::VariableNotFound, + ); + evaluation.variable_key = Some(variable_key.clone()); + let variable = crate::normalize::variables( + &options.data.condition_cache.entities, + &options.data.datafile, + &variable_key, + options.report.as_ref(), + ); + if let Some(value) = options.sticky_variables.get(&variable_key) { + evaluation.reason = EvaluationReason::Sticky; + evaluation.variable_value = Some(value.clone()); + if !options.plain_getter + || !options.modules.is_empty() + || (options.should_report)(LogLevel::Debug) + || options + .data + .datafile + .variables + .get(&variable_key) + .is_some_and(|variable| variable.variable_type == "json") + { + evaluation.variable = variable + .as_ref() + .map(|v| crate::normalize::public_variable(v)); + } + return Ok(evaluation); + } + let Some(variable) = variable else { + return Ok(evaluation); + }; + if !options.plain_getter + || !options.modules.is_empty() + || variable.deprecated == Some(true) + || variable.variable_type == "json" + || (options.should_report)(LogLevel::Debug) + { + evaluation.variable = Some(crate::normalize::public_variable(&variable)); + } + if !variable_selector_matches(variable.conditions.as_ref(), options)? { + evaluation.reason = EvaluationReason::ConditionsUnmet; + evaluation.conditions = variable + .conditions + .as_ref() + .map(crate::normalize::public_conditions); + evaluation.variable_value = if variable.compose.is_some() { + Some( + variable + .disabled_value + .clone() + .unwrap_or(VariableValue::Null), + ) + } else if variable.use_default_when_disabled == Some(true) { + variable.default_value.clone() + } else { + variable.disabled_value.clone() + }; + return Ok(evaluation); + } + let initial = if let Some(compose) = &variable.compose { + Some(composed_value(compose, options)?) + } else { + variable.default_value.clone() + }; + if let Some(program) = &variable.override_program { + let mut value = initial.clone(); + let mut index = 0usize; + let mut paths = Vec::new(); + let mut last_match = None; + while index < program.len() { + let step = &program[index]; + let matched = program_step_matches(step, options)?; + if matched { + value = Some( + apply_variable_operations( + value.as_ref().unwrap_or(&VariableValue::Null), + &step.operations, + ) + .map_err(EvaluationError::Evaluation)?, + ); + let path = if step.key_path.is_empty() { + vec![step.key.clone()] + } else { + step.key_path.clone() + }; + paths.push(path); + last_match = Some(index); + } + let next = mutation_index(if matched { + &step.on_match + } else { + &step.on_miss + }) + .filter(|next| *next > index && *next <= program.len()) + .ok_or_else(|| { + EvaluationError::Evaluation("Invalid variable override program jump".into()) + })?; + index = next; + } + if let Some(index) = last_match { + let step = &program[index]; + evaluation.reason = EvaluationReason::VariableOverrideRule; + evaluation.variable_value = value; + evaluation.variable_override_index = Some(index); + evaluation.variable_override_key = Some(step.key.clone()); + evaluation.variable_override_path = + (!step.key_path.is_empty()).then(|| step.key_path.clone()); + evaluation.variable_override_paths = Some(paths); + return Ok(evaluation); + } + } else if variable.compose.is_none() { + for (index, item) in variable.overrides.iter().enumerate() { + if variable_selector_matches(item.conditions.as_ref(), options)? { + evaluation.reason = EvaluationReason::VariableOverrideRule; + evaluation.variable_value = Some(item.value.clone()); + evaluation.variable_override_index = Some(index); + evaluation.variable_override_key = item.key.clone(); + evaluation.variable_override_path = item.key_path.clone(); + return Ok(evaluation); + } + } + } + evaluation.reason = if variable.compose.is_some() { + EvaluationReason::VariableComposed + } else { + EvaluationReason::VariableDefault }; + evaluation.variable_value = initial; + Ok(evaluation) +} + +fn program_step_matches( + step: &VariableOverrideStep, + options: &EvaluateOptions, +) -> EvaluationResult { + variable_selector_matches(step.conditions.as_ref(), options) +} + +fn clean_requirement_options(options: &EvaluateOptions) -> EvaluateOptions { + let mut clean = options.clone(); + refresh_live_state(&mut clean); + clean.default_variation_value = None; + clean.default_variable_value = None; + clean.synthetic_segment_gate = false; + clean.dependency_of = Some(EvaluationDependency { + evaluation_type: options.evaluation_type, + feature_key: (!options.feature_key.is_empty()).then(|| options.feature_key.clone()), + variable_key: options.variable_key.clone(), + }); + clean.dependency_purpose = Some(DependencyPurpose::Condition); + clean +} + +fn required_feature_leaf( + key: &str, + expected_enabled: bool, + expected_variation: Option<&str>, + options: &EvaluateOptions, +) -> EvaluationResult { + let clean = clean_requirement_options(options); let flag = evaluate_with_modules(EvaluateOptions { + global_variable: false, evaluation_type: EvaluationType::Flag, - feature_key: key.clone(), + feature_key: key.to_string(), variable_key: None, - ..options.clone() + ..clean.clone() }); + dependency_result(&flag)?; if (flag.enabled == Some(true)) != expected_enabled { - return false; + return Ok(false); } - if let Some(expected) = expected { - let variation = evaluate_with_modules(EvaluateOptions { - evaluation_type: EvaluationType::Variation, - feature_key: key.clone(), - variable_key: None, - ..options.clone() - }); - variation.variation_value.as_deref().or_else(|| { - variation - .variation + let Some(expected) = expected_variation else { + return Ok(true); + }; + let variation = evaluate_with_modules(EvaluateOptions { + global_variable: false, + evaluation_type: EvaluationType::Variation, + feature_key: key.to_string(), + variable_key: None, + ..clean + }); + dependency_result(&variation)?; + Ok(variation.variation_value.as_deref().or_else(|| { + variation + .variation + .as_ref() + .map(|value| value.value.as_str()) + }) == Some(expected)) +} + +fn required_variable_value( + required: &VariableCondition, + options: &EvaluateOptions, +) -> EvaluationResult { + let clean = clean_requirement_options(options); + let evaluation = evaluate_with_modules(EvaluateOptions { + global_variable: required.feature.is_none(), + evaluation_type: EvaluationType::Variable, + feature_key: required.feature.clone().unwrap_or_default(), + variable_key: Some(required.variable.clone()), + ..clean + }); + dependency_result(&evaluation)?; + Ok( + normalized_variable_value(&evaluation, options.report.as_ref()) + .unwrap_or(VariableValue::Null), + ) +} + +pub(crate) fn normalized_variable_value( + evaluation: &Evaluation, + report: &dyn Fn(Diagnostic), +) -> Option { + if let Some(value) = &evaluation.shared_composition_value { + return Some((**value).clone()); + } + let value = evaluation.variable_value.clone()?; + normalize_owned_variable(value, evaluation, report) +} + +fn normalize_owned_variable( + value: VariableValue, + evaluation: &Evaluation, + report: &dyn Fn(Diagnostic), +) -> Option { + let variable_type = evaluation + .variable + .as_ref() + .map(|variable| variable.variable_type.as_str()) + .or_else(|| { + evaluation + .variable_schema .as_ref() - .map(|value| value.value.as_str()) - }) == Some(expected) - } else { - true + .map(|schema| schema.variable_type.as_str()) + }); + if variable_type == Some("json") + && !evaluation + .variable + .as_ref() + .is_some_and(|v| v.compose.is_some()) + { + if let VariableValue::String(raw) = value { + return match serde_json::from_str::(&raw) { + Ok(value) => Some(VariableValue::from_json(value)), + Err(error) => { + let mut diagnostic = + Diagnostic::new(LogLevel::Error, "evaluation_error", "getVariable failed"); + diagnostic.original_error = Some(error.to_string()); + if !evaluation.feature_key.is_empty() { + diagnostic.details.insert( + "featureKey".to_string(), + JsonValue::String(evaluation.feature_key.clone()), + ); + } + if let Some(variable_key) = &evaluation.variable_key { + diagnostic.details.insert( + "variableKey".to_string(), + JsonValue::String(variable_key.clone()), + ); + } + report(diagnostic); + None + } + }; + } } + Some(value) } -fn override_matches( - item: &VariableOverride, +fn required_path_value( + mut value: VariableValue, + path: Option<&JsonValue>, +) -> EvaluationResult<(bool, VariableValue)> { + let Some(path) = path else { + return Ok((true, value)); + }; + let values = path.as_array().ok_or_else(|| { + EvaluationError::Input("Variable requirement path must be compiled".into()) + })?; + for part in values { + if let Some(key) = part.as_str() { + if ["__proto__", "prototype", "constructor"].contains(&key) { + return Err(EvaluationError::Input( + "Unsafe variable requirement path".into(), + )); + } + match value { + VariableValue::Object(mut object) => { + let Some(next) = object.remove(key) else { + return Ok((false, VariableValue::Null)); + }; + value = next; + } + VariableValue::Array(values) if key == "length" => { + value = VariableValue::Integer(values.len() as i64); + } + VariableValue::Array(mut values) => { + let Ok(index) = key.parse::() else { + return Ok((false, VariableValue::Null)); + }; + if index.to_string() != key || index >= values.len() { + return Ok((false, VariableValue::Null)); + } + value = values.remove(index); + } + _ => return Ok((false, VariableValue::Null)), + } + continue; + } + let Some(index) = mutation_index(part) else { + return Err(EvaluationError::Input( + "Variable requirement path must be compiled".into(), + )); + }; + let VariableValue::Array(mut values) = value else { + return Ok((false, VariableValue::Null)); + }; + if index >= values.len() { + return Ok((false, VariableValue::Null)); + } + value = values.remove(index); + } + Ok((true, value)) +} + +fn mutation_index(value: &JsonValue) -> Option { + let JsonValue::Number(number) = value else { + return None; + }; + if let Some(value) = number.as_u64() { + return usize::try_from(value).ok(); + } + let value = number.as_f64()?; + (value.is_finite() && value >= 0.0 && value.fract() == 0.0).then_some(value as usize) +} + +fn required_variable_is_matched( + required: &VariableCondition, options: &EvaluateOptions, - data: &EvaluationData, - report: &dyn Fn(Diagnostic), -) -> bool { - if item.required_features.as_ref().is_some_and(|requirements| { - !requirements - .iter() - .all(|required| required_is_met(required, options)) - }) { - return false; +) -> EvaluationResult { + let value = required_variable_value(required, options)?; + let (exists, value) = required_path_value(value, required.path.as_ref())?; + let mut context = Context::new(); + if exists { + context.insert( + "value".to_string(), + AttributeValue::from_json(value.to_json()), + ); } - let conditions_match = item.conditions.as_ref().map_or(true, |conditions| { - data.all_conditions(conditions, &options.context, report) - }); - let segments_match = item.segments.as_ref().map_or(true, |segments| { - data.all_segments(segments, &options.context, report) - }); - conditions_match - && segments_match - && (item.conditions.is_some() - || item.segments.is_some() - || item.required_features.is_some()) + condition_is_matched( + &PlainCondition { + attribute: "value".to_string(), + operator: required.operator.clone(), + value: required.value.clone(), + regex_flags: required.regex_flags.clone(), + }, + &context, + &|pattern, flags| options.data.regex(pattern, flags), + ) + .map_err(EvaluationError::Input) +} + +fn matched_override<'a>( + overrides: &'a [VariableOverride], + options: &EvaluateOptions, +) -> EvaluationResult> { + for (index, item) in overrides.iter().enumerate() { + if let Some(conditions) = &item.conditions { + if selector_matches(conditions, options)? { + return Ok(Some((index, item))); + } + } + } + Ok(None) } pub(crate) fn evaluate_all( diff --git a/src/instance.rs b/src/instance.rs index eeba5c2..1ad429b 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -1,9 +1,10 @@ use crate::child::FeaturevisorChild; +use crate::conditions::ConditionCache; use crate::diagnostics::{Diagnostic, DiagnosticHandler, LogLevel}; use crate::emitter::Emitter; use crate::evaluate::{ - evaluate_all, evaluate_with_modules, EvaluateOptions, Evaluation, EvaluationData, - EvaluationReason, EvaluationType, + evaluate_all, evaluate_with_modules, normalized_variable_value, EvaluateOptions, Evaluation, + EvaluationData, EvaluationType, LiveState, }; use crate::events::{ ContextSetDetails, DatafileSetDetails, EventDetails, EventHandler, EventName, @@ -13,13 +14,14 @@ use crate::helpers::panic_message; use crate::modules::{FeaturevisorModule, ModuleApi, ModuleSubscription}; use crate::types::{ Context, DatafileContent, DatafileInput, EvaluatedFeatures, EvaluatedVariables, Feature, - Segment, StickyFeatures, StickyVariables, VariableValue, + GlobalVariable, RequiredFeature, Segment, StickyFeatures, StickyVariables, VariableOverride, + VariableValue, }; use serde_json::Value as JsonValue; use std::collections::{HashMap, HashSet}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock, Weak}; const EMPTY_REVISION: &str = "unknown"; @@ -31,6 +33,14 @@ pub struct OverrideOptions { pub default_variable_value: Option, } +pub(crate) struct ChildEvaluationState { + pub sticky_features: Arc, + pub sticky_variables: Arc, + pub live: Arc LiveState + Send + Sync>, + pub call_context: Context, + pub plain_getter: bool, +} + #[derive(Clone, Debug, Default)] /// Options used when creating a child evaluator. #[allow(missing_docs)] @@ -60,17 +70,22 @@ struct ModuleRecord { struct Inner { datafile: Arc, + dependency_index: Option, context: Context, - sticky_features: StickyFeatures, - sticky_variables: StickyVariables, + sticky_features: Arc, + sticky_variables: Arc, log_level: LogLevel, on_diagnostic: Option, modules: Vec, + // Phases share membership without allocating another callback list. + module_snapshot: Arc>>, subscriptions: Vec, emitter: Emitter, regex_cache: Arc>>, + condition_cache: Arc, closed: bool, next_module_id: u64, + state_generation: u64, } type Snapshot = ( @@ -81,31 +96,67 @@ type Snapshot = ( LogLevel, Vec>, Arc>>, + Arc, ); #[derive(Clone)] -/// A thread safe Featurevisor v3 datafile evaluator. +/// A thread safe Featurevisor datafile evaluator. pub struct Featurevisor { inner: Arc>, next_subscription_id: Arc, } +// Lifecycle helpers must not keep the instance alive through a module's +// stored subscription token or callback API. +#[derive(Clone)] +pub(crate) struct WeakFeaturevisor { + inner: Weak>, + next_subscription_id: Weak, +} + +impl WeakFeaturevisor { + pub(crate) fn modules(&self) -> Arc>> { + self.inner + .upgrade() + .map(|inner| { + Arc::clone( + &inner + .lock() + .unwrap_or_else(|error| error.into_inner()) + .module_snapshot, + ) + }) + .unwrap_or_default() + } + + fn upgrade(&self) -> Option { + Some(Featurevisor { + inner: self.inner.upgrade()?, + next_subscription_id: self.next_subscription_id.upgrade()?, + }) + } +} + /// Creates a Featurevisor evaluator from the supplied options. pub fn create_featurevisor(options: FeaturevisorOptions) -> Featurevisor { let instance = Featurevisor { inner: Arc::new(Mutex::new(Inner { datafile: Arc::new(DatafileContent::default()), context: options.context.unwrap_or_default(), - sticky_features: options.sticky_features.unwrap_or_default(), - sticky_variables: options.sticky_variables.unwrap_or_default(), + sticky_features: Arc::new(options.sticky_features.unwrap_or_default()), + sticky_variables: Arc::new(options.sticky_variables.unwrap_or_default()), log_level: options.log_level.unwrap_or_default(), on_diagnostic: options.on_diagnostic, + dependency_index: None, modules: Vec::new(), + module_snapshot: Arc::new(Vec::new()), subscriptions: Vec::new(), emitter: Emitter::default(), regex_cache: Arc::new(RwLock::new(HashMap::new())), + condition_cache: Arc::new(ConditionCache::default()), closed: false, next_module_id: 1, + state_generation: 0, })), next_subscription_id: Arc::new(AtomicU64::new(1)), }; @@ -123,13 +174,20 @@ pub fn create_featurevisor(options: FeaturevisorOptions) -> Featurevisor { } impl Featurevisor { + fn downgrade(&self) -> WeakFeaturevisor { + WeakFeaturevisor { + inner: Arc::downgrade(&self.inner), + next_subscription_id: Arc::downgrade(&self.next_subscription_id), + } + } + fn snapshot(&self) -> Snapshot { let inner = self.inner.lock().unwrap_or_else(|error| error.into_inner()); ( Arc::clone(&inner.datafile), inner.context.clone(), - inner.sticky_features.clone(), - inner.sticky_variables.clone(), + (*inner.sticky_features).clone(), + (*inner.sticky_variables).clone(), inner.log_level, inner .modules @@ -137,22 +195,49 @@ impl Featurevisor { .map(|record| Arc::clone(&record.module)) .collect(), Arc::clone(&inner.regex_cache), + Arc::clone(&inner.condition_cache), ) } + pub(crate) fn live_state(&self) -> LiveState { + let inner = self.inner.lock().unwrap_or_else(|error| error.into_inner()); + LiveState { + generation: inner.state_generation, + stored_context: inner.context.clone(), + sticky_features: Arc::clone(&inner.sticky_features), + sticky_variables: Arc::clone(&inner.sticky_variables), + data: Arc::new(EvaluationData { + datafile: Arc::clone(&inner.datafile), + regex_cache: Arc::clone(&inner.regex_cache), + condition_cache: Arc::clone(&inner.condition_cache), + }), + modules: Arc::clone(&inner.module_snapshot), + } + } + fn module_api(&self, id: u64, name: Option) -> ModuleApi { - let instance_for_revision = self.clone(); - let instance_for_subscribe = self.clone(); - let instance_for_report = self.clone(); + let instance_for_revision = self.downgrade(); + let instance_for_subscribe = self.downgrade(); + let instance_for_report = self.downgrade(); let module_name = name.clone(); ModuleApi { - get_revision: Arc::new(move || instance_for_revision.get_revision()), + get_revision: Arc::new(move || { + instance_for_revision + .upgrade() + .map(|instance| instance.get_revision()) + .unwrap_or_else(|| EMPTY_REVISION.into()) + }), on_diagnostic: Arc::new(move |handler, log_level| { - instance_for_subscribe.subscribe_module(id, handler, log_level) + match instance_for_subscribe.upgrade() { + Some(instance) => instance.subscribe_module(id, handler, log_level), + None => Box::new(|| {}), + } }), report_diagnostic: Arc::new(move |mut diagnostic| { diagnostic.module = module_name.clone(); - instance_for_report.report_diagnostic(diagnostic, Some(id)); + if let Some(instance) = instance_for_report.upgrade() { + instance.report_diagnostic(diagnostic, Some(id)); + } }), } } @@ -174,27 +259,33 @@ impl Featurevisor { }); } } - let instance = self.clone(); + let instance = self.downgrade(); let active = Arc::new(std::sync::atomic::AtomicBool::new(true)); let active_for_closure = Arc::clone(&active); Box::new(move || { if !active_for_closure.swap(false, Ordering::AcqRel) { return; } - if let Ok(mut inner) = instance.inner.lock() { - inner - .subscriptions - .retain(|subscription| subscription.id != subscription_id); + if let Some(instance) = instance.upgrade() { + instance.remove_subscriptions(|s| s.id == subscription_id); } }) } fn clear_module_subscriptions(&self, module_id: u64) { - if let Ok(mut inner) = self.inner.lock() { - inner - .subscriptions - .retain(|subscription| subscription.module_id != module_id); - } + self.remove_subscriptions(|s| s.module_id == module_id); + } + + fn remove_subscriptions(&self, matches: impl Fn(&ModuleSubscription) -> bool) { + let removed = self.inner.lock().ok().map(|mut inner| { + let (removed, retained): (Vec<_>, Vec<_>) = std::mem::take(&mut inner.subscriptions) + .into_iter() + .partition(matches); + inner.subscriptions = retained; + removed + }); + // Dropping a captured value can execute user code too. + drop(removed); } fn report_diagnostic(&self, diagnostic: Diagnostic, source_module: Option) { @@ -233,6 +324,26 @@ impl Featurevisor { } } + fn should_report_diagnostic(&self, level: LogLevel) -> bool { + // Errors also feed the error event, independently of diagnostic levels. + // Check current subscriptions, since callbacks can change them during + // an evaluation. No lock is held while constructing or delivering it. + level == LogLevel::Error + || self.inner.lock().is_ok_and(|inner| { + (inner.on_diagnostic.is_some() && inner.log_level.allows(level)) + || inner + .subscriptions + .iter() + .any(|s| s.log_level.allows(level)) + }) + } + + pub(crate) fn normalized_getter_value(&self, evaluation: &Evaluation) -> Option { + normalized_variable_value(evaluation, &|diagnostic| { + self.report_diagnostic(diagnostic, None) + }) + } + /// Sets the minimum diagnostic level delivered to the instance handler. pub fn set_log_level(&self, level: LogLevel) { if let Ok(mut inner) = self.inner.lock() { @@ -243,7 +354,8 @@ impl Featurevisor { } /// Merges a datafile into the current one, or replaces it when `replace` is true. - pub fn set_datafile(&self, input: DatafileInput, replace: bool) { + /// Returns true when accepted, including unchanged input, or false when rejected or closed. + pub fn set_datafile(&self, input: DatafileInput, replace: bool) -> bool { let parsed = match input { DatafileInput::Content(content) => Some(content), DatafileInput::Json(json) => match serde_json::from_str::(&json) { @@ -256,40 +368,64 @@ impl Featurevisor { ); diagnostic.original_error = Some(error.to_string()); self.report_diagnostic(diagnostic, None); - return; + return false; } }, }; - let Some(incoming) = parsed.filter(|datafile| !datafile.revision.is_empty()) else { + let Some(mut incoming) = parsed.filter(|datafile| !datafile.revision.is_empty()) else { let diagnostic = Diagnostic::new( LogLevel::Error, "invalid_datafile", "Could not parse datafile", ); self.report_diagnostic(diagnostic, None); - return; + return false; }; - let (previous, next, emitter) = { + let errors = if incoming.schema_version == "2" { + crate::normalize::datafile(&mut incoming) + } else { + Vec::new() + }; + let (details, emitter) = { let mut inner = match self.inner.lock() { Ok(inner) => inner, - Err(_) => return, + Err(_) => return false, }; if inner.closed { - return; + return false; } let previous = Arc::clone(&inner.datafile); + let cache = if replace { + ConditionCache::default() + } else { + inner.condition_cache.partial(&incoming, &previous) + }; + let touched = incoming_entities(&incoming); let next = if replace { incoming } else { merge_datafile(&previous, incoming) }; - inner.datafile = Arc::new(next.clone()); + let details = datafile_details( + &previous, + &next, + replace, + &touched, + &mut inner.dependency_index, + ); + inner.datafile = Arc::new(next); + inner.state_generation += 1; + // Old evaluations retain their own cache and immutable snapshot. + // New definitions cannot repopulate a previous generation's cache. + inner.condition_cache = Arc::new(cache); if let Ok(mut cache) = inner.regex_cache.write() { cache.clear(); } - (previous, next, inner.emitter.clone()) + (details, inner.emitter.clone()) }; - let details = datafile_details(&previous, &next, replace); + for error in errors { + self.report_diagnostic(error, None); + } let mut diagnostic = Diagnostic::new(LogLevel::Info, "datafile_set", "Datafile set"); diagnostic.details = serde_json::to_value(&details) .ok() @@ -298,6 +434,7 @@ impl Featurevisor { .unwrap_or_default(); self.report_diagnostic(diagnostic, None); emitter.emit(EventName::DatafileSet, EventDetails::DatafileSet(details)); + true } /// Returns the current datafile revision. @@ -316,26 +453,35 @@ impl Featurevisor { } /// Returns a feature definition by key. pub fn get_feature(&self, feature_key: &str) -> Option { - self.inner - .lock() - .ok() - .and_then(|inner| inner.datafile.features.get(feature_key).cloned()) + let state = self.live_state(); + crate::normalize::features( + &state.data.condition_cache.entities, + &state.data.datafile, + feature_key, + &|error| self.report_diagnostic(error, None), + ) + .map(|value| crate::normalize::public_feature(&value)) } /// Returns a segment definition by key. pub fn get_segment(&self, segment_key: &str) -> Option { - let mut segment = self - .inner - .lock() - .ok() - .and_then(|inner| inner.datafile.segments.get(segment_key).cloned())?; - if let JsonValue::String(value) = &segment.conditions { - if value == "*" { - segment.conditions = JsonValue::String(value.clone()); - } else if let Ok(parsed) = serde_json::from_str(value) { - segment.conditions = parsed; - } - } - Some(segment) + self.read_segment(segment_key).map(|value| { + let mut value = (*value).clone(); + value.conditions = crate::normalize::public_conditions(&value.conditions); + value + }) + } + + fn read_segment(&self, segment_key: &str) -> Option> { + let (datafile, cache) = { + let inner = self.inner.lock().unwrap_or_else(|error| error.into_inner()); + ( + Arc::clone(&inner.datafile), + Arc::clone(&inner.condition_cache), + ) + }; + crate::normalize::segments(&cache.entities, &datafile, segment_key, &|error| { + self.report_diagnostic(error, None) + }) } /// Returns the keys of all features in the current datafile. pub fn get_feature_keys(&self) -> Vec { @@ -369,18 +515,56 @@ impl Featurevisor { } #[cfg(feature = "cli")] - pub(crate) fn segment_matches(&self, segment_key: &str, context: &Context) -> bool { - let (datafile, _, _, _, _, _, regex_cache) = self.snapshot(); - let data = EvaluationData { - datafile, - regex_cache, - }; - let report = |_diagnostic: Diagnostic| {}; - data.all_segments( - &JsonValue::String(segment_key.to_string()), - context, - &report, - ) + pub(crate) fn segment_matches( + &self, + segment_key: &str, + fallback: &HashMap, + context: &Context, + child: Option, + ) -> Result { + let mut options = self.evaluate_options(EvaluationType::Flag, "", Some(context), None); + if let Some(child) = child { + self.apply_child_state(&mut options, child); + } + let mut datafile = (*options.data.datafile).clone(); + for (key, segment) in fallback { + datafile + .segments + .entry(key.clone()) + .or_insert_with(|| segment.clone()); + } + if !datafile.segments.contains_key(segment_key) { + return Err(format!("Segment {segment_key:?} is missing")); + } + let mut key = format!("__featurevisor_segment_test__{segment_key}"); + while datafile.features.contains_key(&key) { + key.push('_'); + } + datafile.features.insert( + key.clone(), + serde_json::from_value(serde_json::json!({ + "bucketBy":"__segment_test__", "conditions":segment_key, + "traffic":[{"key":"segment","conditions":"*","percentage":100000}] + })) + .expect("segment gate"), + ); + options.feature_key = key; + options.synthetic_segment_gate = true; + options.data = Arc::new(EvaluationData { + datafile: Arc::new(datafile), + regex_cache: Arc::clone(&options.data.regex_cache), + condition_cache: Arc::new(ConditionCache::default()), + }); + // The temporary snapshot must not be replaced by a live refresh during dependencies. + options.live_state = None; + options.segment_source = None; + let evaluation = evaluate_with_modules(options); + if evaluation.reason == crate::EvaluationReason::Error { + return Err(evaluation + .error + .unwrap_or_else(|| "Segment evaluation failed".into())); + } + Ok(evaluation.enabled.unwrap_or(false)) } /// Updates the stored context, either merging with or replacing it. @@ -398,6 +582,7 @@ impl Featurevisor { } else { inner.context.extend(context); } + inner.state_generation += 1; (inner.context.clone(), inner.emitter.clone()) }; emitter.emit( @@ -428,7 +613,7 @@ impl Featurevisor { /// Returns the stored context merged with an optional per evaluation context. pub fn get_context(&self, context: Option<&Context>) -> Context { - let (_, stored, _, _, _, _, _) = self.snapshot(); + let (_, stored, _, _, _, _, _, _) = self.snapshot(); let mut result = stored; if let Some(context) = context { result.extend(context.clone()); @@ -447,10 +632,11 @@ impl Featurevisor { } let mut features: Vec = inner.sticky_features.keys().cloned().collect(); if replace { - inner.sticky_features = sticky; + inner.sticky_features = Arc::new(sticky); } else { - inner.sticky_features.extend(sticky); + Arc::make_mut(&mut inner.sticky_features).extend(sticky); } + inner.state_generation += 1; for key in inner.sticky_features.keys() { if !features.contains(key) { features.push(key.clone()); @@ -488,10 +674,11 @@ impl Featurevisor { } let mut variables: Vec = inner.sticky_variables.keys().cloned().collect(); if replace { - inner.sticky_variables = sticky; + inner.sticky_variables = Arc::new(sticky); } else { - inner.sticky_variables.extend(sticky); + Arc::make_mut(&mut inner.sticky_variables).extend(sticky); } + inner.state_generation += 1; for key in inner.sticky_variables.keys() { if !variables.contains(key) { variables.push(key.clone()); @@ -522,12 +709,12 @@ impl Featurevisor { /// Registers a module and returns an idempotent cleanup function. pub fn add_module(&self, module: Arc) -> Option { + let name = module.name().map(str::to_string); let (id, name) = { let mut inner = self.inner.lock().ok()?; if inner.closed { return None; } - let name = module.name().map(str::to_string); if name.as_ref().is_some_and(|name| { inner .modules @@ -569,15 +756,19 @@ impl Featurevisor { name, module: Arc::clone(&module), }); + Arc::make_mut(&mut inner.module_snapshot).push(Arc::clone(&module)); + inner.state_generation += 1; } - let instance = self.clone(); + let instance = self.downgrade(); let active = Arc::new(std::sync::atomic::AtomicBool::new(true)); let active_for_closure = Arc::clone(&active); Some(Box::new(move || { if !active_for_closure.swap(false, std::sync::atomic::Ordering::AcqRel) { return; } - instance.remove_module_by_id(id); + if let Some(instance) = instance.upgrade() { + instance.remove_module_by_id(id); + } })) } @@ -585,6 +776,8 @@ impl Featurevisor { let module = self.inner.lock().ok().and_then(|mut inner| { let index = inner.modules.iter().position(|module| module.id == id)?; let record = inner.modules.remove(index); + Arc::make_mut(&mut inner.module_snapshot).remove(index); + inner.state_generation += 1; Some((record.module, record.name)) }); self.clear_module_subscriptions(id); @@ -609,6 +802,16 @@ impl Featurevisor { } } inner.modules = remaining; + if !removed.is_empty() { + inner.module_snapshot = Arc::new( + inner + .modules + .iter() + .map(|record| Arc::clone(&record.module)) + .collect(), + ); + inner.state_generation += 1; + } removed }) .unwrap_or_default(); @@ -629,10 +832,15 @@ impl Featurevisor { /// Subscribes to an instance event and returns an idempotent cleanup function. pub fn on(&self, event: EventName, callback: EventHandler) -> crate::Unsubscribe { - self.inner - .lock() - .map(|inner| inner.emitter.on(event, callback)) - .unwrap_or_else(|_| Box::new(|| {})) + let inner = match self.inner.lock() { + Ok(inner) => inner, + Err(_) => return Box::new(|| {}), + }; + if inner.closed { + drop(inner); + return Box::new(|| {}); + } + inner.emitter.on(event, callback) } fn evaluate_options( @@ -642,8 +850,8 @@ impl Featurevisor { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> EvaluateOptions { - let (datafile, stored_context, sticky, _, _, modules, regex_cache) = self.snapshot(); - let mut evaluation_context = stored_context; + let live = self.live_state(); + let mut evaluation_context = live.stored_context.clone(); if let Some(context) = context { evaluation_context.extend(context.clone()); } @@ -655,16 +863,36 @@ impl Featurevisor { context: evaluation_context, default_variation_value: options.default_variation_value, default_variable_value: options.default_variable_value, - sticky: Some(sticky), - data: Arc::new(EvaluationData { - datafile, - regex_cache, - }), - modules: Arc::new(modules), + global_variable: false, + plain_getter: false, + sticky_features: live.sticky_features, + sticky_variables: live.sticky_variables, + dependency_of: None, + dependency_purpose: None, + synthetic_segment_gate: false, + session: Arc::new(crate::evaluate::EvaluationSession::default()), + data: live.data, + modules: live.modules, + module_source: Some(self.downgrade()), report: Arc::new({ let instance = self.clone(); move |diagnostic| instance.report_diagnostic(diagnostic, None) }), + should_report: Arc::new({ + let instance = self.clone(); + move |level| instance.should_report_diagnostic(level) + }), + live_state: Some(Arc::new({ + let instance = self.clone(); + move || instance.live_state() + })), + segment_source: Some(Arc::new({ + let instance = self.clone(); + move |key| instance.read_segment(key) + })), + seen_generation: live.generation, + stored_context_snapshot: live.stored_context, + call_context: context.cloned().unwrap_or_default(), } } fn evaluate_options_variable( @@ -690,7 +918,9 @@ impl Featurevisor { } /// Returns whether a feature is enabled for the supplied context. pub fn is_enabled(&self, feature_key: &str, context: Option<&Context>) -> bool { - self.evaluate_flag(feature_key, context).enabled == Some(true) + let mut options = self.evaluate_options(EvaluationType::Flag, feature_key, context, None); + options.plain_getter = true; + evaluate_with_modules(options).enabled == Some(true) } /// Evaluates a feature variation and returns evaluation details. pub fn evaluate_variation( @@ -713,7 +943,10 @@ impl Featurevisor { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> Option { - let evaluation = self.evaluate_variation(feature_key, context, options); + let mut evaluate_options = + self.evaluate_options(EvaluationType::Variation, feature_key, context, options); + evaluate_options.plain_getter = true; + let evaluation = evaluate_with_modules(evaluate_options); evaluation .variation_value .or_else(|| evaluation.variation.map(|variation| variation.value)) @@ -741,21 +974,11 @@ impl Featurevisor { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> Option { - let evaluation = self.evaluate_variable(feature_key, variable_key, context, options); - let value = evaluation.variable_value?; - if evaluation - .variable_schema - .as_ref() - .map(|schema| schema.variable_type == "json") - .unwrap_or(false) - { - if let VariableValue::String(value) = value { - return serde_json::from_str::(&value) - .ok() - .map(VariableValue::from_json); - } - } - Some(value) + let mut evaluate_options = + self.evaluate_options_variable(feature_key, variable_key, context, options); + evaluate_options.plain_getter = true; + let evaluation = evaluate_with_modules(evaluate_options); + self.normalized_getter_value(&evaluation) } /// Returns a variable as a boolean when its value has that type. pub fn get_variable_boolean( @@ -856,23 +1079,11 @@ impl Featurevisor { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> Evaluation { - let (sticky_features, sticky_variables) = self - .inner - .lock() - .map(|inner| { - ( - inner.sticky_features.clone(), - inner.sticky_variables.clone(), - ) - }) - .unwrap_or_default(); - self.evaluate_global_variable_with_sticky( - variable_key, - context, - options, - sticky_features, - sticky_variables, - ) + let mut evaluate_options = + self.evaluate_options(EvaluationType::Variable, "", context, options); + evaluate_options.global_variable = true; + evaluate_options.variable_key = Some(variable_key.to_string()); + evaluate_with_modules(evaluate_options) } pub(crate) fn evaluate_global_variable_with_sticky( @@ -880,246 +1091,14 @@ impl Featurevisor { variable_key: &str, context: Option<&Context>, options: Option<&OverrideOptions>, - sticky_features: StickyFeatures, - sticky_variables: StickyVariables, + child: ChildEvaluationState, ) -> Evaluation { - match catch_unwind(AssertUnwindSafe(|| { - self.evaluate_global_variable_inner( - variable_key, - context, - options, - sticky_features, - sticky_variables, - ) - })) { - Ok(evaluation) => evaluation, - Err(error) => { - let mut evaluation = self.empty_global_variable_evaluation(variable_key); - evaluation.reason = EvaluationReason::Error; - evaluation.error = Some(panic_message(error.as_ref())); - let mut diagnostic = Diagnostic::new( - LogLevel::Error, - "evaluation_error", - "Global variable evaluation failed", - ); - diagnostic.original_error = evaluation.error.clone(); - diagnostic.details.insert( - "evaluation".to_string(), - serde_json::to_value(&evaluation).unwrap_or(JsonValue::Null), - ); - self.report_diagnostic(diagnostic, None); - evaluation - } - } - } - - fn empty_global_variable_evaluation(&self, variable_key: &str) -> Evaluation { - Evaluation { - evaluation_type: EvaluationType::Variable, - feature_key: String::new(), - reason: EvaluationReason::VariableNotFound, - bucket_key: None, - bucket_value: None, - rule_key: None, - error: None, - enabled: None, - traffic: None, - force_index: None, - force: None, - required_features: None, - sticky: None, - variation: None, - variation_value: None, - variable_key: Some(variable_key.to_string()), - variable_value: None, - variable_schema: None, - variable: None, - variable_override_index: None, - variable_override_key: None, - variable_override_path: None, - } - } - - fn evaluate_global_variable_inner( - &self, - variable_key: &str, - context: Option<&Context>, - options: Option<&OverrideOptions>, - sticky_features: StickyFeatures, - sticky_variables: StickyVariables, - ) -> Evaluation { - let (datafile, stored_context, _, _, _, modules, regex_cache) = self.snapshot(); - let mut resolved_context = stored_context; - if let Some(context) = context { - resolved_context.extend(context.clone()); - } - let defaults = options.cloned().unwrap_or_default(); - let report: Arc = Arc::new({ - let instance = self.clone(); - move |diagnostic| instance.report_diagnostic(diagnostic, None) - }); - let data = Arc::new(EvaluationData { - datafile: Arc::clone(&datafile), - regex_cache, - }); - let mut evaluation_options = EvaluateOptions { - evaluation_type: EvaluationType::Variable, - feature_key: String::new(), - variable_key: Some(variable_key.to_string()), - context: resolved_context, - default_variation_value: defaults.default_variation_value, - default_variable_value: defaults.default_variable_value.clone(), - sticky: None, - data: Arc::clone(&data), - modules: Arc::new(modules.clone()), - report: Arc::clone(&report), - }; - for module in &modules { - evaluation_options = module.before_evaluation(evaluation_options); - } - let resolved_key = evaluation_options - .variable_key - .clone() - .unwrap_or_else(|| variable_key.to_string()); - let mut evaluation = self.empty_global_variable_evaluation(&resolved_key); - if let Some(value) = sticky_variables.get(&resolved_key) { - evaluation.reason = EvaluationReason::Sticky; - evaluation.variable_value = Some(value.clone()); - } else if let Some(variable) = datafile.variables.get(&resolved_key) { - evaluation.variable = Some(variable.clone()); - let requirements_match = self.required_features_match( - variable.required_features.as_deref(), - &evaluation_options.context, - options, - &sticky_features, - ); - if !requirements_match { - evaluation.reason = EvaluationReason::RequiredFeaturesUnmet; - evaluation.variable_value = if variable.use_default_when_disabled == Some(true) { - variable.default_value.clone() - } else { - variable.disabled_value.clone() - }; - } else { - for (index, item) in variable.overrides.iter().enumerate() { - if !self.required_features_match( - item.required_features.as_deref(), - &evaluation_options.context, - options, - &sticky_features, - ) { - continue; - } - let conditions_match = item.conditions.as_ref().map_or(true, |conditions| { - data.all_conditions( - conditions, - &evaluation_options.context, - report.as_ref(), - ) - }); - let segments_match = item.segments.as_ref().map_or(true, |segments| { - data.all_segments(segments, &evaluation_options.context, report.as_ref()) - }); - if conditions_match && segments_match { - evaluation.reason = EvaluationReason::VariableOverrideRule; - evaluation.variable_value = Some(item.value.clone()); - evaluation.variable_override_index = Some(index); - evaluation.variable_override_key = item.key.clone(); - evaluation.variable_override_path = item.key_path.clone(); - break; - } - } - if evaluation.reason == EvaluationReason::VariableNotFound { - evaluation.reason = EvaluationReason::VariableDefault; - evaluation.variable_value = variable.default_value.clone(); - } - } - } - if evaluation.variable_value.is_none() { - evaluation.variable_value = defaults.default_variable_value; - } - for module in &modules { - evaluation = module.after_evaluation(evaluation, &evaluation_options); - } - if evaluation - .variable - .as_ref() - .is_some_and(|variable| variable.deprecated == Some(true)) - { - let mut diagnostic = Diagnostic::new( - LogLevel::Warn, - "variable_deprecated", - "Global variable is deprecated", - ); - diagnostic - .details - .insert("variableKey".to_string(), JsonValue::String(resolved_key)); - self.report_diagnostic(diagnostic, None); - } - let reason = serde_json::to_string(&evaluation.reason) - .unwrap_or_else(|_| "evaluation".to_string()) - .trim_matches('"') - .to_string(); - let mut diagnostic = Diagnostic::new(LogLevel::Debug, reason, "Global variable evaluated"); - diagnostic.details = serde_json::to_value(&evaluation) - .ok() - .and_then(|value| value.as_object().cloned()) - .map(|value| value.into_iter().collect()) - .unwrap_or_default(); - self.report_diagnostic(diagnostic, None); - evaluation - } - - fn required_features_match( - &self, - requirements: Option<&[crate::types::Required]>, - context: &Context, - options: Option<&OverrideOptions>, - sticky_features: &StickyFeatures, - ) -> bool { - requirements.unwrap_or_default().iter().all(|required| { - let (key, enabled, variation) = match required { - crate::types::Required::Feature(key) => (key.as_str(), true, None), - crate::types::Required::Details { - feature, - enabled, - variation, - } => ( - feature.as_str(), - enabled.unwrap_or(true), - variation.as_deref(), - ), - crate::types::Required::LegacyVariation { key, variation } => { - (key.as_str(), true, Some(variation.as_str())) - } - }; - let enabled_evaluation = self.evaluate_child( - EvaluationType::Flag, - key, - None, - Some(context), - options, - sticky_features.clone(), - ); - if (enabled_evaluation.enabled == Some(true)) != enabled { - return false; - } - variation.map_or(true, |expected| { - let evaluation = self.evaluate_child( - EvaluationType::Variation, - key, - None, - Some(context), - options, - sticky_features.clone(), - ); - evaluation - .variation_value - .or_else(|| evaluation.variation.map(|value| value.value)) - .as_deref() - == Some(expected) - }) - }) + let mut evaluate_options = + self.evaluate_options(EvaluationType::Variable, "", context, options); + evaluate_options.global_variable = true; + evaluate_options.variable_key = Some(variable_key.to_string()); + self.apply_child_state(&mut evaluate_options, child); + evaluate_with_modules(evaluate_options) } /// Returns a global variable value, if one is available. @@ -1129,21 +1108,13 @@ impl Featurevisor { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> Option { - let evaluation = self.evaluate_global_variable(variable_key, context, options); - let is_json = evaluation - .variable - .as_ref() - .map(|variable| variable.variable_type == "json") - .unwrap_or(false); - let value = evaluation.variable_value?; - if is_json { - if let VariableValue::String(value) = value { - return serde_json::from_str::(&value) - .ok() - .map(VariableValue::from_json); - } - } - Some(value) + let mut evaluate_options = + self.evaluate_options(EvaluationType::Variable, "", context, options); + evaluate_options.global_variable = true; + evaluate_options.variable_key = Some(variable_key.to_string()); + evaluate_options.plain_getter = true; + let evaluation = evaluate_with_modules(evaluate_options); + self.normalized_getter_value(&evaluation) } /// Returns a global variable as a boolean when its value has that type. pub fn get_global_variable_boolean( @@ -1276,7 +1247,7 @@ impl Featurevisor { } /// Closes the instance, modules, subscriptions, and event listeners. pub fn close(&self) { - let modules = { + let (modules, subscriptions, emitter) = { let mut inner = match self.inner.lock() { Ok(inner) => inner, Err(_) => return, @@ -1285,19 +1256,47 @@ impl Featurevisor { return; } inner.closed = true; - inner.subscriptions.clear(); - inner.emitter.clear(); - std::mem::take(&mut inner.modules) + inner.module_snapshot = Arc::new(Vec::new()); + inner.dependency_index = None; + inner.state_generation += 1; + inner.condition_cache = Arc::new(ConditionCache::default()); + inner.regex_cache = Arc::new(RwLock::new(HashMap::new())); + ( + std::mem::take(&mut inner.modules), + std::mem::take(&mut inner.subscriptions), + inner.emitter.clone(), + ) }; + emitter.clear(); + drop(subscriptions); for record in modules { self.close_module(record.module, record.name); } + // Keep the handler until module shutdown diagnostics have been sent, + // then release it outside the mutex to break captured instance cycles. + let handler = self + .inner + .lock() + .ok() + .and_then(|mut inner| inner.on_diagnostic.take()); + drop(handler); } pub(crate) fn parent_on(&self, event: EventName, callback: EventHandler) -> crate::Unsubscribe { self.on(event, callback) } + fn apply_child_state(&self, options: &mut EvaluateOptions, child: ChildEvaluationState) { + let current = (child.live)(); + options.sticky_features = child.sticky_features; + options.sticky_variables = child.sticky_variables; + options.live_state = Some(child.live); + options.seen_generation = current.generation; + options.stored_context_snapshot = current.stored_context; + options.call_context = child.call_context; + options.plain_getter = child.plain_getter; + } + pub(crate) fn evaluate_child( &self, evaluation_type: EvaluationType, @@ -1305,12 +1304,12 @@ impl Featurevisor { variable_key: Option<&str>, context: Option<&Context>, options: Option<&OverrideOptions>, - sticky: StickyFeatures, + child: ChildEvaluationState, ) -> Evaluation { let mut evaluate_options = self.evaluate_options(evaluation_type, feature_key, context, options); evaluate_options.variable_key = variable_key.map(str::to_string); - evaluate_options.sticky = Some(sticky); + self.apply_child_state(&mut evaluate_options, child); evaluate_with_modules(evaluate_options) } @@ -1320,17 +1319,18 @@ impl Featurevisor { variable_key: &str, context: Context, options: Option<&OverrideOptions>, - sticky: StickyFeatures, + mut child: ChildEvaluationState, ) -> Option { + child.plain_getter = true; let evaluation = self.evaluate_child( EvaluationType::Variable, feature_key, Some(variable_key), Some(&context), options, - sticky, + child, ); - evaluation.variable_value + self.normalized_getter_value(&evaluation) } pub(crate) fn get_feature_evaluations_with_sticky( @@ -1338,11 +1338,13 @@ impl Featurevisor { context: Option<&Context>, feature_keys: &[String], options: Option<&OverrideOptions>, - sticky: StickyFeatures, + sticky_features: Arc, + sticky_variables: Arc, ) -> EvaluatedFeatures { let mut evaluate_options = self.evaluate_options(EvaluationType::Flag, "", context, options); - evaluate_options.sticky = Some(sticky); + evaluate_options.sticky_features = sticky_features; + evaluate_options.sticky_variables = sticky_variables; let keys = if feature_keys.is_empty() { self.get_feature_keys() } else { @@ -1353,118 +1355,288 @@ impl Featurevisor { } fn merge_datafile(previous: &DatafileContent, incoming: DatafileContent) -> DatafileContent { - let mut segments = previous.segments.clone(); - segments.extend(incoming.segments); - let mut features = previous.features.clone(); - features.extend(incoming.features); DatafileContent { schema_version: incoming.schema_version, revision: incoming.revision, featurevisor_version: incoming.featurevisor_version, - segments, - features, - variables: { - let mut variables = previous.variables.clone(); - variables.extend(incoming.variables); - variables - }, + segments: merge_entities(&previous.segments, incoming.segments), + features: merge_entities(&previous.features, incoming.features), + variables: merge_entities(&previous.variables, incoming.variables), + } +} + +fn merge_entities( + previous: &HashMap, + mut incoming: HashMap, +) -> HashMap { + // Sparse updates keep the existing table layout instead of hashing every + // retained key again to rebuild it from a small incoming map. + if incoming.len() < previous.len() { + let mut merged = previous.clone(); + merged.extend(incoming); + return merged; + } + // Incoming definitions already have owned storage. Only retained definitions + // need copying so old evaluation snapshots remain immutable. + for (key, value) in previous { + if !incoming.contains_key(key) { + incoming.insert(key.clone(), value.clone()); + } + } + incoming +} +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +enum EntityKind { + Feature, + Variable, + Segment, +} +type Entity = (EntityKind, String); + +#[derive(Default)] +struct DatafileDependencyIndex { + owners: HashMap>, + reverse: HashMap>, + #[cfg(test)] + extractions: usize, +} + +fn incoming_entities(data: &DatafileContent) -> Vec { + data.features + .keys() + .map(|key| (EntityKind::Feature, key.clone())) + .chain( + data.variables + .keys() + .map(|key| (EntityKind::Variable, key.clone())), + ) + .chain( + data.segments + .keys() + .map(|key| (EntityKind::Segment, key.clone())), + ) + .collect() +} + +impl DatafileDependencyIndex { + fn remove(&mut self, owner: &Entity) { + if let Some(dependencies) = self.owners.remove(owner) { + for dependency in dependencies { + if let Some(owners) = self.reverse.get_mut(&dependency) { + owners.remove(owner); + if owners.is_empty() { + self.reverse.remove(&dependency); + } + } + } + } + } + + fn add(&mut self, owner: Entity, data: &DatafileContent) { + let dependencies = match owner.0 { + EntityKind::Feature => feature_dependencies(&data.features[&owner.1]), + EntityKind::Variable => variable_dependencies(&data.variables[&owner.1]), + EntityKind::Segment => { + let mut output = EntityDependencies::default(); + visit_conditions(&data.segments[&owner.1].conditions, &mut output); + output + } + }; + #[cfg(test)] + { + self.extractions += 1; + } + let edges: HashSet<_> = dependencies + .features + .into_iter() + .map(|key| (EntityKind::Feature, key)) + .chain( + dependencies + .variables + .into_iter() + .map(|key| (EntityKind::Variable, key)), + ) + .chain( + dependencies + .segments + .into_iter() + .map(|key| (EntityKind::Segment, key)), + ) + .collect(); + for edge in &edges { + self.reverse + .entry(edge.clone()) + .or_default() + .insert(owner.clone()); + } + self.owners.insert(owner, edges); + } + + fn build(data: &DatafileContent) -> Self { + let mut index = Self::default(); + for owner in incoming_entities(data) { + index.add(owner, data); + } + index + } + + fn propagate( + &self, + features: &mut HashSet, + variables: &mut HashSet, + segments: &HashSet, + ) { + let mut queue: Vec<_> = features + .iter() + .map(|key| (EntityKind::Feature, key.clone())) + .chain( + variables + .iter() + .map(|key| (EntityKind::Variable, key.clone())), + ) + .chain( + segments + .iter() + .map(|key| (EntityKind::Segment, key.clone())), + ) + .collect(); + let mut seen: HashSet<_> = queue.iter().cloned().collect(); + let mut cursor = 0; + while cursor < queue.len() { + for owner in self.reverse.get(&queue[cursor]).into_iter().flatten() { + if seen.insert(owner.clone()) { + match owner.0 { + EntityKind::Feature => { + features.insert(owner.1.clone()); + } + EntityKind::Variable => { + variables.insert(owner.1.clone()); + } + EntityKind::Segment => {} + } + queue.push(owner.clone()); + } + } + cursor += 1; + } } } + fn datafile_details( previous: &DatafileContent, next: &DatafileContent, replaced: bool, + touched: &[Entity], + cached: &mut Option, ) -> DatafileSetDetails { - let mut features = Vec::new(); + fn hash_is_missing(hash: &Option) -> bool { + hash.as_deref().map_or(true, str::is_empty) + } + let mut features = HashSet::new(); for (key, old) in &previous.features { match next.features.get(key) { - None => features.push(key.clone()), - Some(new) if old.hash.is_none() || new.hash.is_none() || old.hash != new.hash => { - features.push(key.clone()) + None => { + features.insert(key.clone()); + } + Some(new) + if hash_is_missing(&old.hash) + || hash_is_missing(&new.hash) + || old.hash != new.hash => + { + features.insert(key.clone()); } _ => {} } } - for key in next.features.keys() { - if !previous.features.contains_key(key) { - features.push(key.clone()); - } - } - let mut variables = Vec::new(); + features.extend( + next.features + .keys() + .filter(|key| !previous.features.contains_key(*key)) + .cloned(), + ); + let mut variables = HashSet::new(); for (key, old) in &previous.variables { match next.variables.get(key) { - None => variables.push(key.clone()), - Some(new) if old.hash.is_none() || new.hash.is_none() || old.hash != new.hash => { - variables.push(key.clone()) + None => { + variables.insert(key.clone()); + } + Some(new) + if hash_is_missing(&old.hash) + || hash_is_missing(&new.hash) + || old.hash != new.hash => + { + variables.insert(key.clone()); } _ => {} } } - for key in next.variables.keys() { - if !previous.variables.contains_key(key) { - variables.push(key.clone()); - } - } - let changed_segments: HashSet = previous - .segments - .keys() - .chain(next.segments.keys()) - .filter(|key| previous.segments.get(*key) != next.segments.get(*key)) - .cloned() + variables.extend( + next.variables + .keys() + .filter(|key| !previous.variables.contains_key(*key)) + .cloned(), + ); + let mut segments: HashSet<_> = touched + .iter() + .filter(|(kind, key)| { + *kind == EntityKind::Segment && previous.segments.get(key) != next.segments.get(key) + }) + .map(|(_, key)| key.clone()) .collect(); - let mut changed_features: HashSet = features.iter().cloned().collect(); - let mut changed_variables: HashSet = variables.iter().cloned().collect(); - loop { - let mut progressed = false; - for graph in [previous, next] { - for (key, feature) in &graph.features { - if changed_features.contains(key) { - continue; - } - let value = serde_json::to_value(feature).unwrap_or(JsonValue::Null); - let mut required = HashSet::new(); - collect_reference_values(&value, "requiredFeatures", &mut required); - collect_reference_values(&value, "required", &mut required); - let mut segments = HashSet::new(); - collect_reference_values(&value, "segments", &mut segments); - if required - .iter() - .any(|dependency| changed_features.contains(dependency)) - || segments - .iter() - .any(|dependency| changed_segments.contains(dependency)) - { - changed_features.insert(key.clone()); - progressed = true; + if replaced { + segments.extend( + previous + .segments + .keys() + .filter(|key| previous.segments.get(*key) != next.segments.get(*key)) + .cloned(), + ); + } + if previous.features.is_empty() && previous.variables.is_empty() && previous.segments.is_empty() + { + // Do not move the first update's graph work into construction. + *cached = None; + } else { + let index = cached.get_or_insert_with(|| DatafileDependencyIndex::build(previous)); + index.propagate(&mut features, &mut variables, &segments); + if replaced { + // Missing owners must disappear even when all retained definitions + // compare equal. Incoming reader slots are reset independently. + for owner in incoming_entities(previous) { + let present = match owner.0 { + EntityKind::Feature => next.features.contains_key(&owner.1), + EntityKind::Variable => next.variables.contains_key(&owner.1), + EntityKind::Segment => next.segments.contains_key(&owner.1), + }; + if !present { + index.remove(&owner); } } - for (key, variable) in &graph.variables { - if changed_variables.contains(key) { - continue; + } + for owner in touched { + // Hashes control notifications, not the actual dependency graph. + // Compare only incoming owners so sparse updates stay sparse. + let unchanged = match owner.0 { + EntityKind::Feature => { + previous.features.get(&owner.1) == next.features.get(&owner.1) } - let value = serde_json::to_value(variable).unwrap_or(JsonValue::Null); - let mut required = HashSet::new(); - collect_reference_values(&value, "requiredFeatures", &mut required); - let mut segments = HashSet::new(); - collect_reference_values(&value, "segments", &mut segments); - if required - .iter() - .any(|dependency| changed_features.contains(dependency)) - || segments - .iter() - .any(|dependency| changed_segments.contains(dependency)) - { - changed_variables.insert(key.clone()); - progressed = true; + EntityKind::Variable => { + previous.variables.get(&owner.1) == next.variables.get(&owner.1) + } + EntityKind::Segment => { + previous.segments.get(&owner.1) == next.segments.get(&owner.1) } + }; + if !unchanged { + index.remove(owner); + index.add(owner.clone(), next); } } - if !progressed { - break; - } + // Only feature and variable reachability carries from the old graph. + index.propagate(&mut features, &mut variables, &segments); } - features = changed_features.into_iter().collect(); - variables = changed_variables.into_iter().collect(); + let mut features: Vec<_> = features.into_iter().collect(); + let mut variables: Vec<_> = variables.into_iter().collect(); features.sort(); variables.sort(); DatafileSetDetails { @@ -1477,49 +1649,853 @@ fn datafile_details( } } -fn collect_reference_values(value: &JsonValue, field: &str, output: &mut HashSet) { +#[derive(Debug, Default, PartialEq)] +struct EntityDependencies { + segments: HashSet, + features: HashSet, + variables: HashSet, +} + +fn visit_conditions(value: &JsonValue, output: &mut EntityDependencies) { match value { - JsonValue::Object(object) => { - for (key, value) in object { - if key == field { - collect_reference_expression(value, output); - } else { - collect_reference_values(value, field, output); + JsonValue::String(raw) if raw != "*" => { + if matches!(raw.trim_start().chars().next(), Some('{' | '[' | '"')) { + if let Ok(value) = serde_json::from_str::(raw) { + visit_conditions(&value, output); } + } else { + output.segments.insert(raw.clone()); } } JsonValue::Array(values) => { for value in values { - collect_reference_values(value, field, output); + visit_conditions(value, output); + } + } + JsonValue::Object(object) => { + if let Some(feature) = object.get("feature").and_then(JsonValue::as_str) { + output.features.insert(feature.to_string()); + } else if let Some(variable) = object.get("variable").and_then(JsonValue::as_str) { + output.variables.insert(variable.to_string()); + } else if !object.contains_key("attribute") { + for group in ["and", "or", "not"] { + if let Some(value) = object.get(group) { + visit_conditions(value, output); + } + } } } _ => {} } } -fn collect_reference_expression(value: &JsonValue, output: &mut HashSet) { - match value { - JsonValue::String(value) if value != "*" => { - output.insert(value.clone()); +fn collect_typed_selectors( + conditions: Option<&JsonValue>, + segments: Option<&JsonValue>, + required_features: Option<&[RequiredFeature]>, + output: &mut EntityDependencies, +) { + for selector in conditions.into_iter().chain(segments) { + visit_conditions(selector, output); + } + fn visit_required(required: &RequiredFeature, output: &mut EntityDependencies) { + match required { + RequiredFeature::Feature(key) | RequiredFeature::Details { feature: key, .. } => { + output.features.insert(key.clone()); + } + RequiredFeature::And { and: entries } + | RequiredFeature::Or { or: entries } + | RequiredFeature::Not { not: entries } => { + for entry in entries { + visit_required(entry, output); + } + } } - JsonValue::Array(values) => { - for value in values { - collect_reference_expression(value, output); + } + for required in required_features.into_iter().flatten() { + visit_required(required, output); + } +} + +fn collect_variable_overrides( + overrides: Option<&HashMap>>, + output: &mut EntityDependencies, +) { + for entry in overrides.into_iter().flat_map(|v| v.values()).flatten() { + collect_typed_selectors( + entry.conditions.as_ref(), + entry.segments.as_ref(), + entry.required_features.as_deref(), + output, + ); + } +} + +fn feature_dependencies(feature: &Feature) -> EntityDependencies { + let mut output = EntityDependencies::default(); + collect_typed_selectors( + feature.conditions.as_ref(), + None, + feature.required_features.as_deref(), + &mut output, + ); + for entry in &feature.traffic { + collect_typed_selectors( + entry.conditions.as_ref(), + Some(&entry.segments), + None, + &mut output, + ); + collect_variable_overrides(entry.variable_overrides.as_ref(), &mut output); + } + for entry in feature.force.iter().flatten() { + collect_typed_selectors( + entry.conditions.as_ref(), + entry.segments.as_ref(), + None, + &mut output, + ); + } + for entry in feature.variations.iter().flatten() { + collect_variable_overrides(entry.variable_overrides.as_ref(), &mut output); + } + output +} + +fn variable_dependencies(variable: &GlobalVariable) -> EntityDependencies { + let mut output = EntityDependencies::default(); + collect_typed_selectors( + variable.conditions.as_ref(), + None, + variable.required_features.as_deref(), + &mut output, + ); + if let Some(key) = variable.compose.as_ref().and_then(JsonValue::as_str) { + output.variables.insert(key.to_string()); + } else { + for entry in variable + .compose + .as_ref() + .and_then(JsonValue::as_array) + .into_iter() + .flatten() + { + visit_conditions(entry, &mut output); + } + } + if let Some(program) = &variable.override_program { + for entry in program { + collect_typed_selectors( + entry.conditions.as_ref(), + entry.segments.as_ref(), + entry.required_features.as_deref(), + &mut output, + ); + } + } else { + for entry in &variable.overrides { + collect_typed_selectors( + entry.conditions.as_ref(), + entry.segments.as_ref(), + entry.required_features.as_deref(), + &mut output, + ); + } + } + output +} + +#[cfg(test)] +fn collect_selectors(value: &JsonValue, output: &mut EntityDependencies) { + if let Some(conditions) = value.get("conditions") { + visit_conditions(conditions, output); + } + if let Some(segments) = value.get("segments") { + visit_conditions(segments, output); + } + fn features(value: &JsonValue, output: &mut EntityDependencies) { + match value { + JsonValue::String(key) => { + output.features.insert(key.clone()); + } + JsonValue::Array(values) => { + for value in values { + features(value, output); + } + } + JsonValue::Object(object) => { + if let Some(key) = object.get("feature").and_then(JsonValue::as_str) { + output.features.insert(key.to_string()); + } else { + for group in ["and", "or", "not"] { + if let Some(value) = object.get(group) { + features(value, output); + } + } + } } + _ => {} } - JsonValue::Object(object) => { - if let Some(value) = object - .get("feature") - .or_else(|| object.get("key")) - .and_then(JsonValue::as_str) + } + if let Some(required) = value.get("requiredFeatures") { + features(required, output); + } +} + +#[cfg(test)] +fn serialized_feature_dependencies(feature: &Feature) -> EntityDependencies { + let value = serde_json::to_value(feature).expect("feature"); + let mut output = EntityDependencies::default(); + collect_selectors(&value, &mut output); + for name in ["traffic", "force", "variations"] { + for entry in value[name].as_array().into_iter().flatten() { + collect_selectors(entry, &mut output); + for entries in entry["variableOverrides"] + .as_object() + .into_iter() + .flat_map(|v| v.values()) { - output.insert(value.to_string()); - } else { - for value in object.values() { - collect_reference_expression(value, output); + for entry in entries.as_array().into_iter().flatten() { + collect_selectors(entry, &mut output); } } } - _ => {} + } + output +} + +#[cfg(test)] +fn serialized_variable_dependencies(variable: &GlobalVariable) -> EntityDependencies { + let value = serde_json::to_value(variable).expect("variable"); + let mut output = EntityDependencies::default(); + collect_selectors(&value, &mut output); + if let Some(key) = value["compose"].as_str() { + output.variables.insert(key.to_string()); + } else { + for entry in value["compose"].as_array().into_iter().flatten() { + visit_conditions(entry, &mut output); + } + } + let entries = value + .get("overrideProgram") + .or_else(|| value.get("overrides")); + for entry in entries.and_then(JsonValue::as_array).into_iter().flatten() { + collect_selectors(entry, &mut output); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reused_edges_match_full_rebuild_across_refreshes_and_removals() { + for schema in ["2", "4"] { + for replaced in [false, true] { + let mut data: DatafileContent = serde_json::from_value(serde_json::json!({ + "schemaVersion":schema,"revision":"initial", + "segments":{"gate":{"conditions":{"variable":"source"}},"removed":{"conditions":"gate"}}, + "features":{ + "owner":{"hash":"fixed","bucketBy":"userId","conditions":"gate","traffic":[]}, + "removed":{"hash":"fixed","bucketBy":"userId","conditions":"removed","traffic":[]}}, + "variables":{ + "source":{"hash":"fixed","type":"string","defaultValue":"initial"}, + "alias":{"hash":"fixed","type":"string","compose":"source"}, + "hashless":{"type":"object","defaultValue":{"conditions":"ignored"}}, + "removed":{"hash":"fixed","type":"string","compose":"source"}} + })).unwrap(); + if schema == "2" { + crate::normalize::datafile(&mut data); + } + let mut index = Some(DatafileDependencyIndex::build(&data)); + for step in 0..6 { + let mut next = data.clone(); + next.revision = step.to_string(); + match step { + 1 => { + next.features.get_mut("owner").unwrap().conditions = + Some(serde_json::json!({"variable":"alias"})) + } + 2 => { + next.variables.get_mut("source").unwrap().default_value = + Some("changed".into()) + } + 3 => { + next.variables.get_mut("alias").unwrap().compose = + Some(serde_json::json!("other")) + } + 4 if replaced => { + next.features.remove("removed"); + next.variables.remove("removed"); + next.segments.remove("removed"); + } + _ => {} + } + let touched = incoming_entities(&next); + let before_count = index.as_ref().unwrap().extractions; + let mut oracle = Some(DatafileDependencyIndex::build(&data)); + let expected = datafile_details(&data, &next, replaced, &touched, &mut oracle); + let actual = datafile_details(&data, &next, replaced, &touched, &mut index); + assert_eq!(actual.features, expected.features); + assert_eq!(actual.variables, expected.variables); + assert!(actual.variables.contains(&"hashless".to_string())); + let index = index.as_ref().unwrap(); + let rebuilt = DatafileDependencyIndex::build(&next); + assert_eq!(index.owners, rebuilt.owners); + assert_eq!(index.reverse, rebuilt.reverse); + assert_eq!( + index.extractions - before_count, + usize::from((1..=3).contains(&step)) + ); + // Compare exact affected keys for every possible seed against + // a freshly extracted graph, including removed sources. + for key in ["source", "alias", "other", "removed", "ignored"] { + let mut features = HashSet::new(); + let mut variables = HashSet::from([key.to_string()]); + let segments = HashSet::from([key.to_string()]); + let mut expected_features = features.clone(); + let mut expected_variables = variables.clone(); + index.propagate(&mut features, &mut variables, &segments); + rebuilt.propagate( + &mut expected_features, + &mut expected_variables, + &segments, + ); + assert_eq!(features, expected_features); + assert_eq!(variables, expected_variables); + } + data = next; + } + } + } + } + + #[test] + fn equal_incoming_definitions_reset_diagnostics_but_retain_edges() { + for schema in ["2", "4"] { + for replaced in [false, true] { + let errors = Arc::new(AtomicU64::new(0)); + let make = |revision: &str| { + DatafileInput::Json(serde_json::json!({ + "schemaVersion":schema,"revision":revision,"segments":{},"variables":{}, + "features":{"broken":{"hash":"fixed","bucketBy":"userId","conditions":"[broken","traffic":[]}} + }).to_string()) + }; + let f = create_featurevisor(FeaturevisorOptions { + on_diagnostic: Some(Arc::new({ + let errors = Arc::clone(&errors); + move |d| { + if d.code == "conditions_parse_error" { + errors.fetch_add(1, Ordering::SeqCst); + } + } + })), + log_level: Some(LogLevel::Error), + ..Default::default() + }); + assert!(f.set_datafile(make("initial"), replaced)); + assert!(f.inner.lock().unwrap().dependency_index.is_none()); + for revision in ["one", "two", "three"] { + assert!(!f.is_enabled("broken", None)); + assert!(!f.is_enabled("broken", None)); + assert!(f.set_datafile(make(revision), replaced)); + assert_eq!( + f.inner + .lock() + .unwrap() + .dependency_index + .as_ref() + .unwrap() + .extractions, + 1 + ); + } + assert!(!f.is_enabled("broken", None)); + assert_eq!(errors.load(Ordering::SeqCst), 4); + assert!(f.set_datafile(DatafileInput::Json(serde_json::json!({"schemaVersion":schema,"revision":"empty","features":{},"segments":{},"variables":{}}).to_string()), true)); + assert!(f + .inner + .lock() + .unwrap() + .dependency_index + .as_ref() + .unwrap() + .owners + .is_empty()); + assert!(!f.is_enabled("broken", None)); + assert_eq!(errors.load(Ordering::SeqCst), 4); + f.close(); + } + } + } + + #[test] + fn merge_reuses_full_incoming_maps_and_keeps_sparse_table_cloning() { + struct Tracked(Arc); + impl Clone for Tracked { + fn clone(&self) -> Self { + self.0.fetch_add(1, Ordering::Relaxed); + Self(Arc::clone(&self.0)) + } + } + for touched in [0, 1, 2, 3] { + let copies = Arc::new(AtomicU64::new(0)); + let previous: HashMap<_, _> = (0..3) + .map(|i| (i.to_string(), Tracked(Arc::clone(&copies)))) + .collect(); + let incoming = (0..touched) + .map(|i| (i.to_string(), Tracked(Arc::new(AtomicU64::new(0))))) + .chain([("added".into(), Tracked(Arc::new(AtomicU64::new(0))))]) + .collect(); + let merged = merge_entities(&previous, incoming); + let expected_copies = if touched + 1 < 3 { 3 } else { 3 - touched }; + assert_eq!(copies.load(Ordering::Relaxed), expected_copies); + assert_eq!(merged.len(), 4); + assert_eq!(previous.len(), 3); + for (key, value) in &previous { + assert_eq!( + Arc::ptr_eq(&value.0, &merged[key].0), + key.parse::().unwrap() >= touched + ); + } + } + } + + #[test] + fn merge_keeps_previous_snapshot_and_uses_incoming_metadata_and_definitions() { + let make = |revision: &str, keys: &[&str]| { + let mut data = serde_json::json!({"schemaVersion":"4", "revision":revision, + "featurevisorVersion":revision, "segments":{}, "features":{}, "variables":{}}); + for key in keys { + data["features"][key] = serde_json::json!({"hash":revision, "bucketBy":"userId", "conditions":revision, "traffic":[]}); + data["segments"][key] = serde_json::json!({"conditions":revision}); + data["variables"][key] = serde_json::json!({"hash":revision, "type":"object", "defaultValue":{"value":[revision]}}); + } + serde_json::from_value::(data).unwrap() + }; + let previous = make("before", &["retained", "updated"]); + let saved = previous.clone(); + for keys in [ + vec![], + vec!["added"], + vec!["updated"], + vec!["updated", "added"], + vec!["retained", "updated", "added"], + ] { + let incoming = make("after", &keys); + let mut expected = previous.clone(); + expected.schema_version = incoming.schema_version.clone(); + expected.revision = incoming.revision.clone(); + expected.featurevisor_version = incoming.featurevisor_version.clone(); + expected.features.extend(incoming.features.clone()); + expected.segments.extend(incoming.segments.clone()); + expected.variables.extend(incoming.variables.clone()); + let mut merged = merge_datafile(&previous, incoming); + assert_eq!(merged, expected); + merged.features.clear(); + merged.segments.clear(); + merged.variables.clear(); + assert_eq!(previous, saved); + } + } + + #[test] + fn typed_dependency_walk_matches_serialized_selectors_without_payload_edges() { + let data: DatafileContent = serde_json::from_value(serde_json::json!({ + "schemaVersion":"4", "revision":"selectors", "segments":{}, + "features":{"owner":{ + "bucketBy":"userId", "conditions":"{\"variable\":\"rootVariable\"}", + "requiredFeatures":["rootFeature", {"and":[{"or":[{"not":[{"feature":"nestedFeature", "enabled":false}]}]}]}], + "variablesSchema":{"value":{"type":"object", "defaultValue":{"conditions":"ignoredSchema"}}}, + "traffic":[{"key":"rule", "percentage":100000, + "conditions":{"and":["trafficSegment", {"attribute":"country", "value":{"feature":"ignoredAttribute"}}]}, + "segments":"legacyTrafficSegment", + "variables":{"payload":{"conditions":"ignoredTraffic"}}, + "variableOverrides":{"value":[{"value":{"conditions":"ignoredOverrideValue"}, + "conditions":{"variable":"trafficVariable"}, "segments":"overrideSegment", "requiredFeatures":["overrideFeature"]}]} + }], + "force":[{"conditions":"[\"forceSegment\",{\"feature\":\"forceFeature\"}]", "segments":"legacyForceSegment", "variables":{"payload":{"conditions":"ignoredForce"}}}], + "variations":[{"value":"treatment", "variables":{"payload":{"conditions":"ignoredVariation"}}, + "variableOverrides":{"value":[{"value":null, "conditions":{"variable":"variationVariable"}, "segments":"variationSegment", "requiredFeatures":["variationFeature"]}]} + }] + }}, + "variables":{ + "map":{"type":"object", "conditions":"{\"variable\":\"gateVariable\"}", "requiredFeatures":["gateFeature"], + "compose":[{"key":"feature", "feature":"composedFeature", "variable":"featureVariable"}, {"key":"global", "variable":"composedVariable"}], + "defaultValue":{"conditions":"ignoredDefault"}, "disabledValue":{"feature":"ignoredDisabled"}, + "overrides":[{"value":{"conditions":"ignoredValue"}, "conditions":"overrideSegment", "segments":"legacyOverrideSegment", "requiredFeatures":[{"not":["overrideFeature"]}]}] + }, + "alias":{"type":"string", "compose":"literal.source/key"}, + "program":{"type":"object", "overrides":[{"value":null, "conditions":"ignoredShadowedOverride"}], + "overrideProgram":[{"key":"step", "conditions":{"not":{"variable":"programVariable"}}, "segments":"programSegment", "requiredFeatures":["programFeature"], + "operations":[{"op":"set", "value":{"feature":"ignoredOperation", "conditions":"ignoredOperation"}}], "onMatch":"end", "onMiss":"end"}] + }, + "emptyProgram":{"type":"string", "overrideProgram":[], "overrides":[{"value":null, "conditions":"ignoredShadowedOverride"}]}, + "broken":{"type":"string", "conditions":"[broken"} + } + })).unwrap(); + for normalize in [false, true] { + let mut data = data.clone(); + if normalize { + data.schema_version = "2".into(); + crate::normalize::datafile(&mut data); + } + for feature in data.features.values() { + assert_eq!( + feature_dependencies(feature), + serialized_feature_dependencies(feature) + ); + } + for variable in data.variables.values() { + assert_eq!( + variable_dependencies(variable), + serialized_variable_dependencies(variable) + ); + } + } + let strings = |items: &[&str]| items.iter().map(|s| s.to_string()).collect(); + assert_eq!( + feature_dependencies(&data.features["owner"]), + EntityDependencies { + features: strings(&[ + "rootFeature", + "nestedFeature", + "overrideFeature", + "forceFeature", + "variationFeature" + ]), + variables: strings(&["rootVariable", "trafficVariable", "variationVariable"]), + segments: strings(&[ + "trafficSegment", + "legacyTrafficSegment", + "overrideSegment", + "forceSegment", + "legacyForceSegment", + "variationSegment" + ]), + } + ); + assert_eq!( + variable_dependencies(&data.variables["map"]), + EntityDependencies { + features: strings(&["gateFeature", "composedFeature", "overrideFeature"]), + variables: strings(&["gateVariable", "composedVariable"]), + segments: strings(&["overrideSegment", "legacyOverrideSegment"]), + } + ); + assert_eq!( + variable_dependencies(&data.variables["program"]), + EntityDependencies { + features: strings(&["programFeature"]), + variables: strings(&["programVariable"]), + segments: strings(&["programSegment"]), + } + ); + assert_eq!( + variable_dependencies(&data.variables["emptyProgram"]), + EntityDependencies::default() + ); + assert_eq!( + variable_dependencies(&data.variables["broken"]), + EntityDependencies::default() + ); + assert_eq!( + variable_dependencies(&data.variables["alias"]).variables, + strings(&["literal.source/key"]) + ); + } + + #[test] + fn incremental_index_extracts_only_incoming_owners() { + for size in [1, 64, 512] { + let mut data = serde_json::json!({"schemaVersion":"4","revision":"initial","features":{},"variables":{ + "source":{"hash":"source","type":"string","defaultValue":"initial"} + },"segments":{}}); + for n in 0..size { + let key = n.to_string(); + data["features"][&key] = serde_json::json!({"hash":key,"bucketBy":"userId","conditions":"{\"variable\":\"source\",\"operator\":\"notNull\"}","traffic":[]}); + data["segments"][&key] = + serde_json::json!({"conditions":format!("{{\"feature\":\"{key}\"}}")}); + } + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + log_level: Some(LogLevel::Error), + ..Default::default() + }); + assert!(f.inner.lock().unwrap().dependency_index.is_none()); + for n in 0..5 { + assert!(f.set_datafile(DatafileInput::Json(serde_json::json!({"schemaVersion":"4","revision":n.to_string(),"features":{},"segments":{},"variables":{ + "source":{"hash":n.to_string(),"type":"string","defaultValue":"next"} + }}).to_string()),false)); + let inner = f.inner.lock().unwrap(); + assert_eq!( + inner.dependency_index.as_ref().unwrap().extractions, + 2 * size + 1 + n + 1 + ); + } + f.close(); + } + } + + #[test] + fn event_traversal_does_not_merge_graph_generations() { + let before: DatafileContent = serde_json::from_value(serde_json::json!({"schemaVersion":"4","revision":"before","segments":{ + "oldOnly":{"conditions":{"variable":"seed","operator":"notNull"}}, + "middle":{"conditions":"oldOnly"}}, + "features":{"impossible":{"hash":"same","bucketBy":"userId","conditions":"*","traffic":[]}}, + "variables":{"seed":{"hash":"old","type":"string","defaultValue":"old"}}})).unwrap(); + let mut after = before.clone(); + after.segments.get_mut("oldOnly").unwrap().conditions = serde_json::json!("*"); + after.segments.get_mut("middle").unwrap().conditions = serde_json::json!("*"); + after.features.get_mut("impossible").unwrap().conditions = + Some(serde_json::json!("middle")); + let mut index = DatafileDependencyIndex::build(&before); + let mut features = HashSet::new(); + let mut variables = HashSet::from(["seed".into()]); + let segments = HashSet::new(); + index.propagate(&mut features, &mut variables, &segments); + index = DatafileDependencyIndex::build(&after); + index.propagate(&mut features, &mut variables, &segments); + assert!(!features.contains("impossible")); + } + + #[test] + fn concurrent_writers_retain_cold_slots_and_graph() { + let errors = Arc::new(AtomicU64::new(0)); + let f = create_featurevisor(FeaturevisorOptions{ + datafile:Some(DatafileInput::Json(serde_json::json!({"schemaVersion":"4","revision":"initial","segments":{}, + "features":{"broken":{"hash":"broken","bucketBy":"userId","conditions":"[broken","traffic":[]}},"variables":{}}).to_string())), + log_level:Some(LogLevel::Error), + on_diagnostic:Some(Arc::new({ + let errors = errors.clone(); + move |d:&Diagnostic| {if d.code=="conditions_parse_error" {errors.fetch_add(1,Ordering::SeqCst);}} + })),..Default::default()}); + let old = f.live_state(); + let mut writers = Vec::new(); + for n in 0..20 { + let f = f.clone(); + let old = old.clone(); + writers.push(std::thread::spawn(move || { + let key = n.to_string(); + assert!(f.set_datafile(DatafileInput::Json(serde_json::json!({"schemaVersion":"4","revision":key,"segments":{},"features":{},"variables":{ + key.clone():{"hash":key,"type":"string","defaultValue":"value"} + }}).to_string()),false)); + crate::normalize::features(&old.data.condition_cache.entities,&old.data.datafile,"broken",&|d| f.report_diagnostic(d,None)); + assert!(!f.is_enabled("broken",None)); + })); + } + for writer in writers { + writer.join().unwrap(); + } + assert_eq!(errors.load(Ordering::SeqCst), 1); + assert_eq!(f.get_global_variable_keys().len(), 20); + assert_eq!( + f.inner + .lock() + .unwrap() + .dependency_index + .as_ref() + .unwrap() + .owners + .len(), + 21 + ); + f.close(); + } + + struct DropProbe { + instance: WeakFeaturevisor, + drops: Arc, + } + + impl Drop for DropProbe { + fn drop(&mut self) { + if let Some(f) = self.instance.upgrade() { + assert!( + f.inner.try_lock().is_ok(), + "captured destructor held the instance lock" + ); + let _ = f.get_revision(); + } + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + #[test] + fn close_breaks_the_top_level_diagnostic_capture_cycle() { + for _ in 0..100 { + let holder = Arc::new(Mutex::new(None::)); + let captured = Arc::clone(&holder); + let f = create_featurevisor(FeaturevisorOptions { + on_diagnostic: Some(Arc::new(move |_| { + let _ = &captured; + })), + ..Default::default() + }); + *holder.lock().unwrap() = Some(f.clone()); + let weak = Arc::downgrade(&f.inner); + f.close(); + f.close(); + drop(holder); + drop(f); + assert!(weak.upgrade().is_none()); + } + } + + struct TokenModule(Mutex>); + impl FeaturevisorModule for TokenModule { + fn setup(&self, api: &ModuleApi) { + *self.0.lock().unwrap() = Some(api.on_diagnostic(Arc::new(|_| {}), None)); + } + } + + #[test] + fn module_cleanup_tokens_and_apis_do_not_own_the_instance() { + let f = create_featurevisor(FeaturevisorOptions::default()); + let weak = Arc::downgrade(&f.inner); + let api = f.module_api(100, None); + let module = Arc::new(TokenModule(Mutex::new(None))); + let cleanup = f.add_module(module.clone()).unwrap(); + drop(f); + assert!(weak.upgrade().is_none()); + cleanup(); + module.0.lock().unwrap().take().unwrap()(); + assert_eq!(api.get_revision(), EMPTY_REVISION); + let unsubscribe = api.on_diagnostic(Arc::new(|_| {}), None); + unsubscribe(); + api.report_diagnostic(Diagnostic::new(LogLevel::Info, "after_drop", "ignored")); + } + + #[test] + fn captured_subscription_values_drop_outside_the_instance_mutex() { + for operation in ["unsubscribe", "remove", "close"] { + let f = create_featurevisor(FeaturevisorOptions::default()); + let drops = Arc::new(AtomicU64::new(0)); + let probe = DropProbe { + instance: f.downgrade(), + drops: Arc::clone(&drops), + }; + let mut unsubscribe = Some(f.subscribe_module( + 1, + Arc::new(move |_| { + let _ = &probe; + }), + LogLevel::Info, + )); + match operation { + "unsubscribe" => unsubscribe.take().unwrap()(), + "remove" => f.clear_module_subscriptions(1), + _ => f.close(), + } + assert_eq!(drops.load(Ordering::SeqCst), 1); + if let Some(unsubscribe) = unsubscribe { + unsubscribe(); + } + } + } + + struct FailingClose; + impl FeaturevisorModule for FailingClose { + fn close(&self) { + panic!("shutdown failure"); + } + } + + #[test] + fn close_reports_module_errors_before_releasing_the_handler_and_caches() { + let f = create_featurevisor(FeaturevisorOptions::default()); + let drops = Arc::new(AtomicU64::new(0)); + let probe = DropProbe { + instance: f.downgrade(), + drops: Arc::clone(&drops), + }; + let codes = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&codes); + f.inner.lock().unwrap().on_diagnostic = Some(Arc::new(move |d| { + let _ = &probe; + observed.lock().unwrap().push(d.code.clone()); + })); + let _cleanup = f.add_module(Arc::new(FailingClose)); + let regex_cache = Arc::downgrade(&f.inner.lock().unwrap().regex_cache); + f.close(); + assert!(codes + .lock() + .unwrap() + .iter() + .any(|code| code == "module_close_error")); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!(regex_cache.upgrade().is_none()); + assert!(f.inner.lock().unwrap().on_diagnostic.is_none()); + assert!(f.inner.lock().unwrap().modules.is_empty()); + } + + #[test] + fn event_callbacks_drop_outside_instance_lock_and_closed_instances_reject_listeners() { + let f = create_featurevisor(FeaturevisorOptions::default()); + let drops = Arc::new(AtomicU64::new(0)); + let callback = || { + let probe = DropProbe { + instance: f.downgrade(), + drops: Arc::clone(&drops), + }; + Arc::new(move |_: &EventDetails| { + let _ = &probe; + }) as EventHandler + }; + let cleanup = f.on(EventName::DatafileSet, callback()); + f.close(); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let _ = f.on(EventName::Error, callback()); + assert_eq!(drops.load(Ordering::SeqCst), 2); + cleanup(); + } + + struct ReentrantName(Featurevisor); + impl FeaturevisorModule for ReentrantName { + fn name(&self) -> Option<&str> { + assert!(self.0.inner.try_lock().is_ok()); + let _ = self.0.get_revision(); + Some("reentrant") + } + } + + #[test] + fn module_names_are_read_outside_the_instance_lock() { + let f = create_featurevisor(FeaturevisorOptions::default()); + let cleanup = f.add_module(Arc::new(ReentrantName(f.clone()))).unwrap(); + cleanup(); + assert!(f.inner.lock().unwrap().modules.is_empty()); + } + + #[test] + fn condition_cache_lifetime_follows_datafile_snapshots() { + let f = create_featurevisor(FeaturevisorOptions::default()); + for replace in [false, true] { + let previous = f.evaluate_options(EvaluationType::Flag, "flag", None, None); + let cache = Arc::downgrade(&previous.data.condition_cache); + f.set_datafile(DatafileInput::Json("invalid".into()), replace); + assert!(Arc::ptr_eq( + &previous.data.condition_cache, + &f.inner.lock().unwrap().condition_cache + )); + f.set_datafile( + DatafileInput::Content(DatafileContent { + revision: "next".into(), + ..Default::default() + }), + replace, + ); + assert!(!Arc::ptr_eq( + &previous.data.condition_cache, + &f.inner.lock().unwrap().condition_cache + )); + assert!(cache.upgrade().is_some()); + drop(previous); + assert!(cache.upgrade().is_none()); + } + let cache = Arc::downgrade(&f.inner.lock().unwrap().condition_cache); + f.close(); + assert!(cache.upgrade().is_none()); } } diff --git a/src/lib.rs b/src/lib.rs index cf9713e..3e7aa5f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,16 +1,16 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] -//! Feature flag evaluation for Featurevisor v3 datafiles. +//! Feature flag and remote configuration evaluation for Featurevisor datafiles. //! //! Create a [`Featurevisor`] instance with [`create_featurevisor`], provide a -//! v2 datafile, and evaluate flags, variations, and variables: +//! schema 4 datafile, and evaluate flags, variations, and variables: //! //! ``` //! use featurevisor::{context, create_featurevisor, DatafileInput, FeaturevisorOptions}; //! use serde_json::json; //! //! let datafile = serde_json::from_value(json!({ -//! "schemaVersion": "2", +//! "schemaVersion": "4", //! "revision": "example", //! "segments": {}, //! "features": { @@ -18,7 +18,7 @@ //! "bucketBy": "userId", //! "traffic": [{ //! "key": "everyone", -//! "segments": "*", +//! "conditions": "*", //! "percentage": 100000, //! "enabled": true //! }] @@ -44,6 +44,8 @@ mod helpers; mod instance; mod modules; mod murmurhash; +mod mutations; +mod normalize; mod types; #[cfg(feature = "cli")] @@ -54,7 +56,10 @@ pub use bucketer::MAX_BUCKETED_NUMBER; pub use child::FeaturevisorChild; pub use chrono; pub use diagnostics::{Diagnostic, DiagnosticHandler, LogLevel}; -pub use evaluate::{EvaluateOptions, Evaluation, EvaluationReason, EvaluationType}; +pub use evaluate::{ + DependencyPurpose, EvaluateOptions, Evaluation, EvaluationDependency, EvaluationReason, + EvaluationType, +}; pub use events::{ ContextSetDetails, DatafileSetDetails, EventDetails, EventHandler, EventName, StickyFeaturesSetDetails, StickyVariablesSetDetails, @@ -65,12 +70,14 @@ pub use instance::{ pub use modules::{ ConfigureBucketKeyOptions, ConfigureBucketValueOptions, FeaturevisorModule, ModuleApi, }; +pub use mutations::apply_variable_operations; pub use types::{ Allocation, AndCondition, AndGroupSegment, AttributeValue, BucketBy, Condition, Context, DatafileContent, DatafileInput, EvaluatedFeature, EvaluatedFeatures, EvaluatedVariables, - Feature, FeatureKey, Force, GlobalVariable, GroupSegment, NotCondition, NotGroupSegment, - Operator, OrCondition, OrGroupSegment, PlainCondition, Required, ResolvedVariableSchema, - RuleKey, Segment, SegmentKey, StickyFeatures, StickyVariables, Traffic, VariableOverride, + Feature, FeatureCondition, FeatureKey, Force, GlobalVariable, GroupSegment, NotCondition, + NotGroupSegment, Operator, OrCondition, OrGroupSegment, PlainCondition, RequiredFeature, + ResolvedVariableSchema, RuleKey, Segment, SegmentKey, StickyFeatures, StickyVariables, Traffic, + VariableCondition, VariableMutationOperation, VariableOverride, VariableOverrideStep, VariableValue, Variation, VariationValue, }; diff --git a/src/modules.rs b/src/modules.rs index 614aed2..7349fb4 100644 --- a/src/modules.rs +++ b/src/modules.rs @@ -34,11 +34,6 @@ pub trait FeaturevisorModule: Send + Sync { /// Sets up the module and optionally registers diagnostic subscriptions. fn setup(&self, _api: &ModuleApi) {} - /// Transforms evaluation options before the feature is evaluated. - fn before(&self, options: EvaluateOptions) -> EvaluateOptions { - options - } - /// Transforms options before any feature or global variable evaluation. fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { options @@ -54,11 +49,6 @@ pub trait FeaturevisorModule: Send + Sync { options.bucket_value } - /// Transforms an evaluation after defaults and evaluation details are applied. - fn after(&self, evaluation: Evaluation, _options: &EvaluateOptions) -> Evaluation { - evaluation - } - /// Transforms a result after any feature or global variable evaluation. fn after_evaluation(&self, evaluation: Evaluation, options: &EvaluateOptions) -> Evaluation { let _ = options; diff --git a/src/mutations.rs b/src/mutations.rs new file mode 100644 index 0000000..d3ba42d --- /dev/null +++ b/src/mutations.rs @@ -0,0 +1,373 @@ +use crate::types::{VariableMutationOperation, VariableValue}; +use serde_json::Value as JsonValue; + +const UNSAFE_KEYS: [&str; 3] = ["__proto__", "prototype", "constructor"]; + +#[derive(Clone, Debug)] +enum PathPart { + Key(String), + Index(usize), + Selector { + property: String, + values: Vec, + }, +} + +fn is_unsafe(key: &str) -> bool { + UNSAFE_KEYS.contains(&key) +} + +fn index(value: &JsonValue) -> Option { + let JsonValue::Number(number) = value else { + return None; + }; + if let Some(value) = number.as_u64() { + return usize::try_from(value).ok(); + } + let value = number.as_f64()?; + if value.is_finite() && value >= 0.0 && value.fract() == 0.0 { + Some(value as usize) + } else { + None + } +} + +fn is_primitive(value: &VariableValue) -> bool { + matches!( + value, + VariableValue::String(_) + | VariableValue::Integer(_) + | VariableValue::Double(_) + | VariableValue::Boolean(_) + | VariableValue::Null + ) && !matches!(value, VariableValue::Double(number) if !number.is_finite()) +} + +fn primitive_equal(left: &VariableValue, right: &VariableValue) -> bool { + match (left, right) { + (VariableValue::Integer(left), VariableValue::Integer(right)) => left == right, + (VariableValue::Integer(left), VariableValue::Double(right)) => *left as f64 == *right, + (VariableValue::Double(left), VariableValue::Integer(right)) => *left == *right as f64, + (VariableValue::Double(left), VariableValue::Double(right)) => left == right, + _ => left == right, + } +} + +fn parse_path(path: Option<&JsonValue>) -> Result, String> { + let values = path + .and_then(JsonValue::as_array) + .ok_or_else(|| "Invalid variable mutation path".to_string())?; + values + .iter() + .map(|value| { + if let Some(key) = value.as_str() { + if is_unsafe(key) { + return Err("Unsafe variable mutation key".to_string()); + } + return Ok(PathPart::Key(key.to_string())); + } + if let Some(index) = index(value) { + return Ok(PathPart::Index(index)); + } + let select = value + .as_object() + .and_then(|value| value.get("select")) + .and_then(JsonValue::as_object) + .ok_or_else(|| "Invalid variable mutation path".to_string())?; + let property = select + .get("property") + .and_then(JsonValue::as_str) + .ok_or_else(|| "Invalid variable mutation path".to_string())?; + if is_unsafe(property) { + return Err("Unsafe variable mutation key".to_string()); + } + let candidates = select + .get("values") + .and_then(JsonValue::as_array) + .ok_or_else(|| "Invalid variable mutation path".to_string())? + .iter() + .cloned() + .map(VariableValue::from_json) + .collect::>(); + if candidates.iter().any(|value| !is_primitive(value)) { + return Err("Invalid variable mutation path".to_string()); + } + Ok(PathPart::Selector { + property: property.to_string(), + values: candidates, + }) + }) + .collect() +} + +fn validate(operation: &VariableMutationOperation) -> Result, String> { + if !matches!( + operation.op.as_str(), + "set" + | "append" + | "prepend" + | "appendUnique" + | "removeValue" + | "before" + | "after" + | "remove" + ) { + return Err("Invalid variable mutation operation".to_string()); + } + if operation.op != "remove" && operation.value.is_none() { + return Err("Variable mutation operation requires a value".to_string()); + } + if matches!(operation.op.as_str(), "appendUnique" | "removeValue") + && !operation.value.as_ref().is_some_and(is_primitive) + { + return Err("Variable mutation operation requires a primitive value".to_string()); + } + parse_path(operation.path.as_ref()) +} + +fn selected_index( + values: &[VariableValue], + property: &str, + candidates: &[VariableValue], +) -> Option { + values.iter().position(|item| { + let VariableValue::Object(object) = item else { + return false; + }; + let Some(value) = object.get(property).filter(|value| is_primitive(value)) else { + return false; + }; + candidates + .iter() + .any(|candidate| primitive_equal(value, candidate)) + }) +} + +fn array_index(values: &[VariableValue], part: &PathPart) -> Option { + match part { + PathPart::Index(index) => Some(*index), + PathPart::Selector { + property, + values: candidates, + } => selected_index(values, property, candidates), + PathPart::Key(_) => None, + } +} + +fn update_array( + values: &[VariableValue], + operation: &str, + value: &VariableValue, +) -> Vec { + let found = values.iter().any(|item| primitive_equal(item, value)); + match operation { + "removeValue" if found => values + .iter() + .filter(|item| !primitive_equal(item, value)) + .cloned() + .collect(), + "removeValue" => values.to_vec(), + "appendUnique" if found => values.to_vec(), + "prepend" => std::iter::once(value.clone()) + .chain(values.iter().cloned()) + .collect(), + _ => values + .iter() + .cloned() + .chain(std::iter::once(value.clone())) + .collect(), + } +} + +fn update_final( + current: &VariableValue, + part: &PathPart, + operation: &VariableMutationOperation, +) -> Result { + match current { + VariableValue::Object(object) => { + let PathPart::Key(key) = part else { + return Err("Variable mutation expected an array".to_string()); + }; + if matches!(operation.op.as_str(), "before" | "after") { + return Err( + "Variable mutation before/after requires an array element path".to_string(), + ); + } + let mut result = object.clone(); + if operation.op == "remove" { + result.remove(key); + return Ok(VariableValue::Object(result)); + } + if operation.op == "set" { + result.insert(key.clone(), operation.value.clone().unwrap()); + return Ok(VariableValue::Object(result)); + } + match object.get(key) { + Some(VariableValue::Array(values)) => { + let next = + update_array(values, &operation.op, operation.value.as_ref().unwrap()); + result.insert(key.clone(), VariableValue::Array(next)); + } + Some(VariableValue::Null) if operation.op != "removeValue" => { + result.insert( + key.clone(), + VariableValue::Array(update_array( + &[], + &operation.op, + operation.value.as_ref().unwrap(), + )), + ); + } + Some(VariableValue::Null) => {} + Some(_) => return Err("Variable mutation expected an array".to_string()), + None if operation.op != "removeValue" => { + result.insert( + key.clone(), + VariableValue::Array(update_array( + &[], + &operation.op, + operation.value.as_ref().unwrap(), + )), + ); + } + None => {} + } + Ok(VariableValue::Object(result)) + } + VariableValue::Array(values) => { + let Some(index) = array_index(values, part) else { + return Ok(current.clone()); + }; + if operation.op == "remove" { + if index >= values.len() { + return Ok(current.clone()); + } + let mut result = values.clone(); + result.remove(index); + return Ok(VariableValue::Array(result)); + } + if operation.op == "set" { + if index > values.len() { + return Ok(current.clone()); + } + let mut result = values.clone(); + if index == result.len() { + result.push(operation.value.clone().unwrap()); + } else { + result[index] = operation.value.clone().unwrap(); + } + return Ok(VariableValue::Array(result)); + } + if matches!(operation.op.as_str(), "before" | "after") { + if index >= values.len() { + return Ok(current.clone()); + } + let mut result = values.clone(); + let offset = if operation.op == "after" { 1 } else { 0 }; + result.insert(index + offset, operation.value.clone().unwrap()); + return Ok(VariableValue::Array(result)); + } + if index >= values.len() { + return Ok(current.clone()); + } + if matches!(values[index], VariableValue::Null) { + return Ok(current.clone()); + } + let VariableValue::Array(nested) = &values[index] else { + return Err("Variable mutation expected an array".to_string()); + }; + let mut result = values.clone(); + result[index] = VariableValue::Array(update_array( + nested, + &operation.op, + operation.value.as_ref().unwrap(), + )); + Ok(VariableValue::Array(result)) + } + _ => Err(match part { + PathPart::Key(_) => "Variable mutation expected an object".to_string(), + _ => "Variable mutation expected an array".to_string(), + }), + } +} + +fn update_at_path( + current: &VariableValue, + path: &[PathPart], + operation: &VariableMutationOperation, +) -> Result { + if path.is_empty() { + return match operation.op.as_str() { + "set" => Ok(operation.value.clone().unwrap()), + "remove" => Err("Cannot remove the root variable value".to_string()), + "before" | "after" => { + Err("Variable mutation before/after requires an array element path".to_string()) + } + _ if matches!(current, VariableValue::Null) => Ok(current.clone()), + _ => match current { + VariableValue::Array(values) => Ok(VariableValue::Array(update_array( + values, + &operation.op, + operation.value.as_ref().unwrap(), + ))), + _ => Err("Variable mutation expected an array".to_string()), + }, + }; + } + if matches!(current, VariableValue::Null) { + return Ok(current.clone()); + } + if path.len() == 1 { + return update_final(current, &path[0], operation); + } + match current { + VariableValue::Object(object) => { + let PathPart::Key(key) = &path[0] else { + return Err("Variable mutation expected an array".to_string()); + }; + let Some(child) = object.get(key) else { + return Ok(current.clone()); + }; + if matches!(child, VariableValue::Null) { + return Ok(current.clone()); + } + let next = update_at_path(child, &path[1..], operation)?; + let mut result = object.clone(); + result.insert(key.clone(), next); + Ok(VariableValue::Object(result)) + } + VariableValue::Array(values) => { + let Some(index) = array_index(values, &path[0]) else { + return Ok(current.clone()); + }; + let Some(child) = values.get(index) else { + return Ok(current.clone()); + }; + if matches!(child, VariableValue::Null) { + return Ok(current.clone()); + } + let next = update_at_path(child, &path[1..], operation)?; + let mut result = values.clone(); + result[index] = next; + Ok(VariableValue::Array(result)) + } + _ => Err(match &path[0] { + PathPart::Key(_) => "Variable mutation expected an object".to_string(), + _ => "Variable mutation expected an array".to_string(), + }), + } +} + +/// Applies compiled variable mutations without modifying the input value. +pub fn apply_variable_operations( + value: &VariableValue, + operations: &[VariableMutationOperation], +) -> Result { + let mut current = value.clone(); + for operation in operations { + let path = validate(operation)?; + current = update_at_path(¤t, &path, operation)?; + } + Ok(current) +} diff --git a/src/normalize.rs b/src/normalize.rs new file mode 100644 index 0000000..0c3c5ce --- /dev/null +++ b/src/normalize.rs @@ -0,0 +1,348 @@ +use crate::diagnostics::{Diagnostic, LogLevel}; +use crate::types::{DatafileContent, Feature, GlobalVariable, Segment}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +// A private marker, never a false operand that logical NOT can invert. +pub(crate) fn invalid(value: &Value) -> bool { + value.get("__featurevisor_invalid_conditions").is_some() +} + +pub(crate) fn public_conditions(value: &Value) -> Value { + if invalid(value) { + json!({"or":[]}) + } else { + value.clone() + } +} + +pub(crate) fn public_variable(value: &GlobalVariable) -> GlobalVariable { + let mut value = value.clone(); + if let Some(conditions) = &mut value.conditions { + *conditions = public_conditions(conditions); + } + for item in &mut value.overrides { + if let Some(conditions) = &mut item.conditions { + *conditions = public_conditions(conditions); + } + } + for item in value.override_program.iter_mut().flatten() { + if let Some(conditions) = &mut item.conditions { + *conditions = public_conditions(conditions); + } + } + value +} + +pub(crate) fn public_feature(value: &Feature) -> Feature { + let mut value = value.clone(); + if let Some(conditions) = &mut value.conditions { + *conditions = public_conditions(conditions); + } + for item in value.force.iter_mut().flatten() { + if let Some(conditions) = &mut item.conditions { + *conditions = public_conditions(conditions); + } + } + fn overrides(items: &mut Option>>) { + for entries in items.iter_mut().flat_map(|v| v.values_mut()) { + for item in entries { + if let Some(conditions) = &mut item.conditions { + *conditions = public_conditions(conditions); + } + } + } + } + for item in &mut value.traffic { + if let Some(conditions) = &mut item.conditions { + *conditions = public_conditions(conditions); + } + overrides(&mut item.variable_overrides); + } + for item in value.variations.iter_mut().flatten() { + overrides(&mut item.variable_overrides); + } + value +} + +fn legacy(value: Value, features: bool) -> Value { + match value { + Value::String(key) if features => json!({"feature":key}), + Value::Array(values) => { + if values.is_empty() { + json!("*") + } else { + Value::Array(values.into_iter().map(|v| legacy(v, features)).collect()) + } + } + Value::Object(mut object) => { + for group in ["and", "or", "not"] { + if let Some(Value::Array(values)) = object.remove(group) { + return if group == "and" && values.is_empty() { + json!("*") + } else { + json!({group: values.into_iter().map(|v| legacy(v, features)).collect::>()}) + }; + } + } + Value::Object(object) + } + value => value, + } +} + +fn parse(value: Value, kind: &str, key: &str, errors: &mut Vec) -> Value { + let Some(raw) = value.as_str() else { + return value; + }; + if !matches!(raw.trim_start().chars().next(), Some('{' | '[' | '"')) { + return value; + } + match serde_json::from_str(raw) { + Ok(value) => value, + Err(error) => { + let mut diagnostic = Diagnostic::new( + LogLevel::Error, + "conditions_parse_error", + "Error parsing conditions", + ); + diagnostic.original_error = Some(error.to_string()); + diagnostic.details.insert("conditions".into(), value); + diagnostic.details.insert("entityType".into(), json!(kind)); + diagnostic.details.insert("entityKey".into(), json!(key)); + errors.push(diagnostic); + json!({"__featurevisor_invalid_conditions":true}) + } + } +} + +fn select(value: &mut Value, old: bool, kind: &str, key: &str, errors: &mut Vec) { + let Some(object) = value.as_object_mut() else { + return; + }; + let mut parts = Vec::new(); + if let Some(conditions) = object.remove("conditions") { + let value = parse(conditions, kind, key, errors); + parts.push(if old && !invalid(&value) { + legacy(value, false) + } else { + value + }); + } + if let Some(segments) = object.remove("segments") { + if !segments.is_null() { + let value = parse(segments, kind, key, errors); + parts.push(if invalid(&value) { + value + } else { + legacy(value, false) + }); + } + } + if let Some(features) = object.remove("requiredFeatures") { + let features = match features { + Value::Array(values) => values, + v => vec![v], + }; + if !features.is_empty() { + parts.push(Value::Array( + features.into_iter().map(|v| legacy(v, true)).collect(), + )); + } + } + object.remove("requiredVariables"); + object.remove("required"); + let combined = if parts.iter().any(invalid) { + Some(json!({"__featurevisor_invalid_conditions":true})) + } else if parts.len() == 1 { + parts.pop() + } else if parts.is_empty() { + None + } else { + let mut combined = Vec::new(); + for part in parts { + match part { + Value::Array(values) if values.is_empty() => combined.push(json!({"and":[]})), + Value::Array(values) => combined.extend(values), + part => combined.push(part), + } + } + Some(Value::Array(combined)) + }; + if let Some(value) = combined { + object.insert("conditions".into(), value); + } +} + +fn overrides(value: &mut Value, old: bool, kind: &str, key: &str, errors: &mut Vec) { + if let Some(values) = value + .get_mut("variableOverrides") + .and_then(Value::as_object_mut) + { + for entries in values.values_mut().filter_map(Value::as_array_mut) { + for entry in entries { + select(entry, old, kind, key, errors); + } + } + } +} + +pub(crate) fn entity( + value: &T, + kind: &str, + key: &str, + old: bool, +) -> (T, Vec) { + let mut value = serde_json::to_value(value).expect("serializable definition"); + let mut errors = Vec::new(); + if kind == "segment" { + let conditions = parse(value["conditions"].take(), kind, key, &mut errors); + value["conditions"] = if old && !invalid(&conditions) { + legacy(conditions, false) + } else { + conditions + }; + } else { + select(&mut value, false, kind, key, &mut errors); + if kind == "feature" { + if let Some(entries) = value.get_mut("traffic").and_then(Value::as_array_mut) { + for entry in entries { + select(entry, false, kind, key, &mut errors); + overrides(entry, old, kind, key, &mut errors); + } + } + if let Some(entries) = value.get_mut("force").and_then(Value::as_array_mut) { + for entry in entries { + select(entry, old, kind, key, &mut errors); + } + } + if let Some(entries) = value.get_mut("variations").and_then(Value::as_array_mut) { + for entry in entries { + overrides(entry, old, kind, key, &mut errors); + } + } + } else { + for (name, legacy) in [("overrides", old), ("overrideProgram", false)] { + if let Some(entries) = value.get_mut(name).and_then(Value::as_array_mut) { + for entry in entries { + select(entry, legacy, kind, key, &mut errors); + } + } + } + } + } + ( + serde_json::from_value(value).expect("normalized definition"), + errors, + ) +} + +pub(crate) fn datafile(value: &mut DatafileContent) -> Vec { + let mut errors = Vec::new(); + for (key, value) in &mut value.features { + let (next, diagnostics) = entity(value, "feature", key, true); + *value = next; + errors.extend(diagnostics); + } + for (key, value) in &mut value.segments { + let (next, diagnostics) = entity(value, "segment", key, true); + *value = next; + errors.extend(diagnostics); + } + for (key, value) in &mut value.variables { + let (next, diagnostics) = entity(value, "variable", key, true); + *value = next; + errors.extend(diagnostics); + } + errors +} + +#[derive(Default, Clone)] +pub(crate) struct EntityCache { + features: HashMap>>>, + segments: HashMap>>>, + variables: HashMap>>>, +} + +impl EntityCache { + pub(crate) fn seed(&mut self, datafile: &DatafileContent) { + for key in datafile.features.keys() { + self.features.entry(key.clone()).or_default(); + } + for key in datafile.segments.keys() { + self.segments.entry(key.clone()).or_default(); + } + for key in datafile.variables.keys() { + self.variables.entry(key.clone()).or_default(); + } + } + + pub(crate) fn retain(&mut self, incoming: &DatafileContent) { + self.features + .retain(|key, _| !incoming.features.contains_key(key)); + self.segments + .retain(|key, _| !incoming.segments.contains_key(key)); + self.variables + .retain(|key, _| !incoming.variables.contains_key(key)); + } +} + +macro_rules! getter { + ($name:ident, $ty:ty, $kind:literal) => { + pub(crate) fn $name( + cache: &Mutex, + data: &DatafileContent, + key: &str, + report: &dyn Fn(Diagnostic), + ) -> Option> { + let raw = data.$name.get(key)?; + let slot = { + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + Arc::clone(cache.$name.entry(key.to_string()).or_default()) + }; + let mut errors = Vec::new(); + let result = Arc::clone(slot.get_or_init(|| { + let (value, found) = entity(raw, $kind, key, false); + errors = found; + Arc::new(value) + })); + for error in errors { + report(error); + } + Some(result) + } + }; +} +getter!(features, Feature, "feature"); +getter!(segments, Segment, "segment"); +getter!(variables, GlobalVariable, "variable"); + +#[cfg(test)] +mod tests { + use super::*; + use crate::conditions::ConditionCache; + + #[test] + fn retained_cold_snapshots_share_diagnostics_but_incoming_entities_reset_them() { + let old: DatafileContent = + serde_json::from_value(json!({"schemaVersion":"4","revision":"old", + "segments":{},"features":{"broken":{"bucketBy":"id","conditions":"[","traffic":[]}}})) + .unwrap(); + let empty: DatafileContent = + serde_json::from_value(json!({"schemaVersion":"4","revision":"partial", + "segments":{},"features":{}})) + .unwrap(); + let original = ConditionCache::default(); + let retained = original.partial(&empty, &old); + let reports = Mutex::new(Vec::new()); + let report = |diagnostic| reports.lock().unwrap().push(diagnostic); + assert!(features(&original.entities, &old, "broken", &report).is_some()); + assert!(features(&retained.entities, &old, "broken", &report).is_some()); + assert_eq!(reports.lock().unwrap().len(), 1); + let replaced = retained.partial(&old, &old); + assert!(features(&replaced.entities, &old, "broken", &report).is_some()); + assert_eq!(reports.lock().unwrap().len(), 2); + } +} diff --git a/src/types.rs b/src/types.rs index af7c14f..e70c322 100644 --- a/src/types.rs +++ b/src/types.rs @@ -31,6 +31,13 @@ where Ok(Some(JsonValue::deserialize(deserializer)?)) } +fn deserialize_variable_value<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Some(VariableValue::deserialize(deserializer)?)) +} + #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] /// An operator used to compare a context attribute. @@ -61,6 +68,12 @@ pub enum Operator { NotExists, Includes, NotIncludes, + IncludesAny, + NotIncludesAny, + IsNull, + NotNull, + Empty, + NotEmpty, Unknown(String), } @@ -96,6 +109,12 @@ impl Serialize for Operator { Self::NotExists => "notExists", Self::Includes => "includes", Self::NotIncludes => "notIncludes", + Self::IncludesAny => "includesAny", + Self::NotIncludesAny => "notIncludesAny", + Self::IsNull => "isNull", + Self::NotNull => "notNull", + Self::Empty => "empty", + Self::NotEmpty => "notEmpty", Self::Unknown(value) => value, }; serializer.serialize_str(value) @@ -135,6 +154,12 @@ impl<'de> Deserialize<'de> for Operator { "notExists" => Self::NotExists, "includes" => Self::Includes, "notIncludes" => Self::NotIncludes, + "includesAny" => Self::IncludesAny, + "notIncludesAny" => Self::NotIncludesAny, + "isNull" => Self::IsNull, + "notNull" => Self::NotNull, + "empty" => Self::Empty, + "notEmpty" => Self::NotEmpty, _ => Self::Unknown(value), }) } @@ -177,6 +202,8 @@ pub struct NotCondition { #[serde(untagged)] /// A condition expression accepted by Featurevisor datafiles. pub enum Condition { + Variable(VariableCondition), + Feature(FeatureCondition), Plain(PlainCondition), And(AndCondition), Or(OrCondition), @@ -185,6 +212,16 @@ pub enum Condition { String(String), } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// An ordinary feature getter condition. +pub struct FeatureCondition { + pub feature: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub variation: Option, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] /// An implicit AND group of segment references. pub struct AndGroupSegment { @@ -425,7 +462,7 @@ impl From for VariableValue { } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -/// The v2 datafile consumed by the SDK. +/// A schema 4 or compatible legacy datafile consumed by the SDK. pub struct DatafileContent { #[serde(rename = "schemaVersion")] pub schema_version: String, @@ -479,8 +516,8 @@ impl Default for BucketBy { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(untagged)] -/// A feature dependency requirement. -pub enum Required { +/// A canonical feature dependency leaf or recursive group. +pub enum RequiredFeature { Feature(String), Details { feature: String, @@ -489,10 +526,34 @@ pub enum Required { #[serde(skip_serializing_if = "Option::is_none")] variation: Option, }, - LegacyVariation { - key: String, - variation: String, + And { + and: Vec, }, + Or { + or: Vec, + }, + Not { + not: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// An ordinary variable getter comparison. +pub struct VariableCondition { + #[serde(skip_serializing_if = "Option::is_none")] + pub feature: Option, + pub variable: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + pub operator: Operator, + #[serde( + default, + deserialize_with = "deserialize_condition_value", + skip_serializing_if = "Option::is_none" + )] + pub value: Option, + #[serde(rename = "regexFlags", skip_serializing_if = "Option::is_none")] + pub regex_flags: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -511,7 +572,12 @@ pub struct ResolvedVariableSchema { rename = "useDefaultWhenDisabled" )] pub use_default_when_disabled: Option, - #[serde(skip_serializing_if = "Option::is_none", rename = "disabledValue")] + #[serde( + default, + deserialize_with = "deserialize_variable_value", + skip_serializing_if = "Option::is_none", + rename = "disabledValue" + )] pub disabled_value: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, @@ -539,24 +605,83 @@ pub struct VariableOverride { pub key: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "keyPath")] pub key_path: Option>, + #[serde( + default, + deserialize_with = "deserialize_condition_value", + skip_serializing_if = "Option::is_none" + )] + pub conditions: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub segments: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "requiredFeatures")] + pub required_features: Option>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// One compiled immutable variable mutation. +pub struct VariableMutationOperation { + pub op: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde( + default, + deserialize_with = "deserialize_variable_value", + skip_serializing_if = "Option::is_none" + )] + pub value: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// One forward only step in a compiled global variable override program. +pub struct VariableOverrideStep { + pub key: String, + #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "keyPath")] + pub key_path: Vec, + #[serde( + default, + deserialize_with = "deserialize_condition_value", + skip_serializing_if = "Option::is_none" + )] pub conditions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub segments: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "requiredFeatures")] - pub required_features: Option>, + pub required_features: Option>, + pub operations: Vec, + #[serde(rename = "onMatch")] + pub on_match: JsonValue, + #[serde(rename = "onMiss")] + pub on_miss: JsonValue, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] /// A global variable definition from a datafile. pub struct GlobalVariable { + #[serde( + default, + deserialize_with = "deserialize_condition_value", + skip_serializing_if = "Option::is_none" + )] + pub conditions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compose: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hash: Option, #[serde(rename = "type")] pub variable_type: String, - #[serde(rename = "defaultValue", skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_variable_value", + rename = "defaultValue", + skip_serializing_if = "Option::is_none" + )] pub default_value: Option, - #[serde(rename = "disabledValue", skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_variable_value", + rename = "disabledValue", + skip_serializing_if = "Option::is_none" + )] pub disabled_value: Option, #[serde( rename = "useDefaultWhenDisabled", @@ -564,9 +689,11 @@ pub struct GlobalVariable { )] pub use_default_when_disabled: Option, #[serde(rename = "requiredFeatures", skip_serializing_if = "Option::is_none")] - pub required_features: Option>, + pub required_features: Option>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub overrides: Vec, + #[serde(rename = "overrideProgram", skip_serializing_if = "Option::is_none")] + pub override_program: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub deprecated: Option, } @@ -592,6 +719,13 @@ pub struct Allocation { /// A feature traffic rule. pub struct Traffic { pub key: String, + #[serde( + default, + deserialize_with = "deserialize_condition_value", + skip_serializing_if = "Option::is_none" + )] + pub conditions: Option, + #[serde(default, skip_serializing_if = "JsonValue::is_null")] pub segments: JsonValue, pub percentage: f64, #[serde(skip_serializing_if = "Option::is_none")] @@ -611,7 +745,11 @@ pub struct Traffic { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] /// A force rule that overrides normal traffic evaluation. pub struct Force { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_condition_value", + skip_serializing_if = "Option::is_none" + )] pub conditions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub segments: Option, @@ -626,16 +764,20 @@ pub struct Force { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] /// A feature definition from a datafile. pub struct Feature { + #[serde( + default, + deserialize_with = "deserialize_condition_value", + skip_serializing_if = "Option::is_none" + )] + pub conditions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hash: Option, #[serde(skip_serializing_if = "Option::is_none")] pub deprecated: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub required: Option>, #[serde(skip_serializing_if = "Option::is_none", rename = "requiredFeatures")] - pub required_features: Option>, + pub required_features: Option>, #[serde(skip_serializing_if = "Option::is_none", rename = "variablesSchema")] pub variables_schema: Option>, #[serde( @@ -647,11 +789,10 @@ pub struct Feature { pub variations: Option>, #[serde(rename = "bucketBy")] pub bucket_by: BucketBy, + #[serde(default)] pub traffic: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub force: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub ranges: Option>, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/tests/allocation_guard.rs b/tests/allocation_guard.rs new file mode 100644 index 0000000..580e3c6 --- /dev/null +++ b/tests/allocation_guard.rs @@ -0,0 +1,201 @@ +// This integration test is intentionally the only test in its binary. The +// process-wide allocator counters must not include other tests' allocations. +use featurevisor::{context, create_featurevisor, DatafileInput, FeaturevisorOptions}; +use serde_json::{json, Value}; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct CountingAllocator; +static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0); +static BYTES: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + BYTES.fetch_add(layout.size(), Ordering::Relaxed); + System.alloc(layout) + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + System.dealloc(ptr, layout) + } + + unsafe fn realloc(&self, ptr: *mut u8, old: Layout, new_size: usize) -> *mut u8 { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + BYTES.fetch_add(new_size, Ordering::Relaxed); + System.realloc(ptr, old, new_size) + } +} + +#[global_allocator] +static ALLOCATOR: CountingAllocator = CountingAllocator; + +fn counts(f: impl Fn()) -> (usize, usize) { + const READS: usize = 16; + let before_allocations = ALLOCATIONS.load(Ordering::Relaxed); + let before_bytes = BYTES.load(Ordering::Relaxed); + for _ in 0..READS { + f(); + } + ( + (ALLOCATIONS.load(Ordering::Relaxed) - before_allocations) / READS, + (BYTES.load(Ordering::Relaxed) - before_bytes) / READS, + ) +} + +#[test] +fn plain_getters_do_not_copy_rule_or_variation_payloads() { + let mut variables = serde_json::Map::new(); + for index in 0..8 { + variables.insert(format!("v{index}"), Value::String("x".repeat(1024))); + } + let overrides: Vec<_> = (0..64) + .map(|index| { + json!({ + "key": format!("candidate{index}"), + "segments": if index == 63 { "*" } else { "missing" }, + "value": "x".repeat(1024) + }) + }) + .collect(); + let input = json!({ + "schemaVersion": "2", "revision": "allocations", "segments": {}, + "features": { + "simple": {"bucketBy": "userId", "traffic": [{ + "key": "rule", "segments": "*", "percentage": 100000, "enabled": true + }]}, + "rich": {"bucketBy": "userId", "variations": [{ + "value": "A", "variables": variables + }], "traffic": [{ + "key": "rule", "segments": "*", "percentage": 100000, + "variation": "A", "variables": variables + }]} + }, + "variables": {"long_scan": {"type": "string", "defaultValue": "default", + "overrides": overrides}} + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(input.to_string())), + ..Default::default() + }); + let context = context!("userId" => "one"); + assert!(f.is_enabled("simple", Some(&context))); + assert_eq!( + f.get_variation("rich", Some(&context), None).as_deref(), + Some("A") + ); + assert!(f.evaluate_flag("simple", Some(&context)).traffic.is_some()); + let detailed = f.evaluate_variation("rich", Some(&context), None); + assert!(detailed.traffic.is_some()); + assert!(detailed.variation.is_some()); + assert_eq!( + f.get_global_variable("long_scan", None, None) + .unwrap() + .to_json(), + json!("x".repeat(1024)) + ); + assert_eq!( + f.evaluate_global_variable("long_scan", None, None) + .variable + .unwrap() + .overrides + .len(), + 64 + ); + + let (flag_allocations, flag_bytes) = counts(|| { + assert!(std::hint::black_box(f.is_enabled("simple", Some(&context)))); + }); + let (variation_allocations, variation_bytes) = counts(|| { + assert_eq!( + std::hint::black_box(f.get_variation("rich", Some(&context), None)).as_deref(), + Some("A") + ); + }); + let (global_allocations, global_bytes) = counts(|| { + assert!(std::hint::black_box(f.get_global_variable("long_scan", None, None)).is_some()); + }); + eprintln!("flag: {flag_allocations} allocations, {flag_bytes} bytes; variation: {variation_allocations} allocations, {variation_bytes} bytes"); + assert!( + flag_allocations <= 50, + "simple flag allocation regression: {flag_allocations}" + ); + assert!( + variation_allocations <= 100, + "rich variation allocation regression: {variation_allocations}" + ); + assert!( + variation_bytes <= 16_000, + "rich variation byte regression: {variation_bytes}" + ); + assert!( + global_bytes <= 16_000, + "long scan definition copy regression: {global_bytes}" + ); + assert!( + global_allocations <= 100, + "long scan allocation regression: {global_allocations}" + ); + + let mut wide_context = context.clone(); + for index in 0..32 { + wide_context.insert(format!("unused{index}"), "value".into()); + } + let narrow = counts(|| { + std::hint::black_box(f.is_enabled("simple", Some(&context))); + }); + let wide = counts(|| { + std::hint::black_box(f.is_enabled("simple", Some(&wide_context))); + }); + // Effective context and caller context each own their keys and strings. + // An unused root options snapshot would add another two complete copies. + assert!( + wide.0 <= narrow.0 + 4 * 32 + 4, + "root context copy allocation growth: narrow {narrow:?}, wide {wide:?}" + ); + + for schema in ["2", "4"] { + let selector = json!([ + {"attribute":"country","operator":"equals","value":"nl"}, + {"attribute":"device","operator":"equals","value":"mobile"} + ]); + let input = json!({"schemaVersion":schema,"revision":"warm-predicates", + "segments":{"eligible":{"conditions":selector.to_string()}}, + "features":{"flag":{"bucketBy":"userId","traffic":[ + {"key":"eligible","conditions":"eligible","percentage":100000}]}}, + "variables":{"settings":{"type":"object","defaultValue":{"currency":"USD","retries":0}, + "overrides":[{"conditions":"eligible","value":{"currency":"EUR","retries":2}}]}}}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(input.to_string())), + ..Default::default() + }); + let context = context!("country" => "nl", "device" => "mobile", "userId" => "one"); + let expected = json!({"currency":"EUR","retries":2}); + for _ in 0..2 { + assert!(f.is_enabled("flag", Some(&context))); + assert_eq!( + f.get_global_variable("settings", Some(&context), None) + .unwrap() + .to_json(), + expected + ); + } + let flag = counts(|| { + std::hint::black_box(f.is_enabled("flag", Some(&context))); + }); + let global = counts(|| { + std::hint::black_box(f.get_global_variable("settings", Some(&context), None)); + }); + eprintln!("warm schema {schema}: flag {} allocations, {} bytes; global object {} allocations, {} bytes", flag.0, flag.1, global.0, global.1); + // Warm predicates must not clone their JSON object maps before decoding. + assert!( + flag.0 <= 64 && flag.1 <= 3200, + "schema {schema} flag: {flag:?}" + ); + assert!( + global.0 <= 70 && global.1 <= 4200, + "schema {schema} global: {global:?}" + ); + f.close(); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index bb4e19d..53d212a 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2,6 +2,10 @@ use clap::Parser; use featurevisor::cli::{Cli, Command}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::Command as ProcessCommand; #[test] fn legacy_flags_are_accepted_and_targets_are_repeatable() { @@ -45,3 +49,231 @@ fn benchmark_rejects_variation_and_variable_together() { ]); assert!(result.is_err()); } + +#[test] +fn scenario_entity_filter_is_parsed() { + let cli = Cli::try_parse_from([ + "featurevisor", + "test", + "--entityType=scenario", + "--set=staging", + "--json", + "--pretty", + "--case=case-id", + "--coverage", + "--uncovered", + ]) + .unwrap(); + match cli.command.unwrap() { + Command::Test(options) => { + assert_eq!(options.entity_type.as_deref(), Some("scenario")); + assert_eq!(options.set.as_deref(), Some("staging")); + assert!(options.json); + assert!(options.pretty); + assert_eq!(options.case_id.as_deref(), Some("case-id")); + assert!(options.coverage); + assert!(options.uncovered); + } + _ => panic!("expected test command"), + } +} + +#[test] +fn native_runner_dispatches_scenarios_and_returns_failure_exit_code() { + let temp = std::env::temp_dir().join(format!( + "featurevisor-rust-cli-checks-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&temp); + fs::create_dir_all(&temp).unwrap(); + let npx = temp.join("npx"); + let log = temp.join("calls.log"); + fs::write( + &npx, + r#"#!/bin/sh +printf '%s\n' "$*" >> "$RUNNER_LOG" +emit_tests() { printf '{"kind":"featurevisor.test-execution","schemaVersion":1,"tests":%s}\n' "$1"; } +case "$*" in + *"config --json"*) printf '%s\n' '{"environments":["production"]}' ;; + *"list --segments --json"*) printf '%s\n' '[]' ;; + *"list --targets --json"*) printf '%s\n' '[{"key":"checkout"}]' ;; + *"list --features --without-tests --json"*) printf '%s\n' '[{"key":"unused"}]' ;; + *"list --variables --without-tests --json"*) printf '%s\n' '[{"key":"unusedValue"}]' ;; + *"list --tests"*) + case "$*" in *"--execution-protocol"*) ;; *) exit 16 ;; esac + if [ "$RUNNER_CASE" = protocol ]; then printf '%s\n' '[]'; exit 0; fi + if [ "$RUNNER_CASE" = report ]; then case "$*" in *"--case=case-native"*) ;; *) exit 15 ;; esac; fi + if [ "$RUNNER_CASE" = malformed ]; then emit_tests '[{"key":"scenario-native","scenario":"native","_test":{"source":"tests/scenarios/native.spec.yml","set":null,"entityType":"scenario"},"assertions":[{"environment":"production","_case":{"id":"case-malformed","assertionIndex":0,"matrixIndex":null,"matrixValues":{}}}]}]'; exit 0; fi + if [ "$RUNNER_CASE" = empty ]; then emit_tests '[{"key":"scenario-native","scenario":"native","_test":{"source":"tests/scenarios/native.spec.yml","set":null,"entityType":"scenario"},"assertions":[]}]'; exit 0; fi + if [ "$RUNNER_CASE" = mixed ]; then emit_tests '[{"key":"scenario-native","scenario":"native","variable":"subject","_test":{"source":"tests/scenarios/native.spec.yml","set":null,"entityType":"scenario"},"assertions":[{"environment":"production","_case":{"id":"case-mixed","assertionIndex":0,"matrixIndex":null,"matrixValues":{}},"checks":[{"variable":"subject","operator":"equals","value":"ready"}]}]}]'; exit 0; fi + if [ "$RUNNER_CASE" = variable ]; then emit_tests '[{"key":"subject","variable":"subject","_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":"tests/subject.yml","set":null,"entityType":"variable"},"assertions":[{"environment":"production","target":"checkout","context":{"custom":"literal custom value"},"_case":{"id":"case-variable-segments","assertionIndex":0,"matrixIndex":null,"matrixValues":{}},"checks":[{"segment":"root-context","matches":true}],"children":[{"context":{"mode":"child"},"checks":[{"segment":"merged-child","matches":true}]}]}]}]'; exit 0; fi + if [ "$RUNNER_CASE" = failure ]; then expected=wrong; else expected=ready; fi + if [ "$RUNNER_CASE" = segment-failure ]; then segment_expected=false; else segment_expected=true; fi + tests=$(printf '[{"key":"scenario-native","scenario":"native","_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":"tests/scenarios/native.spec.yml","set":null,"entityType":"scenario"},"assertions":[{"environment":"production","description":"native scenario","context":{"custom":"literal custom value","checks":"literal check value"},"_case":{"id":"case-native","assertionIndex":0,"matrixIndex":null,"matrixValues":{}},"checks":[{"key":"parent-ready","description":"Parent is ready","variable":"subject","operator":"equals","value":"%s"},{"key":"checkout-ready","feature":"checkout","enabled":true},{"key":"root-segment","segment":"root-context","matches":%s}],"children":[{"context":{"mode":"child"},"stickyVariables":{"subject":"child"},"checks":[{"variable":"subject","operator":"equals","value":"child"},{"segment":"merged-child","matches":true}]}]}]}]' "$expected" "$segment_expected"); emit_tests "$tests" ;; + *"build --environment=production"*) 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 14 ;; +esac +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&npx).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&npx, permissions).unwrap(); + + let run = |test_case: &str, entity_type: &str| { + let mut command = ProcessCommand::new(env!("CARGO_BIN_EXE_featurevisor")); + command.args([ + "test", + &format!("--projectDirectoryPath={}", temp.display()), + &format!("--entityType={entity_type}"), + "--onlyFailures", + ]); + let path = std::env::var_os("PATH").unwrap_or_default(); + command.env( + "PATH", + std::env::join_paths( + std::iter::once(PathBuf::from(&temp)).chain(std::env::split_paths(&path)), + ) + .unwrap(), + ); + if !test_case.is_empty() { + command.env("RUNNER_CASE", test_case); + } + command.env("RUNNER_LOG", &log); + command.output().unwrap() + }; + let passing = run("", "scenario"); + assert!( + passing.status.success(), + "{}", + String::from_utf8_lossy(&passing.stderr) + ); + assert!(String::from_utf8_lossy(&passing.stdout).contains("Total cases: 1 passed, 0 failed")); + let variable = run("variable", "variable"); + assert!( + variable.status.success(), + "{}", + String::from_utf8_lossy(&variable.stderr) + ); + let failing = run("failure", "scenario"); + assert!(!failing.status.success()); + assert!(String::from_utf8_lossy(&failing.stderr).contains("checks.1")); + assert!(String::from_utf8_lossy(&failing.stderr).contains("parent-ready")); + let failure_output = String::from_utf8_lossy(&failing.stderr); + assert!(failure_output.contains("check")); + assert!(failure_output.contains("Rerun (POSIX shell):")); + assert!(failure_output.contains("test --case=case-native")); + assert!(!failure_output.contains("Rerun (POSIX shell): featurevisor ")); + let segment_failure = run("segment-failure", "scenario"); + assert!(!segment_failure.status.success()); + assert!(String::from_utf8_lossy(&segment_failure.stderr).contains("root-context")); + for malformed in ["malformed", "empty", "mixed"] { + let output = run(malformed, "scenario"); + assert!( + !output.status.success(), + "{malformed}: {}", + String::from_utf8_lossy(&output.stdout) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("failed")); + } + fs::write(&log, "").unwrap(); + let protocol = run("protocol", "scenario"); + assert!(!protocol.status.success()); + let protocol_output = format!( + "{}{}", + String::from_utf8_lossy(&protocol.stdout), + String::from_utf8_lossy(&protocol.stderr) + ); + assert!(protocol_output + .to_ascii_lowercase() + .contains("test execution protocol")); + assert!(!fs::read_to_string(&log).unwrap().contains("build ")); + + let mut report_command = ProcessCommand::new(env!("CARGO_BIN_EXE_featurevisor")); + report_command.args([ + "test", + &format!("--projectDirectoryPath={}", temp.display()), + "--entityType=scenario", + "--keyPattern=^native$", + "--case=case-native", + "--json", + "--pretty", + "--coverage", + "--uncovered", + ]); + let path = std::env::var_os("PATH").unwrap_or_default(); + report_command.env( + "PATH", + std::env::join_paths( + std::iter::once(PathBuf::from(&temp)).chain(std::env::split_paths(&path)), + ) + .unwrap(), + ); + report_command.env("RUNNER_CASE", "report"); + report_command.env("RUNNER_LOG", &log); + let output = report_command.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["schemaVersion"], 1); + assert_eq!(report["runner"], "rust"); + assert_eq!(report["summary"]["scenarioCases"]["passed"], 1); + assert_eq!(report["results"][0]["cases"][0]["id"], "case-native"); + assert_eq!( + report["results"][0]["cases"][0]["context"], + serde_json::json!({ + "custom": "literal custom value", + "checks": "literal check value" + }) + ); + let rerun_command = report["results"][0]["cases"][0]["rerun"]["command"] + .as_str() + .unwrap(); + assert_ne!(rerun_command, "featurevisor"); + assert!(rerun_command.ends_with("featurevisor")); + assert!(report["results"][0]["cases"][0]["rerun"]["args"] + .as_array() + .unwrap() + .iter() + .any(|arg| arg == &format!("--projectDirectoryPath={}", temp.display()))); + assert_eq!(report["uncovered"][0]["features"][0], "unused"); + assert!(report["coverage"]["selections"] + .as_array() + .unwrap() + .iter() + .any(|selection| selection["featureKey"] == "checkout" && selection["ruleKey"] == "all")); + + let mut segment_report_command = ProcessCommand::new(env!("CARGO_BIN_EXE_featurevisor")); + segment_report_command.args([ + "test", + &format!("--projectDirectoryPath={}", temp.display()), + "--entityType=scenario", + "--json", + ]); + let path = std::env::var_os("PATH").unwrap_or_default(); + segment_report_command.env( + "PATH", + std::env::join_paths( + std::iter::once(PathBuf::from(&temp)).chain(std::env::split_paths(&path)), + ) + .unwrap(), + ); + segment_report_command.env("RUNNER_CASE", "segment-failure"); + segment_report_command.env("RUNNER_LOG", &log); + let output = segment_report_command.output().unwrap(); + assert!(!output.status.success()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let evidence = &report["results"][0]["cases"][0]["errors"][0]["details"]["evaluated"][0]; + assert_eq!( + evidence["reference"], + serde_json::json!({"segment":"root-context"}) + ); + assert_eq!(evidence["property"], "matches"); + assert_eq!(evidence["operator"], "equals"); + assert_eq!(evidence["expected"], false); + assert_eq!(evidence["actual"], true); + let _ = fs::remove_dir_all(&temp); +} diff --git a/tests/cli_options.rs b/tests/cli_options.rs new file mode 100644 index 0000000..4e233c1 --- /dev/null +++ b/tests/cli_options.rs @@ -0,0 +1,354 @@ +#![cfg(feature = "cli")] + +use clap::Parser; +use featurevisor::cli::{Cli, Command}; +use std::process::Command as Process; + +#[test] +fn aliases_and_value_forms_are_equivalent() { + for (old, new, value) in [ + ( + "projectDirectoryPath", + "project-directory-path", + "a path/'quoted'", + ), + ( + "projectDirectoryPath", + "root-directory-path", + "a path/'quoted'", + ), + ( + "projectDirectoryPath", + "rootDirectoryPath", + "a path/'quoted'", + ), + ("keyPattern", "key-pattern", "^some key$"), + ("assertionPattern", "assertion-pattern", "^some assertion$"), + ("entityType", "entity-type", "scenario"), + ] { + let expected = + Cli::try_parse_from(["featurevisor", "test", &format!("--{old}={value}")]).unwrap(); + for args in [ + vec![format!("--{new}={value}")], + vec![format!("--{new}"), value.into()], + ] { + let actual = Cli::try_parse_from( + ["featurevisor", "test"] + .into_iter() + .map(str::to_string) + .chain(args), + ) + .unwrap(); + assert_eq!(format!("{actual:?}"), format!("{expected:?}")); + } + } + for (old, new) in [ + ("onlyFailures", "only-failures"), + ("showDatafile", "show-datafile"), + ] { + let expected = Cli::try_parse_from(["featurevisor", "test", &format!("--{old}")]).unwrap(); + let actual = Cli::try_parse_from(["featurevisor", "test", &format!("--{new}")]).unwrap(); + assert_eq!(format!("{actual:?}"), format!("{expected:?}")); + } +} + +#[test] +fn repeated_values_keep_order_and_short_iterations_accept_both_forms() { + for command in ["benchmark", "assess-distribution"] { + for n in [ + vec!["--n=2"], + vec!["--n", "2"], + vec!["-n=2"], + vec!["-n", "2"], + ] { + let mut args = vec![ + "featurevisor", + command, + "--feature=flag", + "--target=web", + "--target", + "space target", + "--target=web", + ]; + args.extend(n); + if command == "assess-distribution" { + args.extend([ + "--populateUuid=first", + "--populate-uuid", + "second", + "--populate-uuid=third", + "--populateUuid", + "fourth", + ]); + } + let cli = Cli::try_parse_from(args).unwrap(); + let (iterations, common) = match cli.command.unwrap() { + Command::Benchmark(o) => (o.n, o.common), + Command::AssessDistribution(o) => { + assert_eq!(o.populate_uuid, ["first", "second", "third", "fourth"]); + (o.n, o.common) + } + _ => unreachable!(), + }; + assert_eq!(iterations, 2); + assert_eq!(common.target, ["web", "space target", "web"]); + } + } +} + +#[test] +fn help_is_successful_and_scoped_without_required_options() { + for command in [ + None, + Some("test"), + Some("benchmark"), + Some("assess-distribution"), + ] { + for flag in ["--help", "-h"] { + let output = Process::new(env!("CARGO_BIN_EXE_featurevisor")) + .args(command) + .arg(flag) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:")); + } + } +} + +#[test] +fn usage_errors_fail_before_node_delegation() { + for args in [ + vec!["benchmark", "--feature="], + vec!["benchmark", "--variable="], + vec!["assess-distribution", "--feature="], + ] { + assert!(Cli::try_parse_from(std::iter::once("featurevisor").chain(args)).is_err()); + } + let missing_project = + std::env::temp_dir().join("featurevisor-missing-option-validation-project"); + for (command, extras) in [ + ("test", vec!["--entity-type=unknown"]), + ("test", vec!["--feature=ignored"]), + ("test", vec!["--environment=ignored"]), + ("test", vec!["--target"]), + ("test", vec!["--unknown"]), + ("test", vec!["--only-failures", "extra"]), + ("benchmark", vec!["--n=0"]), + ("benchmark", vec!["-n", "-1"]), + ("benchmark", vec!["--n=1.5"]), + ("benchmark", vec!["--n=invalid"]), + ("benchmark", vec!["--n"]), + ("benchmark", vec!["--context=[]"]), + ("benchmark", vec!["--context={"]), + ("benchmark", vec!["--json"]), + ("benchmark", vec!["--quiet"]), + ("benchmark", vec!["--variation", "--variable=title"]), + ("assess-distribution", vec!["--populate-uuid"]), + ( + "assess-distribution", + vec!["--populate-uuid=userId", "ignored"], + ), + ("assess-distribution", vec!["--variable=ignored"]), + ] { + let mut process = Process::new(env!("CARGO_BIN_EXE_featurevisor")); + process.arg(command).arg(format!( + "--project-directory-path={}", + missing_project.display() + )); + if command != "test" { + process.arg("--feature=flag"); + } + let output = process.args(&extras).output().unwrap(); + assert!(!output.status.success(), "{command} {extras:?}"); + assert!(output.stdout.is_empty()); + let error = String::from_utf8_lossy(&output.stderr); + assert!(!error.is_empty()); + assert!(!error.contains("No such file"), "delegated: {error}"); + } + for pattern in ["--key-pattern=[", "--assertion-pattern=["] { + let output = Process::new(env!("CARGO_BIN_EXE_featurevisor")) + .args(["test", "--json", pattern]) + .arg(format!( + "--project-directory-path={}", + missing_project.display() + )) + .output() + .unwrap(); + assert!(!output.status.success()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["passed"], false); + assert!(String::from_utf8_lossy(&output.stderr).contains("Invalid")); + } +} + +#[cfg(unix)] +mod process_tests { + use super::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + + struct Fixture(PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + impl Fixture { + fn new() -> Self { + let directory = std::env::temp_dir().join(format!( + "featurevisor-alias-{} 'quoted' $path", + std::process::id() + )); + fs::create_dir_all(&directory).unwrap(); + let npx = directory.join("npx"); + fs::write(&npx, include_str!("fixtures/cli_npx.sh")).unwrap(); + fs::set_permissions(npx, fs::Permissions::from_mode(0o755)).unwrap(); + Self(directory) + } + fn run(&self, args: &[String]) -> String { + fs::write(self.0.join("calls.log"), "").unwrap(); + let path = std::env::join_paths(std::iter::once(self.0.clone()).chain( + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()), + )) + .unwrap(); + let output = Process::new(env!("CARGO_BIN_EXE_featurevisor")) + .args(args) + .env("PATH", path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("delegated warning")); + let calls = fs::read_to_string(self.0.join("calls.log")).unwrap(); + let targets: Vec<_> = calls + .lines() + .filter(|line| line.starts_with("--target=")) + .collect(); + assert_eq!(targets, ["--target=space target", "--target=web"]); + String::from_utf8(output.stdout).unwrap() + } + } + fn option(name: &str, value: &str, equals: bool) -> Vec { + if equals { + vec![format!("--{name}={value}")] + } else { + vec![format!("--{name}"), value.into()] + } + } + + #[test] + fn aliases_delegate_atomically_and_preserve_json_context_and_target_order() { + let fixture = Fixture::new(); + for directory in [ + "projectDirectoryPath", + "project-directory-path", + "rootDirectoryPath", + "root-directory-path", + ] { + for equals in [false, true] { + for camel in [false, true] { + let (key, assertion, entity, failures, datafile) = if camel { + ( + "keyPattern", + "assertionPattern", + "entityType", + "onlyFailures", + "showDatafile", + ) + } else { + ( + "key-pattern", + "assertion-pattern", + "entity-type", + "only-failures", + "show-datafile", + ) + }; + let mut args = vec![ + "test".into(), + "--json".into(), + format!("--{failures}"), + format!("--{datafile}"), + "--target=space target".into(), + "--target".into(), + "web".into(), + "--target=space target".into(), + ]; + args.extend(option(directory, fixture.0.to_str().unwrap(), equals)); + args.extend(option(key, "^smoke$", equals)); + args.extend(option(assertion, "^ready$", equals)); + args.extend(option(entity, "scenario", equals)); + let report: serde_json::Value = + serde_json::from_str(&fixture.run(&args)).unwrap(); + assert_eq!(report["passed"], true); + assert!(report["summary"]["assertions"]["passed"].as_u64().unwrap() > 0); + } + } + } + let context = + serde_json::json!({"country": "Café 'quoted' $HOME; space", "nested": {"x": "a b"}}); + for command in ["benchmark", "assess-distribution"] { + for n in [ + vec!["--n=2"], + vec!["--n", "2"], + vec!["-n=2"], + vec!["-n", "2"], + ] { + let mut args = vec![ + command.into(), + "--root-directory-path".into(), + fixture.0.to_str().unwrap().into(), + "--feature=checkout".into(), + "--context".into(), + context.to_string(), + "--target=space target".into(), + "--target".into(), + "web".into(), + "--target=space target".into(), + ]; + args.extend(n.into_iter().map(str::to_string)); + if command == "assess-distribution" { + args.extend( + [ + "--verbose", + "--populateUuid=first", + "--populate-uuid", + "second", + "--populate-uuid=third", + "--populateUuid", + "fourth", + ] + .into_iter() + .map(str::to_string), + ); + } + let output = fixture.run(&args); + assert!(output.contains("Iterations: 2")); + assert!(output.contains("Environment: false")); + let contexts: Vec<_> = output + .lines() + .filter_map(|line| line.strip_prefix("Context: ")) + .collect(); + if command == "assess-distribution" { + assert_eq!(contexts.len(), 4); + for line in contexts { + let actual: serde_json::Value = serde_json::from_str(line).unwrap(); + assert_eq!(actual["country"], context["country"]); + assert_eq!(actual["nested"], context["nested"]); + for key in ["first", "second", "third", "fourth"] { + assert!(actual[key].is_string()); + } + } + } else { + assert!(output.contains(&format!("Context: {context}"))); + } + } + } + } +} diff --git a/tests/cli_selection.rs b/tests/cli_selection.rs new file mode 100644 index 0000000..d316e1f --- /dev/null +++ b/tests/cli_selection.rs @@ -0,0 +1,273 @@ +#![cfg(all(feature = "cli", unix))] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +#[test] +fn flat_project_set_arguments_are_delegated_including_empty_values() { + let directory = std::env::temp_dir().join(format!( + "featurevisor-rust-flat-sets-{}", + std::process::id() + )); + fs::create_dir_all(&directory).unwrap(); + let npx = directory.join("npx"); + fs::write(&npx, r#"#!/bin/sh +printf '%s\n' "$*" >> calls.log +for arg do + case "$arg" in --set=*) echo 'This project does not use Sets. Omit --set' >&2; exit 7 ;; esac +done +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/scenarios/smoke.spec.yml","set":null,"entityType":"scenario"},"assertions":[{"_case":{"id":"case-smoke","assertionIndex":0,"matrixIndex":null,"matrixValues":{}},"checks":[{"variable":"marker","operator":"equals","value":"ready"}]}]}]}' ;; + *) printf '%s\n' '[]' ;; + esac ;; + build) printf '%s\n' '{"schemaVersion":"2","revision":"1","segments":{},"features":{},"variables":{"marker":{"type":"string","defaultValue":"ready"}}}' ;; + *) exit 19 ;; +esac +"#).unwrap(); + fs::set_permissions(npx, fs::Permissions::from_mode(0o755)).unwrap(); + let path = std::env::join_paths(std::iter::once(directory.clone()).chain( + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()), + )) + .unwrap(); + for command in ["test", "benchmark", "assess-distribution"] { + for flags in [ + vec![], + vec!["--set=unexpected"], + vec!["--set="], + vec!["--set", ""], + ] { + fs::write(directory.join("calls.log"), "").unwrap(); + let mut process = Command::new(env!("CARGO_BIN_EXE_featurevisor")); + process.args([ + command, + &format!("--projectDirectoryPath={}", directory.display()), + ]); + if command != "test" { + process.args(["--feature=checkout", "--n=1"]); + } + let output = process.args(&flags).env("PATH", &path).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.success(), + flags.is_empty(), + "{command} {flags:?}: {stdout}\n{stderr}" + ); + let calls = fs::read_to_string(directory.join("calls.log")).unwrap(); + if flags.is_empty() { + assert!(!calls.contains("--set=")); + } else { + assert!(format!("{stdout}{stderr}").contains("Omit --set")); + assert!(calls.contains(if flags == ["--set=unexpected"] { + "--set=unexpected" + } else { + "--set=" + })); + } + } + } + fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn evaluation_commands_delegate_selections_and_propagate_errors() { + let directory = std::env::temp_dir().join(format!( + "featurevisor-rust-selection-{}", + std::process::id() + )); + fs::create_dir_all(&directory).unwrap(); + for set in ["alpha", "beta"] { + fs::create_dir_all(directory.join("sets").join(set)).unwrap(); + } + let npx = directory.join("npx"); + fs::write(&npx, r#"#!/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 +"#).unwrap(); + fs::set_permissions(npx, fs::Permissions::from_mode(0o755)).unwrap(); + let path = std::env::join_paths(std::iter::once(directory.clone()).chain( + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()), + )) + .unwrap(); + let sets = + serde_json::json!({"sets": true, "setsDirectoryPath": directory.join("sets")}).to_string(); + for command in ["benchmark", "assess-distribution"] { + for (name, config, args, success, builds, expected) in [ + ("flat", "{}", vec![], true, 1, ""), + ( + "configured", + r#"{"environments":["production"]}"#, + vec!["--environment=production"], + true, + 1, + "--environment=production", + ), + ( + "missing environment", + r#"{"environments":["production"]}"#, + vec![], + false, + 0, + "", + ), + ( + "unknown environment", + r#"{"environments":["production"]}"#, + vec!["--environment=typo"], + false, + 0, + "", + ), + ( + "unexpected environment", + "{}", + vec!["--environment=production"], + false, + 0, + "", + ), + ("sets", &sets, vec![], true, 2, "--set=beta"), + ( + "selected set", + &sets, + vec!["--set=alpha"], + true, + 1, + "--set=alpha", + ), + ( + "targets", + &sets, + vec![ + "--set=alpha", + "--target=web", + "--target=mobile", + "--target=web", + ], + true, + 2, + "--target=mobile", + ), + ( + "bad target", + "{}", + vec!["--target=bad"], + false, + 1, + "--target=bad", + ), + ("bad set", &sets, vec!["--set=bad"], false, 1, "--set=bad"), + ("zero n", "{}", vec!["--n=0"], false, 0, ""), + ("zero inflate", "{}", vec!["--inflate=0"], false, 0, ""), + ("array context", "{}", vec!["--context=[]"], false, 0, ""), + ("null context", "{}", vec!["--context=null"], false, 0, ""), + ("broken context", "{}", vec!["--context={"], false, 0, ""), + ("broken config", "{", vec![], false, 0, ""), + ] { + fs::write(directory.join("config.json"), config).unwrap(); + fs::write(directory.join("calls.log"), "").unwrap(); + let mut process = Command::new(env!("CARGO_BIN_EXE_featurevisor")); + process + .env("PATH", &path) + .args([command, "--feature=checkout"]); + if !args.iter().any(|arg| arg.starts_with("--n=")) { + process.arg("--n=1"); + } + let output = process + .arg(format!("--projectDirectoryPath={}", directory.display())) + .args(args) + .output() + .unwrap(); + assert_eq!( + output.status.success(), + success, + "{command}/{name}: {}", + String::from_utf8_lossy(&output.stderr) + ); + if !success { + assert!( + !output.stderr.is_empty(), + "{command}/{name}: missing diagnostic" + ); + } + let calls = fs::read_to_string(directory.join("calls.log")).unwrap(); + assert_eq!( + calls.matches("featurevisor build").count(), + builds, + "{command}/{name}: {calls}" + ); + assert!(calls.contains(expected), "{command}/{name}: {calls}"); + if name == "flat" { + assert!(!calls.contains("--environment")); + } + } + } + fs::write(directory.join("config.json"), "{}").unwrap(); + for command in ["benchmark", "assess-distribution", "test"] { + let mut arguments = vec![vec![], vec!["--inflate".to_string()]]; + for value in ["1", "2", "0", "-1", "1.5", "invalid", ""] { + arguments.push(vec![format!("--inflate={value}")]); + arguments.push(vec!["--inflate".to_string(), value.to_string()]); + } + for extra in arguments { + fs::write(directory.join("calls.log"), "").unwrap(); + let value = extra + .last() + .map(|value| value.trim_start_matches("--inflate=")); + let valid = matches!(value, None | Some("1" | "2")); + let mut process = Command::new(env!("CARGO_BIN_EXE_featurevisor")); + process + .env("PATH", &path) + .arg(command) + .arg(format!("--projectDirectoryPath={}", directory.display())); + if command != "test" { + process.args(["--feature=checkout", "--n=1"]); + } + let output = process.args(&extra).output().unwrap(); + let calls = fs::read_to_string(directory.join("calls.log")).unwrap(); + assert_eq!( + output.status.success(), + valid, + "{command}/{extra:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + if valid { + assert_eq!( + calls.matches("featurevisor build").count(), + 1, + "{command}/{extra:?}: {calls}" + ); + if extra.is_empty() { + assert!(!calls.contains("--inflate")); + } + if value == Some("2") { + assert!(calls.contains("--inflate=2")); + } + } else { + assert!(calls.is_empty(), "invalid inflation reached Node: {calls}"); + assert!( + !output.stderr.is_empty(), + "missing diagnostic: {command}/{extra:?}" + ); + } + } + } + fs::remove_dir_all(directory).unwrap(); +} diff --git a/tests/composition_session.rs b/tests/composition_session.rs new file mode 100644 index 0000000..438c66e --- /dev/null +++ b/tests/composition_session.rs @@ -0,0 +1,285 @@ +use featurevisor::{ + context, create_featurevisor, DatafileInput, DependencyPurpose, EvaluateOptions, Evaluation, + EvaluationReason, Featurevisor, FeaturevisorModule, FeaturevisorOptions, OverrideOptions, + SpawnOptions, VariableValue, +}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +type Trace = Arc)>>>; + +#[test] +fn invalid_composed_dependencies_abort_the_whole_result_and_sessions_recover() { + for key in ["__proto__", "constructor", "prototype"] { + let mut data = datafile(); + data["variables"]["broken"] = + json!({"type":"object","compose":[{"key":key,"variable":"source"}]}); + data["variables"]["outer"] = json!({"type":"object","compose":[{"key":"good","variable":"source"},{"key":"broken","variable":"broken"}]}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + ..Default::default() + }); + for _ in 0..2 { + assert_eq!( + f.evaluate_global_variable("outer", None, None).reason, + EvaluationReason::Error + ); + assert_eq!(f.get_global_variable("outer", None, None), None); + } + assert!(f.get_global_variable("source", None, None).is_some()); + f.close(); + } +} + +#[test] +fn failed_composed_gates_and_explicit_null_feature_defaults_do_not_inherit_outer_defaults() { + let mut data = datafile(); + data["variables"]["screen"]["conditions"] = json!([]); + data["features"]["disabled"] = json!({"bucketBy":"userId","traffic":[],"variablesSchema":{"value":{"type":"string","defaultValue":"default","disabledValue":null,"useDefaultWhenDisabled":true}}}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + ..Default::default() + }); + let options = OverrideOptions { + default_variable_value: Some("outer".into()), + ..Default::default() + }; + assert_eq!( + f.get_global_variable("screen", None, Some(&options)), + Some(VariableValue::Null) + ); + assert_eq!( + f.evaluate_global_variable("screen", None, Some(&options)) + .variable_value, + Some(VariableValue::Null) + ); + assert_eq!( + f.get_variable("disabled", "value", None, Some(&options)), + Some(VariableValue::Null) + ); + f.close(); +} +struct TraceModule(Trace); +impl FeaturevisorModule for TraceModule { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + let key = options + .variable_key + .clone() + .unwrap_or_else(|| options.feature_key.clone()); + let parent = options + .dependency_of + .as_ref() + .and_then(|d| d.variable_key.clone().or_else(|| d.feature_key.clone())) + .unwrap_or_default(); + self.0 + .lock() + .unwrap() + .push((key, parent, options.dependency_purpose)); + options + } + fn after_evaluation(&self, evaluation: Evaluation, options: &EvaluateOptions) -> Evaluation { + if options.dependency_of.is_some() { + assert!(options.dependency_purpose.is_some()); + } + evaluation + } +} + +fn datafile() -> Value { + json!({"schemaVersion":"4","revision":"composition-session","segments":{},"features":{},"variables":{ + "source":{"type":"object","defaultValue":{"limit":1,"values":[1,2]}}, + "alias":{"type":"object","compose":"source"}, + "screen":{"type":"object","conditions":{"variable":"source","path":["limit"],"operator":"equals","value":1},"compose":[{"key":"one","variable":"source"},{"key":"two","variable":"source"},{"key":"alias","variable":"alias"}],"overrideProgram":[{"key":"local","conditions":{"attribute":"country","operator":"equals","value":"nl"},"operations":[{"op":"set","path":["one","limit"],"value":2}],"onMatch":1,"onMiss":1}]} + }}) +} + +#[test] +fn memo_separates_condition_composition_and_immediate_requesters_and_resets_each_call() { + let trace = Trace::default(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile().to_string())), + modules: vec![Arc::new(TraceModule(Arc::clone(&trace)))], + ..Default::default() + }); + for _ in 0..2 { + trace.lock().unwrap().clear(); + let mut value = f + .get_global_variable("screen", Some(&context!("country"=>"nl")), None) + .unwrap(); + let expected = json!({"one":{"limit":2,"values":[1,2]},"two":{"limit":1,"values":[1,2]},"alias":{"limit":1,"values":[1,2]}}); + assert_eq!(value.to_json(), expected); + assert_eq!( + *trace.lock().unwrap(), + vec![ + ("screen".into(), "".into(), None), + ( + "source".into(), + "screen".into(), + Some(DependencyPurpose::Condition) + ), + ( + "source".into(), + "screen".into(), + Some(DependencyPurpose::Composition) + ), + ( + "alias".into(), + "screen".into(), + Some(DependencyPurpose::Composition) + ), + ( + "source".into(), + "alias".into(), + Some(DependencyPurpose::Composition) + ), + ] + ); + if let VariableValue::Object(object) = &mut value { + object.insert("one".into(), VariableValue::Null); + } + assert_eq!( + f.get_global_variable("source", None, None) + .unwrap() + .to_json(), + json!({"limit":1,"values":[1,2]}) + ); + } + f.close(); +} + +#[test] +fn composition_uses_child_context_and_sticky_maps_without_inheriting_outer_defaults() { + let mut data = datafile(); + data["variables"]["child"] = json!({"type":"object","compose":[{"key":"value","variable":"missing"},{"key":"source","variable":"source"}]}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + context: Some(context!("country"=>"us")), + ..Default::default() + }); + let child = f.spawn( + context!("country"=>"nl"), + SpawnOptions { + sticky_variables: Some( + [( + "source".into(), + VariableValue::from_json(json!({"limit":1,"values":[]})), + )] + .into_iter() + .collect(), + ), + ..Default::default() + }, + ); + let options = OverrideOptions { + default_variable_value: Some("outer".into()), + ..Default::default() + }; + assert_eq!( + child + .get_global_variable("child", None, Some(&options)) + .unwrap() + .to_json(), + json!({"value":null,"source":{"limit":1,"values":[]}}) + ); + let value = child + .get_global_variable("screen", None, None) + .unwrap() + .to_json(); + assert_eq!(value["one"]["limit"], 2); + assert_eq!(value["two"]["limit"], 1); + assert_eq!( + f.get_global_variable("screen", None, None) + .unwrap() + .to_json()["one"]["limit"], + 1 + ); + child.set_sticky_variables( + [("screen".into(), VariableValue::Null)] + .into_iter() + .collect(), + false, + ); + assert_eq!( + child.evaluate_global_variable("screen", None, None).reason, + EvaluationReason::Sticky + ); + assert_eq!( + child.get_global_variable("screen", None, None), + Some(VariableValue::Null) + ); + child.close(); + f.close(); +} + +struct ChangeState(Arc>>); +impl FeaturevisorModule for ChangeState { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if options.variable_key.as_deref() == Some("switch") { + let f = self.0.lock().unwrap().as_ref().unwrap().clone(); + f.set_sticky_variables( + [( + "source".into(), + VariableValue::from_json(json!({"limit":9})), + )] + .into_iter() + .collect(), + false, + ); + } + options + } +} + +#[test] +fn eager_sources_run_before_replacement_and_live_state_invalidates_later_memo_reads() { + let holder = Arc::new(Mutex::new(None)); + let trace = Trace::default(); + let mut data = datafile(); + data["variables"]["switch"] = json!({"type":"boolean","defaultValue":true}); + data["variables"]["screen"] = json!({"type":"object","compose":[{"key":"before","variable":"source"},{"key":"switch","variable":"switch"},{"key":"after","variable":"source"},{"key":"again","variable":"source"}]}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + modules: vec![ + Arc::new(ChangeState(Arc::clone(&holder))), + Arc::new(TraceModule(Arc::clone(&trace))), + ], + ..Default::default() + }); + *holder.lock().unwrap() = Some(f.clone()); + let value = f + .get_global_variable("screen", None, None) + .unwrap() + .to_json(); + assert_eq!(value["before"]["limit"], 1); + assert_eq!(value["after"]["limit"], 9); + assert_eq!(value["again"]["limit"], 9); + assert_eq!( + trace + .lock() + .unwrap() + .iter() + .filter(|(key, _, purpose)| key == "source" + && *purpose == Some(DependencyPurpose::Composition)) + .count(), + 2 + ); + assert_eq!(value["switch"], true); + // A whole replacement still consumes every source before running the program. + data["variables"]["screen"]["overrideProgram"] = json!([{"key":"reset","conditions":"*","operations":[{"op":"set","path":[],"value":{"done":true}}],"onMatch":1,"onMiss":1}]); + f.set_datafile(DatafileInput::Json(data.to_string()), true); + f.set_sticky_variables(Default::default(), true); + assert_eq!( + f.get_global_variable("screen", None, None) + .unwrap() + .to_json(), + json!({"done":true}) + ); + assert_eq!( + f.get_global_variable("source", None, None) + .unwrap() + .to_json(), + json!({"limit":9}) + ); + holder.lock().unwrap().take(); + f.close(); +} diff --git a/tests/conditions.rs b/tests/conditions.rs index ab36fb2..30c59d6 100644 --- a/tests/conditions.rs +++ b/tests/conditions.rs @@ -192,12 +192,52 @@ fn equals_null_matches_a_null_context_attribute() { } #[test] -fn semver_comparison_zero_pads_missing_segments_and_prerelease_parts() { - for (context_version, condition_version) in [ - ("1", "1.0.0"), - ("1.2", "1.2.0"), - ("1.2.3-alpha.0", "1.2.3-alpha"), +fn null_operators_distinguish_missing_and_numeric_equality_excludes_booleans() { + let present_null = context(&[("value", AttributeValue::Null)]); + let missing = Context::new(); + for (operator, context, expected) in [ + ("isNull", &present_null, true), + ("notNull", &present_null, false), + ("isNull", &missing, false), + ("notNull", &missing, false), ] { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(condition_feature( + json!("segment"), + json!({ "attribute": "value", "operator": operator }), + ))), + ..Default::default() + }); + assert_eq!( + f.is_enabled("feature", Some(context)), + expected, + "{operator}" + ); + } + + for (actual, expected) in [ + (AttributeValue::Double(1.0), true), + (AttributeValue::Boolean(true), false), + ] { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(condition_feature( + json!("segment"), + json!({ "attribute": "value", "operator": "equals", "value": 1 }), + ))), + ..Default::default() + }); + assert_eq!( + f.is_enabled("feature", Some(&context(&[("value", actual)]))), + expected + ); + } +} + +#[test] +fn semver_comparison_zero_pads_only_missing_core_segments() { + for (context_version, condition_version) in + [("1", "1.0.0"), ("1.2", "1.2.0"), ("1.2.3", "1.2.3.0")] + { let datafile = condition_feature( json!("segment"), json!({ diff --git a/tests/conformance.rs b/tests/conformance.rs index 1c28d82..e6c81e3 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -1,7 +1,7 @@ use featurevisor::{ create_featurevisor, AttributeValue, ConfigureBucketValueOptions, Context, DatafileInput, Diagnostic, EventDetails, EventName, FeaturevisorModule, FeaturevisorOptions, LogLevel, - ModuleApi, + ModuleApi, StickyFeatures, StickyVariables, VariableValue, }; use serde_json::{json, Value}; use std::sync::{Arc, Mutex}; @@ -66,7 +66,7 @@ fn condition_feature(condition: Value) -> featurevisor::DatafileContent { #[test] fn fixture_version_and_every_section_are_present() { let fixture: Value = serde_json::from_str(include_str!("../conformance/sdk-v3.json")).unwrap(); - assert_eq!(fixture["version"], 6); + assert_eq!(fixture["version"], 15); for section in [ "bucketing", "regularExpressions", @@ -74,6 +74,8 @@ fn fixture_version_and_every_section_are_present() { "datafile", "globalVariables", "requiredFeatures", + "requiredFeatureGroups", + "requiredVariables", "diagnostics", "numericBucketKeys", "portableConditions", @@ -88,7 +90,10 @@ fn fixture_version_and_every_section_are_present() { "missing conformance section {section}" ); } - for case in fixture["conditionCases"].as_array().unwrap() { + let condition_cases = fixture["conditionCases"].as_array().unwrap(); + assert!(!condition_cases.is_empty()); + let mut executed = 0; + for case in condition_cases { let datafile = feature(json!("segment"), case["condition"].clone()); let context: Context = case["context"] .as_object() @@ -106,7 +111,382 @@ fn fixture_version_and_every_section_are_present() { "{}", case["name"] ); + executed += 1; } + assert_eq!(executed, condition_cases.len()); +} + +#[test] +fn portable_v4_cases_run_twice_through_getter_and_detailed_evaluation() { + let fixture: Value = + serde_json::from_str(include_str!("../conformance/sdk-v4-cases.json")).unwrap(); + assert_eq!(fixture["version"], 1); + let cases = fixture["cases"].as_array().unwrap(); + assert!(!cases.is_empty()); + let mut executed = 0; + for case in cases { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json( + serde_json::to_string(&case["datafile"]).unwrap(), + )), + ..Default::default() + }); + let context: Context = case["context"] + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), AttributeValue::from_json(value.clone()))) + .collect(); + let original_context = context.clone(); + for pass in 1..=2 { + let getter = f + .get_global_variable("subject", Some(&context), None) + .map(|value| value.to_json()) + .unwrap_or(Value::Null); + assert_eq!( + getter, case["expected"]["value"], + "{} pass {pass} getter", + case["name"] + ); + let evaluation = f.evaluate_global_variable("subject", Some(&context), None); + let detail_value = evaluation + .variable_value + .as_ref() + .map(VariableValue::to_json) + .unwrap_or(Value::Null); + assert_eq!( + detail_value, case["expected"]["value"], + "{} pass {pass} detail value", + case["name"] + ); + let detail = serde_json::to_value(evaluation).unwrap(); + for (key, expected) in case["expected"].as_object().unwrap() { + if key != "value" { + assert_eq!( + detail.get(key).unwrap_or(&Value::Null), + expected, + "{} pass {pass} {key}", + case["name"] + ); + } + } + } + assert_eq!(context, original_context, "{}", case["name"]); + f.close(); + executed += 1; + } + assert_eq!(executed, cases.len()); +} + +#[test] +fn precomputed_and_runtime_programs_stay_equivalent_across_child_state_and_datafile_updates() { + let fixture: Value = + serde_json::from_str(include_str!("../conformance/sdk-v4-cases.json")).unwrap(); + let cases = fixture["cases"].as_array().unwrap(); + let datafile_for = |name: &str| { + cases + .iter() + .find(|case| case["name"] == name) + .unwrap_or_else(|| panic!("missing portable case {name:?}"))["datafile"] + .clone() + }; + let expected_child = json!({ + "stage": "parent", + "rows": [{ "id": 0, "tags": [] }, { "id": 1, "tags": ["parent", "child"] }], + "flags": ["a"] + }); + let expected_parent = json!({ + "stage": "parent", "rows": [{ "id": 1, "tags": ["parent"] }], "flags": ["a"] + }); + let expected_reset = json!({ "stage": "reset", "rows": [], "flags": [] }); + let expected_default = json!({ + "stage": "default", "rows": [{ "id": 1, "tags": [] }], "flags": [null, "a"] + }); + + for (initial, next) in [ + ( + "first match precomputed stringified: child", + "first match runtime stringified: child", + ), + ( + "first match runtime stringified: child", + "first match precomputed stringified: child", + ), + ] { + let root = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile_for(initial).to_string())), + context: Some(Context::from([ + ("region".to_string(), AttributeValue::from("eu")), + ("eligible".to_string(), AttributeValue::from(true)), + ])), + ..Default::default() + }); + let child = root.spawn( + Context::from([("country".to_string(), AttributeValue::from("nl"))]), + Default::default(), + ); + let sticky_child = root.spawn( + Context::from([("country".to_string(), AttributeValue::from("nl"))]), + featurevisor::SpawnOptions { + sticky_variables: Some(StickyVariables::from([( + "permission".to_string(), + VariableValue::Boolean(false), + )])), + ..Default::default() + }, + ); + + assert_eq!( + child + .get_global_variable("subject", None, None) + .unwrap() + .to_json(), + expected_child, + "{initial} child context" + ); + assert_eq!( + sticky_child + .get_global_variable("subject", None, None) + .unwrap() + .to_json(), + expected_parent, + "{initial} sticky child" + ); + child.set_context( + Context::from([("reset".to_string(), AttributeValue::from(true))]), + false, + ); + assert_eq!( + child + .get_global_variable("subject", None, None) + .unwrap() + .to_json(), + expected_reset, + "{initial} literal reset" + ); + + root.set_datafile( + DatafileInput::Content( + serde_json::from_value(json!({ + "schemaVersion": "2", + "revision": "cache-update", + "segments": { "europe": { "conditions": "[{\"attribute\":\"region\",\"operator\":\"equals\",\"value\":\"asia\"}]" } }, + "features": {}, + "variables": {} + })) + .unwrap(), + ), + false, + ); + assert_eq!( + child + .get_global_variable("subject", None, None) + .unwrap() + .to_json(), + expected_default, + "{initial} merged selector update" + ); + + child.set_context( + Context::from([("reset".to_string(), AttributeValue::from(false))]), + false, + ); + root.set_datafile(DatafileInput::Json(datafile_for(next).to_string()), true); + assert_eq!( + child + .get_global_variable("subject", None, None) + .unwrap() + .to_json(), + expected_child, + "{initial} replacement representation" + ); + sticky_child.close(); + child.close(); + root.close(); + } +} + +fn variable_requirement(value: &Value) -> Value { + match value { + Value::String(variable) => json!({ + "variable": variable, + "operator": "equals", + "value": true + }), + Value::Object(group) => { + let (operator, members) = group.iter().next().expect("requirement group"); + json!({ + (operator): members + .as_array() + .expect("requirement members") + .iter() + .map(variable_requirement) + .collect::>() + }) + } + _ => panic!("unexpected requirement leaf"), + } +} + +#[test] +fn recursive_feature_and_variable_groups_match_the_canonical_fixture() { + let fixture: Value = serde_json::from_str(include_str!("../conformance/sdk-v3.json")).unwrap(); + let feature_inputs = fixture["requiredFeatureGroupInputs"].as_array().unwrap(); + for group in fixture["requiredFeatureGroups"].as_array().unwrap() { + for (index, input) in feature_inputs.iter().enumerate() { + let datafile = json!({ + "schemaVersion": "2", "revision": "feature-groups", "segments": {}, + "features": { + "a": { "bucketBy": "userId", "traffic": [] }, + "b": { "bucketBy": "userId", "traffic": [] }, + "subject": { + "bucketBy": "userId", + "requiredFeatures": group["requirements"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + } + }); + let sticky: StickyFeatures = ["a", "b"] + .into_iter() + .map(|key| { + ( + key.to_string(), + featurevisor::EvaluatedFeature { + enabled: input[key].as_bool().unwrap(), + variation: None, + variables: None, + }, + ) + }) + .collect(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + sticky_features: Some(sticky), + ..Default::default() + }); + assert_eq!( + f.is_enabled("subject", None), + group["results"][index].as_bool().unwrap(), + "{} input {index}", + group["name"] + ); + } + } + + let requirements = &fixture["requiredVariables"]; + for group in requirements["groups"].as_array().unwrap() { + for (index, input) in requirements["groupInputs"] + .as_array() + .unwrap() + .iter() + .enumerate() + { + let compiled = group["requirements"] + .as_array() + .unwrap() + .iter() + .map(variable_requirement) + .collect::>(); + let datafile = json!({ + "schemaVersion": "2", "revision": "variable-groups", "segments": {}, + "features": {}, + "variables": { + "a": { "type": "boolean", "defaultValue": false }, + "b": { "type": "boolean", "defaultValue": false }, + "subject": { + "type": "boolean", "defaultValue": true, "disabledValue": false, + "conditions": compiled + } + } + }); + let sticky: StickyVariables = ["a", "b"] + .into_iter() + .map(|key| { + ( + key.to_string(), + VariableValue::Boolean(input[key].as_bool().unwrap()), + ) + }) + .collect(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + sticky_variables: Some(sticky), + ..Default::default() + }); + assert_eq!( + f.get_global_variable_boolean("subject", None, None), + Some(group["results"][index].as_bool().unwrap()), + "{} input {index}", + group["name"] + ); + } + } +} + +#[test] +fn required_variables_match_canonical_root_override_program_and_feature_cases() { + let fixture: Value = serde_json::from_str(include_str!("../conformance/sdk-v3.json")).unwrap(); + let required = &fixture["requiredVariables"]; + for case in required["stickyCases"].as_array().unwrap() { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(required["datafile"].to_string())), + sticky_features: Some(serde_json::from_value(case["stickyFeatures"].clone()).unwrap()), + sticky_variables: Some( + serde_json::from_value(case["stickyVariables"].clone()).unwrap(), + ), + ..Default::default() + }); + let context = featurevisor::context!("userId" => "u"); + let expected = if case["matched"].as_bool().unwrap() { + required["expected"]["matchedValue"].clone() + } else { + required["expected"]["defaultValue"].clone() + }; + assert_eq!( + f.is_enabled("gated", Some(&context)), + case["matched"].as_bool().unwrap(), + "{} gated", + case["name"] + ); + for key in ["root", "override"] { + assert_eq!( + f.get_global_variable(key, None, None) + .map(|value| value.to_json()), + Some(expected.clone()), + "{} {key}", + case["name"] + ); + } + assert_eq!( + f.get_global_variable("accumulated", None, None) + .map(|value| value.to_json()), + Some(case["accumulated"].clone()), + "{} accumulated", + case["name"] + ); + assert_eq!( + f.get_variable("featureOverride", "message", Some(&context), None) + .map(|value| value.to_json()), + Some(expected), + "{} feature override", + case["name"] + ); + } + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(required["datafile"].to_string())), + ..Default::default() + }); + let evaluation = f.evaluate_global_variable("root", None, None); + assert_eq!( + evaluation.reason, + featurevisor::EvaluationReason::ConditionsUnmet + ); + assert!(evaluation.conditions.is_some()); + assert_eq!( + f.get_global_variable("nullGate", None, None) + .map(|value| value.to_json()), + Some(json!("matched")) + ); } #[test] @@ -223,6 +603,57 @@ fn global_variables_and_required_features_match_the_canonical_fixture() { ); } +#[test] +fn prerequisite_evaluation_details_match_every_canonical_case() { + let fixture: Value = serde_json::from_str(include_str!("../conformance/sdk-v3.json")).unwrap(); + let required = &fixture["requiredFeatures"]; + let cases = required["evaluationCases"].as_array().unwrap(); + assert!(!cases.is_empty()); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content( + serde_json::from_value(required["datafile"].clone()).unwrap(), + )), + log_level: Some(LogLevel::Fatal), + ..Default::default() + }); + + for case in cases { + let context: Context = + serde_json::from_value(case.get("context").cloned().unwrap_or_else(|| json!({}))) + .unwrap(); + let evaluation = match case["type"].as_str().unwrap() { + "flag" => f.evaluate_flag(case["feature"].as_str().unwrap(), Some(&context)), + "variation" => { + f.evaluate_variation(case["feature"].as_str().unwrap(), Some(&context), None) + } + "variable" => match case.get("feature") { + Some(feature) => f.evaluate_variable( + feature.as_str().unwrap(), + case["variable"].as_str().unwrap(), + Some(&context), + None, + ), + None => f.evaluate_global_variable( + case["variable"].as_str().unwrap(), + Some(&context), + None, + ), + }, + other => panic!("unknown evaluation type: {other}"), + }; + let actual = serde_json::to_value(evaluation).unwrap(); + for (field, expected) in case["expected"].as_object().unwrap() { + assert_eq!( + actual.get(field), + Some(expected), + "{}: {field}", + case["name"] + ); + } + } + f.close(); +} + #[test] fn global_variable_datafile_events_include_direct_and_dependency_changes() { let fixture: Value = serde_json::from_str(include_str!("../conformance/sdk-v3.json")).unwrap(); diff --git a/tests/dependency_depth.rs b/tests/dependency_depth.rs new file mode 100644 index 0000000..402ebf9 --- /dev/null +++ b/tests/dependency_depth.rs @@ -0,0 +1,77 @@ +use featurevisor::{create_featurevisor, DatafileInput, EvaluationReason, FeaturevisorOptions}; +use serde_json::json; + +#[test] +fn alias_depth_is_exact_and_failure_does_not_leak_a_session() { + for reads in [1, 32, 255, 256, 257] { + let mut data = json!({"schemaVersion":"4","revision":"depth","segments":{},"features":{},"variables":{"leaf":{"type":"integer","defaultValue":7}}}); + for index in 0..reads { + data["variables"][format!("v{index}")] = json!({"type":"integer","compose":if index == reads - 1 { "leaf".to_string() } else { format!("v{}",index+1) }}); + } + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + ..Default::default() + }); + let result = f.evaluate_global_variable("v0", None, None); + assert_eq!( + result.reason, + if reads > 256 { + EvaluationReason::Error + } else { + EvaluationReason::VariableComposed + }, + "reads={reads}" + ); + if reads <= 256 { + assert_eq!(result.variable_value, Some(7_i64.into())); + } + assert_eq!(f.get_global_variable_integer("leaf", None, None), Some(7)); + f.close(); + } +} + +#[test] +fn segments_and_getters_share_the_combined_depth_bound() { + for (depth, matched) in [(120, true), (140, false)] { + let mut data = json!({"schemaVersion":"4","revision":"depth","segments":{},"features":{"subject":{"bucketBy":"userId","conditions":"s0","traffic":[{"key":"all","conditions":"*","percentage":100000}]}},"variables":{"safe":{"type":"integer","defaultValue":7}}}); + for index in 0..depth { + data["segments"][format!("s{index}")] = + json!({"conditions":{"feature":format!("f{index}")}}); + data["features"][format!("f{index}")] = json!({"bucketBy":"userId","conditions":if index == depth-1 {"*".into()} else {format!("s{}",index+1)},"traffic":[{"key":"all","conditions":"*","percentage":100000}]}); + } + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + ..Default::default() + }); + assert_eq!(f.is_enabled("subject", None), matched, "depth={depth}"); + if !matched { + assert_eq!( + f.evaluate_flag("subject", None).reason, + EvaluationReason::Error + ); + } + assert_eq!(f.get_global_variable_integer("safe", None, None), Some(7)); + f.close(); + } +} + +#[test] +fn segment_only_chains_are_bounded_and_cleaned() { + let mut data = json!({"schemaVersion":"4","revision":"segments","segments":{},"features":{"subject":{"bucketBy":"userId","conditions":"s0","traffic":[{"key":"all","conditions":"*","percentage":100000}]}},"variables":{"safe":{"type":"integer","defaultValue":7}}}); + for index in 0..260 { + data["segments"][format!("s{index}")] = + json!({"conditions":if index==259 {"*".to_string()} else {format!("s{}",index+1)}}); + } + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + ..Default::default() + }); + let result = f.evaluate_flag("subject", None); + assert_eq!(result.reason, EvaluationReason::Error); + assert_eq!( + result.error.as_deref(), + Some("Evaluation dependency depth exceeded") + ); + assert_eq!(f.get_global_variable_integer("safe", None, None), Some(7)); + f.close(); +} diff --git a/tests/dependency_events.rs b/tests/dependency_events.rs new file mode 100644 index 0000000..244a309 --- /dev/null +++ b/tests/dependency_events.rs @@ -0,0 +1,72 @@ +use featurevisor::{ + create_featurevisor, DatafileInput, Diagnostic, EventDetails, EventName, FeaturevisorOptions, + LogLevel, +}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +#[test] +fn cold_stringified_mixed_graphs_propagate_changes_additions_and_removals_without_diagnostics() { + let mut data = json!({"schemaVersion":"4","revision":"initial","segments":{ + "feature":{"conditions":json!({"feature":"base"}).to_string()}, + "alias":{"conditions":"feature"}, + "coldBroken":{"conditions":"{broken"} + },"features":{ + "base":{"hash":"base1","bucketBy":"userId","traffic":[]}, + "consumer":{"hash":"consumer","bucketBy":"userId","conditions":{"variable":"alias" ,"operator":"notNull"},"traffic":[]} + },"variables":{ + "bridge":{"hash":"bridge","type":"boolean","defaultValue":true,"conditions":"alias"}, + "alias":{"hash":"alias","type":"boolean","compose":"bridge"}, + "map":{"hash":"map","type":"object","compose":[{"key":"flag","feature":"consumer"}]}, + "unrelated":{"hash":"unrelated","type":"object","defaultValue":{"feature":"base","conditions":"alias"}} + }}); + let diagnostics = Arc::new(Mutex::new(Vec::new())); + let copy = Arc::clone(&diagnostics); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + log_level: Some(LogLevel::Error), + on_diagnostic: Some(Arc::new(move |d: &Diagnostic| { + copy.lock().unwrap().push(d.clone()); + })), + ..Default::default() + }); + let events = Arc::new(Mutex::new(Vec::new())); + let copy = Arc::clone(&events); + let unsubscribe = f.on( + EventName::DatafileSet, + Arc::new(move |event| { + if let EventDetails::DatafileSet(details) = event { + copy.lock().unwrap().push(details.clone()); + } + }), + ); + let partial = json!({"schemaVersion":"4","revision":"changed","segments":{},"features":{"base":{"hash":"base2","bucketBy":"userId","traffic":[]}},"variables":{}}); + assert!(f.set_datafile(DatafileInput::Json(partial.to_string()), false)); + let changed = events.lock().unwrap().last().unwrap().clone(); + assert_eq!(changed.features, vec!["base", "consumer"]); + assert_eq!(changed.variables, vec!["alias", "bridge", "map"]); + data["features"].as_object_mut().unwrap().remove("base"); + data["revision"] = json!("removed"); + assert!(f.set_datafile(DatafileInput::Json(data.to_string()), true)); + let changed = events.lock().unwrap().last().unwrap().clone(); + assert_eq!(changed.features, vec!["base", "consumer"]); + assert_eq!(changed.variables, vec!["alias", "bridge", "map"]); + assert!(diagnostics.lock().unwrap().is_empty()); + assert!(f.set_datafile(DatafileInput::Json(partial.to_string()), false)); + assert_eq!( + events.lock().unwrap().last().unwrap().variables, + vec!["alias", "bridge", "map"] + ); + assert!(diagnostics.lock().unwrap().is_empty()); + assert_eq!( + f.get_segment("coldBroken").unwrap().conditions, + json!({"or":[]}) + ); + assert_eq!(diagnostics.lock().unwrap().len(), 1); + assert_eq!( + diagnostics.lock().unwrap()[0].details["conditions"], + Value::String("{broken".into()) + ); + unsubscribe(); + f.close(); +} diff --git a/tests/dependency_failures.rs b/tests/dependency_failures.rs new file mode 100644 index 0000000..54898d3 --- /dev/null +++ b/tests/dependency_failures.rs @@ -0,0 +1,143 @@ +#[path = "fixtures/dependency_smoke.rs"] +mod dependency_smoke; + +#[test] +fn dependency_failures_and_deep_tls_callbacks_on_a_small_host_stack() { + std::thread::Builder::new() + .stack_size(512 * 1024) + .spawn(dependency_smoke::run) + .unwrap() + .join() + .unwrap(); +} + +#[test] +fn dependency_errors_are_silent_in_unwind_and_abort_subprocesses() { + for profile in ["dev", "dependency-abort"] { + let build = std::process::Command::new(env!("CARGO")) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args([ + "build", + "--offline", + "--locked", + "-p", + "featurevisor", + "--example", + "dependency_smoke", + "--profile", + profile, + "--message-format=json", + ]) + .output() + .unwrap(); + assert!( + build.status.success(), + "{profile}: {}", + String::from_utf8_lossy(&build.stderr) + ); + let executable = String::from_utf8(build.stdout) + .unwrap() + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|item| { + item["reason"] == "compiler-artifact" + && item["target"]["name"] == "dependency_smoke" + }) + .and_then(|item| item["executable"].as_str().map(str::to_owned)) + .expect("built smoke executable"); + let output = std::process::Command::new(executable) + .arg(profile) + .output() + .unwrap(); + assert!( + output.status.success(), + "{profile}: {:?}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "{profile}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty(), "{profile}: unexpected stdout"); + } +} + +#[test] +fn dependency_failures_report_once_for_each_public_call() { + use featurevisor::{create_featurevisor, DatafileInput, FeaturevisorOptions, LogLevel}; + use std::sync::{Arc, Mutex}; + + let mut features = serde_json::Map::new(); + for index in 0..300 { + let conditions = if index == 0 { + serde_json::json!("*") + } else { + serde_json::json!(format!("[{{\"feature\":\"f{}\"}}]", index - 1)) + }; + features.insert( + format!("f{index}"), + serde_json::json!({"bucketBy":"userId","traffic":[{"key":"all","conditions":conditions,"percentage":100000}]}), + ); + } + let data = serde_json::json!({"schemaVersion":"4","revision":"depth","segments":{},"features":features}); + let errors = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&errors); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + log_level: Some(LogLevel::Error), + on_diagnostic: Some(Arc::new(move |diagnostic| { + if diagnostic.level == LogLevel::Error { + seen.lock().unwrap().push(diagnostic.code.clone()); + } + })), + ..Default::default() + }); + let context = featurevisor::Context::from([( + "userId".to_string(), + featurevisor::AttributeValue::from("u"), + )]); + assert!(f.is_enabled("f256", Some(&context))); + assert!(!f.is_enabled("f299", Some(&context))); + assert!(f.get_variation("f299", Some(&context), None).is_none()); + assert_eq!( + *errors.lock().unwrap(), + vec!["evaluation_error", "evaluation_error"] + ); +} + +#[test] +fn composition_dependency_failures_report_once_at_the_256_boundary() { + use featurevisor::{create_featurevisor, DatafileInput, FeaturevisorOptions, LogLevel}; + use std::sync::{Arc, Mutex}; + + let mut variables = serde_json::Map::new(); + for index in 0..300 { + variables.insert( + format!("v{index}"), + serde_json::json!({"type":"object","compose":format!("v{}", index + 1)}), + ); + } + variables.insert( + "v300".into(), + serde_json::json!({"type":"integer","defaultValue":1}), + ); + let data = serde_json::json!({"schemaVersion":"4","revision":"depth","segments":{},"features":{},"variables":variables}); + let errors = Arc::new(Mutex::new(0)); + let seen = Arc::clone(&errors); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + log_level: Some(LogLevel::Error), + on_diagnostic: Some(Arc::new(move |diagnostic| { + if diagnostic.level == LogLevel::Error { + *seen.lock().unwrap() += 1; + } + })), + ..Default::default() + }); + assert!(f.get_global_variable("v0", None, None).is_none()); + assert_eq!(*errors.lock().unwrap(), 1); + assert!(f.get_global_variable("v44", None, None).is_some()); + assert_eq!(*errors.lock().unwrap(), 1); +} diff --git a/tests/deprecation_diagnostics.rs b/tests/deprecation_diagnostics.rs new file mode 100644 index 0000000..e1d1840 --- /dev/null +++ b/tests/deprecation_diagnostics.rs @@ -0,0 +1,41 @@ +use featurevisor::{create_featurevisor, DatafileInput, FeaturevisorOptions, LogLevel}; +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex}; + +#[test] +fn deprecated_features_and_variables_use_one_code_per_kind() { + let captured = Arc::new(Mutex::new(Vec::::new())); + let observed = Arc::clone(&captured); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json( + r#"{"schemaVersion":"4","revision":"1","segments":{},"features":{"legacy":{"deprecated":true,"bucketBy":"userId","variablesSchema":{"old":{"type":"string","defaultValue":"x","deprecated":true}},"traffic":[{"key":"all","conditions":"*","percentage":100000,"allocation":[]}]}},"variables":{"oldGlobal":{"type":"string","defaultValue":"y","deprecated":true}}}"# + .to_string(), + )), + log_level: Some(LogLevel::Warn), + on_diagnostic: Some(Arc::new(move |diagnostic| { + if diagnostic.code.contains("deprecated") { + observed.lock().unwrap().push(diagnostic.code.clone()); + } + })), + ..Default::default() + }); + let take = || std::mem::take(&mut *captured.lock().unwrap()); + + f.is_enabled("legacy", None); + assert_eq!( + take().into_iter().collect::>(), + BTreeSet::from(["deprecated_feature".to_string()]) + ); + + f.get_variable("legacy", "old", None, None); + assert_eq!( + take().into_iter().collect::>(), + BTreeSet::from([ + "deprecated_feature".to_string(), + "deprecated_variable".to_string() + ]) + ); + + f.get_global_variable("oldGlobal", None, None); + assert_eq!(take(), vec!["deprecated_variable".to_string()]); +} diff --git a/tests/diagnostic_conformance.rs b/tests/diagnostic_conformance.rs new file mode 100644 index 0000000..ce6c416 --- /dev/null +++ b/tests/diagnostic_conformance.rs @@ -0,0 +1,88 @@ +use featurevisor::{ + create_featurevisor, DatafileInput, Featurevisor, FeaturevisorOptions, LogLevel, +}; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +#[test] +fn shared_diagnostics_match_exactly_and_reentrant_reads_do_not_repeat_them() { + let fixture: Value = + serde_json::from_str(include_str!("../conformance/sdk-diagnostics.json")).unwrap(); + assert_eq!(fixture["kind"], "featurevisor.sdk-diagnostics"); + assert_eq!(fixture["version"], 1); + let cases = fixture["cases"].as_array().unwrap(); + assert!(!cases.is_empty()); + let mut executed = 0; + for case in cases.iter().cycle().take(cases.len() * 2) { + let instance = Arc::new(Mutex::new(None::)); + let diagnostics = Arc::new(Mutex::new(Vec::::new())); + let reentering = Arc::new(AtomicBool::new(false)); + let reentry_counts = Arc::new(Mutex::new(Vec::new())); + let f = create_featurevisor(FeaturevisorOptions { + log_level: Some(LogLevel::Error), + on_diagnostic: Some(Arc::new({ + let instance = Arc::clone(&instance); + let diagnostics = Arc::clone(&diagnostics); + let reentry_counts = Arc::clone(&reentry_counts); + move |diagnostic| { + if diagnostic.code != "conditions_parse_error" { + return; + } + let f = instance.lock().unwrap().as_ref().unwrap().clone(); + diagnostics.lock().unwrap().push(json!({ + "code": diagnostic.code, + "level": diagnostic.level, + "details": diagnostic.details, + "revision": f.get_revision(), + })); + if diagnostic.details.get("entityType") == Some(&json!("feature")) + && !reentering.swap(true, Ordering::SeqCst) + { + let before = diagnostics.lock().unwrap().len(); + f.is_enabled(diagnostic.details["entityKey"].as_str().unwrap(), None); + reentry_counts + .lock() + .unwrap() + .push(diagnostics.lock().unwrap().len() - before); + reentering.store(false, Ordering::SeqCst); + } + } + })), + ..Default::default() + }); + *instance.lock().unwrap() = Some(f.clone()); + for (index, operation) in case["operations"].as_array().unwrap().iter().enumerate() { + let before = diagnostics.lock().unwrap().len(); + let result = match operation["action"].as_str().unwrap() { + "setDatafile" => f.set_datafile( + DatafileInput::Json(operation["datafile"].to_string()), + operation["replace"].as_bool().unwrap(), + ), + "isEnabled" => f.is_enabled(operation["feature"].as_str().unwrap(), None), + action => panic!("unsupported diagnostic fixture action: {action}"), + }; + assert_eq!( + json!(result), + operation["expectedResult"], + "{} operation {index} result", + case["name"] + ); + let mut actual = diagnostics.lock().unwrap()[before..].to_vec(); + let mut expected = operation["diagnostics"].as_array().unwrap().clone(); + actual.sort_by_key(Value::to_string); + expected.sort_by_key(Value::to_string); + assert_eq!(actual, expected, "{} operation {index}", case["name"]); + } + assert!(!reentry_counts.lock().unwrap().is_empty()); + assert!(reentry_counts + .lock() + .unwrap() + .iter() + .all(|count| *count == 0)); + f.close(); + instance.lock().unwrap().take(); + executed += 1; + } + assert_eq!(executed, cases.len() * 2); +} diff --git a/tests/empty_operators.rs b/tests/empty_operators.rs new file mode 100644 index 0000000..cc3eda0 --- /dev/null +++ b/tests/empty_operators.rs @@ -0,0 +1,151 @@ +use featurevisor::{ + create_featurevisor, AttributeValue, Context, DatafileInput, FeaturevisorOptions, Operator, + PlainCondition, +}; +use serde_json::json; +use std::collections::HashMap; + +fn condition_datafile(conditions: serde_json::Value) -> featurevisor::DatafileContent { + serde_json::from_value(json!({ + "schemaVersion": "2", + "revision": "empty-operators", + "segments": { "segment": { "conditions": conditions } }, + "features": { + "feature": { + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "segment", "percentage": 100000, "enabled": true }] + } + } + })) + .unwrap() +} + +fn context_value(value: serde_json::Value) -> Context { + HashMap::from([("value".to_string(), AttributeValue::from_json(value))]) +} + +#[test] +fn empty_operators_match_only_supported_shallow_values() { + let cases = [ + ("empty string", json!(""), "empty", true), + ("empty string is not nonempty", json!(""), "notEmpty", false), + ("whitespace is nonempty", json!(" "), "notEmpty", true), + ("empty array", json!([]), "empty", true), + ("array containing null", json!([null]), "notEmpty", true), + ("empty object", json!({}), "empty", true), + ( + "object containing null", + json!({"x": null}), + "notEmpty", + true, + ), + ("null is not empty", json!(null), "empty", false), + ("null is not nonempty", json!(null), "notEmpty", false), + ("number is unsupported", json!(0), "empty", false), + ("boolean is unsupported", json!(false), "notEmpty", false), + ]; + + for (name, value, operator, expected) in cases { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(condition_datafile(json!({ + "attribute": "value", + "operator": operator + })))), + ..Default::default() + }); + assert_eq!( + f.is_enabled("feature", Some(&context_value(value))), + expected, + "{name}" + ); + f.close(); + } + + for operator in ["empty", "notEmpty"] { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(condition_datafile(json!({ + "attribute": "missing", + "operator": operator + })))), + ..Default::default() + }); + assert!( + !f.is_enabled("feature", Some(&Context::new())), + "{operator}" + ); + f.close(); + } + + let date = featurevisor::chrono::DateTime::parse_from_rfc3339("2026-09-18T00:00:00Z").unwrap(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(condition_datafile(json!({ + "attribute": "value", + "operator": "notEmpty" + })))), + ..Default::default() + }); + let date_context = HashMap::from([("value".to_string(), AttributeValue::Date(date))]); + assert!(!f.is_enabled("feature", Some(&date_context))); + f.close(); +} + +#[test] +fn empty_operators_round_trip_and_apply_to_nested_variable_requirements() { + for (wire, expected) in [("empty", Operator::Empty), ("notEmpty", Operator::NotEmpty)] { + let condition: PlainCondition = serde_json::from_value(json!({ + "attribute": "value", + "operator": wire + })) + .unwrap(); + assert_eq!(condition.operator, expected); + assert_eq!(serde_json::to_value(condition).unwrap()["operator"], wire); + } + + let datafile = serde_json::from_value(json!({ + "schemaVersion":"2", "revision":"empty-requirements", "segments":{}, "features":{}, + "variables":{ + "source":{"type":"json","defaultValue":"{\"nested\":{\"emptyArray\":[],\"arrayWithNull\":[null],\"emptyObject\":{},\"objectWithNull\":{\"x\":null}},\"whitespace\":\" \",\"null\":null,\"number\":0}"}, + "emptyArray":{"type":"boolean","defaultValue":true,"disabledValue":false,"conditions":[{"variable":"source","path":["nested","emptyArray"],"operator":"empty"}]}, + "arrayWithNull":{"type":"boolean","defaultValue":true,"disabledValue":false,"conditions":[{"variable":"source","path":["nested","arrayWithNull"],"operator":"notEmpty"}]}, + "emptyObject":{"type":"boolean","defaultValue":true,"disabledValue":false,"conditions":[{"variable":"source","path":["nested","emptyObject"],"operator":"empty"}]}, + "objectWithNull":{"type":"boolean","defaultValue":true,"disabledValue":false,"conditions":[{"variable":"source","path":["nested","objectWithNull"],"operator":"notEmpty"}]}, + "whitespace":{"type":"boolean","defaultValue":true,"disabledValue":false,"conditions":[{"variable":"source","path":["whitespace"],"operator":"notEmpty"}]}, + "nullEmpty":{"type":"boolean","defaultValue":true,"disabledValue":false,"conditions":[{"variable":"source","path":["null"],"operator":"empty"}]}, + "nullNotEmpty":{"type":"boolean","defaultValue":true,"disabledValue":false,"conditions":[{"variable":"source","path":["null"],"operator":"notEmpty"}]}, + "missingNotEmpty":{"type":"boolean","defaultValue":true,"disabledValue":false,"conditions":[{"variable":"source","path":["missing"],"operator":"notEmpty"}]}, + "numberEmpty":{"type":"boolean","defaultValue":true,"disabledValue":false,"conditions":[{"variable":"source","path":["number"],"operator":"empty"}]} + } + })) + .unwrap(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile)), + ..Default::default() + }); + + for key in [ + "emptyArray", + "arrayWithNull", + "emptyObject", + "objectWithNull", + "whitespace", + ] { + assert_eq!( + f.get_global_variable_boolean(key, None, None), + Some(true), + "{key}" + ); + } + for key in [ + "nullEmpty", + "nullNotEmpty", + "missingNotEmpty", + "numberEmpty", + ] { + assert_eq!( + f.get_global_variable_boolean(key, None, None), + Some(false), + "{key}" + ); + } + f.close(); +} diff --git a/tests/evaluation_performance_contract.rs b/tests/evaluation_performance_contract.rs new file mode 100644 index 0000000..436e77d --- /dev/null +++ b/tests/evaluation_performance_contract.rs @@ -0,0 +1,535 @@ +use featurevisor::{ + create_featurevisor, Context, DatafileInput, Diagnostic, EvaluateOptions, Evaluation, + EventName, Featurevisor, FeaturevisorModule, FeaturevisorOptions, LogLevel, ModuleApi, + OverrideOptions, SpawnOptions, +}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +fn input(country: &str) -> DatafileInput { + DatafileInput::Json(json!({ + "schemaVersion": "2", "revision": country, + "segments": {"audience": {"conditions": json!({"and": [ + {"attribute": "country", "operator": "equals", "value": country}, + {"not": [{"attribute": "device", "operator": "equals", "value": "bot"}]} + ]}).to_string()}}, + "features": {"flag": {"bucketBy": "userId", "traffic": [ + {"key": "rule", "segments": "audience", "percentage": 100000, "enabled": true} + ]}}, + "variables": {"settings": {"type": "object", "defaultValue": {"country": "default"}, + "overrides": [{"key": "local", "segments": "audience", "value": {"country": country}}] + }} + }).to_string()) +} + +fn context(country: &str) -> Context { + [ + ("country".to_string(), country.into()), + ("device".to_string(), "mobile".into()), + ] + .into_iter() + .collect() +} + +#[test] +fn warm_expressions_do_not_cache_results_and_follow_merge_and_replace() { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(input("nl")), + ..Default::default() + }); + for replace in [false, true] { + for country in ["nl", "de", "nl"] { + f.set_datafile(input(country), replace); + for _ in 0..10 { + assert!(f.is_enabled("flag", Some(&context(country)))); + assert!(!f.is_enabled("flag", Some(&context("other")))); + let result = f.evaluate_global_variable("settings", Some(&context(country)), None); + assert_eq!( + serde_json::to_value(result.variable_value).unwrap(), + json!({"country": country}) + ); + assert_eq!(result.variable_override_key.as_deref(), Some("local")); + } + } + } + f.set_datafile(DatafileInput::Json("invalid".into()), true); + assert!(f.is_enabled("flag", Some(&context("nl")))); +} + +#[test] +fn cached_parse_errors_still_emit_events_and_allow_reentrant_recovery() { + let f = create_featurevisor(FeaturevisorOptions::default()); + let errors = Arc::new(Mutex::new(0)); + let observed = Arc::clone(&errors); + let reentrant = f.clone(); + let cleanup = f.on( + EventName::Error, + Arc::new(move |_| { + *observed.lock().unwrap() += 1; + // A cache lock must never be held while reporting a failure. + reentrant.set_datafile(input("nl"), true); + }), + ); + for _ in 0..3 { + let mut data: Value = match input("nl") { + DatafileInput::Json(s) => serde_json::from_str(&s).unwrap(), + _ => unreachable!(), + }; + data["schemaVersion"] = json!("4"); + data["segments"]["audience"]["conditions"] = json!("{bad JSON"); + f.set_datafile(DatafileInput::Json(data.to_string()), true); + assert!(!f.is_enabled("flag", Some(&context("nl")))); + assert!(f.is_enabled("flag", Some(&context("nl")))); + } + assert_eq!(*errors.lock().unwrap(), 3); + cleanup(); +} + +#[test] +fn concurrent_warm_caches_use_each_callers_context() { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(input("nl")), + ..Default::default() + }); + let workers: Vec<_> = (0..8) + .map(|index| { + let f = f.clone(); + std::thread::spawn(move || { + let country = if index % 2 == 0 { "nl" } else { "de" }; + for _ in 0..100 { + assert_eq!( + f.is_enabled("flag", Some(&context(country))), + country == "nl" + ); + } + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } +} + +struct ChangeDuringEvaluation { + instance: Featurevisor, +} +impl FeaturevisorModule for ChangeDuringEvaluation { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + self.instance.set_datafile(input("de"), true); + options + } +} + +#[test] +fn segment_reads_follow_callback_updates_without_changing_retained_definitions() { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(input("nl")), + ..Default::default() + }); + assert!(f.is_enabled("flag", Some(&context("nl")))); + let original = f.get_segment("audience").unwrap(); + let cleanup = f + .add_module(Arc::new(ChangeDuringEvaluation { + instance: f.clone(), + })) + .unwrap(); + assert!(!f.is_enabled("flag", Some(&context("nl")))); + assert!(!f.is_enabled("flag", Some(&context("nl")))); + assert!(f.is_enabled("flag", Some(&context("de")))); + assert_eq!(original.conditions["and"][0]["value"], "nl"); + assert_eq!( + f.get_segment("audience").unwrap().conditions["and"][0]["value"], + "de" + ); + cleanup(); +} + +struct EnableDiagnostics { + instance: Featurevisor, +} +impl FeaturevisorModule for EnableDiagnostics { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + self.instance.set_log_level(LogLevel::Debug); + options + } + fn after_evaluation(&self, mut evaluation: Evaluation, _: &EvaluateOptions) -> Evaluation { + if evaluation.feature_key.is_empty() { + evaluation.variable_value = Some("transformed".into()); + } + evaluation + } +} + +#[test] +fn lazy_diagnostics_honour_level_changes_and_complete_metadata() { + let observed = Arc::new(Mutex::new(Vec::::new())); + let received = Arc::clone(&observed); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(input("nl")), + log_level: Some(LogLevel::Error), + on_diagnostic: Some(Arc::new(move |d| received.lock().unwrap().push(d.clone()))), + ..Default::default() + }); + f.is_enabled("flag", Some(&context("nl"))); + assert!(observed.lock().unwrap().is_empty()); + let cleanup = f + .add_module(Arc::new(EnableDiagnostics { + instance: f.clone(), + })) + .unwrap(); + let evaluation = f.evaluate_flag("flag", Some(&context("nl"))); + let diagnostics = observed.lock().unwrap().clone(); + let diagnostic = diagnostics + .iter() + .find(|d| d.level == LogLevel::Debug) + .unwrap(); + assert_eq!( + diagnostic.details["evaluation"], + serde_json::to_value(&evaluation).unwrap() + ); + let global = f.evaluate_global_variable("settings", Some(&context("nl")), None); + let diagnostics = observed.lock().unwrap().clone(); + let diagnostic = diagnostics + .iter() + .find(|d| d.message == "Global variable evaluated") + .unwrap(); + assert_eq!( + serde_json::to_value(&diagnostic.details).unwrap(), + serde_json::to_value(&global).unwrap() + ); + assert_eq!(diagnostic.details["variableValue"], "transformed"); + cleanup(); +} + +#[test] +fn plain_global_debug_materializes_snapshot_after_diagnostic_changes_level() { + let owner = Arc::new(Mutex::new(None::)); + let owner_for_callback = Arc::clone(&owner); + let received = Arc::new(Mutex::new(Vec::::new())); + let received_for_callback = Arc::clone(&received); + let datafile = json!({ + "schemaVersion": "4", "revision": "lazy-global", "segments": {}, "features": {}, + "variables": {"choice": {"type": "string", "defaultValue": "default", + "overrides": [{"key": "bad", "conditions": "{not JSON", "value": "bad"}]}} + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + log_level: Some(LogLevel::Error), + on_diagnostic: Some(Arc::new(move |diagnostic| { + if diagnostic.level == LogLevel::Error { + if let Some(instance) = owner_for_callback.lock().unwrap().as_ref() { + instance.set_log_level(LogLevel::Debug); + } + } + received_for_callback + .lock() + .unwrap() + .push(diagnostic.clone()); + })), + ..Default::default() + }); + *owner.lock().unwrap() = Some(f.clone()); + assert_eq!( + f.get_global_variable("choice", None, None) + .unwrap() + .to_json(), + json!("default") + ); + let diagnostics = received.lock().unwrap(); + let debug = diagnostics + .iter() + .find(|diagnostic| diagnostic.message == "Global variable evaluated") + .unwrap(); + assert_eq!(debug.details["variable"]["type"], json!("string")); + drop(diagnostics); + owner.lock().unwrap().take(); + f.close(); +} + +struct SubscribeDuringEvaluation { + instance: Featurevisor, + subscribed: Mutex, + observed: Arc>>, +} +struct Observer(Arc>>); +impl FeaturevisorModule for Observer { + fn name(&self) -> Option<&str> { + Some("observer") + } + fn setup(&self, api: &ModuleApi) { + let observed = Arc::clone(&self.0); + let _ = api.on_diagnostic( + Arc::new(move |d| observed.lock().unwrap().push(d.clone())), + Some(LogLevel::Debug), + ); + } +} +impl FeaturevisorModule for SubscribeDuringEvaluation { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + let mut subscribed = self.subscribed.lock().unwrap(); + if !*subscribed { + let _ = self + .instance + .add_module(Arc::new(Observer(Arc::clone(&self.observed)))); + *subscribed = true; + } + options + } +} + +#[test] +fn module_diagnostic_subscriptions_added_during_evaluation_are_not_skipped() { + let observed = Arc::new(Mutex::new(Vec::::new())); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(input("nl")), + log_level: Some(LogLevel::Error), + ..Default::default() + }); + let cleanup = f + .add_module(Arc::new(SubscribeDuringEvaluation { + instance: f.clone(), + subscribed: Mutex::new(false), + observed: Arc::clone(&observed), + })) + .unwrap(); + f.is_enabled("flag", Some(&context("nl"))); + assert!(observed + .lock() + .unwrap() + .iter() + .any(|d| d.level == LogLevel::Debug)); + cleanup(); + f.remove_module("observer"); + let count = observed.lock().unwrap().len(); + f.is_enabled("flag", Some(&context("nl"))); + assert_eq!(observed.lock().unwrap().len(), count); +} + +struct InspectDetails(Arc>>); + +impl FeaturevisorModule for InspectDetails { + fn after_evaluation(&self, mut evaluation: Evaluation, _: &EvaluateOptions) -> Evaluation { + if evaluation.evaluation_type == featurevisor::EvaluationType::Variation { + self.0 + .lock() + .unwrap() + .push((evaluation.traffic.is_some(), evaluation.variation.is_some())); + evaluation.traffic.as_mut().unwrap().key = "module rule".to_string(); + evaluation.variation.as_mut().unwrap().value = "module variation".to_string(); + } + evaluation + } +} + +#[test] +fn plain_getters_keep_full_details_for_modules_and_diagnostics() { + let observed = Arc::new(Mutex::new(Vec::::new())); + let received = Arc::clone(&observed); + let datafile = DatafileInput::Json(json!({ + "schemaVersion": "2", "revision": "details", "segments": {}, + "features": {"rich": { + "bucketBy": "userId", "variations": [{"value": "A", "variables": {"large": [1, 2, 3]}}], + "traffic": [{"key": "rule", "segments": "*", "percentage": 100000, + "variation": "A", "variables": {"large": [1, 2, 3]}}] + }} + }).to_string()); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(datafile), + log_level: Some(LogLevel::Debug), + on_diagnostic: Some(Arc::new(move |d| received.lock().unwrap().push(d.clone()))), + ..Default::default() + }); + let context = featurevisor::context!("userId" => "one"); + assert!(f.is_enabled("rich", Some(&context))); + let flag_diagnostic = observed + .lock() + .unwrap() + .iter() + .rev() + .find(|d| { + d.level == LogLevel::Debug + && d.details + .get("evaluation") + .is_some_and(|e| e["type"] == "flag") + }) + .cloned() + .unwrap(); + assert_eq!( + flag_diagnostic.details["evaluation"], + serde_json::to_value(f.evaluate_flag("rich", Some(&context))).unwrap() + ); + assert_eq!( + f.get_variation("rich", Some(&context), None).as_deref(), + Some("A") + ); + let diagnostic = observed + .lock() + .unwrap() + .iter() + .rev() + .find(|d| { + d.level == LogLevel::Debug + && d.details + .get("evaluation") + .is_some_and(|e| e["type"] == "variation") + }) + .cloned() + .unwrap(); + let expected = + serde_json::to_value(f.evaluate_variation("rich", Some(&context), None)).unwrap(); + assert_eq!(diagnostic.details["evaluation"], expected); + assert!(diagnostic.details["evaluation"] + .get("deferredTrafficIndex") + .is_none()); + assert!(diagnostic.details["evaluation"] + .get("deferredVariationIndex") + .is_none()); + + let child = f.spawn(context.clone(), SpawnOptions::default()); + assert_eq!( + child.get_variation("rich", None, None).as_deref(), + Some("A") + ); + let child_diagnostic = observed + .lock() + .unwrap() + .iter() + .rev() + .find(|d| { + d.level == LogLevel::Debug + && d.details + .get("evaluation") + .is_some_and(|e| e["type"] == "variation") + }) + .cloned() + .unwrap(); + assert_eq!( + child_diagnostic.details["evaluation"], + serde_json::to_value(child.evaluate_variation("rich", None, None)).unwrap() + ); + assert!(child.is_enabled("rich", None)); + assert_eq!( + child + .get_variation( + "missing", + None, + Some(&OverrideOptions { + default_variation_value: Some("fallback".to_string()), + ..Default::default() + }) + ) + .as_deref(), + Some("fallback") + ); + assert_eq!( + child.get_variable( + "rich", + "missing", + None, + Some(&OverrideOptions { + default_variable_value: Some("fallback".into()), + ..Default::default() + }) + ), + Some("fallback".into()) + ); + + let module_details = Arc::new(Mutex::new(Vec::new())); + let cleanup = f + .add_module(Arc::new(InspectDetails(Arc::clone(&module_details)))) + .unwrap(); + assert_eq!( + f.get_variation("rich", Some(&context), None).as_deref(), + Some("module variation") + ); + assert_eq!(module_details.lock().unwrap().as_slice(), &[(true, true)]); + let detailed = f.evaluate_variation("rich", Some(&context), None); + assert_eq!(detailed.traffic.unwrap().key, "module rule"); + assert_eq!(detailed.variation.unwrap().value, "module variation"); + cleanup(); +} + +#[test] +fn diagnostic_reentrancy_materializes_the_original_datafile_snapshot() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let old_traffic = json!({ + "key": "old rule", "segments": "*", "percentage": 100000.0, + "variation": "A", "variables": {"payload": [1, 2, 3]} + }); + let old_variation = json!({"value": "A", "variables": {"payload": [1, 2, 3]}}); + let input = |rule: Value, variation: Value| { + DatafileInput::Json( + json!({ + "schemaVersion": "2", "revision": "snapshot", "segments": {}, + "features": {"rich": {"bucketBy": "userId", + "variations": [variation], "traffic": [rule]}} + }) + .to_string(), + ) + }; + let old = input(old_traffic.clone(), old_variation.clone()); + let next = input( + json!({"key": "new rule", "segments": "*", "percentage": 100000, + "variation": "B", "variables": {"payload": [9]}}), + json!({"value": "B", "variables": {"payload": [9]}}), + ); + let instance = Arc::new(Mutex::new(None::)); + let callback_instance = Arc::clone(&instance); + let changed = Arc::new(AtomicBool::new(false)); + let callback_changed = Arc::clone(&changed); + let observed = Arc::new(Mutex::new(Vec::::new())); + let callback_observed = Arc::clone(&observed); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(old), + log_level: Some(LogLevel::Debug), + on_diagnostic: Some(Arc::new(move |diagnostic| { + callback_observed.lock().unwrap().push(diagnostic.clone()); + if diagnostic + .details + .get("evaluation") + .is_some_and(|e| e["type"] == "flag") + && !callback_changed.swap(true, Ordering::SeqCst) + { + let current = callback_instance.lock().unwrap().as_ref().unwrap().clone(); + current.set_datafile(next.clone(), true); + } + })), + ..Default::default() + }); + *instance.lock().unwrap() = Some(f.clone()); + let context = featurevisor::context!("userId" => "one"); + assert_eq!( + f.get_variation("rich", Some(&context), None).as_deref(), + Some("A") + ); + assert!(changed.load(Ordering::SeqCst)); + let diagnostic = observed + .lock() + .unwrap() + .iter() + .rev() + .find(|d| { + d.details + .get("evaluation") + .is_some_and(|e| e["type"] == "variation") + }) + .cloned() + .unwrap(); + let mut normalized_traffic = old_traffic; + normalized_traffic["conditions"] = normalized_traffic + .as_object_mut() + .unwrap() + .remove("segments") + .unwrap(); + assert_eq!( + diagnostic.details["evaluation"]["traffic"], + normalized_traffic + ); + assert_eq!(diagnostic.details["evaluation"]["variation"], old_variation); + assert_eq!( + f.get_variation("rich", Some(&context), None).as_deref(), + Some("B") + ); + instance.lock().unwrap().take(); +} diff --git a/tests/evaluation_reason.rs b/tests/evaluation_reason.rs new file mode 100644 index 0000000..b48e910 --- /dev/null +++ b/tests/evaluation_reason.rs @@ -0,0 +1,61 @@ +use featurevisor::{ + create_featurevisor, DatafileInput, Evaluation, EvaluationReason, FeaturevisorOptions, LogLevel, +}; +use serde_json::json; +use std::sync::{Arc, Mutex}; + +#[test] +fn unmet_feature_prerequisites_use_the_same_reason_in_evaluations_and_diagnostics() { + let diagnostics = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&diagnostics); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content( + serde_json::from_value(json!({ + "schemaVersion": "2", "revision": "reason", "segments": {}, + "features": { + "dependent": { + "bucketBy": "userId", "requiredFeatures": ["missing"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + } + })) + .unwrap(), + )), + log_level: Some(LogLevel::Debug), + on_diagnostic: Some(Arc::new(move |diagnostic| { + observed.lock().unwrap().push(diagnostic.clone()); + })), + ..Default::default() + }); + + let evaluation = f.evaluate_flag("dependent", None); + assert_eq!(evaluation.reason, EvaluationReason::ConditionsUnmet); + assert_eq!(evaluation.enabled, Some(false)); + let wire = serde_json::to_value(&evaluation).unwrap(); + assert_eq!(wire["reason"], "conditions_unmet"); + assert_eq!(wire["conditions"], json!([{ "feature": "missing" }])); + assert_eq!( + serde_json::from_value::(wire.clone()).unwrap(), + evaluation + ); + + let diagnostics = diagnostics.lock().unwrap(); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "conditions_unmet") + .expect("unmet prerequisite diagnostic"); + assert_eq!(diagnostic.details["reason"], "conditions_unmet"); + assert_eq!(diagnostic.details["evaluation"], wire); + assert!(diagnostics + .iter() + .all(|diagnostic| diagnostic.code != "required")); +} + +#[test] +fn required_is_not_an_evaluation_reason_alias() { + assert!(serde_json::from_value::(json!("required")).is_err()); + assert_eq!( + serde_json::from_value::(json!("conditions_unmet")).unwrap(), + EvaluationReason::ConditionsUnmet + ); +} diff --git a/tests/evaluation_session.rs b/tests/evaluation_session.rs new file mode 100644 index 0000000..8a19261 --- /dev/null +++ b/tests/evaluation_session.rs @@ -0,0 +1,655 @@ +use featurevisor::{ + create_featurevisor, AttributeValue, DatafileInput, EvaluateOptions, EvaluationDependency, + Featurevisor, FeaturevisorChild, FeaturevisorModule, FeaturevisorOptions, SpawnOptions, + VariableValue, +}; +use serde_json::json; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +fn graph_datafile() -> String { + json!({ + "schemaVersion": "2", "revision": "session", "segments": {}, "features": {}, + "variables": { + "leaf": {"type": "boolean", "defaultValue": true}, + "left": {"type": "boolean", "defaultValue": true, + "conditions": [{"variable": "leaf", "operator": "equals", "value": true}]}, + "right": {"type": "boolean", "defaultValue": true, + "conditions": [{"variable": "leaf", "operator": "equals", "value": true}]}, + "root": {"type": "boolean", "defaultValue": true, "disabledValue": false, + "conditions": [ + {"variable": "left", "operator": "equals", "value": true}, + {"variable": "right", "operator": "equals", "value": true} + ]} + } + }) + .to_string() +} + +type Visit = (String, Option, Option); + +struct SessionModule { + visits: Arc>>, + transform_context: bool, + instance: Arc>>, + reentered: Arc, +} + +impl FeaturevisorModule for SessionModule { + fn before_evaluation(&self, mut options: EvaluateOptions) -> EvaluateOptions { + let key = options.variable_key.clone().unwrap_or_default(); + if self.transform_context && (key == "left" || key == "right") { + options + .context + .insert("country".to_string(), AttributeValue::String(key.clone())); + } + let country = match options.context.get("country") { + Some(AttributeValue::String(value)) => Some(value.clone()), + _ => None, + }; + self.visits + .lock() + .unwrap() + .push((key.clone(), options.dependency_of.clone(), country)); + if key == "leaf" && self.transform_context && !self.reentered.swap(true, Ordering::SeqCst) { + let instance = self.instance.lock().unwrap().clone().unwrap(); + assert_eq!( + instance.get_global_variable("leaf", None, None), + Some(VariableValue::Boolean(true)) + ); + } + options + } +} + +#[test] +fn diamond_memo_is_request_local_and_marks_immediate_parent() { + let visits = Arc::new(Mutex::new(Vec::new())); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(graph_datafile())), + modules: vec![Arc::new(SessionModule { + visits: Arc::clone(&visits), + transform_context: false, + instance: Arc::new(Mutex::new(None)), + reentered: Arc::new(AtomicBool::new(false)), + })], + ..Default::default() + }); + assert_eq!( + f.get_global_variable("root", None, None), + Some(VariableValue::Boolean(true)) + ); + let first = visits.lock().unwrap().clone(); + assert_eq!( + first.iter().map(|item| item.0.as_str()).collect::>(), + ["root", "left", "leaf", "right", "leaf"] + ); + assert!(first[0].1.is_none()); + assert_eq!( + first[2] + .1 + .as_ref() + .and_then(|parent| parent.variable_key.as_deref()), + Some("left") + ); + assert_eq!( + f.get_global_variable("root", None, None), + Some(VariableValue::Boolean(true)) + ); + assert_eq!(visits.lock().unwrap().len(), 10); +} + +struct StickyScalingModule { + visits: Arc>>, +} + +impl FeaturevisorModule for StickyScalingModule { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if options.dependency_of.is_some() { + self.visits.lock().unwrap().push(( + options.evaluation_type, + options + .variable_key + .clone() + .unwrap_or_else(|| options.feature_key.clone()), + )); + } + options + } +} + +#[test] +fn large_sticky_handoffs_keep_duplicate_reads_memoized_for_parent_and_child() { + let datafile = json!({ + "schemaVersion": "2", "revision": "sticky-scale", "segments": {}, + "features": {"gate": {"bucketBy": "userId", "traffic": [ + {"key": "all", "segments": "*", "percentage": 100000} + ]}}, + "variables": { + "leaf": {"type": "integer", "defaultValue": 1}, + "root": {"type": "integer", "defaultValue": 1, "disabledValue": 0, + "conditions": [ + {"feature":"gate"}, {"feature":"gate"}, + {"variable": "leaf", "operator": "equals", "value": 1}, + {"variable": "leaf", "operator": "equals", "value": 1} + ]} + } + }); + for size in [0, 100, 1000, 5000] { + for kind in ["features", "variables"] { + for child_scope in [false, true] { + let visits = Arc::new(Mutex::new(Vec::new())); + let mut features = featurevisor::StickyFeatures::new(); + let mut variables = featurevisor::StickyVariables::new(); + for index in 0..size { + if kind == "features" { + features.insert( + format!("unrelated{index}"), + featurevisor::EvaluatedFeature { + enabled: true, + variation: None, + variables: None, + }, + ); + } else { + variables + .insert(format!("unrelated{index}"), VariableValue::Integer(index)); + } + } + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + sticky_features: if child_scope { + None + } else { + Some(features.clone()) + }, + sticky_variables: if child_scope { + None + } else { + Some(variables.clone()) + }, + modules: vec![Arc::new(StickyScalingModule { + visits: Arc::clone(&visits), + })], + ..Default::default() + }); + let mut context = featurevisor::Context::new(); + context.insert("userId".into(), AttributeValue::String("test".into())); + let child = if child_scope { + Some(f.spawn( + context.clone(), + SpawnOptions { + sticky_features: Some(features), + sticky_variables: Some(variables), + }, + )) + } else { + None + }; + let value = if let Some(child) = &child { + child.get_global_variable("root", None, None) + } else { + f.get_global_variable("root", Some(&context), None) + }; + assert_eq!(value, Some(VariableValue::Integer(1))); + assert_eq!( + *visits.lock().unwrap(), + vec![ + (featurevisor::EvaluationType::Flag, "gate".into()), + (featurevisor::EvaluationType::Variable, "leaf".into()), + ], + "size={size} kind={kind} child={child_scope}" + ); + if let Some(child) = child { + child.close(); + } + f.close(); + } + } + } +} + +#[test] +fn transformed_contexts_and_reentrant_public_calls_have_separate_scopes() { + let visits = Arc::new(Mutex::new(Vec::new())); + let instance = Arc::new(Mutex::new(None)); + let reentered = Arc::new(AtomicBool::new(false)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(graph_datafile())), + modules: vec![Arc::new(SessionModule { + visits: Arc::clone(&visits), + transform_context: true, + instance: Arc::clone(&instance), + reentered: Arc::clone(&reentered), + })], + ..Default::default() + }); + *instance.lock().unwrap() = Some(f.clone()); + assert_eq!( + f.get_global_variable("root", None, None), + Some(VariableValue::Boolean(true)) + ); + assert!(reentered.load(Ordering::SeqCst)); + let countries = visits + .lock() + .unwrap() + .iter() + .filter(|item| item.0 == "leaf") + .filter_map(|item| item.2.clone()) + .collect::>(); + assert_eq!(countries, ["left", "right"]); +} + +#[test] +fn requirement_path_failure_does_not_become_a_null_match() { + let datafile = json!({ + "schemaVersion": "2", "revision": "invalid-requirement", "segments": {}, "features": {}, + "variables": { + "leaf": {"type": "boolean", "defaultValue": true}, + "source": {"type": "boolean", "defaultValue": true, + "conditions": [{"variable": "leaf", "path": "uncompiled", "operator": "isNull"}]}, + "subject": {"type": "boolean", "defaultValue": true, + "conditions": [{"variable": "source", "operator": "isNull"}]} + } + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + ..Default::default() + }); + assert_eq!(f.get_global_variable("source", None, None), None); + assert_eq!( + f.evaluate_global_variable("subject", None, None).reason, + featurevisor::EvaluationReason::Error + ); +} + +struct LiveSetterModule { + instance: Arc>>, + leaf_reads: Arc>, +} + +impl FeaturevisorModule for LiveSetterModule { + fn after_evaluation( + &self, + evaluation: featurevisor::Evaluation, + _options: &EvaluateOptions, + ) -> featurevisor::Evaluation { + match evaluation.variable_key.as_deref() { + Some("leaf") => *self.leaf_reads.lock().unwrap() += 1, + Some("mutator") => self + .instance + .lock() + .unwrap() + .as_ref() + .unwrap() + .set_sticky_variables( + [("leaf".to_string(), VariableValue::Integer(2))].into(), + false, + ), + _ => {} + } + evaluation + } +} + +#[test] +fn live_setter_invalidates_prior_dependency_memo() { + let datafile = json!({ + "schemaVersion":"2", "revision":"setter", "segments":{}, "features":{}, + "variables": { + "leaf":{"type":"integer","defaultValue":1}, + "mutator":{"type":"boolean","defaultValue":true}, + "root":{"type":"integer","defaultValue":1,"disabledValue":0,"conditions":[ + {"variable":"leaf","operator":"equals","value":1}, + {"variable":"mutator","operator":"equals","value":true}, + {"variable":"leaf","operator":"equals","value":2} + ]} + } + }); + let instance = Arc::new(Mutex::new(None)); + let reads = Arc::new(Mutex::new(0)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + modules: vec![Arc::new(LiveSetterModule { + instance: Arc::clone(&instance), + leaf_reads: Arc::clone(&reads), + })], + ..Default::default() + }); + *instance.lock().unwrap() = Some(f.clone()); + assert_eq!( + f.get_global_variable("root", None, None), + Some(VariableValue::Integer(1)) + ); + assert_eq!(*reads.lock().unwrap(), 2); +} + +struct ChildRestoreModule { + child: Arc>>, + reads: Arc>, +} + +impl FeaturevisorModule for ChildRestoreModule { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + let child = self.child.lock().unwrap().clone(); + if let Some(child) = child { + match options.variable_key.as_deref() { + Some("leaf") => { + let mut reads = self.reads.lock().unwrap(); + *reads += 1; + if *reads == 1 { + child.set_sticky_variables( + [("leaf".to_string(), VariableValue::Integer(2))].into(), + false, + ); + } + } + Some("restore") => child.set_sticky_variables( + [("leaf".to_string(), VariableValue::Integer(1))].into(), + true, + ), + _ => {} + } + } + options + } +} + +#[test] +fn child_sticky_restoration_does_not_resurrect_prior_memo() { + let datafile = json!({ + "schemaVersion":"2", "revision":"child", "segments":{}, "features":{}, + "variables": { + "leaf":{"type":"integer","defaultValue":1}, + "restore":{"type":"integer","defaultValue":1}, + "root":{"type":"integer","defaultValue":1,"disabledValue":0,"conditions":[ + {"variable":"leaf","operator":"equals","value":2}, + {"variable":"restore","operator":"equals","value":1}, + {"variable":"leaf","operator":"equals","value":1} + ]} + } + }); + let child_ref = Arc::new(Mutex::new(None)); + let reads = Arc::new(Mutex::new(0)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + modules: vec![Arc::new(ChildRestoreModule { + child: Arc::clone(&child_ref), + reads: Arc::clone(&reads), + })], + ..Default::default() + }); + let child = f.spawn( + Default::default(), + SpawnOptions { + sticky_variables: Some([("leaf".to_string(), VariableValue::Integer(1))].into()), + ..Default::default() + }, + ); + *child_ref.lock().unwrap() = Some(child.clone()); + assert_eq!( + child.get_global_variable("root", None, None), + Some(VariableValue::Integer(1)) + ); + assert_eq!(*reads.lock().unwrap(), 2); +} + +struct ParentAwareModule(Arc>>); + +impl FeaturevisorModule for ParentAwareModule { + fn after_evaluation( + &self, + mut evaluation: featurevisor::Evaluation, + options: &EvaluateOptions, + ) -> featurevisor::Evaluation { + if evaluation.variable_key.as_deref() == Some("leaf") { + if let Some(parent) = options + .dependency_of + .as_ref() + .and_then(|parent| parent.variable_key.as_deref()) + { + self.0.lock().unwrap().push(parent.to_string()); + evaluation.variable_value = + Some(VariableValue::Integer(if parent == "left" { 1 } else { 2 })); + } + } + evaluation + } +} + +#[test] +fn memo_identity_includes_immediate_requester_for_hooks() { + let datafile = json!({ + "schemaVersion":"2", "revision":"parent-aware", "segments":{}, "features":{}, + "variables": { + "leaf":{"type":"integer","defaultValue":0}, + "left":{"type":"integer","defaultValue":1,"conditions":[ + {"variable":"leaf","operator":"equals","value":1},{"variable":"leaf","operator":"equals","value":1}]}, + "right":{"type":"integer","defaultValue":1,"conditions":[ + {"variable":"leaf","operator":"equals","value":2},{"variable":"leaf","operator":"equals","value":2}]}, + "root":{"type":"integer","defaultValue":1,"conditions":[ + {"variable":"left","operator":"equals","value":1},{"variable":"right","operator":"equals","value":1}, + {"variable":"left","operator":"equals","value":1},{"variable":"right","operator":"equals","value":1}]} + } + }); + let parents = Arc::new(Mutex::new(Vec::new())); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + modules: vec![Arc::new(ParentAwareModule(Arc::clone(&parents)))], + ..Default::default() + }); + assert_eq!( + f.get_global_variable("root", None, None), + Some(VariableValue::Integer(1)) + ); + assert_eq!(*parents.lock().unwrap(), ["left", "right"]); +} + +struct OwnedResultModule { + value: Arc>, + reads: Arc>, +} + +impl FeaturevisorModule for OwnedResultModule { + fn after_evaluation( + &self, + mut evaluation: featurevisor::Evaluation, + _options: &EvaluateOptions, + ) -> featurevisor::Evaluation { + match evaluation.variable_key.as_deref() { + Some("leaf") => { + *self.reads.lock().unwrap() += 1; + evaluation.variable_value = Some(self.value.lock().unwrap().clone()); + } + Some("mutator") => { + if let VariableValue::Object(object) = &mut *self.value.lock().unwrap() { + if let Some(VariableValue::Object(nested)) = object.get_mut("nested") { + nested.insert("value".to_string(), VariableValue::Integer(2)); + } + } + } + _ => {} + } + evaluation + } +} + +#[test] +fn memo_owns_nested_variable_results() { + let datafile = json!({ + "schemaVersion":"2", "revision":"snapshot", "segments":{}, "features":{}, + "variables": { + "leaf":{"type":"object","defaultValue":{}}, + "mutator":{"type":"integer","defaultValue":1}, + "root":{"type":"integer","defaultValue":1,"disabledValue":0,"conditions":[ + {"variable":"leaf","path":["nested","value"],"operator":"equals","value":1}, + {"variable":"mutator","operator":"equals","value":1}, + {"variable":"leaf","path":["nested","value"],"operator":"equals","value":1} + ]} + } + }); + let value = Arc::new(Mutex::new(VariableValue::Object( + [( + "nested".to_string(), + VariableValue::Object([("value".to_string(), VariableValue::Integer(1))].into()), + )] + .into(), + ))); + let reads = Arc::new(Mutex::new(0)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + modules: vec![Arc::new(OwnedResultModule { + value: Arc::clone(&value), + reads: Arc::clone(&reads), + })], + ..Default::default() + }); + assert_eq!( + f.get_global_variable("root", None, None), + Some(VariableValue::Integer(1)) + ); + assert_eq!(*reads.lock().unwrap(), 1); +} + +struct RedirectFeatureModule { + force_on_redirect: bool, + ancestor_visits: Option>, +} + +impl FeaturevisorModule for RedirectFeatureModule { + fn before_evaluation(&self, mut options: EvaluateOptions) -> EvaluateOptions { + if options.feature_key == "a" { + if let Some(visits) = &self.ancestor_visits { + visits.fetch_add(1, Ordering::SeqCst); + } + } + if options.feature_key == "b" { + options.feature_key = "a".to_string(); + if self.force_on_redirect { + options.context.insert( + "phase".to_string(), + AttributeValue::String("forced".to_string()), + ); + } + } + options + } +} + +#[test] +fn original_and_transformed_feature_cycles_return_errors() { + let self_cycle = json!({ + "schemaVersion":"2", "revision":"self-cycle", "segments":{}, "features":{ + "a":{"bucketBy":"userId","requiredFeatures":[{"not":["a"]}], + "traffic":[{"key":"all","segments":"*","percentage":100000}]} + } + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(self_cycle.to_string())), + ..Default::default() + }); + assert_eq!( + f.evaluate_flag("a", None).reason, + featurevisor::EvaluationReason::Error + ); + let redirected = json!({ + "schemaVersion":"2", "revision":"redirect-cycle", "segments":{}, "features":{ + "a":{"bucketBy":"userId","requiredFeatures":["b"], + "traffic":[{"key":"all","segments":"*","percentage":100000}]}, + "b":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":100000}]} + } + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(redirected.to_string())), + modules: vec![Arc::new(RedirectFeatureModule { + force_on_redirect: false, + ancestor_visits: None, + })], + ..Default::default() + }); + assert_eq!( + f.evaluate_flag("a", None).reason, + featurevisor::EvaluationReason::Error + ); +} + +#[test] +fn transformed_ancestor_only_cycles_on_next_prerequisite() { + let without_recursion = json!({ + "schemaVersion":"2", "revision":"alias-forced", "segments":{}, "features":{ + "a":{"bucketBy":"userId","requiredFeatures":["b"], + "force":[{"conditions":{"attribute":"phase","operator":"equals","value":"forced"},"enabled":true}], + "traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]}, + "b":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]} + } + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(without_recursion.to_string())), + modules: vec![Arc::new(RedirectFeatureModule { + force_on_redirect: true, + ancestor_visits: None, + })], + ..Default::default() + }); + let result = f.evaluate_flag("a", None); + assert_ne!(result.reason, featurevisor::EvaluationReason::Error); + assert_eq!(result.enabled, Some(true)); + + let with_next_prerequisite = json!({ + "schemaVersion":"2", "revision":"alias-next", "segments":{}, "features":{ + "a":{"bucketBy":"userId","requiredFeatures":["b","c"], + "force":[{"conditions":{"attribute":"phase","operator":"equals","value":"forced"},"enabled":true}], + "traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]}, + "b":{"bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]}, + "c":{"bucketBy":"userId","requiredFeatures":["a"], + "traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]} + } + }); + let ancestor_visits = Arc::new(AtomicUsize::new(0)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(with_next_prerequisite.to_string())), + modules: vec![Arc::new(RedirectFeatureModule { + force_on_redirect: true, + ancestor_visits: Some(Arc::clone(&ancestor_visits)), + })], + ..Default::default() + }); + assert_eq!( + f.evaluate_flag("a", None).reason, + featurevisor::EvaluationReason::Error + ); + assert_eq!(ancestor_visits.load(Ordering::SeqCst), 1); +} + +#[test] +fn negative_zero_context_conservatively_skips_memo() { + let datafile = json!({ + "schemaVersion":"2", "revision":"negative-zero", "segments":{}, "features":{}, + "variables": { + "leaf":{"type":"integer","defaultValue":1}, + "root":{"type":"integer","defaultValue":1,"conditions":[ + {"variable":"leaf","operator":"equals","value":1}, + {"variable":"leaf","operator":"equals","value":1} + ]} + } + }); + let visits = Arc::new(Mutex::new(Vec::new())); + let mut context = std::collections::HashMap::new(); + context.insert("value".to_string(), AttributeValue::Double(-0.0)); + let f = create_featurevisor(FeaturevisorOptions { + context: Some(context), + datafile: Some(DatafileInput::Json(datafile.to_string())), + modules: vec![Arc::new(SessionModule { + visits: Arc::clone(&visits), + transform_context: false, + instance: Arc::new(Mutex::new(None)), + reentered: Arc::new(AtomicBool::new(false)), + })], + ..Default::default() + }); + assert_eq!( + f.get_global_variable("root", None, None), + Some(VariableValue::Integer(1)) + ); + assert_eq!(visits.lock().unwrap().len(), 3); +} diff --git a/tests/fixtures/cli_npx.sh b/tests/fixtures/cli_npx.sh new file mode 100644 index 0000000..c84aee7 --- /dev/null +++ b/tests/fixtures/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/tests/fixtures/dependency_smoke.rs b/tests/fixtures/dependency_smoke.rs new file mode 100644 index 0000000..623d596 --- /dev/null +++ b/tests/fixtures/dependency_smoke.rs @@ -0,0 +1,224 @@ +use featurevisor::{ + create_featurevisor, ConfigureBucketKeyOptions, ConfigureBucketValueOptions, DatafileInput, + EvaluateOptions, Evaluation, EvaluationReason, Featurevisor, FeaturevisorModule, + FeaturevisorOptions, +}; +use serde_json::{json, Value}; +use std::cell::Cell; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, ThreadId}; + +thread_local! { + static REQUEST_CONTEXT: Cell = const { Cell::new(0) }; +} + +fn datafile() -> Value { + json!({"schemaVersion":"4","revision":"smoke","segments":{},"features":{}, + "variables":{"safe":{"type":"integer","defaultValue":7}}}) +} + +fn instance(data: &Value, modules: Vec>) -> Featurevisor { + create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + modules, + ..Default::default() + }) +} + +fn failed(evaluation: Evaluation, message: &str) { + assert_eq!(evaluation.reason, EvaluationReason::Error); + assert_eq!(evaluation.error.as_deref(), Some(message)); + assert!(evaluation.variable_value.is_none()); + assert!(evaluation.enabled != Some(true)); +} + +fn failures_and_recovery() { + let mut data = datafile(); + data["segments"]["cycle"] = json!({"conditions":{"not":["cycle"]}}); + data["features"]["cycle"] = json!({"bucketBy":"userId","conditions":"cycle","traffic":[]}); + data["variables"]["cycle"] = json!({"type":"object","compose":"cycle"}); + data["variables"]["bad"] = + json!({"type":"object","compose":[{"key":"constructor","variable":"safe"}]}); + data["variables"]["invalid"] = json!({"type":"object","compose":[{"key":"missing"}]}); + data["variables"]["partial"] = json!({"type":"object","compose":[ + {"key":"good","variable":"safe"},{"key":"bad","variable":"bad"}]}); + data["variables"]["negated"] = json!({"type":"boolean","defaultValue":true, + "conditions":{"not":[{"variable":"cycle","operator":"isNull"}]}}); + for index in 0..257 { + data["variables"][format!("v{index}")] = json!({"type":"integer", + "compose": if index == 256 {"safe".into()} else {format!("v{}", index + 1)}}); + } + let f = instance(&data, vec![]); + for _ in 0..2 { + failed( + f.evaluate_flag("cycle", None), + "Circular variable requirement", + ); + assert!(!f.is_enabled("cycle", None)); + for key in ["cycle", "negated"] { + failed( + f.evaluate_global_variable(key, None, None), + "Circular variable requirement", + ); + assert_eq!(f.get_global_variable(key, None, None), None); + } + for key in ["bad", "partial"] { + failed( + f.evaluate_global_variable(key, None, None), + "Invalid composition property", + ); + } + failed( + f.evaluate_global_variable("invalid", None, None), + "Invalid composition source", + ); + failed( + f.evaluate_global_variable("v0", None, None), + "Evaluation dependency depth exceeded", + ); + assert_eq!(f.get_global_variable_integer("safe", None, None), Some(7)); + assert_eq!(f.get_global_variable_integer("v1", None, None), Some(7)); + } + data["variables"]["cycle"] = json!({"type":"integer","defaultValue":9}); + assert!(f.set_datafile(DatafileInput::Json(data.to_string()), true)); + assert_eq!(f.get_global_variable_integer("cycle", None, None), Some(9)); + assert_eq!( + f.evaluate_global_variable("negated", None, None).reason, + EvaluationReason::VariableDefault + ); + f.close(); +} + +struct Affinity { + origin: ThreadId, + observations: Mutex>, +} + +impl Affinity { + fn record(&self, phase: &'static str) { + self.observations.lock().unwrap().push(( + phase, + thread::current().id(), + REQUEST_CONTEXT.with(Cell::get), + )); + } +} + +impl FeaturevisorModule for Affinity { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + self.record("before"); + options + } + + fn after_evaluation(&self, evaluation: Evaluation, _: &EvaluateOptions) -> Evaluation { + self.record("after"); + evaluation + } + + fn bucket_key(&self, options: ConfigureBucketKeyOptions) -> String { + self.record("bucket_key"); + options.bucket_key + } + + fn bucket_value(&self, options: ConfigureBucketValueOptions) -> u32 { + self.record("bucket_value"); + options.bucket_value + } +} + +fn deep_callbacks_stay_on_thread() { + REQUEST_CONTEXT.with(|value| value.set(42)); + for depth in [20, 256] { + let module = Arc::new(Affinity { + origin: thread::current().id(), + observations: Mutex::new(Vec::new()), + }); + let mut data = datafile(); + for index in 0..=depth { + data["features"][format!("f{index}")] = json!({"bucketBy":"userId", + "conditions": if index == depth {json!("*")} else {json!({"feature":format!("f{}",index+1)})}, + "traffic":[{"key":"all","conditions":"*","percentage":100000}]}); + if index < depth { + data["variables"][format!("v{index}")] = json!({"type":"integer", + "compose":if index == depth - 1 {"safe".into()} else {format!("v{}",index+1)}}); + } + } + let f = instance(&data, vec![module.clone()]); + for _ in 0..2 { + assert!(f.is_enabled("f0", None)); + assert_eq!(f.get_global_variable_integer("v0", None, None), Some(7)); + } + let observations = module.observations.lock().unwrap(); + for phase in ["before", "after", "bucket_key", "bucket_value"] { + assert!(observations + .iter() + .any(|(observed, _, _)| *observed == phase)); + } + assert!(observations.len() >= (depth + 1) * 8); + assert!(observations + .iter() + .all(|(_, id, context)| *id == module.origin && *context == 42)); + drop(observations); + f.close(); + } + REQUEST_CONTEXT.with(|value| value.set(0)); +} + +struct ChangeSource { + instance: Mutex>, + changed: AtomicBool, + accepted: AtomicBool, +} + +impl FeaturevisorModule for ChangeSource { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if options.variable_key.as_deref() == Some("source") + && !self.changed.swap(true, Ordering::SeqCst) + { + let f = self.instance.lock().unwrap().as_ref().unwrap().clone(); + let update = json!({"schemaVersion":"4","revision":"callback","segments":{},"features":{}, + "variables":{"source":{"type":"object","compose":"root"}}}); + self.accepted.store( + f.set_datafile(DatafileInput::Json(update.to_string()), false), + Ordering::SeqCst, + ); + } + options + } +} + +fn callback_driven_cycle_and_source_replacement() { + let mut data = datafile(); + data["variables"]["source"] = json!({"type":"integer","defaultValue":1}); + data["variables"]["root"] = json!({"type":"object","compose":[ + {"key":"safe","variable":"safe"},{"key":"source","variable":"source"}]}); + let module = Arc::new(ChangeSource { + instance: Mutex::new(None), + changed: AtomicBool::new(false), + accepted: AtomicBool::new(false), + }); + let f = instance(&data, vec![module.clone()]); + *module.instance.lock().unwrap() = Some(f.clone()); + failed( + f.evaluate_global_variable("root", None, None), + "Circular variable requirement", + ); + assert!(module.accepted.load(Ordering::SeqCst)); + assert_eq!(f.get_revision(), "callback"); + assert_eq!(f.get_global_variable("root", None, None), None); + data["variables"]["source"] = json!({"type":"integer","defaultValue":11}); + assert!(f.set_datafile(DatafileInput::Json(data.to_string()), true)); + assert_eq!( + f.get_global_variable("root", None, None).unwrap().to_json(), + json!({"safe":7,"source":11}) + ); + f.close(); + module.instance.lock().unwrap().take(); +} + +pub fn run() { + failures_and_recovery(); + deep_callbacks_stay_on_thread(); + callback_driven_cycle_and_source_replacement(); +} diff --git a/tests/fixtures/readme_initialization.rs b/tests/fixtures/readme_initialization.rs new file mode 100644 index 0000000..4d9c5b0 --- /dev/null +++ b/tests/fixtures/readme_initialization.rs @@ -0,0 +1,34 @@ +use std::fs; +use std::process::Command; + +#[test] +fn evaluates_datafile_and_preserves_failure_behaviour() { + let path = "featurevisor-all.json"; + let fixture = r#"{"schemaVersion":"2","revision":"readme","segments":{},"variables":{},"features":{"checkout":{"key":"checkout","bucketBy":"userId","traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]}}}"#; + for (datafile, expected) in [ + (fixture.to_owned(), "true"), + ( + fixture.replace("\"enabled\":true", "\"enabled\":false"), + "false", + ), + ("{".to_owned(), "false"), + ] { + fs::write(path, &datafile).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_readme-initialization-fixture")) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stdout}\n{stderr}"); + assert!(stdout.lines().any(|line| line == expected), "{stdout}"); + } + fs::remove_file(path).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_readme-initialization-fixture")) + .output() + .unwrap(); + assert!( + !output.status.success(), + "missing file must propagate from main" + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("Error:")); +} diff --git a/tests/fixtures/stacker-consumer/Cargo.toml b/tests/fixtures/stacker-consumer/Cargo.toml new file mode 100644 index 0000000..a10ef9c --- /dev/null +++ b/tests/fixtures/stacker-consumer/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "featurevisor-stacker-consumer" +version = "0.0.0" +edition = "2021" + +[dependencies] +stacker = "0.1.25" +psm = "0.1.32" diff --git a/tests/fixtures/stacker-consumer/src/main.rs b/tests/fixtures/stacker-consumer/src/main.rs new file mode 100644 index 0000000..3e82fdd --- /dev/null +++ b/tests/fixtures/stacker-consumer/src/main.rs @@ -0,0 +1,5 @@ +fn main() { + let sdk = featurevisor::create_featurevisor(Default::default()); + assert!(!stacker::maybe_grow(64 * 1024, 1024 * 1024, || sdk.is_enabled("missing", None))); + sdk.close(); +} diff --git a/tests/includes_any.rs b/tests/includes_any.rs new file mode 100644 index 0000000..e723463 --- /dev/null +++ b/tests/includes_any.rs @@ -0,0 +1,155 @@ +use featurevisor::{ + create_featurevisor, AttributeValue, Context, DatafileInput, FeaturevisorOptions, Operator, + VariableValue, +}; +use serde_json::{json, Value}; + +fn cases() -> Vec<(Value, Value, bool, bool)> { + vec![ + ( + json!(["admin", 2, true, null]), + json!(["missing", 2.0]), + true, + true, + ), + (json!(["admin", 2, true, null]), json!([true]), true, true), + (json!(["admin", 2, true, null]), json!([null]), true, true), + (json!(["admin"]), json!(["admin", "admin"]), true, true), + ( + json!(["admin", "admin"]), + json!(["other", "admin"]), + true, + true, + ), + (json!(["admin"]), json!(["admin", "other"]), true, true), + (json!([1]), json!([true]), false, true), + (json!([true]), json!([1]), false, true), + (json!([false]), json!([0]), false, true), + (json!(["1"]), json!([1]), false, true), + (json!([1]), json!(["1"]), false, true), + (json!(["true"]), json!([true]), false, true), + (json!([{}, [1], "admin"]), json!(["admin"]), true, true), + (json!([{}, [1]]), json!([null, 1]), false, true), + (json!([]), json!([null]), false, true), + (json!(["admin"]), json!([]), false, true), + (json!([]), json!([]), false, true), + (json!(null), json!([]), false, false), + (json!("admin"), json!(["admin"]), false, false), + (json!(1), json!([1]), false, false), + (json!(true), json!([true]), false, false), + (json!({}), json!([]), false, false), + (json!(["admin"]), json!(null), false, false), + (json!(["admin"]), json!("admin"), false, false), + (json!(["admin"]), json!(1), false, false), + (json!(["admin"]), json!(true), false, false), + (json!(["admin"]), json!({}), false, false), + (json!(["admin"]), json!(["admin", {}]), false, false), + (json!(["admin"]), json!([[], "admin"]), false, false), + ] +} + +#[test] +fn includes_any_conditions_and_requirements_share_strict_membership() { + for (actual, candidates, found, valid) in cases() { + for operator in ["includesAny", "notIncludesAny"] { + let expected = valid + && if operator == "includesAny" { + found + } else { + !found + }; + let condition = + json!({"attribute":"profile.items","operator":operator,"value":candidates}); + for source in [ + json!({"variable":"source","path":["items"]}), + json!({"feature":"sourceFeature","variable":"items"}), + json!({"variable":"source","path":["missing"]}), + ] { + let requirement_expected = expected && source["path"] != json!(["missing"]); + let mut requirement = source; + requirement["operator"] = json!(operator); + requirement["value"] = candidates.clone(); + let datafile = json!({ + "schemaVersion":"2", "revision":"includes-any", "segments":{"condition":{"conditions":condition}}, + "features":{ + "condition":{"bucketBy":"userId","traffic":[{"key":"all","segments":"condition","percentage":100000,"enabled":true}]}, + "gate":{"bucketBy":"userId","conditions":[requirement],"traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]}, + "sourceFeature":{"bucketBy":"userId","variablesSchema":{"items":{"type":"object","defaultValue":actual}},"traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true}]}, + "override":{"bucketBy":"userId","variablesSchema":{"result":{"type":"boolean","defaultValue":false}},"traffic":[{"key":"all","segments":"*","percentage":100000,"enabled":true,"variableOverrides":{"result":[{"key":"matched","conditions":[requirement],"value":true}]}}]} + }, + "variables":{ + "source":{"type":"object","defaultValue":{"items":actual}}, + "gate":{"type":"boolean","defaultValue":true,"disabledValue":false,"conditions":[requirement]} + } + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + ..Default::default() + }); + let context: Context = [( + "profile".to_string(), + AttributeValue::from_json(json!({"items":actual})), + )] + .into(); + for _ in 0..2 { + assert_eq!( + f.is_enabled("condition", Some(&context)), + expected, + "{operator}: {actual} / {candidates}" + ); + assert_eq!( + f.is_enabled("gate", None), + requirement_expected, + "requirement {operator}: {actual} / {candidates}" + ); + assert_eq!( + f.get_global_variable("gate", None, None), + Some(VariableValue::Boolean(requirement_expected)) + ); + assert_eq!( + f.get_variable("override", "result", None, None), + Some(VariableValue::Boolean(requirement_expected)) + ); + } + f.close(); + } + } + } +} + +#[test] +fn includes_any_wire_names_missing_paths_and_logical_groups() { + for (name, operator) in [ + ("includesAny", Operator::IncludesAny), + ("notIncludesAny", Operator::NotIncludesAny), + ] { + assert_eq!( + serde_json::from_value::(json!(name)).unwrap(), + operator + ); + assert_eq!(serde_json::to_value(operator).unwrap(), json!(name)); + let invalid = json!({"attribute":"profile.items","operator":name,"value":["admin",{}]}); + for (condition, expected) in [ + (invalid.clone(), false), + (json!({"not":[invalid]}), true), + ( + json!({"or":[invalid,{"attribute":"present","operator":"equals","value":true}]}), + true, + ), + ] { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(json!({"schemaVersion":"2","revision":"groups","segments":{"group":{"conditions":condition}},"features":{"flag":{"bucketBy":"userId","traffic":[{"key":"all","segments":"group","percentage":100000,"enabled":true}]}}}).to_string())), + ..Default::default() + }); + let context: Context = [("present".to_string(), AttributeValue::Boolean(true))].into(); + assert_eq!(f.is_enabled("flag", Some(&context)), expected); + let mut present_context = context; + present_context.insert( + "profile".to_string(), + AttributeValue::from_json(json!({"items":["admin"]})), + ); + assert_eq!(f.is_enabled("flag", Some(&present_context)), expected); + f.close(); + } + } +} diff --git a/tests/live_modules.rs b/tests/live_modules.rs new file mode 100644 index 0000000..0cdc2b1 --- /dev/null +++ b/tests/live_modules.rs @@ -0,0 +1,301 @@ +use featurevisor::{ + create_featurevisor, AttributeValue, DatafileInput, Diagnostic, EvaluateOptions, Evaluation, + EvaluationReason, EvaluationType, Featurevisor, FeaturevisorModule, FeaturevisorOptions, + LogLevel, OverrideOptions, VariableValue, +}; +use serde_json::json; +use std::sync::{Arc, Mutex}; + +struct Trace(Arc>>, bool); + +fn label(options: &EvaluateOptions) -> &str { + if options.feature_key.is_empty() { + options.variable_key.as_deref().unwrap() + } else { + &options.feature_key + } +} + +impl FeaturevisorModule for Trace { + fn name(&self) -> Option<&str> { + Some("late") + } + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + self.0 + .lock() + .unwrap() + .push(format!("before:{}", label(&options))); + options + } + fn after_evaluation( + &self, + mut evaluation: Evaluation, + options: &EvaluateOptions, + ) -> Evaluation { + self.0 + .lock() + .unwrap() + .push(format!("after:{}", label(options))); + if self.1 && options.feature_key == "gate" { + evaluation.enabled = Some(false); + } + evaluation + } +} + +#[test] +fn module_membership_refreshes_between_phases_for_feature_and_global_roots() { + for global in [false, true] { + for disable_gate in [false, true] { + for removal in [false, true] { + let trace = Arc::new(Mutex::new(Vec::new())); + let owner = Arc::new(Mutex::new(None::)); + let module = Arc::new(Trace(trace.clone(), disable_gate)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(json!({ + "schemaVersion":"4","revision":"modules","segments":{}, + "features":{ + "broken":{"hash":"broken","bucketBy":"userId","conditions":"[broken","traffic":[]}, + "gate":{"hash":"gate","bucketBy":"userId","traffic":[{"key":"all","conditions":"*","percentage":100000}]}, + "subject":{"hash":"subject","bucketBy":"userId","conditions":{"or":[{"feature":"broken"},{"feature":"gate"}]},"traffic":[{"key":"all","conditions":"*","percentage":100000}]} + }, + "variables":{"subject":{"hash":"subject","type":"string","defaultValue":"matched","conditions":{"or":[{"feature":"broken"},{"feature":"gate"}]}}} + }).to_string())), + log_level: Some(LogLevel::Error), + on_diagnostic: Some(Arc::new({ + let owner = owner.clone(); + let module = module.clone(); + move |d: &Diagnostic| { + if d.code == "conditions_parse_error" { + let f = owner.lock().unwrap().as_ref().unwrap().clone(); + if removal { f.remove_module("late"); } + else { f.add_module(module.clone()); } + } + } + })), + ..Default::default() + }); + *owner.lock().unwrap() = Some(f.clone()); + if removal { + f.add_module(module); + } + let result = if global { + f.evaluate_global_variable("subject", None, None) + } else { + f.evaluate_flag("subject", None) + }; + if disable_gate && !removal { + assert_eq!(result.reason, EvaluationReason::ConditionsUnmet); + assert_ne!(result.enabled, Some(true)); + assert!(result.variable_value.is_none()); + } else if global { + assert_eq!(result.variable_value.unwrap().to_json(), json!("matched")); + } else { + assert_eq!(result.enabled, Some(true)); + } + let expected = if removal { + vec!["before:subject", "before:broken"] + } else { + vec!["after:broken", "before:gate", "after:gate", "after:subject"] + }; + assert_eq!( + *trace.lock().unwrap(), + expected, + "global={global}, removal={removal}" + ); + f.close(); + owner.lock().unwrap().take(); + } + } + } +} + +struct RemoveBefore { + owner: Arc>>, + trace: Arc>>, + second: bool, +} +impl FeaturevisorModule for RemoveBefore { + fn name(&self) -> Option<&str> { + Some(if self.second { "second" } else { "first" }) + } + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + self.trace + .lock() + .unwrap() + .push(format!("before:{}", self.name().unwrap())); + if !self.second { + self.owner + .lock() + .unwrap() + .as_ref() + .unwrap() + .remove_module("second"); + } + options + } + fn after_evaluation(&self, evaluation: Evaluation, _: &EvaluateOptions) -> Evaluation { + self.trace + .lock() + .unwrap() + .push(format!("after:{}", self.name().unwrap())); + evaluation + } +} + +struct OptionsBefore; +impl FeaturevisorModule for OptionsBefore { + fn before_evaluation(&self, mut options: EvaluateOptions) -> EvaluateOptions { + if options.dependency_of.is_none() { + options + .context + .insert("phase".into(), AttributeValue::from("before")); + options.default_variable_value = Some(VariableValue::String("changed".into())); + options.default_variation_value = Some("changed variation".into()); + } + options + } +} + +struct OptionsAfter { + transformed: bool, + global: bool, + roots: Arc>, +} +impl FeaturevisorModule for OptionsAfter { + fn after_evaluation(&self, evaluation: Evaluation, options: &EvaluateOptions) -> Evaluation { + assert_eq!( + options.context.get("stored"), + Some(&AttributeValue::from(true)) + ); + assert_eq!( + options.context.get("country"), + Some(&AttributeValue::from("nl")) + ); + if options.dependency_of.is_none() { + assert_eq!(options.evaluation_type, EvaluationType::Variable); + assert_eq!( + options.variable_key.as_deref(), + Some(if self.global { "subject" } else { "value" }) + ); + assert_eq!( + options.default_variable_value, + Some(VariableValue::String( + if self.transformed { + "changed" + } else { + "fallback" + } + .into() + )) + ); + assert_eq!( + options.default_variation_value.as_deref(), + Some(if self.transformed { + "changed variation" + } else { + "variation fallback" + }) + ); + if self.transformed { + assert_eq!( + options.context.get("phase"), + Some(&AttributeValue::from("before")) + ); + } + if self.global { + assert!(evaluation.variable.is_some()); + } else { + assert!(evaluation.variable_schema.is_some()); + } + *self.roots.lock().unwrap() += 1; + } else if let Some(requester) = &options.dependency_of { + assert_eq!(requester.evaluation_type, EvaluationType::Variable); + assert_eq!( + requester.feature_key.as_deref(), + if self.global { None } else { Some("subject") } + ); + assert_eq!( + requester.variable_key.as_deref(), + Some(if self.global { "subject" } else { "value" }) + ); + } + evaluation + } +} + +#[test] +fn late_after_options_are_complete_and_preserve_before_changes() { + for global in [false, true] { + for transformed in [false, true] { + let owner = Arc::new(Mutex::new(None::)); + let roots = Arc::new(Mutex::new(0)); + let after = Arc::new(OptionsAfter { + global, + transformed, + roots: roots.clone(), + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile:Some(DatafileInput::Json(json!({"schemaVersion":"4","revision":"options","segments":{}, + "features":{ + "broken":{"bucketBy":"userId","conditions":"[broken","traffic":[]}, + "gate":{"bucketBy":"userId","traffic":[{"key":"all","conditions":"*","percentage":100000}]}, + "subject":{"bucketBy":"userId","conditions":{"or":[{"feature":"broken"},{"feature":"gate"}]}, + "variablesSchema":{"value":{"type":"string","defaultValue":"feature"}}, + "traffic":[{"key":"all","conditions":"*","percentage":100000}]} + },"variables":{"subject":{"type":"string","defaultValue":"global","conditions":{"or":[{"feature":"broken"},{"feature":"gate"}]}}} + }).to_string())), + context:Some(featurevisor::context!{"stored"=>true,"country"=>"us"}), + log_level:Some(LogLevel::Error), + on_diagnostic:Some(Arc::new({ + let owner = owner.clone(); let after = after.clone(); + move |d:&Diagnostic| { if d.code=="conditions_parse_error" {owner.lock().unwrap().as_ref().unwrap().add_module(after.clone());}} + })), + ..Default::default() + }); + *owner.lock().unwrap() = Some(f.clone()); + if transformed { + f.add_module(Arc::new(OptionsBefore)); + } + let context = featurevisor::context! {"country"=>"nl","userId"=>"call"}; + let defaults = OverrideOptions { + default_variable_value: Some(VariableValue::String("fallback".into())), + default_variation_value: Some("variation fallback".into()), + }; + let value = if global { + f.get_global_variable("subject", Some(&context), Some(&defaults)) + } else { + f.get_variable("subject", "value", Some(&context), Some(&defaults)) + }; + assert_eq!( + value.unwrap().to_json(), + json!(if global { "global" } else { "feature" }) + ); + assert_eq!(*roots.lock().unwrap(), 1); + f.close(); + owner.lock().unwrap().take(); + } + } +} + +#[test] +fn before_phase_keeps_its_starting_membership() { + let owner = Arc::new(Mutex::new(None)); + let trace = Arc::new(Mutex::new(Vec::new())); + let f = create_featurevisor(FeaturevisorOptions::default()); + *owner.lock().unwrap() = Some(f.clone()); + for second in [false, true] { + f.add_module(Arc::new(RemoveBefore { + owner: owner.clone(), + trace: trace.clone(), + second, + })); + } + assert!(!f.is_enabled("missing", None)); + assert_eq!( + *trace.lock().unwrap(), + vec!["before:first", "before:second", "after:first"] + ); + f.close(); + owner.lock().unwrap().take(); +} diff --git a/tests/module_phase_snapshots.rs b/tests/module_phase_snapshots.rs new file mode 100644 index 0000000..9842dcc --- /dev/null +++ b/tests/module_phase_snapshots.rs @@ -0,0 +1,196 @@ +use featurevisor::{ + create_featurevisor, ConfigureBucketKeyOptions, ConfigureBucketValueOptions, DatafileInput, + EvaluateOptions, Evaluation, Featurevisor, FeaturevisorModule, FeaturevisorOptions, + Unsubscribe, +}; +use serde_json::json; +use std::sync::{Arc, Mutex}; + +const PHASES: [&str; 4] = ["before", "key", "value", "after"]; + +struct State { + owner: Mutex>, + trace: Mutex>, + closed: Mutex>, + changed: Mutex, + unsubscribe: Mutex>, + global: bool, + phase: usize, + action: &'static str, +} + +struct Module { + name: &'static str, + state: Arc, +} + +impl Module { + fn record(&self, phase: usize, active: bool) { + if !active { + return; + } + self.state + .trace + .lock() + .unwrap() + .push(format!("{}:{}", PHASES[phase], self.name)); + let mutate = { + let mut changed = self.state.changed.lock().unwrap(); + let mutate = self.name == "first" && phase == self.state.phase && !*changed; + *changed |= mutate; + mutate + }; + if mutate { + let owner = self.state.owner.lock().unwrap().as_ref().unwrap().clone(); + if self.state.action == "unsubscribe" { + let unsubscribe = self.state.unsubscribe.lock().unwrap().take().unwrap(); + unsubscribe(); + } else if self.state.action != "add" { + owner.remove_module("other"); + } + if matches!(self.state.action, "add" | "remove_add") { + owner.add_module(Arc::new(Module { + name: "late", + state: self.state.clone(), + })); + } + } + } + + fn active(&self, options: &EvaluateOptions) -> bool { + if self.state.global { + options.variable_key.as_deref() == Some("subject") + } else { + options.feature_key == "gate" + } + } +} + +impl FeaturevisorModule for Module { + fn name(&self) -> Option<&str> { + Some(self.name) + } + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + self.record(0, self.active(&options)); + options + } + fn bucket_key(&self, options: ConfigureBucketKeyOptions) -> String { + self.record(1, true); + options.bucket_key + } + fn bucket_value(&self, options: ConfigureBucketValueOptions) -> u32 { + self.record(2, true); + options.bucket_value + } + fn after_evaluation(&self, result: Evaluation, options: &EvaluateOptions) -> Evaluation { + self.record(3, self.active(options)); + result + } + fn close(&self) { + self.state.closed.lock().unwrap().push(self.name.into()); + } +} + +#[test] +fn module_phase_snapshot_matrix() { + for global in [false, true] { + for phase in 0..4 { + for action in ["add", "remove_add", "remove", "unsubscribe"] { + let state = Arc::new(State { + owner: Mutex::new(None), + trace: Mutex::new(Vec::new()), + closed: Mutex::new(Vec::new()), + changed: Mutex::new(false), + unsubscribe: Mutex::new(None), + global, + phase, + action, + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json( + json!({ + "schemaVersion":"4", "revision":"phases", "segments":{}, + "features":{"gate":{"bucketBy":"userId","traffic":[ + {"key":"all","conditions":"*","percentage":100000}]}}, + "variables":{"subject":{"type":"string","defaultValue":"matched", + "conditions":{"feature":"gate"}}} + }) + .to_string(), + )), + ..Default::default() + }); + *state.owner.lock().unwrap() = Some(f.clone()); + f.add_module(Arc::new(Module { + name: "first", + state: state.clone(), + })); + *state.unsubscribe.lock().unwrap() = f.add_module(Arc::new(Module { + name: "other", + state: state.clone(), + })); + let evaluate = || { + if global { + assert_eq!( + f.evaluate_global_variable("subject", None, None) + .variable_value + .unwrap() + .to_json(), + json!("matched") + ); + } else { + assert!(f.is_enabled("gate", None)); + } + }; + evaluate(); + let mut after = vec!["first"]; + if action == "add" { + after.push("other"); + } + if matches!(action, "add" | "remove_add") { + after.push("late"); + } + let expected: Vec<_> = PHASES + .iter() + .enumerate() + .flat_map(|(index, phase_name)| { + let members = if index <= phase { + vec!["first", "other"] + } else { + after.clone() + }; + members + .into_iter() + .map(move |name| format!("{phase_name}:{name}")) + }) + .collect(); + assert_eq!( + *state.trace.lock().unwrap(), + expected, + "global={global} phase={phase} action={action}" + ); + assert_eq!( + *state.closed.lock().unwrap(), + if action == "add" { + vec![] + } else { + vec!["other".to_string()] + } + ); + state.trace.lock().unwrap().clear(); + evaluate(); + let expected: Vec<_> = PHASES + .iter() + .flat_map(|phase| after.iter().map(move |name| format!("{phase}:{name}"))) + .collect(); + assert_eq!( + *state.trace.lock().unwrap(), + expected, + "next global={global} phase={phase} action={action}" + ); + f.close(); + state.owner.lock().unwrap().take(); + state.unsubscribe.lock().unwrap().take(); + } + } + } +} diff --git a/tests/modules.rs b/tests/modules.rs index 154c1a4..bdb54da 100644 --- a/tests/modules.rs +++ b/tests/modules.rs @@ -21,14 +21,6 @@ struct PhaseModule { } impl FeaturevisorModule for PhaseModule { - fn before(&self, options: EvaluateOptions) -> EvaluateOptions { - self.events - .lock() - .unwrap() - .push(format!("before:{}", self.name)); - options - } - fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { self.events .lock() @@ -44,14 +36,6 @@ impl FeaturevisorModule for PhaseModule { .push(format!("afterEvaluation:{}", self.name)); evaluation } - - fn after(&self, evaluation: Evaluation, _options: &EvaluateOptions) -> Evaluation { - self.events - .lock() - .unwrap() - .push(format!("after:{}", self.name)); - evaluation - } } impl FeaturevisorModule for RequiredFeatureModule { @@ -102,7 +86,7 @@ impl FeaturevisorModule for TestModule { .unwrap() .push(format!("setup:{}", api.get_revision())); } - fn before(&self, mut options: EvaluateOptions) -> EvaluateOptions { + fn before_evaluation(&self, mut options: EvaluateOptions) -> EvaluateOptions { options.context.insert( "module".to_string(), featurevisor::AttributeValue::from("yes"), @@ -115,7 +99,7 @@ impl FeaturevisorModule for TestModule { fn bucket_value(&self, options: ConfigureBucketValueOptions) -> u32 { options.bucket_value } - fn after(&self, evaluation: Evaluation, _options: &EvaluateOptions) -> Evaluation { + fn after_evaluation(&self, evaluation: Evaluation, _options: &EvaluateOptions) -> Evaluation { self.events.lock().unwrap().push("after".to_string()); evaluation } @@ -172,14 +156,10 @@ fn module_phases_follow_the_canonical_order() { assert_eq!( *events.lock().unwrap(), vec![ - "before:first", - "before:second", "beforeEvaluation:first", "beforeEvaluation:second", "afterEvaluation:first", "afterEvaluation:second", - "after:first", - "after:second", ] ); } diff --git a/tests/mutual_exclusion.rs b/tests/mutual_exclusion.rs new file mode 100644 index 0000000..a8de582 --- /dev/null +++ b/tests/mutual_exclusion.rs @@ -0,0 +1,170 @@ +use featurevisor::{ + create_featurevisor, ConfigureBucketValueOptions, Context, DatafileContent, DatafileInput, + EvaluationReason, FeaturevisorModule, FeaturevisorOptions, LogLevel, +}; +use serde_json::json; +use std::sync::{Arc, Mutex}; + +struct FixedBucket(u32); + +impl FeaturevisorModule for FixedBucket { + fn bucket_value(&self, _: ConfigureBucketValueOptions) -> u32 { + self.0 + } +} + +#[test] +fn legacy_feature_ranges_are_ignored() { + for ranges in [ + json!([[0, 10000]]), + json!([[0, 100000]]), + json!([]), + json!(null), + json!({"removed": true}), + json!("ignored"), + ] { + for percentage in [0, 25000, 50000, 100000] { + let datafile: DatafileContent = serde_json::from_value(json!({ + "schemaVersion": "2", "revision": "legacy", "segments": {}, + "features": {"experiment": { + "bucketBy": "userId", "ranges": ranges, + "variations": [{"value": "a"}, {"value": "b"}], + "traffic": [{"key": "all", "segments": "*", "percentage": percentage, + "allocation": [{"variation": "a", "range": [0, 50000]}, + {"variation": "b", "range": [50000, 100000]}]}] + }} + })) + .unwrap(); + assert!(serde_json::to_value(&datafile.features["experiment"]) + .unwrap() + .get("ranges") + .is_none()); + for bucket in [10000, 40000, 60000] { + let diagnostics = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&diagnostics); + let sdk = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile.clone())), + log_level: Some(LogLevel::Warn), + on_diagnostic: Some(Arc::new(move |diagnostic| { + observed.lock().unwrap().push(diagnostic.clone()) + })), + modules: vec![Arc::new(FixedBucket(bucket))], + ..Default::default() + }); + let context: Context = [("userId".to_string(), "123".into())].into(); + let enabled = percentage > 0 && bucket <= percentage; + let evaluation = sdk.evaluate_flag("experiment", Some(&context)); + assert_eq!( + evaluation.enabled, + Some(enabled), + "{ranges}, {percentage}, {bucket}" + ); + assert_eq!( + evaluation.reason, + if enabled || percentage == 0 { + EvaluationReason::Rule + } else { + EvaluationReason::NoMatch + } + ); + assert_eq!( + sdk.get_variation("experiment", Some(&context), None) + .as_deref(), + if enabled { + Some(if bucket < 50000 { "a" } else { "b" }) + } else { + None + } + ); + sdk.close(); + assert!( + diagnostics.lock().unwrap().is_empty(), + "Ignored ranges must not cause warnings or errors" + ); + } + } + } +} + +#[test] +fn required_features_mutual_exclusion_with_normal_hashing() { + let datafile = json!({ + "schemaVersion": "2", "revision": "requirements", "segments": {}, + "features": { + "allocation": {"bucketBy": "userId", "variations": [{"value": "a"}, {"value": "b"}, {"value": "holdout"}], + "traffic": [{"key": "all", "segments": "*", "percentage": 100000, + "allocation": [{"variation": "a", "range": [0, 40000]}, {"variation": "b", "range": [40000, 80000]}, {"variation": "holdout", "range": [80000, 100000]}]}]}, + "experimentA": {"bucketBy": "userId", "requiredFeatures": [{"feature": "allocation", "variation": "a"}], + "traffic": [{"key": "all", "segments": "*", "percentage": 100000}]}, + "experimentB": {"bucketBy": "userId", "requiredFeatures": [{"feature": "allocation", "variation": "b"}], + "traffic": [{"key": "all", "segments": "*", "percentage": 100000}]}, + "primary": {"bucketBy": "userId", "traffic": [{"key": "all", "segments": "*", "percentage": 50000}]}, + "secondary": {"bucketBy": "userId", "requiredFeatures": [{"feature": "primary", "enabled": false}], + "traffic": [{"key": "all", "segments": "*", "percentage": 100000}]} + } + }); + let sdk = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + ..Default::default() + }); + let mut a_count = 0; + let mut b_count = 0; + let mut holdout_count = 0; + let mut primary_count = 0; + for user in 0..20000 { + let context: Context = [("userId".to_string(), format!("user-{user}").into())].into(); + let a = sdk.is_enabled("experimentA", Some(&context)); + let b = sdk.is_enabled("experimentB", Some(&context)); + let primary = sdk.is_enabled("primary", Some(&context)); + let secondary = sdk.is_enabled("secondary", Some(&context)); + assert!(!(a && b), "user {user}"); + assert_ne!(primary, secondary, "user {user}"); + let variation = sdk.get_variation("allocation", Some(&context), None); + assert_eq!(a, variation.as_deref() == Some("a")); + assert_eq!(b, variation.as_deref() == Some("b")); + assert_eq!(!a && !b, variation.as_deref() == Some("holdout")); + let b_reversed = sdk.is_enabled("experimentB", Some(&context)); + let a_reversed = sdk.is_enabled("experimentA", Some(&context)); + let secondary_reversed = sdk.is_enabled("secondary", Some(&context)); + let primary_reversed = sdk.is_enabled("primary", Some(&context)); + assert_eq!( + (b_reversed, a_reversed, secondary_reversed, primary_reversed), + (b, a, secondary, primary) + ); + assert_eq!( + sdk.evaluate_variation("allocation", Some(&context), None) + .reason, + EvaluationReason::Allocated + ); + if a { + a_count += 1; + } + if b { + b_count += 1; + } + if variation.as_deref() == Some("holdout") { + holdout_count += 1; + } + if primary { + primary_count += 1; + } + } + assert!( + (7000..=9000).contains(&a_count), + "Allocation A population: {a_count}" + ); + assert!( + (7000..=9000).contains(&b_count), + "Allocation B population: {b_count}" + ); + assert!( + (3000..=5000).contains(&holdout_count), + "Holdout population: {holdout_count}" + ); + assert!( + (9000..=11000).contains(&primary_count), + "Priority population: {primary_count}" + ); + println!("20,000 users: A={a_count}, B={b_count}, holdout={holdout_count}, primary={primary_count}; zero overlap in both evaluation orders"); + sdk.close(); +} diff --git a/tests/native_parity_regressions.rs b/tests/native_parity_regressions.rs new file mode 100644 index 0000000..c591fd6 --- /dev/null +++ b/tests/native_parity_regressions.rs @@ -0,0 +1,188 @@ +use featurevisor::{ + create_featurevisor, DatafileInput, DependencyPurpose, EvaluateOptions, EvaluationDependency, + EvaluationType, FeaturevisorModule, FeaturevisorOptions, LogLevel, VariableValue, +}; +use serde_json::json; +use std::sync::{Arc, Mutex}; + +type Visits = Arc>>; + +struct RequesterModule { + visits: Visits, + transform: bool, +} + +impl FeaturevisorModule for RequesterModule { + fn before_evaluation(&self, mut options: EvaluateOptions) -> EvaluateOptions { + if let Some(parent) = &options.dependency_of { + self.visits + .lock() + .unwrap() + .push((parent.clone(), options.dependency_purpose.unwrap())); + } else if self.transform { + options.feature_key = "subject".into(); + } + options + } +} + +#[test] +fn internal_flag_checks_keep_the_outer_requester_and_memo_across_selector_sites() { + let dependency = json!({"feature":"gate"}); + let data = json!({"schemaVersion":"4","revision":"parity","segments":{},"features":{ + "gate":{"bucketBy":"userId","traffic":[{"key":"all","conditions":"*","percentage":100000}]}, + "subject":{"bucketBy":"userId","conditions":[dependency.clone(),dependency.clone()], + "traffic":[{"key":"all","conditions":dependency,"percentage":100000,"allocation":[{"variation":"on","range":[0,100000]}]}], + "variations":[{"value":"on"}],"variablesSchema":{"value":{"type":"string","defaultValue":"ok"}}} + }}); + for transform in [false, true] { + let visits = Visits::default(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + log_level: Some(LogLevel::Fatal), + modules: vec![Arc::new(RequesterModule { + visits: visits.clone(), + transform, + })], + ..Default::default() + }); + let key = if transform { "alias" } else { "subject" }; + for _ in 0..2 { + visits.lock().unwrap().clear(); + assert_eq!(f.get_variation(key, None, None).as_deref(), Some("on")); + assert_eq!( + *visits.lock().unwrap(), + vec![( + EvaluationDependency { + evaluation_type: EvaluationType::Variation, + feature_key: Some("subject".into()), + variable_key: None, + }, + DependencyPurpose::Condition + )] + ); + visits.lock().unwrap().clear(); + assert_eq!(f.get_variable(key, "value", None, None), Some("ok".into())); + assert_eq!( + *visits.lock().unwrap(), + vec![( + EvaluationDependency { + evaluation_type: EvaluationType::Variable, + feature_key: Some("subject".into()), + variable_key: Some("value".into()), + }, + DependencyPurpose::Condition + )] + ); + } + f.close(); + } +} + +#[test] +fn repeated_invalid_json_leaves_memoize_the_ordinary_getter_null() { + for feature_source in [false, true] { + let mut leaf = json!({"variable":"source","operator":"isNull"}); + if feature_source { + leaf["feature"] = json!("sourceFeature"); + } + let data = json!({"schemaVersion":"4","revision":"json-errors","segments":{},"features":{ + "sourceFeature":{"bucketBy":"userId","traffic":[{"key":"all","conditions":"*","percentage":100000}], + "variablesSchema":{"source":{"type":"json","defaultValue":"{oops"}}} + },"variables":{ + "source":{"type":"json","defaultValue":"{oops"}, + "subject":{"type":"string","defaultValue":"ok","conditions":[leaf.clone(),leaf]} + }}); + let errors = Arc::new(Mutex::new(Vec::new())); + let observed = errors.clone(); + let visits = Visits::default(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + log_level: Some(LogLevel::Error), + on_diagnostic: Some(Arc::new(move |diagnostic| { + if diagnostic.message == "getVariable failed" { + observed.lock().unwrap().push(diagnostic.clone()); + } + })), + modules: vec![Arc::new(RequesterModule { + visits: visits.clone(), + transform: false, + })], + ..Default::default() + }); + for attempt in 1..=2 { + assert_eq!( + f.get_global_variable("subject", None, None), + Some("ok".into()) + ); + assert_eq!(errors.lock().unwrap().len(), attempt); + assert_eq!( + *visits.lock().unwrap(), + vec![ + ( + EvaluationDependency { + evaluation_type: EvaluationType::Variable, + feature_key: None, + variable_key: Some("subject".into()), + }, + DependencyPurpose::Condition + ); + attempt + ] + ); + } + f.close(); + } +} + +type Purposes = Arc>>>; +struct PurposeModule(Purposes); +impl FeaturevisorModule for PurposeModule { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if options.variable_key.as_deref() == Some("source") { + self.0.lock().unwrap().push(options.dependency_purpose); + } + options + } +} + +#[test] +fn json_string_aliases_decode_once_and_keep_condition_and_composition_memos_separate() { + for value in ["123", "null", "{\"nested\":true}", "\"quoted\""] { + let leaf = json!({"variable":"source","operator":"equals","value":value}); + let data = json!({"schemaVersion":"4","revision":"json-aliases","segments":{},"features":{},"variables":{ + "source":{"type":"json","defaultValue":json!(value).to_string()}, + "alias":{"type":"json","compose":"source"}, + "aliasAgain":{"type":"json","compose":"alias"}, + "subject":{"type":"object","conditions":[leaf.clone(),leaf],"compose":[{"key":"first","variable":"source"},{"key":"second","variable":"source"}]} + }}); + let purposes = Purposes::default(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + log_level: Some(LogLevel::Fatal), + modules: vec![Arc::new(PurposeModule(purposes.clone()))], + ..Default::default() + }); + for _ in 0..2 { + purposes.lock().unwrap().clear(); + assert_eq!( + f.get_global_variable("subject", None, None) + .unwrap() + .to_json(), + json!({"first":value,"second":value}) + ); + assert_eq!( + *purposes.lock().unwrap(), + vec![ + Some(DependencyPurpose::Condition), + Some(DependencyPurpose::Composition) + ] + ); + assert_eq!( + f.get_global_variable("aliasAgain", None, None), + Some(VariableValue::from(value)) + ); + } + f.close(); + } +} diff --git a/tests/normalization.rs b/tests/normalization.rs new file mode 100644 index 0000000..2789d8f --- /dev/null +++ b/tests/normalization.rs @@ -0,0 +1,177 @@ +use featurevisor::{ + create_featurevisor, DatafileInput, Diagnostic, EvaluationReason, Featurevisor, + FeaturevisorOptions, LogLevel, +}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +fn base(version: &str) -> Value { + json!({"schemaVersion":version,"revision":"accepted","segments":{},"features":{},"variables":{}}) +} + +#[test] +fn diagnostics_are_eager_for_legacy_lazy_for_v4_cached_before_reentry_and_scoped_to_entity_loads() { + for version in ["2", "4"] { + let holder = Arc::new(Mutex::new(None::)); + let observed = Arc::new(Mutex::new(Vec::new())); + let copy = Arc::clone(&observed); + let owner = Arc::clone(&holder); + let f = create_featurevisor(FeaturevisorOptions { + log_level: Some(LogLevel::Error), + on_diagnostic: Some(Arc::new(move |diagnostic: &Diagnostic| { + if diagnostic.code != "conditions_parse_error" { + return; + } + let f = owner.lock().unwrap().as_ref().unwrap().clone(); + assert_eq!(f.get_revision(), "accepted"); + copy.lock().unwrap().push(diagnostic.clone()); + assert_eq!(diagnostic.details.len(), 3); + assert_eq!(diagnostic.details["entityType"], "feature"); + assert_eq!(diagnostic.details["entityKey"], "broken"); + assert_eq!( + f.get_feature("broken").unwrap().conditions, + Some(json!({"or":[]})) + ); + })), + ..Default::default() + }); + *holder.lock().unwrap() = Some(f.clone()); + let mut data = base(version); + data["features"]["broken"] = json!({"bucketBy":"userId","conditions":" {broken", "traffic":[{"key":"bad","conditions":" {broken","percentage":100000}]}); + data["features"]["healthy"] = json!({"bucketBy":"userId","traffic":[{"key":"all","conditions":"*","percentage":100000}]}); + assert!(f.set_datafile(DatafileInput::Json(data.to_string()), true)); + assert_eq!( + observed.lock().unwrap().len(), + if version == "2" { 2 } else { 0 } + ); + assert_eq!(f.get_feature_keys().len(), 2); + assert!(f.is_enabled("healthy", None)); + for _ in 0..2 { + let evaluation = f.evaluate_flag("broken", None); + assert_eq!(evaluation.reason, EvaluationReason::ConditionsUnmet); + assert_eq!(evaluation.conditions, Some(json!({"or":[]}))); + } + assert_eq!(observed.lock().unwrap().len(), 2); + assert!(f.set_datafile( + DatafileInput::Json(base(if version == "2" { "4" } else { "2" }).to_string()), + false + )); + assert!(!f.is_enabled("broken", None)); + assert_eq!(observed.lock().unwrap().len(), 2); + assert!(f.set_datafile(DatafileInput::Json(data.to_string()), false)); + assert!(!f.is_enabled("broken", None)); + assert_eq!(observed.lock().unwrap().len(), 4); + assert!(f.set_datafile(DatafileInput::Json(data.to_string()), true)); + assert!(!f.is_enabled("broken", None)); + assert_eq!(observed.lock().unwrap().len(), 6); + holder.lock().unwrap().take(); + f.close(); + } +} + +#[test] +fn cold_mixed_version_merges_keep_legacy_empty_semantics_without_provenance_maps() { + for replace in [false, true] { + let mut legacy = base("2"); + legacy["segments"]["legacy"] = json!({"conditions":[]}); + legacy["features"]["old"] = json!({"bucketBy":"userId","traffic":[{"key":"all","segments":"legacy","percentage":100000}]}); + legacy["variables"]["old"] = json!({"type":"string","defaultValue":"no","overrides":[{"conditions":[],"value":"yes"}]}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(legacy.to_string())), + ..Default::default() + }); + let mut modern = base("4"); + modern["segments"]["modern"] = json!({"conditions":[]}); + modern["features"]["new"] = json!({"bucketBy":"userId","traffic":[{"key":"all","conditions":"modern","percentage":100000}]}); + assert!(f.set_datafile(DatafileInput::Json(modern.to_string()), replace)); + assert!(!f.is_enabled("new", None)); + assert_eq!(f.is_enabled("old", None), !replace); + assert_eq!( + f.get_global_variable_string("old", None, None).as_deref(), + if replace { None } else { Some("yes") } + ); + // Loading a legacy fragment must not reinterpret retained modern selectors. + assert!(f.set_datafile(DatafileInput::Json(base("2").to_string()), false)); + assert!(!f.is_enabled("new", None)); + f.close(); + } +} + +#[test] +fn mixed_selectors_have_exact_flat_normalized_metadata_and_preserve_empty_operands() { + for version in ["2", "3", "4"] { + for conditions in [json!([]), json!([{"feature":"gate","enabled":false}])] { + let mut data = base(version); + data["features"]["subject"] = json!({"bucketBy":"userId", "conditions":conditions,"requiredFeatures":["missing"],"traffic":[]}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + ..Default::default() + }); + let expected = if conditions.as_array().unwrap().is_empty() { + json!([{"and":[]},{"feature":"missing"}]) + } else { + json!([{"feature":"gate","enabled":false},{"feature":"missing"}]) + }; + assert_eq!( + f.get_feature("subject").unwrap().conditions, + Some(expected.clone()) + ); + let result = f.evaluate_flag("subject", None); + assert_eq!(result.reason, EvaluationReason::ConditionsUnmet); + assert_eq!(result.conditions, Some(expected)); + f.close(); + } + } +} + +#[test] +fn malformed_segments_fail_whole_selector_even_through_negation_and_aliases() { + for version in ["2", "4"] { + for raw in [ + "{", + "[{\"feature\":", + "\"unterminated", + " \n {\"or\": [oops] }", + ] { + let mut data = base(version); + data["segments"] = json!({"broken":{"conditions":raw},"alias":{"conditions":"broken"}}); + data["features"]["subject"] = json!({"bucketBy":"userId","conditions":{"not":["alias"]},"traffic":[{"key":"all","conditions":"*","percentage":100000}]}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + ..Default::default() + }); + assert_eq!( + f.evaluate_flag("subject", None).reason, + EvaluationReason::ConditionsUnmet + ); + assert!(!f.is_enabled("subject", None)); + f.close(); + } + } +} + +#[test] +fn only_released_legacy_locations_preserve_empty_and_and_old_unreleased_fields_are_ignored() { + let mut data = base("2"); + data["features"] = json!({ + "root":{"bucketBy":"userId","conditions":[],"traffic":[{"key":"all","segments":"*","percentage":100000}]}, + "traffic":{"bucketBy":"userId","traffic":[{"key":"empty","conditions":[],"percentage":100000}]}, + "force":{"bucketBy":"userId","force":[{"conditions":[],"enabled":true}],"traffic":[]}, + "ignored":{"bucketBy":"userId","required":["missing"],"requiredVariables":"not even valid","traffic":[{"key":"all","segments":"*","percentage":100000}]} + }); + data["variables"] = json!({"program":{"type":"string","defaultValue":"no","overrideProgram":[{"key":"empty","conditions":[],"operations":[{"op":"set","path":[],"value":"yes"}],"onMatch":1,"onMiss":1}]}}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + ..Default::default() + }); + assert!(!f.is_enabled("root", None)); + assert!(!f.is_enabled("traffic", None)); + assert!(f.is_enabled("force", None)); + assert!(f.is_enabled("ignored", None)); + assert_eq!( + f.get_global_variable_string("program", None, None) + .as_deref(), + Some("no") + ); + f.close(); +} diff --git a/tests/readme_initialization.rs b/tests/readme_initialization.rs new file mode 100644 index 0000000..dc3193a --- /dev/null +++ b/tests/readme_initialization.rs @@ -0,0 +1,72 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct FixtureDirectory(PathBuf); + +impl Drop for FixtureDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn readme_initialization() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let readme = fs::read_to_string(root.join("README.md")).unwrap(); + let section = readme + .split_once("## Initialization\n") + .expect("initialization heading") + .1 + .split_once("\n### ") + .expect("end of initialization") + .0; + let source = section + .split_once("```rust\n") + .expect("initialization fence") + .1 + .split_once("\n```") + .expect("closing fence") + .0; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = FixtureDirectory(std::env::temp_dir().join(format!( + "featurevisor-readme-{}-{nonce}", + std::process::id() + ))); + fs::create_dir_all(directory.0.join("src")).unwrap(); + fs::create_dir_all(directory.0.join("tests")).unwrap(); + fs::write(directory.0.join("src/main.rs"), source).unwrap(); + // Use the current source and lockfile, without adding dependencies or touching + // the repository's build output from this isolated consumer test. + let manifest = format!( + "[package]\nname = \"readme-initialization-fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\ + [workspace]\n[dependencies]\nfeaturevisor = {{ path = {} }}\n", + serde_json::to_string(&root.to_string_lossy()).unwrap() + ); + fs::write(directory.0.join("Cargo.toml"), manifest).unwrap(); + fs::copy(root.join("Cargo.lock"), directory.0.join("Cargo.lock")).unwrap(); + fs::write( + directory.0.join("tests/initialization.rs"), + include_str!("fixtures/readme_initialization.rs"), + ) + .unwrap(); + let output = Command::new(env!("CARGO")) + .args(["test", "--offline", "--quiet", "--test", "initialization"]) + .arg("--manifest-path") + .arg(directory.0.join("Cargo.toml")) + .arg("--target-dir") + .arg(directory.0.join("target")) + .current_dir(&directory.0) + .output() + .expect("run isolated README consumer tests"); + assert!( + output.status.success(), + "README consumer failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/review_correctness.rs b/tests/review_correctness.rs new file mode 100644 index 0000000..9062b2c --- /dev/null +++ b/tests/review_correctness.rs @@ -0,0 +1,376 @@ +use featurevisor::{ + context, create_featurevisor, DatafileInput, Diagnostic, EvaluateOptions, Evaluation, + Featurevisor, FeaturevisorModule, FeaturevisorOptions, LogLevel, OverrideOptions, + VariableValue, +}; +use serde_json::json; +use std::sync::{Arc, Mutex}; + +struct PartialUpdateBeforeRead(Arc>>); + +struct RootStickyChange(Arc>>); +impl FeaturevisorModule for RootStickyChange { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if options.feature_key == "subject" && options.dependency_of.is_none() { + let f = self.0.lock().unwrap().as_ref().unwrap().clone(); + f.set_sticky_features( + [( + "subject".into(), + featurevisor::EvaluatedFeature { + enabled: false, + variation: None, + variables: None, + }, + )] + .into(), + true, + ); + } + options + } +} + +#[test] +fn root_feature_before_hooks_refresh_inherited_sticky_features() { + let holder = Arc::new(Mutex::new(None)); + let data = json!({"schemaVersion":"4","revision":"root-sticky","segments":{},"features":{ + "subject":{"bucketBy":"id","traffic":[{"key":"all","conditions":"*","percentage":100000}]}}}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + modules: vec![Arc::new(RootStickyChange(holder.clone()))], + ..Default::default() + }); + *holder.lock().unwrap() = Some(f.clone()); + assert!(!f.is_enabled("subject", None)); + holder.lock().unwrap().take(); + f.close(); +} + +struct GateDefaults; + +struct ReplaceAfterSource { + owner: Arc>>, + replacement: serde_json::Value, +} + +impl FeaturevisorModule for ReplaceAfterSource { + fn after_evaluation(&self, evaluation: Evaluation, _: &EvaluateOptions) -> Evaluation { + if evaluation.variable_key.as_deref() == Some("mutator") { + let f = self.owner.lock().unwrap().as_ref().unwrap().clone(); + f.set_datafile(DatafileInput::Json(self.replacement.to_string()), true); + f.set_context(context!("country" => "de", "added" => true), true); + } + evaluation + } +} + +#[test] +fn live_sources_keep_the_selected_owner_effective_context_and_fresh_root_recovery() { + for feature_source in [false, true] { + let mut source = json!({"variable":"leaf"}); + if feature_source { + source["feature"] = json!("source"); + } + let member = |key: &str| { + let mut value = source.clone(); + value["key"] = json!(key); + value + }; + let initial = json!({"schemaVersion":"4","revision":"old","segments":{}, + "features":{"source":{"bucketBy":"id","variablesSchema":{"leaf":{"type":"integer","defaultValue":1}}, + "traffic":[{"key":"all","conditions":"*","percentage":100000}]}}, + "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"}]}}}); + let mut replacement = initial.clone(); + replacement["revision"] = json!("new"); + replacement["variables"]["leaf"]["defaultValue"] = json!(2); + replacement["features"]["source"]["variablesSchema"]["leaf"]["defaultValue"] = json!(2); + replacement["variables"]["view"]["compose"] = json!([member("fresh")]); + let owner = Arc::new(Mutex::new(None)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(initial.to_string())), + context: Some(context!("country" => "nl")), + modules: vec![Arc::new(ReplaceAfterSource { + owner: owner.clone(), + replacement, + })], + ..Default::default() + }); + *owner.lock().unwrap() = Some(f.clone()); + assert_eq!( + f.get_global_variable("view", None, None).unwrap().to_json(), + json!({"before":1,"mutator":true,"after":2,"again":2,"context":"preserved"}) + ); + assert_eq!( + f.get_global_variable("view", None, None).unwrap().to_json(), + json!({"fresh":2}) + ); + assert_eq!( + f.get_global_variable_string("context", None, None) + .as_deref(), + Some("changed") + ); + owner.lock().unwrap().take(); + f.close(); + } +} + +#[test] +fn later_segments_use_new_definitions_while_the_loaded_owner_finishes_its_old_rule() { + let initial = json!({"schemaVersion":"4","revision":"old","segments":{"s":{"conditions":"*"}}, + "features":{"subject":{"bucketBy":"id","conditions":["s",{"variable":"mutator","operator":"equals","value":true},{"not":["s"]}], + "traffic":[{"key":"old","conditions":"*","percentage":100000}]}}, + "variables":{"mutator":{"type":"boolean","defaultValue":true}}}); + let mut replacement = initial.clone(); + replacement["revision"] = json!("new"); + replacement["segments"]["s"]["conditions"] = json!([]); + replacement["features"]["subject"]["conditions"] = json!("*"); + replacement["features"]["subject"]["traffic"][0]["key"] = json!("new"); + let owner = Arc::new(Mutex::new(None)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(initial.to_string())), + modules: vec![Arc::new(ReplaceAfterSource { + owner: owner.clone(), + replacement, + })], + ..Default::default() + }); + *owner.lock().unwrap() = Some(f.clone()); + let first = f.evaluate_flag("subject", None); + assert_eq!(first.reason, featurevisor::EvaluationReason::Rule); + assert_eq!(first.rule_key.as_deref(), Some("old")); + assert_eq!(first.traffic.unwrap().key, "old"); + assert_eq!( + f.evaluate_flag("subject", None).rule_key.as_deref(), + Some("new") + ); + assert_eq!(f.get_segment("s").unwrap().conditions, json!([])); + owner.lock().unwrap().take(); + f.close(); +} + +#[test] +fn segment_depth_counts_terminal_expressions_consistently_and_recovers_after_failure() { + for terminal in [ + json!("*"), + json!({"not":[null]}), + json!({"attribute":"country","operator":"equals","value":"nl"}), + ] { + let limit = if terminal.get("attribute").is_some() { + 257 + } else { + 256 + }; + for count in [255, 256, 257, 258] { + let mut data = json!({"schemaVersion":"4","revision":"terminal","segments":{},"features":{ + "subject":{"bucketBy":"id","conditions":"s0","traffic":[{"key":"all","conditions":"*","percentage":100000}]}, + "safe":{"bucketBy":"id","traffic":[{"key":"all","conditions":"*","percentage":100000}]}}}); + for index in 0..count { + data["segments"][format!("s{index}")] = json!({"conditions":if index == count - 1 { terminal.clone() } else { json!(format!("s{}",index+1)) }}); + } + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + context: Some(context!("country" => "nl")), + ..Default::default() + }); + let result = f.evaluate_flag("subject", None); + assert_eq!( + result.reason, + if count > limit { + featurevisor::EvaluationReason::Error + } else { + featurevisor::EvaluationReason::Rule + }, + "{terminal}: {count}" + ); + assert!(f.is_enabled("safe", None)); + f.close(); + } + } +} + +impl FeaturevisorModule for GateDefaults { + fn before_evaluation(&self, mut options: EvaluateOptions) -> EvaluateOptions { + options.default_variable_value = Some("module default".into()); + options + } +} + +#[test] +fn nonarray_not_getters_are_safe_and_composed_null_gates_ignore_explicit_defaults() { + for operand in [ + json!(null), + json!("missing"), + json!({}), + json!(false), + json!(1), + ] { + let data = json!({"schemaVersion":"4","revision":"not","segments":{"s":{"conditions":{"not":operand}}}, + "features":{"direct":{"bucketBy":"id","conditions":{"not":operand},"traffic":[{"key":"all","conditions":"*","percentage":100000}]}, + "segment":{"bucketBy":"id","conditions":"s","traffic":[{"key":"all","conditions":"*","percentage":100000}]}}}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + ..Default::default() + }); + assert!(!f.is_enabled("direct", None), "{operand}"); + assert!(!f.is_enabled("segment", None), "{operand}"); + f.close(); + } + let data = json!({"schemaVersion":"4","revision":"null-gates","segments":{},"features":{},"variables":{ + "gated":{"type":"string","conditions":null,"compose":"neverRead"}, + "disabled":{"type":"string","conditions":null,"compose":"neverRead","disabledValue":"disabled"}}}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + modules: vec![Arc::new(GateDefaults)], + ..Default::default() + }); + let options = OverrideOptions { + default_variable_value: Some("caller default".into()), + ..Default::default() + }; + assert_eq!( + f.get_global_variable("gated", None, Some(&options)), + Some(VariableValue::Null) + ); + assert_eq!( + f.get_global_variable("disabled", None, Some(&options)), + Some("disabled".into()) + ); + f.close(); +} + +impl FeaturevisorModule for PartialUpdateBeforeRead { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if options.feature_key == "broken" { + let f = self.0.lock().unwrap().as_ref().unwrap().clone(); + f.set_datafile( + DatafileInput::Json( + json!({"schemaVersion":"4","revision":"partial", + "segments":{},"features":{},"variables":{}}) + .to_string(), + ), + false, + ); + assert_eq!( + f.get_feature("broken").unwrap().conditions, + Some(json!({"or":[]})) + ); + } + options + } +} + +#[test] +fn cold_retained_entities_share_normalization_across_active_partial_snapshots() { + let holder = Arc::new(Mutex::new(None)); + let errors = Arc::new(Mutex::new(Vec::new())); + let observed = errors.clone(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json( + json!({"schemaVersion":"4","revision":"initial", + "segments":{},"features":{"broken":{"bucketBy":"id","conditions":"[", + "traffic":[]}},"variables":{}}) + .to_string(), + )), + log_level: Some(LogLevel::Error), + on_diagnostic: Some(Arc::new(move |d: &Diagnostic| { + if d.code == "conditions_parse_error" { + observed.lock().unwrap().push(d.clone()); + } + })), + modules: vec![Arc::new(PartialUpdateBeforeRead(holder.clone()))], + ..Default::default() + }); + *holder.lock().unwrap() = Some(f.clone()); + assert!(!f.is_enabled("broken", None)); + assert_eq!(errors.lock().unwrap().len(), 1); + assert!(!f.is_enabled("broken", None)); + assert_eq!(errors.lock().unwrap().len(), 1); + holder.lock().unwrap().take(); + f.close(); +} + +struct SourceContext { + owner: Arc>>, + reads: Arc>, +} + +impl FeaturevisorModule for SourceContext { + fn before_evaluation(&self, mut options: EvaluateOptions) -> EvaluateOptions { + if options.feature_key == "source" { + *self.reads.lock().unwrap() += 1; + let f = self.owner.lock().unwrap().as_ref().unwrap().clone(); + options.context = f.get_context(None); + } + options + } + + fn after_evaluation(&self, evaluation: Evaluation, _: &EvaluateOptions) -> Evaluation { + if evaluation.variable_key.as_deref() == Some("mutator") { + let f = self.owner.lock().unwrap().as_ref().unwrap().clone(); + f.set_context(context!("country" => "nl"), false); + } + evaluation + } +} + +#[test] +fn module_driven_context_state_invalidates_repeated_composed_feature_source_reads() { + let holder = Arc::new(Mutex::new(None)); + let reads = Arc::new(Mutex::new(0)); + let data = json!({"schemaVersion":"4","revision":"context","segments":{}, + "features":{"source":{"bucketBy":"id","variablesSchema":{"value":{"type":"integer","defaultValue":1}}, + "traffic":[{"key":"all","conditions":"*","percentage":100000,"variableOverrides":{"value":[ + {"conditions":{"attribute":"country","operator":"equals","value":"nl"},"value":2}]}}]}}, + "variables":{"mutator":{"type":"boolean","defaultValue":true},"view":{"type":"object","compose":[ + {"key":"before","feature":"source","variable":"value"},{"key":"mutator","variable":"mutator"}, + {"key":"after","feature":"source","variable":"value"},{"key":"again","feature":"source","variable":"value"}]}}}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + context: Some(context!("country" => "us")), + modules: vec![Arc::new(SourceContext { + owner: holder.clone(), + reads: reads.clone(), + })], + ..Default::default() + }); + *holder.lock().unwrap() = Some(f.clone()); + assert_eq!( + f.get_global_variable("view", None, None).unwrap().to_json(), + json!({"before":1,"mutator":true,"after":2,"again":2}) + ); + assert_eq!(*reads.lock().unwrap(), 2); + holder.lock().unwrap().take(); + f.close(); +} + +#[test] +fn composition_keeps_exact_getter_fallbacks_without_outer_defaults() { + let data = json!({"schemaVersion":"4","revision":"fallbacks","segments":{}, + "features":{"off":{"bucketBy":"id","traffic":[],"disabledVariationValue":"", + "variablesSchema":{"value":{"type":"string","defaultValue":"wrong","disabledValue":null,"useDefaultWhenDisabled":true}}}}, + "variables":{"zero":{"type":"integer","defaultValue":0},"false":{"type":"boolean","defaultValue":false}, + "empty":{"type":"string","defaultValue":""},"json":{"type":"json","defaultValue":"\"null\""}, + "view":{"type":"object","compose":[{"key":"zero","variable":"zero"},{"key":"false","variable":"false"}, + {"key":"empty","variable":"empty"},{"key":"json","variable":"json"},{"key":"missing","variable":"missing"}, + {"key":"disabled","feature":"off","variable":"value"},{"key":"flag","feature":"off"}, + {"key":"variation","feature":"off","variation":true}]}}}); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(data.to_string())), + ..Default::default() + }); + let options = OverrideOptions { + default_variable_value: Some(VariableValue::from("outer")), + ..Default::default() + }; + for _ in 0..2 { + assert_eq!( + f.get_global_variable("view", None, Some(&options)) + .unwrap() + .to_json(), + json!({"zero":0,"false":false,"empty":"","json":"null","missing":null,"disabled":null,"flag":false,"variation":""}) + ); + } + f.close(); +} diff --git a/tests/set_datafile.rs b/tests/set_datafile.rs new file mode 100644 index 0000000..54cd012 --- /dev/null +++ b/tests/set_datafile.rs @@ -0,0 +1,266 @@ +use featurevisor::{ + create_featurevisor, DatafileContent, DatafileInput, EventDetails, EventName, + FeaturevisorModule, FeaturevisorOptions, LogLevel, ModuleApi, +}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +fn datafile(revision: &str, key: &str) -> Value { + json!({ + "schemaVersion": "2", "revision": revision, + "features": {key: {"hash": key, "bucketBy": "id", "traffic": []}}, + "segments": {key: {"conditions": "*"}}, + "variables": {key: {"hash": key, "type": "string", "defaultValue": key}} + }) +} + +fn input(value: &Value, encoded: bool) -> DatafileInput { + if encoded { + DatafileInput::Json(value.to_string()) + } else { + DatafileInput::Content(serde_json::from_value(value.clone()).unwrap()) + } +} + +#[test] +fn accepted_means_success_for_both_inputs_and_modes_including_unchanged() { + for encoded in [false, true] { + for replace in [false, true] { + let events = Arc::new(Mutex::new(Vec::new())); + let diagnostics = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&diagnostics); + let f = create_featurevisor(FeaturevisorOptions { + on_diagnostic: Some(Arc::new(move |d| { + observed.lock().unwrap().push(d.clone()); + })), + log_level: Some(LogLevel::Info), + ..Default::default() + }); + let observed = Arc::clone(&events); + let _unsubscribe = f.on( + EventName::DatafileSet, + Arc::new(move |event| { + if let EventDetails::DatafileSet(details) = event { + observed.lock().unwrap().push(details.clone()); + } + }), + ); + + let first = datafile("one", "original"); + assert!(f.set_datafile(input(&first, encoded), replace)); + assert_eq!(f.get_revision(), "one"); + assert!(f.set_datafile(input(&first, encoded), replace)); + let unchanged = events.lock().unwrap()[1].clone(); + assert_eq!(unchanged.previous_revision, "one"); + assert_eq!(unchanged.revision, "one"); + assert!(!unchanged.revision_changed); + assert!(unchanged.features.is_empty()); + assert!(unchanged.variables.is_empty()); + assert_eq!(unchanged.replaced, replace); + + // The same revision can still introduce different definitions. + assert!(f.set_datafile(input(&datafile("one", "next"), encoded), replace)); + assert_eq!(f.get_feature("original").is_some(), !replace); + assert_eq!(f.get_segment("original").is_some(), !replace); + assert_eq!( + f.get_global_variable_keys().contains(&"original".into()), + !replace + ); + assert!(f.get_feature("next").is_some()); + assert!(f.get_segment("next").is_some()); + assert!(f.get_global_variable_keys().contains(&"next".into())); + + let mut updated = datafile("two", "next"); + updated["features"]["next"]["hash"] = json!("changed"); + assert!(f.set_datafile(input(&updated, encoded), replace)); + assert_eq!(f.get_revision(), "two"); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 4); + assert!(events[3].revision_changed); + assert_eq!(events[3].previous_revision, "one"); + assert_eq!(events[3].features, ["next"]); + assert_eq!( + diagnostics + .lock() + .unwrap() + .iter() + .filter(|d| d.code == "datafile_set") + .count(), + 4 + ); + f.close(); + } + } +} + +#[test] +fn rejected_inputs_preserve_state_and_only_emit_existing_errors() { + for replace in [false, true] { + let diagnostics = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&diagnostics); + let original = datafile("valid", "original"); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(input(&original, false)), + on_diagnostic: Some(Arc::new(move |d| observed.lock().unwrap().push(d.clone()))), + ..Default::default() + }); + diagnostics.lock().unwrap().clear(); + let events = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&events); + let _error = f.on( + EventName::Error, + Arc::new(move |e| observed.lock().unwrap().push(e.clone())), + ); + let sets = Arc::new(Mutex::new(0)); + let observed = Arc::clone(&sets); + let _count = f.on( + EventName::DatafileSet, + Arc::new(move |_| *observed.lock().unwrap() += 1), + ); + let mut invalid = vec!["{", "null", "[]", "true", "7", "\"text\"", "{}"] + .into_iter() + .map(|s| DatafileInput::Json(s.into())) + .collect::>(); + for field in [ + "schemaVersion", + "revision", + "features", + "segments", + "variables", + ] { + let mut malformed = original.clone(); + malformed[field] = json!(17); + invalid.push(input(&malformed, true)); + } + let mut empty_revision: DatafileContent = serde_json::from_value(original.clone()).unwrap(); + empty_revision.revision.clear(); + invalid.push(DatafileInput::Content(empty_revision)); + invalid.push(input(&datafile("", "ignored"), true)); + for (index, rejected) in invalid.into_iter().enumerate() { + assert!(!f.set_datafile(rejected, replace)); + assert_eq!(f.get_revision(), "valid"); + assert!(f.get_feature("original").is_some()); + assert!(f.get_segment("original").is_some()); + assert_eq!(f.get_global_variable_keys(), ["original"]); + assert_eq!(*sets.lock().unwrap(), 0); + assert_eq!(events.lock().unwrap().len(), index + 1); + let diagnostics = diagnostics.lock().unwrap(); + assert_eq!(diagnostics.len(), index + 1); + assert_eq!(diagnostics[index].code, "invalid_datafile"); + assert_eq!(diagnostics[index].message, "Could not parse datafile"); + } + assert!(f.set_datafile(input(&original, true), replace)); + assert_eq!(*sets.lock().unwrap(), 1); + f.close(); + } +} + +struct FailingDiagnosticModule; + +impl FeaturevisorModule for FailingDiagnosticModule { + fn setup(&self, api: &ModuleApi) { + let _ = api.on_diagnostic( + Arc::new(|_| panic!("module diagnostic")), + Some(LogLevel::Debug), + ); + } +} + +#[test] +fn callback_failures_do_not_change_acceptance_or_stop_other_listeners() { + for replace in [false, true] { + let diagnostics = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&diagnostics); + let f = create_featurevisor(FeaturevisorOptions { + modules: vec![Arc::new(FailingDiagnosticModule)], + log_level: Some(LogLevel::Debug), + on_diagnostic: Some(Arc::new(move |d| { + observed.lock().unwrap().push(d.code.clone()); + panic!("application diagnostic"); + })), + ..Default::default() + }); + let events = Arc::new(Mutex::new(Vec::new())); + for event in [EventName::DatafileSet, EventName::Error] { + let _ = f.on(event, Arc::new(|_| panic!("listener"))); + let observed = Arc::clone(&events); + let current = f.clone(); + let _ = f.on( + event, + Arc::new(move |_| { + observed + .lock() + .unwrap() + .push((event, current.get_revision())); + }), + ); + } + assert!(f.set_datafile(input(&datafile("accepted", "flag"), true), replace)); + assert!(!f.set_datafile(DatafileInput::Json("bad".into()), replace)); + assert_eq!( + events.lock().unwrap().as_slice(), + [ + (EventName::DatafileSet, "accepted".into()), + (EventName::Error, "accepted".into()) + ] + ); + assert!(diagnostics + .lock() + .unwrap() + .contains(&"invalid_datafile".into())); + f.close(); + } +} + +#[test] +fn accepted_update_stays_true_when_listener_closes_the_instance() { + let f = create_featurevisor(FeaturevisorOptions::default()); + let current = f.clone(); + let _unsubscribe = f.on(EventName::DatafileSet, Arc::new(move |_| current.close())); + assert!(f.set_datafile(input(&datafile("accepted", "flag"), false), true)); + assert!(!f.set_datafile(input(&datafile("late", "flag"), false), true)); +} + +#[test] +fn rejected_update_stays_false_when_an_error_listener_recovers() { + let f = create_featurevisor(FeaturevisorOptions::default()); + let current = f.clone(); + let results = Arc::new(Mutex::new(Vec::new())); + let observed = Arc::clone(&results); + let _unsubscribe = f.on( + EventName::Error, + Arc::new(move |_| { + let accepted = current.set_datafile(input(&datafile("recovered", "flag"), false), true); + observed.lock().unwrap().push(accepted); + }), + ); + assert!(!f.set_datafile(DatafileInput::Json("bad".into()), true)); + assert_eq!(results.lock().unwrap().as_slice(), [true]); + assert_eq!(f.get_revision(), "recovered"); + f.close(); +} + +#[test] +fn closed_instances_reject_every_input_and_mode_without_callbacks() { + let calls = Arc::new(Mutex::new(0)); + let observed = Arc::clone(&calls); + let f = create_featurevisor(FeaturevisorOptions { + on_diagnostic: Some(Arc::new(move |_| *observed.lock().unwrap() += 1)), + ..Default::default() + }); + for event in [EventName::DatafileSet, EventName::Error] { + let observed = Arc::clone(&calls); + let _ = f.on(event, Arc::new(move |_| *observed.lock().unwrap() += 1)); + } + f.close(); + let before = *calls.lock().unwrap(); + for replace in [false, true] { + for encoded in [false, true] { + assert!(!f.set_datafile(input(&datafile("closed", "flag"), encoded), replace)); + } + assert!(!f.set_datafile(DatafileInput::Json("bad".into()), replace)); + assert!(!f.set_datafile(input(&datafile("", "flag"), false), replace)); + } + assert_eq!(*calls.lock().unwrap(), before); + assert!(f.get_feature_keys().is_empty()); +} diff --git a/tests/stateful_conformance.rs b/tests/stateful_conformance.rs new file mode 100644 index 0000000..e394803 --- /dev/null +++ b/tests/stateful_conformance.rs @@ -0,0 +1,410 @@ +use featurevisor::{ + create_featurevisor, Context, DatafileInput, Diagnostic, EventDetails, EventName, Featurevisor, + FeaturevisorChild, FeaturevisorOptions, LogLevel, SpawnOptions, StickyFeatures, + StickyVariables, Unsubscribe, +}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; +use std::fs; +use std::sync::{Arc, Mutex}; + +#[derive(Deserialize)] +struct Fixture { + kind: String, + #[serde(rename = "schemaVersion")] + schema_version: u64, + cases: Vec, +} + +#[derive(Deserialize)] +struct FixtureCase { + name: String, + initial: InitialState, + operations: Vec, +} + +#[derive(Deserialize)] +struct InitialState { + datafile: Value, + #[serde(default)] + context: Context, + #[serde(rename = "stickyFeatures")] + sticky_features: Option, + #[serde(rename = "stickyVariables")] + sticky_variables: Option, +} + +#[derive(Default, Deserialize)] +struct FixtureSpawnOptions { + #[serde(rename = "stickyFeatures")] + sticky_features: Option, + #[serde(rename = "stickyVariables")] + sticky_variables: Option, +} + +#[derive(Deserialize)] +struct Operation { + action: String, + #[serde(default)] + target: String, + parent: Option, + context: Option, + #[serde(default)] + options: FixtureSpawnOptions, + datafile: Option, + #[serde(rename = "stickyFeatures")] + sticky_features: Option, + #[serde(rename = "stickyVariables")] + sticky_variables: Option, + #[serde(default)] + replace: bool, + expect: Expectation, +} + +#[derive(Deserialize)] +struct Expectation { + error: Option, + events: Vec, + results: Vec, +} + +#[derive(Deserialize)] +struct ExpectedError { + code: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +struct RecordedEvent { + target: String, + name: String, + details: Value, +} + +#[derive(Deserialize)] +struct ExpectedResult { + target: String, + method: String, + args: Vec, + value: Value, +} + +#[derive(Clone)] +enum Target { + Root(Featurevisor), + Child(FeaturevisorChild), +} + +fn datafile_input(value: &Value) -> DatafileInput { + DatafileInput::Json(match value { + Value::String(value) => value.clone(), + value => value.to_string(), + }) +} + +fn normalize_details(mut details: Value) -> Value { + let Some(object) = details.as_object_mut() else { + return details; + }; + for key in ["features", "variables"] { + let Some(Value::Array(values)) = object.get_mut(key) else { + continue; + }; + values.sort_by(|left, right| { + left.as_str() + .unwrap_or_default() + .cmp(right.as_str().unwrap_or_default()) + }); + } + details +} + +fn event_payload(details: &EventDetails) -> Option<(&'static str, Value)> { + match details { + EventDetails::DatafileSet(details) => Some(( + "datafile_set", + serde_json::to_value(details).expect("serializable datafile event"), + )), + EventDetails::ContextSet(details) => Some(( + "context_set", + serde_json::to_value(details).expect("serializable context event"), + )), + EventDetails::StickyFeaturesSet(details) => Some(( + "sticky_features_set", + serde_json::to_value(details).expect("serializable sticky feature event"), + )), + EventDetails::StickyVariablesSet(details) => Some(( + "sticky_variables_set", + serde_json::to_value(details).expect("serializable sticky variable event"), + )), + EventDetails::Error { .. } => None, + _ => None, + } +} + +fn subscribe( + target_name: &str, + target: &Target, + events: &Arc>>, + subscriptions: &mut Vec, +) { + for event_name in [ + EventName::DatafileSet, + EventName::ContextSet, + EventName::StickyFeaturesSet, + EventName::StickyVariablesSet, + ] { + let target_name = target_name.to_string(); + let events = Arc::clone(events); + let handler = Arc::new(move |details: &EventDetails| { + let Some((name, details)) = event_payload(details) else { + return; + }; + events.lock().expect("event lock").push(RecordedEvent { + target: target_name.clone(), + name: name.to_string(), + details: normalize_details(details), + }); + }); + let unsubscribe = match target { + Target::Root(root) => root.on(event_name, handler), + Target::Child(child) => child.on(event_name, handler), + }; + subscriptions.push(unsubscribe); + } +} + +fn string_arg(result: &ExpectedResult, index: usize) -> &str { + result + .args + .get(index) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("{} argument {index} must be a string", result.method)) +} + +fn evaluate(target: &Target, result: &ExpectedResult) -> Value { + match result.method.as_str() { + "getRevision" => match target { + Target::Root(root) => Value::String(root.get_revision()), + Target::Child(_) => panic!("getRevision requires a root target"), + }, + "isEnabled" => { + let feature_key = string_arg(result, 0); + let context = context_arg(result, 1); + let enabled = match target { + Target::Root(root) => root.is_enabled(feature_key, context.as_ref()), + Target::Child(child) => child.is_enabled(feature_key, context.as_ref()), + }; + Value::Bool(enabled) + } + "getVariable" if !result.args.get(1).is_some_and(Value::is_string) => { + let variable_key = string_arg(result, 0); + let context = context_arg(result, 1); + let value = match target { + Target::Root(root) => { + root.get_global_variable(variable_key, context.as_ref(), None) + } + Target::Child(child) => { + child.get_global_variable(variable_key, context.as_ref(), None) + } + }; + value.map_or(Value::Null, |value| value.to_json()) + } + "getVariable" => { + let feature_key = string_arg(result, 0); + let variable_key = string_arg(result, 1); + let context = context_arg(result, 2); + let value = match target { + Target::Root(root) => { + root.get_variable(feature_key, variable_key, context.as_ref(), None) + } + Target::Child(child) => { + child.get_variable(feature_key, variable_key, context.as_ref(), None) + } + }; + value.map_or(Value::Null, |value| value.to_json()) + } + method => panic!("unknown stateful result method {method:?}"), + } +} + +fn context_arg(result: &ExpectedResult, index: usize) -> Option { + let value = result.args.get(index)?; + assert_eq!( + result.args.len(), + index + 1, + "unexpected extra arguments for {}", + result.method + ); + if value.is_null() { + return None; + } + assert!( + value.is_object(), + "{} argument {index} must be a context object", + result.method + ); + Some(serde_json::from_value(value.clone()).expect("valid context")) +} + +#[test] +fn stateful_sdk_conformance() { + let fixture: Fixture = serde_json::from_str( + &fs::read_to_string("conformance/sdk-stateful.json").expect("stateful fixture"), + ) + .expect("valid stateful fixture"); + assert_eq!(fixture.kind, "featurevisor.sdk-stateful"); + assert_eq!(fixture.schema_version, 1); + assert!(!fixture.cases.is_empty()); + let case_count = fixture.cases.len(); + let mut executed = 0; + + for case in fixture.cases { + let diagnostic_codes = Arc::new(Mutex::new(Vec::::new())); + let diagnostic_codes_for_handler = Arc::clone(&diagnostic_codes); + let root = create_featurevisor(FeaturevisorOptions { + datafile: Some(datafile_input(&case.initial.datafile)), + context: Some(case.initial.context), + sticky_features: case.initial.sticky_features, + sticky_variables: case.initial.sticky_variables, + on_diagnostic: Some(Arc::new(move |diagnostic: &Diagnostic| { + if diagnostic.level == LogLevel::Error { + diagnostic_codes_for_handler + .lock() + .expect("diagnostic lock") + .push(diagnostic.code.clone()); + } + })), + ..Default::default() + }); + let mut targets = HashMap::from([("root".to_string(), Target::Root(root.clone()))]); + let events = Arc::new(Mutex::new(Vec::::new())); + let mut subscriptions = Vec::new(); + subscribe( + "root", + targets.get("root").expect("root target"), + &events, + &mut subscriptions, + ); + + for operation in case.operations { + match operation.action.as_str() { + "spawn" => { + let parent_name = operation.parent.as_deref().unwrap_or("root"); + let Target::Root(parent) = targets + .get(parent_name) + .unwrap_or_else(|| panic!("unknown spawn parent {parent_name:?}")) + else { + panic!("spawn parent {parent_name:?} is not a root"); + }; + let child = parent.spawn( + operation.context.unwrap_or_default(), + SpawnOptions { + sticky_features: operation.options.sticky_features, + sticky_variables: operation.options.sticky_variables, + }, + ); + let target = Target::Child(child); + subscribe(&operation.target, &target, &events, &mut subscriptions); + targets.insert(operation.target.clone(), target); + } + "setDatafile" => { + let Target::Root(target) = + targets.get(&operation.target).unwrap_or_else(|| { + panic!("unknown datafile target {:?}", operation.target) + }) + else { + panic!("setDatafile target {:?} is not a root", operation.target); + }; + let accepted = target.set_datafile( + datafile_input(operation.datafile.as_ref().expect("operation datafile")), + operation.replace, + ); + assert_eq!(accepted, operation.expect.error.is_none(), "{}", case.name); + } + "setStickyFeatures" => { + let sticky = operation.sticky_features.expect("operation stickyFeatures"); + match targets + .get(&operation.target) + .unwrap_or_else(|| panic!("unknown sticky target {:?}", operation.target)) + { + Target::Root(target) => { + target.set_sticky_features(sticky, operation.replace) + } + Target::Child(target) => { + target.set_sticky_features(sticky, operation.replace) + } + } + } + "setStickyVariables" => { + let sticky = operation + .sticky_variables + .expect("operation stickyVariables"); + match targets + .get(&operation.target) + .unwrap_or_else(|| panic!("unknown sticky target {:?}", operation.target)) + { + Target::Root(target) => { + target.set_sticky_variables(sticky, operation.replace) + } + Target::Child(target) => { + target.set_sticky_variables(sticky, operation.replace) + } + } + } + action => panic!("unknown stateful action {action:?}"), + } + + let mut actual_events = std::mem::take(&mut *events.lock().expect("event lock")); + let mut expected_events = operation.expect.events; + for event in &mut actual_events { + event.details = normalize_details(std::mem::take(&mut event.details)); + } + for event in &mut expected_events { + event.details = normalize_details(std::mem::take(&mut event.details)); + } + assert_eq!( + actual_events, expected_events, + "{}: {} events", + case.name, operation.action + ); + + let actual_codes = + std::mem::take(&mut *diagnostic_codes.lock().expect("diagnostic lock")); + let expected_codes = operation + .expect + .error + .map(|error| vec![error.code]) + .unwrap_or_default(); + assert_eq!( + actual_codes, expected_codes, + "{}: {} diagnostics", + case.name, operation.action + ); + + for result in operation.expect.results { + let target = targets + .get(&result.target) + .unwrap_or_else(|| panic!("unknown result target {:?}", result.target)); + assert_eq!( + evaluate(target, &result), + result.value, + "{}: {} {:?}", + case.name, + operation.action, + result.method + ); + } + } + + for target in targets.values() { + if let Target::Child(child) = target { + child.close(); + } + } + root.close(); + drop(subscriptions); + executed += 1; + } + assert_eq!(executed, case_count); +} diff --git a/tests/v4_capabilities.rs b/tests/v4_capabilities.rs new file mode 100644 index 0000000..b016300 --- /dev/null +++ b/tests/v4_capabilities.rs @@ -0,0 +1,240 @@ +use featurevisor::{ + create_featurevisor, Context, DatafileContent, DatafileInput, EvaluateOptions, + FeaturevisorModule, FeaturevisorOptions, +}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +fn subset(actual: &Value, expected: &Value, label: &str) { + if let Some(object) = expected.as_object() { + assert!( + actual.is_object(), + "{label}: expected an object, got {actual}" + ); + for (key, value) in object { + assert!(actual.get(key).is_some(), "{label}.{key} is absent"); + subset(&actual[key], value, &format!("{label}.{key}")); + } + } else { + assert!( + json_equal(actual, expected), + "{label}: {actual} != {expected}" + ); + } +} + +fn json_equal(actual: &Value, expected: &Value) -> bool { + if actual == expected { + return true; + } + match (actual, expected) { + (Value::Number(a), Value::Number(b)) => { + let (integer, float) = if a.is_f64() && !b.is_f64() { + (b, a) + } else if b.is_f64() && !a.is_f64() { + (a, b) + } else { + return false; + }; + let float = float.as_f64().unwrap(); + if float.fract() != 0.0 { + return false; + } + if let Some(integer) = integer.as_u64() { + (0.0..18_446_744_073_709_551_616.0).contains(&float) && float as u64 == integer + } else { + (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0).contains(&float) + && Some(float as i64) == integer.as_i64() + } + } + (Value::Array(a), Value::Array(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| json_equal(a, b)) + } + (Value::Object(a), Value::Object(b)) => { + a.len() == b.len() + && a.iter() + .all(|(k, a)| b.get(k).is_some_and(|b| json_equal(a, b))) + } + _ => false, + } +} + +#[test] +fn fixture_numbers_are_numeric_but_not_boolean_or_string_values() { + assert!(json_equal(&json!(1), &json!(1.0))); + assert!(json_equal( + &json!({"a":[-1, 0, 1]}), + &json!({"a":[-1.0, 0.0, 1.0]}) + )); + for wrong in [json!(true), json!("1"), json!(null)] { + assert!(!json_equal(&json!(1), &wrong)); + } + assert!(!json_equal(&json!({}), &json!([]))); + assert!(!json_equal( + &json!(9_007_199_254_740_993u64), + &json!(9_007_199_254_740_992.0) + )); + assert!(!json_equal( + &json!(u64::MAX), + &json!(18_446_744_073_709_551_616.0) + )); +} + +struct Reads(Arc>>); +impl FeaturevisorModule for Reads { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if let Some(purpose) = options.dependency_purpose { + let mut read = json!({"dependencyPurpose":purpose}); + if !options.feature_key.is_empty() { + read["feature"] = json!(options.feature_key); + } + if let Some(key) = &options.variable_key { + read["variable"] = json!(key); + } + self.0.lock().unwrap().push(read); + } + options + } +} + +#[test] +fn unified_conditions_all_cases_twice_with_preorder_dependency_callbacks() { + let fixture: Value = + serde_json::from_str(include_str!("../conformance/sdk-conditions.json")).unwrap(); + assert_eq!(fixture["version"], 1); + assert_eq!(fixture["capability"], "unified-conditions"); + let cases = fixture["cases"].as_array().unwrap(); + assert!(!cases.is_empty()); + let mut executed = 0; + for case in cases { + let datafile: DatafileContent = serde_json::from_value(case["datafile"].clone()).unwrap(); + let before = datafile.clone(); + let reads = Arc::new(Mutex::new(Vec::new())); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile.clone())), + sticky_features: case["options"] + .get("stickyFeatures") + .map(|v| serde_json::from_value(v.clone()).unwrap()), + sticky_variables: case["options"] + .get("stickyVariables") + .map(|v| serde_json::from_value(v.clone()).unwrap()), + modules: vec![Arc::new(Reads(Arc::clone(&reads)))], + ..Default::default() + }); + let context: Context = serde_json::from_value(case["context"].clone()).unwrap(); + for pass in 0..2 { + reads.lock().unwrap().clear(); + let evaluation = if case["type"] == "flag" { + f.evaluate_flag(case["feature"].as_str().unwrap(), Some(&context)) + } else { + f.evaluate_global_variable(case["variable"].as_str().unwrap(), Some(&context), None) + }; + if let Some(expected) = case.get("dependencies") { + assert_eq!( + &json!(*reads.lock().unwrap()), + expected, + "{} pass {pass} dependencies", + case["name"] + ); + } + let actual = serde_json::to_value(evaluation).unwrap(); + for (key, expected) in case["expected"].as_object().unwrap() { + if key != "value" { + assert!( + actual.get(key).is_some(), + "{}: {key} is absent", + case["name"] + ); + subset( + &actual[key], + expected, + &format!("{} pass {pass} {key}", case["name"]), + ); + } + } + if case["type"] == "flag" { + assert_eq!( + f.is_enabled(case["feature"].as_str().unwrap(), Some(&context)), + case["expected"]["enabled"].as_bool().unwrap_or(false), + "{} getter", + case["name"] + ); + } else { + let value = f + .get_global_variable(case["variable"].as_str().unwrap(), Some(&context), None) + .map(|v| v.to_json()) + .unwrap_or(Value::Null); + assert!( + json_equal(&value, &case["expected"]["value"]), + "{} getter: {value}", + case["name"] + ); + } + } + assert_eq!(datafile, before); + f.close(); + executed += 1; + } + assert_eq!(executed, cases.len()); +} + +#[test] +fn composition_all_cases_twice_and_sources_unchanged() { + let fixture: Value = + serde_json::from_str(include_str!("../conformance/sdk-composition.json")).unwrap(); + assert_eq!(fixture["version"], 2); + assert_eq!(fixture["capability"], "composition-and-overrides"); + let cases = fixture["cases"].as_array().unwrap(); + assert!(!cases.is_empty()); + let mut executed = 0; + for case in cases { + let datafile: DatafileContent = + serde_json::from_value(fixture["datafile"].clone()).unwrap(); + let before = datafile.clone(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile.clone())), + sticky_features: case + .get("stickyFeatures") + .map(|v| serde_json::from_value(v.clone()).unwrap()), + sticky_variables: case + .get("stickyVariables") + .map(|v| serde_json::from_value(v.clone()).unwrap()), + ..Default::default() + }); + let context: Context = serde_json::from_value(case["context"].clone()).unwrap(); + let key = case["variable"].as_str().unwrap(); + for pass in 0..2 { + assert!( + json_equal( + &f.get_global_variable(key, Some(&context), None) + .map(|v| v.to_json()) + .unwrap_or(Value::Null), + &case["expectedValue"] + ), + "{} pass {pass}", + case["name"] + ); + subset( + &serde_json::to_value(f.evaluate_global_variable(key, Some(&context), None)) + .unwrap(), + &case["expectedEvaluation"], + case["name"].as_str().unwrap(), + ); + } + assert_eq!(datafile, before); + assert_eq!( + f.get_global_variable("policy", None, None) + .map(|v| v.to_json()), + Some( + if case["stickyVariables"].get("policy") == Some(&Value::Null) { + Value::Null + } else { + json!({"limit":50,"methods":["card"]}) + } + ) + ); + f.close(); + executed += 1; + } + assert_eq!(executed, cases.len()); +} diff --git a/tests/v4_runtime.rs b/tests/v4_runtime.rs new file mode 100644 index 0000000..6e0f9cb --- /dev/null +++ b/tests/v4_runtime.rs @@ -0,0 +1,529 @@ +use featurevisor::{ + apply_variable_operations, create_featurevisor, DatafileInput, Diagnostic, EvaluateOptions, + Evaluation, EvaluationReason, EventDetails, EventName, Featurevisor, FeaturevisorModule, + FeaturevisorOptions, OverrideOptions, SpawnOptions, VariableMutationOperation, VariableValue, +}; +use serde_json::json; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; + +struct CountModule { + before: Arc, + after: Arc, +} + +impl FeaturevisorModule for CountModule { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + self.before.fetch_add(1, Ordering::SeqCst); + options + } + + fn after_evaluation(&self, evaluation: Evaluation, _: &EvaluateOptions) -> Evaluation { + self.after.fetch_add(1, Ordering::SeqCst); + evaluation + } +} + +struct PanicSourceModule; + +impl FeaturevisorModule for PanicSourceModule { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if options.variable_key.as_deref() == Some("source") { + panic!("source callback failed"); + } + options + } +} + +struct SourceVisitModule(Arc); + +impl FeaturevisorModule for SourceVisitModule { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if options.variable_key.as_deref() == Some("source") { + self.0.fetch_add(1, Ordering::SeqCst); + } + options + } +} + +struct PanicFeatureModule; + +impl FeaturevisorModule for PanicFeatureModule { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if options.feature_key == "source" { + panic!("source feature callback failed"); + } + options + } +} + +struct ReentrantModule { + instance: Arc>>, + entered: Arc, + result: Arc>>, +} + +impl FeaturevisorModule for ReentrantModule { + fn before_evaluation(&self, options: EvaluateOptions) -> EvaluateOptions { + if options.variable_key.as_deref() == Some("subject") + && !self.entered.swap(true, Ordering::SeqCst) + { + let instance = self.instance.lock().unwrap().clone().unwrap(); + let value = instance.get_global_variable("helper", None, None); + *self.result.lock().unwrap() = value; + } + options + } +} + +#[test] +fn mutations_are_immutable_and_accumulation_callbacks_wrap_the_whole_evaluation() { + let original = VariableValue::from_json(json!({ "items": [], "nested": { "kept": true } })); + let operations: Vec = serde_json::from_value(json!([ + { "op": "append", "path": ["items"], "value": "a" }, + { "op": "set", "path": ["enabled"], "value": true } + ])) + .unwrap(); + let result = apply_variable_operations(&original, &operations).unwrap(); + assert_eq!( + result.to_json(), + json!({ "items": ["a"], "nested": { "kept": true }, "enabled": true }) + ); + assert_eq!( + original.to_json(), + json!({ "items": [], "nested": { "kept": true } }) + ); + + let datafile = json!({ + "schemaVersion": "2", "revision": "accumulation", "segments": {}, "features": {}, + "variables": { "subject": { + "type": "object", "defaultValue": { "items": [] }, + "overrideProgram": [ + { "key": "first", "keyPath": ["root", "first"], "segments": "*", + "operations": [{ "op": "set", "path": ["enabled"], "value": true }], "onMatch": 1, "onMiss": 1 }, + { "key": "second", "keyPath": ["root", "second"], "segments": "*", + "operations": [{ "op": "append", "path": ["items"], "value": "a" }], "onMatch": 2, "onMiss": 2 } + ] + } } + }); + let before = Arc::new(AtomicUsize::new(0)); + let after = Arc::new(AtomicUsize::new(0)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + modules: vec![Arc::new(CountModule { + before: Arc::clone(&before), + after: Arc::clone(&after), + })], + ..Default::default() + }); + let evaluation = f.evaluate_global_variable("subject", None, None); + assert_eq!(before.load(Ordering::SeqCst), 1); + assert_eq!(after.load(Ordering::SeqCst), 1); + assert_eq!(evaluation.variable_override_index, Some(1)); + assert_eq!(evaluation.variable_override_key.as_deref(), Some("second")); + assert_eq!( + evaluation.variable_override_path, + Some(vec!["root".into(), "second".into()]) + ); + assert_eq!( + evaluation.variable_override_paths, + Some(vec![ + vec!["root".into(), "first".into()], + vec!["root".into(), "second".into()] + ]) + ); +} + +#[test] +fn long_accumulation_keeps_ordered_provenance_and_owned_details() { + let steps: Vec<_> = (0..32) + .map(|index| { + json!({ + "key": format!("step{index}"), + "keyPath": ["root", format!("step{index}")], + "segments": "*", + "operations": [{"op": "set", "path": [], "value": index}], + "onMatch": index + 1, + "onMiss": index + 1 + }) + }) + .collect(); + let datafile = json!({ + "schemaVersion": "2", "revision": "long-program", "segments": {}, "features": {}, + "variables": {"counter": {"type": "integer", "defaultValue": 0, + "overrideProgram": steps}} + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + ..Default::default() + }); + assert_eq!( + f.get_global_variable("counter", None, None), + Some(VariableValue::Integer(31)) + ); + let mut detailed = f.evaluate_global_variable("counter", None, None); + assert_eq!(detailed.variable_override_index, Some(31)); + assert_eq!(detailed.variable_override_key.as_deref(), Some("step31")); + assert_eq!( + detailed.variable_override_paths, + Some( + (0..32) + .map(|index| vec!["root".into(), format!("step{index}")]) + .collect() + ) + ); + detailed.variable_override_paths.as_mut().unwrap()[0][0] = "caller".into(); + assert_eq!( + f.evaluate_global_variable("counter", None, None) + .variable_override_paths + .unwrap()[0][0], + "root" + ); +} + +#[test] +fn only_cycles_propagate_across_required_variable_getters() { + let ordinary_failure = json!({ + "schemaVersion": "2", "revision": "getter-error", "segments": {}, "features": {}, + "variables": { + "source": { "type": "string", "defaultValue": "value" }, + "subject": { "type": "string", "defaultValue": "matched", "disabledValue": "disabled", + "conditions": [{ "variable": "source", "operator": "isNull" }] } + } + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(ordinary_failure.to_string())), + modules: vec![Arc::new(PanicSourceModule)], + ..Default::default() + }); + let evaluation = f.evaluate_global_variable("subject", None, None); + assert_eq!(evaluation.reason, EvaluationReason::VariableDefault); + assert_eq!( + evaluation.variable_value, + Some(VariableValue::from("matched")) + ); + + let comparator_error = json!({ + "schemaVersion": "2", "revision": "comparator", "segments": {}, "features": {}, + "variables": { + "source": { "type": "string", "defaultValue": "value" }, + "subject": { "type": "string", "defaultValue": "incorrect", "disabledValue": "incorrect", + "conditions": [{ "not": [{ "variable": "source", "operator": "matches", "value": "(" }] }] } + } + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(comparator_error.to_string())), + ..Default::default() + }); + assert_eq!( + f.evaluate_global_variable("subject", None, None).reason, + EvaluationReason::Error + ); +} + +#[test] +fn malformed_json_getters_report_diagnostics_and_still_satisfy_is_null() { + let datafile = json!({ + "schemaVersion": "2", "revision": "malformed-json", "segments": {}, + "features": { "feature": { + "bucketBy": "userId", + "variablesSchema": { "config": { "type": "json", "defaultValue": "{" } }, + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } }, + "variables": { + "source": { "type": "json", "defaultValue": "{" }, + "subject": { "type": "boolean", "defaultValue": true, "disabledValue": false, + "conditions": [{ "variable": "source", "operator": "isNull" }] } + } + }); + let diagnostics = Arc::new(Mutex::new(Vec::::new())); + let observed = Arc::clone(&diagnostics); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + on_diagnostic: Some(Arc::new(move |diagnostic| { + observed.lock().unwrap().push(diagnostic.clone()); + })), + ..Default::default() + }); + let child = f.spawn(Default::default(), SpawnOptions::default()); + + assert_eq!(f.get_global_variable("source", None, None), None); + assert_eq!(f.get_variable("feature", "config", None, None), None); + assert_eq!(child.get_global_variable("source", None, None), None); + assert_eq!(child.get_variable("feature", "config", None, None), None); + + let getter_errors = || { + diagnostics + .lock() + .unwrap() + .iter() + .filter(|diagnostic| { + diagnostic.code == "evaluation_error" + && diagnostic.message == "getVariable failed" + && diagnostic.original_error.is_some() + }) + .count() + }; + assert_eq!(getter_errors(), 4); + + let evaluation = f.evaluate_global_variable("subject", None, None); + assert_eq!(evaluation.reason, EvaluationReason::VariableDefault); + assert_eq!( + evaluation.variable_value, + Some(VariableValue::Boolean(true)) + ); + assert_eq!( + f.get_global_variable("subject", None, None), + Some(true.into()) + ); + assert_eq!(getter_errors(), 6); +} + +#[test] +fn malformed_global_selectors_are_local_misses_not_datafile_failures() { + let datafile = json!({ + "schemaVersion": "2", "revision": "malformed-selectors", "segments": {}, "features": {}, + "variables": { + "segments": { "type": "string", "defaultValue": "default", "overrideProgram": [ + { "key": "broken", "segments": "{", "operations": [{ "op": "set", "path": [], "value": "wrong" }], "onMatch": 1, "onMiss": 1 } + ] }, + "conditions": { "type": "string", "defaultValue": "default", "overrideProgram": [ + { "key": "broken", "conditions": "{", "operations": [{ "op": "set", "path": [], "value": "wrong" }], "onMatch": 1, "onMiss": 1 } + ] } + } + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + ..Default::default() + }); + let segments = f.evaluate_global_variable("segments", None, None); + assert_eq!(segments.reason, EvaluationReason::VariableDefault); + assert!(segments.error.is_none()); + let conditions = f.evaluate_global_variable("conditions", None, None); + assert_eq!(conditions.reason, EvaluationReason::VariableDefault); + assert!(conditions.error.is_none()); +} + +#[test] +fn variable_requirement_state_is_reentrant_and_chain_local_under_concurrency() { + let datafile = json!({ + "schemaVersion": "2", "revision": "cycles", "segments": {}, "features": {}, + "variables": { + "a": { "type": "boolean", "defaultValue": true, + "conditions": [{ "not": [{ "variable": "b", "operator": "equals", "value": true }] }] }, + "b": { "type": "boolean", "defaultValue": true, + "conditions": [{ "variable": "a", "operator": "equals", "value": true }] }, + "safe": { "type": "object", "defaultValue": {} }, + "helper": { "type": "string", "defaultValue": "ready" }, + "subject": { "type": "string", "defaultValue": "matched", "disabledValue": "disabled", + "conditions": [{ "variable": "missing", "operator": "equals", "value": "caller" }] } + } + }); + let holder = Arc::new(Mutex::new(None)); + let entered = Arc::new(AtomicBool::new(false)); + let result = Arc::new(Mutex::new(None)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + modules: vec![Arc::new(ReentrantModule { + instance: Arc::clone(&holder), + entered: Arc::clone(&entered), + result: Arc::clone(&result), + })], + ..Default::default() + }); + *holder.lock().unwrap() = Some(f.clone()); + let evaluation = f.evaluate_global_variable( + "subject", + None, + Some(&OverrideOptions { + default_variable_value: Some("caller".into()), + ..Default::default() + }), + ); + assert_eq!(evaluation.reason, EvaluationReason::ConditionsUnmet); + assert_eq!(*result.lock().unwrap(), Some(VariableValue::from("ready"))); + + let mut workers = Vec::new(); + for _ in 0..24 { + let cycle = f.clone(); + workers.push(thread::spawn(move || { + assert_eq!( + cycle.evaluate_global_variable("a", None, None).reason, + EvaluationReason::Error + ); + })); + let safe = f.clone(); + workers.push(thread::spawn(move || { + assert_eq!( + safe.get_global_variable("safe", None, None) + .map(|value| value.to_json()), + Some(json!({})) + ); + })); + } + for worker in workers { + worker.join().unwrap(); + } +} + +#[test] +fn feature_variable_prerequisites_skip_false_rule_and_variation_selectors() { + let datafile = json!({ + "schemaVersion": "2", "revision": "ordering", "segments": {}, + "features": { + "rule": { + "bucketBy": "userId", "variablesSchema": { "message": { "type": "string", "defaultValue": "default" } }, + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000, + "variableOverrides": { "message": [{ "key": "blocked", + "conditions": [{ "attribute": "country", "operator": "equals", "value": "nl" }, { "variable": "source", "operator": "equals", "value": true }], "value": "incorrect" }] } }] + }, + "variation": { + "bucketBy": "userId", "variablesSchema": { "message": { "type": "string", "defaultValue": "default" } }, + "variations": [{ "value": "control", "variableOverrides": { "message": [{ "key": "blocked", + "conditions": [{ "attribute": "country", "operator": "equals", "value": "nl" }, { "variable": "source", "operator": "equals", "value": true }], "value": "incorrect" }] } }], + "force": [{ "segments": "*", "enabled": true, "variation": "control" }], "traffic": [] + } + }, + "variables": { "source": { "type": "boolean", "defaultValue": true } } + }); + let visits = Arc::new(AtomicUsize::new(0)); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + modules: vec![Arc::new(SourceVisitModule(Arc::clone(&visits)))], + ..Default::default() + }); + for feature in ["rule", "variation"] { + let before = visits.load(Ordering::SeqCst); + assert_eq!( + f.get_variable_string(feature, "message", None, None) + .as_deref(), + Some("default") + ); + assert_eq!(visits.load(Ordering::SeqCst), before, "{feature}"); + } +} + +#[test] +fn required_feature_errors_match_disabled_leaves_but_mixed_cycles_still_propagate() { + let datafile = json!({ + "schemaVersion": "2", "revision": "required-feature-errors", "segments": {}, + "features": { + "source": { "bucketBy": "userId", "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] }, + "expectedDisabled": { "bucketBy": "userId", + "requiredFeatures": [{ "feature": "source", "enabled": false }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] }, + "notSource": { "bucketBy": "userId", "requiredFeatures": [{ "not": ["source"] }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] }, + "mixed": { "bucketBy": "userId", + "conditions": [{ "variable": "a", "operator": "equals", "value": true }], + "variablesSchema": { "value": { "type": "boolean", "defaultValue": true } }, + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] } + }, + "variables": { + "a": { "type": "boolean", "defaultValue": true, + "conditions": [{ "feature": "mixed", "variable": "value", "operator": "equals", "value": true }] } + } + }); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(datafile.to_string())), + modules: vec![Arc::new(PanicFeatureModule)], + ..Default::default() + }); + assert!(f.is_enabled("expectedDisabled", None)); + assert!(f.is_enabled("notSource", None)); + let evaluation = f.evaluate_global_variable("a", None, None); + assert_eq!(evaluation.reason, EvaluationReason::Error); + assert_eq!( + evaluation.error.as_deref(), + Some("Circular variable requirement") + ); +} + +#[test] +fn dependency_notifications_follow_mixed_graphs_and_ignore_inactive_ordinary_overrides() { + let make = |revision: &str, base_hash: &str, country: &str, enabled: bool| { + json!({ + "schemaVersion": "2", "revision": revision, + "segments": { "legacy": { "conditions": { "attribute": "country", "operator": "equals", "value": country } } }, + "features": { + "base": { "hash": base_hash, "bucketBy": "userId", "traffic": if enabled { json!([{ "key": "all", "segments": "*", "percentage": 100000 }]) } else { json!([]) } }, + "leaf": { "hash": "leaf", "bucketBy": "userId", + "conditions": [{ "variable": "bridge", "operator": "equals", "value": true }], "traffic": [] } + }, + "variables": { + "bridge": { "hash": "bridge", "type": "boolean", "defaultValue": true, "requiredFeatures": ["base"] }, + "subject": { "hash": "subject", "type": "string", "defaultValue": "default", + "overrides": [{ "segments": "legacy", "value": "legacy" }], "overrideProgram": [] } + } + }) + }; + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json( + make("old", "base-old", "us", false).to_string(), + )), + ..Default::default() + }); + let observed = Arc::new(Mutex::new(None)); + let copy = Arc::clone(&observed); + let _unsubscribe = f.on( + EventName::DatafileSet, + Arc::new(move |event| { + if let EventDetails::DatafileSet(details) = event { + *copy.lock().unwrap() = Some(details.clone()); + } + }), + ); + f.set_datafile( + DatafileInput::Json(make("new", "base-new", "nl", true).to_string()), + true, + ); + let details = observed.lock().unwrap().clone().unwrap(); + assert!(details.features.contains(&"base".to_string())); + assert!(details.features.contains(&"leaf".to_string())); + assert!(details.variables.contains(&"bridge".to_string())); + assert!(!details.variables.contains(&"subject".to_string())); + + let hashless = json!({ + "schemaVersion": "2", "revision": "hashless", "segments": {}, + "features": { + "source": { "bucketBy": "userId", "traffic": [] }, + "dependent": { "hash": "dependent", "bucketBy": "userId", "requiredFeatures": ["source"], "traffic": [] } + }, + "variables": { + "sourceVariable": { "hash": "", "type": "boolean", "defaultValue": true }, + "dependentVariable": { "hash": "dependent-variable", "type": "boolean", "defaultValue": true, + "conditions": [{ "variable": "sourceVariable", "operator": "equals", "value": true }] } + } + }); + let partial = json!({ + "schemaVersion": "2", "revision": "hashless-partial", "segments": {}, + "features": { "source": { "bucketBy": "userId", "traffic": [] } }, + "variables": { "sourceVariable": { "hash": "", "type": "boolean", "defaultValue": true } } + }); + for replace in [false, true] { + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Json(hashless.to_string())), + ..Default::default() + }); + let observed = Arc::new(Mutex::new(None)); + let copy = Arc::clone(&observed); + let _unsubscribe = f.on( + EventName::DatafileSet, + Arc::new(move |event| { + if let EventDetails::DatafileSet(details) = event { + *copy.lock().unwrap() = Some(details.clone()); + } + }), + ); + let incoming = if replace { &hashless } else { &partial }; + f.set_datafile(DatafileInput::Json(incoming.to_string()), replace); + let details = observed.lock().unwrap().clone().unwrap(); + for key in ["source", "dependent"] { + assert!(details.features.contains(&key.to_string()), "{key}"); + } + for key in ["sourceVariable", "dependentVariable"] { + assert!(details.variables.contains(&key.to_string()), "{key}"); + } + } +}