From b7e506cde46eea9950301fd5b289528744e2599d Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 18 Oct 2026 20:30:55 +0200 Subject: [PATCH] feat: v4 changes --- .github/workflows/checks.yml | 35 +- .github/workflows/publish.yml | 2 +- Cargo.lock | 49 +- Cargo.toml | 10 +- README.md | 352 +- conformance/openfeature-provider.json | 264 + conformance/sdk-composition.json | 436 + conformance/sdk-conditions.json | 10990 ++++++++ conformance/sdk-diagnostics.json | 196 + conformance/sdk-stateful.json | 4023 +++ conformance/sdk-v3.json | 2292 +- conformance/sdk-v4-cases.json | 21153 ++++++++++++++++ conformance/semver.json | 76 + conformance/test-checks.json | 3495 +++ examples/dependency_smoke.rs | 9 + examples/sticky_session_bench.rs | 136 + openfeature/Cargo.toml | 4 +- openfeature/README.md | 9 +- .../conformance/openfeature-provider.json | 264 + openfeature/src/lib.rs | 36 +- openfeature/tests/provider.rs | 432 +- scripts/check-stacker-consumer.sh | 10 + src/bin/featurevisor.rs | 5 +- src/child.rs | 256 +- src/cli/assess_distribution.rs | 37 +- src/cli/benchmark.rs | 45 +- src/cli/mod.rs | 30 +- src/cli/options.rs | 57 +- src/cli/project.rs | 210 +- src/cli/semver_tests.rs | 133 + src/cli/test.rs | 2096 +- src/cli/test_checks.rs | 2751 ++ src/compare_versions.rs | 87 +- src/conditions.rs | 565 +- src/emitter.rs | 67 +- src/evaluate.rs | 2674 +- src/instance.rs | 1958 +- src/lib.rs | 23 +- src/modules.rs | 10 - src/mutations.rs | 373 + src/normalize.rs | 348 + src/types.rs | 175 +- tests/allocation_guard.rs | 201 + tests/cli.rs | 232 + tests/cli_options.rs | 354 + tests/cli_selection.rs | 273 + tests/composition_session.rs | 285 + tests/conditions.rs | 50 +- tests/conformance.rs | 437 +- tests/dependency_depth.rs | 77 + tests/dependency_events.rs | 72 + tests/dependency_failures.rs | 143 + tests/deprecation_diagnostics.rs | 41 + tests/diagnostic_conformance.rs | 88 + tests/empty_operators.rs | 151 + tests/evaluation_performance_contract.rs | 535 + tests/evaluation_reason.rs | 61 + tests/evaluation_session.rs | 655 + tests/fixtures/cli_npx.sh | 15 + tests/fixtures/dependency_smoke.rs | 224 + tests/fixtures/readme_initialization.rs | 34 + tests/fixtures/stacker-consumer/Cargo.toml | 8 + tests/fixtures/stacker-consumer/src/main.rs | 5 + tests/includes_any.rs | 155 + tests/live_modules.rs | 301 + tests/module_phase_snapshots.rs | 196 + tests/modules.rs | 24 +- tests/mutual_exclusion.rs | 170 + tests/native_parity_regressions.rs | 188 + tests/normalization.rs | 177 + tests/readme_initialization.rs | 72 + tests/review_correctness.rs | 376 + tests/set_datafile.rs | 266 + tests/stateful_conformance.rs | 410 + tests/v4_capabilities.rs | 240 + tests/v4_runtime.rs | 529 + 76 files changed, 61224 insertions(+), 1994 deletions(-) create mode 100644 conformance/openfeature-provider.json create mode 100644 conformance/sdk-composition.json create mode 100644 conformance/sdk-conditions.json create mode 100644 conformance/sdk-diagnostics.json create mode 100644 conformance/sdk-stateful.json create mode 100644 conformance/sdk-v4-cases.json create mode 100644 conformance/semver.json create mode 100644 conformance/test-checks.json create mode 100644 examples/dependency_smoke.rs create mode 100644 examples/sticky_session_bench.rs create mode 100644 openfeature/conformance/openfeature-provider.json create mode 100644 scripts/check-stacker-consumer.sh create mode 100644 src/cli/semver_tests.rs create mode 100644 src/cli/test_checks.rs create mode 100644 src/mutations.rs create mode 100644 src/normalize.rs create mode 100644 tests/allocation_guard.rs create mode 100644 tests/cli_options.rs create mode 100644 tests/cli_selection.rs create mode 100644 tests/composition_session.rs create mode 100644 tests/dependency_depth.rs create mode 100644 tests/dependency_events.rs create mode 100644 tests/dependency_failures.rs create mode 100644 tests/deprecation_diagnostics.rs create mode 100644 tests/diagnostic_conformance.rs create mode 100644 tests/empty_operators.rs create mode 100644 tests/evaluation_performance_contract.rs create mode 100644 tests/evaluation_reason.rs create mode 100644 tests/evaluation_session.rs create mode 100644 tests/fixtures/cli_npx.sh create mode 100644 tests/fixtures/dependency_smoke.rs create mode 100644 tests/fixtures/readme_initialization.rs create mode 100644 tests/fixtures/stacker-consumer/Cargo.toml create mode 100644 tests/fixtures/stacker-consumer/src/main.rs create mode 100644 tests/includes_any.rs create mode 100644 tests/live_modules.rs create mode 100644 tests/module_phase_snapshots.rs create mode 100644 tests/mutual_exclusion.rs create mode 100644 tests/native_parity_regressions.rs create mode 100644 tests/normalization.rs create mode 100644 tests/readme_initialization.rs create mode 100644 tests/review_correctness.rs create mode 100644 tests/set_datafile.rs create mode 100644 tests/stateful_conformance.rs create mode 100644 tests/v4_capabilities.rs create mode 100644 tests/v4_runtime.rs 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}"); + } + } +}