From 30d1cd9d2b367cdb8ee278338858cc7d7056e4d8 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 18 Oct 2026 20:30:54 +0200 Subject: [PATCH 1/2] feat: v4 changes --- .github/workflows/checks.yml | 5 +- .github/workflows/publish.yml | 3 + README.md | 464 +- 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 +++ pyproject.toml | 4 +- src/featurevisor/_evaluation_session.py | 207 + src/featurevisor/_json.py | 13 + src/featurevisor/child.py | 14 +- src/featurevisor/cli.py | 106 +- src/featurevisor/compare_versions.py | 50 +- src/featurevisor/conditions.py | 170 +- src/featurevisor/dependencies.py | 149 + src/featurevisor/diagnostics.py | 17 +- src/featurevisor/evaluate.py | 199 +- src/featurevisor/evaluation_data_provider.py | 232 +- src/featurevisor/events.py | 136 +- src/featurevisor/helpers.py | 2 +- src/featurevisor/instance.py | 506 +- src/featurevisor/modules.py | 35 +- src/featurevisor/mutations.py | 231 + src/featurevisor/normalize.py | 108 + src/featurevisor/openfeature.py | 69 +- src/featurevisor/project.py | 75 +- src/featurevisor/segment_tests.py | 91 + src/featurevisor/test_checks.py | 865 + src/featurevisor/tester.py | 808 +- src/featurevisor/types.py | 35 +- tests/benchmark_sticky_handoff.py | 101 + tests/benchmark_v4_review.py | 59 + tests/test_attribute_selector_cache.py | 138 + tests/test_cli.py | 1069 +- tests/test_cli_options.py | 149 + tests/test_conditions_parity.py | 57 + tests/test_conformance.py | 74 +- tests/test_dependency_errors.py | 37 + tests/test_deprecation_diagnostics.py | 40 + tests/test_evaluation_data_provider.py | 3 +- tests/test_evaluation_performance_contract.py | 408 + tests/test_evaluation_session.py | 670 + tests/test_feature_allocation.py | 83 + tests/test_helpers_parity.py | 6 +- tests/test_includes_any.py | 116 + tests/test_incremental_dependencies.py | 284 + tests/test_instance_parity.py | 21 +- tests/test_lazy_cache_concurrency.py | 236 + tests/test_live_modules.py | 87 + tests/test_module_phase_snapshots.py | 75 + tests/test_native_timestamps.py | 53 + tests/test_node_cli.py | 32 + tests/test_readme_initialization.py | 68 + tests/test_refresh_state.py | 168 + tests/test_required_features_reason.py | 37 + tests/test_resource_errors.py | 387 + tests/test_review_gaps.py | 310 + tests/test_runtime_projects.py | 228 + tests/test_sdk.py | 4 +- tests/test_sdk_diagnostics.py | 54 + tests/test_sdk_stateful.py | 122 + tests/test_semver_conformance.py | 79 + tests/test_set_datafile_result.py | 138 + tests/test_v4_alignment.py | 368 + tests/test_v4_capabilities.py | 105 + tests/test_v4_cases.py | 111 + tests/test_v4_runtime.py | 398 + tests_openfeature/test_provider.py | 215 +- tests_openfeature/test_resource_errors.py | 78 + 74 files changed, 53292 insertions(+), 895 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 src/featurevisor/_evaluation_session.py create mode 100644 src/featurevisor/_json.py create mode 100644 src/featurevisor/dependencies.py create mode 100644 src/featurevisor/mutations.py create mode 100644 src/featurevisor/normalize.py create mode 100644 src/featurevisor/segment_tests.py create mode 100644 src/featurevisor/test_checks.py create mode 100644 tests/benchmark_sticky_handoff.py create mode 100644 tests/benchmark_v4_review.py create mode 100644 tests/test_attribute_selector_cache.py create mode 100644 tests/test_cli_options.py create mode 100644 tests/test_dependency_errors.py create mode 100644 tests/test_deprecation_diagnostics.py create mode 100644 tests/test_evaluation_performance_contract.py create mode 100644 tests/test_evaluation_session.py create mode 100644 tests/test_feature_allocation.py create mode 100644 tests/test_includes_any.py create mode 100644 tests/test_incremental_dependencies.py create mode 100644 tests/test_lazy_cache_concurrency.py create mode 100644 tests/test_live_modules.py create mode 100644 tests/test_module_phase_snapshots.py create mode 100644 tests/test_native_timestamps.py create mode 100644 tests/test_node_cli.py create mode 100644 tests/test_readme_initialization.py create mode 100644 tests/test_refresh_state.py create mode 100644 tests/test_required_features_reason.py create mode 100644 tests/test_resource_errors.py create mode 100644 tests/test_review_gaps.py create mode 100644 tests/test_runtime_projects.py create mode 100644 tests/test_sdk_diagnostics.py create mode 100644 tests/test_sdk_stateful.py create mode 100644 tests/test_semver_conformance.py create mode 100644 tests/test_set_datafile_result.py create mode 100644 tests/test_v4_alignment.py create mode 100644 tests/test_v4_capabilities.py create mode 100644 tests/test_v4_cases.py create mode 100644 tests/test_v4_runtime.py create mode 100644 tests_openfeature/test_resource_errors.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7b699ed..05de725 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -59,6 +59,7 @@ jobs: - name: Verify source distribution contents run: | tar -tzf dist/featurevisor-*.tar.gz | grep '/conformance/sdk-v3.json$' + tar -tzf dist/featurevisor-*.tar.gz | grep '/conformance/sdk-v4-cases.json$' tar -tzf dist/featurevisor-*.tar.gz | grep '/tests/test_sdk.py$' tar -tzf dist/featurevisor-*.tar.gz | grep '/tests_openfeature/test_provider.py$' @@ -107,10 +108,10 @@ jobs: - name: Install Python SDK run: python -m pip install -e . - - name: Set up Featurevisor v3 example-1 project + - name: Set up Featurevisor example-1 project run: | mkdir example-1 - (cd example-1 && npx --yes @featurevisor/cli@3.x init --example=1) + (cd example-1 && npx --yes @featurevisor/cli@latest init --example=1) (cd example-1 && npm install) (cd example-1 && npx featurevisor build) (cd example-1 && npx featurevisor test) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b8cd983..ab6b6fe 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,6 +38,9 @@ jobs: python -m build python -m twine check dist/* + - name: Verify v4 conformance is in the source distribution + run: tar -tzf dist/featurevisor-*.tar.gz | grep '/conformance/sdk-v4-cases.json$' + - name: Verify built OpenFeature package run: | python -m venv /tmp/featurevisor-release diff --git a/README.md b/README.md index 3bc2051..3c1c19d 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,15 @@ # Featurevisor Python SDK -This repository ports the latest Featurevisor [JavaScript SDK](https://featurevisor.com/docs/sdks/javascript/) to Python. +Use the Featurevisor Python SDK to evaluate flags, variations and variables in Python applications. The package name is `featurevisor`, and it targets Python 3.10+. -This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - ## 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) @@ -19,11 +17,13 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Manually passing context](#manually-passing-context) - [Check if enabled](#check-if-enabled) - [Getting variation](#getting-variation) -- [Getting feature variables](#getting-feature-variables) -- [Getting global variables](#getting-global-variables) -- [Type specific methods](#type-specific-methods) -- [Getting evaluation snapshots](#getting-evaluation-snapshots) -- [Sticky features and variables](#sticky-features-and-variables) +- [Getting variables](#getting-variables) + - [Getting feature variables](#getting-feature-variables) + - [Getting global variables](#getting-global-variables) + - [Conditions and composition](#conditions-and-composition) + - [Type specific methods](#type-specific-methods) +- [Getting evaluations](#getting-evaluations) +- [Sticky](#sticky) - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) @@ -40,6 +40,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [`context_set`](#context_set) - [`sticky_features_set` and `sticky_variables_set`](#sticky_features_set-and-sticky_variables_set) - [`error`](#error) +- [Evaluation details](#evaluation-details) - [Modules](#modules) - [Defining a module](#defining-a-module) - [Registering modules](#registering-modules) @@ -57,6 +58,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Test](#test) - [Benchmark](#benchmark) - [Assess distribution](#assess-distribution) +- [GitHub repositories](#github-repositories) - [Development](#development) - [Releasing](#releasing) - [License](#license) @@ -65,26 +67,12 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. ## Installation -```bash -pip install featurevisor -``` - -## Public API - -The main runtime API is `create_featurevisor()`: - -```python -from featurevisor import Featurevisor, create_featurevisor +Upgrading an existing application? Follow the [Python migration guide](https://featurevisor.com/docs/migrations/v4/python/). -f: Featurevisor = create_featurevisor({ - "datafile": datafile_content, -}) +```bash +pip install "featurevisor>=4.0.0,<5" ``` -Most applications only need `create_featurevisor` and the `Featurevisor` instance type. Public extension and observability APIs include `FeaturevisorModule`, diagnostics, events, and the datafile dictionaries accepted by the factory. - -Concurrent evaluations are safe after an instance is configured. Do not mutate or close the same instance concurrently with evaluations. Serialize calls to `set_datafile`, `set_context`, `set_sticky_features`, `set_sticky_variables`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. - ## Initialization Initialize the SDK with Featurevisor datafile content: @@ -95,7 +83,7 @@ import json from featurevisor import create_featurevisor -datafile_url = "https://cdn.yoursite.com/datafile.json" +datafile_url = "https://cdn.yoursite.com/featurevisor-all.json" with urlopen(datafile_url) as response: datafile_content = json.load(response) @@ -103,21 +91,47 @@ with urlopen(datafile_url) as response: f = create_featurevisor({ "datafile": datafile_content, }) +try: + print(f.is_enabled("checkout", {"userId": "123"})) +finally: + f.close() +``` + +This example closes the instance after evaluation. In a long running application, keep it alive for subsequent evaluations and close it during shutdown. HTTP failures and invalid JSON propagate to the caller before initialization. + +### Public API + +The main runtime API is `create_featurevisor()`: + +```python +from featurevisor import Featurevisor, create_featurevisor + +f: Featurevisor = create_featurevisor({ + "datafile": datafile_content, +}) ``` -## Evaluation types +Most applications only need `create_featurevisor` and the `Featurevisor` instance type. Public extension and observability APIs include `FeaturevisorModule`, diagnostics, events, and the datafile dictionaries accepted by the factory. + +Concurrent evaluations are safe after an instance is configured. Do not mutate or close the same instance concurrently with evaluations. Serialize calls to `set_datafile`, `set_context`, `set_sticky_features`, `set_sticky_variables`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. -Featurevisor evaluates flags, variations, feature variables, and global variables: +### Evaluation types + +Featurevisor evaluates four kinds of values: - [**Flag**](#check-if-enabled) (`bool`): whether the feature is enabled or not -- [**Variation**](#getting-variation) (`string`): the variation of the feature (if any) -- [**Feature variables**](#getting-feature-variables): variable values owned by a feature -- [**Global variables**](#getting-global-variables): independently evaluated project values +- [**Variation**](#getting-variation) (`str`): 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 ## Context Context is a plain dictionary of attribute values used during evaluation: +Conditions compare these attributes using the [operators](https://featurevisor.com/docs/operators/), including `empty` and `notEmpty`. + +Date comparisons accept portable timestamp strings and aware `datetime` values with a usable UTC offset. Plain `date` and naive `datetime` values are not accepted. Offsets must stay within 14 hours and the UTC year within 0001 to 9999. Fractions are truncated to milliseconds after conversion to UTC. + ```python context = { "userId": "123", @@ -127,7 +141,7 @@ context = { ### Setting initial context -You can provide context at initialization: +Set context when you create the instance: ```python f = create_featurevisor({ @@ -140,7 +154,7 @@ f = create_featurevisor({ ### Setting after initialization -You can merge more context later: +Merge more context later: ```python f.set_context({ @@ -166,38 +180,40 @@ f.set_context( ### Manually passing context -You can also pass additional per-evaluation context: +Pass additional context to one evaluation: ```python -is_enabled = f.is_enabled("my_feature", {"country": "nl"}) -variation = f.get_variation("my_feature", {"country": "nl"}) -feature_variable = f.get_variable("my_feature", "my_variable", {"country": "nl"}) +is_enabled = f.is_enabled("checkout", {"country": "nl"}) +variation = f.get_variation("checkout", {"country": "nl"}) +feature_variable = f.get_variable("checkout", "bgColor", {"country": "nl"}) global_variable = f.get_variable("supportEmail", {"country": "nl"}) ``` ## Check if enabled ```python -if f.is_enabled("my_feature"): +if f.is_enabled("checkout"): pass ``` ## Getting variation ```python -variation = f.get_variation("my_feature") +variation = f.get_variation("checkout") if variation == "treatment": pass ``` -## Getting feature variables +## Getting variables + +### Getting feature variables ```python -bg_color = f.get_variable("my_feature", "bgColor") +bg_color = f.get_variable("checkout", "bgColor") ``` -## Getting global variables +### Getting global variables Global variables use the same methods without a feature key: @@ -206,9 +222,61 @@ support_email = f.get_variable("supportEmail", {"country": "nl"}) evaluation = f.evaluate_variable("supportEmail", {"country": "nl"}) ``` -Global variables honour sticky values, required features, and the first matching override. When an override contains both conditions and segments, both must match. +Conditions, composition and overrides are applied for you. See [Variables](https://featurevisor.com/docs/variables/#global-variables) and [Conditions](https://featurevisor.com/docs/conditions/) for authoring guidance. + +To see which overrides changed a value, inspect `variableOverridePaths` in the detailed evaluation: + +```python +evaluation = f.evaluate_variable("runtimePolicy", {"country": "nl"}) +value = evaluation.get("variableValue") +matched_paths = evaluation.get("variableOverridePaths") +``` + +Modules should return the same result for the same input during one evaluation. Pass values that change through context. -## Type specific methods +### Conditions and composition + +Schema version `"4"` uses one condition language for segments, root gates, force entries, traffic and variable overrides. Leaves compare attributes, reference a segment by its literal key, check a feature's enabled state and optional variation, or compare an ordinary variable getter result. A variable condition can specify its feature owner and a compiled path such as `["items", 0, "enabled"]`. + +Arrays imply AND. Groups use `and`, `or` and `not`. NOT negates the AND of its direct children. Empty expressions are false. Conditions preserve order and short circuiting. Missing paths remain distinct from explicit null. + +Feature evaluation checks sticky state, force entries, the root gate, then traffic and variable overrides. A failed feature or global variable root gate reports `conditions_unmet` and records the normalized expression in `evaluation.conditions`. Variation and feature variable disabled reasons are unchanged. + +A global variable can compose ordinary getter results: + +```python +datafile_content = { + "schemaVersion": "4", + "revision": "example", + "segments": {}, + "features": {}, + "variables": { + "policy": {"type": "object", "defaultValue": {"limit": 50}}, + "screen": {"type": "object", "compose": [ + {"key": "policy", "variable": "policy"}, + ]}, + "policyAlias": {"type": "object", "compose": "policy"}, + }, +} +f = create_featurevisor({"datafile": datafile_content}) +try: + screen = f.get_variable("screen") + policy = f.get_variable("policyAlias") +finally: + f.close() +``` + +The compiled list creates a fresh outer dictionary with literal property names. Entries may read global variables, feature variables, enabled flags or variations. A string `compose` is an alias of one literal global variable key and returns its value directly. False, zero, empty strings, arrays and null remain ordinary source values. JSON is decoded exactly once, including alias chains. + +Compositions have no `defaultValue` or `useDefaultWhenDisabled`. They may have root conditions and `disabledValue`. An unmet gate skips every source and override, returning the disabled value or null. Whole sticky state bypasses source reads. Otherwise all sources are read before the existing override program runs. Detailed evaluations report `variable_composed`, or `variable_override_rule` when an override matches. + +Sources use effective context, modules and child sticky state without inheriting an outer explicit default. SDK mutations copy changed paths and never change source, sibling or sticky values. Treat supplied datafiles and returned dictionaries and lists as read only. Copy them before application mutations. Cycles and more than 256 dependency handoffs fail the entire evaluation, with no partial composition returned. Python's interpreter recursion limit can stop a very deep chain sooner. With the default recursion limit of 1000, a chain of about 120 feature handoffs fits. This depends on the host and the caller's own stack. Lint warns about chains deeper than 32, which fit comfortably. The logical bound counts active dependency frames rather than every entity name or terminal attribute predicate. The SDK preserves the host application's recursion setting. Exhaustion returns an error evaluation, and later independent evaluations remain usable. + +Schema version `"2"` selectors are normalized eagerly before merging, without changing caller objects or their informational version metadata. Schema version `"4"` entities normalize lazily on their first read. Partial updates invalidate only incoming entities. Replacement clears all normalized entity caches. Concurrent evaluations and background datafile refreshes are supported. Diagnostic handlers may evaluate the same entity again. + +Malformed stringified selector JSON fails that entire selector, even through NOT or a segment alias, while other entities remain usable. An error diagnostic with code `conditions_parse_error` is reported once per selector per incoming entity load, eagerly for schema version `"2"` and on first read for schema version `"4"`. Its details contain exactly `conditions`, `entityType` and `entityKey`. Accepted data is stored before eager callbacks, and lazy results are cached before callbacks, so diagnostic handlers can safely reenter evaluation. + +### Type specific methods Typed convenience methods are also available: @@ -216,7 +284,7 @@ Typed convenience methods are also available: f.get_variable_boolean(feature_key, variable_key, context={}) f.get_variable_string(feature_key, variable_key, context={}) f.get_variable_integer(feature_key, variable_key, context={}) -f.get_variable_double(feature_key, variable_key, context={}) +f.get_variable_number(feature_key, variable_key, context={}) f.get_variable_array(feature_key, variable_key, context={}) f.get_variable_object(feature_key, variable_key, context={}) f.get_variable_json(feature_key, variable_key, context={}) @@ -231,25 +299,29 @@ f.get_variable_object("checkoutSettings", context={}) Type specific methods do not coerce values. `get_variable_integer()` returns `None` for the string `"1"`, and boolean getters return `None` for non-boolean values. -## Getting evaluation snapshots +`get_variable_number()` reads a variable of type `number` as a float, whether it holds `3` or `4.5`. Datafiles built before v4 call that type `double`, and it reads the same way. The v3 name `get_variable_double()` was removed in v4. Each method also has a camelCase name, such as `getVariableNumber()`. + +## Getting evaluations ```python features = f.get_feature_evaluations() variables = f.get_variable_evaluations() ``` -## Sticky features and variables +## Sticky ### Initialize with sticky -You can pin feature evaluations with sticky values: +Pin feature evaluations with sticky values: Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides. Pass `stickyFeatures` or `stickyVariables` when a child needs its own state. +Treat supplied sticky maps and their nested values as read only after handoff. Use the instance or child sticky setters to change them. Mutating a supplied map in place is not supported. + ```python f = create_featurevisor({ "stickyFeatures": { - "myFeatureKey": { + "checkout": { "enabled": True, "variation": "treatment", "variables": { @@ -269,7 +341,7 @@ Or update them later: ```python f.set_sticky_features({ - "myFeatureKey": { + "checkout": { "enabled": False, } }) @@ -286,6 +358,8 @@ f.set_datafile(datafile_content) f.set_datafile(json.dumps(datafile_content)) ``` +`set_datafile` returns `True` when the datafile is accepted, including unchanged content. It returns `False` when the datafile is rejected or the instance is closed. Rejected datafiles leave the last accepted datafile in place. Diagnostics and events keep their existing behaviour. The `setDatafile` alias returns the same boolean. + ### Merging by default By default, `set_datafile(datafile)` merges incoming content into the SDK's current datafile: @@ -293,63 +367,51 @@ By default, `set_datafile(datafile)` merges incoming content into the SDK's curr - top-level metadata such as `schemaVersion`, `revision`, and `featurevisorVersion` comes from the incoming datafile - `segments` are merged, with incoming entries overriding existing ones - `features` are merged, with incoming entries overriding existing ones +- global `variables` are merged, with incoming entries overriding existing ones -This means you can call `set_datafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. This is what makes [loading datafiles on demand](#loading-datafiles-on-demand) possible. +Call `set_datafile` with different datafiles to merge their features, segments, and global variables. Use this to [load datafiles on demand](#loading-datafiles-on-demand). ### Replacing -To fully replace the stored datafile, pass `True` as the second argument: +To fully replace the stored datafile, pass `replace=True`: ```python -f.set_datafile(datafile_content, True) +f.set_datafile(datafile_content, replace=True) ``` ### Loading datafiles on demand -Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront. - -This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application. You can load the datafile for the current part, and load others only when the user reaches them: +Merge datafiles for different [targets](https://featurevisor.com/docs/targets/) into the same instance. Here, the two datafile values have already been loaded and validated by your application from the same published release: ```python -from urllib.request import urlopen -import json - -from featurevisor import create_featurevisor - -f = create_featurevisor({}) - -def load_datafile(target): - url = f"https://cdn.yoursite.com/production/featurevisor-{target}.json" - with urlopen(url) as response: - datafile = json.load(response) - - # merges into whatever was loaded before - f.set_datafile(datafile) - -load_datafile("products") - -# later, when the user reaches checkout -load_datafile("checkout") +f.set_datafile(products_datafile) +f.set_datafile(checkout_datafile) ``` +Use immutable release URLs, check HTTP responses and keep shared dependencies consistent. See [Loading datafiles on demand](https://featurevisor.com/docs/use-cases/on-demand-datafiles/) for the complete loading and refresh flow. + ### Updating datafile -You can set the datafile as many times as you want in your application, which will emit a [`datafile_set`](#datafile-set) event that you can listen and react to accordingly. +Each accepted datafile update emits a [`datafile_set`](#datafile-set) event. Subscribe to react to updates. ### Interval-based update -Here's a minimal interval-style example: +Replace the complete datafile on each refresh so definitions removed upstream are removed from the SDK too. Use merging only when loading partial datafiles intentionally. Schedule the next attempt even if fetching or decoding fails. ```python import json +import logging import threading from urllib.request import urlopen def update_datafile(): - with urlopen(datafile_url) as response: - f.set_datafile(json.load(response)) - - threading.Timer(5 * 60, update_datafile).start() + try: + with urlopen(datafile_url, timeout=30) as response: + f.set_datafile(json.load(response), replace=True) + except Exception: + logging.exception("Failed to update datafile") + finally: + threading.Timer(5 * 60, update_datafile).start() update_datafile() ``` @@ -388,10 +450,9 @@ Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` Diagnostic handlers are isolated from SDK behaviour. An exception in a handler does not stop other handlers or evaluations. - ## Events -Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. +Subscribe to SDK events to respond to context, sticky state and datafile changes. ### `datafile_set` @@ -437,7 +498,7 @@ The `error` event is emitted for diagnostics reported with `level` set to `error ## Evaluation details -Besides logging with debug level enabled, you can also get more details about how feature variations and variables are evaluated at runtime against a given context: +Inspect how variations and variables are evaluated against a context: ```python # flag @@ -452,8 +513,10 @@ evaluation = f.evaluate_variable(feature_key, variable_key, context={}) The returned object will always contain the following properties: -- `featureKey`: the feature key -- `reason`: the reason how the value was evaluated +- `type`: flag, variation or variable +- `reason`: why the value was evaluated + +Feature evaluations also contain `featureKey`. Global variable evaluations contain `variableKey`. And optionally these properties depending on whether you are evaluating a feature variation or a variable: @@ -469,16 +532,19 @@ And optionally these properties depending on whether you are evaluating a featur - `variableOverrideIndex`: index of matched variable override when applicable - `variableOverrideKey`: key of the matched variable override when available - `variableOverridePath`: nested authoring path of the matched global variable override +- `variableOverridePaths`: all matched paths from an accumulating global variable override +- `conditions`: the normalized root expression when its gate prevents evaluation +- `variable`: the global variable definition when available ## Modules +The before evaluation and after evaluation phases each use the module membership at the start of that phase. Where an evaluation uses bucketing, the bucket key and bucket value phases each use their own starting membership. Adding or removing a module during a phase does not change the iteration already in progress. The next phase uses the membership then installed. A module added before the after phase can receive after callbacks for evaluations already in progress, even if it did not receive their before callbacks. A module removed before the after phase receives no after callback. + Modules can intercept evaluation and participate in SDK lifecycle: - `setup` -- `before` - `bucketKey` - `bucketValue` -- `after` - `beforeEvaluation` - `afterEvaluation` - `close` @@ -489,24 +555,24 @@ Modules can intercept evaluation and participate in SDK lifecycle: my_module = { "name": "my-module", "setup": lambda api: api["onDiagnostic"](lambda diagnostic: print(diagnostic)), - "before": lambda options: {**options, "context": {**options["context"], "country": "nl"}}, - "beforeEvaluation": lambda options: options, + "beforeEvaluation": lambda options: {**options, "context": {**options["context"], "country": "nl"}}, "bucketKey": lambda options: options["bucketKey"], "bucketValue": lambda options: options["bucketValue"], - "after": lambda evaluation, options: evaluation, "afterEvaluation": lambda evaluation, options: evaluation, "close": lambda: None, } ``` -For feature evaluations, all `before` callbacks run in registration order, followed by all `beforeEvaluation` callbacks. After evaluation and caller defaults, all `afterEvaluation` callbacks run, followed by all `after` callbacks. Global variable evaluations use only `beforeEvaluation` and `afterEvaluation`. Required feature checks run through the complete module pipeline, and transformed defaults are preserved. +`beforeEvaluation` and `afterEvaluation` run for feature and global variable evaluations. Required feature and variable checks use the same module pipeline, while an accumulating override publishes only its final result to `afterEvaluation`. -`before` and `after` remain available as deprecated feature-only compatibility callbacks. Use `beforeEvaluation` and `afterEvaluation` for new modules so the same callbacks can handle feature and global variable evaluations. +For an internal prerequisite read, both callbacks receive `options["dependencyOf"]` identifying the immediate requester, for example `{"type": "variable", "variableKey": "checkoutPolicy"}`. Feature variable requesters also include `featureKey`. Ordinary application calls have no `dependencyOf` entry. Callbacks can inspect this metadata without adding it to application options. The module API passed to `setup` exposes `getRevision`, `onDiagnostic`, and `reportDiagnostic`. If `setup` raises an exception, the module is not registered. Featurevisor removes subscriptions created during setup, reports `module_setup_error`, and calls `close` when present. +Internal reads carry `dependencyOf` identifying the immediate requester and `dependencyPurpose` set to `condition` or `composition`. Direct application calls carry neither. Conditions use owned comparison snapshots. Composition follows ordinary getter ownership. Memoization separates these purposes and is scoped to one public call. Tracking modules should normally skip condition reads, but composition reads consume real values. Private request state is never exposed to callbacks. + ### Registering modules Modules can be registered at initialization or afterwards: @@ -529,10 +595,10 @@ A child snapshots the parent keys that exist when it is spawned. Child values wi ```python child = f.spawn({"country": "de"}) -child.is_enabled("my_feature") -child.evaluate_flag("my_feature") -child.evaluate_variation("my_feature") -child.evaluate_variable("my_feature", "my_variable") +child.is_enabled("checkout") +child.evaluate_flag("checkout") +child.evaluate_variation("checkout") +child.evaluate_variable("checkout", "bgColor") child.evaluate_variable("supportEmail") ``` @@ -542,118 +608,14 @@ child.evaluate_variable("supportEmail") f.close() ``` -## CLI usage - -The Python package also exposes a CLI: - -```bash -python -m featurevisor test -python -m featurevisor benchmark -python -m featurevisor assess-distribution -``` - -These commands are intended for use from inside a Featurevisor project and rely on `npx featurevisor` being available locally. - -All three commands accept repeatable `--target=` options. `test` builds only the selected Target datafiles and runs untargeted assertions plus assertions for those targets. `benchmark` and `assess-distribution` run independently against every selected Target datafile. Without `--target`, existing project-wide behaviour is preserved. Project definitions, test specs, Target discovery, and datafile generation continue to come from the Node.js CLI. - -### Test - -Run Featurevisor test specs using the Python SDK: - -```bash -python -m featurevisor test \ - --projectDirectoryPath=/path/to/featurevisor-project -``` - -Useful options: - -```bash -python -m featurevisor test --keyPattern=foo -python -m featurevisor test --assertionPattern=variation -python -m featurevisor test --onlyFailures -python -m featurevisor test --showDatafile -python -m featurevisor test --verbose -``` - -The Python test runner builds base datafiles and Target datafiles with `npx featurevisor build --json`. Assertions containing `target` are evaluated against the matching Target datafile. - -### Benchmark - -Benchmark repeated Python SDK evaluations against a built datafile: - -```bash -python -m featurevisor benchmark \ - --projectDirectoryPath=/path/to/featurevisor-project \ - --environment=production \ - --feature=my_feature \ - --context='{"userId":"123"}' \ - --n=1000 -``` - -For variation benchmarks: - -```bash -python -m featurevisor benchmark \ - --projectDirectoryPath=/path/to/featurevisor-project \ - --environment=production \ - --feature=my_feature \ - --variation \ - --context='{"userId":"123"}' -``` - -For variable benchmarks: - -```bash -python -m featurevisor benchmark \ - --projectDirectoryPath=/path/to/featurevisor-project \ - --environment=production \ - --feature=my_feature \ - --variable=my_variable_key \ - --context='{"userId":"123"}' -``` - -For a global variable, omit `--feature`: - -```bash -python -m featurevisor benchmark \ - --projectDirectoryPath=/path/to/featurevisor-project \ - --environment=production \ - --variable=supportEmail \ - --context='{"country":"nl"}' -``` - -### Assess distribution - -Inspect enabled/disabled and variation distribution over repeated evaluations: - -```bash -python -m featurevisor assess-distribution \ - --projectDirectoryPath=/path/to/featurevisor-project \ - --environment=production \ - --feature=my_feature \ - --context='{"country":"nl"}' \ - --n=1000 -``` - -You can also populate UUID-based context keys per iteration: - -```bash -python -m featurevisor assess-distribution \ - --projectDirectoryPath=/path/to/featurevisor-project \ - --environment=production \ - --feature=my_feature \ - --populateUuid=userId \ - --populateUuid=deviceId -``` - ## OpenFeature -The provider targets OpenFeature specification `0.8.0` through OpenFeature Python SDK `0.10.x`. OpenFeature remains optional and is not installed or imported by the base Featurevisor package. +The provider targets OpenFeature specification `0.8.0` through OpenFeature Python SDK `0.10.x`, tested with `0.10.0`. OpenFeature remains optional and is not installed or imported by the base Featurevisor package. ### Installation ```bash -pip install "featurevisor[openfeature]" +pip install "featurevisor[openfeature]>=4.0.0,<5" ``` If the extra is not installed, importing `featurevisor.openfeature` reports the installation command needed to enable it. @@ -713,6 +675,8 @@ provider = FeaturevisorOpenFeatureProvider( ) ``` +The provider distinguishes a missing targeting key from an explicitly empty one when called directly. The upstream OpenFeature Python client merges contexts with a truthy fallback, so it drops an empty targeting key before the provider receives it. To override an existing targeting field with an empty string through that client, set the configured field itself in `attributes`, such as `{"userId": ""}`. Clear any inherited nonempty targeting key at its source first, since it would overwrite that attribute again. A narrower empty key may not clear it. + OpenFeature context attributes are copied without mutating the incoming context. Nested arrays and mappings are preserved. Datetimes are normalized to UTC ISO strings, matching the JavaScript provider. ### Resolution details @@ -721,9 +685,9 @@ The provider maps Featurevisor evaluation results to OpenFeature details: | Featurevisor result | OpenFeature result | | --- | --- | -| Required feature rule, forced, sticky, or rule match | `TARGETING_MATCH` | +| Forced, sticky, rule match or composed variable | `TARGETING_MATCH` | | Traffic allocation | `SPLIT` | -| Unmet global variable requirements, disabled variation, or disabled variable | `DISABLED` | +| Unmet root conditions, disabled variation or disabled variable | `DISABLED` | | No match or variable default | `DEFAULT` | | Missing feature, variable, or variations | `ERROR` with `FLAG_NOT_FOUND` | | Wrong resolver type | `ERROR` with `TYPE_MISMATCH` | @@ -759,10 +723,90 @@ provider = FeaturevisorOpenFeatureProvider(featurevisor=featurevisor) The caller owns an instance passed this way. Provider shutdown does not close it. Call `featurevisor.close()` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `featurevisor` takes precedence over the options dictionary. +An invalid initial datafile in an owned instance fails `set_provider_and_wait` with `ParseError`, independently of `logLevel`. A valid datafile update clears the error and emits readiness. Supplied diagnostic callbacks keep their normal filtering. Shutdown removes provider subscriptions and closes only an owned instance. + See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages. +## CLI usage + +Install the package in your Python environment: + +```bash +python -m pip install "featurevisor>=4.0.0,<5" +``` + +The native test runner requires `@featurevisor/cli` 4.0.0 or later and Node.js 24 or later. Install the Node.js CLI in your Featurevisor project: + +```bash +npm install --save-dev @featurevisor/cli@^4.0.0 +``` + +The native CLI provides `test`, `benchmark` and `assess-distribution`. Use the Node.js CLI for authoring commands such as `init`, `lint` and `build`. Native commands delegate project parsing, test listing, matrix expansion and datafile generation to `npx featurevisor`, then evaluate with this Python SDK. The SDK runtime has no Node.js dependency. + +Options use kebab case, such as `--project-directory-path`. The camelCase forms from earlier releases still work, and `--root-directory-path` is an alias for `--project-directory-path`. Pass values as `--name=value` or `--name value`. Repeat `--target` to select several targets in order. Run ` --help` to list every option. + +Duplicate targets are evaluated once, at their first position. `--n` and `-n` accept a positive integer. + +### Test + +```bash +python -m featurevisor test \ + --project-directory-path=/path/to/featurevisor-project \ + --only-failures +``` + +See [Testing](https://featurevisor.com/docs/testing/) for authoring specs and [Checks](https://featurevisor.com/docs/testing/#checks) for check syntax and operators. + +Useful test options: + +- `--key-pattern=` and `--assertion-pattern=` filter tests. +- `--entity-type=scenario` selects scenarios. `--set=` selects one project set. Omitting `--set` runs all sets. +- `--case=` reruns one expanded case. Copy the opaque ID or rerun command from a failure report. +- `--json --pretty` produces an indented JSON report. +- `--coverage` reports observed rule and override selections. `--uncovered` lists features and global variables without associated specs. +- `--only-failures`, `--quiet`, `--verbose` and `--show-datafile` control diagnostic output. + +### 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. Python evaluates them. Replace `checkout` with a feature defined in your project. + +```bash +python -m featurevisor benchmark \ + --project-directory-path=/path/to/featurevisor-project \ + --feature=checkout \ + --context='{"country":"nl"}' \ + --n=1000 +``` + +Use `--variation` for a variation or `--variable=` for a feature variable. Omit `--feature` to benchmark a global variable. Do not combine `--variation` with `--variable`. See [Benchmarking](https://featurevisor.com/docs/cli/#benchmarking). + +### Assess distribution + +```bash +python -m featurevisor assess-distribution \ + --project-directory-path=/path/to/featurevisor-project \ + --feature=checkout \ + --populate-uuid=userId \ + --n=1000 +``` + +Repeat `--populate-uuid=` to generate UUID values for multiple context attributes. Supply one key per option. See [Assess distribution](https://featurevisor.com/docs/cli/#assess-distribution). + +## GitHub repositories + +- SDK: [featurevisor/featurevisor-python](https://github.com/featurevisor/featurevisor-python) +- Example application: [featurevisor/featurevisor-example-python](https://github.com/featurevisor/featurevisor-example-python) + + + +The aggregate evaluation reference has moved to [Getting evaluations](#getting-evaluations). + + + +The sticky state reference has moved to [Sticky](#sticky). + ## Development This repository assumes: @@ -778,7 +822,9 @@ python -m pip install -e '.[dev]' make check ``` -`make check` runs the base SDK tests, OpenFeature provider tests, and static type checking. You can also run them separately with `make test`, `make test-openfeature`, and `make typecheck`. +`make check` runs the base SDK tests, OpenFeature provider tests, and static type checking. Run them separately with `make test`, `make test-openfeature`, and `make typecheck`. + +Run `PYTHONPATH=src python3 -m unittest discover -s tests -p test_readme_initialization.py -v` to execute the actual initialization snippet with simulated HTTP responses. The check covers evaluation, cleanup and failures without network access, and also runs in the ordinary SDK suite and CI. Run the example project integration directly: @@ -796,6 +842,8 @@ make test-example-1 ## Releasing +The next breaking release is `4.0.0`, including the optional OpenFeature extra. Before tagging, run `make check`, build both distributions with `python -m build`, and validate them with `python -m twine check dist/*`. Verify a base wheel installation without OpenFeature and a separate installation with the extra. The publish workflow rejects a tag that does not match `pyproject.toml`. + - Update version in pyproject.toml - Push commit to main branch - Wait for CI to complete 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/pyproject.toml b/pyproject.toml index 56bae94..251f467 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "featurevisor" -version = "3.1.0" -description = "Featurevisor Python SDK" +version = "4.0.0" +description = "Featurevisor SDK for Python: feature flags, experiments and remote configuration" readme = "README.md" requires-python = ">=3.10" dependencies = [ diff --git a/src/featurevisor/_evaluation_session.py b/src/featurevisor/_evaluation_session.py new file mode 100644 index 0000000..15bad62 --- /dev/null +++ b/src/featurevisor/_evaluation_session.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import copy +import datetime as dt +import math +from contextlib import contextmanager +from typing import Any, Iterator + + +class RequirementInputError(ValueError): + """A malformed or circular prerequisite, distinct from an ordinary getter error.""" + + +class DependencySnapshot(dict[str, Any]): + """Internal evaluation with a lazily normalized ordinary getter value.""" + + getter_value: Any + + +_UNSUPPORTED = object() + + +def _fingerprint(value: Any, active: set[int] | None = None) -> Any: + if value is None: + return ("null",) + if isinstance(value, bool): + return ("boolean", value) + if isinstance(value, (int, float)): + if isinstance(value, float) and not math.isfinite(value): + return _UNSUPPORTED + if isinstance(value, float) and value == 0 and math.copysign(1.0, value) < 0: + return ("number", "negative-zero") + # Modules can distinguish Python integers from floats even when the + # comparison operators deliberately treat their numeric values equally. + return ("float" if isinstance(value, float) else "integer", value) + if isinstance(value, str): + return ("string", value) + if isinstance(value, (dt.datetime, dt.date)): + return (type(value).__name__, value.isoformat()) + if not isinstance(value, (dict, list, tuple)): + return _UNSUPPORTED + active = set() if active is None else active + identity = id(value) + if identity in active: + return _UNSUPPORTED + active.add(identity) + try: + if isinstance(value, dict): + items = [] + for key, item in value.items(): + key_part = _fingerprint(key, active) + value_part = _fingerprint(item, active) + if key_part is _UNSUPPORTED or value_part is _UNSUPPORTED: + return _UNSUPPORTED + items.append((key_part, value_part)) + return ("object", tuple(sorted(items, key=lambda pair: repr(pair[0])))) + parts = tuple(_fingerprint(item, active) for item in value) + if any(part is _UNSUPPORTED for part in parts): + return _UNSUPPORTED + return ("tuple" if isinstance(value, tuple) else "array", parts) + finally: + active.remove(identity) + + +def dependency_of(options: dict[str, Any]) -> dict[str, Any]: + parent: dict[str, Any] = {"type": options["type"]} + if options.get("featureKey") is not None: + parent["featureKey"] = options["featureKey"] + if options.get("variableKey") is not None: + parent["variableKey"] = options["variableKey"] + return parent + + +def refresh_evaluation_state(options: dict[str, Any]) -> None: + # Skip only owner state lookups. Module replacements remain in options, + # and dependency memo keys still fingerprint the effective context. + version = options.get("__featurevisor_state_version") + if callable(version): + generation = version() + if options.get("__featurevisor_refreshed_state_version", _UNSUPPORTED) == generation: + return + options["__featurevisor_refreshed_state_version"] = generation + source = options.get("__featurevisor_child_sticky_source") + initial = options.get("__featurevisor_child_initial_sticky") + if callable(source) and isinstance(initial, tuple): + current = source() + baseline = list(initial) + for index, name in enumerate(("stickyFeatures", "stickyVariables")): + if options.get(name) is initial[index]: + options[name] = current[index] + baseline[index] = current[index] + options["__featurevisor_child_initial_sticky"] = tuple(baseline) + datafile = options.get("__featurevisor_datafile_source") + if callable(datafile) and options.get("datafile") is options.get("__featurevisor_initial_datafile"): + options["datafile"] = datafile() + options["__featurevisor_initial_datafile"] = options["datafile"] + + +class EvaluationSession: + """Request-local prerequisite stack and completed dependency values.""" + + def __init__(self) -> None: + self.memo: dict[tuple[Any, ...], dict[str, Any]] = {} + self.sticky_refs: dict[int, Any] = {} + self.active: list[tuple[Any, ...]] = [] + self.current: list[dict[str, Any]] = [] + self.aliases: list[dict[str, Any]] = [] + self.segments: set[str] = set() + + @staticmethod + def identity(options: dict[str, Any]) -> tuple[Any, ...]: + return (options.get("type"), options.get("featureKey"), options.get("variableKey")) + + @contextmanager + def frame(self, options: dict[str, Any]) -> Iterator[None]: + identity = self.identity(options) + if len(self.active) + len(self.segments) > 256: + raise RequirementInputError("Evaluation dependency depth exceeded") + if identity in self.active or any(identity == self.identity(current) for current in self.current): + raise RequirementInputError("Circular evaluation requirement") + parent = dependency_of(options) + self.active.append(identity) + self.current.append(parent) + try: + yield + finally: + self.active.pop() + self.current.pop() + if not self.active: + self.memo.clear() + self.sticky_refs.clear() + for alias in self.aliases: + for name in ("_evaluationSession", "__featurevisor_state_version", + "__featurevisor_child_state_version", "__featurevisor_child_sticky_source", + "__featurevisor_child_initial_sticky", "__featurevisor_datafile_source", + "__featurevisor_initial_datafile", "__featurevisor_refreshed_state_version"): + alias.pop(name, None) + self.aliases.clear() + + def retain(self, options: dict[str, Any]) -> None: + self.aliases.append(options) + + def update(self, options: dict[str, Any]) -> None: + if self.current: + self.current[-1] = dependency_of(options) + + def parent(self) -> dict[str, Any]: + return dict(self.current[-1]) + + def key(self, options: dict[str, Any]) -> tuple[Any, ...] | None: + if "dependencyOf" not in options: + return None + # Sticky maps and their nested values are read only inputs. Identity + # distinguishes module replacements, while owner generations detect + # setter changes even when a value is restored during the same request. + # Keep every keyed map alive until the root request ends so an id cannot + # be recycled while an earlier memo key still exists. + features = options.get("stickyFeatures") + variables = options.get("stickyVariables") + if features is not None: + self.sticky_refs[id(features)] = features + if variables is not None: + self.sticky_refs[id(variables)] = variables + parts = (_fingerprint(options.get("context")), id(features), id(variables), + _fingerprint(options.get("dependencyOf"))) + if any(part is _UNSUPPORTED for part in parts): + return None + version = options.get("__featurevisor_state_version") + generation = version() if callable(version) else None + return (self.identity(options), parts, generation, id(options.get("datafile")), + options.get("dependencyPurpose"), + tuple(id(module) for module in options["modulesManager"].get_all())) + + def get(self, key: tuple[Any, ...] | None) -> dict[str, Any] | None: + if key is None or key not in self.memo: + return None + # Internal reads only. Conditions own snapshots, while composition keeps + # borrowed values. Neither entry is passed to application callbacks. + return self.memo[key] + + def put(self, key: tuple[Any, ...] | None, evaluation: dict[str, Any]) -> DependencySnapshot | None: + if key is None or isinstance(evaluation.get("error"), (RequirementInputError, RecursionError)): + return None + fields = {"type", "featureKey", "variableKey", "reason", "enabled", "variationValue", "variableValue"} + minimal = {name: value for name, value in evaluation.items() if name in fields} + if "variationValue" not in minimal and isinstance(evaluation.get("variation"), dict): + minimal["variationValue"] = evaluation["variation"].get("value") + for name in ("variableSchema", "variable"): + value = evaluation.get(name) + if isinstance(value, dict) and "type" in value: + minimal[name] = {"type": value["type"]} + if "compose" in value: + minimal[name]["compose"] = True + if key[4] == "composition": + snapshot = DependencySnapshot(minimal) + self.memo[key] = snapshot + return snapshot + if _fingerprint(minimal) is _UNSUPPORTED: + return None + try: + snapshot = DependencySnapshot(copy.deepcopy(minimal)) + self.memo[key] = snapshot + return snapshot + except RecursionError as exc: + raise RequirementInputError("Host recursion limit exceeded during dependency evaluation") from exc + except Exception: + return None diff --git a/src/featurevisor/_json.py b/src/featurevisor/_json.py new file mode 100644 index 0000000..95d9bd9 --- /dev/null +++ b/src/featurevisor/_json.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import json +from typing import Any + + +def _reject_non_finite_constant(value: str) -> None: + raise ValueError(f"Invalid JSON numeric constant: {value}") + + +def strict_json_loads(source: str) -> Any: + """Decode standards compliant JSON without Python's NaN and Infinity extensions.""" + return json.loads(source, parse_constant=_reject_non_finite_constant) diff --git a/src/featurevisor/child.py b/src/featurevisor/child.py index 2ee18ec..22b3981 100644 --- a/src/featurevisor/child.py +++ b/src/featurevisor/child.py @@ -17,6 +17,7 @@ def __init__(self, *, parent: "Featurevisor", context: dict[str, Any], sticky_fe self.sticky_variables = sticky_variables or {} self.emitter = Emitter() self._parent_unsubscribers: list[Any] = [] + self._state_version = 0 def on(self, event_name, callback): if event_name in {"context_set", "sticky_features_set", "sticky_variables_set"}: @@ -44,6 +45,7 @@ def close(self) -> None: def set_context(self, context: dict[str, Any], replace: bool = False) -> None: self.context = dict(context) if replace else {**self.context, **context} + self._state_version += 1 self.emitter.trigger("context_set", {"context": self.context, "replaced": replace}) def get_context(self, context: dict[str, Any] | None = None) -> dict[str, Any]: @@ -52,12 +54,14 @@ def get_context(self, context: dict[str, Any] | None = None) -> dict[str, Any]: def set_sticky_features(self, sticky: dict[str, Any], replace: bool = False) -> None: previous = self.sticky_features self.sticky_features = dict(sticky) if replace else {**self.sticky_features, **sticky} + self._state_version += 1 params = get_params_for_sticky_features_set_event(previous, self.sticky_features, replace) self.emitter.trigger("sticky_features_set", params) def set_sticky_variables(self, sticky: dict[str, Any], replace: bool = False) -> None: previous = self.sticky_variables self.sticky_variables = dict(sticky) if replace else {**self.sticky_variables, **sticky} + self._state_version += 1 self.emitter.trigger("sticky_variables_set", get_params_for_sticky_variables_set_event(previous, self.sticky_variables, replace)) def _merge_context(self, context: dict[str, Any] | None) -> dict[str, Any]: @@ -71,6 +75,9 @@ def _merge_options(self, options: dict[str, Any] | None) -> dict[str, Any]: **(options or {}), "__featurevisor_child_sticky_features": self.sticky_features, "__featurevisor_child_sticky_variables": self.sticky_variables, + "__featurevisor_child_state_version": lambda: self._state_version, + "__featurevisor_child_sticky_source": lambda: (self.sticky_features, self.sticky_variables), + "__featurevisor_child_initial_sticky": (self.sticky_features, self.sticky_variables), } def is_enabled(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None) -> bool: @@ -112,8 +119,9 @@ def get_variable_string(self, *args): def get_variable_integer(self, *args): return self.parent.get_variable_integer(*self._variable_args(*args)) - def get_variable_double(self, *args): - return self.parent.get_variable_double(*self._variable_args(*args)) + def get_variable_number(self, *args): + """Return a number variable, whole or fractional, as a float.""" + return self.parent.get_variable_number(*self._variable_args(*args)) def get_variable_array(self, *args): return self.parent.get_variable_array(*self._variable_args(*args)) @@ -143,7 +151,7 @@ def get_variable_evaluations(self, context=None, variable_keys=None, options=Non getVariableBoolean = get_variable_boolean getVariableString = get_variable_string getVariableInteger = get_variable_integer - getVariableDouble = get_variable_double + getVariableNumber = get_variable_number getVariableArray = get_variable_array getVariableObject = get_variable_object getVariableJSON = get_variable_json diff --git a/src/featurevisor/cli.py b/src/featurevisor/cli.py index fffc1ef..39df195 100644 --- a/src/featurevisor/cli.py +++ b/src/featurevisor/cli.py @@ -4,48 +4,99 @@ import json import os import sys +import subprocess from .tester import run_assess_distribution, run_benchmark, run_test_project +def positive_inflate(value: str) -> int: + try: + parsed = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("--inflate must be a positive integer") from error + if parsed < 1: + raise argparse.ArgumentTypeError("--inflate must be a positive integer") + return parsed + + +def positive_iterations(value: str) -> int: + try: + parsed = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("--n must be a positive integer") from error + if parsed < 1: + raise argparse.ArgumentTypeError("--n must be a positive integer") + return parsed + + +def nonempty_value(value: str) -> str: + if not value: + raise argparse.ArgumentTypeError("value must not be empty") + return value + + def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="featurevisor") + parser = argparse.ArgumentParser(prog="featurevisor", allow_abbrev=False) subparsers = parser.add_subparsers(dest="command", required=True) common = argparse.ArgumentParser(add_help=False) - common.add_argument("--projectDirectoryPath", default=os.getcwd()) + common.add_argument("--project-directory-path", "--projectDirectoryPath", "--root-directory-path", "--rootDirectoryPath", dest="projectDirectoryPath", type=nonempty_value) common.add_argument("--environment") - common.add_argument("--feature") + common.add_argument("--feature", type=nonempty_value) common.add_argument("--context") - common.add_argument("--keyPattern") - common.add_argument("--assertionPattern") - common.add_argument("--onlyFailures", action="store_true") + common.add_argument("--key-pattern", "--keyPattern", dest="keyPattern") + common.add_argument("--assertion-pattern", "--assertionPattern", dest="assertionPattern") + common.add_argument("--only-failures", "--onlyFailures", dest="onlyFailures", action="store_true") common.add_argument("--quiet", action="store_true") common.add_argument("--verbose", action="store_true") - common.add_argument("--showDatafile", action="store_true") + common.add_argument("--show-datafile", "--showDatafile", dest="showDatafile", action="store_true") common.add_argument("--variation", action="store_true") - common.add_argument("--variable") - common.add_argument("--n", type=int, default=1000) - common.add_argument("--inflate", type=int, default=0) + common.add_argument("--variable", type=nonempty_value) + common.add_argument("--n", "-n", type=positive_iterations, default=1000) + common.add_argument("--inflate", type=positive_inflate, default=0) common.add_argument("--with-scopes", "--withScopes", dest="with_scopes", action="store_true") common.add_argument("--with-tags", "--withTags", dest="with_tags", action="store_true") common.add_argument("--schemaVersion", "--schema-version", dest="schema_version") - common.add_argument("--populateUuid", action="append", default=[]) - common.add_argument("--target", action="append", default=[]) - subparsers.add_parser("test", parents=[common]) - subparsers.add_parser("benchmark", parents=[common]) - subparsers.add_parser("assess-distribution", parents=[common]) + common.add_argument("--populate-uuid", "--populateUuid", dest="populateUuid", type=nonempty_value, action="append", default=[]) + common.add_argument("--target", type=nonempty_value, action="append", default=[]) + common.add_argument("--set") + test_parser = subparsers.add_parser("test", parents=[common], allow_abbrev=False) + test_parser.add_argument("--entity-type", "--entityType", dest="entityType", choices=("feature", "segment", "variable", "scenario")) + test_parser.add_argument("--json", action="store_true") + test_parser.add_argument("--pretty", action="store_true") + test_parser.add_argument("--case", dest="case_id") + test_parser.add_argument("--coverage", action="store_true") + test_parser.add_argument("--uncovered", action="store_true") + subparsers.add_parser("benchmark", parents=[common], allow_abbrev=False) + subparsers.add_parser("assess-distribution", parents=[common], allow_abbrev=False) return parser def main(argv: list[str] | None = None) -> int: + try: + return _main(argv) + except subprocess.CalledProcessError as error: + print(error.stderr or str(error), file=sys.stderr) + return 1 + except (ValueError, OSError) as error: + print(str(error), file=sys.stderr) + return 1 + + +def _main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) - context = json.loads(args.context) if args.context else {} + context = json.loads(args.context) if args.context is not None else {} + if not isinstance(context, dict): + parser.error("--context must be a JSON object") + if args.command != "test": + if args.variation and args.variable: + parser.error("--variation and --variable cannot be combined") if args.command == "test": ok = run_test_project( - args.projectDirectoryPath, + args.projectDirectoryPath or os.getcwd(), key_pattern=args.keyPattern, assertion_pattern=args.assertionPattern, + entity_type=args.entityType, verbose=args.verbose, quiet=args.quiet, show_datafile=args.showDatafile, @@ -55,13 +106,20 @@ def main(argv: list[str] | None = None) -> int: with_scopes=args.with_scopes, with_tags=args.with_tags, targets=args.target, + set_name=args.set, + json_output=args.json, + pretty=args.pretty, + case_id=args.case_id, + coverage=args.coverage, + uncovered=args.uncovered, + rerun_project_path=args.projectDirectoryPath, ) return 0 if ok else 1 if args.command == "benchmark": - if not args.environment or (not args.feature and not args.variable) or (args.variation and not args.feature): - parser.error("benchmark requires --environment and either --feature or --variable") + if (not args.feature and not args.variable) or (args.variation and not args.feature): + parser.error("benchmark requires --feature or --variable; --variation requires --feature") return run_benchmark( - args.projectDirectoryPath, + args.projectDirectoryPath or os.getcwd(), environment=args.environment, feature=args.feature, context=context, @@ -72,12 +130,13 @@ def main(argv: list[str] | None = None) -> int: verbose=args.verbose, quiet=args.quiet, targets=args.target, + set_name=args.set, ) if args.command == "assess-distribution": - if not args.environment or not args.feature: - parser.error("assess-distribution requires --environment and --feature") + if not args.feature: + parser.error("assess-distribution requires --feature") return run_assess_distribution( - args.projectDirectoryPath, + args.projectDirectoryPath or os.getcwd(), environment=args.environment, feature=args.feature, context=context, @@ -87,6 +146,7 @@ def main(argv: list[str] | None = None) -> int: verbose=args.verbose, quiet=args.quiet, targets=args.target, + set_name=args.set, ) return 1 diff --git a/src/featurevisor/compare_versions.py b/src/featurevisor/compare_versions.py index 95472aa..19f5adf 100644 --- a/src/featurevisor/compare_versions.py +++ b/src/featurevisor/compare_versions.py @@ -3,14 +3,15 @@ import re SEMVER_REGEX = re.compile( - r"(?i)^[v^~<>=]*?(\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+))?(?:-([\da-z\-]+(?:\.[\da-z\-]+)*))?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$" + r"^[v^~<>=]*?([0-9]+)(?:\.([x*]|[0-9]+)(?:\.([x*]|[0-9]+)(?:\.([x*]|[0-9]+))?(?:-([0-9a-z\-]+(?:\.[0-9a-z\-]+)*))?(?:\+[0-9a-z\-]+(?:\.[0-9a-z\-]+)*)?)?)?$", + re.IGNORECASE | re.ASCII, ) def validate_and_parse(version: str) -> list[str]: - if not version: + if not isinstance(version, str) or not version: raise ValueError("invalid argument expected string") - match = SEMVER_REGEX.match(version) + match = SEMVER_REGEX.fullmatch(version) if not match: raise ValueError(f"invalid argument not valid semver ('{version}' received)") return [group or "" for group in match.groups()] @@ -20,30 +21,28 @@ def is_wildcard(value: str) -> bool: return value in {"*", "x", "X"} -def try_parse(value: str): - try: - return int(value) - except ValueError: - return value - - -def compare_strings(a: str, b: str) -> int: - if is_wildcard(a) or is_wildcard(b): +def compare_strings(a: str, b: str, prerelease: bool = False) -> int: + if not prerelease and (is_wildcard(a) or is_wildcard(b)): return 0 - parsed_a = try_parse(a) - parsed_b = try_parse(b) - if type(parsed_a) is not type(parsed_b): - parsed_a = str(parsed_a) - parsed_b = str(parsed_b) - return (parsed_a > parsed_b) - (parsed_a < parsed_b) - - -def compare_segments(a: list[str], b: list[str]) -> int: + an = bool(re.fullmatch(r"[0-9]+", a)) + bn = bool(re.fullmatch(r"[0-9]+", b)) + if an and bn: + a, b = a.lstrip("0") or "0", b.lstrip("0") or "0" + if len(a) != len(b): + return 1 if len(a) > len(b) else -1 + elif prerelease and an != bn: + return -1 if an else 1 + return (a > b) - (a < b) + + +def compare_segments(a: list[str], b: list[str], prerelease: bool = False) -> int: max_len = max(len(a), len(b)) for index in range(max_len): - left = a[index] if index < len(a) else "0" - right = b[index] if index < len(b) else "0" - result = compare_strings(left, right) + if prerelease and (index == len(a) or index == len(b)): + return 1 if len(a) > len(b) else -1 + left = (a[index] if index < len(a) else "0") or "0" + right = (b[index] if index < len(b) else "0") or "0" + result = compare_strings(left, right, prerelease) if result: return result return 0 @@ -58,8 +57,7 @@ def compare_versions(v1: str, v2: str) -> int: if result: return result if p1 and p2: - return compare_segments(p1.split("."), p2.split(".")) + return compare_segments(p1.split("."), p2.split("."), True) if p1 or p2: return -1 if p1 else 1 return 0 - diff --git a/src/featurevisor/conditions.py b/src/featurevisor/conditions.py index d2eafb4..f4a3785 100644 --- a/src/featurevisor/conditions.py +++ b/src/featurevisor/conditions.py @@ -2,12 +2,81 @@ import datetime as dt import re +from dataclasses import dataclass from typing import Any, Callable from .compare_versions import compare_versions from .types import AttributeValue, Context MISSING = object() +PORTABLE_TIMESTAMP = re.compile(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})$") +MIN_PORTABLE_DATE = dt.datetime(1, 1, 1, tzinfo=dt.timezone.utc) +MAX_PORTABLE_DATE = dt.datetime(9999, 12, 31, 23, 59, 59, 999000, tzinfo=dt.timezone.utc) + + +def normalize_portable_regex(pattern: str) -> str: + """Use JS ASCII digit and word shorthands without altering Unicode whitespace.""" + inside = { + "d": "0-9", "D": r"\x00-\x2F\x3A-\U0010FFFF", + "w": "A-Za-z0-9_", "W": r"\x00-\x2F\x3A-\x40\x5B-\x5E\x60\x7B-\U0010FFFF", + } + outside = {"d": "[0-9]", "D": "[^0-9]", "w": "(?-i:[A-Za-z0-9_])", "W": "(?-i:[^A-Za-z0-9_])"} + result: list[str] = [] + in_class = False + index = 0 + while index < len(pattern): + char = pattern[index] + if char == "\\" and index + 1 < len(pattern): + escaped = pattern[index + 1] + result.append((inside if in_class else outside).get(escaped, pattern[index:index + 2])) + index += 2 + continue + if char == "[" and not in_class: + in_class = True + elif char == "]" and in_class: + in_class = False + result.append(char) + index += 1 + return "".join(result) + + +@dataclass(frozen=True, slots=True) +class _PreparedCondition: + source: dict[str, Any] + attribute: str | None + path: tuple[str, ...] | None + operator: Any + value: Any + regex_flags: Any + + +@dataclass(frozen=True, slots=True) +class _PreparedConditionGroup: + operator: str + items: tuple[Any, ...] + + +def prepare_conditions(conditions: Any) -> Any: + """Prepare private parsed syntax without caching a context decision.""" + if isinstance(conditions, list): + return _PreparedConditionGroup("and", tuple(prepare_conditions(item) for item in conditions)) + if not isinstance(conditions, dict): + return conditions + if "attribute" in conditions: + attribute = conditions.get("attribute") + return _PreparedCondition( + source=conditions, + attribute=attribute if isinstance(attribute, str) else None, + path=tuple(attribute.split(".")) if isinstance(attribute, str) and "." in attribute else None, + operator=conditions.get("operator"), + value=conditions.get("value"), + regex_flags=conditions.get("regexFlags", ""), + ) + for operator in ("and", "or", "not"): + items = conditions.get(operator) + if isinstance(items, list): + return _PreparedConditionGroup(operator, tuple(prepare_conditions(item) for item in items)) + return conditions def _strict_equal(left: Any, right: Any) -> bool: @@ -37,26 +106,98 @@ def get_value_from_context(obj: Context, path: str) -> AttributeValue: def _to_datetime(value: Any) -> dt.datetime | None: if isinstance(value, dt.datetime): - return value if value.tzinfo is not None else None - if not isinstance(value, str): + try: + native_offset = value.utcoffset() + except (OverflowError, ValueError, TypeError): + return None + if native_offset is None or abs(native_offset) > dt.timedelta(hours=14): + return None + parsed = value + elif isinstance(value, str) and PORTABLE_TIMESTAMP.fullmatch(value): + offset = value[-6:] if value[-1] != "Z" else "+00:00" + if offset != "+00:00" and (int(offset[1:3]) > 14 or int(offset[4:]) > 59 or (offset[1:3] == "14" and offset[4:] != "00")): + return None + try: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + else: return None - if isinstance(value, str) and value.endswith("Z"): - value = value.replace("Z", "+00:00") try: - parsed = dt.datetime.fromisoformat(value) - except ValueError: + utc = parsed.astimezone(dt.timezone.utc) + utc = utc.replace(microsecond=(utc.microsecond // 1000) * 1000) + except (OverflowError, ValueError): return None - return parsed if parsed.tzinfo is not None else None + return utc if MIN_PORTABLE_DATE <= utc <= MAX_PORTABLE_DATE else None def condition_is_matched(condition: dict[str, Any], context: Context, get_regex: Callable[[str, str], re.Pattern[str]]) -> bool: attribute = condition.get("attribute") - operator = condition.get("operator") - value = condition.get("value") - regex_flags = condition.get("regexFlags", "") if not isinstance(attribute, str): return False - context_value = get_value_from_context(context, attribute) + return value_is_matched(get_value_from_context(context, attribute), condition, get_regex) + + +def prepared_condition_is_matched( + condition: _PreparedCondition, + context: Context, + get_regex: Callable[[str, str], re.Pattern[str]], +) -> bool: + if condition.attribute is None: + return False + context_value: Any + if condition.path is None: + context_value = context.get(condition.attribute, MISSING) + else: + context_value = context + for part in condition.path: + if not isinstance(context_value, dict): + context_value = MISSING + break + context_value = context_value.get(part, MISSING) + if context_value is MISSING: + break + return _value_is_matched( + context_value, + condition.operator, + condition.value, + condition.regex_flags, + get_regex, + ) + + +def value_is_matched( + context_value: Any, + condition: dict[str, Any], + get_regex: Callable[[str, str], re.Pattern[str]], +) -> bool: + return _value_is_matched( + context_value, + condition.get("operator"), + condition.get("value"), + condition.get("regexFlags", ""), + get_regex, + ) + + +def _value_is_matched( + context_value: Any, + operator: Any, + value: Any, + regex_flags: Any, + get_regex: Callable[[str, str], re.Pattern[str]], +) -> bool: + + if operator == "isNull": + return context_value is None + if operator == "notNull": + return context_value is not MISSING and context_value is not None + if operator in {"empty", "notEmpty"}: + if context_value is MISSING or context_value is None: + return False + if not isinstance(context_value, (str, list, dict)): + return False + return len(context_value) == 0 if operator == "empty" else len(context_value) > 0 if operator == "equals": return _strict_equal(context_value, value) @@ -115,6 +256,13 @@ def condition_is_matched(condition: dict[str, Any], context: Context, get_regex: return context_value is not MISSING if operator == "notExists": return context_value is MISSING + if operator in {"includesAny", "notIncludesAny"}: + if not isinstance(context_value, list) or not isinstance(value, list): + return False + if not all(candidate is None or isinstance(candidate, (str, int, float, bool)) for candidate in value): + return False + matched = any(_strict_equal(candidate, actual) for candidate in value for actual in context_value) + return matched if operator == "includesAny" else not matched if isinstance(context_value, list) and (isinstance(value, (str, int, float, bool)) or value is None): if operator == "includes": return any(_strict_equal(value, candidate) for candidate in context_value) diff --git a/src/featurevisor/dependencies.py b/src/featurevisor/dependencies.py new file mode 100644 index 0000000..13bbe75 --- /dev/null +++ b/src/featurevisor/dependencies.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from typing import Any + +from ._json import strict_json_loads + + +def _condition_dependencies(expression: Any, sources: dict) -> None: + if isinstance(expression, str): + if expression.lstrip()[:1] in ('{', '[', '"'): + try: + _condition_dependencies(strict_json_loads(expression), sources) + except (TypeError, ValueError): + pass + elif expression != "*": + sources[("segment", expression)] = None + elif isinstance(expression, list): + for item in expression: + _condition_dependencies(item, sources) + elif isinstance(expression, dict): + if "variable" in expression: + if expression.get("feature") is not None: + sources[("feature", expression["feature"])] = None + else: + sources[("variable", expression["variable"])] = None + elif "feature" in expression: + sources[("feature", expression["feature"])] = None + else: + for operator in ("and", "or", "not"): + if operator in expression: + _condition_dependencies(expression[operator], sources) + + +def _entity_dependencies(entry: Any, sources: dict) -> None: + if not isinstance(entry, dict): + return + if "conditions" in entry: + _condition_dependencies(entry["conditions"], sources) + # Most entries have no nested selectors. Avoid allocating empty containers + # for absent fields while preserving the authored traversal order. + for name in ("force", "traffic", "variations", "overrides", "overrideProgram"): + if name in entry: + for child in entry[name] or (): + _entity_dependencies(child, sources) + if "variableOverrides" in entry: + for overrides in (entry["variableOverrides"] or {}).values(): + for override in overrides: + _entity_dependencies(override, sources) + + +def extract_entity_dependencies(definition: dict[str, Any]) -> tuple: + sources: dict[tuple[str, str], None] = {} + _entity_dependencies(definition, sources) + compose = definition.get("compose") + if isinstance(compose, str): + sources[("variable", compose)] = None + elif isinstance(compose, list): + for reference in compose: + if reference.get("feature") is not None: + sources[("feature", reference["feature"])] = None + elif reference.get("variable") is not None: + sources[("variable", reference["variable"])] = None + return tuple(sources) + + +COLLECTIONS = (("segments", "segment"), ("features", "feature"), ("variables", "variable")) + + +class DatafileDependencyIndex: + """Retain edge identities, never historical providers or definitions.""" + + def __init__(self) -> None: + self.reverse: dict[tuple, dict[tuple, None]] = {} + self.owners: dict[tuple, tuple] = {} + self.order: dict[tuple, int] = {} + self.next_order = 0 + + def update(self, datafile: dict, incoming: dict, previous: dict | None = None) -> None: + # Extract first so failures cannot partially modify the graph. + updates = [] + for name, kind in COLLECTIONS: + definitions = datafile.get(name) or {} + previous_definitions = (previous or {}).get(name) or {} + for key in incoming.get(name, {}): + definition = definitions.get(key) + owner = (kind, key) + if definition is not None and owner in self.owners: + old = previous_definitions.get(key) + try: + if old is definition or old == definition: + continue + except RecursionError: + # Recursive native metadata need not be comparable. + pass + updates.append((owner, None if definition is None else extract_entity_dependencies(definition))) + for owner, sources in updates: + for source in self.owners.pop(owner, ()): + dependents = self.reverse[source] + del dependents[owner] + if not dependents: + del self.reverse[source] + if sources is None: + self.order.pop(owner, None) + continue + if owner not in self.order: + self.order[owner] = self.next_order + self.next_order += 1 + self.owners[owner] = sources + for source in sources: + self.reverse.setdefault(source, {})[owner] = None + + def get(self, identity, default=()): + dependents = self.reverse.get(identity) + return sorted(dependents, key=self.order.__getitem__) if dependents else default + + def reorder(self, datafile: dict) -> None: + self.order = {(kind, key): rank for name, kind in COLLECTIONS + for rank, key in enumerate(datafile.get(name) or {})} + self.next_order = max(self.order.values(), default=-1) + 1 + + +def create_datafile_dependency_index(datafile: dict[str, Any]) -> DatafileDependencyIndex: + index = DatafileDependencyIndex() + index.update(datafile, datafile) + return index + + +def add_datafile_dependency_changes(features, variables, changed_segments, index) -> None: + kinds = ("feature", "variable", "segment") + affected = [dict.fromkeys(features), dict.fromkeys(variables), dict.fromkeys(changed_segments)] + queues = [list(values) for values in affected] + cursors = [0, 0, 0] + while True: + advanced = False + for source, kind in enumerate(kinds): + if cursors[source] >= len(queues[source]): + continue + advanced = True + key = queues[source][cursors[source]] + cursors[source] += 1 + for target_kind, target_key in index.get((kind, key), ()): + target = kinds.index(target_kind) + if target_key not in affected[target]: + affected[target][target_key] = None + queues[target].append(target_key) + if not advanced: + break + features[:] = affected[0] + variables[:] = affected[1] diff --git a/src/featurevisor/diagnostics.py b/src/featurevisor/diagnostics.py index 6d9cd23..9cf2f83 100644 --- a/src/featurevisor/diagnostics.py +++ b/src/featurevisor/diagnostics.py @@ -4,13 +4,13 @@ LOG_LEVELS = ["fatal", "error", "warn", "info", "debug"] DEFAULT_LOG_LEVEL = "info" +_LOG_LEVEL_INDEX = {level: index for index, level in enumerate(LOG_LEVELS)} def should_report(configured_level: str, diagnostic_level: str) -> bool: - try: - return LOG_LEVELS.index(configured_level) >= LOG_LEVELS.index(diagnostic_level) - except ValueError: - return False + configured_index = _LOG_LEVEL_INDEX.get(configured_level) + diagnostic_index = _LOG_LEVEL_INDEX.get(diagnostic_level) + return configured_index is not None and diagnostic_index is not None and configured_index >= diagnostic_index def write_diagnostic_to_console(diagnostic: dict[str, Any]) -> None: @@ -20,10 +20,17 @@ def write_diagnostic_to_console(diagnostic: dict[str, Any]) -> None: class _EvaluationDiagnostics: """Evaluator adapter that emits structured diagnostics directly.""" - def __init__(self, report: Callable[[dict[str, Any]], None]) -> None: + def __init__( + self, + report: Callable[[dict[str, Any]], None], + is_interested: Callable[[str], bool] | None = None, + ) -> None: self._report = report + self._is_interested = is_interested def _emit(self, level: str, message: str, details: dict[str, Any] | None = None) -> None: + if self._is_interested is not None and not self._is_interested(level): + return details = dict(details or {}) explicit_code = details.pop("code", None) original_error = details.pop("originalError", details.pop("error", None)) diff --git a/src/featurevisor/evaluate.py b/src/featurevisor/evaluate.py index 18e5121..1d28604 100644 --- a/src/featurevisor/evaluate.py +++ b/src/featurevisor/evaluate.py @@ -1,18 +1,16 @@ from __future__ import annotations -import json from enum import Enum from typing import Any from .bucketer import get_bucket_key, get_bucketed_number +from ._evaluation_session import EvaluationSession, RequirementInputError, dependency_of, refresh_evaluation_state class EvaluationReason(str, Enum): FEATURE_NOT_FOUND = "feature_not_found" DISABLED = "disabled" - REQUIRED = "required" - REQUIRED_FEATURES_UNMET = "required_features_unmet" - OUT_OF_RANGE = "out_of_range" + CONDITIONS_UNMET = "conditions_unmet" NO_VARIATIONS = "no_variations" VARIATION_DISABLED = "variation_disabled" VARIABLE_NOT_FOUND = "variable_not_found" @@ -20,6 +18,7 @@ class EvaluationReason(str, Enum): VARIABLE_DISABLED = "variable_disabled" VARIABLE_OVERRIDE_VARIATION = "variable_override_variation" VARIABLE_OVERRIDE_RULE = "variable_override_rule" + VARIABLE_COMPOSED = "variable_composed" NO_MATCH = "no_match" FORCED = "forced" STICKY = "sticky" @@ -32,28 +31,60 @@ def __str__(self) -> str: def evaluate_with_modules(options: dict[str, Any]) -> dict[str, Any]: + session: EvaluationSession = options.get("_evaluationSession") or EvaluationSession() + options = {**options, "_evaluationSession": session} + refresh_evaluation_state(options) + cache_key = None try: - modules_manager = options["modulesManager"] - current_options = modules_manager.run_before_modules(options) - current_options = modules_manager.run_before_evaluation_modules(current_options) - evaluation = evaluate(current_options) - if ( - "defaultVariationValue" in current_options - and evaluation["type"] == "variation" - and "variationValue" not in evaluation - and "variation" not in evaluation - ): - evaluation["variationValue"] = current_options["defaultVariationValue"] - if ( - "defaultVariableValue" in current_options - and evaluation["type"] == "variable" - and "variableValue" not in evaluation - ): - evaluation["variableValue"] = current_options["defaultVariableValue"] - evaluation = modules_manager.run_after_evaluation_modules(evaluation, current_options) - evaluation = modules_manager.run_after_modules(evaluation, current_options) - return evaluation + with session.frame(options): + session.retain(options) + cache_key = session.key(options) + cached = session.get(cache_key) + if cached is not None: + return cached + modules_manager = options["modulesManager"] + module_options = None + current_options = options + if modules_manager.modules: + private = {key: value for key, value in options.items() + if key == "_evaluationSession" or key.startswith("__featurevisor_")} + module_options = {key: value for key, value in options.items() if key not in private} + module_options = modules_manager.run_before_evaluation_modules(module_options, session.retain) + current_options = {**module_options, **private} + session.retain(current_options) + session.update(current_options) + refresh_evaluation_state(current_options) + evaluation = evaluate(current_options) + if isinstance(evaluation.get("error"), RequirementInputError): + raise evaluation["error"] + if ( + "defaultVariationValue" in current_options + and evaluation["type"] == "variation" + and "variationValue" not in evaluation + and "variation" not in evaluation + ): + evaluation["variationValue"] = current_options["defaultVariationValue"] + if ( + "defaultVariableValue" in current_options + and evaluation["type"] == "variable" + and "variableValue" not in evaluation + ): + evaluation["variableValue"] = current_options["defaultVariableValue"] + # Evaluation callbacks may install a module after the initial + # empty-list check. Its after hook must still see public options. + if modules_manager.modules: + if module_options is None: + module_options = {key: value for key, value in options.items() + if key != "_evaluationSession" and not key.startswith("__featurevisor_")} + evaluation = modules_manager.run_after_evaluation_modules(evaluation, module_options) + if cache_key == session.key(options): + snapshot = session.put(cache_key, evaluation) + if snapshot is not None: + return snapshot + return evaluation except Exception as exc: + if isinstance(exc, RecursionError): + exc = RequirementInputError("Host recursion limit exceeded during dependency evaluation") evaluation = { "type": options["type"], "featureKey": options["featureKey"], @@ -61,64 +92,20 @@ def evaluate_with_modules(options: dict[str, Any]) -> dict[str, Any]: "reason": EvaluationReason.ERROR, "error": exc, } - options["diagnostics"].error("Error during evaluation", evaluation) + # A dependency failure fails the complete public call. Nested getters raise it + # again, so only the call that started the session reports it. + if options.get("dependencyOf") is None or not isinstance(exc, RequirementInputError): + options["diagnostics"].error("Error during evaluation", evaluation) + if cache_key == session.key(options): + session.put(cache_key, evaluation) return evaluation -def _normalise_requirements(requirements: Any) -> list[Any]: - if requirements is None: - return [] - return requirements if isinstance(requirements, list) else [requirements] - - -def _required_features_are_matched(requirements: Any, options: dict[str, Any]) -> bool: - for required in _normalise_requirements(requirements): - if isinstance(required, str): - feature_key, expected_enabled, expected_variation = required, True, None - elif "feature" in required: - feature_key = required["feature"] - expected_enabled = required.get("enabled", True) - expected_variation = required.get("variation") - else: - feature_key = required["key"] - expected_enabled = True - expected_variation = required.get("variation") - - nested = { - key: value - for key, value in options.items() - if key not in {"type", "featureKey", "variableKey", "defaultVariationValue", "defaultVariableValue"} - } - flag = evaluate_with_modules({**nested, "type": "flag", "featureKey": feature_key}) - if (flag.get("enabled") is True) != expected_enabled: - return False - if expected_variation is not None: - variation = evaluate_with_modules({**nested, "type": "variation", "featureKey": feature_key}) - actual = variation.get("variationValue") - if actual is None and variation.get("variation"): - actual = variation["variation"].get("value") - if actual != expected_variation: - return False - return True - - def _find_override_index(overrides: list[dict[str, Any]], context: dict[str, Any], datafile, options: dict[str, Any]) -> int: for index, override in enumerate(overrides): - if not _required_features_are_matched(override.get("requiredFeatures"), options): + if "conditions" not in override or not datafile.conditions_are_matched(override["conditions"], options): continue - conditions_match = True - segments_match = True - if override.get("conditions"): - conditions = override["conditions"] - if isinstance(conditions, str) and conditions != "*": - conditions = json.loads(conditions) - conditions_match = datafile.all_conditions_are_matched(conditions, context) - if override.get("segments"): - segments_match = datafile.all_segments_are_matched( - datafile.parse_segments_if_stringified(override["segments"]), context - ) - if conditions_match and segments_match: - return index + return index return -1 @@ -136,6 +123,8 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: try: if type_ != "flag": flag = evaluate({**options, "type": "flag"}) + if isinstance(flag.get("error"), RequirementInputError): + raise flag["error"] if flag.get("enabled") is False: evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.DISABLED} feature = datafile.get_feature(feature_key) @@ -175,7 +164,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: if sticky and sticky.get(feature_key): item = sticky[feature_key] if type_ == "flag" and "enabled" in item: - evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.STICKY, "sticky": item, "enabled": item["enabled"]} + evaluation = {"type": type_, "featureKey": feature_key, "reason": EvaluationReason.STICKY, "stickyFeature": item, "enabled": item["enabled"]} diagnostics.debug("using sticky enabled", evaluation) return evaluation if type_ == "variation" and "variation" in item: @@ -216,7 +205,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: diagnostics.warn("no variations", evaluation) return evaluation - force_result = datafile.get_matched_force(feature, context) + force_result = datafile.get_matched_force(feature, context, options) force = force_result.get("force") force_index = force_result.get("forceIndex") if force: @@ -244,19 +233,14 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: diagnostics.debug("forced variable", evaluation) return evaluation - required_features = feature.get("requiredFeatures") or feature.get("required") - if type_ == "flag" and required_features: - if not _required_features_are_matched(required_features, options): - evaluation = { - "type": type_, - "featureKey": feature_key, - "reason": EvaluationReason.REQUIRED, - "required": feature.get("required"), - "requiredFeatures": feature.get("requiredFeatures"), - "enabled": False, - } - diagnostics.debug("required features not enabled", evaluation) - return evaluation + if type_ == "flag" and "conditions" in feature and not datafile.conditions_are_matched(feature["conditions"], options): + evaluation = { + "type": type_, "featureKey": feature_key, + "reason": EvaluationReason.CONDITIONS_UNMET, + "conditions": feature["conditions"], "enabled": False, + } + diagnostics.debug("root conditions did not match", evaluation) + return evaluation bucket_key = get_bucket_key(featureKey=feature_key, bucketBy=feature["bucketBy"], context=context, diagnostics=diagnostics) bucket_key = modules_manager.run_bucket_key_modules( @@ -267,7 +251,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: {"featureKey": feature_key, "bucketKey": bucket_key, "context": context, "bucketValue": bucket_value} ) - matched_traffic = datafile.get_matched_traffic(feature["traffic"], context) + matched_traffic = datafile.get_matched_traffic(feature["traffic"], context, options) matched_allocation = datafile.get_matched_allocation(matched_traffic, bucket_value) if type_ != "flag" and matched_traffic else None if matched_traffic: @@ -285,31 +269,6 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: diagnostics.debug("matched rule with 0 percentage", evaluation) return evaluation if type_ == "flag": - if feature.get("ranges"): - matched_range = next((range_ for range_ in feature["ranges"] if range_[0] <= bucket_value < range_[1]), None) - if matched_range: - evaluation = { - "type": type_, - "featureKey": feature_key, - "reason": EvaluationReason.ALLOCATED, - "bucketKey": bucket_key, - "bucketValue": bucket_value, - "ruleKey": matched_traffic["key"], - "traffic": matched_traffic, - "enabled": matched_traffic.get("enabled", True), - } - diagnostics.debug("matched", evaluation) - return evaluation - evaluation = { - "type": type_, - "featureKey": feature_key, - "reason": EvaluationReason.OUT_OF_RANGE, - "bucketKey": bucket_key, - "bucketValue": bucket_value, - "enabled": False, - } - diagnostics.debug("not matched", evaluation) - return evaluation if "enabled" in matched_traffic: evaluation = { "type": type_, @@ -480,6 +439,10 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: diagnostics.debug("nothing matched", evaluation) return evaluation except Exception as exc: + if isinstance(exc, RecursionError): + exc = RequirementInputError("Host recursion limit exceeded during dependency evaluation") evaluation = {"type": type_, "featureKey": feature_key, "variableKey": variable_key, "reason": EvaluationReason.ERROR, "error": exc} - diagnostics.error("Error during evaluation", evaluation) + # evaluate_with_modules raises dependency failures again and reports them once. + if not isinstance(exc, RequirementInputError): + diagnostics.error("Error during evaluation", evaluation) return evaluation diff --git a/src/featurevisor/evaluation_data_provider.py b/src/featurevisor/evaluation_data_provider.py index 42c2cf7..6953cef 100644 --- a/src/featurevisor/evaluation_data_provider.py +++ b/src/featurevisor/evaluation_data_provider.py @@ -1,16 +1,36 @@ from __future__ import annotations -import json +import copy import re +from threading import Lock from typing import Any -from .conditions import condition_is_matched +from .conditions import ( + _PreparedCondition, + _PreparedConditionGroup, + condition_is_matched, + normalize_portable_regex, + prepare_conditions, + prepared_condition_is_matched, +) from .diagnostics import _EvaluationDiagnostics +from .dependencies import DatafileDependencyIndex +from ._json import strict_json_loads +from .normalize import InvalidConditions, normalize_entity from .types import Context, DatafileContent, Feature, Force, Segment, Traffic +class _EntitySlot: + __slots__ = ("value",) + + def __init__(self) -> None: + self.value: Any = None + + class _InstanceEvaluationDataProvider: def __init__(self, *, datafile: DatafileContent, diagnostics: _EvaluationDiagnostics) -> None: + helpers: Any = datafile.get("_segment_test_keys", {}) + self.synthetic_keys = set(helpers.values()) self.diagnostics = diagnostics self.schema_version = datafile["schemaVersion"] self.revision = datafile["revision"] @@ -19,6 +39,59 @@ def __init__(self, *, datafile: DatafileContent, diagnostics: _EvaluationDiagnos self.features = datafile.get("features", {}) self.variables = datafile.get("variables", {}) self.regex_cache: dict[str, re.Pattern[str]] = {} + self.expression_cache: dict[str, Any] = {} + self.prepared_expression_cache: dict[str, Any] = {} + self.cached_expression_ids: set[int] = set() + self.prepared_conditions: dict[int, Any] = {} + self.entity_cache: dict[tuple[str, str], Any] = {} + self._entity_lock = Lock() + self._entity_slots: dict[tuple[str, str], _EntitySlot] = {} + self._attribute_selectors: dict[int, tuple[Any, Any]] = {} + self.conditions_are_matched: Any = None + self.dependency_index: DatafileDependencyIndex | None = None + + def _entity(self, kind: str, key: str, definitions): + identity = (kind, key) + # Entries are published once and never replaced within this provider. + # A lookup retains its value even if close clears the cache afterwards. + cached = self.entity_cache.get(identity) + if cached is not None: + return cached + errors = [] + with self._entity_lock: + if identity in self.entity_cache: + return self.entity_cache[identity] + value = definitions.get(key) + if value is None: + return None + slot = self._entity_slots.get(identity) + if slot is None: + slot = self._entity_slots[identity] = _EntitySlot() + if slot.value is None: + slot.value, errors = normalize_entity(kind, key, value) + result = self.entity_cache[identity] = slot.value + # A diagnostic may reenter evaluation or publish another datafile. + for diagnostic in errors: + self.diagnostics._report(diagnostic) + return result + + def retain_entities(self, previous: _InstanceEvaluationDataProvider, incoming: dict) -> None: + # Share individual cold slots, not the previous provider or its datafile. + # All snapshots using those slots also share their synchronization lock. + with previous._entity_lock: + self._entity_lock = previous._entity_lock + for kind, definitions in (("feature", previous.features), ("segment", previous.segments), + ("variable", previous.variables)): + for key in definitions: + if key in incoming[kind]: + continue + identity = (kind, key) + slot = previous._entity_slots.get(identity) + if slot is None: + slot = previous._entity_slots[identity] = _EntitySlot() + self._entity_slots[identity] = slot + self.entity_cache = {identity: value for identity, value in previous.entity_cache.items() + if identity[1] not in incoming[identity[0]]} def get_revision(self) -> str: return self.revision @@ -38,17 +111,50 @@ def get_datafile(self) -> dict[str, Any]: return {key: value for key, value in datafile.items() if value is not None} def get_segment(self, segment_key: str) -> Segment | None: - segment = self.segments.get(segment_key) - if not segment: + return self._entity("segment", segment_key, self.segments) + + def get_attribute_selector(self, conditions: Any) -> Any: + identity = id(conditions) + cached = self._attribute_selectors.get(identity) + if cached is not None: + return cached[1] + with self._entity_lock: + cached = self._attribute_selectors.get(identity) + if cached is None: + prepared = self._prepare_attribute_selector(conditions) + # Retain the syntax itself so its identity cannot be recycled. + self._attribute_selectors[identity] = (conditions, prepared) + return prepared + return cached[1] + + @staticmethod + def _prepare_attribute_selector(conditions: Any) -> Any: + if isinstance(conditions, InvalidConditions): return None - segment["conditions"] = self.parse_conditions_if_stringified(segment["conditions"]) - return segment + operator = "and" + if isinstance(conditions, dict): + if "variable" in conditions or "feature" in conditions: + return None + if "attribute" in conditions: + return prepare_conditions(conditions) + operator = next((name for name in ("and", "or", "not") if name in conditions), "and") + conditions = conditions.get(operator, []) + if not isinstance(conditions, list): + return None + # Unified empty groups are false, including AND. The general attribute + # matcher also supports legacy syntax, whose empty AND is true. + if not conditions: + return _PreparedConditionGroup("or", ()) + items = tuple(_InstanceEvaluationDataProvider._prepare_attribute_selector(item) for item in conditions) + if any(item is None for item in items): + return None + return _PreparedConditionGroup(operator, items) def get_feature_keys(self) -> list[str]: return list(self.features.keys()) def get_feature(self, feature_key: str) -> Feature | None: - return self.features.get(feature_key) + return self._entity("feature", feature_key, self.features) def get_variable_keys(self, feature_key: str | None = None) -> list[str]: if feature_key is None: @@ -57,7 +163,7 @@ def get_variable_keys(self, feature_key: str | None = None) -> list[str]: return list((feature or {}).get("variablesSchema", {}).keys()) def get_global_variable(self, variable_key: str): - return self.variables.get(variable_key) + return self._entity("variable", variable_key, self.variables) def get_segment_keys(self) -> list[str]: return list(self.segments.keys()) @@ -79,21 +185,42 @@ def get_regex(self, regex_string: str, regex_flags: str = "") -> re.Pattern[str] flags |= re.MULTILINE if "s" in regex_flags: flags |= re.DOTALL - self.regex_cache[key] = re.compile(regex_string, flags) + self.regex_cache[key] = re.compile(normalize_portable_regex(regex_string), flags) return self.regex_cache[key] def all_conditions_are_matched(self, conditions: Any, context: Context) -> bool: if isinstance(conditions, str): return conditions == "*" - get_regex = lambda regex_string, regex_flags="": self.get_regex(regex_string, regex_flags) + if isinstance(conditions, _PreparedCondition): + try: + return prepared_condition_is_matched(conditions, context, self.get_regex) + except Exception as exc: + self.diagnostics.warn(str(exc), { + "code": "condition_match_error", + "originalError": exc, + "details": {"condition": copy.deepcopy(conditions.source), "context": context}, + }) + return False + if isinstance(conditions, _PreparedConditionGroup): + if conditions.operator == "and": + return all(self.all_conditions_are_matched(item, context) for item in conditions.items) + if conditions.operator == "or": + return any(self.all_conditions_are_matched(item, context) for item in conditions.items) + return bool(conditions.items) and not all( + self.all_conditions_are_matched(item, context) for item in conditions.items + ) if isinstance(conditions, dict) and "attribute" in conditions: try: - return condition_is_matched(conditions, context, get_regex) + identity = id(conditions) + if identity not in self.prepared_conditions: + self.prepared_conditions[identity] = prepare_conditions(conditions) + return prepared_condition_is_matched(self.prepared_conditions[identity], context, self.get_regex) except Exception as exc: + diagnostic_condition = copy.deepcopy(conditions) self.diagnostics.warn(str(exc), { "code": "condition_match_error", "originalError": exc, - "details": {"condition": conditions, "context": context}, + "details": {"condition": diagnostic_condition, "context": context}, }) return False if isinstance(conditions, dict) and isinstance(conditions.get("and"), list): @@ -101,6 +228,8 @@ def all_conditions_are_matched(self, conditions: Any, context: Context) -> bool: if isinstance(conditions, dict) and isinstance(conditions.get("or"), list): return any(self.all_conditions_are_matched(item, context) for item in conditions["or"]) if isinstance(conditions, dict) and isinstance(conditions.get("not"), list): + if not conditions["not"]: + return False return not self.all_conditions_are_matched({"and": conditions["not"]}, context) if isinstance(conditions, list): return all(self.all_conditions_are_matched(item, context) for item in conditions) @@ -113,21 +242,25 @@ def all_segments_are_matched(self, group_segments: Any, context: Context) -> boo if group_segments == "*": return True if isinstance(group_segments, str): - segment = self.get_segment(group_segments) - return self.segment_is_matched(segment, context) if segment else False + segment = self.segments.get(group_segments) + return self.all_conditions_are_matched( + self.parse_conditions_if_stringified(segment["conditions"]), context + ) if segment else False if isinstance(group_segments, dict) and isinstance(group_segments.get("and"), list): return all(self.all_segments_are_matched(item, context) for item in group_segments["and"]) if isinstance(group_segments, dict) and isinstance(group_segments.get("or"), list): return any(self.all_segments_are_matched(item, context) for item in group_segments["or"]) if isinstance(group_segments, dict) and isinstance(group_segments.get("not"), list): + if not group_segments["not"]: + return False return not self.all_segments_are_matched({"and": group_segments["not"]}, context) if isinstance(group_segments, list): return all(self.all_segments_are_matched(item, context) for item in group_segments) return False - def get_matched_traffic(self, traffic: list[Traffic], context: Context) -> Traffic | None: + def get_matched_traffic(self, traffic: list[Traffic], context: Context, options=None) -> Traffic | None: for rule in traffic: - if self.all_segments_are_matched(self.parse_segments_if_stringified(rule["segments"]), context): + if "conditions" in rule and self.conditions_are_matched(rule["conditions"], options): return rule return None @@ -138,27 +271,45 @@ def get_matched_allocation(self, traffic: Traffic, bucket_value: int): return allocation return None - def get_matched_force(self, feature_or_key: str | Feature, context: Context) -> dict[str, Any]: + def get_matched_force(self, feature_or_key: str | Feature, context: Context, options=None) -> dict[str, Any]: feature = self.get_feature(feature_or_key) if isinstance(feature_or_key, str) else feature_or_key if not feature or not feature.get("force"): return {"force": None, "forceIndex": None} for index, current_force in enumerate(feature["force"]): - if current_force.get("conditions") and self.all_conditions_are_matched( - self.parse_conditions_if_stringified(current_force["conditions"]), context - ): - return {"force": current_force, "forceIndex": index} - if current_force.get("segments") and self.all_segments_are_matched( - self.parse_segments_if_stringified(current_force["segments"]), context - ): + if "conditions" in current_force and self.conditions_are_matched(current_force["conditions"], options): return {"force": current_force, "forceIndex": index} return {"force": None, "forceIndex": None} def parse_conditions_if_stringified(self, conditions: Any) -> Any: + return self._parse_conditions_if_stringified(conditions, cache=True) + + def parse_conditions_if_stringified_strict(self, conditions: Any) -> Any: + return self._parse_conditions_if_stringified(conditions, cache=True, raise_errors=True) + + def _parse_conditions_if_stringified( + self, + conditions: Any, + *, + cache: bool, + raise_errors: bool = False, + ) -> Any: if not isinstance(conditions, str) or conditions == "*": return conditions + if cache and conditions in self.expression_cache: + if conditions not in self.prepared_expression_cache: + self.prepared_expression_cache[conditions] = prepare_conditions(self.expression_cache[conditions]) + return self.prepared_expression_cache[conditions] try: - return json.loads(conditions) + parsed = strict_json_loads(conditions) + if cache: + self._cache_expression(conditions, parsed) + prepared = prepare_conditions(parsed) + self.prepared_expression_cache[conditions] = prepared + return prepared + return parsed except Exception as exc: + if raise_errors: + raise self.diagnostics.error("Error parsing conditions", { "code": "conditions_parse_error", "originalError": exc, @@ -168,9 +319,38 @@ def parse_conditions_if_stringified(self, conditions: Any) -> Any: def parse_segments_if_stringified(self, segments: Any) -> Any: if isinstance(segments, str) and segments[:1] in {"{", "["}: - return json.loads(segments) + if segments not in self.expression_cache: + self._cache_expression(segments, strict_json_loads(segments)) + return self.expression_cache[segments] return segments + def _cache_expression(self, source: str, parsed: Any) -> None: + self.expression_cache[source] = parsed + + def record_ids(value: Any) -> None: + if isinstance(value, dict): + self.cached_expression_ids.add(id(value)) + for item in value.values(): + record_ids(item) + elif isinstance(value, list): + self.cached_expression_ids.add(id(value)) + for item in value: + record_ids(item) + + record_ids(parsed) + + def close(self) -> None: + self.dependency_index = None + with self._entity_lock: + self.entity_cache.clear() + self._entity_slots.clear() + self._attribute_selectors.clear() + self.prepared_conditions.clear() + self.expression_cache.clear() + self.prepared_expression_cache.clear() + self.cached_expression_ids.clear() + self.regex_cache.clear() + getRevision = get_revision getSchemaVersion = get_schema_version getDatafile = get_datafile diff --git a/src/featurevisor/events.py b/src/featurevisor/events.py index c778783..d118e84 100644 --- a/src/featurevisor/events.py +++ b/src/featurevisor/events.py @@ -1,7 +1,6 @@ from __future__ import annotations -import json - +from .dependencies import add_datafile_dependency_changes, create_datafile_dependency_index from .evaluation_data_provider import _InstanceEvaluationDataProvider @@ -21,62 +20,62 @@ def get_params_for_sticky_variables_set_event(previous: dict | None = None, new: return {"variables": list(dict.fromkeys(keys)), "replaced": replace} -def get_params_for_datafile_set_event(previous_datafile: _InstanceEvaluationDataProvider, new_datafile: _InstanceEvaluationDataProvider, replace: bool = False) -> dict: +def get_params_for_datafile_set_event(previous_datafile: _InstanceEvaluationDataProvider, new_datafile: _InstanceEvaluationDataProvider, replace: bool = False, incoming: dict | None = None) -> dict: previous_revision = previous_datafile.get_revision() previous_feature_keys = previous_datafile.get_feature_keys() new_revision = new_datafile.get_revision() new_feature_keys = new_datafile.get_feature_keys() - removed_features = [key for key in previous_feature_keys if key not in new_feature_keys] + # An empty instance already announces every new feature and variable. + # The first actual update pays the initial graph extraction. + if not previous_feature_keys and not previous_datafile.variables and not previous_datafile.segments: + return { + "revision": new_revision, + "previousRevision": previous_revision, + "revisionChanged": previous_revision != new_revision, + "features": new_feature_keys, + "variables": new_datafile.get_variable_keys(), + "replaced": replace, + } + removed_features = [key for key in previous_feature_keys if key not in new_datafile.features] changed_features = [ key for key in previous_feature_keys - if key in new_feature_keys + if key in new_datafile.features and ( - not (previous_datafile.get_feature(key) or {}).get("hash") - or not (new_datafile.get_feature(key) or {}).get("hash") - or (previous_datafile.get_feature(key) or {}).get("hash") != (new_datafile.get_feature(key) or {}).get("hash") + not (previous_datafile.features.get(key) or {}).get("hash") + or not (new_datafile.features.get(key) or {}).get("hash") + or (previous_datafile.features.get(key) or {}).get("hash") != (new_datafile.features.get(key) or {}).get("hash") ) ] - added_features = [key for key in new_feature_keys if key not in previous_feature_keys] - features = [] - for key in removed_features + changed_features + added_features: - if key not in features: - features.append(key) + added_features = [key for key in new_feature_keys if key not in previous_datafile.features] + features = list(dict.fromkeys(removed_features + changed_features + added_features)) previous_variable_keys = previous_datafile.get_variable_keys() new_variable_keys = new_datafile.get_variable_keys() variables = [ key for key in dict.fromkeys(previous_variable_keys + new_variable_keys) - if _entity_changed(previous_datafile.get_global_variable(key), new_datafile.get_global_variable(key)) + if _entity_changed(previous_datafile.variables.get(key), new_datafile.variables.get(key)) ] + segment_keys = dict.fromkeys(previous_datafile.get_segment_keys() + new_datafile.get_segment_keys()) changed_segments = [ - key for key in dict.fromkeys(previous_datafile.get_segment_keys() + new_datafile.get_segment_keys()) - if previous_datafile.get_segment(key) != new_datafile.get_segment(key) + key for key in segment_keys + if previous_datafile.segments.get(key) is not new_datafile.segments.get(key) + and previous_datafile.segments.get(key) != new_datafile.segments.get(key) ] - feature_keys = list(dict.fromkeys(previous_feature_keys + new_feature_keys)) - while True: - before = len(features) - for key in feature_keys: - if key in features: - continue - candidates = [value for value in (previous_datafile.get_feature(key), new_datafile.get_feature(key)) if value] - for feature in candidates: - segments, required = _feature_dependencies(feature) - if set(segments) & set(changed_segments) or set(required) & set(features): - features.append(key) - break - if len(features) == before: - break - - for key in dict.fromkeys(previous_variable_keys + new_variable_keys): - if key in variables: - continue - candidates = [value for value in (previous_datafile.get_global_variable(key), new_datafile.get_global_variable(key)) if value] - for variable in candidates: - segments, required = _global_variable_dependencies(variable) - if set(segments) & set(changed_segments) or set(required) & set(features): - variables.append(key) - break + index = previous_datafile.dependency_index + if index is None: + index = create_datafile_dependency_index(previous_datafile.get_datafile()) + next_datafile = new_datafile.get_datafile() + if replace or incoming is None: + incoming = {name: dict.fromkeys(list(getattr(previous_datafile, name)) + list(next_datafile[name])) + for name in ("segments", "features", "variables")} + # Preserve graph generation order rather than inventing union paths. + add_datafile_dependency_changes(features, variables, changed_segments, index) + index.update(next_datafile, incoming, previous_datafile.get_datafile()) + if replace: + index.reorder(next_datafile) + add_datafile_dependency_changes(features, variables, changed_segments, index) + new_datafile.dependency_index = index return { "revision": new_revision, "previousRevision": previous_revision, @@ -91,60 +90,3 @@ def _entity_changed(previous, current) -> bool: if previous is None or current is None: return True return not previous.get("hash") or not current.get("hash") or previous.get("hash") != current.get("hash") - - -def _required_feature_keys(values) -> list[str]: - values = values if isinstance(values, list) else ([values] if values is not None else []) - return [value if isinstance(value, str) else value.get("feature") or value.get("key") for value in values] - - -def _segment_keys(value) -> list[str]: - if value is None or value == "*": - return [] - if isinstance(value, str): - if value[:1] in {"{", "["}: - try: - return _segment_keys(json.loads(value)) - except ValueError: - return [] - return [value] - if isinstance(value, list): - return list(dict.fromkeys(key for item in value for key in _segment_keys(item))) - if isinstance(value, dict): - return list(dict.fromkeys(key for operator in ("and", "or", "not") for key in _segment_keys(value.get(operator)))) - return [] - - -def _override_dependencies(groups) -> tuple[list[str], list[str]]: - overrides = [override for values in (groups or {}).values() for override in values] - return ( - list(dict.fromkeys(key for override in overrides for key in _segment_keys(override.get("segments")))), - list(dict.fromkeys(key for override in overrides for key in _required_feature_keys(override.get("requiredFeatures")))), - ) - - -def _feature_dependencies(feature) -> tuple[list[str], list[str]]: - requirements = feature.get("requiredFeatures") if "requiredFeatures" in feature else feature.get("required") - segments: list[str] = [] - required = _required_feature_keys(requirements) - for traffic in feature.get("traffic", []): - segments.extend(_segment_keys(traffic.get("segments"))) - nested_segments, nested_required = _override_dependencies(traffic.get("variableOverrides")) - segments.extend(nested_segments) - required.extend(nested_required) - for force in feature.get("force", []): - segments.extend(_segment_keys(force.get("segments"))) - for variation in feature.get("variations", []): - nested_segments, nested_required = _override_dependencies(variation.get("variableOverrides")) - segments.extend(nested_segments) - required.extend(nested_required) - return list(dict.fromkeys(segments)), list(dict.fromkeys(required)) - - -def _global_variable_dependencies(variable) -> tuple[list[str], list[str]]: - segments: list[str] = [] - required = _required_feature_keys(variable.get("requiredFeatures")) - for override in variable.get("overrides", []): - segments.extend(_segment_keys(override.get("segments"))) - required.extend(_required_feature_keys(override.get("requiredFeatures"))) - return list(dict.fromkeys(segments)), list(dict.fromkeys(required)) diff --git a/src/featurevisor/helpers.py b/src/featurevisor/helpers.py index 053f2f6..b466823 100644 --- a/src/featurevisor/helpers.py +++ b/src/featurevisor/helpers.py @@ -16,7 +16,7 @@ def get_value_by_type(value: Any, field_type: str) -> Any: if isinstance(value, float) and value.is_integer(): return int(value) return None - if field_type == "double": + if field_type == "number": if isinstance(value, bool) or not isinstance(value, (int, float)): return None result = float(value) diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index a6cfcdd..377f8e6 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -1,16 +1,21 @@ from __future__ import annotations -import json import uuid +from threading import Lock from typing import Any, cast from .child import FeaturevisorChildInstance +from ._evaluation_session import DependencySnapshot, EvaluationSession, RequirementInputError, dependency_of, refresh_evaluation_state from .evaluation_data_provider import _InstanceEvaluationDataProvider from .emitter import Emitter from .evaluate import evaluate_with_modules from .events import get_params_for_datafile_set_event, get_params_for_sticky_features_set_event, get_params_for_sticky_variables_set_event from .helpers import get_value_by_type +from ._json import strict_json_loads +from .conditions import MISSING, value_is_matched, _strict_equal +from .normalize import normalize_datafile, InvalidConditions, ConditionsParseFailure from .diagnostics import DEFAULT_LOG_LEVEL, LOG_LEVELS, _EvaluationDiagnostics, should_report, write_diagnostic_to_console +from .mutations import _array_index, apply_variable_operations from .modules import FeaturevisorModule, ModulesManager from .types import DatafileContent, LogLevel @@ -27,9 +32,15 @@ def __init__(self, options: dict[str, Any] | None = None) -> None: self.sticky_features = options.get("stickyFeatures") self.sticky_variables = options.get("stickyVariables") self.closed = False + self._state_version = 0 + self._datafile_lock = Lock() self.module_diagnostic_subscriptions: list[dict[str, Any]] = [] - self.evaluation_diagnostics = _EvaluationDiagnostics(self.report_diagnostic) + self.evaluation_diagnostics = _EvaluationDiagnostics( + self.report_diagnostic, + self._diagnostic_is_observed, + ) self.datafile = _InstanceEvaluationDataProvider(datafile=empty_datafile, diagnostics=self.evaluation_diagnostics) + self.datafile.conditions_are_matched = self._conditions_are_matched self.modules_manager = ModulesManager( modules=options.get("modules") or [], report_diagnostic=self.report_diagnostic, @@ -53,12 +64,13 @@ def set_log_level(self, level: LogLevel) -> None: raise ValueError("Invalid log level") self.log_level = level - def set_datafile(self, datafile, replace: bool = False) -> None: + def set_datafile(self, datafile, replace: bool = False) -> bool: + """Return whether the datafile was accepted, even if unchanged.""" if self.closed: - return + return False try: - parsed = json.loads(datafile) if isinstance(datafile, str) else datafile + parsed = strict_json_loads(datafile) if isinstance(datafile, str) else datafile if not ( isinstance(parsed, dict) and isinstance(parsed.get("schemaVersion"), str) @@ -68,20 +80,39 @@ def set_datafile(self, datafile, replace: bool = False) -> None: and (parsed.get("variables") is None or isinstance(parsed.get("variables"), dict)) ): raise ValueError("Invalid datafile") - next_datafile = parsed if replace else self._merge_datafiles(self.datafile.get_datafile(), parsed) - new_datafile = _InstanceEvaluationDataProvider(datafile=cast(DatafileContent, next_datafile), diagnostics=self.evaluation_diagnostics) - details = get_params_for_datafile_set_event(self.datafile, new_datafile, replace) - self.datafile = new_datafile + errors = [] + if parsed["schemaVersion"] == "2": + parsed, errors = normalize_datafile(parsed) + # Serialize read, merge and publication, but never application callbacks. + with self._datafile_lock: + if self.closed: + return False + previous_datafile = self.datafile + next_datafile = parsed if replace else self._merge_datafiles(previous_datafile.get_datafile(), parsed) + new_datafile = _InstanceEvaluationDataProvider(datafile=cast(DatafileContent, next_datafile), diagnostics=self.evaluation_diagnostics) + new_datafile.conditions_are_matched = self._conditions_are_matched + if not replace: + incoming = {"feature": parsed.get("features", {}), "segment": parsed.get("segments", {}), + "variable": parsed.get("variables") or {}} + new_datafile.retain_entities(previous_datafile, incoming) + details = get_params_for_datafile_set_event(previous_datafile, new_datafile, replace, parsed) + self.datafile = new_datafile + self._state_version += 1 + for diagnostic in errors: + self.report_diagnostic(diagnostic) self.report_diagnostic({"level": "info", "code": "datafile_set", "message": "Datafile set", "details": details}) self.emitter.trigger("datafile_set", details) + return True except Exception as exc: self.report_diagnostic({"level": "error", "code": "invalid_datafile", "message": "Could not parse datafile", "originalError": exc}) + return False def set_sticky_features(self, sticky: dict[str, Any], replace: bool = False) -> None: if self.closed: return previous = self.sticky_features or {} self.sticky_features = dict(sticky) if replace else {**(self.sticky_features or {}), **sticky} + self._state_version += 1 params = get_params_for_sticky_features_set_event(previous, self.sticky_features, replace) self.report_diagnostic({"level": "info", "code": "sticky_features_set", "message": "Sticky features set", "details": params}) self.emitter.trigger("sticky_features_set", params) @@ -91,6 +122,7 @@ def set_sticky_variables(self, sticky: dict[str, Any], replace: bool = False) -> return previous = self.sticky_variables or {} self.sticky_variables = dict(sticky) if replace else {**(self.sticky_variables or {}), **sticky} + self._state_version += 1 params = get_params_for_sticky_variables_set_event(previous, self.sticky_variables, replace) self.report_diagnostic({"level": "info", "code": "sticky_variables_set", "message": "Sticky variables set", "details": params}) self.emitter.trigger("sticky_variables_set", params) @@ -119,12 +151,16 @@ def get_feature(self, feature_key: str): def add_module(self, module: dict[str, Any] | FeaturevisorModule): if self.closed: return None - return self.modules_manager.add(module) + added = self.modules_manager.add(module) + if added is not None: + self._state_version += 1 + return added def remove_module(self, name: str) -> None: if self.closed: return self.modules_manager.remove(name) + self._state_version += 1 def on(self, event_name, callback): if self.closed: @@ -132,17 +168,20 @@ def on(self, event_name, callback): return self.emitter.on(event_name, callback) def close(self) -> None: - if self.closed: - return - self.closed = True + with self._datafile_lock: + if self.closed: + return + self.closed = True self.modules_manager.close_all() self.module_diagnostic_subscriptions = [] + self.datafile.close() self.emitter.clear_all() def set_context(self, context: dict[str, Any], replace: bool = False) -> None: if self.closed: return self.context = dict(context) if replace else {**self.context, **context} + self._state_version += 1 self.emitter.trigger("context_set", {"context": self.context, "replaced": replace}) self.report_diagnostic({ "level": "debug", @@ -165,18 +204,35 @@ def spawn(self, context: dict[str, Any] | None = None, options: dict[str, Any] | def _get_evaluation_dependencies(self, context: dict[str, Any], options: dict[str, Any] | None = None) -> dict[str, Any]: options = options or {} + child_version = options.get("__featurevisor_child_state_version") + state_version = (lambda: (self._state_version, child_version())) if callable(child_version) else (lambda: self._state_version) + generation = state_version() + child_source = options.get("__featurevisor_child_sticky_source") or (lambda: (self.sticky_features, self.sticky_variables)) + child_initial = options.get("__featurevisor_child_initial_sticky", (self.sticky_features, self.sticky_variables)) + sticky_features = options.get("__featurevisor_child_sticky_features", self.sticky_features) + sticky_variables = options.get("__featurevisor_child_sticky_variables", self.sticky_variables) dependencies = { - "context": self.get_context(context), + "context": context if options.get("__featurevisor_context_is_effective") else self.get_context(context), "diagnostics": self.evaluation_diagnostics, "reportDiagnostic": self.report_diagnostic, "modulesManager": self.modules_manager, "datafile": self.datafile, - "stickyFeatures": ( - options["__featurevisor_child_sticky_features"] - if "__featurevisor_child_sticky_features" in options - else self.sticky_features - ), + "stickyFeatures": sticky_features, + "stickyVariables": sticky_variables, + "_evaluationSession": options.get("__featurevisor_session") or EvaluationSession(), + "__featurevisor_state_version": state_version, + "__featurevisor_child_state_version": child_version, + "__featurevisor_child_sticky_source": child_source, + "__featurevisor_child_initial_sticky": child_initial, + "__featurevisor_datafile_source": lambda: self.datafile, + "__featurevisor_initial_datafile": self.datafile, } + # Fresh root inputs already carry current owner maps. Inherited child + # and dependency inputs still need their identity based refresh below. + if ("__featurevisor_child_sticky_source" not in options + or options.get("__featurevisor_refreshed_state_version") == generation): + dependencies["__featurevisor_refreshed_state_version"] = generation + refresh_evaluation_state(dependencies) if "defaultVariationValue" in options: dependencies["defaultVariationValue"] = options["defaultVariationValue"] if "defaultVariableValue" in options: @@ -225,9 +281,9 @@ def get_variable(self, feature_or_variable_key: str, variable_key_or_context=Non evaluation = self.evaluate_variable(feature_or_variable_key, variable_key_or_context, context_or_options, options) if "variableValue" in evaluation: value = evaluation.get("variableValue") - variable_type = evaluation.get("variableSchema", {}).get("type") or evaluation.get("variable", {}).get("type") - if variable_type == "json" and isinstance(value, str): - return json.loads(value) + variable_type = (evaluation.get("variableSchema") or {}).get("type") or (evaluation.get("variable") or {}).get("type") + if variable_type == "json" and isinstance(value, str) and "compose" not in (evaluation.get("variable") or {}): + return strict_json_loads(value) return value return None except Exception as exc: @@ -243,8 +299,9 @@ def get_variable_string(self, *args): def get_variable_integer(self, *args): return get_value_by_type(self.get_variable(*args), "integer") - def get_variable_double(self, *args): - return get_value_by_type(self.get_variable(*args), "double") + def get_variable_number(self, *args): + """Return a number variable, whole or fractional, as a float.""" + return get_value_by_type(self.get_variable(*args), "number") def get_variable_array(self, *args): return get_value_by_type(self.get_variable(*args), "array") @@ -255,98 +312,358 @@ def get_variable_object(self, *args): def get_variable_json(self, *args): return get_value_by_type(self.get_variable(*args), "json") - def _required_features_are_matched(self, requirements, context, options) -> bool: - items = requirements if isinstance(requirements, list) else ([requirements] if requirements is not None else []) - clean_options = { - key: value - for key, value in (options or {}).items() - if key not in {"defaultVariationValue", "defaultVariableValue"} - } - for required in items: - if isinstance(required, str): - feature_key, expected_enabled, expected_variation = required, True, None - else: - feature_key = required["feature"] - expected_enabled = required.get("enabled", True) - expected_variation = required.get("variation") - if self.is_enabled(feature_key, context, clean_options) != expected_enabled: - return False - if expected_variation is not None and self.get_variation(feature_key, context, clean_options) != expected_variation: + def _conditions_are_matched(self, conditions, dependencies) -> bool: + try: + return self._match_conditions(conditions, dependencies) + except ConditionsParseFailure: + return False + + def _match_conditions(self, conditions, dependencies) -> bool: + if isinstance(conditions, InvalidConditions): + raise ConditionsParseFailure() + if conditions == "*": + return True + if isinstance(conditions, str): + refresh_evaluation_state(dependencies) + session = dependencies["_evaluationSession"] + provider = dependencies["datafile"] + segment = provider.get_segment(conditions) + if segment is None: return False - return True + prepared = provider.get_attribute_selector(segment.get("conditions")) + if prepared is not None: + return provider.all_conditions_are_matched(prepared, dependencies["context"]) + if conditions in session.segments: + raise RequirementInputError("Circular dependency") + if len(session.segments) + len(session.active) - 1 >= 256: + raise RequirementInputError("Evaluation dependency depth exceeded") + session.segments.add(conditions) + try: + return self._match_conditions(segment.get("conditions"), dependencies) + finally: + session.segments.remove(conditions) + if isinstance(conditions, dict): + if "variable" in conditions: + return self._variable_condition_is_matched(conditions, dependencies) + if "feature" in conditions: + flag = self._evaluate_dependency(conditions, dependencies, "flag") + if (flag.get("enabled") is True) != conditions.get("enabled", True): + return False + if "variation" in conditions: + variation = self._evaluate_dependency(conditions, dependencies, "variation") + actual = variation.get("variationValue", (variation.get("variation") or {}).get("value")) + return _strict_equal(actual, conditions["variation"]) + return True + if "attribute" in conditions: + return dependencies["datafile"].all_conditions_are_matched(conditions, dependencies["context"]) + for operator in ("and", "or", "not"): + if isinstance(conditions.get(operator), list): + items = conditions[operator] + if not items: + return False + if operator == "or": + return any(self._match_conditions(item, dependencies) for item in items) + result = all(self._match_conditions(item, dependencies) for item in items) + return not result if operator == "not" else result + if isinstance(conditions, list): + return bool(conditions) and all(self._match_conditions(item, dependencies) for item in conditions) + return False + + def _evaluate_dependency(self, reference, dependencies, type_, purpose="condition"): + session = dependencies["_evaluationSession"] + nested = {key: value for key, value in dependencies.items() + if key not in {"type", "featureKey", "variableKey", "defaultVariableValue", + "defaultVariationValue", "dependencyOf", "dependencyPurpose"}} + if reference.get("feature") is None and type_ == "variable": + result = self._evaluate_global_variable(reference["variable"], dependencies["context"], { + "__featurevisor_session": session, "__dependency_of": session.parent(), + "__dependency_purpose": purpose, "__featurevisor_context_is_effective": True, + "__featurevisor_child_sticky_features": dependencies.get("stickyFeatures"), + "__featurevisor_child_sticky_variables": dependencies.get("stickyVariables"), + "__featurevisor_child_state_version": dependencies.get("__featurevisor_child_state_version"), + "__featurevisor_child_sticky_source": dependencies.get("__featurevisor_child_sticky_source"), + "__featurevisor_child_initial_sticky": dependencies.get("__featurevisor_child_initial_sticky"), + }) + else: + result = evaluate_with_modules({ + **nested, "type": type_, "featureKey": reference["feature"], + **({"variableKey": reference["variable"]} if type_ == "variable" else {}), + "dependencyOf": session.parent(), "dependencyPurpose": purpose, + }) + if isinstance(result.get("error"), RecursionError): + raise RequirementInputError("Host recursion limit exceeded during dependency evaluation") from result["error"] + if isinstance(result.get("error"), RequirementInputError): + raise result["error"] + return result + + def _evaluate_required_variable(self, required: dict[str, Any], dependencies: dict[str, Any], purpose="condition"): + evaluation = self._evaluate_dependency(required, dependencies, "variable", purpose) + if isinstance(evaluation.get("error"), RequirementInputError): + raise evaluation["error"] + if isinstance(evaluation, DependencySnapshot) and hasattr(evaluation, "getter_value"): + return evaluation.getter_value + value = evaluation.get("variableValue") + variable_type = (evaluation.get("variableSchema") or {}).get("type") or (evaluation.get("variable") or {}).get("type") + if variable_type == "json" and isinstance(value, str) and "compose" not in (evaluation.get("variable") or {}): + try: + value = strict_json_loads(value) + except RecursionError as exc: + raise RequirementInputError("Host recursion limit exceeded during dependency evaluation") from exc + except Exception as exc: + details = {"variableKey": required["variable"]} + if "feature" in required: + details["featureKey"] = required["feature"] + self.report_diagnostic({"level": "error", "code": "evaluation_error", "message": "getVariable failed", "originalError": exc, "details": details}) + value = None + if isinstance(evaluation, DependencySnapshot): + evaluation.getter_value = value + return value + + def _variable_condition_is_matched(self, required, dependencies: dict[str, Any]) -> bool: + value = self._evaluate_required_variable(required, dependencies) + if "path" in required: + if not isinstance(required["path"], list): + raise RequirementInputError("Variable requirement path must be compiled") + for part in required["path"]: + if isinstance(part, str) and part in {"__proto__", "prototype", "constructor"}: + raise RequirementInputError("Unsafe variable requirement path") + index = _array_index(part) + if isinstance(part, (int, float)) and not isinstance(part, bool) and index is None: + raise RequirementInputError("Invalid variable requirement path") + if index is not None: + value = value[index] if isinstance(value, list) and index < len(value) else MISSING + elif isinstance(part, str): + if isinstance(value, dict): + value = value[part] if part in value else MISSING + elif isinstance(value, list) and part == "length": + value = len(value) + elif ( + isinstance(value, list) + and part.isdigit() + and str(int(part)) == part + and int(part) < len(value) + ): + value = value[int(part)] + else: + value = MISSING + else: + raise RequirementInputError("Invalid variable requirement path") + try: + return value_is_matched(value, required, self.datafile.get_regex) + except Exception as exc: + raise RequirementInputError(str(exc)) from exc def _evaluate_global_variable(self, variable_key: str, context: dict[str, Any], options: dict[str, Any]): + session: EvaluationSession = options.get("__featurevisor_session") or EvaluationSession() + options = {**options, "__featurevisor_session": session} + identity = {"type": "variable", "variableKey": variable_key} + try: + with session.frame(identity): + return self._evaluate_global_variable_impl(variable_key, context, options, session) + except (RequirementInputError, RecursionError) as exc: + if isinstance(exc, RecursionError): + exc = RequirementInputError("Host recursion limit exceeded during dependency evaluation") + return {**identity, "reason": "error", "error": exc} + + def _evaluate_global_variable_impl(self, variable_key: str, context: dict[str, Any], options: dict[str, Any], session: EvaluationSession): + options = { + **options, + "__featurevisor_child_sticky_source": options.get("__featurevisor_child_sticky_source") or + (lambda: (self.sticky_features, self.sticky_variables)), + "__featurevisor_child_initial_sticky": options.get("__featurevisor_child_initial_sticky", + (self.sticky_features, self.sticky_variables)), + } evaluation_options: dict[str, Any] = { "type": "variable", "variableKey": variable_key, - "context": self.get_context(context), + "context": context if options.get("__featurevisor_context_is_effective") else self.get_context(context), } if "defaultVariableValue" in options: evaluation_options["defaultVariableValue"] = options["defaultVariableValue"] + if "__dependency_of" in options: + evaluation_options["dependencyOf"] = options["__dependency_of"] + evaluation_options["dependencyPurpose"] = options.get("__dependency_purpose", "condition") + child_version = options.get("__featurevisor_child_state_version") + state_version = (lambda: (self._state_version, child_version())) if callable(child_version) else (lambda: self._state_version) + evaluation_options["__featurevisor_state_version"] = state_version + generation = state_version() + session.retain(evaluation_options) + sticky_features = options.get("__featurevisor_child_sticky_features", self.sticky_features) + sticky_variables = options.get("__featurevisor_child_sticky_variables", self.sticky_variables) + child_source = options.get("__featurevisor_child_sticky_source") + child_initial = options.get("__featurevisor_child_initial_sticky") + if callable(child_source) and isinstance(child_initial, tuple): + current_features, current_variables = child_source() + baseline = list(child_initial) + if sticky_features is child_initial[0]: + sticky_features = current_features + baseline[0] = current_features + if sticky_variables is child_initial[1]: + sticky_variables = current_variables + baseline[1] = current_variables + options["__featurevisor_child_initial_sticky"] = tuple(baseline) + # These inherited maps were just refreshed. Reuse that generation only + # if callbacks and diagnostics leave both owners unchanged. + options["__featurevisor_refreshed_state_version"] = generation + # Outer requests cannot reuse a dependency memo entry. + cache_key = session.key({ + **evaluation_options, + "datafile": self.datafile, + "modulesManager": self.modules_manager, + "stickyFeatures": sticky_features, + "stickyVariables": sticky_variables, + }) if "dependencyOf" in evaluation_options else None + cached = session.get(cache_key) + if cached is not None: + return cached + input_options = evaluation_options try: - evaluation_options = self.modules_manager.run_before_evaluation_modules(evaluation_options) + had_modules = bool(self.modules_manager.modules) + if had_modules: + evaluation_options = self.modules_manager.run_before_evaluation_modules( + {key: value for key, value in evaluation_options.items() if not key.startswith("__featurevisor_")}, + session.retain, + ) + session.update(evaluation_options) resolved_key = evaluation_options["variableKey"] resolved_context = evaluation_options["context"] + nested_options = {**options, "__dependency_of": session.parent(), + "__featurevisor_context_is_effective": True} variable = self.datafile.get_global_variable(resolved_key) evaluation: dict[str, Any] = { "type": "variable", "variableKey": resolved_key, "reason": "variable_not_found" } - sticky = options.get("__featurevisor_child_sticky_variables", self.sticky_variables) or {} + dependencies = { + **self._get_evaluation_dependencies(resolved_context, { + **nested_options, "__featurevisor_session": session, + "__featurevisor_child_sticky_features": evaluation_options.get("stickyFeatures", sticky_features), + "__featurevisor_child_sticky_variables": evaluation_options.get("stickyVariables", sticky_variables), + }), + "type": "variable", "variableKey": resolved_key, + } + sticky = dependencies.get("stickyVariables") or {} if resolved_key in sticky: evaluation.update(reason="sticky", variable=variable, variableValue=sticky[resolved_key]) elif variable: - if not self._required_features_are_matched(variable.get("requiredFeatures"), resolved_context, options): - evaluation.update(reason="required_features_unmet", variable=variable) - value_key = "defaultValue" if variable.get("useDefaultWhenDisabled") else "disabledValue" + composed = "compose" in variable + if "conditions" in variable and not self._conditions_are_matched(variable["conditions"], dependencies): + evaluation.update(reason="conditions_unmet", variable=variable, conditions=variable["conditions"]) + value_key = "defaultValue" if not composed and variable.get("useDefaultWhenDisabled") else "disabledValue" if value_key in variable: - evaluation["variableValue"] = variable[value_key] + value = variable[value_key] + evaluation["variableValue"] = dict(value) if isinstance(variable.get("compose"), list) and isinstance(value, dict) else value + elif composed: + evaluation["variableValue"] = None else: - for index, override in enumerate(variable.get("overrides", [])): - if not self._required_features_are_matched(override.get("requiredFeatures"), resolved_context, options): - continue - conditions_match = not override.get("conditions") or self.datafile.all_conditions_are_matched( - self.datafile.parse_conditions_if_stringified(override["conditions"]), resolved_context + source_value = self._compose_variable(variable["compose"], dependencies) if composed else variable.get("defaultValue") + program = variable.get("overrideProgram") + overrides = program if program is not None else (variable.get("overrides") or []) + accumulated_value = source_value + applied_paths: list[list[str]] = [] + matched_index: int | None = None + matched_override: dict[str, Any] | None = None + index = 0 + while index < len(overrides): + override = overrides[index] + matched = "conditions" not in override or self._conditions_are_matched(override["conditions"], dependencies) + if matched: + if program is not None: + accumulated_value = apply_variable_operations(accumulated_value, override.get("operations")) + applied_paths.append(override.get("keyPath") or [override.get("key")]) + matched_index = index + matched_override = override + if program is None: + break + next_index = override.get("onMatch" if matched else "onMiss") if program is not None else index + 1 + if program is not None and ( + not isinstance(next_index, int) or isinstance(next_index, bool) + or next_index <= index or next_index > len(overrides) + ): + raise ValueError("Invalid variable override program jump") + index = next_index + if matched_override is not None: + evaluation.update( + reason="variable_override_rule", + variable=variable, + variableValue=accumulated_value if program is not None else matched_override.get("value"), + variableOverrideIndex=matched_index, ) - segments_match = not override.get("segments") or self.datafile.all_segments_are_matched( - self.datafile.parse_segments_if_stringified(override["segments"]), resolved_context - ) - if conditions_match and segments_match: - evaluation.update( - reason="variable_override_rule", - variable=variable, - variableValue=override.get("value"), - variableOverrideIndex=index, - ) - if override.get("key") is not None: - evaluation["variableOverrideKey"] = override["key"] - if override.get("keyPath") is not None: - evaluation["variableOverridePath"] = override["keyPath"] - break - if evaluation["reason"] == "variable_not_found": - evaluation.update(reason="variable_default", variable=variable) - if "defaultValue" in variable: - evaluation["variableValue"] = variable["defaultValue"] + if "key" in matched_override: + evaluation["variableOverrideKey"] = matched_override["key"] + if "keyPath" in matched_override: + evaluation["variableOverridePath"] = matched_override["keyPath"] + if program is not None: + evaluation["variableOverridePaths"] = applied_paths + else: + evaluation.update(reason="variable_composed" if composed else "variable_default", variable=variable) + if composed or "defaultValue" in variable: + evaluation["variableValue"] = source_value + if isinstance(variable.get("compose"), list) and isinstance(evaluation.get("variableValue"), dict): + evaluation["variableValue"] = dict(evaluation["variableValue"]) if variable.get("deprecated"): self.report_diagnostic({ - "level": "warn", "code": "variable_deprecated", + "level": "warn", "code": "deprecated_variable", "message": f'Variable "{resolved_key}" is deprecated', "details": {"variableKey": resolved_key, "evaluation": evaluation}, }) - if "variableValue" not in evaluation and "defaultVariableValue" in options: - evaluation["variableValue"] = options["defaultVariableValue"] - evaluation = self.modules_manager.run_after_evaluation_modules(evaluation, evaluation_options) - self.report_diagnostic({ - "level": "debug", "code": str(evaluation["reason"]), - "message": "Global variable evaluated", "details": dict(evaluation), - }) + if "variableValue" not in evaluation and "defaultVariableValue" in evaluation_options: + evaluation["variableValue"] = evaluation_options["defaultVariableValue"] + if self.modules_manager.modules: + public_options = (evaluation_options if had_modules else { + key: value for key, value in evaluation_options.items() + if not key.startswith("__featurevisor_") + }) + evaluation = self.modules_manager.run_after_evaluation_modules(evaluation, public_options) + if self._diagnostic_is_observed("debug"): + self.report_diagnostic({ + "level": "debug", "code": str(evaluation["reason"]), + "message": "Global variable evaluated", "details": dict(evaluation), + }) + post_key = session.key({**input_options, "datafile": self.datafile, + "modulesManager": self.modules_manager, + "stickyFeatures": sticky_features, "stickyVariables": sticky_variables}) if cache_key is not None else None + if cache_key is not None and cache_key == post_key: + snapshot = session.put(cache_key, evaluation) + if snapshot is not None: + return snapshot return evaluation except Exception as exc: - evaluation = {"type": "variable", "variableKey": variable_key, "reason": "error", "error": exc} - self.report_diagnostic({"level": "error", "code": "evaluation_error", "message": "Global variable evaluation failed", "originalError": exc, "details": evaluation}) + if isinstance(exc, RecursionError): + exc = RequirementInputError("Host recursion limit exceeded during dependency evaluation") + evaluation = {"type": "variable", "variableKey": evaluation_options["variableKey"], "reason": "error", "error": exc} + if evaluation_options.get("dependencyOf") is None or not isinstance(exc, RequirementInputError): + self.report_diagnostic({"level": "error", "code": "evaluation_error", "message": "Global variable evaluation failed", "originalError": exc, "details": evaluation}) + post_key = session.key({**input_options, "datafile": self.datafile, + "modulesManager": self.modules_manager, + "stickyFeatures": sticky_features, "stickyVariables": sticky_variables}) if cache_key is not None else None + if cache_key is not None and cache_key == post_key: + session.put(cache_key, evaluation) return evaluation + def _compose_variable(self, compose, dependencies): + if isinstance(compose, str) and compose: + return self._evaluate_required_variable({"variable": compose}, dependencies, "composition") + if not isinstance(compose, list) or not compose: + raise RequirementInputError("Invalid composition") + value = {} + for reference in compose: + key = reference.get("key") + if not isinstance(key, str) or not key or key in {"__proto__", "constructor", "prototype"}: + raise RequirementInputError("Invalid composition property") + if "variable" in reference: + member = self._evaluate_required_variable(reference, dependencies, "composition") + elif "feature" in reference: + type_ = "variation" if reference.get("variation") is True else "flag" + result = self._evaluate_dependency(reference, dependencies, type_, "composition") + member = (result.get("enabled") is True if type_ == "flag" else + result.get("variationValue", (result.get("variation") or {}).get("value"))) + else: + raise RequirementInputError("Invalid composition source") + value[key] = member + return value + def get_feature_evaluations(self, context: dict[str, Any] | None = None, feature_keys: list[str] | None = None, options: dict[str, Any] | None = None) -> dict[str, Any]: result: dict[str, Any] = {} keys = feature_keys or self.datafile.get_feature_keys() @@ -402,8 +719,11 @@ def clear_module_diagnostic_subscriptions(self, module: FeaturevisorModule) -> N ] def report_diagnostic(self, diagnostic: dict[str, Any], source_module: FeaturevisorModule | None = None) -> None: + level = (diagnostic or {}).get("level") or "info" + if not self._diagnostic_is_observed(level, source_module): + return diagnostic = dict(diagnostic or {}) - diagnostic["level"] = diagnostic.get("level") or "info" + diagnostic["level"] = level if source_module and source_module.name: diagnostic["module"] = source_module.name details = dict(diagnostic.get("details") or {}) @@ -435,7 +755,21 @@ def report_diagnostic(self, diagnostic: dict[str, Any], source_module: Featurevi if diagnostic["level"] == "error": self.emitter.trigger("error", {"diagnostic": diagnostic}) - def _should_report_diagnostic(self, diagnostic_level: LogLevel, subscriber_level: LogLevel) -> bool: + def _diagnostic_is_observed( + self, + diagnostic_level: str, + source_module: FeaturevisorModule | None = None, + ) -> bool: + if diagnostic_level == "error": + return True + for subscription in self.module_diagnostic_subscriptions: + if source_module and subscription["moduleId"] == source_module.id: + continue + if self._should_report_diagnostic(diagnostic_level, subscription["level"]): + return True + return self._should_report_diagnostic(diagnostic_level, self.log_level) + + def _should_report_diagnostic(self, diagnostic_level: str, subscriber_level: str) -> bool: return should_report(subscriber_level, diagnostic_level) def _merge_datafiles(self, previous: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]: @@ -472,7 +806,7 @@ def _merge_datafiles(self, previous: dict[str, Any], incoming: dict[str, Any]) - getVariableBoolean = get_variable_boolean getVariableString = get_variable_string getVariableInteger = get_variable_integer - getVariableDouble = get_variable_double + getVariableNumber = get_variable_number getVariableArray = get_variable_array getVariableObject = get_variable_object getVariableJSON = get_variable_json diff --git a/src/featurevisor/modules.py b/src/featurevisor/modules.py index e9966f3..714b8b0 100644 --- a/src/featurevisor/modules.py +++ b/src/featurevisor/modules.py @@ -12,10 +12,8 @@ def __init__(self, options: dict[str, Any] | None = None) -> None: self.id = str(uuid.uuid4()) self.name = options.get("name") self.setup = options.get("setup") - self.before = options.get("before") self.bucket_key = options.get("bucketKey") or options.get("bucket_key") self.bucket_value = options.get("bucketValue") or options.get("bucket_value") - self.after = options.get("after") self.before_evaluation = options.get("beforeEvaluation") or options.get("before_evaluation") self.after_evaluation = options.get("afterEvaluation") or options.get("after_evaluation") self.close = options.get("close") @@ -24,11 +22,6 @@ def call_setup(self, api: dict[str, Any]) -> None: if self.setup: self.setup(api) - def call_before(self, options: dict[str, Any]) -> dict[str, Any]: - if not self.before: - return options - return self.before(options) - def call_bucket_key(self, options: dict[str, Any]) -> str: if not self.bucket_key: return options["bucketKey"] @@ -39,11 +32,6 @@ def call_bucket_value(self, options: dict[str, Any]) -> int: return options["bucketValue"] return self.bucket_value(options) - def call_after(self, evaluation: dict[str, Any], options: dict[str, Any]) -> dict[str, Any]: - if not self.after: - return evaluation - return self.after(evaluation, options) - def call_before_evaluation(self, options: dict[str, Any]) -> dict[str, Any]: if not self.before_evaluation: return options @@ -112,7 +100,8 @@ def add(self, module: dict[str, Any] | FeaturevisorModule): self._close_module(module) return None - self.modules.append(module) + # Replace membership so an active phase keeps its starting list. + self.modules = [*self.modules, module] def remove() -> None: self.remove(module) @@ -138,16 +127,14 @@ def remove(self, name_or_module: str | FeaturevisorModule) -> None: def get_all(self) -> list[FeaturevisorModule]: return list(self.modules) - def run_before_modules(self, options: dict[str, Any]) -> dict[str, Any]: - current = options - for module in self.modules: - current = module.call_before(current) - return current - - def run_before_evaluation_modules(self, options: dict[str, Any]) -> dict[str, Any]: + def run_before_evaluation_modules(self, options: dict[str, Any], on_option: Callable[[dict[str, Any]], None] | None = None) -> dict[str, Any]: current = options + if on_option: + on_option(current) for module in self.modules: current = module.call_before_evaluation(current) + if on_option and isinstance(current, dict): + on_option(current) return current def run_bucket_key_modules(self, options: dict[str, Any]) -> str: @@ -162,12 +149,6 @@ def run_bucket_value_modules(self, options: dict[str, Any]) -> int: bucket_value = module.call_bucket_value({**options, "bucketValue": bucket_value}) return bucket_value - def run_after_modules(self, evaluation: dict[str, Any], options: dict[str, Any]) -> dict[str, Any]: - current = evaluation - for module in self.modules: - current = module.call_after(current, options) - return current - def run_after_evaluation_modules(self, evaluation: dict[str, Any], options: dict[str, Any]) -> dict[str, Any]: current = evaluation for module in self.modules: @@ -201,10 +182,8 @@ def _report(self, diagnostic: dict[str, Any], module: FeaturevisorModule | None self.report_diagnostic(diagnostic, module) getAll = get_all - runBeforeModules = run_before_modules runBeforeEvaluationModules = run_before_evaluation_modules runBucketKeyModules = run_bucket_key_modules runBucketValueModules = run_bucket_value_modules - runAfterModules = run_after_modules runAfterEvaluationModules = run_after_evaluation_modules closeAll = close_all diff --git a/src/featurevisor/mutations.py b/src/featurevisor/mutations.py new file mode 100644 index 0000000..a240193 --- /dev/null +++ b/src/featurevisor/mutations.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import math +from typing import Any, cast + + +_DANGEROUS_KEYS = {"__proto__", "prototype", "constructor"} +_MAX_SAFE_INTEGER = 9_007_199_254_740_991 + + +def _is_primitive(value: Any) -> bool: + return value is None or isinstance(value, (str, bool)) or ( + isinstance(value, (int, float)) and not isinstance(value, bool) + ) + + +def _same(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return isinstance(left, bool) and isinstance(right, bool) and left == right + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return left == right + if left is None or right is None: + return left is None and right is None + if isinstance(left, str) and isinstance(right, str): + return left == right + return left is right + + +def _array_index(value: Any) -> int | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + if isinstance(value, float) and not math.isfinite(value): + return None + if value < 0 or value > _MAX_SAFE_INTEGER or value != math.trunc(value): + return None + return int(value) + + +def _selector(part: Any) -> dict[str, Any] | None: + if not isinstance(part, dict) or not isinstance(part.get("select"), dict): + return None + select = part["select"] + if not isinstance(select.get("property"), str) or not isinstance(select.get("values"), list): + return None + if not all(_is_primitive(value) for value in select["values"]): + return None + return select + + +def _validate_operation(operation: Any) -> None: + if not isinstance(operation, dict) or "op" not in operation or "path" not in operation: + raise ValueError("Invalid variable mutation operation") + op = operation["op"] + if op not in {"set", "append", "prepend", "appendUnique", "removeValue", "before", "after", "remove"}: + raise ValueError("Invalid variable mutation operation") + if op != "remove" and ("value" not in operation or operation["value"] is _UNDEFINED): + raise ValueError("Variable mutation operation requires a value") + if op in {"appendUnique", "removeValue"}: + value = operation.get("value") + if not _is_primitive(value) or ( + isinstance(value, float) and not math.isfinite(value) + ): + raise ValueError("Variable mutation operation requires a primitive value") + path = operation["path"] + if not isinstance(path, list): + raise ValueError("Invalid variable mutation path") + for part in path: + if isinstance(part, str): + key = part + elif _array_index(part) is not None: + key = None + else: + select = _selector(part) + if select is None: + raise ValueError("Invalid variable mutation path") + key = select["property"] + if key in _DANGEROUS_KEYS: + raise ValueError("Unsafe variable mutation key") + + +class _Undefined: + pass + + +_UNDEFINED = _Undefined() + + +def _selected_index(value: list[Any], select: dict[str, Any]) -> int: + for index, candidate in enumerate(value): + if not isinstance(candidate, dict) or select["property"] not in candidate: + continue + property_value = candidate[select["property"]] + if _is_primitive(property_value) and any( + _same(property_value, expected) for expected in select["values"] + ): + return index + return -1 + + +def _path_key(container: Any, part: Any) -> str | int: + if isinstance(part, str): + if not isinstance(container, dict): + raise ValueError("Variable mutation expected an object") + return part + if not isinstance(container, list): + raise ValueError("Variable mutation expected an array") + index = _array_index(part) + if index is not None: + return index + select = _selector(part) + if select is None: + raise ValueError("Invalid variable mutation path") + return _selected_index(container, select) + + +def _exists(container: Any, key: str | int) -> bool: + return key in container if isinstance(key, str) else 0 <= key < len(container) + + +def _child(container: Any, key: str | int) -> Any: + return container[key] + + +def _replace(container: Any, key: str | int, value: Any) -> Any: + if isinstance(container, list): + index = cast(int, key) + result_list = list(container) + if index == len(result_list): + result_list.append(value) + else: + result_list[index] = value + return result_list + result_object = dict(container) + result_object[cast(str, key)] = value + return result_object + + +def _update_array(array: list[Any], op: str, item: Any) -> list[Any]: + if op == "removeValue": + if not any(_same(value, item) for value in array): + return array + return [value for value in array if not _same(value, item)] + if op == "appendUnique" and any(_same(value, item) for value in array): + return array + return [item, *array] if op == "prepend" else [*array, item] + + +def _update_final(container: Any, part: Any, operation: dict[str, Any]) -> Any: + if container is None: + return container + op = operation["op"] + if op in {"before", "after"} and isinstance(part, str): + raise ValueError("Variable mutation before/after requires an array element path") + key = _path_key(container, part) + found = _exists(container, key) + + if op == "remove": + if not found: + return container + result = list(container) if isinstance(container, list) else dict(container) + if isinstance(result, list): + result.pop(cast(int, key)) + else: + del result[key] + return result + + item = operation["value"] + if op == "set": + if isinstance(key, int) and (key < 0 or key > len(container)): + return container + if found and _same(_child(container, key), item): + return container + return _replace(container, key, item) + if op in {"before", "after"}: + if not found: + return container + result = list(container) + result.insert(cast(int, key) + (1 if op == "after" else 0), item) + return result + + current = _child(container, key) if found else None + if current is None and isinstance(key, int): + return container + if current is None and op == "removeValue": + return container + if current is not None and not isinstance(current, list): + raise ValueError("Variable mutation expected an array") + array = [] if current is None else current + result = _update_array(array, op, item) + return container if result is current else _replace(container, key, result) + + +def _update_at_path(value: Any, operation: dict[str, Any], offset: int) -> Any: + path = operation["path"] + if offset == len(path): + if operation["op"] == "set": + replacement = operation["value"] + return value if _same(value, replacement) else replacement + if operation["op"] == "remove": + raise ValueError("Cannot remove the root variable value") + if operation["op"] in {"before", "after"}: + raise ValueError("Variable mutation before/after requires an array element path") + if value is None: + return value + if not isinstance(value, list): + raise ValueError("Variable mutation expected an array") + return _update_array(value, operation["op"], operation["value"]) + + if offset == len(path) - 1: + return _update_final(value, path[offset], operation) + if value is None: + return value + key = _path_key(value, path[offset]) + if not _exists(value, key): + return value + current = _child(value, key) + if current is None: + return value + result = _update_at_path(current, operation, offset + 1) + return value if result is current else _replace(value, key, result) + + +def apply_variable_operations(value: Any, operations: list[dict[str, Any]]) -> Any: + """Apply compiled variable operations without mutating the input value.""" + if not isinstance(operations, list): + raise ValueError("Invalid variable mutation operations") + current = value + for operation in operations: + _validate_operation(operation) + current = _update_at_path(current, operation, 0) + return current diff --git a/src/featurevisor/normalize.py b/src/featurevisor/normalize.py new file mode 100644 index 0000000..7ff761b --- /dev/null +++ b/src/featurevisor/normalize.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import Any + +from ._json import strict_json_loads + + +class InvalidConditions(dict): + """A selector failure which cannot become true through negation.""" + + +class ConditionsParseFailure(Exception): + pass + + +def legacy_expression(value: Any, features: bool = False) -> Any: + if isinstance(value, InvalidConditions): + return value + if isinstance(value, str): + return {"feature": value} if features else value + if isinstance(value, list): + return [legacy_expression(item, features) for item in value] if value else "*" + if isinstance(value, dict): + for operator in ("and", "or", "not"): + if operator in value: + items = value[operator] + if operator == "and" and not items: + return "*" + return {operator: [legacy_expression(item, features) for item in items]} + return value + + +def normalize_entity(kind: str, key: str, value: dict[str, Any], legacy: bool = False): + errors: list[dict[str, Any]] = [] + + def parse(expression): + if not isinstance(expression, str) or expression.lstrip()[:1] not in ('{', '[', '"'): + return expression + try: + return strict_json_loads(expression) + except Exception as exc: + errors.append({"level": "error", "code": "conditions_parse_error", + "message": "Error parsing conditions", "originalError": exc, + "details": {"conditions": expression, "entityType": kind, "entityKey": key}}) + return InvalidConditions({"or": []}) + + def select(entry, old=False): + result = {name: item for name, item in entry.items() + if name not in {"segments", "requiredFeatures", "requiredVariables", "required"}} + parts = [] + if "conditions" in entry: + expression = parse(entry["conditions"]) + parts.append(legacy_expression(expression) if old else expression) + if "segments" in entry: + parts.append(legacy_expression(parse(entry["segments"]))) + if "requiredFeatures" in entry: + features = entry["requiredFeatures"] + features = features if isinstance(features, list) else [features] + if features: + parts.append([legacy_expression(item, True) for item in features]) + if any(isinstance(part, InvalidConditions) for part in parts): + result["conditions"] = InvalidConditions({"or": []}) + elif len(parts) == 1: + result["conditions"] = parts[0] + elif parts: + result["conditions"] = [leaf for part in parts for leaf in + (part if isinstance(part, list) and part else + [{"and": []}] if part == [] else [part])] + return result + + def with_overrides(entry): + result = dict(entry) + if "variableOverrides" in entry: + result["variableOverrides"] = { + name: [select(override, legacy) for override in overrides] + for name, overrides in entry["variableOverrides"].items()} + return result + + if kind == "segment": + expression = parse(value.get("conditions")) + result = {**value, "conditions": legacy_expression(expression) if legacy else expression} + else: + result = select(value) + if kind == "feature": + result["traffic"] = [with_overrides(select(rule)) for rule in value.get("traffic", [])] + if "force" in value: + result["force"] = [select(force, legacy) for force in value["force"]] + if "variations" in value: + result["variations"] = [with_overrides(variation) for variation in value["variations"]] + else: + for name in ("overrides", "overrideProgram"): + if name in value: + result[name] = [select(step, legacy and name == "overrides") for step in value[name]] + return result, errors + + +def normalize_datafile(datafile): + result = dict(datafile) + errors = [] + for name, kind in (("features", "feature"), ("segments", "segment"), ("variables", "variable")): + if name not in datafile: + continue + result[name] = {} + for key, value in (datafile[name] or {}).items(): + normalized, diagnostics = normalize_entity(kind, key, value, True) + result[name][key] = normalized + errors.extend(diagnostics) + return result, errors diff --git a/src/featurevisor/openfeature.py b/src/featurevisor/openfeature.py index 587b6b6..b389218 100644 --- a/src/featurevisor/openfeature.py +++ b/src/featurevisor/openfeature.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import math from collections.abc import Mapping, Sequence from datetime import datetime, timezone @@ -8,7 +7,8 @@ try: from openfeature.evaluation_context import EvaluationContext - from openfeature.exception import ErrorCode + from openfeature.exception import ErrorCode, ParseError + from openfeature.event import ProviderEvent, ProviderEventDetails from openfeature.flag_evaluation import FlagResolutionDetails, Reason from openfeature.provider import AbstractProvider from openfeature.provider.metadata import Metadata @@ -22,10 +22,11 @@ raise from .instance import Featurevisor, create_featurevisor +from ._json import strict_json_loads class FeaturevisorOpenFeatureProvider(AbstractProvider): - """OpenFeature provider backed by the Featurevisor v3 SDK.""" + """OpenFeature provider backed by the Featurevisor SDK.""" def __init__( self, @@ -52,31 +53,39 @@ def __init__( self.featurevisor = featurevisor else: featurevisor_options = dict(options or {}) - datafile = featurevisor_options.get("datafile") - if isinstance(datafile, str): - try: - json.loads(datafile) - except (TypeError, ValueError): - self.datafile_error = "Could not parse datafile" - original_handler = featurevisor_options.get("onDiagnostic") or featurevisor_options.get("on_diagnostic") - - def on_diagnostic(diagnostic: dict[str, Any]) -> None: - if diagnostic.get("code") == "invalid_datafile": - self.datafile_error = str(diagnostic.get("message")) - if diagnostic.get("code") == "datafile_set": - self.datafile_error = None - if original_handler: - original_handler(diagnostic) - - featurevisor_options["onDiagnostic"] = on_diagnostic + datafile = featurevisor_options.pop("datafile", None) + if not (featurevisor_options.get("onDiagnostic") or featurevisor_options.get("on_diagnostic")): + featurevisor_options["onDiagnostic"] = lambda _: None self.featurevisor = create_featurevisor(featurevisor_options) - self._datafile_unsubscribe = self.featurevisor.on("datafile_set", lambda _: setattr(self, "datafile_error", None)) + self._datafile_unsubscribe = self.featurevisor.on("datafile_set", self._on_datafile_set) + self._error_unsubscribe = self.featurevisor.on("error", self._on_error) + if self._owns_featurevisor and datafile is not None: + self.featurevisor.set_datafile(datafile, True) + + def initialize(self, evaluation_context: EvaluationContext) -> None: + if self.datafile_error: + raise ParseError(self.datafile_error) + + def _on_datafile_set(self, _: dict[str, Any]) -> None: + recovering = self.datafile_error is not None + self.datafile_error = None + if recovering: + self.emit(ProviderEvent.PROVIDER_READY, ProviderEventDetails()) + + def _on_error(self, event: dict[str, Any]) -> None: + diagnostic = event.get("diagnostic", {}) + if diagnostic.get("code") == "invalid_datafile": + self.datafile_error = str(diagnostic.get("message")) + self.emit(ProviderEvent.PROVIDER_ERROR, ProviderEventDetails( + message=self.datafile_error, error_code=ErrorCode.PARSE_ERROR, + )) def get_metadata(self) -> Metadata: return Metadata(name="Featurevisor") def shutdown(self) -> None: self._datafile_unsubscribe() + self._error_unsubscribe() if self._owns_featurevisor: self.featurevisor.close() @@ -116,9 +125,12 @@ def _resolve(self, flag_key: str, default_value: Any, evaluation_context: Evalua evaluation = self.featurevisor.evaluate_variable(selector, context) value = evaluation.get("variableValue") variable = evaluation.get("variable") or {} - if variable.get("type") == "json" and isinstance(value, str): + if variable.get("type") == "json" and isinstance(value, str) and "compose" not in variable: try: - value = json.loads(value) + value = strict_json_loads(value) + except RecursionError as exc: + return self._error(default_value, ErrorCode.GENERAL, str(exc), + self._metadata({**evaluation, "reason": "error"})) except (TypeError, ValueError): pass elif not selector: @@ -136,7 +148,10 @@ def _resolve(self, flag_key: str, default_value: Any, evaluation_context: Evalua value = evaluation.get("variableValue") if evaluation.get("variableSchema", {}).get("type") == "json" and isinstance(value, str): try: - value = json.loads(value) + value = strict_json_loads(value) + except RecursionError as exc: + return self._error(default_value, ErrorCode.GENERAL, str(exc), + self._metadata({**evaluation, "reason": "error"})) except (TypeError, ValueError): pass @@ -158,7 +173,7 @@ def _resolve(self, flag_key: str, default_value: Any, evaluation_context: Evalua def _context(self, context: EvaluationContext | None) -> dict[str, Any]: result = self._normalize(dict(context.attributes)) if context else {} - if context and context.targeting_key: + if context and context.targeting_key is not None: result[self.targeting_key_field] = context.targeting_key return result @@ -181,11 +196,11 @@ def _metadata(self, evaluation: dict[str, Any]) -> dict[str, bool | int | float def _reason(reason: str | None) -> Reason: if reason in {"feature_not_found", "variable_not_found", "no_variations", "error"}: return Reason.ERROR - if reason in {"required", "forced", "sticky", "rule", "variable_override_variation", "variable_override_rule"}: + if reason in {"forced", "sticky", "rule", "variable_override_variation", "variable_composed", "variable_override_rule"}: return Reason.TARGETING_MATCH if reason == "allocated": return Reason.SPLIT - if reason in {"disabled", "required_features_unmet", "variation_disabled", "variable_disabled"}: + if reason in {"disabled", "conditions_unmet", "variation_disabled", "variable_disabled"}: return Reason.DISABLED return Reason.DEFAULT diff --git a/src/featurevisor/project.py b/src/featurevisor/project.py index 25cbdf2..43f12e8 100644 --- a/src/featurevisor/project.py +++ b/src/featurevisor/project.py @@ -3,38 +3,89 @@ import json import os import pathlib +import shutil import subprocess import time from typing import Any +def node_cli_command(project_directory_path: pathlib.Path) -> list[str]: + """Run 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 the full path of its npx.cmd wrapper, because subprocess only looks for + executables with an .exe suffix. + """ + for directory in [project_directory_path.resolve(), *project_directory_path.resolve().parents]: + entry = directory / "node_modules" / "@featurevisor" / "cli" / "bin.js" + if entry.is_file(): + return ["node", str(entry)] + npx = shutil.which("npx") if os.name == "nt" else None + return [npx or "npx", "featurevisor"] + + +class FeaturevisorProjectError(ValueError): + def __init__(self, message: str, *, code: str | None = None) -> None: + super().__init__(message) + self.code = code + + class FeaturevisorProject: - def __init__(self, project_directory_path: str | None = None) -> None: + def __init__(self, project_directory_path: str | None = None, set_name: str | None = None) -> None: self.project_directory_path = pathlib.Path(project_directory_path or os.getcwd()) + self.set_name = set_name - def run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + def run(self, *args: str, check: bool = True, include_set: bool = True) -> subprocess.CompletedProcess[str]: return subprocess.run( - ["npx", "featurevisor", *args], + [*node_cli_command(self.project_directory_path), *args, *([f"--set={self.set_name}"] if include_set and self.set_name is not None else [])], cwd=self.project_directory_path, text=True, + encoding="utf-8", capture_output=True, check=check, ) - def run_json(self, *args: str) -> Any: - result = self.run(*args) + def run_json(self, *args: str, include_set: bool = True) -> Any: + result = self.run(*args, include_set=include_set) return json.loads(result.stdout) def get_config(self) -> dict[str, Any]: - return self.run_json("config", "--json") + return self.run_json("config", "--json", include_set=False) - def list_tests(self, key_pattern: str | None = None, assertion_pattern: str | None = None) -> list[dict[str, Any]]: - args = ["list", "--tests", "--applyMatrix", "--json"] + def list_tests(self, key_pattern: str | None = None, assertion_pattern: str | None = None, entity_type: str | None = None, case_id: str | None = None) -> list[dict[str, Any]]: + args = ["list", "--tests", "--execution-protocol", "--json"] if key_pattern: args.append(f"--keyPattern={key_pattern}") if assertion_pattern: args.append(f"--assertionPattern={assertion_pattern}") - return self.run_json(*args) + if entity_type: + args.append(f"--entityType={entity_type}") + if case_id: + args.append(f"--case={case_id}") + result = self.run(*args, check=False) + if result.returncode != 0: + try: + payload = json.loads(result.stderr) + error = payload.get("error") or {} + raise FeaturevisorProjectError( + error.get("message") or result.stderr.strip() or "Failed to list tests", + code=error.get("code"), + ) + except json.JSONDecodeError as error: + raise FeaturevisorProjectError(result.stderr.strip() or "Failed to list tests") from error + payload = json.loads(result.stdout) + if not isinstance(payload, dict): + raise FeaturevisorProjectError("Invalid test execution protocol: expected an envelope object") + if payload.get("kind") != "featurevisor.test-execution": + raise FeaturevisorProjectError('Invalid test execution protocol: kind must be "featurevisor.test-execution"') + version = payload.get("schemaVersion") + if isinstance(version, bool) or not isinstance(version, (int, float)) or version != 1: + raise FeaturevisorProjectError("Unsupported test execution protocol schemaVersion; expected numeric 1") + tests = payload.get("tests") + if not isinstance(tests, list) or not all(isinstance(test, dict) for test in tests): + raise FeaturevisorProjectError("Invalid test execution protocol: tests must be an array of objects") + return tests def list_segments(self) -> list[dict[str, Any]]: return self.run_json("list", "--segments", "--json") @@ -42,6 +93,10 @@ def list_segments(self) -> list[dict[str, Any]]: def list_features(self) -> list[dict[str, Any]]: return self.run_json("list", "--features", "--json") + def list_uncovered(self, selector: str) -> list[str]: + items = self.run_json("list", f"--{selector}", "--without-tests", "--json") + return sorted(str(item.get("key") if isinstance(item, dict) else item) for item in items) + def list_targets(self) -> list[str]: targets = self.run_json("list", "--targets", "--json") if isinstance(targets, list): @@ -87,7 +142,7 @@ def pretty_duration(seconds: float) -> str: return " ".join(parts) -def timed_build(project: FeaturevisorProject, *, environment: str, inflate: int | None = None, target: str | None = None) -> tuple[dict[str, Any], float]: +def timed_build(project: FeaturevisorProject, *, environment: str | None, inflate: int | None = None, target: str | None = None) -> tuple[dict[str, Any], float]: start = time.perf_counter() datafile = project.build_datafile_json(environment=environment, inflate=inflate, target=target) return datafile, time.perf_counter() - start diff --git a/src/featurevisor/segment_tests.py b/src/featurevisor/segment_tests.py new file mode 100644 index 0000000..a0f50c6 --- /dev/null +++ b/src/featurevisor/segment_tests.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import Any + +from ._json import strict_json_loads +from .normalize import normalize_entity + + +def prepare_segment_datafile(datafile, segments): + definitions = {**segments, **datafile.get("segments", {})} + features = dict(datafile.get("features", {})) + keys = {} + for key in segments: + helper = "__featurevisor_segment_test__" + key + while helper in features: + helper += "_" + keys[key] = helper + features[helper] = {"bucketBy": "__segment_test__", "conditions": key, + "traffic": [{"key": "segment", "conditions": "*", "percentage": 100000}]} + return {**datafile, "features": features, "segments": definitions, "_segment_test_keys": keys} + + +def assert_segment_references(key, datafile, segments): + """Inspect the full dependency closure without calling an SDK getter.""" + pending: list[tuple[str, Any, Any]] = [("segment", key, None)] + seen = set() + + def expression(value): + if isinstance(value, str): + if value.lstrip()[:1] in ('{', '[', '"'): + try: + expression(strict_json_loads(value)) + except ValueError: + pass + elif value != "*": + pending.append(("segment", value, None)) + elif isinstance(value, list): + for child in value: + expression(child) + elif isinstance(value, dict): + if "variable" in value: + pending.append(("feature" if value.get("feature") is not None else "variable", + value.get("feature", value["variable"]), value["variable"] if value.get("feature") is not None else None)) + elif "feature" in value: + pending.append(("feature", value["feature"], None)) + else: + for group in ("and", "or", "not"): + if group in value: + expression(value[group]) + + def selectors(entry): + if isinstance(entry, dict): + expression(entry.get("conditions")) + + cursor = 0 + while cursor < len(pending): + identity = pending[cursor] + cursor += 1 + if identity in seen: + continue + seen.add(identity) + kind, name, variable = identity + if kind == "segment": + definition = segments.get(name, datafile.get("segments", {}).get(name)) + else: + definition = (datafile.get("features" if kind == "feature" else "variables") or {}).get(name) + if not isinstance(definition, dict): + raise ValueError(f'{kind} "{name}" is absent from the selected datafile and test definitions') + if variable is not None and variable not in definition.get("variablesSchema", {}): + raise ValueError(f'variable "{variable}" is absent from feature "{name}" in the selected datafile') + normalized, _ = normalize_entity(kind, name, definition) + selectors(normalized) + if kind == "feature": + for entry in [*normalized.get("force", []), *normalized.get("traffic", [])]: + selectors(entry) + if variable is not None: + for entry in [*normalized.get("traffic", []), *normalized.get("variations", [])]: + for override in (entry.get("variableOverrides") or {}).get(variable, []): + selectors(override) + elif kind == "variable": + for entry in [*normalized.get("overrides", []), *normalized.get("overrideProgram", [])]: + selectors(entry) + compose = definition.get("compose") + if isinstance(compose, str): + pending.append(("variable", compose, None)) + elif isinstance(compose, list): + for reference in compose: + if "variable" in reference: + expression(reference) + elif "feature" in reference: + pending.append(("feature", reference["feature"], None)) diff --git a/src/featurevisor/test_checks.py b/src/featurevisor/test_checks.py new file mode 100644 index 0000000..941ccc2 --- /dev/null +++ b/src/featurevisor/test_checks.py @@ -0,0 +1,865 @@ +from __future__ import annotations + +from .segment_tests import assert_segment_references, prepare_segment_datafile +from .evaluate import evaluate_with_modules + +import json +import math +import re +from datetime import date +from typing import Any + +from .compare_versions import compare_versions +from .evaluation_data_provider import _InstanceEvaluationDataProvider + + +MISSING = object() +UNSAFE_PATH_PARTS = {"__proto__", "prototype", "constructor"} +MAX_SAFE_INTEGER = 9_007_199_254_740_991 +VALUELESS_OPERATORS = {"exists", "notExists", "isNull", "notNull", "empty", "notEmpty"} +KNOWN_OPERATORS = VALUELESS_OPERATORS | { + "equals", "notEquals", "greaterThan", "greaterThanOrEquals", "lessThan", + "lessThanOrEquals", "contains", "notContains", "startsWith", "endsWith", + "matches", "notMatches", "semverEquals", "semverNotEquals", + "semverGreaterThan", "semverGreaterThanOrEquals", "semverLessThan", + "semverLessThanOrEquals", "before", "after", "in", "notIn", "includes", + "notIncludes", "includesAny", "notIncludesAny", +} + + +class CheckError(ValueError): + pass + + +class CheckPreflightError(CheckError): + def __init__(self, message: str, expected: str, check_index: int | None = None, child_index: int | None = None): + super().__init__(message) + self.expected = expected + self.details = {} + if check_index is not None: + self.details["checkIndex"] = check_index + if child_index is not None: + self.details["childIndex"] = child_index + + +def _json(value: Any) -> str: + if value is MISSING: + return "" + return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) + + +def _reference_label(reference: dict[str, Any], owner_variable: str | None) -> str: + variable = reference.get("variable") or owner_variable or "" + root = f'{reference["feature"]}.{variable}' if reference.get("feature") else variable + path = reference.get("path") + return root if path is None or path == [] else f"{root}:{_json(path)}" + + +def _path_parts(path: Any) -> list[str | int]: + if path is None: + return [] + if isinstance(path, str): + raw: Any = path.split(".") + if any(part == "" for part in raw): + raise CheckError("path must not contain empty dot components") + parts: list[str | int] = [] + for part in raw: + if re.fullmatch(r"0|[1-9]\d*", part): + index = int(part) + if index > MAX_SAFE_INTEGER: + raise CheckError("path indices must be safe nonnegative integers") + parts.append(index) + else: + parts.append(part) + elif isinstance(path, list): + parts = path + else: + raise CheckError("path must be a dot separated string or an array") + for part in parts: + if isinstance(part, bool) or not isinstance(part, (str, int)): + raise CheckError("path entries must be string keys or nonnegative integer indices") + if isinstance(part, int) and (part < 0 or part > MAX_SAFE_INTEGER): + raise CheckError("path indices must be safe nonnegative integers") + if isinstance(part, str) and part in UNSAFE_PATH_PARTS: + raise CheckError(f'unsafe path segment "{part}" is not allowed') + if part == "*": + raise CheckError("wildcards are not allowed in paths") + return parts + + +def _at_path(value: Any, parts: list[str | int]) -> Any: + current = value + for part in parts: + if isinstance(part, int): + if not isinstance(current, list) or part >= len(current): + return MISSING + current = current[part] + else: + if isinstance(current, list): + if part == "length": + current = len(current) + elif re.fullmatch(r"0|[1-9]\d*", part) and int(part) < len(current): + current = current[int(part)] + else: + return MISSING + elif isinstance(current, dict) and part in current: + current = current[part] + else: + return MISSING + return current + + +def _strict_equal(left: Any, right: Any) -> bool: + if left is None or right is None: + return left is right + if isinstance(left, bool) or isinstance(right, bool): + return isinstance(left, bool) and isinstance(right, bool) and left == right + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return math.isfinite(left) and math.isfinite(right) and left == right + return type(left) is type(right) and isinstance(left, str) and left == right + + +def _primitive(value: Any) -> bool: + return value is None or isinstance(value, (str, bool)) or ( + isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + ) + + +def _membership_primitive(value: Any) -> bool: + return value is None or isinstance(value, str) or ( + isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + ) + + +def _date(value: Any) -> tuple[int, int]: + if not isinstance(value, str): + raise CheckError("date comparisons require valid portable timestamps") + match = re.fullmatch( + 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})", + value, + ) + if match is None: + raise CheckError("date comparisons require valid portable timestamps") + year, month, day, hour, minute, second = (int(part) for part in match.groups()[:6]) + if not 1 <= year <= 9999 or not 0 <= hour <= 23 or not 0 <= minute <= 59 or not 0 <= second <= 59: + raise CheckError("date comparisons require valid portable timestamps") + try: + ordinal = date(year, month, day).toordinal() + except ValueError as error: + raise CheckError("date comparisons require valid portable timestamps") from error + zone = match.group(8) + offset = 0 + if zone != "Z": + offset_hour, offset_minute = (int(part) for part in zone[1:].split(":")) + if offset_hour > 14 or offset_minute > 59 or (offset_hour == 14 and offset_minute != 0): + raise CheckError("date comparisons require valid portable timestamps") + offset = offset_hour * 3600 + offset_minute * 60 + if zone[0] == "-": + offset = -offset + instant = (ordinal - 1) * 86400 + hour * 3600 + minute * 60 + second - offset + maximum = date(9999, 12, 31).toordinal() * 86400 - 1 + if instant < 0 or instant > maximum: + raise CheckError("date comparisons require valid portable timestamps") + fraction = match.group(7) or "" + return instant, int(fraction[:3].ljust(3, "0") or "0") + + +def _regex(pattern: Any, flags: Any) -> re.Pattern[str]: + if not isinstance(pattern, str): + raise CheckError("regular expression comparisons require string operands") + flags = flags or "" + if not isinstance(flags, str) or not re.fullmatch(r"[gims]*", flags) or len(set(flags)) != len(flags): + raise CheckError("regexFlags must contain unique characters from g, i, m and s") + if re.search(r"\(\?", pattern) or re.search(r"\\(?:[1-9]|k<|k'|g<|g')", pattern) or re.search(r"(?:[?*+]|\{\d+(?:,\d*)?\})\+", pattern): + raise CheckError("regular expression uses syntax outside the portable subset") + native_flags = (re.IGNORECASE if "i" in flags else 0) | (re.MULTILINE if "m" in flags else 0) | (re.DOTALL if "s" in flags else 0) + try: + return re.compile(pattern, native_flags) + except re.error as error: + raise CheckError(f"invalid regular expression: {error}") from error + + +def _compare(operator: str, left: Any, right: Any, regex_flags: Any = None) -> bool: + if operator == "exists": + return left is not MISSING + if operator == "notExists": + return left is MISSING + if operator == "isNull": + return left is None + if operator == "notNull": + return left is not MISSING and left is not None + if operator in {"empty", "notEmpty"}: + if left is MISSING or left is None: + return False + if not isinstance(left, (str, list, dict)): + raise CheckError(f'operator "{operator}" requires a string, array or object operand') + return len(left) == 0 if operator == "empty" else len(left) > 0 + if left is MISSING or right is MISSING: + return False + if operator in {"equals", "notEquals"}: + if not _primitive(left) or not _primitive(right): + raise CheckError(f'operator "{operator}" only supports scalar values') + matched = _strict_equal(left, right) + return matched if operator == "equals" else not matched + if operator in {"greaterThan", "greaterThanOrEquals", "lessThan", "lessThanOrEquals"}: + numeric = lambda value: isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + if not numeric(left) or not numeric(right): + raise CheckError(f'operator "{operator}" requires numeric operands') + return {"greaterThan": left > right, "greaterThanOrEquals": left >= right, "lessThan": left < right, "lessThanOrEquals": left <= right}[operator] + if operator in {"contains", "notContains", "startsWith", "endsWith", "matches", "notMatches"}: + if not isinstance(left, str) or not isinstance(right, str): + raise CheckError(f'operator "{operator}" requires string operands') + if operator == "contains": return right in left + if operator == "notContains": return right not in left + if operator == "startsWith": return left.startswith(right) + if operator == "endsWith": return left.endswith(right) + matched = _regex(right, regex_flags).search(left) is not None + return matched if operator == "matches" else not matched + if operator.startswith("semver"): + if not isinstance(left, str) or not isinstance(right, str): + raise CheckError(f'operator "{operator}" requires string operands') + try: + compared = compare_versions(left, right) + except ValueError as error: + raise CheckError(str(error)) from error + return { + "semverEquals": compared == 0, "semverNotEquals": compared != 0, + "semverGreaterThan": compared == 1, "semverGreaterThanOrEquals": compared >= 0, + "semverLessThan": compared == -1, "semverLessThanOrEquals": compared <= 0, + }[operator] + if operator in {"before", "after"}: + return _date(left) < _date(right) if operator == "before" else _date(left) > _date(right) + if operator in {"in", "notIn"}: + if not _membership_primitive(left) or not isinstance(right, list) or not all(_membership_primitive(value) for value in right): + raise CheckError(f'operator "{operator}" requires a scalar left operand and scalar array right operand') + matched = any(_strict_equal(left, value) for value in right) + return matched if operator == "in" else not matched + if operator in {"includesAny", "notIncludesAny"}: + if not isinstance(left, list) or not isinstance(right, list) or not all(_primitive(value) for value in right): + raise CheckError(f'operator "{operator}" requires an array left operand and primitive array right operand') + matched = any(_strict_equal(actual, candidate) for candidate in right for actual in left) + return matched if operator == "includesAny" else not matched + if operator in {"includes", "notIncludes"}: + if not isinstance(left, list) or not _membership_primitive(right): + raise CheckError(f'operator "{operator}" requires an array left operand and scalar right operand') + matched = any(_strict_equal(value, right) for value in left) + return matched if operator == "includes" else not matched + raise CheckError(f'unknown check operator "{operator}"') + + +class _Resolver: + def __init__( + self, + sdk: Any, + datafile: dict[str, Any], + diagnostics: list[dict[str, Any]], + owner_variable: str | None, + segments: dict[str, Any], + context: dict[str, Any], + ): + self.sdk = sdk + self.datafile = datafile + self.diagnostics = diagnostics + self.owner_variable = owner_variable + self.segments = segments + self.context = context + + + def resolve(self, reference: dict[str, Any]) -> Any: + variable = reference.get("variable") or self.owner_variable + feature = reference.get("feature") + if not isinstance(variable, str) or not variable: + raise CheckError("variable reference requires a nonempty variable") + if feature is not None and (not isinstance(feature, str) or not feature): + raise CheckError("feature reference requires a nonempty feature") + if feature: + definition = (self.datafile.get("features") or {}).get(feature) + if not isinstance(definition, dict): + raise CheckError(f'feature "{feature}" is absent from the selected datafile') + if variable not in (definition.get("variablesSchema") or {}): + raise CheckError(f'variable "{variable}" is absent from feature "{feature}" in the selected datafile') + elif variable not in (self.datafile.get("variables") or {}): + raise CheckError(f'global variable "{variable}" is absent from the selected datafile') + parts = _path_parts(reference.get("path")) + self.diagnostics.clear() + try: + value = self.sdk.get_variable(feature, variable, {}) if feature else self.sdk.get_variable(variable, {}) + except Exception as error: + raise CheckError(f"variable getter failed: {error}") from error + errors = [item for item in self.diagnostics if item.get("level") == "error"] + if errors: + diagnostic = errors[0] + raise CheckError(f'variable getter reported {diagnostic.get("code", "an error")}: {diagnostic.get("message", "evaluation failed")}') + return _at_path(value, parts) + + def resolve_feature(self, feature: str, getter: str) -> Any: + definition = (self.datafile.get("features") or {}).get(feature) + if not isinstance(definition, dict): + raise CheckError(f'feature "{feature}" is absent from the selected datafile') + self.diagnostics.clear() + try: + value = self.sdk.is_enabled(feature, {}) if getter == "enabled" else self.sdk.get_variation(feature, {}) + except Exception as error: + raise CheckError(f'feature "{feature}" {getter} getter failed: {error}') from error + errors = [item for item in self.diagnostics if item.get("level") == "error"] + if errors: + diagnostic = errors[0] + raise CheckError( + f'feature "{feature}" {getter} getter reported {diagnostic.get("code", "an error")}: ' + f'{diagnostic.get("message", "evaluation failed")}' + ) + return value + + def resolve_segment(self, segment: str) -> bool: + definition = self.segments.get(segment) + if not isinstance(definition, dict) or "conditions" not in definition: + raise CheckError(f'segment "{segment}" definition is missing from the normalized test spec') + self.diagnostics.clear() + helper = self.datafile.get("_segment_test_keys", {}).get(segment) + if helper is None: + prepared = prepare_segment_datafile(self.datafile, self.segments) + helper = prepared["_segment_test_keys"][segment] + root = getattr(self.sdk, "parent", self.sdk) + options = self.sdk._merge_options({}) if hasattr(self.sdk, "parent") else {} + options["__featurevisor_context_is_effective"] = True + dependencies = root._get_evaluation_dependencies(self.sdk.get_context(self.context), options) + reader = _InstanceEvaluationDataProvider(datafile=prepared, diagnostics=root.evaluation_diagnostics) + reader.conditions_are_matched = root._conditions_are_matched + evaluation = evaluate_with_modules({**dependencies, "datafile": reader, "type": "flag", "featureKey": helper}) + else: + evaluation = self.sdk.evaluate_flag(helper, {}) + errors = [item for item in self.diagnostics if item.get("level") == "error"] + if evaluation.get("reason") == "error" or errors: + error = evaluation.get("error") or errors[0].get("message", "evaluation failed") + raise CheckError(f'segment "{segment}" evaluation failed: {error}') + return evaluation.get("enabled") is True + + +def _is_feature_check(node: Any) -> bool: + return isinstance(node, dict) and ( + "enabled" in node + or "variation" in node + or ("feature" in node and "variable" not in node and "operator" not in node) + ) + + +def _is_segment_check(node: Any) -> bool: + return isinstance(node, dict) and ("segment" in node or "matches" in node) + + +def _validate_segment_check(node: dict[str, Any], path: str) -> None: + allowed = {"segment", "matches", "key", "description"} + unknown = next((key for key in node if key not in allowed), None) + if unknown is not None: + raise CheckError(f'{path}: segment check cannot contain property "{unknown}"') + if not isinstance(node.get("segment"), str) or not node["segment"]: + raise CheckError(f"{path}: segment check requires a nonempty segment") + if "matches" not in node or not isinstance(node["matches"], bool): + raise CheckError(f'{path}: segment "{node["segment"]}" matches must be a boolean') + + +def _validate_feature_check(node: dict[str, Any], path: str) -> None: + allowed = {"feature", "enabled", "variation", "key", "description"} + unknown = next((key for key in node if key not in allowed), None) + if unknown is not None: + raise CheckError(f'{path}: feature check cannot contain property "{unknown}"') + if not isinstance(node.get("feature"), str) or not node["feature"]: + raise CheckError(f"{path}: feature must be a nonempty string") + if "enabled" not in node and "variation" not in node: + raise CheckError(f"{path}: feature check requires enabled or variation") + if "enabled" in node and not isinstance(node["enabled"], bool): + raise CheckError(f"{path}: enabled must be a boolean") + if "variation" in node and node["variation"] is not None and not isinstance(node["variation"], str): + raise CheckError(f"{path}: variation must be a string or null") + + +def _validate_node(node: Any, owner_variable: str | None, path: str) -> None: + if not isinstance(node, dict): + raise CheckError(f"{path}: check must be an object") + if "key" in node and (not isinstance(node["key"], str) or not node["key"]): + raise CheckError(f"{path}.key: key must be a nonempty string") + if "description" in node and not isinstance(node["description"], str): + raise CheckError(f"{path}.description: description must be a string") + if _is_segment_check(node): + _validate_segment_check(node, path) + return + groups = [key for key in ("and", "or", "not") if key in node] + if groups: + allowed = {groups[0], "key", "description"} + if len(groups) != 1 or any(key not in allowed for key in node): + raise CheckError(f"{path}: group must contain exactly one of and, or or not plus optional key and description") + children = node[groups[0]] + if not isinstance(children, list) or not children: + raise CheckError(f"{path}.{groups[0]}: group must be a nonempty array") + _validate_sibling_keys(children, f"{path}.{groups[0]}") + for index, child in enumerate(children): + _validate_node(child, owner_variable, f"{path}.{groups[0]}[{index}]") + return + if _is_feature_check(node): + _validate_feature_check(node, path) + return + operator = node.get("operator") + allowed = {"variable", "feature", "path", "operator", "value", "compareTo", "regexFlags", "key", "description"} + unknown = next((key for key in node if key not in allowed), None) + if unknown is not None: + raise CheckError(f'{path}: unknown check property "{unknown}"') + if not isinstance(operator, str) or operator not in KNOWN_OPERATORS: + raise CheckError(f'{path}: unknown check operator "{operator}"') + if "feature" in node and (not isinstance(node["feature"], str) or not node["feature"]): + raise CheckError(f"{path}: feature must be a nonempty string") + if "variable" in node and (not isinstance(node["variable"], str) or not node["variable"]): + raise CheckError(f"{path}: variable must be a nonempty string") + if "feature" in node and "variable" not in node: + raise CheckError(f"{path}: a feature reference requires an explicit variable") + if not node.get("variable") and not owner_variable: + raise CheckError(f"{path}: variable reference requires an explicit variable") + if "path" in node and node["path"] is None: + raise CheckError(f"{path}: path must not be null") + _path_parts(node.get("path")) + supplied = int("value" in node) + int("compareTo" in node) + if operator in VALUELESS_OPERATORS: + if supplied: + raise CheckError(f'{path}: operator "{operator}" accepts neither value nor compareTo') + elif supplied != 1: + raise CheckError(f"{path}: exactly one of value or compareTo is required") + if "value" in node: + value = node["value"] + if operator in {"equals", "notEquals"} and not _primitive(value): + raise CheckError(f'{path}: operator "{operator}" requires a scalar value') + if operator in {"greaterThan", "greaterThanOrEquals", "lessThan", "lessThanOrEquals"} and not _membership_primitive(value): + raise CheckError(f'{path}: operator "{operator}" requires a numeric value') + if operator in {"greaterThan", "greaterThanOrEquals", "lessThan", "lessThanOrEquals"} and not isinstance(value, (int, float)): + raise CheckError(f'{path}: operator "{operator}" requires a numeric value') + if operator in {"contains", "notContains", "startsWith", "endsWith", "matches", "notMatches", "semverEquals", "semverNotEquals", "semverGreaterThan", "semverGreaterThanOrEquals", "semverLessThan", "semverLessThanOrEquals", "before", "after"} and not isinstance(value, str): + raise CheckError(f'{path}: operator "{operator}" requires a string value') + if operator in {"in", "notIn"} and (not isinstance(value, list) or not all(_membership_primitive(item) for item in value)): + raise CheckError(f'{path}: operator "{operator}" requires an array of string, number or null values') + if operator in {"includes", "notIncludes"} and not _membership_primitive(value): + raise CheckError(f'{path}: operator "{operator}" requires a scalar value') + if operator in {"includesAny", "notIncludesAny"} and (not isinstance(value, list) or not all(_primitive(item) for item in value)): + raise CheckError(f'{path}: operator "{operator}" requires an array of primitive values') + if operator in {"matches", "notMatches"}: + _regex(value, node.get("regexFlags")) + if operator in {"before", "after"}: + _date(value) + if operator.startswith("semver"): + try: + compare_versions(value, value) + except ValueError as error: + raise CheckError(str(error)) from error + if "compareTo" in node: + other = node["compareTo"] + if not isinstance(other, dict): + raise CheckError(f"{path}.compareTo: reference must be an object") + if "feature" in other and (not isinstance(other["feature"], str) or not other["feature"]): + raise CheckError(f"{path}.compareTo: feature must be a nonempty string") + if "variable" in other and (not isinstance(other["variable"], str) or not other["variable"]): + raise CheckError(f"{path}.compareTo: variable must be a nonempty string") + if "feature" in other and "variable" not in other: + raise CheckError(f"{path}.compareTo: a feature reference requires an explicit variable") + if not other.get("variable") and not owner_variable: + raise CheckError(f"{path}.compareTo: variable reference requires an explicit variable") + unknown = next((key for key in other if key not in {"variable", "feature", "path"}), None) + if unknown is not None: + raise CheckError(f'{path}.compareTo: unknown reference property "{unknown}"') + if "path" in other and other["path"] is None: + raise CheckError(f"{path}.compareTo: path must not be null") + _path_parts(other.get("path")) + if "regexFlags" in node and operator not in {"matches", "notMatches"}: + raise CheckError(f'{path}: regexFlags are only allowed with matches or notMatches') + if operator in {"matches", "notMatches"}: + flags = node.get("regexFlags", "") + if not isinstance(flags, str) or not re.fullmatch(r"[gims]*", flags) or len(set(flags)) != len(flags): + raise CheckError(f"{path}: regexFlags must contain unique characters from g, i, m and s") + + +def _missing_value() -> dict[str, str]: + return {"$type": "undefined"} + + +def _report_operand(value: Any) -> Any: + return _missing_value() if value is MISSING else value + + +def _normalized_reference(reference: dict[str, Any], owner_variable: str | None) -> dict[str, Any]: + result: dict[str, Any] = {} + variable = reference.get("variable") or owner_variable + if variable is not None: + result["variable"] = variable + if "feature" in reference: + result["feature"] = reference["feature"] + if "path" in reference: + result["path"] = reference["path"] + return result + + +def _format_path(path: list[str | int]) -> str: + rendered = "" + for index, part in enumerate(path): + if index == 0: + rendered = f"checks[{part}]" + elif isinstance(part, int): + rendered += f"[{part}]" + else: + rendered += f".{part}" + return rendered + + +def _evaluate_node( + node: dict[str, Any], + resolver: _Resolver, + path: list[str | int], + evaluated: list[dict[str, Any]], +) -> bool: + for group in ("and", "or", "not"): + if group in node: + if group in {"and", "not"}: + for index, child in enumerate(node[group]): + matched = _evaluate_node(child, resolver, [*path, group, index], evaluated) + if not matched: + return group == "not" + return group != "not" + for index, child in enumerate(node[group]): + matched = _evaluate_node(child, resolver, [*path, group, index], evaluated) + if matched: + return True + return False + if _is_segment_check(node): + trace = { + "path": path, + "reference": {"segment": node["segment"]}, + "property": "matches", + "operator": "equals", + "actual": _missing_value(), + "expected": node["matches"], + } + if "key" in node: + trace["key"] = node["key"] + if "description" in node: + trace["description"] = node["description"] + evaluated.append(trace) + actual = resolver.resolve_segment(node["segment"]) + trace["actual"] = actual + return actual is node["matches"] + if _is_feature_check(node): + feature = node["feature"] + matched = True + for property_name in ("enabled", "variation"): + if property_name not in node: + continue + trace = { + "path": path, + "reference": {"feature": feature}, + "property": property_name, + "operator": "equals", + "actual": _missing_value(), + "expected": node[property_name], + } + if "key" in node: + trace["key"] = node["key"] + if "description" in node: + trace["description"] = node["description"] + evaluated.append(trace) + actual = resolver.resolve_feature(feature, property_name) + trace["actual"] = _report_operand(actual) + matched = _strict_equal(actual, node[property_name]) and matched + return matched + left_ref = {key: node[key] for key in ("variable", "feature", "path") if key in node} + trace = { + "path": path, + "reference": _normalized_reference(left_ref, resolver.owner_variable), + "operator": node["operator"], + "actual": _missing_value(), + "expected": _missing_value() if "compareTo" in node else _report_operand(node.get("value", MISSING)), + } + if "key" in node: + trace["key"] = node["key"] + if "description" in node: + trace["description"] = node["description"] + if "compareTo" in node: + trace["compareTo"] = _normalized_reference(node["compareTo"], resolver.owner_variable) + evaluated.append(trace) + try: + left = resolver.resolve(left_ref) + except CheckError as error: + raise CheckError(f"{_format_path(path)} left operand ({_reference_label(left_ref, resolver.owner_variable)}): {error}") from error + trace["actual"] = _report_operand(left) + if left is MISSING: + trace["actualMissing"] = True + if "compareTo" in node: + right_ref = node["compareTo"] + try: + right = resolver.resolve(right_ref) + except CheckError as error: + raise CheckError(f"{_format_path(path)} compareTo operand ({_reference_label(right_ref, resolver.owner_variable)}): {error}") from error + else: + right = node.get("value", MISSING) + trace["expected"] = _report_operand(right) + if "compareTo" in node and right is MISSING: + trace["expectedMissing"] = True + return _compare(node["operator"], left, right, node.get("regexFlags")) + + +def _check_label(path: str, node: dict[str, Any]) -> str: + metadata: list[str] = [] + if "key" in node: + metadata.append(f'key={json.dumps(node["key"])}') + if "description" in node: + metadata.append(f'description={json.dumps(node["description"])}') + return f'{path} [{", ".join(metadata)}]' if metadata else path + + +def _validate_sibling_keys(nodes: list[Any], path: str) -> None: + seen: dict[str, int] = {} + for index, node in enumerate(nodes): + if not isinstance(node, dict) or "key" not in node: + continue + key = node["key"] + if not isinstance(key, str) or not key: + raise CheckError(f"{path}[{index}].key: key must be a nonempty string") + if key in seen: + raise CheckError(f'{path}[{index}].key: duplicate sibling key "{key}", first used at {path}[{seen[key]}]') + seen[key] = index + + +def _validate_reference_membership( + reference: dict[str, Any], + datafile: dict[str, Any], + owner_variable: str | None, + path: str, +) -> None: + variable = reference.get("variable") or owner_variable + feature = reference.get("feature") + if feature: + definition = (datafile.get("features") or {}).get(feature) + if not isinstance(definition, dict): + raise CheckError(f'{path}: feature "{feature}" is absent from the selected datafile') + if variable not in (definition.get("variablesSchema") or {}): + raise CheckError( + f'{path}: variable "{variable}" is absent from feature "{feature}" in the selected datafile' + ) + elif variable not in (datafile.get("variables") or {}): + raise CheckError(f'{path}: global variable "{variable}" is absent from the selected datafile') + + +def _preflight_node( + node: dict[str, Any], + datafile: dict[str, Any], + owner_variable: str | None, + segments: dict[str, Any], + path: str, +) -> None: + if _is_segment_check(node): + definition = segments.get(node["segment"]) + if not isinstance(definition, dict) or "conditions" not in definition: + raise CheckError( + f'{path}: segment "{node["segment"]}" definition is missing from the normalized test spec' + ) + try: + assert_segment_references(node["segment"], datafile, segments) + except ValueError as exc: + raise CheckError(f"{path}: {exc}") from exc + return + for group in ("and", "or", "not"): + if group in node: + for index, child in enumerate(node[group]): + _preflight_node( + child, + datafile, + owner_variable, + segments, + f"{path}.{group}[{index}]", + ) + return + if _is_feature_check(node): + if not isinstance((datafile.get("features") or {}).get(node["feature"]), dict): + raise CheckError( + f'{path}: feature "{node["feature"]}" is absent from the selected datafile' + ) + return + _validate_reference_membership(node, datafile, owner_variable, path) + if "compareTo" in node: + _validate_reference_membership( + node["compareTo"], datafile, owner_variable, f"{path}.compareTo" + ) + + +def preflight_checks( + checks: Any, + datafile: dict[str, Any], + *, + owner_variable: str | None, + segments: dict[str, Any] | None = None, + child_index: int | None = None, +) -> None: + try: + if not isinstance(checks, list) or not checks: + raise CheckError("checks must be a nonempty array") + _validate_sibling_keys(checks, "checks") + except CheckError as error: + raise CheckPreflightError(str(error), "valid checks", child_index=child_index) from error + for index, check in enumerate(checks): + try: + _validate_node(check, owner_variable, f"checks[{index}]") + except CheckError as error: + raise CheckPreflightError(str(error), "valid checks", index, child_index) from error + for index, check in enumerate(checks): + try: + _preflight_node( + check, + datafile, + owner_variable, + segments or {}, + f"checks[{index}]", + ) + except CheckError as error: + raise CheckPreflightError(str(error), "valid check references", index, child_index) from error + + +def evaluate_checks_result( + sdk: Any, + datafile: dict[str, Any], + checks: Any, + *, + owner_variable: str | None, + diagnostics: list[dict[str, Any]], + child_index: int | None = None, + segments: dict[str, Any] | None = None, + context: dict[str, Any] | None = None, +) -> dict[str, Any]: + def comparison_error( + message: str, + expected: Any, + actual: Any, + *, + check_index: int | None = None, + check: Any = None, + evaluated: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + details: dict[str, Any] = {} + if check_index is not None: + details["checkIndex"] = check_index + if isinstance(check, dict) and "key" in check: + details["checkKey"] = check["key"] + if isinstance(check, dict) and "description" in check: + details["checkDescription"] = check["description"] + if evaluated is not None: + details["evaluated"] = evaluated + if child_index is not None: + details["childIndex"] = child_index + return { + "type": "check", + "message": message, + "expected": expected, + "actual": actual, + "details": details, + } + + def result(errors: list[dict[str, Any]], has_error: bool) -> dict[str, Any]: + return { + "passed": not errors, + "error": has_error, + "failures": [error["message"] for error in errors], + "errors": errors, + } + + try: + preflight_checks( + checks, + datafile, + owner_variable=owner_variable, + segments=segments, + child_index=child_index, + ) + except CheckPreflightError as error: + return result([comparison_error( + str(error), error.expected, str(error), + check_index=error.details.get("checkIndex"), + )], True) + except CheckError as error: + return result([comparison_error(str(error), "valid checks", checks)], True) + resolver = _Resolver(sdk, datafile, diagnostics, owner_variable, segments or {}, context or {}) + errors: list[dict[str, Any]] = [] + has_error = False + for index, check in enumerate(checks): + evaluated: list[dict[str, Any]] = [] + try: + matched = _evaluate_node(check, resolver, [index], evaluated) + if not matched: + label = _check_label(f"checks[{index}]", check) + if evaluated and all(leaf.get("reference", {}).get("segment") for leaf in evaluated): + evidence = ", ".join( + f'segment "{leaf["reference"]["segment"]}": expected matches={_json(leaf["expected"])}, ' + f'actual matches={_json(leaf["actual"])}' + for leaf in evaluated + ) + message = f"{label}: segment expectations failed ({evidence})" + elif evaluated and all(leaf.get("reference", {}).get("feature") and leaf.get("property") for leaf in evaluated): + evidence = ", ".join( + f'expected {leaf["property"]}={_json(leaf["expected"])}, ' + f'actual {leaf["property"]}={_json(leaf["actual"])}' + for leaf in evaluated + ) + message = f'{label}: feature "{evaluated[0]["reference"]["feature"]}" expectations failed ({evidence})' + elif evaluated and all(leaf.get("property") for leaf in evaluated): + evidence = ", ".join( + f'{"segment" if "segment" in leaf["reference"] else "feature"} ' + f'"{leaf["reference"].get("segment") or leaf["reference"].get("feature")}": ' + f'expected {leaf["property"]}={_json(leaf["expected"])}, ' + f'actual {leaf["property"]}={_json(leaf["actual"])}' + for leaf in evaluated + ) + message = f"{label}: expectations failed ({evidence})" + else: + evidence = "; ".join( + f'{_reference_label(leaf["reference"], None)} {leaf["operator"]} failed ' + f'(left={_json(leaf["actual"])}, right={_json(leaf["expected"])})' + for leaf in evaluated + ) + message = f"{label}: {evidence}" + errors.append( + comparison_error( + message, + True, + False, + check_index=index, + check=check, + evaluated=evaluated, + ) + ) + except CheckError as error: + has_error = True + errors.append( + comparison_error( + str(error), + "successful comparison", + str(error), + check_index=index, + check=check, + evaluated=evaluated, + ) + ) + return result(errors, has_error) + + +def evaluate_checks( + sdk: Any, + datafile: dict[str, Any], + checks: Any, + *, + owner_variable: str | None, + diagnostics: list[dict[str, Any]], + child_index: int | None = None, + segments: dict[str, Any] | None = None, + context: dict[str, Any] | None = None, +) -> list[str]: + return evaluate_checks_result( + sdk, + datafile, + checks, + owner_variable=owner_variable, + diagnostics=diagnostics, + child_index=child_index, + segments=segments, + context=context, + )["failures"] diff --git a/src/featurevisor/tester.py b/src/featurevisor/tester.py index 5cbcb38..24c5fa4 100644 --- a/src/featurevisor/tester.py +++ b/src/featurevisor/tester.py @@ -1,14 +1,19 @@ from __future__ import annotations import json +import os +import shlex +import subprocess +import sys import time import uuid +from collections.abc import Sequence from typing import Any -from .evaluation_data_provider import _InstanceEvaluationDataProvider +from .segment_tests import assert_segment_references, prepare_segment_datafile from .instance import create_featurevisor -from .diagnostics import _create_evaluation_diagnostics -from .project import FeaturevisorProject, pretty_duration, timed_build +from .project import FeaturevisorProject, FeaturevisorProjectError, pretty_duration, timed_build +from .test_checks import CheckError, CheckPreflightError, evaluate_checks_result, preflight_checks def _stringify_value(value: Any) -> str: @@ -87,21 +92,30 @@ def _log_level(verbose: bool = False, quiet: bool = False) -> str: return "warn" -def _get_base_datafile_key(assertion: dict[str, Any]) -> str | bool: +def _check_log_level(assertion: dict[str, Any], verbose: bool, quiet: bool) -> str: + return "error" if "checks" in assertion or assertion.get("children") else _log_level(verbose, quiet) + + +def _get_base_datafile_key(assertion: dict[str, Any]) -> tuple[str | None, None]: environment = assertion.get("environment") - return environment if environment is not None else False + return (environment if isinstance(environment, str) else None, None) -def _get_target_datafile_key(environment: str | bool | None, target: str) -> str: - return f"{environment}-target-{target}" if environment else f"false-target-{target}" +def _get_target_datafile_key(environment: str | bool | None, target: str) -> tuple[str | None, str]: + return (environment if isinstance(environment, str) else None, target) -def _get_datafile_for_assertion(assertion: dict[str, Any], cache: dict[Any, dict[str, Any]]) -> dict[str, Any] | None: +def _get_datafile_for_assertion(assertion: dict[str, Any], cache: dict[tuple[str | None, str | None], dict[str, Any]]) -> dict[str, Any] | None: key = _get_base_datafile_key(assertion) - target_key = _get_target_datafile_key(key, assertion["target"]) if assertion.get("target") else None + target_key = _get_target_datafile_key(assertion.get("environment"), assertion["target"]) if assertion.get("target") else None return cache.get(target_key if target_key else key) +def _get_authored_segments(test: dict[str, Any]) -> dict[str, Any]: + segments = test.get("_segments") + return segments if isinstance(segments, dict) else {} + + def _assert_feature(sdk, feature_key: str, assertion: dict[str, Any], datafile: dict[str, Any], result: dict[str, Any], assertion_result: dict[str, Any], *, child_index: int | None = None) -> None: context = assertion.get("context", {}) details_base = {} @@ -136,7 +150,7 @@ def _assert_feature(sdk, feature_key: str, assertion: dict[str, Any], datafile: if not variable_schema: result["passed"] = False assertion_result["passed"] = False - assertion_result["errors"].append({"type": "variable", "expected": expected, "actual": None, "message": f'schema for variable "{variable_key}" not found in feature'}) + assertion_result["errors"].append({"type": "variable", "expected": expected, "actual": None, "details": {**details_base, "variableKey": variable_key}, "message": f'schema for variable "{variable_key}" not found in feature'}) continue passed = _compare_jsonish(json.loads(expected) if variable_schema.get("type") == "json" and isinstance(expected, str) else expected, actual) if not passed: @@ -161,10 +175,16 @@ def _assert_feature(sdk, feature_key: str, assertion: dict[str, Any], datafile: if actual != expected: result["passed"] = False assertion_result["passed"] = False - assertion_result["errors"].append({"type": "evaluation", "expected": expected, "actual": actual, "details": {**details_base, "evaluationType": "variable", "evaluationKey": variable_key}}) + assertion_result["errors"].append({"type": "evaluation", "expected": expected, "actual": actual, "details": {**details_base, "evaluationType": "variable", "variableKey": variable_key, "evaluationKey": key}}) + +def _append_check_errors(result: dict[str, Any], assertion_result: dict[str, Any], errors: list[dict[str, Any]]) -> None: + if errors: + result["passed"] = assertion_result["passed"] = False + assertion_result["errors"].extend(errors) -def _assert_global_variable(sdk, variable_key: str, assertion: dict[str, Any], result: dict[str, Any], assertion_result: dict[str, Any], *, child_index: int | None = None) -> None: + +def _assert_global_variable(sdk, variable_key: str, assertion: dict[str, Any], datafile: dict[str, Any], diagnostics: list[dict[str, Any]], result: dict[str, Any], assertion_result: dict[str, Any], *, child_index: int | None = None, segments: dict[str, Any] | None = None, context: dict[str, Any] | None = None) -> None: options = {} if "defaultVariableValue" in assertion: options["defaultVariableValue"] = assertion["defaultVariableValue"] @@ -182,6 +202,62 @@ def _assert_global_variable(sdk, variable_key: str, assertion: dict[str, Any], r if not _compare_jsonish(expected, actual): result["passed"] = assertion_result["passed"] = False assertion_result["errors"].append({"type": "evaluation", "expected": expected, "actual": actual, "details": {**details, "evaluationType": "variable", "evaluationKey": key}}) + if "checks" in assertion: + _append_check_errors( + result, + assertion_result, + evaluate_checks_result( + sdk, + datafile, + assertion["checks"], + owner_variable=variable_key, + diagnostics=diagnostics, + child_index=child_index, + segments=segments, + context=context, + )["errors"], + ) + + +def _assert_scenario(sdk, assertion: dict[str, Any], datafile: dict[str, Any], diagnostics: list[dict[str, Any]], result: dict[str, Any], assertion_result: dict[str, Any], *, segments: dict[str, Any] | None = None) -> None: + root_context = dict(assertion.get("context", {})) + if "checks" in assertion: + _append_check_errors( + result, + assertion_result, + evaluate_checks_result( + sdk, datafile, assertion["checks"], owner_variable=None, diagnostics=diagnostics, + segments=segments, context=root_context, + )["errors"], + ) + elif not assertion.get("children"): + _append_check_errors( + result, + assertion_result, + [{"type": "check", "message": "scenario assertion requires checks or children"}], + ) + for index, child_assertion in enumerate(assertion.get("children", [])): + child = sdk.spawn(child_assertion.get("context", {}), { + "stickyFeatures": child_assertion.get("stickyFeatures", {}), + "stickyVariables": child_assertion.get("stickyVariables", {}), + }) + try: + _append_check_errors( + result, + assertion_result, + evaluate_checks_result( + child, + datafile, + child_assertion.get("checks"), + owner_variable=None, + diagnostics=diagnostics, + child_index=index, + segments=segments, + context={**root_context, **child_assertion.get("context", {})}, + )["errors"], + ) + finally: + child.close() def _missing_datafile_assertion(assertion: dict[str, Any]) -> dict[str, Any]: @@ -195,24 +271,57 @@ def _missing_datafile_assertion(assertion: dict[str, Any]) -> dict[str, Any]: } -def test_segment(segment: dict[str, Any], assertion_options: dict[str, Any] | None = None) -> dict[str, Any]: - options = assertion_options or {} - diagnostics = _create_evaluation_diagnostics() - reader = _InstanceEvaluationDataProvider(datafile={"schemaVersion": "2", "revision": "tester", "segments": {}, "features": {}}, diagnostics=diagnostics) - result = {"type": "segment", "key": segment["segment"], "notFound": False, "passed": True, "duration": 0, "assertions": []} - start = time.perf_counter() - for assertion in segment["assertions"]: - assertion_start = time.perf_counter() - actual = reader.all_conditions_are_matched(segment["conditions"], assertion.get("context", {})) - passed = actual == assertion["expectedToMatch"] - assertion_result = {"description": assertion.get("description", ""), "duration": 0, "passed": passed, "errors": []} - if not passed: - result["passed"] = False - assertion_result["errors"].append({"type": "segment", "expected": assertion["expectedToMatch"], "actual": actual}) - assertion_result["duration"] = int((time.perf_counter() - assertion_start) * 1000) - result["assertions"].append(assertion_result) - result["duration"] = int((time.perf_counter() - start) * 1000) - return result +def _preflight_assertion( + test: dict[str, Any], + assertion: dict[str, Any], + datafile: dict[str, Any], + segments: dict[str, Any], +) -> None: + feature_key = test.get("feature") + variable_key = test.get("variable") + children = assertion.get("children", []) + if not isinstance(children, list): + raise CheckError("children must be an array") + for index, child in enumerate(children): + if not isinstance(child, dict): + raise CheckError(f"children[{index}] must be an object") + + if feature_key: + feature = (datafile.get("features") or {}).get(feature_key) + variable_schema = (feature.get("variablesSchema") or {}) if isinstance(feature, dict) else {} + for current in [assertion, *children]: + expected_variables = current.get("expectedVariables") or {} + if not isinstance(expected_variables, dict): + raise CheckError("expectedVariables must be an object") + for expected_variable in expected_variables: + if expected_variable not in variable_schema: + raise CheckError( + f'variable "{expected_variable}" is absent from feature "{feature_key}" in the selected datafile' + ) + owner_variable = variable_key if isinstance(variable_key, str) else None + if "checks" in assertion: + preflight_checks( + assertion["checks"], datafile, owner_variable=owner_variable, segments=segments + ) + for index, child in enumerate(children): + if "checks" in child or test.get("scenario"): + preflight_checks( + child.get("checks"), datafile, owner_variable=owner_variable, segments=segments, child_index=index + ) + + +def _preflight_failure(assertion: dict[str, Any], error: CheckError) -> dict[str, Any]: + return { + "description": assertion.get("description", ""), + "duration": 0, + "passed": False, + "errors": [{ + "type": "check", "message": str(error), + "expected": error.expected if isinstance(error, CheckPreflightError) else "valid check references", + "actual": str(error), + "details": error.details if isinstance(error, CheckPreflightError) else {}, + }], + } def _resolve_targets(project: FeaturevisorProject, requested: list[str] | None) -> list[str]: @@ -224,29 +333,363 @@ def _resolve_targets(project: FeaturevisorProject, requested: list[str] | None) return selected -def run_test_project(project_directory_path: str, *, key_pattern: str | None = None, assertion_pattern: str | None = None, verbose: bool = False, quiet: bool = False, show_datafile: bool = False, only_failures: bool = False, schema_version: str | None = None, inflate: int = 0, with_scopes: bool = False, with_tags: bool = False, targets: list[str] | None = None) -> bool: - project = FeaturevisorProject(project_directory_path) +def _empty_report() -> dict[str, Any]: + return { + "schemaVersion": 1, + "runner": "python", + "passed": True, + "summary": { + "specs": {"passed": 0, "failed": 0}, + "assertions": {"passed": 0, "failed": 0}, + "entitySpecs": {"passed": 0, "failed": 0}, + "entityAssertions": {"passed": 0, "failed": 0}, + "scenarioSpecs": {"passed": 0, "failed": 0}, + "scenarioCases": {"passed": 0, "failed": 0}, + }, + "results": [], + "errors": [], + } + + +def _selected_sets(project_directory_path: str, config: dict[str, Any], set_name: str | None) -> Sequence[str | None]: + if set_name is not None: + return [set_name] + if not config.get("sets"): + return [None] + directory = config.get("setsDirectoryPath") or "sets" + path = os.path.join(project_directory_path, directory) + sets = sorted(item for item in os.listdir(path) if os.path.isdir(os.path.join(path, item))) + if not sets: + raise ValueError(f"No sets found in {directory}.") + return sets + + +def _coverage_module(assertion: dict[str, Any], set_name: str | None, selections: list[dict[str, Any]]) -> dict[str, Any]: + def after_evaluation(evaluation: dict[str, Any], _options: dict[str, Any]) -> dict[str, Any]: + if evaluation.get("featureKey") in getattr(_options.get("datafile"), "synthetic_keys", set()): + return evaluation + rule_key = evaluation.get("ruleKey") + paths = list(evaluation.get("variableOverridePaths") or []) + if not paths and evaluation.get("variableOverridePath") is not None: + paths = [evaluation["variableOverridePath"]] + if not paths and evaluation.get("variableOverrideKey") is not None: + paths = [evaluation["variableOverrideKey"]] + if not paths and rule_key is not None: + paths = [None] + for path in paths: + if rule_key is None and path is None: + continue + item: dict[str, Any] = { + "set": set_name, + "environment": assertion.get("environment"), + "target": assertion.get("target"), + } + for source, destination in (("featureKey", "featureKey"), ("variableKey", "variableKey"), ("ruleKey", "ruleKey")): + if evaluation.get(source) is not None: + item[destination] = evaluation[source] + if path is not None: + item["overridePath"] = path + selections.append(item) + return evaluation + + return { + "name": "tester", + "bucketValue": lambda opts, at=assertion.get("at"): int(at * 1000) if at is not None else opts["bucketValue"], + "afterEvaluation": after_evaluation, + } + + +def _case_result( + assertion: dict[str, Any], + assertion_result: dict[str, Any], + result_type: str, + set_name: str | None, + targets: list[str], + project_directory_path: str | None, +) -> dict[str, Any]: + case_metadata = assertion.get("_case") or {} + args = ["test"] + if case_metadata.get("id") is not None: + args.append(f'--case={case_metadata["id"]}') + if project_directory_path is not None: + args.append(f"--projectDirectoryPath={project_directory_path}") + if set_name is not None: + args.append(f"--set={set_name}") + args.extend(f"--target={target}" for target in targets) + errors = [] + for error in assertion_result.get("errors", []): + item = dict(error) + if item.get("type") == "check" and item.get("expected") is True and item.get("actual") is False: + item["message"] = "Check did not match" + elif not item.get("message"): + item["message"] = ( + f'expected {_stringify_value(error.get("expected"))}, ' + f'received {_stringify_value(error.get("actual"))}' + ) + if item.get("type") == "check" and isinstance(item.get("details"), dict): + details = dict(item["details"]) + base: list[Any] = [] if result_type == "scenario" else [ + "assertions", case_metadata.get("assertionIndex", 0) + ] + if "childIndex" in details: + base.extend(["children", details["childIndex"]]) + collection = "checks" + item["path"] = [*base, collection] + if "checkIndex" in details: + item["path"].append(details["checkIndex"]) + if isinstance(details.get("evaluated"), list): + details["evaluated"] = [ + {**leaf, "path": [*base, collection, *leaf.get("path", [])]} + for leaf in details["evaluated"] + ] + item["details"] = details + elif item.get("type") in {"flag", "variation", "variable", "evaluation", "segment"}: + raw_details = item.get("details") + details = raw_details if isinstance(raw_details, dict) else {} + base = ["assertions", case_metadata.get("assertionIndex", 0)] + if "childIndex" in details: + base.extend(["children", details["childIndex"]]) + if item["type"] == "flag": + suffix: list[Any] = ["expectedToBeEnabled"] + elif item["type"] == "variation": + suffix = ["expectedVariation"] + elif item["type"] == "segment": + suffix = ["expectedToMatch"] + elif item["type"] == "variable": + suffix = ["expectedValue"] if result_type == "variable" else [ + "expectedVariables", details.get("variableKey") + ] + elif result_type == "variable": + suffix = ["expectedEvaluation", details.get("evaluationKey")] + elif "variableKey" in details: + suffix = [ + "expectedEvaluations", "variables", details["variableKey"], details.get("evaluationKey") + ] + else: + suffix = ["expectedEvaluations", details.get("evaluationType"), details.get("evaluationKey")] + item["path"] = [*base, *(part for part in suffix if part is not None)] + errors.append(item) + return { + "id": case_metadata.get("id"), + "description": assertion.get("description"), + "environment": assertion.get("environment"), + "target": assertion.get("target"), + "context": assertion.get("context") if isinstance(assertion.get("context"), dict) else {}, + "matrixValues": case_metadata.get("matrixValues") or {}, + "passed": assertion_result["passed"], + "errors": errors, + "rerun": {"command": f"{shlex.quote(sys.executable)} -m featurevisor", "args": args}, + } + + +def _finalize_report(report: dict[str, Any]) -> None: + summary = _empty_report()["summary"] + for result in report["results"]: + spec_bucket = "scenarioSpecs" if result["type"] == "scenario" else "entitySpecs" + case_bucket = "scenarioCases" if result["type"] == "scenario" else "entityAssertions" + spec_state = "passed" if result["passed"] else "failed" + summary["specs"][spec_state] += 1 + summary[spec_bucket][spec_state] += 1 + for case in result["cases"]: + case_state = "passed" if case["passed"] else "failed" + summary["assertions"][case_state] += 1 + summary[case_bucket][case_state] += 1 + report["summary"] = summary + report["passed"] = not report["errors"] and summary["specs"]["failed"] == 0 and summary["assertions"]["failed"] == 0 + + +def _human_path(path: Any) -> str: + return ".".join(str(part + 1 if isinstance(part, int) else part) for part in (path or [])) + + +def _human_reference(reference: Any) -> str: + if not isinstance(reference, dict): + return "" + if reference.get("segment"): + return f'segment {reference["segment"]}' + if reference.get("property"): + return f'feature {reference.get("feature")}, {reference["property"]}' + variable = reference.get("variable") + root = (f'feature {reference["feature"]}, ' if reference.get("feature") else "global ") + f"variable {variable}" + if "path" in reference: + return f'{root} at {json.dumps(reference["path"], separators=(",", ":"))}' + return root + + +def _human_error_lines(error: dict[str, Any]) -> list[str]: + details = error.get("details") + if error.get("type") != "check" or not isinstance(details, dict): + path = error.get("path") or [] + if "children" in path: + child_at = path.index("children") + child = path[child_at + 1] + section = _human_path(path[child_at + 2:]) + label = f"Child {child + 1}, {section}" + else: + label = _human_path(path[2:] if path[:1] == ["assertions"] else path) + message = error.get("message") or f'expected {_stringify_value(error.get("expected"))}, received {_stringify_value(error.get("actual"))}' + return [f"{label}: {message}" if label else message] + named = f' "{details["checkKey"]}"' if details.get("checkKey") else "" + description = f' ({details["checkDescription"]})' if details.get("checkDescription") else "" + child = f'Child {details["childIndex"] + 1}, ' if isinstance(details.get("childIndex"), int) else "" + lines = [f'{child}check{named}{description}: {error.get("message", "Check did not match")} at {_human_path(error.get("path"))}'] + for leaf in details.get("evaluated") or []: + reference = _human_reference(leaf.get("reference")) + if leaf.get("property"): + reference = f'{reference}.{leaf["property"]}' + comparison = _human_reference(leaf.get("compareTo")) if leaf.get("compareTo") else "literal" + lines.append( + f'{_human_path(leaf.get("path"))}: {reference} {leaf.get("operator")} {comparison} ' + f'(left={json.dumps(leaf.get("actual"), separators=(",", ":"))}, ' + f'right={json.dumps(leaf.get("expected"), separators=(",", ":"))})' + ) + return lines + + +def _human_case_label(case: dict[str, Any], index: int) -> str: + label = case.get("description") or f"assertion {index + 1}" + details = [] + if case.get("matrixValues"): + values = ", ".join(f"{key}={json.dumps(value, separators=(',', ':'))}" for key, value in case["matrixValues"].items()) + details.append(f"matrix {values}") + if case.get("target"): + details.append(f'target {case["target"]}') + return f'{label} ({"; ".join(details)})' if details else label + + +def _human_rerun(rerun: dict[str, Any]) -> tuple[str, str]: + if os.name == "nt": + quote = lambda value: "'" + str(value).replace("'", "''") + "'" + return "PowerShell", rerun["command"] + " " + " ".join(quote(arg) for arg in rerun["args"]) + quote = lambda value: "'" + str(value).replace("'", "'\\''") + "'" + return "POSIX shell", rerun["command"] + " " + " ".join(quote(arg) for arg in rerun["args"]) + + +def _print_report(report: dict[str, Any], *, json_output: bool, pretty: bool, only_failures: bool) -> None: + if json_output: + print(json.dumps(report, indent=2 if pretty else None, separators=None if pretty else (",", ":"))) + return + if not report["results"] and not report["errors"]: + print("No tests found") + for result in report["results"]: + if only_failures and result["passed"]: + continue + print(f'\nTesting: {result["source"]}') + print(f' {result["type"]} "{result["key"]}":') + for index, case in enumerate(result["cases"]): + if only_failures and case["passed"]: + continue + print(f' {"✔" if case["passed"] else "✘"} {_human_case_label(case, index)}') + for error in case["errors"]: + for line in _human_error_lines(error): + print(f" ✘ {line}") + if not case["passed"]: + shell, rendered = _human_rerun(case["rerun"]) + print(f' Rerun ({shell}): {rendered}') + for error in report["errors"]: + print(f'Error: {error["message"]}') + summary = report["summary"] + print("") + print(f'Entity specs: {summary["entitySpecs"]["passed"]} passed, {summary["entitySpecs"]["failed"]} failed') + print(f'Entity assertions: {summary["entityAssertions"]["passed"]} passed, {summary["entityAssertions"]["failed"]} failed') + print(f'Scenario specs: {summary["scenarioSpecs"]["passed"]} passed, {summary["scenarioSpecs"]["failed"]} failed') + print(f'Scenario cases: {summary["scenarioCases"]["passed"]} passed, {summary["scenarioCases"]["failed"]} failed') + print(f'Total cases: {summary["assertions"]["passed"]} passed, {summary["assertions"]["failed"]} failed') + if "uncovered" in report: + print("\nDefinitions without associated specs (not evaluation coverage):") + for item in report.get("uncovered", []): + if item["set"]: + print(f' Set: {item["set"]}') + print(f' Features: {", ".join(item["features"]) or "none"}') + print(f' Global variables: {", ".join(item["variables"]) or "none"}') + if "coverage" in report: + print("\nObserved rule and override selections (not branch coverage):") + for selection in report["coverage"]["selections"]: + owner = (f'feature {selection["featureKey"]}' + (f' variable {selection["variableKey"]}' if selection.get("variableKey") else "")) if selection.get("featureKey") else f'global variable {selection.get("variableKey")}' + scope = ", ".join(f'{key} {selection[key]}' for key in ("set", "environment", "target") if selection.get(key)) + parts = [] + if selection.get("ruleKey"): + parts.append(f'rule {selection["ruleKey"]}') + if "variation" in selection: + parts.append(f'variation {json.dumps(selection["variation"])}') + if selection.get("overridePath"): + parts.append("override " + " > ".join(map(str, selection["overridePath"]))) + elif selection.get("overrideIndex") is not None: + parts.append(f'override {selection["overrideIndex"] + 1}') + print(f' {owner}{f" ({scope})" if scope else ""}: {", ".join(parts)}') + print("") + + +def run_test_project(project_directory_path: str, *, key_pattern: str | None = None, assertion_pattern: str | None = None, entity_type: str | None = None, verbose: bool = False, quiet: bool = False, show_datafile: bool = False, only_failures: bool = False, schema_version: str | None = None, inflate: int = 0, with_scopes: bool = False, with_tags: bool = False, targets: list[str] | None = None, set_name: str | None = None, json_output: bool = False, pretty: bool = False, case_id: str | None = None, coverage: bool = False, uncovered: bool = False, rerun_project_path: str | None = None) -> bool: + report = _empty_report() + selections: list[dict[str, Any]] = [] + try: + root_config = FeaturevisorProject(project_directory_path).get_config() + matched_case = case_id is None + for current_set in _selected_sets(project_directory_path, root_config, set_name): + project = FeaturevisorProject(project_directory_path, set_name=current_set) + if uncovered: + report.setdefault("uncovered", []).append({ + "set": current_set, + "features": project.list_uncovered("features"), + "variables": project.list_uncovered("variables"), + }) + try: + results = _run_test_project_set( + project, + key_pattern=key_pattern, + assertion_pattern=assertion_pattern, + entity_type=entity_type, + verbose=verbose, + quiet=quiet, + show_datafile=show_datafile, + inflate=inflate, + targets=targets, + case_id=case_id, + coverage=coverage, + coverage_selections=selections, + json_output=json_output, + rerun_project_path=rerun_project_path, + ) + except FeaturevisorProjectError as error: + if case_id is not None and set_name is None and error.code == "test_case_not_found": + continue + raise + matched_case = True + report["results"].extend(results) + if not matched_case: + raise ValueError("No test case matches --case and the selected filters") + except (OSError, ValueError, subprocess.SubprocessError, json.JSONDecodeError) as error: + report["errors"].append({"message": str(error)}) + if coverage: + unique = {json.dumps(item, sort_keys=True, separators=(",", ":")): item for item in selections} + report["coverage"] = {"mode": "observed-selections", "selections": [unique[key] for key in sorted(unique)]} + if uncovered: + report.setdefault("uncovered", []).sort(key=lambda item: item["set"] or "") + _finalize_report(report) + _print_report(report, json_output=json_output, pretty=pretty, only_failures=only_failures) + return bool(report["passed"]) + + +def _run_test_project_set(project: FeaturevisorProject, *, key_pattern: str | None = None, assertion_pattern: str | None = None, entity_type: str | None = None, verbose: bool = False, quiet: bool = False, show_datafile: bool = False, inflate: int = 0, targets: list[str] | None = None, case_id: str | None = None, coverage: bool = False, coverage_selections: list[dict[str, Any]] | None = None, json_output: bool = False, rerun_project_path: str | None = None) -> list[dict[str, Any]]: + coverage_selections = coverage_selections if coverage_selections is not None else [] config = project.get_config() - tests = project.list_tests(key_pattern=key_pattern, assertion_pattern=assertion_pattern) + tests = project.list_tests(key_pattern=key_pattern, assertion_pattern=assertion_pattern, entity_type=entity_type, case_id=case_id) features_by_key = {item["key"]: item for item in project.list_features()} segments_by_key = {item["key"]: item for item in project.list_segments()} selected_targets = _resolve_targets(project, targets) targets_to_build = selected_targets or project.list_targets() - datafile_cache: dict[Any, dict[str, Any]] = {} - start = time.perf_counter() - passed_tests_count = 0 - failed_tests_count = 0 - passed_assertions_count = 0 - failed_assertions_count = 0 + datafile_cache: dict[tuple[str | None, str | None], dict[str, Any]] = {} + report_results: list[dict[str, Any]] = [] environments = config.get("environments") if environments is False: environments = [None] for environment in environments or [None]: - base_key = environment if environment is not None else False + base_key = _get_base_datafile_key({"environment": environment}) datafile_cache[base_key] = project.build_datafile_json(environment=environment, inflate=inflate or None) for target in targets_to_build: - datafile_cache[_get_target_datafile_key(base_key, target)] = project.build_datafile_json( + datafile_cache[_get_target_datafile_key(environment, target)] = project.build_datafile_json( environment=environment, inflate=inflate or None, target=target, @@ -254,46 +697,80 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N passed = True for test in tests: - if test.get("variable"): + executed_assertions = list(test.get("assertions") or []) + discriminators = [key for key in ("feature", "variable", "segment", "scenario") if key in test] + if len(discriminators) != 1: + executed_assertions = [{"description": "malformed test spec"}] + result = { + "type": "test", "key": test.get("key", "malformed"), "notFound": False, "passed": False, "duration": 0, + "assertions": [{"description": "malformed test spec", "duration": 0, "passed": False, "errors": [{"type": "check", "message": "test spec requires exactly one of feature, variable, segment or scenario"}]}], + } + elif "scenario" in test and not test.get("assertions"): + executed_assertions = [{"description": "malformed scenario spec"}] + result = { + "type": "scenario", "key": test["scenario"], "notFound": False, "passed": False, "duration": 0, + "assertions": [{"description": "malformed scenario spec", "duration": 0, "passed": False, "errors": [{"type": "check", "message": "scenario spec requires assertions"}]}], + } + elif test.get("variable"): assertions = [ assertion for assertion in test["assertions"] if not selected_targets or not assertion.get("target") or assertion.get("target") in selected_targets ] if not assertions: continue + executed_assertions = assertions variable_key = test["variable"] + test_segments = _get_authored_segments(test) result = {"type": "variable", "key": variable_key, "notFound": False, "passed": True, "duration": 0, "assertions": []} variable_start = time.perf_counter() for assertion in assertions: assertion_start = time.perf_counter() datafile = _get_datafile_for_assertion(assertion, datafile_cache) + if datafile is not None: + datafile = prepare_segment_datafile(datafile, _get_authored_segments(test)) if datafile is None: result["passed"] = False result["assertions"].append(_missing_datafile_assertion(assertion)) continue + try: + _preflight_assertion(test, assertion, datafile, test_segments) + except CheckError as error: + result["passed"] = False + result["assertions"].append(_preflight_failure(assertion, error)) + continue if show_datafile: - print(json.dumps(datafile, indent=2)) + print(json.dumps(datafile, indent=2), file=sys.stderr if json_output else sys.stdout) + diagnostics: list[dict[str, Any]] = [] sdk = create_featurevisor({ "datafile": datafile, "context": assertion.get("context", {}), "stickyFeatures": assertion.get("stickyFeatures", {}), "stickyVariables": assertion.get("stickyVariables", {}), - "modules": [{ + "modules": [_coverage_module(assertion, project.set_name, coverage_selections)] if coverage else [{ "name": "tester", "bucketValue": lambda opts, at=assertion.get("at"): int(at * 1000) if at is not None else opts["bucketValue"], }], - "logLevel": _log_level(verbose, quiet), + "logLevel": _check_log_level(assertion, verbose, quiet), + "onDiagnostic": diagnostics.append, }) assertion_result = {"description": assertion.get("description", ""), "duration": 0, "passed": True, "errors": []} try: - _assert_global_variable(sdk, variable_key, assertion, result, assertion_result) + root_context = dict(assertion.get("context", {})) + _assert_global_variable( + sdk, variable_key, assertion, datafile, diagnostics, result, assertion_result, + segments=test_segments, context=root_context, + ) for index, child_assertion in enumerate(assertion.get("children", [])): child = sdk.spawn(child_assertion.get("context", {}), { "stickyFeatures": child_assertion.get("stickyFeatures", {}), "stickyVariables": child_assertion.get("stickyVariables", {}), }) try: - _assert_global_variable(child, variable_key, child_assertion, result, assertion_result, child_index=index) + _assert_global_variable( + child, variable_key, child_assertion, datafile, diagnostics, result, assertion_result, + child_index=index, segments=test_segments, + context={**root_context, **child_assertion.get("context", {})}, + ) finally: child.close() finally: @@ -301,6 +778,56 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N assertion_result["duration"] = int((time.perf_counter() - assertion_start) * 1000) result["assertions"].append(assertion_result) result["duration"] = int((time.perf_counter() - variable_start) * 1000) + elif test.get("scenario"): + assertions = [ + assertion for assertion in test["assertions"] + if not selected_targets or not assertion.get("target") or assertion.get("target") in selected_targets + ] + if not assertions: + continue + executed_assertions = assertions + scenario_key = test["scenario"] + test_segments = _get_authored_segments(test) + result = {"type": "scenario", "key": scenario_key, "notFound": False, "passed": True, "duration": 0, "assertions": []} + scenario_start = time.perf_counter() + for assertion in assertions: + assertion_start = time.perf_counter() + datafile = _get_datafile_for_assertion(assertion, datafile_cache) + if datafile is not None: + datafile = prepare_segment_datafile(datafile, _get_authored_segments(test)) + if datafile is None: + result["passed"] = False + result["assertions"].append(_missing_datafile_assertion(assertion)) + continue + try: + _preflight_assertion(test, assertion, datafile, test_segments) + except CheckError as error: + result["passed"] = False + result["assertions"].append(_preflight_failure(assertion, error)) + continue + if show_datafile: + print(json.dumps(datafile, indent=2), file=sys.stderr if json_output else sys.stdout) + diagnostics = [] + sdk = create_featurevisor({ + "datafile": datafile, + "context": assertion.get("context", {}), + "stickyFeatures": assertion.get("stickyFeatures", {}), + "stickyVariables": assertion.get("stickyVariables", {}), + "modules": [_coverage_module(assertion, project.set_name, coverage_selections)] if coverage else [{ + "name": "tester", + "bucketValue": lambda opts, at=assertion.get("at"): int(at * 1000) if at is not None else opts["bucketValue"], + }], + "logLevel": _check_log_level(assertion, verbose, quiet), + "onDiagnostic": diagnostics.append, + }) + assertion_result = {"description": assertion.get("description", ""), "duration": 0, "passed": True, "errors": []} + try: + _assert_scenario(sdk, assertion, datafile, diagnostics, result, assertion_result, segments=test_segments) + finally: + sdk.close() + assertion_result["duration"] = int((time.perf_counter() - assertion_start) * 1000) + result["assertions"].append(assertion_result) + result["duration"] = int((time.perf_counter() - scenario_start) * 1000) elif test.get("feature"): assertions = [ assertion for assertion in test["assertions"] @@ -308,6 +835,7 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N ] if not assertions: continue + executed_assertions = assertions feature_key = test["feature"] result = {"type": "feature", "key": feature_key, "notFound": False, "passed": True, "duration": 0, "assertions": []} feature_start = time.perf_counter() @@ -317,23 +845,31 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N for assertion in assertions: assertion_start = time.perf_counter() datafile = _get_datafile_for_assertion(assertion, datafile_cache) + if datafile is not None: + datafile = prepare_segment_datafile(datafile, _get_authored_segments(test)) if datafile is None: result["passed"] = False result["assertions"].append(_missing_datafile_assertion(assertion)) continue + try: + _preflight_assertion(test, assertion, datafile, {}) + except CheckError as error: + result["passed"] = False + result["assertions"].append(_preflight_failure(assertion, error)) + continue if show_datafile: - print(json.dumps(datafile, indent=2)) + print(json.dumps(datafile, indent=2), file=sys.stderr if json_output else sys.stdout) sdk = create_featurevisor({ "datafile": datafile, - "stickyFeatures": assertion.get("stickyFeatures") or assertion.get("sticky", {}), + "stickyFeatures": assertion.get("stickyFeatures", {}), "stickyVariables": assertion.get("stickyVariables", {}), - "modules": [ + "modules": [_coverage_module(assertion, project.set_name, coverage_selections)] if coverage else [ { "name": "tester", "bucketValue": lambda opts, at=assertion.get("at"): int(at * 1000) if at is not None else opts["bucketValue"], } ], - "logLevel": _log_level(verbose, quiet), + "logLevel": "fatal" if json_output else _log_level(verbose, quiet), }) context = dict(assertion.get("context", {})) if context: @@ -343,8 +879,8 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N _assert_feature(sdk, feature_key, {**assertion, "context": context}, datafile, result, assertion_result) for index, child in enumerate(assertion.get("children", [])): child_instance = sdk.spawn(child.get("context", {}), { - "stickyFeatures": child.get("stickyFeatures") or child.get("sticky") or assertion.get("stickyFeatures") or assertion.get("sticky"), - "stickyVariables": child.get("stickyVariables") or assertion.get("stickyVariables"), + "stickyFeatures": child.get("stickyFeatures", {}), + "stickyVariables": child.get("stickyVariables", {}), }) try: _assert_feature(child_instance, feature_key, child, datafile, result, assertion_result, child_index=index) @@ -357,46 +893,128 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N result["duration"] = int((time.perf_counter() - feature_start) * 1000) else: segment_key = test["segment"] - segment_source = next((value for value in segments_by_key.values() if value.get("key") == segment_key), None) - if not segment_source: - result = {"type": "segment", "key": segment_key, "notFound": True, "passed": False, "duration": 0, "assertions": []} - else: - result = test_segment({"segment": segment_key, "conditions": segment_source["conditions"], "assertions": test["assertions"]}, {"verbose": verbose, "quiet": quiet}) - if result["passed"]: - passed_tests_count += 1 + test_segments = _get_authored_segments(test) + if segment_key not in test_segments: + source = segments_by_key.get(segment_key) + if source is not None: + test_segments = {**test_segments, segment_key: source} + assertions = [assertion for assertion in test["assertions"] + if not selected_targets or not assertion.get("target") or assertion["target"] in selected_targets] + if not assertions: + continue + executed_assertions = assertions + result = {"type": "segment", "key": segment_key, "notFound": segment_key not in test_segments, + "passed": True, "duration": 0, "assertions": []} + for assertion in assertions: + datafile = _get_datafile_for_assertion(assertion, datafile_cache) + if datafile is None and not assertion.get("environment") and not assertion.get("target"): + datafile = {"schemaVersion": "4", "revision": "segment-tests", "segments": {}, "features": {}} + if datafile is None: + result["passed"] = False + result["assertions"].append(_missing_datafile_assertion(assertion)) + continue + datafile = prepare_segment_datafile(datafile, test_segments) + try: + assert_segment_references(segment_key, datafile, test_segments) + _preflight_assertion(test, assertion, datafile, test_segments) + except ValueError as error: + result["passed"] = False + result["assertions"].append(_preflight_failure(assertion, CheckError(str(error)))) + continue + diagnostics = [] + sdk = create_featurevisor({ + "datafile": datafile, "context": assertion.get("context", {}), + "stickyFeatures": assertion.get("stickyFeatures", {}), + "stickyVariables": assertion.get("stickyVariables", {}), + "logLevel": "error", "onDiagnostic": diagnostics.append, + "modules": [{"name": "tester", "bucketValue": + lambda opts, at=assertion.get("at"): int(at * 1000) if at is not None else opts["bucketValue"]}], + }) + assertion_result = {"description": assertion.get("description", ""), "duration": 0, "passed": True, "errors": []} + try: + helper = datafile["_segment_test_keys"].get(segment_key) + if helper is None: + raise CheckError(f'segment "{segment_key}" definition is missing') + for index, current in enumerate([assertion, *assertion.get("children", [])]): + segment_instance = sdk if index == 0 else sdk.spawn(current.get("context", {}), { + "stickyFeatures": current.get("stickyFeatures", {}), + "stickyVariables": current.get("stickyVariables", {}), + }) + try: + evaluation = segment_instance.evaluate_flag(helper) + actual = evaluation.get("enabled") is True + if evaluation.get("reason") == "error": + raise CheckError(f'segment "{segment_key}" evaluation failed: {evaluation.get("error")}') + if "expectedToMatch" in current and actual != current["expectedToMatch"]: + assertion_result["errors"].append({"type": "segment", "expected": current["expectedToMatch"], "actual": actual}) + if "checks" in current: + assertion_result["errors"].extend(evaluate_checks_result( + segment_instance, datafile, current["checks"], diagnostics=diagnostics, owner_variable=None, + segments=test_segments, context=segment_instance.get_context()).get("errors", [])) + finally: + if segment_instance is not sdk: + segment_instance.close() + except CheckError as error: + assertion_result["errors"].append({"type": "check", "message": str(error)}) + finally: + sdk.close() + assertion_result["passed"] = not assertion_result["errors"] + result["passed"] = result["passed"] and assertion_result["passed"] + result["assertions"].append(assertion_result) + metadata = test.get("_test") or {} + current_set = metadata.get("set") if "set" in metadata else project.set_name + report_results.append({ + "type": result["type"], + "key": result["key"], + "source": metadata.get("source") or test.get("key"), + "set": current_set, + "passed": result["passed"], + "cases": [ + _case_result( + assertion, + assertion_result, + result["type"], + current_set, + selected_targets, + rerun_project_path, + ) + for assertion, assertion_result in zip(executed_assertions, result["assertions"]) + ], + }) + return report_results + + +def _runtime_selections(project_directory_path: str, environment: str | None, set_name: str | None, targets: list[str] | None) -> list[tuple[FeaturevisorProject, str | None]]: + config = FeaturevisorProject(project_directory_path).get_config() + environments = config.get("environments") + if isinstance(environments, list): + if not environment: + raise ValueError("Pass --environment= when selecting one datafile.") + if environment not in environments: + raise ValueError(f'Unknown environment "{environment}". Available environments: {", ".join(environments)}.') + elif environment is not None: + raise ValueError("This project does not configure environments. Omit --environment.") + selections: list[tuple[FeaturevisorProject, str | None]] = [] + for current_set in _selected_sets(project_directory_path, config, set_name): + project = FeaturevisorProject(project_directory_path, set_name=current_set) + selected_targets = _resolve_targets(project, targets) + if selected_targets: + selections.extend((project, target) for target in selected_targets) else: - failed_tests_count += 1 - for assertion in result["assertions"]: - if assertion["passed"]: - passed_assertions_count += 1 - else: - failed_assertions_count += 1 - if not (only_failures and result["passed"]): - _print_test_result(result, test["key"]) - passed = passed and result["passed"] - if not only_failures or not passed: - print("\n---") - print("") - print(f"Test specs: {passed_tests_count} passed, {failed_tests_count} failed") - print(f"Assertions: {passed_assertions_count} passed, {failed_assertions_count} failed") - print(f"Time: {pretty_duration(time.perf_counter() - start)}") - return passed + selections.append((project, None)) + return selections -def run_benchmark(project_directory_path: str, *, environment: str, feature: str | None = None, context: dict[str, Any] | None = None, n: int = 1000, variation: bool = False, variable: str | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False, targets: list[str] | None = None) -> int: - project = FeaturevisorProject(project_directory_path) - selected_targets = _resolve_targets(project, targets) - entries: list[str | None] = [] - entries.extend(selected_targets) - if not entries: - entries.append(None) - for target in entries: +def run_benchmark(project_directory_path: str, *, environment: str | None = None, feature: str | None = None, context: dict[str, Any] | None = None, n: int = 1000, variation: bool = False, variable: str | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False, targets: list[str] | None = None, set_name: str | None = None) -> int: + for project, target in _runtime_selections(project_directory_path, environment, set_name, targets): + if project.set_name: + print(f'Set "{project.set_name}"') datafile, build_duration = timed_build(project, environment=environment, inflate=inflate or None, target=target) _run_benchmark_datafile(datafile, build_duration, environment=environment, target=target, feature=feature, context=context, n=n, variation=variation, variable=variable, verbose=verbose, quiet=quiet) return 0 -def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, environment: str, target: str | None, feature: str | None, context: dict[str, Any] | None, n: int, variation: bool, variable: str | None, verbose: bool, quiet: bool) -> None: +def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, environment: str | None, target: str | None, feature: str | None, context: dict[str, Any] | None, n: int, variation: bool, variable: str | None, verbose: bool, quiet: bool) -> None: level = _log_level(verbose, quiet) instance = create_featurevisor({"datafile": datafile, "logLevel": level}) context = context or {} @@ -428,7 +1046,8 @@ def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, print(f" Feature: {feature}") if variable: print(f" Variable: {variable}") - print(f" Environment: {environment}") + if environment is not None: + print(f" Environment: {environment}") if target: print(f" Target: {target}") print(f" Iterations: {n}") @@ -443,20 +1062,16 @@ def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, print(f"Maximum duration: {max_duration_ns / 1_000_000:.6f}ms") -def run_assess_distribution(project_directory_path: str, *, environment: str, feature: str, context: dict[str, Any] | None = None, n: int = 1000, populate_uuid: list[str] | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False, targets: list[str] | None = None) -> int: - project = FeaturevisorProject(project_directory_path) - selected_targets = _resolve_targets(project, targets) - entries: list[str | None] = [] - entries.extend(selected_targets) - if not entries: - entries.append(None) - for target in entries: +def run_assess_distribution(project_directory_path: str, *, environment: str | None = None, feature: str, context: dict[str, Any] | None = None, n: int = 1000, populate_uuid: list[str] | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False, targets: list[str] | None = None, set_name: str | None = None) -> int: + for project, target in _runtime_selections(project_directory_path, environment, set_name, targets): + if project.set_name: + print(f'Set "{project.set_name}"') datafile = project.build_datafile_json(environment=environment, inflate=inflate or None, target=target) _run_assess_datafile(datafile, environment=environment, target=target, feature=feature, context=context, n=n, populate_uuid=populate_uuid, verbose=verbose, quiet=quiet) return 0 -def _run_assess_datafile(datafile: dict[str, Any], *, environment: str, target: str | None, feature: str, context: dict[str, Any] | None, n: int, populate_uuid: list[str] | None, verbose: bool, quiet: bool) -> None: +def _run_assess_datafile(datafile: dict[str, Any], *, environment: str | None, target: str | None, feature: str, context: dict[str, Any] | None, n: int, populate_uuid: list[str] | None, verbose: bool, quiet: bool) -> None: instance = create_featurevisor({"datafile": datafile, "logLevel": _log_level(verbose, quiet)}) context = context or {} populate_uuid = populate_uuid or [] @@ -466,7 +1081,8 @@ def _run_assess_datafile(datafile: dict[str, Any], *, environment: str, target: variation_counts: dict[str, int] = {} print("\nAssess Featurevisor distribution") print(f" Feature: {feature}") - print(f" Environment: {environment}") + if environment is not None: + print(f" Environment: {environment}") if target: print(f" Target: {target}") print(f" Iterations: {n}") diff --git a/src/featurevisor/types.py b/src/featurevisor/types.py index a9a8aef..425d19b 100644 --- a/src/featurevisor/types.py +++ b/src/featurevisor/types.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from typing import Any, Callable, Literal, TypedDict +from typing import Any, Callable, Literal, TypeAlias, TypedDict if sys.version_info >= (3, 11): from typing import NotRequired @@ -44,12 +44,19 @@ class RequiredFeature(TypedDict, total=False): variation: VariationValue +RequiredFeatureExpression: TypeAlias = str | RequiredFeature | dict[str, list["RequiredFeatureExpression"]] +RequiredFeatures: TypeAlias = RequiredFeatureExpression | list[RequiredFeatureExpression] + + class VariableOverride(TypedDict, total=False): key: str keyPath: list[str] conditions: Any segments: Any - requiredFeatures: str | RequiredFeature | list[str | RequiredFeature] + requiredFeatures: RequiredFeatures + operations: list[dict[str, Any]] + onMatch: int + onMiss: int value: VariableValue @@ -60,11 +67,6 @@ class Variation(TypedDict, total=False): variableOverrides: dict[VariableKey, list[VariableOverride]] -class RequiredFeatureRef(TypedDict, total=False): - key: FeatureKey - variation: VariationValue - - class VariableSchema(TypedDict, total=False): type: str defaultValue: VariableValue @@ -74,10 +76,13 @@ class VariableSchema(TypedDict, total=False): class GlobalVariable(VariableSchema, total=False): + conditions: Any + compose: str | list[dict[str, Any]] key: VariableKey hash: str - requiredFeatures: str | RequiredFeature | list[str | RequiredFeature] + requiredFeatures: RequiredFeatures overrides: list[VariableOverride] + overrideProgram: list[VariableOverride] class Allocation(TypedDict): @@ -86,6 +91,7 @@ class Allocation(TypedDict): class Traffic(TypedDict, total=False): + conditions: Any key: RuleKey segments: Any percentage: int @@ -106,18 +112,17 @@ class Force(TypedDict, total=False): class Feature(TypedDict, total=False): + conditions: Any key: FeatureKey hash: str deprecated: bool - required: list[str | RequiredFeatureRef] - requiredFeatures: str | RequiredFeature | list[str | RequiredFeature] + requiredFeatures: RequiredFeatures variablesSchema: dict[VariableKey, VariableSchema] disabledVariationValue: VariationValue variations: list[Variation] bucketBy: str | list[str] | dict[str, list[str]] traffic: list[Traffic] force: list[Force] - ranges: list[list[int]] class Segment(TypedDict, total=False): @@ -139,6 +144,7 @@ class Evaluation(TypedDict, total=False): type: Literal["flag", "variation", "variable"] featureKey: FeatureKey reason: str + conditions: Any bucketKey: BucketKey bucketValue: BucketValue ruleKey: RuleKey @@ -147,9 +153,7 @@ class Evaluation(TypedDict, total=False): traffic: Traffic forceIndex: int force: Force - required: list[str | RequiredFeatureRef] - requiredFeatures: str | RequiredFeature | list[str | RequiredFeature] - sticky: EvaluatedFeature + stickyFeature: EvaluatedFeature variation: Variation variationValue: VariationValue variableKey: VariableKey @@ -159,6 +163,7 @@ class Evaluation(TypedDict, total=False): variableOverrideIndex: int variableOverrideKey: str variableOverridePath: list[str] + variableOverridePaths: list[list[str]] class FeaturevisorDiagnostic(TypedDict, total=False): @@ -173,10 +178,8 @@ class FeaturevisorDiagnostic(TypedDict, total=False): class FeaturevisorModule(TypedDict, total=False): name: str - before: Callable[[dict[str, Any]], dict[str, Any]] bucketKey: Callable[[dict[str, Any]], BucketKey] bucketValue: Callable[[dict[str, Any]], BucketValue] - after: Callable[[Evaluation, dict[str, Any]], Evaluation] beforeEvaluation: Callable[[dict[str, Any]], dict[str, Any]] afterEvaluation: Callable[[Evaluation, dict[str, Any]], Evaluation] setup: Callable[[dict[str, Any]], None] diff --git a/tests/benchmark_sticky_handoff.py b/tests/benchmark_sticky_handoff.py new file mode 100644 index 0000000..a445038 --- /dev/null +++ b/tests/benchmark_sticky_handoff.py @@ -0,0 +1,101 @@ +"""Serial sticky handoff benchmark: PYTHONPATH=src python3 tests/benchmark_sticky_handoff.py. + +Use --check to validate fixtures without running timings. +""" + +from __future__ import annotations + +import argparse +import time + +from featurevisor import create_featurevisor + + +CONTEXT = {"userId": "sticky-bench"} +ENABLED = {"bucketBy": "userId", "traffic": [ + {"key": "all", "segments": "*", "percentage": 100000, "enabled": True}, +]} + + +def setup(kind: str, size: int, child_mode: bool): + keys = [f"unrelated{index}" for index in range(size)] + source = { + "schemaVersion": "2", "revision": "sticky-source", "segments": {}, + "features": {key: ENABLED for key in keys} if kind == "features" else {}, + "variables": {key: {"type": "boolean", "defaultValue": True} for key in keys} + if kind == "variables" else {}, + } + server = create_featurevisor({"datafile": source, "logLevel": "fatal"}) + if kind == "features": + sticky = server.get_feature_evaluations(CONTEXT, keys) + sticky_name = "stickyFeatures" + else: + sticky = server.get_variable_evaluations(CONTEXT, keys) + sticky_name = "stickyVariables" + server.close() + if len(sticky) != size: + raise AssertionError(f"exported {len(sticky)} sticky entries, expected {size}") + + requirement = {"variable": "leaf", "operator": "equals", "value": True} + gated = { + "schemaVersion": "2", "revision": "sticky-gated", "segments": {}, + "features": {"gate": ENABLED}, + "variables": { + "leaf": {"type": "boolean", "defaultValue": True}, + "root": {"type": "boolean", "defaultValue": True, + **({"requiredFeatures": ["gate", "gate"]} if kind == "features" + else {"conditions": [requirement, requirement]})}, + }, + } + # The gated global is created only after export, so no sticky value can + # bypass its prerequisite path. + config = {"datafile": gated, "logLevel": "fatal"} + if not child_mode: + config[sticky_name] = sticky + instance = create_featurevisor(config) + target = instance.spawn({}, {sticky_name: sticky}) if child_mode else instance + if target.get_variable("root", CONTEXT) is not True: + raise AssertionError("gated global did not evaluate to true") + return instance, target + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--iterations", type=int, default=1000) + parser.add_argument("--warmup", type=int, default=100) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + if args.iterations < 1: + parser.error("--iterations must be positive") + if args.warmup < 0: + parser.error("--warmup must be nonnegative") + if not args.check: + print("kind,size,instance,average_ns_per_evaluation") + for kind in ("features", "variables"): + for size in (0, 100, 1000, 5000): + for child_mode in (False, True): + instance, target = setup(kind, size, child_mode) + try: + if not args.check: + for _ in range(args.warmup): + if target.get_variable("root", CONTEXT) is not True: + raise AssertionError("warmup result changed") + elapsed = 0 + for _ in range(args.iterations): + start = time.perf_counter_ns() + result = target.get_variable("root", CONTEXT) + elapsed += time.perf_counter_ns() - start + if result is not True: + raise AssertionError("benchmark result changed") + print(f"{kind},{size},{'child' if child_mode else 'instance'}," + f"{elapsed / args.iterations:.1f}") + finally: + if child_mode: + target.close() + instance.close() + if args.check: + print("All 16 sticky handoff fixtures passed") + + +if __name__ == "__main__": + main() diff --git a/tests/benchmark_v4_review.py b/tests/benchmark_v4_review.py new file mode 100644 index 0000000..05436d0 --- /dev/null +++ b/tests/benchmark_v4_review.py @@ -0,0 +1,59 @@ +"""Local review timings. Run with PYTHONPATH pointing to the source snapshot.""" + +import json +import platform +import statistics +import time + +from featurevisor import create_featurevisor + + +def corpus(schema): + return {"schemaVersion": schema, "revision": "bench", "segments": {}, + "features": {f"f{i}": {"bucketBy": "userId", "hash": str(i), + "variations": [{"value": "on"}], + "variablesSchema": {"value": {"type": "integer", "defaultValue": 7}}, + "traffic": [{"key": "all", "conditions": '[{"attribute":"country","operator":"equals","value":"nl"}]', + "percentage": 100000, "variation": "on"}]} for i in range(500)}, + "variables": {"value": {"type": "integer", "defaultValue": 7}}} + + +def measure(call, iterations, expected=None): + for _ in range(100): + call() + rounds = [] + for _ in range(5): + elapsed = 0 + for _ in range(iterations): + start = time.perf_counter_ns() + result = call() + elapsed += time.perf_counter_ns() - start + if expected is not None: + assert result == expected, result + rounds.append(elapsed / iterations / 1000) + return {"median_us": statistics.median(rounds), "rounds_us": rounds} + + +if __name__ == "__main__": + data = corpus("4") + f = create_featurevisor({"datafile": data, "logLevel": "fatal"}) + context = {"country": "nl", "userId": "bench"} + results = {"python": platform.python_version()} + for name, call, expected in ( + ("flag", lambda: f.is_enabled("f0", context), True), + ("variation", lambda: f.get_variation("f0", context), "on"), + ("feature_variable", lambda: f.get_variable("f0", "value", context), 7), + ("global_variable", lambda: f.get_variable("value", context), 7), + ): + results[name] = measure(call, 20000, expected) + f.close() + for schema in ("2", "4"): + data = corpus(schema) + + def load(): + instance = create_featurevisor({"datafile": data, "logLevel": "fatal"}) + assert not instance.datafile.entity_cache + instance.close() + + results[f"schema_{schema}_load"] = measure(load, 100) + print(json.dumps(results, indent=2)) diff --git a/tests/test_attribute_selector_cache.py b/tests/test_attribute_selector_cache.py new file mode 100644 index 0000000..3d7221c --- /dev/null +++ b/tests/test_attribute_selector_cache.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import copy +import threading +import unittest +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import patch + +from featurevisor import create_featurevisor +from featurevisor.evaluation_data_provider import _InstanceEvaluationDataProvider + + +def feature(conditions): + return {"bucketBy": "userId", "conditions": conditions, + "traffic": [{"key": "all", "conditions": "*", "percentage": 100000}]} + + +def datafile(expression, revision="initial"): + return {"schemaVersion": "4", "revision": revision, + "segments": {"audience": {"conditions": expression}}, + "features": {"segment": feature("audience"), "direct": feature(expression)}, "variables": {}} + + +class AttributeSelectorCacheTests(unittest.TestCase): + def test_cached_syntax_does_not_cache_context_or_change_public_entities(self): + expression = [{"attribute": "country", "operator": "equals", "value": "nl"}, + {"attribute": "device", "operator": "equals", "value": "mobile"}] + fixture = datafile(expression) + original = copy.deepcopy(fixture) + f = create_featurevisor({"logLevel": "fatal", "datafile": fixture}) + self.addCleanup(f.close) + self.assertEqual(f.datafile.entity_cache, {}) + self.assertEqual(f.datafile._attribute_selectors, {}) + with patch.object(_InstanceEvaluationDataProvider, "_prepare_attribute_selector", + wraps=_InstanceEvaluationDataProvider._prepare_attribute_selector) as prepare: + for _ in range(3): + self.assertTrue(f.is_enabled("segment", {"country": "nl", "device": "mobile"})) + self.assertFalse(f.is_enabled("segment", {"country": "de", "device": "mobile"})) + self.assertFalse(f.is_enabled("segment", {"country": "nl", "device": "desktop"})) + self.assertEqual(prepare.call_count, 3) + original_lock = f.datafile._entity_lock + class UnexpectedLock: + def __enter__(self): + raise AssertionError("Warm selector read acquired the normalization lock") + def __exit__(self, *args): + pass + f.datafile._entity_lock = UnexpectedLock() + try: + self.assertTrue(f.is_enabled("segment", {"country": "nl", "device": "mobile"})) + finally: + f.datafile._entity_lock = original_lock + self.assertEqual(f.get_segment("audience"), {"conditions": expression}) + self.assertEqual(f.evaluate_flag("direct", {})["conditions"], expression) + self.assertEqual(fixture, original) + old = f.datafile + self.assertTrue(f.set_datafile(datafile({"or": []}, "replacement"))) + self.assertEqual(f.datafile._attribute_selectors, {}) + self.assertFalse(f.is_enabled("segment", {"country": "nl", "device": "mobile"})) + self.assertTrue(old.all_conditions_are_matched(old.get_attribute_selector(expression), + {"country": "nl", "device": "mobile"})) + f.close() + self.assertEqual(f.datafile._attribute_selectors, {}) + + def test_empty_and_nested_groups_keep_unified_selector_semantics(self): + leaf = {"attribute": "country", "operator": "equals", "value": "nl"} + expressions = [leaf, [], {"and": []}, {"or": []}, {"not": []}] + for operator in ("and", "or", "not"): + expressions.extend({operator: [empty, leaf]} for empty in ([], {"and": []}, {"or": []}, {"not": []})) + expressions += [{"not": [{"or": [leaf, {"and": []}]}]}, [leaf, {"not": [{"not": [leaf]}]}]] + for expression in expressions: + with self.subTest(expression=expression): + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile(expression)}) + try: + for country in ("nl", "de"): + for _ in range(2): + self.assertEqual(f.is_enabled("segment", {"country": country}), + f.is_enabled("direct", {"country": country})) + finally: + f.close() + + def test_mixed_dependencies_and_malformed_selectors_stay_on_dependency_path(self): + for expression in ([{"attribute": "country", "operator": "equals", "value": "nl"}, {"feature": "direct"}], + {"not": ["audience"]}, "{"): + with self.subTest(expression=expression): + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile(expression)}) + self.addCleanup(f.close) + result = f.evaluate_flag("segment", {"country": "nl"}) + self.assertEqual(result["reason"], "conditions_unmet" if expression == "{" else "error") + normalized = f.get_segment("audience")["conditions"] + self.assertIsNone(f.datafile.get_attribute_selector(normalized)) + + def test_concurrent_preparation_is_published_once(self): + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile([])}) + self.addCleanup(f.close) + provider = f.datafile + expression = [{"attribute": "country", "operator": "equals", "value": "nl"}] + original_lock = provider._entity_lock + barrier = threading.Barrier(8, timeout=5) + + class ContendedLock: + def __enter__(self): + barrier.wait() + original_lock.acquire() + + def __exit__(self, *args): + original_lock.release() + + provider._entity_lock = ContendedLock() + try: + with patch.object(_InstanceEvaluationDataProvider, "_prepare_attribute_selector", + wraps=_InstanceEvaluationDataProvider._prepare_attribute_selector) as prepare: + with ThreadPoolExecutor(max_workers=8) as pool: + futures = [pool.submit(provider.get_attribute_selector, expression) for _ in range(8)] + results = [future.result(timeout=10) for future in futures] + self.assertTrue(all(result is results[0] for result in results)) + self.assertEqual(prepare.call_count, 2) + finally: + provider._entity_lock = original_lock + + def test_predicate_diagnostics_run_outside_lock_and_later_reads_refresh(self): + invalid_regex = {"attribute": "text", "operator": "matches", "value": "["} + expression = {"or": [invalid_regex, {"attribute": "country", "operator": "equals", "value": "nl"}]} + f = create_featurevisor({"logLevel": "warn", "datafile": datafile(expression)}) + self.addCleanup(f.close) + diagnostics = [] + context = {"text": "hello", "country": "de"} + with ThreadPoolExecutor(max_workers=1) as pool: + def diagnostic(item): + if item["code"] == "condition_match_error": + diagnostics.append(item) + item["details"]["context"]["country"] = "nl" + self.assertTrue(pool.submit(f.set_datafile, datafile({"or": []}, "callback")).result(timeout=5)) + f.on_diagnostic = diagnostic + self.assertTrue(f.is_enabled("segment", context)) + self.assertEqual(len(diagnostics), 1) + self.assertEqual(diagnostics[0]["details"]["condition"], invalid_regex) + self.assertFalse(f.is_enabled("segment", context)) + self.assertEqual(f.get_revision(), "callback") diff --git a/tests/test_cli.py b/tests/test_cli.py index 5cccfd3..282203a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,16 +1,188 @@ from __future__ import annotations import sys +import json +import os +import shlex +import subprocess +import tempfile import unittest -from unittest.mock import patch +from contextlib import redirect_stdout +from io import StringIO +from unittest.mock import Mock, patch sys.path.insert(0, "src") from featurevisor.cli import build_parser, main -from featurevisor.tester import _get_datafile_for_assertion, _get_target_datafile_key +from featurevisor.instance import create_featurevisor +from featurevisor.project import FeaturevisorProject, FeaturevisorProjectError +from featurevisor.test_checks import CheckError, _date, evaluate_checks, evaluate_checks_result, preflight_checks +from featurevisor.tester import ( + _assert_global_variable, + _assert_scenario, + _case_result, + _get_datafile_for_assertion, + _get_base_datafile_key, + _get_target_datafile_key, + _preflight_assertion, + _run_test_project_set, + _print_report, + _empty_report, +) class CLITests(unittest.TestCase): + def test_runner_preflight_preserves_syntax_and_reference_failure_details(self) -> None: + datafile = {"schemaVersion": "2", "revision": "preflight", "features": {}, "segments": {}, + "variables": {"version": {"type": "string", "defaultValue": "1.0.0"}}} + valid = {"variable": "version", "operator": "semverEquals", "value": "1.0.0"} + project = Mock(spec=FeaturevisorProject) + project.set_name = None + project.get_config.return_value = {"environments": False} + project.list_targets.return_value = [] + project.list_features.return_value = [] + project.list_segments.return_value = [] + project.build_datafile_json.return_value = datafile + for child in [False, True]: + for entity in [False, True]: + for invalid in ["", "1.0.0\n", "١.0.0", None]: + with self.subTest(child=child, entity=entity, invalid=invalid): + check = {**valid, **({"variable": "missing"} if invalid is None else {"value": invalid})} + checks = [valid, {"not": [check]}] + assertion = {"children": [{"checks": [valid]}, {"checks": checks}]} if child else {"checks": checks} + assertion["_case"] = {"assertionIndex": 7} + spec = {"variable": "version"} if entity else {"scenario": "preflight"} + project.list_tests.return_value = [{**spec, "assertions": [assertion]}] + with patch("featurevisor.tester.create_featurevisor") as create: + report = _run_test_project_set(project, json_output=True)[0] + create.assert_not_called() + self.assertFalse(report["passed"]) + errors = report["cases"][0]["errors"] + self.assertEqual(len(errors), 1) + error = errors[0] + self.assertEqual(error["type"], "check") + self.assertEqual(error["expected"], "valid check references" if invalid is None else "valid checks") + self.assertIsInstance(error["actual"], str) + self.assertTrue(error["actual"]) + prefix = ["assertions", 7] if entity else [] + if child: + prefix.extend(["children", 1]) + self.assertEqual(error["path"], [*prefix, "checks", 1]) + + def test_ordinary_failures_preserve_authored_paths_values_and_children(self) -> None: + datafile = { + "schemaVersion": "2", "revision": "failures", "segments": {}, + "features": {"flag": {"bucketBy": "userId", "traffic": [{"key": "all", "segments": "*", "percentage": 100000, "allocation": []}], + "variablesSchema": {"text": {"type": "string", "defaultValue": "actual"}}}}, + "variables": {"text": {"type": "string", "defaultValue": "actual"}}, + } + feature = { + "expectedToBeEnabled": False, "expectedVariation": "missing", + "expectedVariables": {"text": "wrong"}, + "expectedEvaluations": {"flag": {"enabled": False}, "variation": {"featureKey": "wrong"}, "variables": {"text": {"variableValue": "wrong"}}}, + } + variable = {"expectedValue": "wrong", "expectedEvaluation": {"variableValue": "wrong"}} + project = Mock(spec=FeaturevisorProject) + project.set_name = None + project.get_config.return_value = {"environments": False} + project.list_targets.return_value = [] + project.list_features.return_value = [{"key": "flag"}] + project.list_segments.return_value = [{"key": "all", "conditions": "*"}] + project.build_datafile_json.return_value = datafile + project.list_tests.return_value = [ + {"feature": "flag", "assertions": [{**feature, "children": [feature], "_case": {"assertionIndex": 7}}]}, + {"variable": "text", "assertions": [{**variable, "children": [variable], "_case": {"assertionIndex": 7}}]}, + {"segment": "all", "assertions": [{"expectedToMatch": False, "_case": {"assertionIndex": 7}}]}, + ] + reports = _run_test_project_set(project, json_output=True) + expected = [ + [("flag", ["expectedToBeEnabled"], False, True), + ("variation", ["expectedVariation"], "missing", None), + ("variable", ["expectedVariables", "text"], "wrong", "actual"), + ("evaluation", ["expectedEvaluations", "flag", "enabled"], False, True), + ("evaluation", ["expectedEvaluations", "variation", "featureKey"], "wrong", "flag"), + ("evaluation", ["expectedEvaluations", "variables", "text", "variableValue"], "wrong", "actual")], + [("variable", ["expectedValue"], "wrong", "actual"), + ("evaluation", ["expectedEvaluation", "variableValue"], "wrong", "actual")], + [("segment", ["expectedToMatch"], False, True)], + ] + for report, rows in zip(reports, expected): + self.assertFalse(report["passed"]) + case = report["cases"][0] + prefixes = [[], ["children", 0]] if report["type"] != "segment" else [[]] + self.assertEqual(len(case["errors"]), len(rows) * len(prefixes)) + for child, prefix in enumerate(prefixes): + for index, (kind, suffix, wanted, actual) in enumerate(rows): + error = case["errors"][child * len(rows) + index] + self.assertEqual((error["type"], error["path"], error["expected"], error["actual"]), + (kind, ["assertions", 7, *prefix, *suffix], wanted, actual)) + if prefix: + self.assertEqual(error["details"]["childIndex"], 0) + + def test_inflate_requires_an_explicit_positive_integer(self) -> None: + for command in ("test", "benchmark", "assess-distribution"): + parser = build_parser() + self.assertEqual(parser.parse_args([command]).inflate, 0) + self.assertEqual(parser.parse_args([command, "--inflate=1"]).inflate, 1) + for value in ("0", "-5", "1.5", "invalid", "", "1foo"): + with self.subTest(command=command, value=value): + with self.assertRaises(SystemExit) as error: + parser.parse_args([command, f"--inflate={value}"]) + self.assertNotEqual(error.exception.code, 0) + + def test_portable_check_date_fractional_precision(self) -> None: + expected = _date("2024-01-01T00:00:00.123Z") + for digits in ("1239", "123999", "1239999", "123999999"): + with self.subTest(digits=digits): + self.assertEqual(_date(f"2024-01-01T00:00:00.{digits}Z"), expected) + with self.assertRaises(CheckError): + _date("2024-01-01T00:00:00.1234567890Z") + self.assertEqual(_date("1969-12-31T23:59:59.999999999Z"), _date("1969-12-31T23:59:59.999Z")) + + def test_human_report_uses_one_based_paths_named_shell_and_readable_coverage(self) -> None: + report = _empty_report() + report["summary"]["assertions"]["failed"] = 1 + report["summary"]["scenarioCases"]["failed"] = 1 + report["results"] = [{ + "type": "scenario", "key": "checkout", "source": "tests/scenarios/checkout.spec.yml", + "passed": False, "cases": [{ + "description": "checkout", "target": "web", "matrixValues": {"country": "nl"}, + "passed": False, "errors": [{ + "type": "check", "message": "Check did not match", "path": ["checks", 0], + "details": {"checkKey": "price", "evaluated": [{ + "path": ["checks", 0], "reference": {"variable": "price"}, + "operator": "equals", "actual": 1, "expected": 2, + }]}, + }, { + "type": "variable", "message": "variable expectation did not match", + "path": ["assertions", 0, "children", 0, "expectedValue"], + "details": {"childIndex": 0}, "expected": 2, "actual": 1, + }, { + "type": "check", "message": "Check did not match", + "path": ["children", 0, "checks", 0], + "details": {"childIndex": 0, "checkIndex": 0}, + }], + "rerun": {"command": "python -m featurevisor", "args": ["test", "--case=opaque id"]}, + }], + }] + report["coverage"] = {"mode": "observed-selections", "selections": [{ + "featureKey": "checkout", "variableKey": "price", "variation": "blue", + "overrideIndex": 0, + }]} + output = StringIO() + with redirect_stdout(output): + _print_report(report, json_output=False, pretty=False, only_failures=False) + human = output.getvalue() + self.assertIn("matrix country=\"nl\"; target web", human) + self.assertIn("checks.1", human) + self.assertIn("Child 1, expectedValue", human) + self.assertIn("Child 1, check", human) + self.assertIn("Rerun (POSIX shell)", human) + self.assertIn("'--case=opaque id'", human) + self.assertIn("Total cases: 0 passed, 1 failed", human) + self.assertIn("feature checkout variable price", human) + self.assertIn("variation \"blue\", override 1", human) + def test_parse_test_flags(self) -> None: parser = build_parser() args = parser.parse_args(["test", "--withScopes", "--withTags", "--schema-version=2", "--projectDirectoryPath=/tmp/project"]) @@ -37,27 +209,910 @@ def test_parse_repeated_targets(self) -> None: args = build_parser().parse_args(["benchmark", "--target=web", "--target=mobile"]) self.assertEqual(args.target, ["web", "mobile"]) + def test_parse_scenario_filter(self) -> None: + args = build_parser().parse_args([ + "test", "--entityType=scenario", "--json", "--pretty", "--case=opaque", + "--coverage", "--uncovered", + ]) + self.assertEqual(args.entityType, "scenario") + self.assertTrue(args.json) + self.assertTrue(args.pretty) + self.assertEqual(args.case_id, "opaque") + self.assertTrue(args.coverage) + self.assertTrue(args.uncovered) + def test_main_returns_non_zero_on_failed_tests(self) -> None: with patch("featurevisor.cli.run_test_project", return_value=False): self.assertEqual(main(["test"]), 1) + def test_execution_protocol_envelope_is_strictly_validated(self) -> None: + project = FeaturevisorProject("/tmp/project") + valid = { + "kind": "featurevisor.test-execution", + "schemaVersion": 1, + "tests": [{"scenario": "ready", "assertions": []}], + } + malformed = [ + [], + {}, + {**valid, "kind": "other"}, + {**valid, "schemaVersion": True}, + {**valid, "schemaVersion": "1"}, + {**valid, "schemaVersion": 2}, + {**valid, "tests": None}, + {**valid, "tests": [None]}, + ] + for payload in malformed: + with self.subTest(payload=payload), patch.object( + project, + "run", + return_value=subprocess.CompletedProcess([], 0, json.dumps(payload), ""), + ): + with self.assertRaises(FeaturevisorProjectError): + project.list_tests() + + with patch.object( + project, + "run", + return_value=subprocess.CompletedProcess([], 0, json.dumps(valid), ""), + ) as run: + self.assertEqual(project.list_tests(key_pattern="ready"), valid["tests"]) + self.assertEqual( + run.call_args.args, + ("list", "--tests", "--execution-protocol", "--json", "--keyPattern=ready"), + ) + def test_target_datafile_cache_key(self) -> None: - self.assertEqual(_get_target_datafile_key(False, "checkout"), "false-target-checkout") - self.assertEqual(_get_target_datafile_key("production", "checkout"), "production-target-checkout") + self.assertEqual(_get_target_datafile_key(False, "checkout"), (None, "checkout")) + self.assertEqual(_get_target_datafile_key(None, "checkout"), (None, "checkout")) + self.assertEqual(_get_target_datafile_key("production", "checkout"), ("production", "checkout")) + self.assertEqual(_get_base_datafile_key({}), (None, None)) def test_target_assertion_selects_target_datafile(self) -> None: cache = { - "production": {"kind": "base"}, - "production-target-checkout": {"kind": "target"}, + ("production", None): {"kind": "base"}, + ("production", "checkout"): {"kind": "target"}, } datafile = _get_datafile_for_assertion({"environment": "production", "target": "checkout"}, cache) self.assertEqual(datafile["kind"], "target") def test_target_assertion_does_not_fall_back_to_base_datafile(self) -> None: - cache = {"production": {"kind": "base"}} + cache = {("production", None): {"kind": "base"}} datafile = _get_datafile_for_assertion({"environment": "production", "target": "checkout"}, cache) self.assertIsNone(datafile) + def test_datafile_cache_keys_preserve_every_environment_target_pair(self) -> None: + environments = [None, "false", "prod", "prod-target-web", "prod-target-a", 'café"\\\n東京'] + targets = [None, "web", "a-target-b", "b", 'web"\\\n東京'] + cache = {} + for environment in environments: + for target in targets: + key = _get_base_datafile_key({"environment": environment}) if target is None else _get_target_datafile_key(environment, target) + cache[key] = {"route": (environment, target)} + self.assertEqual(len(cache), len(environments) * len(targets)) + for environment in environments: + for target in targets: + assertion = {"environment": environment, "target": target} + self.assertEqual(_get_datafile_for_assertion(assertion, cache)["route"], (environment, target)) + self.assertIs(_get_datafile_for_assertion({}, cache), cache[(None, None)]) + self.assertIs(_get_datafile_for_assertion({"environment": False, "target": "web"}, cache), cache[(None, "web")]) + self.assertIsNone(_get_datafile_for_assertion({"environment": "prod", "target": "missing"}, cache)) + + def test_runner_builds_and_evaluates_collision_free_datafiles(self) -> None: + for environments in (["prod", "prod-target-web", "prod-target-a", "false", 'café"\\東京'], [None]): + with self.subTest(environments=environments): + targets = ["web", "a-target-b", "b", 'web"\\東京'] + project = Mock(spec=FeaturevisorProject) + project.set_name = None + project.get_config.return_value = {"environments": environments if environments != [None] else False} + project.list_features.return_value = [] + project.list_segments.return_value = [] + project.list_targets.return_value = targets + def build(environment=None, inflate=None, target=None): + return {"schemaVersion": "2", "revision": "cache-test", "features": {}, "segments": {}, + "variables": {"route": {"type": "string", "defaultValue": json.dumps([environment, target])}}} + project.build_datafile_json.side_effect = build + assertions = [{"environment": environment, **({"target": target} if target else {}), + "expectedValue": json.dumps([environment, target]), "_case": {"id": f"case-{index}"}} + for index, (environment, target) in enumerate((e, t) for e in environments for t in [None, *targets])] + project.list_tests.return_value = [{"key": "route", "variable": "route", "assertions": assertions}] + result = _run_test_project_set(project, quiet=True, json_output=True)[0] + self.assertTrue(result["passed"], result) + self.assertEqual(project.build_datafile_json.call_count, len(assertions)) + self.assertEqual([case["id"] for case in result["cases"]], [a["_case"]["id"] for a in assertions]) + + def test_segment_checks_use_authored_definitions_and_effective_context(self) -> None: + segments = { + "adult": {"conditions": [{"attribute": "age", "operator": "greaterThanOrEquals", "value": 18}]}, + "netherlands": {"conditions": {"attribute": "country", "operator": "equals", "value": "nl"}}, + "not-netherlands": {"conditions": {"not": [{"attribute": "country", "operator": "equals", "value": "nl"}]}}, + "everyone": {"conditions": "*"}, + } + datafile = {"schemaVersion": "2", "revision": "target", "segments": {}, "features": {}, "variables": {}} + checks = [{"and": [ + {"segment": "adult", "matches": True}, + {"or": [ + {"segment": "netherlands", "matches": True}, + {"segment": "everyone", "matches": False}, + ]}, + {"not": [{"segment": "not-netherlands", "matches": True}]}, + ]}] + result = evaluate_checks_result( + create_featurevisor({"datafile": datafile, "logLevel": "fatal"}), datafile, checks, owner_variable="unused", diagnostics=[], + segments=segments, context={"country": "nl", "age": 20}, + ) + self.assertTrue(result["passed"], result["failures"]) + + child = evaluate_checks_result( + create_featurevisor({"datafile": datafile, "logLevel": "fatal"}), datafile, [{"segment": "adult", "matches": False}], + owner_variable=None, diagnostics=[], segments=segments, + context={**{"country": "nl", "age": 20}, **{"age": 17}}, + ) + self.assertTrue(child["passed"], child["failures"]) + + def test_segment_checks_reject_invalid_shapes_and_missing_definitions(self) -> None: + datafile = {"segments": {}, "features": {}, "variables": {}} + segments = {"everyone": {"conditions": "*"}} + invalid = [ + {"matches": True}, + {"segment": "", "matches": True}, + {"segment": "everyone"}, + {"segment": "everyone", "matches": "true"}, + {"segment": "everyone", "matches": True, "variable": "value"}, + ] + for leaf in invalid: + with self.subTest(leaf=leaf): + result = evaluate_checks_result( + object(), datafile, [leaf], owner_variable=None, diagnostics=[], + segments=segments, context={}, + ) + self.assertTrue(result["error"]) + self.assertIn("segment", result["failures"][0]) + + skipped = evaluate_checks_result( + object(), datafile, + [{"or": [{"segment": "everyone", "matches": True}, {"segment": "everyone", "matches": "yes"}]}], + owner_variable=None, diagnostics=[], segments=segments, context={}, + ) + self.assertTrue(skipped["error"]) + + missing = evaluate_checks_result( + object(), datafile, [{"segment": "missing", "matches": False}], + owner_variable=None, diagnostics=[], segments=segments, context={}, + ) + self.assertTrue(missing["error"]) + self.assertIn('segment "missing"', missing["failures"][0]) + + def test_segment_check_failure_has_typed_leaf_evidence(self) -> None: + result = evaluate_checks_result( + create_featurevisor({"logLevel": "fatal"}), {"schemaVersion": "4", "revision": "test", "segments": {}, "features": {}, "variables": {}}, + [{"key": "adult-check", "segment": "adult", "matches": False}], + owner_variable=None, diagnostics=[], + segments={"adult": {"conditions": {"attribute": "age", "operator": "greaterThanOrEquals", "value": 18}}}, + context={"age": 20}, + ) + self.assertFalse(result["passed"]) + self.assertIn('segment "adult"', result["failures"][0]) + self.assertEqual(result["errors"][0]["details"]["evaluated"][0], { + "path": [0], + "key": "adult-check", + "reference": {"segment": "adult"}, + "property": "matches", + "operator": "equals", + "expected": False, + "actual": True, + }) + + def test_scenario_children_and_global_variable_checks_receive_segment_context(self) -> None: + datafile = { + "schemaVersion": "2", "revision": "runner", "segments": {}, "features": {}, + "variables": {"message": {"type": "string", "defaultValue": "ready"}}, + } + segments = { + "netherlands": {"conditions": {"attribute": "country", "operator": "equals", "value": "nl"}}, + "young-dutch": {"conditions": [ + {"attribute": "country", "operator": "equals", "value": "nl"}, + {"attribute": "age", "operator": "lessThan", "value": 18}, + ]}, + } + diagnostics: list[dict] = [] + scenario = { + "context": {"country": "nl", "age": 30}, + "checks": [{"segment": "netherlands", "matches": True}], + "children": [{"context": {"age": 17}, "checks": [{"segment": "young-dutch", "matches": True}]}], + } + sdk = create_featurevisor({"datafile": datafile, "context": scenario["context"], "logLevel": "fatal"}) + result = {"passed": True} + assertion_result = {"passed": True, "errors": []} + try: + _assert_scenario(sdk, scenario, datafile, diagnostics, result, assertion_result, segments=segments) + finally: + sdk.close() + self.assertTrue(result["passed"], assertion_result["errors"]) + + variable_assertion = { + "context": {"country": "nl"}, + "checks": [{"segment": "netherlands", "matches": True}], + } + sdk = create_featurevisor({"datafile": datafile, "context": variable_assertion["context"], "logLevel": "fatal"}) + result = {"passed": True} + assertion_result = {"passed": True, "errors": []} + try: + _assert_global_variable( + sdk, "message", variable_assertion, datafile, diagnostics, result, assertion_result, + segments=segments, context=variable_assertion["context"], + ) + finally: + sdk.close() + self.assertTrue(result["passed"], assertion_result["errors"]) + + def test_native_check_semantics(self) -> None: + datafile = { + "schemaVersion": "2", + "revision": "checks", + "segments": {}, + "features": { + "account": { + "bucketBy": "userId", + "variablesSchema": {"plan": {"type": "string", "defaultValue": "pro"}}, + "force": [{"segments": "*", "enabled": True}], + "traffic": [], + } + }, + "variables": { + "profile": {"type": "object", "defaultValue": {"name": "Ada", "a.b": "literal"}}, + "count": {"type": "integer", "defaultValue": 2}, + "numbers": {"type": "array", "defaultValue": [1, 2, 3]}, + "nothing": {"type": "json", "defaultValue": None}, + "version": {"type": "semver", "defaultValue": "2.1.0"}, + }, + } + diagnostics: list[dict] = [] + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", "onDiagnostic": diagnostics.append}) + try: + checks = [ + {"path": "name", "operator": "equals", "value": "Ada"}, + {"path": ["a.b"], "operator": "equals", "value": "literal"}, + {"variable": "numbers", "path": "1", "operator": "equals", "compareTo": {"variable": "count"}}, + {"variable": "numbers", "operator": "includes", "value": 3}, + {"variable": "nothing", "operator": "isNull"}, + {"variable": "profile", "path": "name", "operator": "notNull"}, + {"feature": "account", "variable": "plan", "operator": "equals", "value": "pro"}, + {"variable": "version", "operator": "semverGreaterThan", "value": "2.0.0"}, + {"or": [ + {"variable": "count", "operator": "equals", "value": 1}, + {"not": [{"variable": "count", "operator": "lessThan", "value": 2}]}, + ]}, + ] + self.assertEqual(evaluate_checks(sdk, datafile, checks, owner_variable="profile", diagnostics=diagnostics), []) + errors = evaluate_checks( + sdk, + datafile, + [{"variable": "count", "operator": "notContains", "value": "2"}], + owner_variable="profile", + diagnostics=diagnostics, + ) + self.assertIn("requires string operands", errors[0]) + errors = evaluate_checks( + sdk, + datafile, + [{"variable": "missing", "operator": "notEquals", "value": "anything"}], + owner_variable="profile", + diagnostics=diagnostics, + ) + self.assertIn("absent from the selected datafile", errors[0]) + finally: + sdk.close() + + def test_feature_check_getter_errors_and_diagnostics_are_errors(self) -> None: + datafile = {"features": {"experiment": {}}} + + class ThrowingVariation: + def __init__(self) -> None: + self.calls: list[str] = [] + + def is_enabled(self, feature: str, context: dict) -> bool: + self.calls.append(f"enabled:{feature}") + return False + + def get_variation(self, feature: str, context: dict) -> str: + self.calls.append(f"variation:{feature}") + raise RuntimeError("variation exploded") + + throwing = ThrowingVariation() + result = evaluate_checks_result( + throwing, + datafile, + [{"feature": "experiment", "enabled": True, "variation": "treatment"}], + owner_variable=None, + diagnostics=[], + ) + self.assertTrue(result["error"]) + self.assertEqual(throwing.calls, ["enabled:experiment", "variation:experiment"]) + self.assertIn("variation getter failed", result["failures"][0]) + self.assertIn('feature "experiment"', result["failures"][0]) + + diagnostics: list[dict] = [] + + class DiagnosticEnabled: + def is_enabled(self, feature: str, context: dict) -> bool: + diagnostics.append({"level": "error", "code": "evaluation_error", "message": "flag failed"}) + return False + + result = evaluate_checks_result( + DiagnosticEnabled(), + datafile, + [{"feature": "experiment", "enabled": False}], + owner_variable=None, + diagnostics=diagnostics, + ) + self.assertTrue(result["error"]) + self.assertIn("enabled getter reported evaluation_error", result["failures"][0]) + self.assertIn('feature "experiment"', result["failures"][0]) + + class MismatchingFeature: + def is_enabled(self, feature: str, context: dict) -> bool: + return False + + def get_variation(self, feature: str, context: dict) -> str: + return "treatment" + + result = evaluate_checks_result( + MismatchingFeature(), + datafile, + [{"feature": "experiment", "enabled": True, "variation": "control"}], + owner_variable=None, + diagnostics=[], + ) + self.assertFalse(result["passed"]) + self.assertFalse(result["error"]) + self.assertIn('feature "experiment"', result["failures"][0]) + self.assertIn("expected enabled=true, actual enabled=false", result["failures"][0]) + self.assertIn('expected variation="control", actual variation="treatment"', result["failures"][0]) + + def test_check_syntax_is_fully_validated_before_getters_run(self) -> None: + class TrackingEvaluator: + def __init__(self) -> None: + self.calls = 0 + + def is_enabled(self, feature: str, context: dict) -> bool: + self.calls += 1 + return True + + evaluator = TrackingEvaluator() + result = evaluate_checks_result( + evaluator, + {"features": {"checkout": {}}}, + [ + {"feature": "checkout", "enabled": True}, + {"feature": "checkout", "enabled": "true"}, + ], + owner_variable=None, + diagnostics=[], + ) + self.assertTrue(result["error"]) + self.assertEqual(evaluator.calls, 0) + + def test_preflight_validates_all_references_and_children_without_getters(self) -> None: + class TrackingEvaluator: + def __init__(self) -> None: + self.calls = 0 + + def get_variable(self, *args) -> str: + self.calls += 1 + return "value" + + def is_enabled(self, *args) -> bool: + self.calls += 1 + return True + + def get_variation(self, *args) -> str: + self.calls += 1 + return "control" + + datafile = { + "features": { + "checkout": { + "variablesSchema": {"message": {"type": "string", "defaultValue": "ready"}} + } + }, + "variables": {"message": {"type": "string", "defaultValue": "ready"}}, + } + invalid_checks = [ + [{"or": [ + {"variable": "message", "operator": "exists"}, + {"feature": "missing", "enabled": True}, + ]}], + [{"and": [ + {"feature": "checkout", "enabled": True}, + {"variable": "missing", "operator": "exists"}, + ]}], + [{"not": [{"feature": "checkout", "variable": "missing", "operator": "exists"}]}], + [{ + "variable": "message", + "operator": "equals", + "compareTo": {"variable": "missing"}, + }], + [{"segment": "missing", "matches": False}], + [{"or": [ + {"variable": "message", "operator": "exists"}, + {"variable": "message", "operator": "unknown", "value": "x"}, + ]}], + ] + for checks in invalid_checks: + evaluator = TrackingEvaluator() + result = evaluate_checks_result( + evaluator, + datafile, + checks, + owner_variable=None, + diagnostics=[], + segments={"known": {"conditions": "*"}}, + ) + self.assertTrue(result["error"], checks) + self.assertEqual(evaluator.calls, 0, checks) + + preflight_checks( + [{"segment": "known", "matches": True}], + datafile, + owner_variable=None, + segments={"known": {"conditions": {"attribute": "never-called"}}}, + ) + with self.assertRaises(CheckError): + _preflight_assertion( + {"variable": "missing"}, + {"checks": [{"operator": "isNull"}]}, + datafile, + {}, + ) + with self.assertRaises(CheckError): + _preflight_assertion( + {"scenario": "child"}, + { + "checks": [{"variable": "message", "operator": "exists"}], + "children": [{"checks": [{"variable": "missing", "operator": "exists"}]}], + }, + datafile, + {}, + ) + + def test_excluded_owner_expectations_allow_missing_but_child_checks_preflight(self) -> None: + project = Mock(spec=FeaturevisorProject) + project.set_name = None + project.get_config.return_value = {"environments": ["production"]} + project.list_features.return_value = [] + project.list_segments.return_value = [] + project.list_targets.return_value = ["filtered"] + project.build_datafile_json.return_value = { + "schemaVersion": "2", "revision": "target", "segments": {}, "features": {}, "variables": {}, + } + routing = {"environment": "production", "target": "filtered"} + project.list_tests.return_value = [ + { + entity: "excluded", + "assertions": [{**routing, **expectation, "children": [expectation]}], + } + for entity, expectation in [ + ("feature", {"expectedToBeEnabled": False}), + ("variable", {"expectedEvaluation": {"reason": "variable_not_found"}}), + ] + ] + results = _run_test_project_set(project, json_output=True) + self.assertEqual(len(results), 2) + self.assertTrue(all(result["passed"] for result in results), results) + + project.list_tests.return_value = [{ + "variable": "excluded", + "assertions": [{ + **routing, "expectedValue": None, + "children": [{"checks": [{"operator": "isNull"}]}], + }], + }] + with patch("featurevisor.tester.create_featurevisor") as create: + results = _run_test_project_set(project, json_output=True) + create.assert_not_called() + self.assertFalse(results[0]["passed"]) + self.assertIn('global variable "excluded"', results[0]["cases"][0]["errors"][0]["message"]) + + def test_named_check_metadata_is_validated_and_keeps_operand_evidence(self) -> None: + class Evaluator: + def get_variable(self, variable: str, context: dict) -> str: + return "actual" + + datafile = {"variables": {"message": {"type": "string", "defaultValue": "actual"}}} + result = evaluate_checks_result( + Evaluator(), + datafile, + [{ + "key": "message-ready", "description": "Message is ready", + "variable": "message", "operator": "equals", "value": "expected", + }], + owner_variable=None, + diagnostics=[], + ) + self.assertFalse(result["passed"]) + self.assertIn('key="message-ready"', result["errors"][0]["message"]) + self.assertIn('description="Message is ready"', result["errors"][0]["message"]) + self.assertIn('left="actual"', result["errors"][0]["message"]) + self.assertIn('right="expected"', result["errors"][0]["message"]) + self.assertEqual(result["errors"][0]["type"], "check") + self.assertIs(result["errors"][0]["expected"], True) + self.assertIs(result["errors"][0]["actual"], False) + self.assertEqual(result["errors"][0]["details"]["checkIndex"], 0) + self.assertEqual(result["errors"][0]["details"]["checkKey"], "message-ready") + self.assertEqual(result["errors"][0]["details"]["evaluated"][0], { + "path": [0], + "key": "message-ready", + "description": "Message is ready", + "reference": {"variable": "message"}, + "operator": "equals", + "actual": "actual", + "expected": "expected", + }) + + duplicate = evaluate_checks_result( + Evaluator(), + datafile, + [ + {"key": "same", "variable": "message", "operator": "exists"}, + {"key": "same", "variable": "message", "operator": "exists"}, + ], + owner_variable=None, + diagnostics=[], + ) + self.assertTrue(duplicate["error"]) + self.assertIn('duplicate sibling key "same"', duplicate["failures"][0]) + + def test_structured_check_evidence_preserves_paths_operands_and_missing_state(self) -> None: + class Evaluator: + def get_variable(self, variable: str, context: dict) -> object: + return {"minimum": 12, "maximum": 10, "settings": {"nullable": None}}[variable] + + datafile = {"variables": { + "minimum": {"type": "integer", "defaultValue": 12}, + "maximum": {"type": "integer", "defaultValue": 10}, + "settings": {"type": "object", "defaultValue": {"nullable": None}}, + }} + result = evaluate_checks_result( + Evaluator(), + datafile, + [{"key": "bounds", "and": [{ + "key": "checks[1]", "variable": "minimum", "operator": "lessThan", + "compareTo": {"variable": "maximum"}, + }]}], + owner_variable=None, + diagnostics=[], + child_index=2, + ) + error = result["errors"][0] + self.assertEqual((error["type"], error["expected"], error["actual"]), ("check", True, False)) + self.assertEqual(error["details"]["checkKey"], "bounds") + self.assertEqual(error["details"]["childIndex"], 2) + self.assertEqual(error["details"]["evaluated"][0], { + "path": [0, "and", 0], "key": "checks[1]", + "reference": {"variable": "minimum"}, "operator": "lessThan", + "actual": 12, "expected": 10, "compareTo": {"variable": "maximum"}, + }) + + nullable = evaluate_checks_result( + Evaluator(), datafile, + [{"variable": "settings", "path": "nullable", "operator": "isNull"}], + owner_variable=None, diagnostics=[], + ) + self.assertTrue(nullable["passed"]) + missing = evaluate_checks_result( + Evaluator(), datafile, + [{"variable": "settings", "path": "absent", "operator": "equals", "value": None}], + owner_variable=None, diagnostics=[], + ) + leaf = missing["errors"][0]["details"]["evaluated"][0] + self.assertEqual(leaf["actual"], {"$type": "undefined"}) + self.assertTrue(leaf["actualMissing"]) + self.assertIsNone(leaf["expected"]) + self.assertNotIn("expectedMissing", leaf) + + def test_report_expectations_keep_typed_values_and_authored_paths(self) -> None: + assertion = { + "description": "typed expectations", + "_case": {"id": "case", "assertionIndex": 4, "matrixValues": {}}, + } + assertion_result = { + "passed": False, + "errors": [ + {"type": "flag", "expected": True, "actual": False}, + { + "type": "variation", "expected": "control", "actual": None, + "details": {"childIndex": 2}, + }, + { + "type": "variable", "expected": 10, "actual": 12, + "details": {"variableKey": "limit"}, + }, + ], + } + case = _case_result(assertion, assertion_result, "feature", None, [], None) + + self.assertEqual(case["rerun"]["command"], f"{shlex.quote(sys.executable)} -m featurevisor") + self.assertEqual(case["errors"][0]["path"], ["assertions", 4, "expectedToBeEnabled"]) + self.assertIs(case["errors"][0]["expected"], True) + self.assertIs(case["errors"][0]["actual"], False) + self.assertEqual( + case["errors"][1]["path"], + ["assertions", 4, "children", 2, "expectedVariation"], + ) + self.assertIsNone(case["errors"][1]["actual"]) + self.assertEqual( + case["errors"][2]["path"], + ["assertions", 4, "expectedVariables", "limit"], + ) + self.assertEqual(case["errors"][2]["expected"], 10) + self.assertEqual(case["errors"][2]["actual"], 12) + + def test_json_report_exact_rerun_coverage_and_uncovered(self) -> None: + datafile = { + "schemaVersion": "2", "revision": "report", "segments": {}, "features": {}, + "variables": { + "message": { + "type": "string", "defaultValue": "default", + "overrides": [{"key": "selected", "keyPath": "selected", "segments": "*", "value": "actual"}], + } + }, + } + tests = [{ + "key": "tests/scenarios/checkout.spec.yml", "scenario": "checkout", + "_test": {"source": "tests/scenarios/checkout.spec.yml", "set": None, "entityType": "scenario"}, + "assertions": [{ + "description": "Checkout is ready", "environment": "production", "target": "checkout", "context": {}, + "checks": [{"key": "message", "variable": "message", "operator": "equals", "value": "expected"}], + "_case": {"id": "opaque case", "assertionIndex": 0, "matrixIndex": None, "matrixValues": {}}, + }], + }] + with tempfile.TemporaryDirectory() as directory: + log_path = os.path.join(directory, "calls.log") + script = os.path.join(directory, "npx") + source = f'''#!{sys.executable} +import json, os, sys +with open(os.environ["FAKE_NPX_LOG"], "a", encoding="utf-8") as log: + log.write(" ".join(sys.argv[1:]) + "\\n") +args = sys.argv[2:] +if args[:2] == ["config", "--json"]: value = {{"environments": ["production"]}} +elif args[:2] == ["list", "--targets"]: value = ["checkout"] +elif args[:2] == ["list", "--segments"]: value = [] +elif args[:2] == ["list", "--features"]: value = [{{"key": "untestedFeature"}}] +elif args[:2] == ["list", "--variables"]: value = [{{"key": "untestedVariable"}}] +elif args[:2] == ["list", "--tests"]: value = {{"kind": "featurevisor.test-execution", "schemaVersion": 1, "tests": json.loads({json.dumps(json.dumps(tests))})}} +elif args and args[0] == "build": value = json.loads({json.dumps(json.dumps(datafile))}) +else: raise SystemExit("unexpected arguments: " + repr(args)) +print(json.dumps(value)) +''' + with open(script, "w", encoding="utf-8") as handle: + handle.write(source) + os.chmod(script, 0o755) + env = dict(os.environ) + env.update({"PATH": directory + os.pathsep + env["PATH"], "PYTHONPATH": "src", "FAKE_NPX_LOG": log_path}) + result = subprocess.run( + [ + sys.executable, "-m", "featurevisor", "test", "--json", "--pretty", + f"--projectDirectoryPath={os.path.dirname(os.path.dirname(__file__))}", + "--case=opaque case", "--target=checkout", "--coverage", "--uncovered", + ], + cwd=os.path.dirname(os.path.dirname(__file__)), env=env, text=True, capture_output=True, + ) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + report = json.loads(result.stdout) + self.assertEqual(report["schemaVersion"], 1) + self.assertEqual(report["runner"], "python") + self.assertFalse(report["passed"]) + self.assertFalse(report["errors"], report) + self.assertEqual(report["summary"]["scenarioCases"], {"passed": 0, "failed": 1}) + case = report["results"][0]["cases"][0] + self.assertEqual(case["rerun"]["args"], [ + "test", "--case=opaque case", + f"--projectDirectoryPath={os.path.dirname(os.path.dirname(__file__))}", + "--target=checkout", + ]) + check_error = case["errors"][0] + self.assertEqual(check_error["type"], "check") + self.assertEqual(check_error["message"], "Check did not match") + self.assertIs(check_error["expected"], True) + self.assertIs(check_error["actual"], False) + self.assertEqual(check_error["path"], ["checks", 0]) + self.assertEqual(check_error["details"]["checkKey"], "message") + self.assertEqual(check_error["details"]["evaluated"][0], { + "path": ["checks", 0], "key": "message", + "reference": {"variable": "message"}, "operator": "equals", + "actual": "actual", "expected": "expected", + }) + self.assertEqual(report["coverage"]["mode"], "observed-selections") + self.assertEqual(report["coverage"]["selections"][0]["overridePath"], "selected") + self.assertEqual(report["uncovered"], [{ + "set": None, "features": ["untestedFeature"], "variables": ["untestedVariable"], + }]) + with open(log_path, encoding="utf-8") as handle: + self.assertIn( + "list --tests --execution-protocol --json --case=opaque case", + handle.read(), + ) + + def test_shared_check_fixture_runs_every_case_with_a_fresh_native_instance(self) -> None: + fixture_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "conformance", "test-checks.json") + with open(fixture_path, encoding="utf-8") as handle: + fixture = json.load(handle) + self.assertEqual(fixture["version"], 3) + self.assertEqual(len(fixture["cases"]), 255) + self.assertIsInstance(fixture["datafile"], dict) + executed_any = 0 + for case in fixture["cases"]: + self.assertIsInstance(case.get("name"), str) + self.assertIn("checks", case) + self.assertIsInstance(case.get("passed"), bool) + diagnostics: list[dict] = [] + sdk = create_featurevisor({"datafile": fixture["datafile"], "logLevel": "fatal", "onDiagnostic": diagnostics.append}) + try: + result = evaluate_checks_result( + sdk, + fixture["datafile"], + case["checks"], + owner_variable=case.get("owner"), + diagnostics=diagnostics, + ) + finally: + sdk.close() + preflight_error = case["name"] in { + "or skips unreached missing feature", + "and skips unreached missing feature", + "short circuit skips unresolved ref", + "AND short circuit", + } + self.assertEqual( + result["passed"], False if preflight_error else case["passed"], + case["name"] + ": " + "; ".join(result["failures"]), + ) + self.assertEqual( + result["error"], True if preflight_error else case.get("error", False), + case["name"] + ": " + "; ".join(result["failures"]), + ) + if "includesAny" in json.dumps(case["checks"]) or "notIncludesAny" in json.dumps(case["checks"]): + executed_any += 1 + self.assertEqual(executed_any, 36) + + def test_quiet_check_collection_rejects_malformed_json_getter(self) -> None: + datafile = { + "schemaVersion": "2", "revision": "bad-json", "segments": {}, "features": {}, + "variables": {"broken": {"type": "json", "defaultValue": "["}}, + } + diagnostics: list[dict] = [] + sdk = create_featurevisor({"datafile": datafile, "logLevel": "error", "onDiagnostic": diagnostics.append}) + try: + result = evaluate_checks_result( + sdk, + datafile, + [{"variable": "broken", "operator": "isNull"}], + owner_variable=None, + diagnostics=diagnostics, + ) + finally: + sdk.close() + self.assertFalse(result["passed"]) + self.assertTrue(result["error"]) + self.assertIn("variable getter reported", result["failures"][0]) + + def test_native_executable_runs_scenario_children_filter_and_failure_exit(self) -> None: + datafile = { + "schemaVersion": "2", + "revision": "runner", + "segments": {}, + "features": {}, + "variables": { + "message": { + "type": "string", + "defaultValue": "parent", + "overrides": [{ + "key": "child", + "segments": "*", + "conditions": {"attribute": "mode", "operator": "equals", "value": "child"}, + "value": "child", + }], + } + }, + } + passing = [{ + "key": "scenarios/context.spec", + "scenario": "context", + "assertions": [{ + "description": "matrix expanded scenario", + "checks": [{"variable": "message", "operator": "equals", "value": "parent"}], + "children": [{ + "context": {"mode": "child"}, + "checks": [{"variable": "message", "operator": "equals", "value": "child"}], + }], + }], + }] + failing = [{ + "key": "scenarios/failure.spec", + "scenario": "failure", + "assertions": [{ + "description": "failing scenario", + "checks": [{"variable": "message", "operator": "equals", "value": "wrong"}], + }], + }] + malformed = [ + {"key": "scenarios/empty.spec", "scenario": "empty", "assertions": []}, + { + "key": "scenarios/hybrid.spec", "scenario": "hybrid", "variable": "message", + "assertions": [{"description": "hybrid", "checks": [{"variable": "message", "operator": "exists"}]}], + }, + ] + with tempfile.TemporaryDirectory() as directory: + log_path = os.path.join(directory, "calls.log") + script = os.path.join(directory, "npx") + source = f'''#!{sys.executable} +import json, os, sys +with open(os.environ["FAKE_NPX_LOG"], "a", encoding="utf-8") as log: + log.write(" ".join(sys.argv[1:]) + "\\n") +args = sys.argv[2:] +if args[:2] == ["config", "--json"]: value = {{"environments": False}} +elif args[:2] == ["list", "--targets"]: value = [] +elif args[:2] == ["list", "--segments"]: value = [] +elif args[:2] == ["list", "--features"]: value = [] +elif args[:2] == ["list", "--tests"]: + if os.environ.get("FAKE_MODE") == "protocol": + value = [] + else: + tests = {json.dumps(passing)} if os.environ.get("FAKE_MODE") == "pass" else ({json.dumps(malformed)} if os.environ.get("FAKE_MODE") == "malformed" else {json.dumps(failing)}) + value = {{"kind": "featurevisor.test-execution", "schemaVersion": 1, "tests": tests}} +elif args and args[0] == "build": value = {json.dumps(datafile)} +else: raise SystemExit("unexpected arguments: " + repr(args)) +print(json.dumps(value)) +''' + with open(script, "w", encoding="utf-8") as handle: + handle.write(source) + os.chmod(script, 0o755) + env = dict(os.environ) + env.update({"PATH": directory + os.pathsep + env["PATH"], "PYTHONPATH": "src", "FAKE_NPX_LOG": log_path}) + env["FAKE_MODE"] = "pass" + passed = subprocess.run( + [sys.executable, "-m", "featurevisor", "test", "--set=staging", "--entityType=scenario", "--onlyFailures"], + cwd=os.path.dirname(os.path.dirname(__file__)), env=env, text=True, capture_output=True, + ) + self.assertEqual(passed.returncode, 0, passed.stdout + passed.stderr) + self.assertIn("Scenario specs: 1 passed, 0 failed", passed.stdout) + self.assertIn("Total cases: 1 passed, 0 failed", passed.stdout) + with open(log_path, encoding="utf-8") as log: + calls = log.read() + self.assertIn("--entityType=scenario", calls) + self.assertIn("--set=staging", calls) + env["FAKE_MODE"] = "fail" + failed = subprocess.run( + [sys.executable, "-m", "featurevisor", "test", "--set=staging", "--entityType=scenario", "--onlyFailures"], + cwd=os.path.dirname(os.path.dirname(__file__)), env=env, text=True, capture_output=True, + ) + self.assertEqual(failed.returncode, 1, failed.stdout + failed.stderr) + self.assertIn("checks.1", failed.stdout) + self.assertIn("left=\"parent\"", failed.stdout) + env["FAKE_MODE"] = "malformed" + malformed_result = subprocess.run( + [sys.executable, "-m", "featurevisor", "test", "--set=staging", "--entityType=scenario", "--onlyFailures"], + cwd=os.path.dirname(os.path.dirname(__file__)), env=env, text=True, capture_output=True, + ) + self.assertEqual(malformed_result.returncode, 1, malformed_result.stdout + malformed_result.stderr) + self.assertIn("scenario spec requires assertions", malformed_result.stdout) + self.assertIn("requires exactly one of feature, variable, segment or scenario", malformed_result.stdout) + + with open(log_path, "w", encoding="utf-8"): + pass + env["FAKE_MODE"] = "protocol" + protocol_result = subprocess.run( + [sys.executable, "-m", "featurevisor", "test", "--set=staging", "--entityType=scenario", "--onlyFailures"], + cwd=os.path.dirname(os.path.dirname(__file__)), env=env, text=True, capture_output=True, + ) + self.assertEqual(protocol_result.returncode, 1, protocol_result.stdout + protocol_result.stderr) + self.assertIn("test execution protocol", (protocol_result.stdout + protocol_result.stderr).lower()) + with open(log_path, encoding="utf-8") as log: + calls = log.read() + self.assertIn("list --tests --execution-protocol --json", calls) + self.assertNotIn("featurevisor build", calls) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli_options.py b/tests/test_cli_options.py new file mode 100644 index 0000000..c6c9013 --- /dev/null +++ b/tests/test_cli_options.py @@ -0,0 +1,149 @@ +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from unittest.mock import patch + +from featurevisor.cli import build_parser, main + + +class CLIOptionTests(unittest.TestCase): + def test_aliases_are_equivalent_in_both_value_forms(self): + aliases = { + "projectDirectoryPath": ("project-directory-path", "/tmp/project with spaces"), + "keyPattern": ("key-pattern", "^ready.*$"), + "assertionPattern": ("assertion-pattern", "ready with spaces"), + "onlyFailures": ("only-failures", None), + "showDatafile": ("show-datafile", None), + "populateUuid": ("populate-uuid", "userId"), + "entityType": ("entity-type", "feature"), + } + parser = build_parser() + for command in ("test", "benchmark", "assess-distribution"): + for camel, (kebab, value) in aliases.items(): + if camel == "entityType" and command != "test": + continue + for separate in (False, True): + def args(name): + return [f"--{name}"] if value is None else ([f"--{name}", value] if separate else [f"--{name}={value}"]) + with self.subTest(command=command, option=kebab, separate=separate): + self.assertEqual(vars(parser.parse_args([command, *args(camel)])), + vars(parser.parse_args([command, *args(kebab)]))) + for name in ("project-directory-path", "projectDirectoryPath", "root-directory-path", "rootDirectoryPath"): + for arguments in ([f"--{name}=/tmp/quoted ' path"], [f"--{name}", "/tmp/quoted ' path"]): + self.assertEqual(parser.parse_args([command, *arguments]).projectDirectoryPath, "/tmp/quoted ' path") + + def test_iterations_repeated_selectors_and_json_are_not_split(self): + parser = build_parser() + context = '{"userId":"a b", "literal":"$(touch injected)"}' + for command in ("test", "benchmark", "assess-distribution"): + for iterations in (["--n=2"], ["--n", "2"], ["-n=2"], ["-n", "2"]): + args = parser.parse_args([command, *iterations, "--target=web space", "--target", "mobile", + "--target=web space", "--populate-uuid=userId", "--populateUuid", "accountId", "--context", context]) + self.assertEqual(args.n, 2) + self.assertEqual(args.target, ["web space", "mobile", "web space"]) + self.assertEqual(args.populateUuid, ["userId", "accountId"]) + self.assertEqual(args.context, context) + with patch("featurevisor.cli.run_assess_distribution", return_value=0) as runner: + self.assertEqual(main(["assess-distribution", "--feature=ready", "-n=2", "--context", context, + "--populate-uuid=userId", "--populateUuid", "accountId"]), 0) + self.assertEqual(runner.call_args.kwargs["context"], json.loads(context)) + self.assertEqual(runner.call_args.kwargs["populate_uuid"], ["userId", "accountId"]) + + def test_usage_errors_never_delegate_or_write_stdout(self): + invalid = [["--unknown"], ["extra"], ["--", "extra"], ["--target", "web", "mobile"], + ["--populate-uuid", "userId", "accountId"], ["--target="], ["--populate-uuid="], + ["--root-directory-path="], ["--target"], ["--populate-uuid"], ["--context="], + ["--context=[]"], ["--context=null"], ["--context={"], ["--feature="], ["--variable="]] + for name in ("--n", "-n", "--inflate"): + invalid.extend([[f"{name}={value}"] for value in ("0", "-1", "1.5", "bad", "")]) + invalid.append([name]) + with patch("featurevisor.cli.run_test_project") as test, patch("featurevisor.cli.run_benchmark") as benchmark, patch("featurevisor.cli.run_assess_distribution") as assess: + for command in ("test", "benchmark", "assess-distribution"): + for arguments in invalid: + with self.subTest(command=command, arguments=arguments): + stdout, stderr = StringIO(), StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr): + try: + status = main([command, "--feature=ready", *arguments]) + except SystemExit as error: + status = error.code + self.assertNotEqual(status, 0) + self.assertEqual(stdout.getvalue(), "") + self.assertTrue(stderr.getvalue()) + with redirect_stderr(StringIO()), self.assertRaises(SystemExit): + main(["test", "--entity-type=unknown", "--json"]) + for runner in (test, benchmark, assess): + runner.assert_not_called() + + def test_help_and_unknown_commands(self): + for arguments in (["--help"], ["test", "--help"], ["benchmark", "--help"], ["assess-distribution", "--help"]): + output = StringIO() + with redirect_stdout(output), self.assertRaises(SystemExit) as error: + main(arguments) + self.assertEqual(error.exception.code, 0) + self.assertIn("usage:", output.getvalue()) + if len(arguments) > 1: + self.assertIn("--root-directory-path", output.getvalue()) + self.assertIn("--populate-uuid", output.getvalue()) + with redirect_stderr(StringIO()), self.assertRaises(SystemExit) as error: + main(["evaluate"]) + self.assertNotEqual(error.exception.code, 0) + + def test_real_process_preserves_paths_selectors_and_json_boundary(self): + with tempfile.TemporaryDirectory(prefix="featurevisor cli ") as directory: + root = Path(directory) + project = root / "project ' café $(touch injected)" + project.mkdir() + calls = root / "calls.jsonl" + shim = root / "npx" + shim.write_text(f'''#!{sys.executable} +import json, os, sys +from pathlib import Path +args = sys.argv[2:] +with Path({str(calls)!r}).open("a") as log: + log.write(json.dumps([os.getcwd(), args]) + "\\n") +if args[0] == "config": value = {{"environments": False, "sets": False}} +elif args[0] == "list": + if "--tests" in args: value = {{"kind": "featurevisor.test-execution", "schemaVersion": 1, "tests": [{{"feature": "ready", "assertions": [{{"expectedToBeEnabled": True}}]}}]}} + elif "--targets" in args: value = ["web space", "mobile'quote"] + else: value = [] +else: + sys.stderr.write("delegated warning\\n") + value = {{"schemaVersion": "2", "revision": "test", "segments": {{}}, "features": {{"ready": {{"bucketBy": "userId", "traffic": [{{"key": "all", "segments": "*", "percentage": 100000}}]}}}}}} +print(json.dumps(value)) +''', encoding="utf-8") + shim.chmod(0o755) + env = dict(os.environ, PATH=f"{root}{os.pathsep}{os.environ['PATH']}", + PYTHONPATH=str(Path(__file__).resolve().parents[1] / "src")) + context = json.dumps({"userId": "a b ' café", "literal": "$(touch injected)"}) + pattern = "ready ' café;$()" + for command in ("test", "benchmark", "assess-distribution"): + calls.write_text("") + options = ["--json", "--show-datafile", "--only-failures", "--key-pattern", pattern, + "--assertion-pattern=with spaces", "--entity-type=feature"] if command == "test" else ["--context", context] + result = subprocess.run([sys.executable, "-m", "featurevisor", command, + "--root-directory-path", str(project), "--feature=ready", "-n=2", + "--target=web space", "--target", "mobile'quote", "--target=web space", *options], + env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + recorded = [json.loads(line) for line in calls.read_text().splitlines()] + self.assertTrue(all(Path(cwd).resolve() == project.resolve() for cwd, _ in recorded)) + targets = [arg for _, call in recorded if call[0] == "build" for arg in call if arg.startswith("--target=")] + self.assertEqual(targets, ["--target=web space", "--target=mobile'quote"]) + if command == "test": + self.assertTrue(json.loads(result.stdout)["passed"]) + delegated = next(call for _, call in recorded if "--tests" in call) + self.assertIn(f"--keyPattern={pattern}", delegated) + self.assertIn("--assertionPattern=with spaces", delegated) + self.assertIn("--entityType=feature", delegated) + self.assertIn('"schemaVersion": "2"', result.stderr) + else: + self.assertNotIn("Environment:", result.stdout) + self.assertIn(context, result.stdout) + self.assertFalse((project / "injected").exists()) diff --git a/tests/test_conditions_parity.py b/tests/test_conditions_parity.py index 60f7d13..c3a0773 100644 --- a/tests/test_conditions_parity.py +++ b/tests/test_conditions_parity.py @@ -7,10 +7,32 @@ sys.path.insert(0, "src") from featurevisor.evaluation_data_provider import _InstanceEvaluationDataProvider +from featurevisor.conditions import _to_datetime from featurevisor.diagnostics import _create_evaluation_diagnostics class ConditionsParityTests(unittest.TestCase): + def test_portable_string_fractional_precision(self) -> None: + expected = _to_datetime("2024-01-01T00:00:00.123Z") + for digits in ("1239", "123999", "1239999", "123999999"): + with self.subTest(digits=digits): + self.assertEqual(_to_datetime(f"2024-01-01T00:00:00.{digits}Z"), expected) + self.assertIsNone(_to_datetime("2024-01-01T00:00:00.1234567890Z")) + self.assertEqual( + _to_datetime("1969-12-31T23:59:59.999999999Z"), + dt.datetime(1969, 12, 31, 23, 59, 59, 999000, tzinfo=dt.timezone.utc), + ) + + def test_host_dates_floor_pre_epoch_milliseconds_and_require_timezone(self) -> None: + host = dt.datetime(1969, 12, 31, 23, 59, 59, 999500, tzinfo=dt.timezone.utc) + self.assertEqual( + _to_datetime(host), + dt.datetime(1969, 12, 31, 23, 59, 59, 999000, tzinfo=dt.timezone.utc), + ) + self.assertIsNone(_to_datetime(host.replace(tzinfo=None))) + wide = dt.timezone(dt.timedelta(hours=15)) + self.assertIsNone(_to_datetime(dt.datetime(2024, 1, 1, 15, tzinfo=wide))) + @classmethod def setUpClass(cls) -> None: cls.reader = _InstanceEvaluationDataProvider( @@ -25,6 +47,41 @@ def test_should_match_all_via_star(self) -> None: self.assertTrue(self.reader.allConditionsAreMatched("*", {"browser_type": "chrome"})) self.assertFalse(self.reader.allConditionsAreMatched("blah", {"browser_type": "chrome"})) + def test_empty_and_not_empty_use_shallow_supported_contents(self) -> None: + cases = [ + ("empty", "", True), + ("notEmpty", "", False), + ("empty", " ", False), + ("notEmpty", " ", True), + ("empty", [], True), + ("notEmpty", [None], True), + ("empty", {}, True), + ("notEmpty", {"value": None}, True), + ("empty", None, False), + ("notEmpty", None, False), + ("empty", 0, False), + ("notEmpty", False, False), + ("empty", (), False), + ("notEmpty", (), False), + ] + for operator, value, expected in cases: + with self.subTest(operator=operator, value=value): + self.assertEqual( + self.reader.allConditionsAreMatched( + [{"attribute": "profile.value", "operator": operator}], + {"profile": {"value": value}}, + ), + expected, + ) + + for operator in ("empty", "notEmpty"): + self.assertFalse( + self.reader.allConditionsAreMatched( + [{"attribute": "profile.missing", "operator": operator}], + {"profile": {}}, + ) + ) + def test_operator_cases(self) -> None: cases = [ ([{"attribute": "browser_type", "operator": "equals", "value": "chrome"}], {"browser_type": "chrome"}, True), diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 83c9780..a49837b 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -24,7 +24,8 @@ def test_evaluation_reason_is_python_310_compatible_and_string_like(self) -> Non self.assertEqual(json.dumps({"reason": reason}), '{"reason": "feature_not_found"}') def test_allocation_and_typed_value_contracts(self) -> None: - self.assertEqual(self.fixture["version"], 6) + self.assertEqual(self.fixture["version"], 15) + self.assertEqual(len(self.fixture["conditionCases"]), 189) reader = _InstanceEvaluationDataProvider( datafile={"schemaVersion": "2", "revision": "conformance", "segments": {}, "features": {}}, diagnostics=_create_evaluation_diagnostics(), @@ -37,7 +38,9 @@ def test_allocation_and_typed_value_contracts(self) -> None: self.assertEqual(allocation["variation"], expected) for item in self.fixture["typedVariables"]: - actual = get_value_by_type(item["value"], item["type"]) + # The fixture names the fractional number getter by its v3 name. + field_type = "number" if item["type"] == "double" else item["type"] + actual = get_value_by_type(item["value"], field_type) self.assertEqual(actual is not None, item["valid"]) for item in self.fixture["numericBucketKeys"]: @@ -65,12 +68,16 @@ def test_allocation_and_typed_value_contracts(self) -> None: f'pattern {item["pattern"]}, flags {item["flags"]}', ) + executed_any = 0 for item in self.fixture["conditionCases"]: self.assertEqual( reader.all_conditions_are_matched(item["condition"], item["context"]), item["expected"], item["name"], ) + if "includesAny" in json.dumps(item["condition"]) or "notIncludesAny" in json.dumps(item["condition"]): + executed_any += 1 + self.assertEqual(executed_any, 73) aggregate_case = self.fixture["defaults"]["aggregateCase"] featurevisor = create_featurevisor({"datafile": aggregate_case["datafile"]}) @@ -119,6 +126,67 @@ def test_canonical_required_features(self) -> None: self.assertEqual(evaluation.get("variableValue"), case["expectedValue"]) self.assertEqual(evaluation.get("variableOverrideKey"), case["expectedOverrideKey"]) + def test_required_feature_evaluation_cases(self) -> None: + fixture = self.fixture["requiredFeatures"] + cases = fixture["evaluationCases"] + self.assertTrue(cases) + f = create_featurevisor({"datafile": fixture["datafile"], "logLevel": "fatal"}) + self.addCleanup(f.close) + for case in cases: + with self.subTest(name=case["name"]): + context = case.get("context", {}) + if case["type"] == "flag": + actual = f.evaluate_flag(case["feature"], context) + elif case["type"] == "variation": + actual = f.evaluate_variation(case["feature"], context) + elif case["type"] == "variable": + actual = (f.evaluate_variable(case["feature"], case["variable"], context) + if "feature" in case else f.evaluate_variable(case["variable"], context)) + else: + self.fail(f"Unknown evaluation type: {case['type']}") + self.assertTrue(case["expected"]) + for key, expected in case["expected"].items(): + self.assertIn(key, actual) + self.assertEqual(actual[key], expected, key) + + def test_recursive_required_feature_groups(self) -> None: + for case in self.fixture["requiredFeatureGroups"]: + for inputs, expected in zip(self.fixture["requiredFeatureGroupInputs"], case["results"]): + datafile = { + "schemaVersion": "2", + "revision": "groups", + "segments": {}, + "features": { + "a": {"bucketBy": "userId", "force": [{"segments": "*", "enabled": inputs["a"]}], "traffic": []}, + "b": {"bucketBy": "userId", "force": [{"segments": "*", "enabled": inputs["b"]}], "traffic": []}, + "subject": { + "bucketBy": "userId", + "requiredFeatures": case["requirements"], + "traffic": [{"key": "all", "segments": "*", "percentage": 100000}], + }, + }, + } + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal"}) + self.assertEqual(sdk.is_enabled("subject"), expected, f'{case["name"]}: {inputs}') + + def test_required_variables_conformance(self) -> None: + fixture = self.fixture["requiredVariables"] + for case in fixture["stickyCases"]: + sdk = create_featurevisor({ + "datafile": fixture["datafile"], + "stickyFeatures": case["stickyFeatures"], + "stickyVariables": case["stickyVariables"], + "logLevel": "fatal", + }) + self.assertEqual(sdk.is_enabled("gated"), case["matched"], case["name"]) + self.assertEqual(sdk.get_variable("root"), fixture["expected"]["matchedValue"] if case["matched"] else fixture["expected"]["defaultValue"], case["name"]) + self.assertEqual(sdk.get_variable("override"), fixture["expected"]["matchedValue"] if case["matched"] else fixture["expected"]["defaultValue"], case["name"]) + self.assertEqual(sdk.get_variable("featureOverride", "message"), fixture["expected"]["matchedValue"] if case["matched"] else fixture["expected"]["defaultValue"], case["name"]) + self.assertEqual(sdk.get_variable("accumulated"), case["accumulated"], case["name"]) + sdk = create_featurevisor({"datafile": fixture["datafile"], "logLevel": "fatal"}) + null_evaluation = sdk.evaluate_variable("nullGate") + self.assertEqual(null_evaluation.get("variableValue"), fixture["expected"]["matchedValue"]) + def test_datafile_dependency_events(self) -> None: fixture = self.fixture["globalVariables"] update = fixture["datafileUpdateCase"] @@ -179,10 +247,8 @@ def test_required_features_use_modules_and_module_phases_are_ordered(self) -> No for name in ("first", "second"): modules.append({ "name": name, - "before": lambda options, name=name: order.append(f"before:{name}") or options, "beforeEvaluation": lambda options, name=name: order.append(f"beforeEvaluation:{name}") or options, "afterEvaluation": lambda evaluation, _options, name=name: order.append(f"afterEvaluation:{name}") or evaluation, - "after": lambda evaluation, _options, name=name: order.append(f"after:{name}") or evaluation, }) f = create_featurevisor({ "datafile": self.fixture["requiredFeatures"]["datafile"], diff --git a/tests/test_dependency_errors.py b/tests/test_dependency_errors.py new file mode 100644 index 0000000..08cbf21 --- /dev/null +++ b/tests/test_dependency_errors.py @@ -0,0 +1,37 @@ +import json +import unittest + +from featurevisor import create_featurevisor + + +def chain(length): + features = {} + for index in range(length): + conditions = "*" if index == 0 else json.dumps([{"feature": f"f{index - 1}"}]) + features[f"f{index}"] = { + "bucketBy": "userId", + "traffic": [{"key": "all", "conditions": conditions, "percentage": 100000}], + } + return {"schemaVersion": "4", "revision": "depth", "segments": {}, "features": features} + + +class DependencyErrorDiagnosticsTest(unittest.TestCase): + def test_dependency_failures_report_once_for_each_public_call(self): + errors = [] + f = create_featurevisor({ + "datafile": chain(300), + "logLevel": "error", + "onDiagnostic": lambda diagnostic: errors.append(diagnostic["code"]) if diagnostic["level"] == "error" else None, + }) + try: + self.assertFalse(f.is_enabled("f299", {"userId": "u"})) + self.assertIsNone(f.get_variation("f299", {"userId": "u"})) + self.assertEqual(errors, ["evaluation_error", "evaluation_error"]) + self.assertTrue(f.is_enabled("f20", {"userId": "u"})) + self.assertEqual(len(errors), 2) + finally: + f.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deprecation_diagnostics.py b/tests/test_deprecation_diagnostics.py new file mode 100644 index 0000000..88883df --- /dev/null +++ b/tests/test_deprecation_diagnostics.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +import unittest + +from featurevisor import create_featurevisor + +DATAFILE = json.loads( + '{"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}}}' +) + + +class DeprecationDiagnosticsTests(unittest.TestCase): + def test_deprecated_features_and_variables_use_one_code_per_kind(self) -> None: + captured: list[str] = [] + f = create_featurevisor({ + "datafile": DATAFILE, + "logLevel": "warn", + "onDiagnostic": lambda d: captured.append(d["code"]) if "deprecated" in d["code"] else None, + }) + try: + f.is_enabled("legacy") + self.assertEqual(set(captured), {"deprecated_feature"}) + + captured.clear() + f.get_variable("legacy", "old") + self.assertEqual(set(captured), {"deprecated_feature", "deprecated_variable"}) + + captured.clear() + f.get_variable("oldGlobal") + self.assertEqual(captured, ["deprecated_variable"]) + finally: + f.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_evaluation_data_provider.py b/tests/test_evaluation_data_provider.py index cf43fae..99ad21e 100644 --- a/tests/test_evaluation_data_provider.py +++ b/tests/test_evaluation_data_provider.py @@ -41,7 +41,8 @@ def test_v2_datafile_schema_should_return_requested_entities(self) -> None: self.assertEqual(reader.getSegment("netherlands"), datafile_json["segments"]["netherlands"]) self.assertEqual(reader.getSegment("germany")["conditions"][0]["value"], "de") self.assertIsNone(reader.getSegment("belgium")) - self.assertEqual(reader.getFeature("test"), datafile_json["features"]["test"]) + self.assertEqual(reader.getFeature("test")["traffic"][0]["conditions"], "*") + self.assertEqual(datafile_json["features"]["test"]["traffic"][0]["segments"], "*") self.assertEqual(reader.getVariableKeys("test"), []) self.assertIsNone(reader.getFeature("test2")) self.assertEqual(reader.getVariableKeys("test2"), []) diff --git a/tests/test_evaluation_performance_contract.py b/tests/test_evaluation_performance_contract.py new file mode 100644 index 0000000..72d90de --- /dev/null +++ b/tests/test_evaluation_performance_contract.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import gc +import sys +import unittest +import weakref +from unittest.mock import Mock, patch + +sys.path.insert(0, "src") + +from featurevisor import create_featurevisor +from featurevisor._evaluation_session import EvaluationSession, refresh_evaluation_state +from featurevisor.dependencies import create_datafile_dependency_index + + +def datafile(revision: str = "1", conditions: str | None = None) -> dict: + segment_conditions = conditions or '[{"attribute":"country","operator":"equals","value":"nl"}]' + return { + "schemaVersion": "2", + "revision": revision, + "segments": { + "country": {"key": "country", "conditions": segment_conditions}, + }, + "features": { + "enabled": { + "key": "enabled", + "bucketBy": "userId", + "traffic": [ + { + "key": "country-rule", + "segments": '["country"]', + "percentage": 100000, + } + ], + }, + "secondary": { + "key": "secondary", + "bucketBy": "userId", + "traffic": [{"key": "all", "segments": "*", "percentage": 100000}], + }, + }, + "variables": { + "message": {"type": "string", "defaultValue": "hello"}, + }, + } + + +class EvaluationPerformanceContractTests(unittest.TestCase): + def test_outer_global_reads_do_not_probe_dependency_memo_even_on_error(self): + sdk = create_featurevisor({"datafile": datafile(), "logLevel": "fatal"}) + self.addCleanup(sdk.close) + with patch.object(EvaluationSession, "key", side_effect=AssertionError("outer memo probe")): + self.assertEqual(sdk.get_variable("message"), "hello") + self.assertEqual(sdk.get_variable("missing", {}, {"defaultVariableValue": "fallback"}), "fallback") + sdk.add_module({"beforeEvaluation": lambda options: {**options, "variableKey": "missing"}}) + self.assertEqual(sdk.evaluate_variable("message")["reason"], "variable_not_found") + sdk.set_datafile({"schemaVersion": "4", "revision": "error", "segments": {}, "features": {}, + "variables": {"missing": {"type": "string", "conditions": {"attribute": "x", "operator": "equals", "value": 1}}}}) + failure = ValueError("evaluation failed") + with patch.object(sdk, "_conditions_are_matched", side_effect=failure): + evaluation = sdk.evaluate_variable("message") + self.assertEqual(evaluation["reason"], "error") + self.assertIs(evaluation["error"], failure) + + def test_unchanged_state_skips_sources_but_preserves_module_inputs(self): + generation = [0] + maps = [{}, {}] + provider = [object()] + sticky_source = Mock(side_effect=lambda: tuple(maps)) + datafile_source = Mock(side_effect=lambda: provider[0]) + options = {"__featurevisor_state_version": lambda: generation[0], + "__featurevisor_child_sticky_source": sticky_source, + "__featurevisor_child_initial_sticky": tuple(maps), + "__featurevisor_datafile_source": datafile_source, + "__featurevisor_initial_datafile": provider[0], + "datafile": provider[0], "stickyFeatures": maps[0], "stickyVariables": maps[1], + "context": {"nested": {"value": 1}}} + refresh_evaluation_state(options) + for _ in range(10): + refresh_evaluation_state(options) + self.assertEqual(sticky_source.call_count, 1) + self.assertEqual(datafile_source.call_count, 1) + replacement = {"module": True} + options["stickyVariables"] = replacement + options["context"]["nested"]["value"] = 2 + refresh_evaluation_state(options) + self.assertIs(options["stickyVariables"], replacement) + self.assertEqual(options["context"]["nested"]["value"], 2) + self.assertEqual(sticky_source.call_count, 1) + generation[0] += 1 + maps[:] = [{"updated": True}, {"owner": True}] + provider[0] = object() + refresh_evaluation_state(options) + self.assertIs(options["datafile"], provider[0]) + self.assertIs(options["stickyFeatures"], maps[0]) + self.assertIs(options["stickyVariables"], replacement) + self.assertEqual(sticky_source.call_count, 2) + self.assertEqual(datafile_source.call_count, 2) + + def test_initial_load_skips_dependency_indexes_without_losing_future_propagation(self): + for construct in (False, True): + with self.subTest(construct=construct): + initial = {"schemaVersion": "4", "revision": "1", "segments": { + "gate": {"conditions": '{"variable":"source","operator":"notNull"}'}}, + "features": {"root": {"conditions": "gate", "hash": "same"}}, + "variables": {"source": {"type": "integer", "defaultValue": 1, "hash": "1"}, + "alias": {"type": "integer", "compose": "source", "hash": "same"}}} + with patch("featurevisor.events.create_datafile_dependency_index", wraps=create_datafile_dependency_index) as index: + sdk = create_featurevisor({"logLevel": "fatal", **({"datafile": initial} if construct else {})}) + self.addCleanup(sdk.close) + events = [] + sdk.on("datafile_set", events.append) + if not construct: + self.assertTrue(sdk.set_datafile(initial)) + self.assertEqual(events[-1]["features"], ["root"]) + self.assertEqual(events[-1]["variables"], ["source", "alias"]) + index.assert_not_called() + self.assertEqual(sdk.datafile.entity_cache, {}) + self.assertTrue(sdk.set_datafile({"schemaVersion": "4", "revision": "2", "segments": {}, + "features": {}, "variables": {"source": {"type": "integer", "defaultValue": 2, "hash": "2"}}})) + self.assertEqual(index.call_count, 1) + self.assertEqual(events[-1]["features"], ["root"]) + self.assertEqual(set(events[-1]["variables"]), {"source", "alias"}) + self.assertEqual(sdk.datafile.entity_cache, {}) + + def test_empty_module_dispatch_and_module_added_during_evaluation(self) -> None: + sdk = create_featurevisor({"datafile": datafile(), "logLevel": "fatal"}) + with patch.object(sdk.modules_manager, "run_before_evaluation_modules") as before, patch.object( + sdk.modules_manager, "run_after_evaluation_modules" + ) as after: + self.assertTrue(sdk.is_enabled("enabled", {"country": "nl", "userId": "one"})) + self.assertEqual(sdk.get_variable("message"), "hello") + before.assert_not_called() + after.assert_not_called() + sdk.close() + + received = [] + holder = {} + + def on_diagnostic(diagnostic: dict) -> None: + if diagnostic["message"] == "using sticky enabled" and not received: + holder["sdk"].add_module({ + "afterEvaluation": lambda evaluation, options: ( + received.append(options) or evaluation + ), + }) + + sdk = create_featurevisor({ + "datafile": datafile(), "logLevel": "debug", + "stickyFeatures": {"enabled": {"enabled": True}}, + "onDiagnostic": on_diagnostic, + }) + holder["sdk"] = sdk + self.assertTrue(sdk.is_enabled("enabled")) + self.assertEqual(len(received), 1) + self.assertEqual(received[0]["featureKey"], "enabled") + self.assertNotIn("_evaluationSession", received[0]) + self.assertFalse(any(key.startswith("__featurevisor_") for key in received[0])) + sdk.close() + + fixture = datafile() + fixture["variables"]["message"]["deprecated"] = True + received.clear() + holder.clear() + + def on_variable_diagnostic(diagnostic: dict) -> None: + if diagnostic["code"] == "deprecated_variable": + holder["sdk"].add_module({ + "afterEvaluation": lambda evaluation, options: ( + received.append(options) or evaluation + ), + }) + + sdk = create_featurevisor({ + "datafile": fixture, "logLevel": "warn", "onDiagnostic": on_variable_diagnostic, + }) + holder["sdk"] = sdk + self.assertEqual(sdk.get_variable("message"), "hello") + self.assertEqual(len(received), 1) + self.assertNotIn("__featurevisor_state_version", received[0]) + sdk.close() + + def test_expression_cache_follows_datafile_update_and_close_lifecycle(self) -> None: + sdk = create_featurevisor({"datafile": datafile(), "logLevel": "fatal"}) + first = sdk.datafile + self.assertTrue(sdk.is_enabled("enabled", {"country": "nl", "userId": "one"})) + self.assertIn(("feature", "enabled"), first.entity_cache) + self.assertIn(("segment", "country"), first.entity_cache) + sdk.set_datafile("{") + self.assertIs(sdk.datafile, first) + normalized = first.entity_cache[("feature", "enabled")] + sdk.set_datafile({"schemaVersion": "4", "revision": "partial", "segments": {}, "features": {}, + "variables": {"new": {"type": "integer", "defaultValue": 1}}}) + self.assertIs(sdk.datafile.entity_cache[("feature", "enabled")], normalized) + sdk.set_datafile(datafile("2"), True) + self.assertEqual(sdk.datafile.entity_cache, {}) + self.assertTrue(sdk.is_enabled("enabled", {"country": "nl", "userId": "three"})) + sdk.close() + self.assertEqual(sdk.datafile.entity_cache, {}) + self.assertEqual(sdk.datafile.prepared_conditions, {}) + self.assertEqual(sdk.datafile.regex_cache, {}) + + def test_feature_override_preparation_caches_syntax_but_not_context_decisions(self) -> None: + conditions = '[{"attribute":"country","operator":"equals","value":"nl"}]' + fixture = datafile() + fixture["features"]["enabled"]["variablesSchema"] = { + "message": { + "type": "string", + "defaultValue": "default", + } + } + fixture["features"]["enabled"]["traffic"][0]["segments"] = "*" + fixture["features"]["enabled"]["traffic"][0]["variableOverrides"] = { + "message": [{"conditions": conditions, "value": "matched"}] + } + sdk = create_featurevisor({"datafile": fixture, "logLevel": "fatal"}) + + self.assertEqual( + sdk.get_variable("enabled", "message", {"country": "nl", "userId": "one"}), + "matched", + ) + self.assertIn(("feature", "enabled"), sdk.datafile.entity_cache) + self.assertTrue(sdk.datafile.prepared_conditions) + self.assertEqual( + sdk.get_variable("enabled", "message", {"country": "de", "userId": "two"}), + "default", + ) + + broken = datafile("broken") + broken["features"]["enabled"]["variablesSchema"] = { + "message": { + "type": "string", + "defaultValue": "default", + } + } + broken["features"]["enabled"]["traffic"][0]["segments"] = "*" + broken["features"]["enabled"]["traffic"][0]["variableOverrides"] = { + "message": [{"conditions": "{", "value": "matched"}] + } + diagnostics = [] + sdk = create_featurevisor( + {"datafile": broken, "logLevel": "fatal", "onDiagnostic": diagnostics.append} + ) + sdk.on("error", lambda event: diagnostics.append(event["diagnostic"])) + self.assertEqual(sdk.get_variable("enabled", "message", {"userId": "one"}), "default") + self.assertEqual(sdk.get_variable("enabled", "message", {"userId": "two"}), "default") + self.assertNotIn("{", sdk.datafile.expression_cache) + self.assertEqual([item["code"] for item in diagnostics].count("evaluation_error"), 0) + + def test_closed_instance_with_provider_caches_is_released(self) -> None: + gc.collect() + sdk = create_featurevisor({"datafile": datafile(), "logLevel": "fatal"}) + self.assertTrue(sdk.is_enabled("enabled", {"country": "nl", "userId": "warm"})) + + replacement = datafile("replacement") + replacement["features"]["enabled"]["traffic"][0]["segments"] = "*" + sdk.set_datafile(replacement, True) + sdk.add_module({ + "name": "dynamic", + "beforeEvaluation": lambda options: options, + }) + self.assertTrue(sdk.is_enabled("enabled", {"userId": "after"})) + + reference = weakref.ref(sdk) + sdk.close() + del sdk + gc.collect() + + self.assertIsNone(reference()) + + def test_private_cache_does_not_alias_public_segments_with_identical_definitions(self) -> None: + shared_conditions = '[{"attribute":"country","operator":"equals","value":"nl"}]' + fixture = datafile() + fixture["schemaVersion"] = "4" + fixture["segments"] = { + "first": {"conditions": shared_conditions}, + "second": {"conditions": shared_conditions}, + } + fixture["features"]["enabled"]["traffic"][0]["segments"] = '["second"]' + sdk = create_featurevisor({"datafile": fixture, "logLevel": "fatal"}) + self.assertTrue(sdk.is_enabled("enabled", {"country": "nl", "userId": "one"})) + self.assertIsInstance(sdk.datafile.segments["first"]["conditions"], str) + self.assertIsInstance(sdk.datafile.segments["second"]["conditions"], str) + first = sdk.get_segment("first") + second = sdk.get_segment("second") + self.assertIsNot(first["conditions"], second["conditions"]) + self.assertEqual(fixture["segments"]["first"]["conditions"], shared_conditions) + self.assertEqual(fixture["segments"]["second"]["conditions"], shared_conditions) + + def test_diagnostic_callback_cannot_poison_nested_cached_conditions(self) -> None: + cached_conditions = ( + '{"and":[{"attribute":"name","operator":"matches","value":"[",' + '"meta":{"nested":{"poisoned":false}}}]}' + ) + fixture = datafile() + fixture["features"]["enabled"]["force"] = [ + {"conditions": cached_conditions, "enabled": True} + ] + fixture["features"]["enabled"]["traffic"] = [] + diagnostics = [] + + def on_diagnostic(diagnostic: dict) -> None: + if diagnostic["code"] != "condition_match_error": + return + diagnostics.append(diagnostic) + condition = diagnostic["details"]["condition"] + condition["value"] = ".*" + condition["meta"]["nested"]["poisoned"] = True + + sdk = create_featurevisor( + { + "datafile": fixture, + "logLevel": "warn", + "onDiagnostic": on_diagnostic, + } + ) + context = {"name": "anything", "userId": "one"} + self.assertFalse(sdk.is_enabled("enabled", context)) + self.assertFalse(sdk.is_enabled("enabled", context)) + self.assertEqual(len(diagnostics), 2) + + cached = sdk.get_feature("enabled")["force"][0]["conditions"] + self.assertEqual(cached["and"][0]["value"], "[") + self.assertFalse(cached["and"][0]["meta"]["nested"]["poisoned"]) + + def test_parse_failures_report_once_per_load_and_allow_reentrant_evaluation(self) -> None: + sdk = create_featurevisor({"logLevel": "error"}) + diagnostics = [] + reentered = [] + + def on_error(event: dict) -> None: + diagnostic = event["diagnostic"] + diagnostics.append(diagnostic) + if diagnostic["code"] == "conditions_parse_error" and not reentered: + reentered.append(sdk.is_enabled("secondary", {"userId": "nested"})) + + sdk.on("error", on_error) + sdk.set_datafile(datafile(conditions="{")) + self.assertFalse(sdk.is_enabled("enabled", {"country": "nl", "userId": "one"})) + self.assertFalse(sdk.is_enabled("enabled", {"country": "nl", "userId": "two"})) + self.assertEqual(reentered, [True]) + errors = [item for item in diagnostics if item["code"] == "conditions_parse_error"] + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0]["details"], {"conditions": "{", "entityType": "segment", "entityKey": "country"}) + + def test_callback_changes_to_threshold_and_subscriptions_are_live(self) -> None: + instance_diagnostics = [] + module_diagnostics = [] + sdk_holder = {} + subscribe_holder = {} + subscribed = False + + def setup(api: dict) -> None: + subscribe_holder["subscribe"] = api["onDiagnostic"] + + def before(options: dict) -> dict: + nonlocal subscribed + sdk_holder["sdk"].set_log_level("debug") + if not subscribed: + subscribe_holder["subscribe"]( + module_diagnostics.append, + {"logLevel": "debug"}, + ) + subscribed = True + return options + + sdk = create_featurevisor( + { + "datafile": datafile(), + "logLevel": "fatal", + "onDiagnostic": instance_diagnostics.append, + "modules": [{"name": "live", "setup": setup, "beforeEvaluation": before}], + } + ) + sdk_holder["sdk"] = sdk + + evaluation = sdk.evaluate_flag("enabled", {"country": "nl", "userId": "one"}) + self.assertEqual(evaluation["reason"], "rule") + self.assertEqual(instance_diagnostics[-1]["code"], "rule") + self.assertEqual(module_diagnostics[-1]["code"], "rule") + self.assertEqual(instance_diagnostics[-1]["details"]["evaluation"], evaluation) + self.assertEqual(module_diagnostics[-1], instance_diagnostics[-1]) + + def test_after_callback_threshold_change_applies_to_global_diagnostic(self) -> None: + diagnostics = [] + sdk_holder = {} + + def after(evaluation: dict, _options: dict) -> dict: + sdk_holder["sdk"].set_log_level("debug") + return evaluation + + sdk = create_featurevisor( + { + "datafile": datafile(), + "logLevel": "fatal", + "onDiagnostic": diagnostics.append, + "modules": [{"name": "live", "afterEvaluation": after}], + } + ) + sdk_holder["sdk"] = sdk + + evaluation = sdk.evaluate_variable("message") + self.assertEqual(diagnostics[-1]["code"], "variable_default") + self.assertEqual(diagnostics[-1]["details"], evaluation) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_evaluation_session.py b/tests/test_evaluation_session.py new file mode 100644 index 0000000..e83c0ac --- /dev/null +++ b/tests/test_evaluation_session.py @@ -0,0 +1,670 @@ +from __future__ import annotations + +import copy +import gc +import unittest +import weakref + +from featurevisor import create_featurevisor +from featurevisor._evaluation_session import EvaluationSession, _fingerprint + + +def graph_datafile() -> dict: + requirement = lambda key: {"variable": key, "operator": "equals", "value": True} + return { + "schemaVersion": "2", "revision": "session", "segments": {}, "features": {}, + "variables": { + "leaf": {"type": "boolean", "defaultValue": True}, + "left": {"type": "boolean", "defaultValue": True, "conditions": [requirement("leaf")]}, + "right": {"type": "boolean", "defaultValue": True, "conditions": [requirement("leaf")]}, + "root": {"type": "boolean", "defaultValue": True, "disabledValue": False, + "conditions": [requirement("left"), requirement("right")]}, + }, + } + + +class EvaluationSessionTests(unittest.TestCase): + def test_session_owns_replacement_sticky_maps_until_root_boundary(self): + class Sticky(dict): + pass + + class Modules: + def get_all(self): + return [] + + session = EvaluationSession() + common = {"type": "flag", "featureKey": "gate", "context": {}, + "dependencyOf": {"type": "flag", "featureKey": "root"}, + "datafile": object(), "modulesManager": Modules()} + first = Sticky({"unrelated": {"enabled": True}}) + first_ref = weakref.ref(first) + first_key = session.key({**common, "stickyFeatures": first}) + del first + gc.collect() + self.assertIsNotNone(first_ref()) + + replacement = Sticky({"unrelated": {"enabled": True}}) + replacement_ref = weakref.ref(replacement) + replacement_key = session.key({**common, "stickyFeatures": replacement}) + self.assertNotEqual(first_key, replacement_key) + del replacement + gc.collect() + self.assertIsNotNone(replacement_ref()) + + with session.frame({"type": "flag", "featureKey": "root"}): + pass + gc.collect() + self.assertIsNone(first_ref()) + self.assertIsNone(replacement_ref()) + + def test_sticky_handoff_never_enumerates_unrelated_entries(self): + class ObservedSticky(dict): + visits = 0 + + def items(self): + self.visits += 1 + return super().items() + + def keys(self): + self.visits += 1 + return super().keys() + + def values(self): + self.visits += 1 + return super().values() + + def __iter__(self): + self.visits += 1 + return super().__iter__() + + for kind in ("features", "variables"): + for child_mode in (False, True): + for size in (0, 100, 1000, 5000): + with self.subTest(kind=kind, child=child_mode, size=size): + datafile = graph_datafile() + datafile["features"]["gate"] = { + "bucketBy": "userId", "traffic": [ + {"key": "all", "segments": "*", "percentage": 100000, "enabled": True}, + ], + } + requirement = {"variable": "leaf", "operator": "equals", "value": True} + datafile["variables"]["root"] = { + "type": "boolean", "defaultValue": True, + **({"requiredFeatures": ["gate", "gate"]} if kind == "features" + else {"conditions": [requirement, requirement]}), + } + # An unsupported unrelated value also proves that a full + # sticky fingerprint has not merely moved elsewhere. + sticky = ObservedSticky({f"unrelated{i}": ( + {"enabled": True, "variables": {"unused": object()}} + if kind == "features" else object()) for i in range(size)}) + reads = [] + def before(options): + if options.get("featureKey") == "gate" or options.get("variableKey") == "leaf": + reads.append(1) + return options + config = {"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": before}]} + sticky_name = "stickyFeatures" if kind == "features" else "stickyVariables" + if not child_mode: + config[sticky_name] = sticky + sdk = create_featurevisor(config) + target = sdk.spawn({}, {sticky_name: sticky}) if child_mode else sdk + self.assertIs(target.get_variable("root", {"userId": "a"}), True) + self.assertEqual(len(reads), 1) + self.assertIs(target.get_variable("root", {"userId": "a"}), True) + self.assertEqual(len(reads), 2) + self.assertEqual(sticky.visits, 0) + if child_mode: + target.close() + sdk.close() + + def test_module_replaced_sticky_map_reaches_dependencies_and_reuses_result(self): + datafile = graph_datafile() + enabled = {"bucketBy": "userId", "traffic": [ + {"key": "all", "segments": "*", "percentage": 100000, "enabled": True}, + ]} + datafile["features"] = { + "gate": enabled, + "root": {**enabled, "requiredFeatures": [ + {"feature": "gate", "enabled": False}, + {"feature": "gate", "enabled": False}, + ]}, + } + reads = [] + def before(options): + if options.get("featureKey") == "root": + return {**options, "stickyFeatures": {"gate": {"enabled": False}}} + if options.get("featureKey") == "gate": + reads.append(1) + return options + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": before}]}) + self.assertTrue(sdk.is_enabled("root", {"userId": "a"})) + self.assertEqual(len(reads), 1) + sdk.close() + + def test_memo_fingerprint_preserves_observable_numeric_and_container_types(self): + self.assertNotEqual(_fingerprint({"n": True}), _fingerprint({"n": 1})) + self.assertNotEqual(_fingerprint({"n": 1}), _fingerprint({"n": 1.0})) + self.assertNotEqual(_fingerprint({"n": 0.0}), _fingerprint({"n": -0.0})) + self.assertNotEqual(_fingerprint([1]), _fingerprint((1,))) + self.assertEqual(_fingerprint({"a": 1, "b": 2}), _fingerprint({"b": 2, "a": 1})) + self.assertNotEqual(_fingerprint(None), _fingerprint({})) + self.assertNotEqual(_fingerprint({}), _fingerprint({"value": None})) + + def test_internal_dependencies_keep_post_module_context_without_restoring_root_keys(self): + datafile = graph_datafile() + datafile["features"]["gate"] = { + "bucketBy": "userId", "traffic": [{ + "key": "all", "segments": "*", "percentage": 100000, "enabled": True, + }], + } + datafile["variables"]["root"] = { + "type": "boolean", "defaultValue": True, + "requiredFeatures": ["gate"], + "conditions": [{"variable": "leaf", "operator": "equals", "value": True}], + } + seen = [] + def before(options): + if options.get("variableKey") == "root": + return {**options, "context": {}} + if options.get("dependencyOf"): + seen.append((options.get("featureKey"), options.get("variableKey"), dict(options["context"]))) + return options + sdk = create_featurevisor({"datafile": datafile, "context": {"country": "de"}, + "logLevel": "fatal", "modules": [{"beforeEvaluation": before}]}) + self.assertIs(sdk.get_variable("root"), True) + self.assertEqual(seen, [(None, "leaf", {}), ("gate", None, {})]) + + def test_module_observable_numeric_types_do_not_share_memo_entries(self): + datafile = graph_datafile() + datafile["variables"]["mutator"] = {"type": "boolean", "defaultValue": True} + datafile["variables"]["root"]["conditions"] = [ + {"variable": "leaf", "operator": "equals", "value": True}, + {"variable": "mutator", "operator": "equals", "value": True}, + {"variable": "leaf", "operator": "equals", "value": False}, + ] + seen = [] + def after(evaluation, options): + if options.get("variableKey") == "mutator": + options["context"]["nested"]["value"] = 1.0 + if options.get("variableKey") == "leaf": + value = options["context"]["nested"]["value"] + seen.append(type(value)) + evaluation["variableValue"] = isinstance(value, int) + return evaluation + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"afterEvaluation": after}]}) + self.assertIs(sdk.get_variable("root", {"nested": {"value": 1}}), True) + self.assertEqual(seen, [int, float]) + sdk.close() + + def test_feature_requirement_keeps_post_module_context(self): + datafile = graph_datafile() + datafile["features"]["root"] = { + "bucketBy": "userId", "traffic": [{ + "key": "all", "segments": "*", "percentage": 100000, "enabled": True, + }], + "conditions": [{"variable": "leaf", "operator": "equals", "value": True}], + } + seen = [] + def before(options): + if options.get("featureKey") == "root": + return {**options, "context": {}} + if options.get("variableKey") == "leaf": + seen.append(dict(options["context"])) + return options + sdk = create_featurevisor({"datafile": datafile, "context": {"country": "de"}, + "logLevel": "fatal", "modules": [{"beforeEvaluation": before}]}) + self.assertTrue(sdk.is_enabled("root")) + self.assertEqual(seen, [{}]) + + def test_unsupported_module_value_skips_memo_without_changing_getter(self): + datafile = graph_datafile() + datafile["variables"]["root"] = { + "type": "integer", "defaultValue": 1, + "conditions": [ + {"variable": "leaf", "operator": "exists"}, + {"variable": "leaf", "operator": "exists"}, + ], + } + reads = [] + def after(evaluation, options): + if options.get("variableKey") == "leaf": + reads.append(1) + return {**evaluation, "variableValue": {1, 2}} + return evaluation + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"afterEvaluation": after}]}) + self.assertEqual(sdk.get_variable("root"), 1) + self.assertEqual(len(reads), 2) + + def test_module_result_is_copied_before_memoisation(self): + datafile = graph_datafile() + datafile["variables"]["root"] = { + "type": "integer", "defaultValue": 1, + "conditions": [ + {"variable": "leaf", "path": ["value"], "operator": "equals", "value": 1}, + {"variable": "mutator", "operator": "equals", "value": 1}, + {"variable": "leaf", "path": ["value"], "operator": "equals", "value": 1}, + ], + } + datafile["variables"]["mutator"] = {"type": "integer", "defaultValue": 1} + shared = {"value": 1} + reads = [] + def after(evaluation, options): + if options.get("variableKey") == "leaf": + reads.append(1) + return {**evaluation, "variableValue": shared} + if options.get("variableKey") == "mutator": + shared["value"] = 2 + return evaluation + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"afterEvaluation": after}]}) + self.assertEqual(sdk.get_variable("root"), 1) + self.assertEqual(len(reads), 1) + + def test_state_changes_during_hooks_invalidate_datafile_modules_and_context(self): + for state in ("datafile", "modules", "context"): + with self.subTest(state=state): + datafile = graph_datafile() + datafile["variables"]["leaf"] = {"type": "integer", "defaultValue": 1} + datafile["variables"]["mutator"] = {"type": "integer", "defaultValue": 1} + datafile["variables"]["root"] = { + "type": "integer", "defaultValue": 1, + "conditions": [ + {"variable": "leaf", "operator": "equals", "value": 1}, + {"variable": "mutator", "operator": "equals", "value": 1}, + {"variable": "leaf", "operator": "equals", "value": 2}, + ], + } + reads = [] + sdk = None + def after(evaluation, options): + if options.get("variableKey") == "leaf": + reads.append(1) + if state == "context": + return {**evaluation, "variableValue": 2 if sdk.get_context().get("changed") else 1} + if options.get("variableKey") == "mutator": + if state == "datafile": + replacement = copy.deepcopy(datafile) + replacement["variables"]["leaf"]["defaultValue"] = 2 + sdk.set_datafile(replacement, True) + elif state == "modules": + sdk.add_module({"afterEvaluation": lambda result, _: ( + {**result, "variableValue": 2} if result.get("variableKey") == "leaf" else result + )}) + else: + sdk.set_context({"changed": True}) + return evaluation + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"afterEvaluation": after}]}) + self.assertEqual(sdk.get_variable("root"), 1) + self.assertEqual(len(reads), 2) + + def test_hook_state_change_does_not_memoise_started_dependency(self): + datafile = graph_datafile() + datafile["variables"]["leaf"] = {"type": "integer", "defaultValue": 1} + datafile["variables"]["root"] = { + "type": "integer", "defaultValue": 1, + "conditions": [ + {"variable": "leaf", "operator": "equals", "value": 1}, + {"variable": "leaf", "operator": "equals", "value": 2}, + ], + } + sdk = None + reads = [] + def after(evaluation, options): + if options.get("variableKey") == "leaf": + reads.append(1) + if len(reads) == 1: + sdk.set_sticky_variables({"leaf": 2}) + return evaluation + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"afterEvaluation": after}]}) + self.assertEqual(sdk.get_variable("root"), 1) + self.assertEqual(len(reads), 2) + + def test_missed_override_selector_skips_cyclic_prerequisite(self): + datafile = graph_datafile() + datafile["variables"]["root"] = { + "type": "integer", "defaultValue": 1, + "overrides": [ + {"key": "skip", "conditions": [{"attribute": "country", "operator": "equals", "value": "nl"}, + {"variable": "root", "operator": "isNull"}], "value": 2}, + {"key": "take", "segments": "*", "value": 3}, + ], + } + seen = [] + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": lambda options: seen.append(options.get("variableKey")) or options}]}) + self.assertEqual(sdk.get_variable("root", {"country": "us"}), 3) + self.assertEqual(seen, ["root"]) + + def test_dependency_memo_is_immediate_parent_aware_for_module_hooks(self): + datafile = graph_datafile() + datafile["variables"]["leaf"] = {"type": "integer", "defaultValue": 0} + datafile["variables"]["left"]["conditions"] = [{"variable": "leaf", "operator": "equals", "value": 1}] + datafile["variables"]["right"]["conditions"] = [{"variable": "leaf", "operator": "equals", "value": 2}] + visits = [] + def before(options): + if options.get("variableKey") == "leaf": + parent = options["dependencyOf"]["variableKey"] + visits.append(parent) + return {**options, "parentSelectedValue": 1 if parent == "left" else 2} + return options + def after(evaluation, options): + if options.get("variableKey") == "leaf": + return {**evaluation, "variableValue": options["parentSelectedValue"]} + return evaluation + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": before, "afterEvaluation": after}]}) + self.assertIs(sdk.get_variable("root"), True) + self.assertEqual(visits, ["left", "right"]) + + def test_dependency_identities_distinguish_flag_variation_feature_and_global_variables(self): + datafile = graph_datafile() + datafile["features"] = {"source": { + "bucketBy": "userId", "traffic": [{ + "key": "all", "segments": "*", "percentage": 100000, + "enabled": True, "variation": "on", + }], + "variations": [{"value": "on"}], + "variablesSchema": {"source": {"type": "integer", "defaultValue": 2}}, + }} + datafile["variables"]["source"] = {"type": "integer", "defaultValue": 1} + datafile["variables"]["root"] = { + "type": "integer", "defaultValue": 1, + "requiredFeatures": [ + {"feature": "source", "variation": "on"}, + {"feature": "source", "variation": "on"}, + ], + "conditions": [ + {"variable": "source", "operator": "equals", "value": 1}, + {"feature": "source", "variable": "source", "operator": "equals", "value": 2}, + {"variable": "source", "operator": "equals", "value": 1}, + ], + } + seen = [] + def before(options): + seen.append((options["type"], options.get("featureKey"), options.get("variableKey"), options.get("dependencyOf"))) + return options + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": before}]}) + self.assertEqual(sdk.get_variable("root"), 1) + self.assertEqual(len(seen), 5) + self.assertEqual(len({entry[:3] for entry in seen}), 5) + self.assertIsNone(seen[0][3]) + self.assertTrue(all(entry[3] == {"type": "variable", "variableKey": "root"} for entry in seen[1:])) + + def test_deep_diamond_is_linear_per_public_call(self): + datafile = graph_datafile() + datafile["variables"] = {"leaf": {"type": "integer", "defaultValue": 1}} + previous = "leaf" + for index in range(18): + key = f"level{index}" + requirement = {"variable": previous, "operator": "equals", "value": 1} + datafile["variables"][key] = { + "type": "integer", "defaultValue": 1, + "conditions": [requirement, requirement], + } + previous = key + visits = [] + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": lambda options: visits.append(options["variableKey"]) or options}]}) + self.assertEqual(sdk.get_variable(previous), 1) + self.assertEqual(len(visits), 19) + self.assertEqual(sdk.get_variable(previous), 1) + self.assertEqual(len(visits), 38) + + def test_feature_diamond_is_linear_and_uses_immediate_parent(self): + datafile = graph_datafile() + datafile["variables"] = {} + enabled = {"bucketBy": "userId", "traffic": [ + {"key": "all", "segments": "*", "percentage": 100000, "enabled": True}, + ]} + datafile["features"] = {"leaf": dict(enabled)} + previous = "leaf" + for index in range(18): + key = f"level{index}" + datafile["features"][key] = {**enabled, "requiredFeatures": [previous, previous]} + previous = key + visits = [] + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": lambda options: visits.append((options["featureKey"], options.get("dependencyOf"))) or options}]}) + self.assertTrue(sdk.is_enabled(previous)) + self.assertEqual(len(visits), 19) + self.assertIsNone(visits[0][1]) + self.assertEqual(visits[-1][1], {"type": "flag", "featureKey": "level0"}) + + def test_global_and_feature_cycles_are_errors(self): + datafile = graph_datafile() + datafile["variables"]["cycle"] = { + "type": "boolean", "defaultValue": True, + "conditions": [{"variable": "cycle", "operator": "isNull"}], + } + datafile["features"]["cycle"] = {"requiredFeatures": ["cycle"]} + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal"}) + for evaluation in (sdk.evaluate_variable("cycle"), sdk.evaluate_flag("cycle")): + self.assertEqual(evaluation["reason"], "error") + self.assertIn("Circular", str(evaluation["error"])) + + def test_cycle_checks_post_module_identity(self): + datafile = graph_datafile() + datafile["variables"]["root"] = { + "type": "boolean", "defaultValue": True, + "conditions": [{"variable": "alias", "operator": "equals", "value": True}], + } + datafile["variables"]["other"] = { + "type": "boolean", "defaultValue": True, + "conditions": [{"variable": "other", "operator": "isNull"}], + } + def before(options): + return {**options, "variableKey": "other"} if options.get("variableKey") == "alias" else options + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": before}]}) + evaluation = sdk.evaluate_variable("root") + self.assertEqual(evaluation["reason"], "error") + self.assertIn("Circular", str(evaluation["error"])) + + def test_retained_module_options_release_session_after_success_and_error(self): + for throws in (False, True): + datafile = graph_datafile() + retained = [] + def before(options): + retained.append(options) + replacement = {**options} + retained.append(replacement) + if throws and options.get("variableKey") == "leaf": + raise RuntimeError("ordinary module error") + return replacement + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": before}]}) + sdk.get_variable("root") + for options in retained: + self.assertNotIn("_evaluationSession", options) + self.assertNotIn("__featurevisor_state_version", options) + + def test_ordinary_module_error_is_memoised_as_null_operand(self): + datafile = graph_datafile() + datafile["variables"]["root"] = { + "type": "boolean", "defaultValue": True, + "conditions": [ + {"variable": "broken", "operator": "isNull"}, + {"variable": "broken", "operator": "isNull"}, + ], + } + visits = [] + def before(options): + visits.append(options["variableKey"]) + if options["variableKey"] == "broken": + raise RuntimeError("module failed") + return options + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": before}]}) + self.assertTrue(sdk.get_variable("root")) + self.assertEqual(visits, ["root", "broken"]) + + def test_ordinary_feature_module_error_is_false_prerequisite_operand(self): + datafile = graph_datafile() + datafile["features"] = {"root": { + "bucketBy": "userId", "traffic": [{ + "key": "all", "segments": "*", "percentage": 100000, "enabled": True, + }], + "requiredFeatures": [{"feature": "broken", "enabled": False}, {"not": ["broken"]}], + }} + visits = [] + def before(options): + visits.append(options.get("featureKey")) + if options.get("featureKey") == "broken": + raise RuntimeError("module failed") + return options + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": before}]}) + self.assertTrue(sdk.is_enabled("root")) + self.assertEqual(visits, ["root", "broken"]) + + def test_state_change_during_hook_rechecks_dependency(self): + datafile = graph_datafile() + datafile["variables"]["root"] = { + "type": "integer", "defaultValue": 1, + "conditions": [ + {"variable": "leaf", "operator": "equals", "value": True}, + {"variable": "mutator", "operator": "equals", "value": 1}, + {"variable": "leaf", "operator": "equals", "value": 2}, + ], + } + datafile["variables"]["mutator"] = {"type": "integer", "defaultValue": 1} + reads = [] + sdk = None + def after(evaluation, options): + if options.get("variableKey") == "leaf": + reads.append(evaluation.get("variableValue")) + if options.get("variableKey") == "mutator": + sdk.set_sticky_variables({"leaf": 2}) + return evaluation + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"afterEvaluation": after}]}) + self.assertEqual(sdk.get_variable("root"), 1) + self.assertEqual(len(reads), 2) + + def test_child_sticky_change_and_restoration_do_not_resurrect_memo(self): + datafile = graph_datafile() + datafile["variables"]["leaf"] = {"type": "integer", "defaultValue": 1} + datafile["variables"]["restore"] = {"type": "integer", "defaultValue": 1} + datafile["variables"]["root"] = { + "type": "integer", "defaultValue": 1, + "conditions": [ + {"variable": "leaf", "operator": "equals", "value": 2}, + {"variable": "restore", "operator": "equals", "value": 1}, + {"variable": "leaf", "operator": "equals", "value": 1}, + ], + } + child = None + reads = [] + def before(options): + if options.get("variableKey") == "leaf": + reads.append(1) + if len(reads) == 1: + child.set_sticky_variables({"leaf": 2}) + if options.get("variableKey") == "restore": + child.set_sticky_variables({"leaf": 1}, True) + return options + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "modules": [{"beforeEvaluation": before}]}) + child = sdk.spawn(options={"stickyVariables": {"leaf": 1}}) + self.assertEqual(child.get_variable("root"), 1) + self.assertEqual(len(reads), 2) + + def test_diamond_callbacks_are_once_per_request_and_mark_immediate_parent(self): + visits: list[tuple[str, dict | None]] = [] + + def before(options): + visits.append((options.get("variableKey", ""), options.get("dependencyOf"))) + return options + + sdk = create_featurevisor({"datafile": graph_datafile(), "logLevel": "fatal", + "modules": [{"name": "observe", "beforeEvaluation": before}]}) + self.assertIs(sdk.get_variable("root"), True) + self.assertEqual([key for key, _ in visits], ["root", "left", "leaf", "right", "leaf"]) + self.assertIsNone(visits[0][1]) + self.assertEqual(visits[2][1], {"type": "variable", "variableKey": "left"}) + self.assertIs(sdk.get_variable("root"), True) + self.assertEqual([key for key, _ in visits].count("leaf"), 4) + + def test_reentrant_public_getter_owns_a_new_stack(self): + sdk = None + reentered = False + + def before(options): + nonlocal reentered + if options.get("variableKey") == "leaf" and not reentered: + reentered = True + self.assertIs(sdk.get_variable("leaf"), True) + return options + + sdk = create_featurevisor({"datafile": graph_datafile(), "logLevel": "fatal", + "modules": [{"name": "reenter", "beforeEvaluation": before}]}) + self.assertIs(sdk.get_variable("root"), True) + self.assertTrue(reentered) + + def test_reentrant_root_evaluation_has_no_dependency_marker(self): + sdk = None + inside = False + reentered = False + markers = [] + leaf_reads = [] + def before(options): + nonlocal inside, reentered + if options.get("variableKey") == "root": + markers.append(options.get("dependencyOf")) + if options.get("variableKey") == "leaf": + leaf_reads.append(1) + if not inside and not reentered: + inside = True + reentered = True + try: + self.assertIs(sdk.get_variable("root"), True) + finally: + inside = False + return options + sdk = create_featurevisor({"datafile": graph_datafile(), "logLevel": "fatal", + "modules": [{"beforeEvaluation": before}]}) + self.assertIs(sdk.get_variable("root"), True) + self.assertEqual(markers, [None, None]) + self.assertEqual(len(leaf_reads), 4) + + def test_context_transforms_keep_dependency_reads_separate(self): + countries: list[str] = [] + + def before(options): + key = options.get("variableKey") + if key in {"left", "right"}: + options = {**options, "context": {**options["context"], "country": key}} + if key == "leaf": + countries.append(options["context"].get("country")) + return options + + sdk = create_featurevisor({"datafile": graph_datafile(), "logLevel": "fatal", + "modules": [{"name": "transform", "beforeEvaluation": before}]}) + self.assertIs(sdk.get_variable("root"), True) + self.assertEqual(countries, ["left", "right"]) + + def test_requirement_path_failure_does_not_become_an_is_null_match(self): + datafile = graph_datafile() + datafile["variables"]["source"] = { + "type": "boolean", "defaultValue": True, + "conditions": [{"variable": "leaf", "path": "uncompiled", "operator": "isNull"}], + } + datafile["variables"]["subject"] = { + "type": "boolean", "defaultValue": True, + "conditions": [{"variable": "source", "operator": "isNull"}], + } + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal"}) + self.assertIsNone(sdk.get_variable("source")) + self.assertEqual(sdk.evaluate_variable("subject")["reason"], "error") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_feature_allocation.py b/tests/test_feature_allocation.py new file mode 100644 index 0000000..c067b10 --- /dev/null +++ b/tests/test_feature_allocation.py @@ -0,0 +1,83 @@ +import copy +import json +import unittest + +from featurevisor import create_featurevisor + + +def flag(key, percentage=100000): + return { + "key": key, + "bucketBy": "userId", + "traffic": [{"key": "1", "segments": "*", "percentage": percentage, "allocation": []}], + } + + +def datafile(features): + return {"schemaVersion": "2", "revision": "1", "segments": {}, "features": features} + + +class FeatureAllocationTests(unittest.TestCase): + def test_legacy_ranges_are_unknown_fields_without_warnings(self): + for ranges in (None, [], [[50000, 100000]], [[0, 100000]], "ignored"): + for percentage in (0, 50000, 100000): + for enabled in (None, False, True): + with self.subTest(ranges=ranges, percentage=percentage, enabled=enabled): + feature = flag("test", percentage) + if enabled is not None: + feature["traffic"][0]["enabled"] = enabled + baseline = create_featurevisor({"datafile": datafile({"test": feature}), "logLevel": "warn"}) + legacy = copy.deepcopy(feature) + legacy["ranges"] = ranges + diagnostics = [] + sdk = create_featurevisor({ + "datafile": json.dumps(datafile({"test": legacy})), + "logLevel": "warn", + "onDiagnostic": diagnostics.append, + }) + for user in range(32): + context = {"userId": f"user-{user}"} + self.assertEqual(sdk.evaluate_flag("test", context), baseline.evaluate_flag("test", context)) + self.assertEqual(diagnostics, []) + sdk.close() + baseline.close() + + def test_required_variations_and_priority_exclusion_use_normal_hashing(self): + features = {key: flag(key) for key in ("cohort", "experimentA", "experimentB", "priority", "fallback")} + features["cohort"]["variations"] = [{"value": value} for value in ("a", "b", "holdout")] + features["cohort"]["traffic"][0]["allocation"] = [ + {"variation": "a", "range": [0, 40000]}, + {"variation": "b", "range": [40000, 80000]}, + {"variation": "holdout", "range": [80000, 100000]}, + ] + features["experimentA"]["requiredFeatures"] = [{"feature": "cohort", "variation": "a"}] + features["experimentB"]["requiredFeatures"] = [{"feature": "cohort", "variation": "b"}] + features["priority"]["traffic"][0]["percentage"] = 50000 + features["fallback"]["requiredFeatures"] = [{"feature": "priority", "enabled": False}] + sdk = create_featurevisor({"datafile": datafile(features), "logLevel": "warn"}) + self.addCleanup(sdk.close) + counts = {"a": 0, "b": 0, "holdout": 0, "priority": 0, "fallback": 0} + cohort_overlap = priority_overlap = 0 + for user in range(20000): + context = {"userId": f"user-{user}"} + variation = sdk.get_variation("cohort", context) + self.assertIn(variation, ("a", "b", "holdout")) + counts[variation] += 1 + a = sdk.is_enabled("experimentA", context) + b = sdk.is_enabled("experimentB", context) + self.assertEqual(a, variation == "a") + self.assertEqual(b, variation == "b") + cohort_overlap += int(a and b) + self.assertEqual(not a and not b, variation == "holdout") + priority = sdk.is_enabled("priority", context) + fallback = sdk.is_enabled("fallback", context) + self.assertNotEqual(priority, fallback) + priority_overlap += int(priority and fallback) + counts["priority" if priority else "fallback"] += 1 + self.assertEqual(cohort_overlap, 0) + self.assertEqual(priority_overlap, 0) + self.assertEqual(counts["a"] + counts["b"] + counts["holdout"], 20000) + self.assertEqual(counts["priority"] + counts["fallback"], 20000) + # Allow two percentage points around each intended population share. + for key, expected in {"a": 8000, "b": 8000, "holdout": 4000, "priority": 10000, "fallback": 10000}.items(): + self.assertLessEqual(abs(counts[key] - expected), 400, counts) diff --git a/tests/test_helpers_parity.py b/tests/test_helpers_parity.py index 72f1274..1af6b6f 100644 --- a/tests/test_helpers_parity.py +++ b/tests/test_helpers_parity.py @@ -31,8 +31,10 @@ def test_should_return_integer_if_the_value_is_an_integer(self) -> None: self.assertIsNone(get_value_by_type("1", "integer")) self.assertIsNone(get_value_by_type(1.5, "integer")) - def test_should_return_double_if_the_value_is_a_double(self) -> None: - self.assertIsNone(get_value_by_type("1.1", "double")) + def test_should_return_number_if_the_value_is_a_number(self) -> None: + self.assertIsNone(get_value_by_type("1.1", "number")) + self.assertEqual(get_value_by_type(1.1, "number"), 1.1) + self.assertEqual(get_value_by_type(2, "number"), 2.0) def test_should_return_null_for_non_boolean_values(self) -> None: self.assertIsNone(get_value_by_type("true", "boolean")) diff --git a/tests/test_includes_any.py b/tests/test_includes_any.py new file mode 100644 index 0000000..d692e32 --- /dev/null +++ b/tests/test_includes_any.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +import unittest + +from featurevisor import create_featurevisor +from featurevisor.test_checks import evaluate_checks_result + + +CASES = json.loads(r''' +[ + ["strings",["read","write"],["admin","write"],true], + ["no match",["read"],["write"],false], + ["mixed primitives",["one",2,true,null],["missing",2,false],true], + ["null",[null],[null],true], + ["true",[true],[true],true], + ["false",[false],[false],true], + ["numeric equality",[1],[1],true], + ["number not boolean",[1],[true],false], + ["boolean not number",[true],[1],false], + ["zero not false",[0],[false],false], + ["false not zero",[false],[0],false], + ["number not string",[1],["1"],false], + ["string not number",["1"],[1],false], + ["null not false",[null],[false],false], + ["duplicates",["write","write"],["write","write"],true], + ["candidate order",["read","write"],["write","read"],true], + ["empty candidate",["read"],[],false], + ["empty actual",[],["read"],false], + ["both empty",[],[],false], + ["structured actual ignored",[{},[],{"x":1},"read"],["read"],true], + ["only structured actual",[{},[],{"x":1}],[null,1],false], + ["null actual",null,[],null], + ["string actual","read",["read"],null], + ["number actual",1,[1],null], + ["boolean actual",true,[true],null], + ["object actual",{},[],null], + ["numeric object actual",{"0":"read"},["read"],null], + ["null candidate",["read"],null,null], + ["scalar candidate",["read"],"read",null], + ["object candidate",["read"],{},null], + ["nested candidate after match",["read"],["read",[]],null], + ["object candidate after match",["read"],["read",{}],null], + ["invalid before match",["read"],[{},"read"],null], + ["invalid candidate empty actual",[],[{}],null] +] +''') + + +class IncludesAnyTests(unittest.TestCase): + def test_runtime_requirements_and_checks(self): + for name, actual, candidate, matched in CASES: + for operator in ("includesAny", "notIncludesAny"): + expected = matched is not None and (matched if operator == "includesAny" else not matched) + with self.subTest(case=name, operator=operator): + requirement = {"variable": "source", "path": ["items"], "operator": operator, "value": candidate} + traffic = [{"key": "all", "segments": "*", "percentage": 100000, "allocation": []}] + source = {"type": "object", "defaultValue": {"items": actual}} + datafile = { + "schemaVersion": "2", "revision": "any", "segments": {}, + "features": { + "sourceFeature": {"bucketBy": "userId", "traffic": traffic, "variablesSchema": {"source": source}}, + "condition": {"bucketBy": "userId", "traffic": [], "force": [{ + "conditions": {"attribute": "profile.items", "operator": operator, "value": candidate}, "enabled": True, + }]}, + }, + "variables": {"source": source, "other": {"type": "object", "defaultValue": {"items": candidate}}}, + } + for feature_source in (False, True): + selected = {**requirement, **({"feature": "sourceFeature"} if feature_source else {})} + for group in ("direct", "not", "or"): + requirements = [selected] if group == "direct" else [{group: [selected]}] + key = f"gate{feature_source}{group}" + datafile["variables"][key] = {"type": "boolean", "defaultValue": True, "disabledValue": False, "conditions": requirements} + datafile["features"][key] = {"bucketBy": "userId", "traffic": traffic, "conditions": requirements} + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal"}) + try: + self.assertEqual(sdk.is_enabled("condition", {"profile": {"items": actual}}), expected) + self.assertFalse(sdk.is_enabled("condition", {"profile": {}})) + condition = datafile["features"]["condition"]["force"][0]["conditions"] + for group in (None, "not", "or"): + expression = condition if group is None else {group: [condition]} + datafile["features"]["condition"]["force"][0]["conditions"] = json.dumps(expression) + sdk.set_datafile(datafile, replace=True) + self.assertEqual(sdk.is_enabled("condition", {"profile": {"items": actual}}), not expected if group == "not" else expected) + for feature_source in (False, True): + selected = {**requirement, **({"feature": "sourceFeature"} if feature_source else {})} + for group in ("direct", "not", "or"): + key = f"gate{feature_source}{group}" + self.assertEqual(sdk.get_variable(key), not expected if group == "not" else expected) + self.assertEqual(sdk.is_enabled(key), not expected if group == "not" else expected) + for compare_to in (False, True): + check = dict(selected) + if compare_to: + del check["value"] + check["compareTo"] = {"variable": "other", "path": ["items"]} + for group in ("direct", "not", "or"): + node = check if group == "direct" else {group: [check]} + report = evaluate_checks_result(sdk, datafile, [node], owner_variable=None, diagnostics=[]) + self.assertEqual(report["error"], matched is None) + self.assertEqual(report["passed"], matched is not None and (not expected if group == "not" else expected)) + if matched is None: + self.assertEqual(report["errors"][0]["type"], "check") + self.assertIn(report["errors"][0]["expected"], ("valid checks", "successful comparison")) + if compare_to: + self.assertTrue(report["errors"][0]["details"]["evaluated"]) + check = {"variable": "source", "path": ["absent"], "operator": operator, "value": []} + report = evaluate_checks_result(sdk, datafile, [check], owner_variable=None, diagnostics=[]) + self.assertFalse(report["error"]) + self.assertFalse(report["passed"]) + malformed = {"variable": "source", "path": ["items"], "operator": operator, "compareTo": {"variable": "other", "path": ["items"]}} + report = evaluate_checks_result(sdk, datafile, [{"or": [{"variable": "source", "operator": "exists"}, malformed]}], owner_variable=None, diagnostics=[]) + self.assertTrue(report["passed"]) + self.assertFalse(report["error"]) + finally: + sdk.close() diff --git a/tests/test_incremental_dependencies.py b/tests/test_incremental_dependencies.py new file mode 100644 index 0000000..0b7652d --- /dev/null +++ b/tests/test_incremental_dependencies.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import gc +import copy +import json +import threading +import unittest +import weakref +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import patch + +from featurevisor import create_featurevisor +from featurevisor.dependencies import extract_entity_dependencies +from featurevisor._json import strict_json_loads + + +def datafile(revision="initial", **values): + return {"schemaVersion": "4", "revision": revision, "segments": {}, "features": {}, "variables": {}, **values} + + +def feature(**values): + return {"hash": "stable", "bucketBy": "userId", + "traffic": [{"key": "all", "conditions": "*", "percentage": 100000}], **values} + + +class IncrementalDependencyTests(unittest.TestCase): + def test_equal_actual_definitions_reuse_edges_in_both_schemas_and_modes(self): + for schema in ("2", "4"): + for replace in (False, True): + with self.subTest(schema=schema, replace=replace): + initial = datafile(schemaVersion=schema, + segments={"gate": {"conditions": '{"variable":"source","operator":"notNull"}'}}, + features={"root": feature(conditions="gate")}, + variables={"source": {"hash": "one", "type": "integer", "defaultValue": 1}, + "alias": {"hash": "stable", "compose": "source"}, + "hashless": {"type": "integer", "defaultValue": 0}}) + sdk = create_featurevisor({"datafile": initial, "logLevel": "fatal"}) + self.addCleanup(sdk.close) + self.assertTrue(sdk.set_datafile(copy.deepcopy(initial))) + events = [] + sdk.on("datafile_set", events.append) + with patch("featurevisor.dependencies.extract_entity_dependencies", wraps=extract_entity_dependencies) as extract: + incoming = copy.deepcopy(initial) + incoming["revision"] = "equal" + self.assertTrue(sdk.set_datafile(incoming, replace)) + extract.assert_not_called() + self.assertEqual(events[-1]["variables"], ["hashless"]) + self.assertEqual(events[-1]["features"], []) + incoming = copy.deepcopy(initial) + incoming["variables"]["alias"]["compose"] = "hashless" + self.assertTrue(sdk.set_datafile(incoming, replace)) + self.assertEqual(extract.call_count, 1) + self.assertEqual(sdk.datafile.dependency_index.owners[("variable", "alias")], + (("variable", "hashless"),)) + # Payload changes require comparison of the whole definition too. + incoming = copy.deepcopy(incoming) + incoming["variables"]["alias"]["metadata"] = {"conditions": "ignored"} + self.assertTrue(sdk.set_datafile(incoming, replace)) + self.assertEqual(extract.call_count, 2) + self.assertNotIn(("segment", "ignored"), sdk.datafile.dependency_index.reverse) + + def test_edge_reuse_does_not_retain_diagnostics_or_removed_normalized_entities(self): + for schema in ("2", "4"): + for replace in (False, True): + with self.subTest(schema=schema, replace=replace): + diagnostics = [] + initial = datafile(schemaVersion=schema, segments={"broken": {"conditions": "{bad"}}, + features={"removed": feature()}, variables={"removed": {"type": "integer", "defaultValue": 1}}) + sdk = create_featurevisor({"datafile": initial, "logLevel": "error", "onDiagnostic": diagnostics.append}) + self.addCleanup(sdk.close) + sdk.datafile.get_segment("broken") + sdk.datafile.get_feature("removed") + sdk.datafile.get_global_variable("removed") + self.assertEqual(len(diagnostics), 1) + self.assertTrue(sdk.set_datafile(copy.deepcopy(initial), replace)) + sdk.datafile.get_segment("broken") + sdk.datafile.get_segment("broken") + self.assertEqual([d["code"] for d in diagnostics], ["conditions_parse_error"] * 2) + self.assertTrue(sdk.set_datafile(datafile("empty", schemaVersion=schema), True)) + self.assertEqual(sdk.datafile.entity_cache, {}) + self.assertEqual(sdk.datafile._entity_slots, {}) + self.assertEqual(sdk.datafile.dependency_index.owners, {}) + self.assertEqual(sdk.datafile.dependency_index.reverse, {}) + self.assertIsNone(sdk.datafile.get_feature("removed")) + self.assertIsNone(sdk.datafile.get_global_variable("removed")) + + def test_dependency_extraction_preserves_selector_order_and_ignores_values(self): + definition = { + "conditions": '{"and":["segment",{"variable":"global"},' + '{"feature":"flag","variable":"value"}]}', + "force": [{"conditions": {"or": ["segment", {"feature": "forced"}]}}], + "traffic": [{"conditions": "traffic", "variableOverrides": { + "value": [{"conditions": {"not": [{"variable": "rule"}]}}]}}], + "variations": [{"variableOverrides": {"value": [ + {"conditions": '"variation"'}, {"conditions": "{"}]}}], + "overrides": [{"conditions": {"variable": "override"}}], + "overrideProgram": [{"conditions": {"feature": "program"}}], + "variableOverrides": {"value": [{"conditions": {"variable": "last"}}]}, + "compose": [{"feature": "flag", "variable": "value"}, {"variable": "composed"}], + "defaultValue": {"conditions": "not-a-dependency"}, + "metadata": {"conditions": "also-not-a-dependency"}, + } + self.assertEqual(extract_entity_dependencies(definition), ( + ("segment", "segment"), ("variable", "global"), ("feature", "flag"), + ("feature", "forced"), ("segment", "traffic"), ("variable", "rule"), + ("segment", "variation"), ("variable", "override"), ("feature", "program"), + ("variable", "last"), ("variable", "composed"), + )) + self.assertEqual(extract_entity_dependencies({"compose": "alias"}), (("variable", "alias"),)) + self.assertEqual(extract_entity_dependencies({"traffic": None, "variableOverrides": None}), ()) + + def test_full_merge_and_replace_refresh_same_hash_edges(self): + for replace in (False, True): + with self.subTest(replace=replace): + initial = datafile(features={"root": feature(conditions={"variable": "old"})}, + variables={"old": {"type": "integer", "defaultValue": 1, "hash": "1"}, + "new": {"type": "integer", "defaultValue": 2, "hash": "2"}}) + sdk = create_featurevisor({"datafile": initial, "logLevel": "fatal"}) + self.addCleanup(sdk.close) + events = [] + sdk.on("datafile_set", events.append) + incoming = {**initial, "revision": "edges", + "features": {"root": feature(conditions={"variable": "new"})}} + self.assertTrue(sdk.set_datafile(incoming, replace)) + self.assertEqual(events[-1]["features"], []) + self.assertEqual(events[-1]["variables"], []) + self.assertEqual(events[-1]["replaced"], replace) + self.assertTrue(sdk.set_datafile(datafile("old", variables={ + "old": {"type": "integer", "defaultValue": 3, "hash": "3"}}))) + self.assertEqual(events[-1]["features"], []) + self.assertEqual(events[-1]["variables"], ["old"]) + self.assertTrue(sdk.set_datafile(datafile("new", variables={ + "new": {"type": "integer", "defaultValue": 4, "hash": "4"}}))) + self.assertEqual(events[-1]["features"], ["root"]) + self.assertEqual(events[-1]["variables"], ["new"]) + + def test_unrelated_entities_do_not_increase_cached_graph_traversal(self): + for size in (16, 512): + with self.subTest(size=size): + sdk = create_featurevisor({"logLevel": "fatal", "datafile": datafile( + segments={f"s{i}": {"conditions": '{"attribute":"country","operator":"notNull"}'} for i in range(size)}, + features={f"f{i}": feature(conditions=f"s{i}") for i in range(size)}, + variables={"source": {"type": "integer", "defaultValue": 1, "hash": "1"}})}) + self.addCleanup(sdk.close) + self.assertTrue(sdk.set_datafile(datafile("warm"))) + index = sdk.datafile.dependency_index + with patch.object(index, "get", wraps=index.get) as visit, patch( + "featurevisor.dependencies.extract_entity_dependencies", wraps=extract_entity_dependencies + ) as extract: + self.assertTrue(sdk.set_datafile(datafile("next", variables={"source": { + "type": "integer", "defaultValue": 2, "hash": "2"}}))) + self.assertEqual(visit.call_count, 2) + self.assertEqual(extract.call_count, 1) + + def test_only_incoming_entities_are_extracted_and_parsed_after_the_first_update(self): + for size in (8, 128, 1024): + with self.subTest(size=size): + condition = json.dumps({"variable": "source", "operator": "notNull"}) + source = datafile( + segments={f"s{i}": {"conditions": condition} for i in range(size)}, + features={f"f{i}": feature(conditions=f"s{i}") for i in range(size)}, + variables={"source": {"type": "integer", "defaultValue": 1, "hash": "1"}, + **{f"v{i}": {"type": "integer", "compose": "source", "hash": "stable"} for i in range(size)}}) + with patch("featurevisor.dependencies.extract_entity_dependencies", wraps=extract_entity_dependencies) as extract, patch( + "featurevisor.dependencies.strict_json_loads", wraps=strict_json_loads + ) as parse: + sdk = create_featurevisor({"datafile": source, "logLevel": "fatal"}) + self.addCleanup(sdk.close) + extract.assert_not_called() + parse.assert_not_called() + events = [] + sdk.on("datafile_set", events.append) + self.assertTrue(sdk.set_datafile(datafile("cold", variables={"source": { + "type": "integer", "defaultValue": 2, "hash": "2"}}))) + self.assertEqual(extract.call_count, size * 3 + 2) + self.assertEqual(parse.call_count, size) + index = sdk.datafile.dependency_index + extract.reset_mock() + parse.reset_mock() + for revision in range(3): + self.assertTrue(sdk.set_datafile(datafile(str(revision), variables={"source": { + "type": "integer", "defaultValue": revision, "hash": str(revision), "conditions": condition}}))) + self.assertIs(sdk.datafile.dependency_index, index) + self.assertEqual(events[-1]["features"], [f"f{i}" for i in range(size)]) + self.assertEqual(events[-1]["variables"], ["source"] + [f"v{i}" for i in range(size)]) + self.assertEqual(extract.call_count, 3) + self.assertEqual(parse.call_count, 3) + + def test_retained_segments_are_not_deep_compared(self): + class RetainedSegment(dict): + def __eq__(self, other): + raise AssertionError("retained segment compared") + + __ne__ = __eq__ + + sdk = create_featurevisor({"datafile": datafile(segments={"retained": RetainedSegment(conditions="*")}), "logLevel": "fatal"}) + self.addCleanup(sdk.close) + self.assertTrue(sdk.set_datafile(datafile("next"))) + + def test_old_then_new_graph_does_not_walk_new_then_old_paths(self): + sdk = create_featurevisor({"datafile": datafile(variables={ + "source": {"type": "integer", "defaultValue": 1, "hash": "1"}, + "bridge": {"type": "integer", "defaultValue": 0, "hash": "same"}, + "unreachable": {"type": "integer", "compose": "bridge", "hash": "same"}}), "logLevel": "fatal"}) + self.addCleanup(sdk.close) + events = [] + sdk.on("datafile_set", events.append) + self.assertTrue(sdk.set_datafile(datafile("next", variables={ + "source": {"type": "integer", "defaultValue": 2, "hash": "2"}, + "bridge": {"type": "integer", "compose": "source", "hash": "same"}, + "unreachable": {"type": "integer", "defaultValue": 0, "hash": "same"}}))) + self.assertEqual(events[-1]["variables"], ["source", "bridge"]) + + def test_segment_chains_aliases_compositions_overrides_removals_and_replacement(self): + source = datafile(segments={"leaf": {"conditions": {"variable": "source", "operator": "notNull"}}, + "chain": {"conditions": "leaf"}}, features={ + "root": feature(conditions="chain"), + "override": feature(traffic=[{"key": "all", "conditions": "*", "percentage": 100000, + "variableOverrides": {"message": [{"conditions": {"variable": "alias", "operator": "notNull"}, "value": "yes"}]}}])}, + variables={"source": {"type": "integer", "defaultValue": 1, "hash": "1"}, + "alias": {"type": "integer", "compose": "source", "hash": "same"}, + "view": {"type": "object", "compose": [{"key": "flag", "feature": "root"}], "hash": "same"}}) + sdk = create_featurevisor({"datafile": source, "logLevel": "fatal"}) + self.addCleanup(sdk.close) + events = [] + sdk.on("datafile_set", events.append) + for revision in ("2", "3"): + self.assertTrue(sdk.set_datafile(datafile(revision, variables={"source": { + "type": "integer", "defaultValue": 2, "hash": revision}}))) + self.assertEqual(set(events[-1]["features"]), {"root", "override"}) + self.assertEqual(set(events[-1]["variables"]), {"source", "alias", "view"}) + self.assertTrue(sdk.set_datafile(datafile("segments", segments={"chain": {"conditions": "*"}}))) + self.assertEqual(events[-1]["features"], ["root"]) + self.assertEqual(events[-1]["variables"], ["view"]) + self.assertTrue(sdk.set_datafile(datafile("removed"), True)) + self.assertEqual(sdk.datafile.dependency_index.owners, {}) + self.assertEqual(sdk.datafile.dependency_index.reverse, {}) + self.assertTrue(sdk.set_datafile(datafile("fresh", variables={"source": {"type": "integer", "defaultValue": 3, "hash": "3"}}))) + self.assertEqual(events[-1]["features"], []) + self.assertEqual(events[-1]["variables"], ["source"]) + + def test_concurrent_partial_writers_cannot_lose_accepted_updates(self): + sdk = create_featurevisor({"datafile": datafile(), "logLevel": "fatal"}) + self.addCleanup(sdk.close) + entered, release = threading.Event(), threading.Event() + original = sdk._merge_datafiles + + def blocked_merge(previous, incoming): + if incoming["revision"] == "first": + entered.set() + self.assertTrue(release.wait(5)) + return original(previous, incoming) + + with patch.object(sdk, "_merge_datafiles", blocked_merge), ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(sdk.set_datafile, datafile("first", variables={"one": {"type": "integer", "defaultValue": 1, "hash": "1"}})) + self.assertTrue(entered.wait(5)) + second = pool.submit(sdk.set_datafile, datafile("second", variables={"two": {"type": "integer", "compose": "one", "hash": "2"}})) + release.set() + self.assertTrue(first.result(timeout=5)) + self.assertTrue(second.result(timeout=5)) + self.assertEqual(sdk.get_variable("two"), 1) + self.assertEqual(set(sdk.get_variable_keys()), {"one", "two"}) + + def test_event_callbacks_run_outside_writer_lock_and_history_is_not_retained(self): + sdk = create_featurevisor({"datafile": datafile(variables={"source": {"type": "integer", "defaultValue": 1, "hash": "1"}}), "logLevel": "fatal"}) + self.addCleanup(sdk.close) + old = weakref.ref(sdk.datafile) + + def on_event(event): + if event["revision"] == "first": + with ThreadPoolExecutor(max_workers=1) as pool: + self.assertTrue(pool.submit(sdk.set_datafile, datafile("callback")).result(timeout=5)) + + sdk.on("datafile_set", on_event) + self.assertTrue(sdk.set_datafile(datafile("first"))) + self.assertEqual(sdk.get_revision(), "callback") + gc.collect() + self.assertIsNone(old()) + sdk.close() + self.assertIsNone(sdk.datafile.dependency_index) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_instance_parity.py b/tests/test_instance_parity.py index d4f6251..c81cce0 100644 --- a/tests/test_instance_parity.py +++ b/tests/test_instance_parity.py @@ -63,7 +63,7 @@ def test_should_configure_plain_and_and_or_bucket_by(self) -> None: self.assertEqual(sdk.getVariation("test", context), "control") self.assertEqual(captured["bucket"], expected_bucket) - def test_should_intercept_before_and_after_modules(self) -> None: + def test_should_intercept_canonical_evaluation_modules(self) -> None: before = {"called": False, "featureKey": "", "variableKey": None} sdk = create_featurevisor( { @@ -74,8 +74,8 @@ def test_should_intercept_before_and_after_modules(self) -> None: "segments": {}, }, "modules": [ - {"name": "before", "before": lambda options: before.update(called=True, featureKey=options["featureKey"], variableKey=options.get("variableKey")) or options}, - {"name": "after", "after": lambda evaluation, options: {**evaluation, "variationValue": "control_intercepted"}}, + {"name": "before", "beforeEvaluation": lambda options: before.update(called=True, featureKey=options["featureKey"], variableKey=options.get("variableKey")) or options}, + {"name": "after", "afterEvaluation": lambda evaluation, options: {**evaluation, "variationValue": "control_intercepted"}}, ], "logLevel": "fatal", } @@ -117,7 +117,7 @@ def test_required_deprecated_and_rule_override_cases(self) -> None: "revision": "1.0", "features": { "requiredKey": {"key": "requiredKey", "bucketBy": "userId", "variations": [{"value": "control"}, {"value": "treatment"}], "traffic": [{"key": "1", "segments": "*", "percentage": 100000, "allocation": [{"variation": "control", "range": [0, 0]}, {"variation": "treatment", "range": [0, 100000]}]}]}, - "myKey": {"key": "myKey", "bucketBy": "userId", "required": [{"key": "requiredKey", "variation": "treatment"}], "traffic": [{"key": "1", "segments": "*", "percentage": 100000, "allocation": []}]}, + "myKey": {"key": "myKey", "bucketBy": "userId", "requiredFeatures": [{"feature": "requiredKey", "variation": "treatment"}], "traffic": [{"key": "1", "segments": "*", "percentage": 100000, "allocation": []}]}, "deprecatedTest": {"key": "deprecatedTest", "deprecated": True, "bucketBy": "userId", "variations": [{"value": "control"}, {"value": "treatment"}], "traffic": [{"key": "1", "segments": "*", "percentage": 100000, "allocation": [{"variation": "control", "range": [0, 100000]}, {"variation": "treatment", "range": [0, 0]}]}]}, "flagOverride": {"key": "flagOverride", "bucketBy": "userId", "traffic": [{"key": "2", "segments": ["netherlands"], "percentage": 100000, "enabled": False, "allocation": []}, {"key": "1", "segments": "*", "percentage": 100000, "allocation": []}]}, }, @@ -132,7 +132,7 @@ def test_required_deprecated_and_rule_override_cases(self) -> None: self.assertTrue(sdk.isEnabled("flagOverride", {"userId": "user-123", "country": "de"})) self.assertFalse(sdk.isEnabled("flagOverride", {"userId": "user-123", "country": "nl"})) - def test_mutually_exclusive_variation_and_variable_cases(self) -> None: + def test_ignored_feature_ranges_variation_and_variable_cases(self) -> None: bucket = {"value": 10000} sdk = create_featurevisor( { @@ -147,7 +147,7 @@ def test_mutually_exclusive_variation_and_variable_cases(self) -> None: "mobile": {"key": "mobile", "conditions": '[{"attribute":"device","operator":"equals","value":"mobile"}]'}, }, "features": { - "mutex": {"key": "mutex", "bucketBy": "userId", "ranges": [[0, 50000]], "traffic": [{"key": "1", "segments": "*", "percentage": 50000, "allocation": []}]}, + "mutex": {"key": "mutex", "bucketBy": "userId", "ranges": [[50000, 100000]], "traffic": [{"key": "1", "segments": "*", "percentage": 50000, "allocation": []}]}, "test": { "key": "test", "bucketBy": "userId", @@ -173,6 +173,8 @@ def test_mutually_exclusive_variation_and_variable_cases(self) -> None: ) bucket["value"] = 40000 self.assertTrue(sdk.isEnabled("mutex", {"userId": "123"})) + bucket["value"] = 50000 + self.assertTrue(sdk.isEnabled("mutex", {"userId": "123"})) bucket["value"] = 60000 self.assertFalse(sdk.isEnabled("mutex", {"userId": "123"})) self.assertEqual(sdk.getVariation("test", {"userId": "123"}), "treatment") @@ -186,7 +188,12 @@ def test_mutually_exclusive_variation_and_variable_cases(self) -> None: self.assertEqual(sdk.getVariableBoolean("test", "showSidebar", {"userId": "123", "country": "nl"}), False) self.assertEqual(sdk.getVariableString("test", "sidebarTitle", {"userId": "user-forced-variation", "country": "de"}), "German title") self.assertEqual(sdk.getVariableInteger("test", "count", {"userId": "123"}), 0) - self.assertEqual(sdk.getVariableDouble("test", "price", {"userId": "123"}), 9.99) + self.assertEqual(sdk.getVariableNumber("test", "price", {"userId": "123"}), 9.99) + self.assertEqual(sdk.get_variable_number("test", "price", {"userId": "123"}), 9.99) + self.assertEqual(sdk.spawn({"userId": "123"}).get_variable_number("test", "price"), 9.99) + # The double getters were removed in v4. Datafile double types still read as numbers. + self.assertFalse(hasattr(sdk, "get_variable_double") or hasattr(sdk, "getVariableDouble")) + self.assertFalse(hasattr(sdk.spawn({}), "get_variable_double")) self.assertEqual(sdk.getVariableArray("test", "paymentMethods", {"userId": "123"}), ["paypal", "creditcard"]) self.assertEqual(sdk.getVariableObject("test", "flatConfig", {"userId": "123"}), {"key": "value"}) self.assertEqual(sdk.getVariableJSON("test", "nestedConfig", {"userId": "123"}), {"key": {"nested": "value"}}) diff --git a/tests/test_lazy_cache_concurrency.py b/tests/test_lazy_cache_concurrency.py new file mode 100644 index 0000000..c89b20f --- /dev/null +++ b/tests/test_lazy_cache_concurrency.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import gc +import threading +import unittest +import weakref +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import patch + +from featurevisor import create_featurevisor +from featurevisor.normalize import normalize_entity + + +def datafile(revision="initial", **values): + return {"schemaVersion": "4", "revision": revision, "segments": {}, "features": {}, "variables": {}, **values} + + +def feature(**values): + return {"bucketBy": "userId", "hash": "stable", + "traffic": [{"key": "all", "conditions": "*", "percentage": 100000}], **values} + + +class ContendedLock: + """Hold all first readers at the lock boundary until everyone arrives.""" + + def __init__(self, lock, readers): + self.lock = lock + self.barrier = threading.Barrier(readers, timeout=5) + self.entries = 0 + self.count_lock = threading.Lock() + + def __enter__(self): + with self.count_lock: + self.entries += 1 + self.barrier.wait() + self.lock.acquire() + + def __exit__(self, *args): + self.lock.release() + + +class LazyCacheConcurrencyTests(unittest.TestCase): + def test_warm_entity_reads_do_not_acquire_normalization_lock(self): + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile( + features={"warm": feature()}, segments={"warm": {"conditions": "*"}}, + variables={"warm": {"type": "integer", "defaultValue": 1}})}) + self.addCleanup(f.close) + provider = f.datafile + reads = (provider.get_feature, provider.get_segment, provider.get_global_variable) + expected = [read("warm") for read in reads] + original_lock = provider._entity_lock + + class UnexpectedLock: + def __enter__(self): + raise AssertionError("Warm entity read acquired the normalization lock") + + def __exit__(self, *args): + pass + + provider._entity_lock = UnexpectedLock() + try: + def reader(): + for _ in range(1000): + for read, value in zip(reads, expected): + self.assertIs(read("warm"), value) + with ThreadPoolExecutor(max_workers=10) as pool: + futures = [pool.submit(reader) for _ in range(10)] + for future in futures: + future.result(timeout=10) + finally: + provider._entity_lock = original_lock + + def test_concurrent_cold_reads_normalize_and_diagnose_once_at_every_entity_kind(self): + for kind in ("feature", "segment", "variable"): + with self.subTest(kind=kind): + diagnostics = [] + d = datafile(**{{"feature": "features", "segment": "segments", "variable": "variables"}[kind]: + {"broken": {"conditions": "{"}}}) + f = create_featurevisor({"datafile": d, "logLevel": "error", "onDiagnostic": diagnostics.append}) + self.addCleanup(f.close) + provider = f.datafile + read = {"feature": provider.get_feature, "segment": provider.get_segment, + "variable": provider.get_global_variable}[kind] + original_lock = provider._entity_lock + provider._entity_lock = ContendedLock(original_lock, 8) + try: + with patch("featurevisor.evaluation_data_provider.normalize_entity", wraps=normalize_entity) as normalize: + with ThreadPoolExecutor(max_workers=8) as pool: + futures = [pool.submit(read, "broken") for _ in range(8)] + values = [future.result(timeout=10) for future in futures] + self.assertEqual(normalize.call_count, 1) + self.assertEqual(provider._entity_lock.entries, 8) + self.assertTrue(all(value is values[0] for value in values)) + self.assertEqual(len(diagnostics), 1) + self.assertEqual(diagnostics[0]["code"], "conditions_parse_error") + self.assertEqual(diagnostics[0]["details"], + {"conditions": "{", "entityType": kind, "entityKey": "broken"}) + finally: + provider._entity_lock = original_lock + + def test_cold_slots_are_shared_without_retaining_previous_providers(self): + diagnostics = [] + f = create_featurevisor({"logLevel": "error", "onDiagnostic": diagnostics.append, + "datafile": datafile(features={"broken": feature(conditions="{")}, + segments={"broken": {"conditions": "{"}}, + variables={"broken": {"type": "integer", "conditions": "{"}})}) + self.addCleanup(f.close) + old = f.datafile + self.assertTrue(f.set_datafile(datafile("partial"))) + new = f.datafile + self.assertEqual(old.entity_cache, {}) + self.assertEqual(new.entity_cache, {}) + self.assertEqual(diagnostics, []) + original_lock = old._entity_lock + lock = ContendedLock(original_lock, 2) + old._entity_lock = new._entity_lock = lock + try: + for method in ("get_feature", "get_segment", "get_global_variable"): + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(getattr(old, method), "broken") + second = pool.submit(getattr(new, method), "broken") + self.assertIs(first.result(timeout=10), second.result(timeout=10)) + finally: + old._entity_lock = new._entity_lock = original_lock + self.assertEqual(len(diagnostics), 3) + old_ref = weakref.ref(old) + del old, first, second + gc.collect() + self.assertIsNone(old_ref()) + retained = new.get_feature("broken") + self.assertTrue(f.set_datafile(datafile("replace", features={"broken": feature(conditions="{")}))) + self.assertFalse(f.is_enabled("broken")) + self.assertIs(new.get_feature("broken"), retained) + self.assertIsNot(f.get_feature("broken"), retained) + self.assertEqual(len(diagnostics), 4) + + def test_diagnostic_reentry_and_refresh_do_not_hold_cache_lock(self): + f = create_featurevisor({"logLevel": "error", "datafile": datafile(features={"broken": feature(conditions="{")})}) + self.addCleanup(f.close) + observed = [] + with ThreadPoolExecutor(max_workers=1) as pool: + def diagnostic(item): + if item["code"] == "conditions_parse_error": + provider = f.datafile + def reenter(): + value = provider.get_feature("broken") + accepted = f.set_datafile(datafile("reentrant")) + return value is not None, accepted + observed.append(pool.submit(reenter).result(timeout=5)) + f.on_diagnostic = diagnostic + self.assertFalse(f.is_enabled("broken")) + self.assertEqual(observed, [(True, True)]) + self.assertEqual(f.get_revision(), "reentrant") + + def test_refresh_snapshot_blocks_cold_insert_until_iteration_finishes(self): + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile( + features={"warm": feature(), "cold": feature()})}) + self.addCleanup(f.close) + old = f.datafile + old.get_feature("warm") + snapshot_started = threading.Event() + reader_at_lock = threading.Event() + original_lock = old._entity_lock + + class ObservedLock: + def __enter__(self): + if threading.current_thread().name == "cold-reader": + reader_at_lock.set() + original_lock.acquire() + + def __exit__(self, *args): + original_lock.release() + + class PausedCache(dict): + def items(self): + for item in super().items(): + snapshot_started.set() + if not reader_at_lock.wait(5): + raise AssertionError("Reader did not reach cache lock") + yield item + + old._entity_lock = ObservedLock() + old.entity_cache = PausedCache(old.entity_cache) + reads = [] + def read(): + if snapshot_started.wait(5): + reads.append(old.get_feature("cold")) + reader = threading.Thread(target=read, name="cold-reader", daemon=True) + reader.start() + try: + self.assertTrue(f.set_datafile(datafile("partial"))) + reader.join(5) + self.assertFalse(reader.is_alive()) + self.assertEqual(len(reads), 1) + self.assertIs(f.get_feature("cold"), reads[0]) + finally: + old._entity_lock = f.datafile._entity_lock = original_lock + + def test_four_cold_readers_and_300_valid_partial_updates_have_zero_failures(self): + rounds = 300 + diagnostics = [] + f = create_featurevisor({"logLevel": "error", "onDiagnostic": diagnostics.append, + "datafile": datafile(features={f"f{i}": feature() for i in range(4 * rounds)})}) + self.addCleanup(f.close) + barrier = threading.Barrier(5, timeout=20) + + def reader(index): + failures = [] + try: + for step in range(rounds): + barrier.wait() + result = f.evaluate_flag(f"f{step * 4 + index}") + if result.get("enabled") is not True or result.get("reason") != "rule": + failures.append(result) + barrier.wait() + return failures + except BaseException: + barrier.abort() + raise + + with ThreadPoolExecutor(max_workers=4) as pool: + readers = [pool.submit(reader, index) for index in range(4)] + accepted = [] + try: + for step in range(rounds): + barrier.wait() + accepted.append(f.set_datafile(datafile(str(step), variables={ + "updated": {"type": "integer", "defaultValue": step, "hash": str(step)}}))) + barrier.wait() + self.assertEqual(f.get_variable("updated"), step) + finally: + if len(accepted) != rounds: + barrier.abort() + self.assertEqual([future.result(timeout=20) for future in readers], [[], [], [], []]) + self.assertEqual(accepted, [True] * rounds) + self.assertEqual(diagnostics, []) diff --git a/tests/test_live_modules.py b/tests/test_live_modules.py new file mode 100644 index 0000000..c6b67bf --- /dev/null +++ b/tests/test_live_modules.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import unittest + +from featurevisor import create_featurevisor + + +def feature(**values): + return {"bucketBy": "userId", "traffic": [{"key": "all", "conditions": "*", "percentage": 100000}], **values} + + +class LiveModuleTests(unittest.TestCase): + def test_membership_refreshes_between_phases_for_feature_and_global_roots(self): + for global_root in (False, True): + for removal, disable_gate in ((False, False), (False, True), (True, False), (True, True)): + with self.subTest(global_root=global_root, removal=removal, disable_gate=disable_gate): + trace = [] + condition = {"or": [{"feature": "broken"}, {"feature": "gate"}]} + + def key(options): + return options.get("featureKey") or options.get("variableKey") + + def before(options): + trace.append("before:" + key(options)) + return options + + def after(result, options): + trace.append("after:" + key(options)) + return {**result, "enabled": False} if disable_gate and key(options) == "gate" else result + + module = {"name": "late", "beforeEvaluation": before, "afterEvaluation": after} + + def on_diagnostic(diagnostic): + if diagnostic["code"] == "conditions_parse_error": + if removal: + sdk.remove_module("late") + else: + sdk.add_module(module) + + sdk = create_featurevisor({"logLevel": "error", "onDiagnostic": on_diagnostic, + "modules": [module] if removal else [], "datafile": { + "schemaVersion": "4", "revision": "live", "segments": {}, "features": { + "broken": feature(conditions="[broken"), "gate": feature(), + "subject": feature(conditions=condition)}, "variables": { + "subject": {"type": "string", "defaultValue": "matched", "conditions": condition}}}}) + try: + evaluation = sdk.evaluate_variable("subject") if global_root else sdk.evaluate_flag("subject") + if disable_gate and not removal: + self.assertEqual(evaluation["reason"], "conditions_unmet") + if global_root: + self.assertIsNone(evaluation.get("variableValue")) + else: + self.assertFalse(evaluation.get("enabled")) + else: + self.assertEqual(evaluation.get("variableValue") if global_root else evaluation.get("enabled"), + "matched" if global_root else True) + self.assertEqual(trace, ["before:subject", "before:broken"] if removal else [ + "after:broken", "before:gate", "after:gate", "after:subject"]) + finally: + sdk.close() + + def test_after_phase_uses_one_membership_snapshot(self): + trace = [] + + def first(result, options): + trace.append("first") + sdk.add_module({"name": "added", "afterEvaluation": added}) + sdk.remove_module("second") + return result + + def second(result, options): + trace.append("second") + return result + + def added(result, options): + trace.append("added") + return result + + sdk = create_featurevisor({"logLevel": "fatal", "modules": [ + {"name": "first", "afterEvaluation": first}, {"name": "second", "afterEvaluation": second}]}) + self.addCleanup(sdk.close) + sdk.evaluate_flag("missing") + self.assertEqual(trace, ["first", "second"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_module_phase_snapshots.py b/tests/test_module_phase_snapshots.py new file mode 100644 index 0000000..e1c8cc4 --- /dev/null +++ b/tests/test_module_phase_snapshots.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import unittest + +from featurevisor import create_featurevisor + + +class ModulePhaseSnapshotTests(unittest.TestCase): + def test_membership_matrix(self): + phases = ("beforeEvaluation", "bucketKey", "bucketValue", "afterEvaluation") + for global_root in (False, True): + for changed_phase in phases: + for action in ("add", "remove_add", "remove", "unsubscribe"): + with self.subTest(global_root=global_root, phase=changed_phase, action=action): + trace = [] + closed = [] + changed = False + + def hook(name, phase): + def call(*args): + nonlocal changed + options = args[-1] + active = phase in ("bucketKey", "bucketValue") or ( + options.get("variableKey") == "subject" if global_root + else options.get("featureKey") == "gate" + ) + if active: + trace.append(f"{phase}:{name}") + if name == "first" and phase == changed_phase and not changed: + changed = True + if action == "unsubscribe": + unsubscribe() + elif action != "add": + sdk.remove_module("other") + if action in ("add", "remove_add"): + sdk.add_module(module("late")) + if phase in ("bucketKey", "bucketValue"): + return options[phase] + return args[0] + return call + + def module(name): + return {"name": name, "close": lambda: closed.append(name), + **{phase: hook(name, phase) for phase in phases}} + + sdk = create_featurevisor({"logLevel": "fatal", "datafile": { + "schemaVersion": "4", "revision": "phases", "segments": {}, + "features": {"gate": {"bucketBy": "userId", "traffic": [ + {"key": "all", "conditions": "*", "percentage": 100000}]}}, + "variables": {"subject": {"type": "string", "defaultValue": "matched", + "conditions": {"feature": "gate"}}}}}) + sdk.add_module(module("first")) + unsubscribe = sdk.add_module(module("other")) + try: + def evaluate(): + result = sdk.evaluate_variable("subject") if global_root else sdk.evaluate_flag("gate") + self.assertEqual(result.get("variableValue") if global_root else result.get("enabled"), + "matched" if global_root else True) + + evaluate() + after = ["first"] + (["other"] if action == "add" else []) + ( + ["late"] if action in ("add", "remove_add") else []) + expected = [f"{phase}:{name}" for index, phase in enumerate(phases) + for name in (["first", "other"] if index <= phases.index(changed_phase) else after)] + self.assertEqual(trace, expected) + self.assertEqual(closed, [] if action == "add" else ["other"]) + trace.clear() + evaluate() + self.assertEqual(trace, [f"{phase}:{name}" for phase in phases for name in after]) + finally: + sdk.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_native_timestamps.py b/tests/test_native_timestamps.py new file mode 100644 index 0000000..f736353 --- /dev/null +++ b/tests/test_native_timestamps.py @@ -0,0 +1,53 @@ +import datetime as dt +import unittest + +from featurevisor.conditions import _to_datetime, value_is_matched + + +class NoOffset(dt.tzinfo): + def utcoffset(self, value): + return None + + +class InvalidOffset(dt.tzinfo): + def utcoffset(self, value): + return dt.timedelta(hours=24) + + +class NativeTimestampTests(unittest.TestCase): + def matches(self, actual, operator, expected): + return value_is_matched(actual, {"operator": operator, "value": expected}, lambda *_: None) + + def test_native_precision_and_offsets(self): + for stamp in ("2026-01-01T00:00:00Z", "1969-12-31T23:59:59.999Z"): + boundary = dt.datetime.fromisoformat(stamp.replace("Z", "+00:00")) + for hours in (-14, 0, 14): + host = (boundary + dt.timedelta(microseconds=999)).astimezone(dt.timezone(dt.timedelta(hours=hours))) + self.assertEqual(_to_datetime(host), boundary) + for operator in ("before", "after"): + self.assertFalse(self.matches(host, operator, stamp)) + self.assertEqual(self.matches(host, operator, (boundary + dt.timedelta(milliseconds=1)).isoformat()), operator == "before") + self.assertEqual(self.matches(host, operator, (boundary - dt.timedelta(milliseconds=1)).isoformat()), operator == "after") + + def test_fractional_native_offset_truncates_the_utc_fraction(self): + offset = dt.timezone(dt.timedelta(microseconds=500)) + host = dt.datetime(1970, 1, 1, tzinfo=offset) + self.assertEqual(_to_datetime(host), dt.datetime(1969, 12, 31, 23, 59, 59, 999000, tzinfo=dt.timezone.utc)) + self.assertFalse(self.matches(host, "after", "1969-12-31T23:59:59.999Z")) + self.assertTrue(self.matches(host, "before", "1970-01-01T00:00:00Z")) + + def test_bounds_and_invalid_types(self): + for host in (dt.datetime.min.replace(tzinfo=dt.timezone.utc), dt.datetime.max.replace(tzinfo=dt.timezone.utc)): + self.assertEqual(_to_datetime(host), host.replace(microsecond=host.microsecond // 1000 * 1000)) + invalid = [dt.date(2026, 1, 1), dt.datetime(2026, 1, 1), dt.datetime(2026, 1, 1, tzinfo=NoOffset()), + dt.datetime(2026, 1, 1, tzinfo=InvalidOffset()), + dt.datetime.min.replace(tzinfo=dt.timezone(dt.timedelta(hours=1))), + dt.datetime.max.replace(tzinfo=dt.timezone(dt.timedelta(hours=-1))), + "2026-01-01", "2026-01-01T00:00:00", 1767225600000, None] + for seconds in (-54000, -50401, 50401, 54000): + invalid.append(dt.datetime(2026, 1, 1, tzinfo=dt.timezone(dt.timedelta(seconds=seconds)))) + for actual in invalid: + for operator in ("before", "after"): + with self.subTest(actual=actual, operator=operator): + self.assertFalse(self.matches(actual, operator, "2026-01-01T00:00:00Z")) + self.assertFalse(self.matches(dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc), operator, actual)) diff --git a/tests/test_node_cli.py b/tests/test_node_cli.py new file mode 100644 index 0000000..9ca050f --- /dev/null +++ b/tests/test_node_cli.py @@ -0,0 +1,32 @@ +import os +import pathlib +import tempfile +import unittest +from unittest import mock + +from featurevisor.project import node_cli_command + + +class NodeCliCommandTest(unittest.TestCase): + def test_runs_the_nearest_local_cli_entry_with_node(self): + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory).resolve() + entry = root / "node_modules" / "@featurevisor" / "cli" / "bin.js" + entry.parent.mkdir(parents=True) + entry.write_text("") + project = root / "packages" / "flags" + project.mkdir(parents=True) + self.assertEqual(node_cli_command(project), ["node", str(entry)]) + + def test_falls_back_to_npx_and_uses_the_windows_wrapper_path(self): + with tempfile.TemporaryDirectory() as directory: + project = pathlib.Path(directory) + self.assertEqual(node_cli_command(project), ["npx", "featurevisor"]) + with mock.patch.object(os, "name", "nt"), mock.patch( + "featurevisor.project.shutil.which", return_value=r"C:\\node\\npx.cmd" + ): + self.assertEqual(node_cli_command(project), [r"C:\\node\\npx.cmd", "featurevisor"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_readme_initialization.py b/tests/test_readme_initialization.py new file mode 100644 index 0000000..46bcdea --- /dev/null +++ b/tests/test_readme_initialization.py @@ -0,0 +1,68 @@ +import contextlib +import io +import json +from pathlib import Path +import re +import unittest +from unittest.mock import patch +from urllib.error import HTTPError, URLError + +from featurevisor import Featurevisor, create_featurevisor + + +class ReadmeInitializationTests(unittest.TestCase): + def setUp(self): + readme = Path(__file__).resolve().parents[1] / "README.md" + match = re.search(r"## Initialization\n.*?```python\n(.*?)\n```", readme.read_text(), re.S) + self.assertIsNotNone(match, "README initialization fence not found") + self.code = compile(match[1], str(readme), "exec") + + def fixture(self, enabled): + return json.dumps({ + "schemaVersion": "2", "revision": "readme", "segments": {}, "variables": {}, + "features": {"checkout": {"key": "checkout", "bucketBy": "userId", "traffic": [ + {"key": "all", "segments": "*", "percentage": 100000, "enabled": enabled}, + ]}}, + }).encode() + + def test_evaluation_and_cleanup(self): + for enabled in (True, False): + with self.subTest(enabled=enabled): + response = io.BytesIO(self.fixture(enabled)) + namespace = {} + output = io.StringIO() + with patch("urllib.request.urlopen", return_value=response) as fetch: + with contextlib.redirect_stdout(output): + exec(self.code, namespace) + fetch.assert_called_once_with("https://cdn.yoursite.com/featurevisor-all.json") + self.assertIn(str(enabled), output.getvalue().splitlines()) + self.assertTrue(response.closed) + self.assertTrue(namespace["f"].closed) + + def test_cleanup_when_application_evaluation_raises(self): + instance = create_featurevisor({}) + self.addCleanup(instance.close) + with patch("urllib.request.urlopen", return_value=io.BytesIO(self.fixture(True))): + with patch("featurevisor.create_featurevisor", return_value=instance): + with patch.object(Featurevisor, "is_enabled", side_effect=RuntimeError("application failure")): + with self.assertRaisesRegex(RuntimeError, "application failure"): + exec(self.code, {}) + self.assertTrue(instance.closed) + + def test_invalid_json_does_not_initialize(self): + response = io.BytesIO(b"{") + with patch("urllib.request.urlopen", return_value=response): + with patch("featurevisor.create_featurevisor") as create: + with self.assertRaises(json.JSONDecodeError): + exec(self.code, {}) + create.assert_not_called() + self.assertTrue(response.closed) + + def test_http_and_transport_errors_do_not_initialize(self): + for error in (HTTPError("fixture", 503, "Unavailable", {}, None), URLError("offline")): + with self.subTest(error=error): + with patch("urllib.request.urlopen", side_effect=error): + with patch("featurevisor.create_featurevisor") as create: + with self.assertRaises(type(error)): + exec(self.code, {}) + create.assert_not_called() diff --git a/tests/test_refresh_state.py b/tests/test_refresh_state.py new file mode 100644 index 0000000..e22ae71 --- /dev/null +++ b/tests/test_refresh_state.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import unittest +from unittest.mock import Mock + +from featurevisor import create_featurevisor + + +def feature(**values): + return {"bucketBy": "userId", "traffic": [{"key": "all", "conditions": "*", "percentage": 100000}], **values} + + +def datafile(**values): + return {"schemaVersion": "4", "revision": "initial", "segments": {}, "features": {}, "variables": {}, **values} + + +class RefreshStateTests(unittest.TestCase): + def test_global_getter_reuses_initial_sticky_refresh_until_a_before_hook_changes_state(self): + for child_mode in (False, True): + for setter in (False, True): + for replacement in (False, True): + with self.subTest(child=child_mode, setter=setter, replacement=replacement): + def before(options): + if setter: + target.set_sticky_variables({"leaf": 2}) + return {**options, "stickyVariables": {"leaf": 3}} if replacement else options + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile( + variables={"leaf": {"type": "integer", "defaultValue": 1}}), + "modules": [{"beforeEvaluation": before}]}) + self.addCleanup(f.close) + target = f.spawn() if child_mode else f + if child_mode: + self.addCleanup(target.close) + source = Mock(side_effect=lambda: (target.sticky_features, target.sticky_variables)) + options = {"__featurevisor_child_sticky_source": source, + "__featurevisor_child_initial_sticky": (target.sticky_features, target.sticky_variables)} + if child_mode: + options.update(__featurevisor_child_state_version=lambda: target._state_version, + __featurevisor_child_sticky_features=target.sticky_features, + __featurevisor_child_sticky_variables=target.sticky_variables) + self.assertEqual(f.get_variable("leaf", {}, options), 3 if replacement else 2 if setter else 1) + self.assertEqual(source.call_count, 2 if setter else 1) + + def test_before_hook_setters_refresh_the_current_feature_read(self): + for child in (False, True): + for setter in ("datafile", "sticky"): + with self.subTest(child=child, setter=setter): + def before(options): + if setter == "datafile": + self.assertTrue(f.set_datafile(datafile(revision="updated", features={ + "source": feature(conditions={"or": []})}))) + else: + target.set_sticky_features({"source": {"enabled": False}}) + return options + f = create_featurevisor({"datafile": datafile(features={"source": feature()}), + "logLevel": "fatal", "modules": [{"name": "setter", "beforeEvaluation": before}]}) + self.addCleanup(f.close) + target = f.spawn() if child else f + if child: + self.addCleanup(target.close) + result = target.evaluate_flag("source") + self.assertIs(result["enabled"], False) + self.assertEqual(result["reason"], "conditions_unmet" if setter == "datafile" else "sticky") + f.remove_module("setter") + if child and setter == "sticky": + self.assertTrue(f.is_enabled("source")) + + def test_child_sticky_setters_invalidate_completed_composition_reads(self): + for feature_source in (False, True): + for hook in ("before", "after"): + with self.subTest(feature_source=feature_source, hook=hook): + reads = [] + def before(options): + if options.get("variableKey") == ("value" if feature_source else "source"): + reads.append(1) + if hook == "before" and options.get("variableKey") == "mutator": + update() + return options + + def after(result, options): + if hook == "after" and options.get("variableKey") == "mutator": + update() + return result + + def update(): + if feature_source: + child.set_sticky_features({"source": {"enabled": True, "variables": {"value": 2}}}) + else: + child.set_sticky_variables({"source": 2}) + + reference = {"feature": "source", "variable": "value"} if feature_source else {"variable": "source"} + f = create_featurevisor({"logLevel": "fatal", "modules": [{"beforeEvaluation": before, "afterEvaluation": after}], + "datafile": datafile(features={"source": feature(variablesSchema={"value": {"type": "integer", "defaultValue": 9}})}, + variables={"source": {"type": "integer", "defaultValue": 9}, + "mutator": {"type": "integer", "defaultValue": 0}, + "root": {"type": "object", "compose": [ + {"key": "first", **reference}, {"key": "mutator", "variable": "mutator"}, + {"key": "last", **reference}]}})}) + self.addCleanup(f.close) + child = f.spawn(options={"stickyFeatures": {"source": {"enabled": True, "variables": {"value": 1}}}, + "stickyVariables": {"source": 1}}) + self.addCleanup(child.close) + self.assertEqual(child.get_variable("root"), {"first": 1, "mutator": 0, "last": 2}) + self.assertEqual(reads, [1, 1]) + self.assertIsNone(f.sticky_features) + self.assertIsNone(f.sticky_variables) + + def test_refresh_marker_is_absent_during_every_public_module_hook(self): + seen = [] + + def record(hook, options): + seen.append(hook) + self.assertNotIn("__featurevisor_refreshed_state_version", options) + self.assertNotIn("_evaluationSession", options) + self.assertFalse(any(key.startswith("__featurevisor_") for key in options)) + + def before(options): + record("before", options) + return options + + def after(result, options): + record("after", options) + return result + + def bucket_key(options): + record("bucketKey", options) + return options["bucketKey"] + + def bucket_value(options): + record("bucketValue", options) + return options["bucketValue"] + + module = {"beforeEvaluation": before, "afterEvaluation": after, "bucketKey": bucket_key, "bucketValue": bucket_value} + f = create_featurevisor({"logLevel": "fatal", "modules": [module], "datafile": datafile( + features={"source": feature(variations=[{"value": "on"}], + variablesSchema={"value": {"type": "integer", "defaultValue": 1}})}, + variables={"source": {"type": "integer", "defaultValue": 1}, + "alias": {"type": "integer", "compose": "source"}, + "root": {"type": "object", "compose": [ + {"key": "global", "variable": "alias"}, + {"key": "feature", "feature": "source", "variable": "value"}]}})}) + self.addCleanup(f.close) + child = f.spawn() + self.addCleanup(child.close) + for target in (f, child): + self.assertTrue(target.is_enabled("source")) + target.get_variation("source") + self.assertEqual(target.get_variable("source", "value"), 1) + self.assertEqual(target.get_variable("source"), 1) + self.assertEqual(target.get_variable("root"), {"global": 1, "feature": 1}) + self.assertEqual(set(seen), {"before", "after", "bucketKey", "bucketValue"}) + + # A diagnostic can install the first module after before hooks were skipped. + for global_variable in (False, True): + late = create_featurevisor({"logLevel": "warn", "datafile": datafile( + features={"late": feature(deprecated=True)}, + variables={"late": {"type": "integer", "defaultValue": 1, "deprecated": True}})}) + self.addCleanup(late.close) + seen.clear() + def diagnostic(item): + if "deprecated" in item["code"] and not late.modules_manager.modules: + late.add_module(module) + late.on_diagnostic = diagnostic + if global_variable: + self.assertEqual(late.get_variable("late"), 1) + else: + self.assertTrue(late.is_enabled("late")) + self.assertIn("after", seen) diff --git a/tests/test_required_features_reason.py b/tests/test_required_features_reason.py new file mode 100644 index 0000000..5da9493 --- /dev/null +++ b/tests/test_required_features_reason.py @@ -0,0 +1,37 @@ +import json +import unittest + +from featurevisor import create_featurevisor +from featurevisor.evaluate import EvaluationReason + + +class RequiredFeaturesReasonTests(unittest.TestCase): + def test_unmet_feature_reason_in_evaluation_and_callbacks(self): + observed = [] + + def after(evaluation, options): + if evaluation.get("featureKey") == "subject": + observed.append(evaluation["reason"]) + return evaluation + + f = create_featurevisor({ + "datafile": { + "schemaVersion": "2", "revision": "reason", "segments": {}, + "features": {"subject": { + "bucketBy": "userId", "requiredFeatures": ["missing"], "traffic": [], + }}, + }, + "logLevel": "fatal", + "modules": [{"name": "observe", "afterEvaluation": after}], + }) + self.addCleanup(f.close) + evaluation = f.evaluate_flag("subject") + self.assertEqual(evaluation["reason"], EvaluationReason.CONDITIONS_UNMET) + self.assertIs(evaluation["enabled"], False) + self.assertEqual(observed, ["conditions_unmet"]) + wire = json.loads(json.dumps(evaluation)) + self.assertEqual(wire["reason"], "conditions_unmet") + self.assertEqual(wire["conditions"], [{"feature": "missing"}]) + self.assertFalse(hasattr(EvaluationReason, "REQUIRED")) + with self.assertRaises(ValueError): + EvaluationReason("required") diff --git a/tests/test_resource_errors.py b/tests/test_resource_errors.py new file mode 100644 index 0000000..e8e8cd0 --- /dev/null +++ b/tests/test_resource_errors.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import sys +import unittest +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import patch + +from featurevisor import create_featurevisor +from featurevisor._evaluation_session import EvaluationSession, RequirementInputError +from featurevisor._evaluation_session import dependency_of + + +def feature(**values): + return {"bucketBy": "userId", "conditions": "*", + "traffic": [{"key": "all", "conditions": "*", "percentage": 100000, + "variation": "on"}], + "variations": [{"value": "on"}], + "variablesSchema": {"copy": {"type": "integer", "defaultValue": 7}}, **values} + + +def aliases(): + return {**{str(i): {"type": "integer", "compose": str(i + 1)} for i in range(256)}, + "256": {"type": "integer", "defaultValue": 7}} + + +class ResourceErrorTests(unittest.TestCase): + def setUp(self): + previous = sys.getrecursionlimit() + sys.setrecursionlimit(1000) + self.addCleanup(sys.setrecursionlimit, previous) + setter = patch("sys.setrecursionlimit", side_effect=AssertionError("SDK changed the host budget")) + setter.start() + self.addCleanup(setter.stop) + + def instance(self, *, variables=None, features=None, segments=None, modules=None): + f = create_featurevisor({"logLevel": "fatal", "modules": modules or [], "datafile": { + "schemaVersion": "4", "revision": "resource", "segments": segments or {}, + "features": {"healthy": feature(), **(features or {})}, + "variables": {"healthy": {"type": "integer", "defaultValue": 7}, **(variables or {})}, + }}) + self.addCleanup(f.close) + return f + + def assert_error(self, evaluation, *, feature_key=None, variable_key=None): + self.assertEqual(evaluation["reason"], "error", evaluation) + self.assertIsInstance(evaluation["error"], RequirementInputError) + self.assertNotIn("variableValue", evaluation) + self.assertNotIn("variationValue", evaluation) + self.assertNotIn("variation", evaluation) + self.assertIsNot(evaluation.get("enabled"), True) + if feature_key is not None: + self.assertEqual(evaluation["featureKey"], feature_key) + if variable_key is not None: + self.assertEqual(evaluation["variableKey"], variable_key) + self.assertEqual(sys.getrecursionlimit(), 1000) + + def assert_clean(self, session): + for name in ("active", "current", "segments", "memo", "sticky_refs", "aliases"): + self.assertFalse(getattr(session, name), name) + + def test_deep_aliases_abort_maps_before_overrides_and_explicit_defaults(self): + variables = aliases() + variables["map"] = {"type": "object", "compose": [ + {"key": "good", "variable": "healthy"}, {"key": "bad", "variable": "0"}], + "overrides": [{"conditions": "*", "value": {"rescued": True}}]} + f = self.instance(variables=variables) + for owner in (f, f.spawn({"userId": "child"})): + for key in ("0", "map"): + for _ in range(2): + with self.subTest(child=owner is not f, key=key): + session = EvaluationSession() + options = {"__featurevisor_session": session, "defaultVariableValue": "outer fallback"} + self.assert_error(owner.evaluate_variable(key, {}, options), variable_key=key) + self.assert_clean(session) + self.assertIsNone(owner.get_variable(key, {}, options)) + self.assert_clean(session) + self.assertEqual(owner.get_variable("healthy", {}, options), 7) + self.assert_clean(session) + + def test_deep_alias_errors_cannot_be_negated_or_matched_as_null(self): + for operator in ("isNull", "notNull", "equals"): + leaf = {"variable": "0", "operator": operator, "value": 7} + for conditions in (leaf, {"not": [leaf]}, {"not": [{"not": [leaf]}]}, + {"or": [leaf, "*"]}): + with self.subTest(operator=operator, conditions=conditions): + f = self.instance(variables={**aliases(), "root": { + "type": "integer", "conditions": conditions, "defaultValue": 99, + "disabledValue": 88, "useDefaultWhenDisabled": True}}, + features={"root": feature(conditions=conditions, disabledVariationValue="off")}) + self.assert_error(f.evaluate_variable("root"), variable_key="root") + self.assertIsNone(f.get_variable("root")) + for evaluate, getter, args in ((f.evaluate_flag, f.is_enabled, ("root",)), + (f.evaluate_variation, f.get_variation, ("root",)), + (f.evaluate_variable, f.get_variable, ("root", "copy"))): + self.assert_error(evaluate(*args), feature_key="root") + self.assertEqual(getter(*args), False if getter == f.is_enabled else None) + + def test_mixed_segments_features_and_variables_under_not_cleanup_and_recover(self): + variables, features, segments = {}, {}, {} + for i in range(80): + variables[f"v{i}"] = {"type": "integer", "defaultValue": 7, + "conditions": {"not": [f"s{i}"]}} + segments[f"s{i}"] = {"conditions": {"feature": f"f{i}"}} + features[f"f{i}"] = feature(conditions={"variable": f"v{i + 1}", + "operator": "equals", "value": 7}) + variables["v80"] = {"type": "integer", "defaultValue": 7} + variables["root"] = {"type": "object", "compose": [ + {"key": "good", "variable": "healthy"}, {"key": "bad", "variable": "v0"}]} + f = self.instance(variables=variables, features=features, segments=segments) + session = EvaluationSession() + options = {"__featurevisor_session": session} + for key in ("v0", "root"): + self.assert_error(f.evaluate_variable(key, {}, options), variable_key=key) + self.assert_clean(session) + self.assertIsNone(f.get_variable(key, {}, options)) + self.assert_clean(session) + self.assert_error(f.evaluate_flag("f0", {}, options), feature_key="f0") + self.assert_clean(session) + self.assertEqual(f.get_variable("v80", {}, options), 7) + self.assertTrue(f.is_enabled("healthy", {}, options)) + self.assert_clean(session) + + def test_json_decoder_exhaustion_is_not_an_ordinary_null_dependency(self): + # The source getter still has ordinary JSON parsing semantics. Once used + # as a dependency, host exhaustion must abort its entire requester. + f = self.instance(variables={ + "source": {"type": "json", "defaultValue": "[" * 10000 + "0" + "]" * 10000}, + "alias": {"type": "json", "compose": "source"}, + "map": {"type": "object", "compose": [{"key": "good", "variable": "healthy"}, + {"key": "bad", "variable": "source"}]}, + "root": {"type": "integer", "conditions": {"not": [ + {"variable": "source", "operator": "notNull"}]}, "defaultValue": 99}, + }, features={"root": feature(conditions={"not": [ + {"variable": "source", "operator": "notNull"}]})}) + self.assertIsNone(f.get_variable("source")) + for key in ("alias", "map", "root"): + self.assert_error(f.evaluate_variable(key), variable_key=key) + self.assertIsNone(f.get_variable(key)) + self.assert_error(f.evaluate_flag("root"), feature_key="root") + self.assertFalse(f.is_enabled("root")) + self.assertEqual(f.get_variable("healthy"), 7) + + def test_feature_flag_exhaustion_cannot_continue_into_variation_or_variable(self): + f = self.instance(features={"source": feature()}) + original = f.datafile.conditions_are_matched + + def exhausted(conditions, options): + if options.get("featureKey") == "source" and options["type"] == "flag": + raise RecursionError("flag stack exhausted") + return original(conditions, options) + + with patch.object(f.datafile, "conditions_are_matched", exhausted): + for evaluate, getter, args in ((f.evaluate_flag, f.is_enabled, ("source",)), + (f.evaluate_variation, f.get_variation, ("source",)), + (f.evaluate_variable, f.get_variable, ("source", "copy"))): + with self.subTest(args=args, evaluate=evaluate.__name__): + self.assert_error(evaluate(*args), feature_key="source") + self.assertEqual(getter(*args), False if getter == f.is_enabled else None) + self.assertEqual(f.get_variation("source"), "on") + self.assertEqual(f.get_variable("source", "copy"), 7) + + def test_callback_exhaustion_aborts_each_composition_source_kind_and_not(self): + for hook in ("beforeEvaluation", "afterEvaluation", "bucketKey", "bucketValue"): + for source in ({"variable": "source"}, {"feature": "source"}, + {"feature": "source", "variation": True}, + {"feature": "source", "variable": "copy"}): + if hook.startswith("bucket") and "feature" not in source: + continue + with self.subTest(hook=hook, source=source): + def fail(*args): + options = args[-1] + if options.get("featureKey") == "source" or options.get("variableKey") == "source": + raise RecursionError("callback stack exhausted") + return args[0] if hook == "afterEvaluation" else options.get(hook, options) + + condition = ({"feature": "source"} if "variable" not in source else + {**source, "operator": "notNull"}) + f = self.instance(modules=[{hook: fail}], features={"source": feature()}, variables={ + "source": {"type": "integer", "defaultValue": 7}, + "map": {"type": "object", "compose": [{"key": "good", "variable": "healthy"}, + {"key": "bad", **source}]}, + "not": {"type": "integer", "defaultValue": 99, "conditions": {"not": [condition]}}, + }) + for key in ("map", "not"): + session = EvaluationSession() + self.assert_error(f.evaluate_variable(key, {}, {"__featurevisor_session": session}), + variable_key=key) + self.assert_clean(session) + self.assertIsNone(f.get_variable(key)) + self.assertEqual(f.get_variable("healthy"), 7) + + def test_failed_resource_outcomes_are_never_memoized_as_completed_null(self): + session = EvaluationSession() + # Composition memo values intentionally omit ordinary getter errors, + # but resource errors are not completed getter values. + for purpose in ("condition", "composition"): + key = (("variable", None, "source"), (), None, 0, purpose, ()) + session.put(key, {"type": "variable", "variableKey": "source", "reason": "error", + "error": RecursionError("stack exhausted")}) + self.assertIsNone(session.get(key)) + + def test_exhaustion_during_frame_entry_leaves_no_partial_stack(self): + f = self.instance(variables={"root": {"type": "integer", "compose": "source"}, + "source": {"type": "integer", "defaultValue": 7}}) + session = EvaluationSession() + options = {"__featurevisor_session": session} + + def exhausted(options): + if options.get("variableKey") == "source": + raise RecursionError("frame entry stack exhausted") + return dependency_of(options) + + with patch("featurevisor._evaluation_session.dependency_of", exhausted): + self.assert_error(f.evaluate_variable("root", {}, options), variable_key="root") + self.assert_clean(session) + self.assertEqual(f.get_variable("root", {}, options), 7) + self.assert_clean(session) + + def test_condition_snapshot_exhaustion_aborts_instead_of_borrowing_or_returning_null(self): + leaf = {"variable": "source", "operator": "isNull"} + f = self.instance(variables={"source": {"type": "object", "defaultValue": {"a": 7}}, + "root": {"type": "integer", "defaultValue": 99, + "conditions": {"not": [leaf]}}}) + session = EvaluationSession() + with patch("featurevisor._evaluation_session.copy.deepcopy", side_effect=RecursionError("snapshot exhausted")): + self.assert_error(f.evaluate_variable("root", {}, {"__featurevisor_session": session}), + variable_key="root") + self.assert_clean(session) + self.assertEqual(f.get_variable("root"), 99) + + def test_context_fingerprint_exhaustion_aborts_both_root_kinds_and_cleans_up(self): + context = {} + for _ in range(1100): + context = {"nested": context} + leaf = {"variable": "healthy", "operator": "isNull"} + f = self.instance(variables={"root": {"type": "integer", "defaultValue": 99, + "conditions": {"not": [leaf]}}}, + features={"root": feature(conditions={"not": [leaf]})}) + session = EvaluationSession() + options = {"__featurevisor_session": session} + self.assert_error(f.evaluate_variable("root", context, options), variable_key="root") + self.assert_clean(session) + self.assert_error(f.evaluate_flag("root", context, options), feature_key="root") + self.assert_clean(session) + self.assertEqual(f.get_variable("healthy", {}, options), 7) + self.assertTrue(f.is_enabled("healthy", {}, options)) + self.assert_clean(session) + + def test_completed_null_false_and_ordinary_getter_errors_remain_values(self): + def before(options): + if options.get("variableKey") == "ordinaryError": + raise ValueError("ordinary getter failure") + return options + + f = self.instance(modules=[{"beforeEvaluation": before}], variables={ + "null": {"type": "object", "defaultValue": None}, + "false": {"type": "boolean", "defaultValue": False}, + "malformedJson": {"type": "json", "defaultValue": "{"}, + "ordinaryError": {"type": "integer", "defaultValue": 7}, + "map": {"type": "object", "compose": [ + {"key": key, "variable": key} for key in ("null", "false", "malformedJson", "ordinaryError")]}, + }) + self.assertEqual(f.get_variable("map"), {"null": None, "false": False, + "malformedJson": None, "ordinaryError": None}) + self.assertEqual(f.evaluate_variable("map")["reason"], "variable_composed") + self.assertEqual(sys.getrecursionlimit(), 1000) + + def test_threads_keep_the_host_budget_and_recover_after_deep_getters(self): + f = self.instance(variables=aliases()) + child = f.spawn({"userId": "child"}) + + def read(index): + owner = f if index % 2 else child + self.assert_error(owner.evaluate_variable("0"), variable_key="0") + self.assertIsNone(owner.get_variable("0")) + self.assertEqual(owner.get_variable("healthy"), 7) + self.assertEqual(owner.get_variable("256"), 7) + self.assertTrue(owner.is_enabled("healthy")) + return sys.getrecursionlimit() + + with ThreadPoolExecutor(max_workers=4) as pool: + self.assertEqual(list(pool.map(read, range(20))), [1000] * 20) + self.assertEqual(f.get_variable("healthy"), 7) + + def test_internal_flags_preserve_the_original_requester_in_before_traces(self): + visits = [] + + def before(options): + visits.append({key: options[key] for key in + ("type", "featureKey", "variableKey", "dependencyOf", "dependencyPurpose") + if key in options}) + return options + + f = self.instance(modules=[{"beforeEvaluation": before}], features={ + "gate": feature(), "subject": feature(conditions={"feature": "gate"})}) + for owner in (f, f.spawn({"userId": "child"})): + for type_, read, expected in ( + ("variable", lambda: owner.get_variable("subject", "copy"), 7), + ("variation", lambda: owner.get_variation("subject"), "on"), + ): + with self.subTest(type=type_, child=owner is not f): + visits.clear() + self.assertEqual(read(), expected) + requester = {"type": type_, "featureKey": "subject"} + if type_ == "variable": + requester["variableKey"] = "copy" + self.assertEqual(visits, [requester, { + "type": "flag", "featureKey": "gate", "dependencyOf": requester, + "dependencyPurpose": "condition"}]) + + def test_malformed_json_getter_fallback_is_memoized_once_per_root_and_purpose(self): + for source in ({"variable": "source"}, {"feature": "source", "variable": "copy"}): + for negate in (False, True): + with self.subTest(source=source, negate=negate): + leaf = {**source, "operator": "isNull"} + conditions = [leaf, leaf] + if negate: + conditions = {"not": [conditions]} + visits, diagnostics = [], [] + + def before(options): + if options.get("dependencyPurpose"): + visits.append((options.get("featureKey"), options.get("variableKey"), + options["dependencyPurpose"], options["dependencyOf"])) + return options + + f = self.instance(modules=[{"beforeEvaluation": before}], variables={ + "source": {"type": "json", "defaultValue": "{oops"}, + "root": {"type": "integer", "defaultValue": 7, "conditions": conditions}, + }, features={"source": feature(variablesSchema={ + "copy": {"type": "json", "defaultValue": "{oops"}})}) + f.on_diagnostic = diagnostics.append + f.set_log_level("error") + for run in range(2): + visits.clear() + evaluation = f.evaluate_variable("root") + self.assertEqual(evaluation["reason"], "conditions_unmet" if negate else "variable_default") + self.assertEqual(evaluation.get("variableValue"), None if negate else 7) + self.assertEqual(visits, [(source.get("feature"), source["variable"], "condition", + {"type": "variable", "variableKey": "root"})]) + failures = [item for item in diagnostics if item.get("message") == "getVariable failed"] + self.assertEqual(len(failures), run + 1) + self.assertEqual(failures[-1]["details"], {"variableKey": source["variable"], + **({"featureKey": source["feature"]} if "feature" in source else {})}) + + def test_getter_memos_separate_condition_and_composition_without_double_decoding(self): + visits, diagnostics = [], [] + + def before(options): + if options.get("dependencyPurpose"): + visits.append((options.get("variableKey"), options["dependencyPurpose"], options["dependencyOf"])) + return options + + f = self.instance(modules=[{"beforeEvaluation": before}], variables={ + "bad": {"type": "json", "defaultValue": "{oops"}, + "text": {"type": "json", "defaultValue": '"null"'}, + "alias": {"type": "json", "compose": "text"}, + "root": {"type": "object", "conditions": [ + {"variable": "bad", "operator": "isNull"}, {"variable": "bad", "operator": "isNull"}], + "compose": [{"key": "a", "variable": "bad"}, {"key": "b", "variable": "bad"}, + {"key": "text1", "variable": "alias"}, {"key": "text2", "variable": "alias"}]}, + }) + f.on_diagnostic = diagnostics.append + f.set_log_level("error") + self.assertEqual(f.get_variable("root"), {"a": None, "b": None, "text1": "null", "text2": "null"}) + root = {"type": "variable", "variableKey": "root"} + self.assertEqual(visits, [("bad", "condition", root), ("bad", "composition", root), + ("alias", "composition", root), + ("text", "composition", {"type": "variable", "variableKey": "alias"})]) + self.assertEqual(len([item for item in diagnostics if item.get("message") == "getVariable failed"]), 2) + + def test_not_never_inverts_variable_comparison_errors(self): + for leaf in ({"variable": "source", "operator": "matches", "value": "["}, + {"variable": "source", "operator": "isNull", "path": ["__proto__"]}): + with self.subTest(leaf=leaf): + f = self.instance(variables={"source": {"type": "string", "defaultValue": "text"}, + "root": {"type": "integer", "defaultValue": 7, + "conditions": {"not": [leaf]}}}, + features={"root": feature(conditions={"not": [leaf]})}) + self.assert_error(f.evaluate_variable("root"), variable_key="root") + self.assertIsNone(f.get_variable("root")) + self.assert_error(f.evaluate_flag("root"), feature_key="root") + self.assertFalse(f.is_enabled("root")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_review_gaps.py b/tests/test_review_gaps.py new file mode 100644 index 0000000..7c4494c --- /dev/null +++ b/tests/test_review_gaps.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import copy +import json +import sys +import unittest + +from featurevisor import create_featurevisor + + +def feature(**values): + return {"bucketBy": "userId", "traffic": [{"key": "all", "conditions": "*", "percentage": 100000}], **values} + + +def datafile(**values): + return {"schemaVersion": "4", "revision": "review", "segments": {}, "features": {}, "variables": {}, **values} + + +class ReviewGapTests(unittest.TestCase): + def test_segment_reads_use_datafile_accepted_by_previous_dependency_callback(self): + for global_root in (False, True): + with self.subTest(global_root=global_root): + gate = [{"variable": "mutator", "operator": "notNull"}, "audience"] + d = datafile(segments={"audience": {"conditions": {"or": []}}}, + features={"root": feature(conditions=gate), "probe": feature(conditions="audience")}, + variables={"root": {"type": "string", "conditions": gate, "defaultValue": "yes"}, + "mutator": {"type": "integer", "defaultValue": 1}}) + def after(evaluation, options): + if options.get("variableKey") == "mutator": + f.set_datafile(datafile(revision="updated", segments={"audience": {"conditions": "*"}})) + return evaluation + f = create_featurevisor({"datafile": d, "logLevel": "fatal", "modules": [{"afterEvaluation": after}]}) + self.addCleanup(f.close) + self.assertFalse(f.is_enabled("probe")) + self.assertEqual(f.get_variable("root") if global_root else f.is_enabled("root"), "yes" if global_root else True) + self.assertTrue(f.is_enabled("probe")) + + def test_module_add_remove_and_unsubscribe_invalidate_composition_reads(self): + for change in ("add", "remove", "unsubscribe"): + with self.subTest(change=change): + module = {"name": "value", "afterEvaluation": lambda result, options: ( + {**result, "variableValue": 2} if options.get("variableKey") == "leaf" else result)} + def before(options): + if options.get("variableKey") == "mutator": + if change == "add": + f.add_module(module) + elif change == "remove": + f.remove_module("value") + else: + remove() + return options + f = create_featurevisor({"logLevel": "fatal", "modules": [{"beforeEvaluation": before}], + "datafile": datafile(variables={"leaf": {"type": "integer", "defaultValue": 1}, + "mutator": {"type": "integer", "defaultValue": 0}, "root": {"type": "object", "compose": [ + {"key": "before", "variable": "leaf"}, {"key": "mutator", "variable": "mutator"}, + {"key": "after", "variable": "leaf"}]}})}) + self.addCleanup(f.close) + if change != "add": + remove = f.add_module(module) + self.assertEqual(f.get_variable("root"), {"before": 1 if change == "add" else 2, + "mutator": 0, "after": 2 if change == "add" else 1}) + + def test_terminal_segment_fast_predicates_preserve_the_active_depth_boundary(self): + country = {"attribute": "country", "operator": "equals", "value": "nl"} + for terminal in ("*", country, {"and": [country, {"not": [{**country, "value": "de"}]}]}): + for count in (256, 257, 258): + with self.subTest(terminal=terminal, count=count): + segments = {str(i): {"conditions": str(i + 1) if i + 1 < count else terminal} for i in range(count)} + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile( + features={"root": feature(conditions="0"), "independent": feature()}, segments=segments)}) + self.addCleanup(f.close) + allowed = count <= (256 if terminal == "*" else 257) + for _ in range(2): + evaluation = f.evaluate_flag("root", {"country": "nl"}) + self.assertEqual(evaluation["reason"], "rule" if allowed else "error") + self.assertEqual(f.is_enabled("root", {"country": "nl"}), allowed) + if allowed and terminal != "*": + self.assertEqual(f.evaluate_flag("root", {"country": "de"})["reason"], "conditions_unmet") + self.assertTrue(f.is_enabled("independent")) + + def test_mixed_getter_and_segment_depth_preserves_terminal_fast_predicates(self): + host_limit = sys.getrecursionlimit() + country = {"attribute": "country", "operator": "equals", "value": "nl"} + for getters in (1, 8): + for terminal in ("*", country): + for active_depth in (256, 257): + with self.subTest(getters=getters, terminal=terminal, active_depth=active_depth): + count = active_depth - getters + (0 if terminal == "*" else 1) + segments = {str(i): {"conditions": str(i + 1) if i + 1 < count else terminal} for i in range(count)} + variables = {f"v{i}": {"type": "integer", "compose": f"v{i + 1}"} for i in range(getters)} + variables[f"v{getters}"] = {"type": "integer", "conditions": "0", "defaultValue": 7} + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile(segments=segments, variables=variables)}) + self.addCleanup(f.close) + for _ in range(2): + self.assertEqual(f.evaluate_variable("v0", {"country": "nl"})["reason"], + "variable_composed" if active_depth == 256 else "error") + self.assertEqual(f.get_variable("v0", {"country": "nl"}), 7 if active_depth == 256 else None) + self.assertEqual(f.get_variable(f"v{getters}", {"country": "nl"}), 7) + self.assertEqual(sys.getrecursionlimit(), host_limit) + + def test_not_operands_and_explicit_null_gates_at_each_root_and_segment(self): + expressions = [(None, False), ({"not": None}, False), ({"not": "*"}, False), + ({"not": {}}, False), ({"not": {"0": None}}, False), + ({"not": {"attribute": "missing", "operator": "exists"}}, False), + ({"not": 1}, False), ({"not": False}, False), ({"not": []}, False), + ({"not": [None]}, True)] + for expression, expected in expressions: + for encoding in ("native", "selector", "datafile"): + with self.subTest(expression=expression, encoding=encoding): + selector = json.dumps(expression) if encoding == "selector" and expression is not None else expression + d = datafile(segments={"selected": {"conditions": selector}}, + features={"direct": feature(conditions=selector), "segment": feature(conditions="selected"), "absent": feature()}, + variables={"direct": {"type": "string", "conditions": selector, "defaultValue": "yes", "disabledValue": "no"}}) + diagnostics = [] + f = create_featurevisor({"datafile": json.dumps(d) if encoding == "datafile" else d, + "logLevel": "error", "onDiagnostic": diagnostics.append}) + self.addCleanup(f.close) + for _ in range(2): + self.assertEqual(f.is_enabled("direct"), expected) + self.assertEqual(f.is_enabled("segment"), expected) + if expression is None or isinstance(expression.get("not"), list): + self.assertEqual(f.get_variable("direct"), "yes" if expected else "no") + else: + self.assertNotEqual(f.get_variable("direct"), "yes") + self.assertTrue(f.is_enabled("absent")) + self.assertFalse([item for item in diagnostics if item["code"] == "conditions_parse_error"]) + if expression is None: + evaluation = f.evaluate_flag("direct") + self.assertEqual(evaluation["reason"], "conditions_unmet") + self.assertIn("conditions", evaluation) + self.assertIsNone(evaluation["conditions"]) + + def test_composition_gate_ignores_explicit_caller_and_module_defaults(self): + for alias in (False, True): + for disabled in ({}, {"disabledValue": None}, {"disabledValue": False}, {"disabledValue": 0}, {"disabledValue": "null"}): + with self.subTest(alias=alias, disabled=disabled): + seen = [] + def before(options): + seen.append(options["variableKey"]) + return {**options, "defaultVariableValue": "module"} + f = create_featurevisor({"logLevel": "fatal", "modules": [{"beforeEvaluation": before}], + "datafile": datafile(variables={"root": {"type": "json" if alias else "object", "conditions": None, + "compose": "source" if alias else [{"key": "source", "variable": "source"}], + "overrides": [{"conditions": "*", "value": "override"}], **disabled}, + "source": {"type": "integer", "defaultValue": 1}})}) + self.addCleanup(f.close) + for _ in range(2): + expected = disabled.get("disabledValue") + self.assertIs(type(f.get_variable("root", {}, {"defaultVariableValue": "caller"})), type(expected)) + self.assertEqual(f.get_variable("root", {}, {"defaultVariableValue": "caller"}), expected) + evaluation = f.evaluate_variable("root", {}, {"defaultVariableValue": "caller"}) + self.assertEqual(evaluation["reason"], "conditions_unmet") + self.assertEqual(evaluation["variableValue"], expected) + self.assertEqual(seen, ["root"] * 6) + + def test_module_sticky_replacements_survive_later_owner_setters(self): + def before(options): + if options.get("variableKey") == "root": + return {**options, "stickyVariables": {"source": 2}, + "stickyFeatures": {"source": {"enabled": True, "variables": {"value": 3}}}} + return options + def after(evaluation, options): + if options.get("variableKey") == "mutator": + f.set_sticky_variables({"source": 9}) + f.set_sticky_features({"source": {"enabled": True, "variables": {"value": 9}}}) + return evaluation + f = create_featurevisor({"logLevel": "fatal", "modules": [{"beforeEvaluation": before, "afterEvaluation": after}], + "datafile": datafile(features={"source": feature(variablesSchema={"value": {"type": "integer", "defaultValue": 1}})}, + variables={"source": {"type": "integer", "defaultValue": 1}, "mutator": {"type": "integer", "defaultValue": 0}, + "root": {"type": "object", "compose": [{"key": "before", "variable": "source"}, + {"key": "mutator", "variable": "mutator"}, {"key": "after", "variable": "source"}, + {"key": "feature", "feature": "source", "variable": "value"}]}})}) + self.addCleanup(f.close) + for _ in range(2): + self.assertEqual(f.get_variable("root"), {"before": 2, "mutator": 0, "after": 2, "feature": 3}) + self.assertEqual(f.get_variable("source"), 9) + self.assertEqual(f.get_variable("source", "value"), 9) + + def test_callback_setters_refresh_current_read_and_whole_composition_sticky(self): + for global_root in (False, True): + with self.subTest(global_root=global_root): + seen = [] + def before(options): + seen.append(options.get("variableKey") or options.get("featureKey")) + if options.get("variableKey") == "root" or options.get("featureKey") == "root": + if global_root: + f.set_sticky_variables({"root": "whole"}) + else: + f.set_sticky_features({"root": {"enabled": True, "variables": {"value": "whole"}}}) + return options + f = create_featurevisor({"logLevel": "fatal", "modules": [{"beforeEvaluation": before}], "datafile": datafile( + features={"root": feature(variablesSchema={"value": {"type": "string", "defaultValue": "old"}})}, + variables={"root": {"type": "object", "compose": [{"key": "unused", "variable": "unused"}]}})}) + self.addCleanup(f.close) + self.assertEqual(f.get_variable("root") if global_root else f.get_variable("root", "value"), "whole") + self.assertEqual(seen, ["root"] if global_root else ["value"]) + + def test_context_changes_and_independent_callback_reentry_keep_requester_and_memo(self): + seen = [] + inner = [] + def before(options): + if options.get("featureKey") == "source": + seen.append((options.get("dependencyOf"), options.get("dependencyPurpose"))) + if not options["context"].get("inner"): + inner.append(f.get_variable("source", "value", {"inner": True, "nested": {"value": 9}})) + return options + def after(evaluation, options): + if options.get("featureKey") == "source": + evaluation["variableValue"] = options["context"]["nested"]["value"] + if options.get("variableKey") == "mutator": + options["context"]["nested"]["value"] = 2 + return evaluation + leaf = {"feature": "source", "variable": "value"} + f = create_featurevisor({"logLevel": "fatal", "modules": [{"beforeEvaluation": before, "afterEvaluation": after}], + "datafile": datafile(features={"source": feature(variablesSchema={"value": {"type": "integer", "defaultValue": 0}})}, + variables={"mutator": {"type": "integer", "defaultValue": 0}, "root": {"type": "object", "compose": [ + {"key": "first", **leaf}, {"key": "duplicate", **leaf}, + {"key": "mutator", "variable": "mutator"}, {"key": "changed", **leaf}]}})}) + self.addCleanup(f.close) + for _ in range(2): + seen.clear() + inner.clear() + self.assertEqual(f.get_variable("root", {"nested": {"value": 1}}), {"first": 1, "duplicate": 1, "mutator": 0, "changed": 2}) + self.assertEqual(inner, [9, 9]) + self.assertEqual(seen, [({"type": "variable", "variableKey": "root"}, "composition"), (None, None)] * 2) + + def test_sources_keep_exact_ordinary_fallbacks_without_outer_defaults(self): + sources = {"null": None, "false": False, "zero": 0, "empty": "", "array": [], "object": {}} + variables = {key: {"type": "object", "defaultValue": value} for key, value in sources.items()} + variables.update({"json": {"type": "json", "defaultValue": '"null"'}, + "broken": {"type": "json", "defaultValue": '{'}, + "throws": {"type": "integer", "defaultValue": 1}, + "gated": {"type": "json", "compose": "throws", "conditions": {"or": []}, "disabledValue": "null"}}) + expected = {**sources, "json": "null", "broken": None, "throws": None, "missing": None, "gated": "null", + "flag": False, "variation": None, "featureVariable": None} + members = [{"key": key, "variable": key} for key in [*sources, "json", "broken", "throws", "missing", "gated"]] + members += [{"key": "flag", "feature": "absent"}, {"key": "variation", "feature": "absent", "variation": True}, + {"key": "featureVariable", "feature": "absent", "variable": "value"}] + variables["root"] = {"type": "object", "compose": members} + def before(options): + if options.get("variableKey") == "throws": + raise ValueError("ordinary module failure") + return options + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile(variables=variables), "modules": [{"beforeEvaluation": before}]}) + self.addCleanup(f.close) + for _ in range(2): + result = f.get_variable("root", {}, {"defaultVariableValue": "outer", "defaultVariationValue": "outer"}) + self.assertEqual(result, expected) + self.assertIs(result["false"], False) + self.assertIs(type(result["zero"]), int) + + def test_global_module_sticky_replacements_reach_composition_and_conditions(self): + for composed in (False, True): + with self.subTest(composed=composed): + root = {"type": "object", "conditions": [ + {"variable": "source", "operator": "equals", "value": 2}, + {"feature": "source", "variable": "value", "operator": "equals", "value": 3}, + ], "defaultValue": "matched"} + if composed: + root["compose"] = [{"key": "global", "variable": "source"}, + {"key": "feature", "feature": "source", "variable": "value"}] + def before(options): + if options.get("variableKey") == "root": + return {**options, "stickyVariables": {"source": 2}, + "stickyFeatures": {"source": {"enabled": True, "variables": {"value": 3}}}} + return options + f = create_featurevisor({"logLevel": "fatal", "modules": [{"beforeEvaluation": before}], + "datafile": datafile(features={"source": feature(variablesSchema={"value": {"type": "integer", "defaultValue": 1}})}, + variables={"source": {"type": "integer", "defaultValue": 1}, "root": root})}) + self.addCleanup(f.close) + for _ in range(2): + self.assertEqual(f.get_variable("root"), {"global": 2, "feature": 3} if composed else "matched") + self.assertEqual(f.get_variable("source"), 1) + self.assertEqual(f.get_variable("source", "value"), 1) + + def test_feature_sources_refresh_after_module_setters(self): + for state in ("datafile", "sticky"): + for child_mode in (False, True): + for composed in (False, True): + with self.subTest(state=state, child=child_mode, composed=composed): + leaf = {"feature": "source", "variable": "value"} + root = {"type": "object", "defaultValue": "matched", "conditions": [ + {**leaf, "operator": "equals", "value": 1}, + {"variable": "mutator", "operator": "equals", "value": 0}, + {**leaf, "operator": "equals", "value": 2}]} + if composed: + root = {"type": "object", "compose": [ + {"key": "before", **leaf}, {"key": "mutator", "variable": "mutator"}, + {"key": "after", **leaf}]} + d = datafile(features={"source": feature(variablesSchema={"value": {"type": "integer", "defaultValue": 1}})}, + variables={"mutator": {"type": "integer", "defaultValue": 0}, "root": root}) + reads = [] + def after(evaluation, options): + if options.get("variableKey") == "value": + reads.append(evaluation.get("variableValue")) + if options.get("variableKey") == "mutator": + if state == "sticky": + target.set_sticky_features({"source": {"enabled": True, "variables": {"value": 2}}}) + else: + update = copy.deepcopy(d) + update["features"]["source"]["variablesSchema"]["value"]["defaultValue"] = 2 + f.set_datafile(update, True) + return evaluation + f = create_featurevisor({"logLevel": "fatal", "datafile": d, "modules": [{"afterEvaluation": after}]}) + self.addCleanup(f.close) + target = f.spawn() if child_mode else f + if child_mode: + self.addCleanup(target.close) + self.assertEqual(target.get_variable("root"), {"before": 1, "mutator": 0, "after": 2} if composed else "matched") + self.assertEqual(reads, [1, 2]) diff --git a/tests/test_runtime_projects.py b/tests/test_runtime_projects.py new file mode 100644 index 0000000..e4c0e57 --- /dev/null +++ b/tests/test_runtime_projects.py @@ -0,0 +1,228 @@ +"""CLI project selection regressions, optionally against an already built Node CLI. + +Set FEATUREVISOR_NODE_CLI to its entry point to exercise real authored projects. +FEATUREVISOR_NATIVE_CLI can select another native runner for the same contract. +Neither mode installs dependencies or builds Node packages. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import unittest + + +class RuntimeProjectTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="featurevisor runtime ") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.calls = self.root / "calls.jsonl" + self.env = dict(os.environ, PATH=f"{self.root}{os.pathsep}{os.environ['PATH']}") + self.env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src") + self.runner = shlex.split(os.environ.get("FEATUREVISOR_NATIVE_CLI", "")) or [sys.executable, "-m", "featurevisor"] + node_cli = os.environ.get("FEATUREVISOR_NODE_CLI") + script = f'''#!{sys.executable} +import json, os, subprocess, sys +from pathlib import Path +args = sys.argv[2:] +with open({str(self.calls)!r}, "a", encoding="utf-8") as log: + log.write(json.dumps(args) + "\\n") +node_cli = {node_cli!r} +if node_cli: + sys.exit(subprocess.call(["node", node_cli, *args])) +config = json.loads(Path("config.json").read_text()) +selected = next((a.split("=", 1)[1] for a in args if a.startswith("--set=")), None) +if selected is not None and config.get("sets") is False: + sys.stderr.write("This project does not configure sets. Omit --set.") + sys.exit(3) +if selected and not Path("sets", selected).is_dir(): + sys.stderr.write("Unknown set: " + selected) + sys.exit(3) +if args[0] == "config": + value = config +elif args[0] == "list": + if "--tests" in args: + value = {{"kind": "featurevisor.test-execution", "schemaVersion": 1, "tests": [{{"key": "ready", "feature": "ready", "assertions": [{{"at": 50, "expectedToBeEnabled": True}}]}}]}} + else: + value = ["all", "web"] if "--targets" in args else [] +elif Path("fail-build").exists(): + sys.stderr.write("café build failed") + sys.exit(17) +else: + value = {{"schemaVersion": "2", "revision": "test", "segments": {{}}, "features": {{"ready": {{"bucketBy": "userId", "traffic": [{{"key": "everyone", "segments": "*", "percentage": 100000}}]}}}}, "variables": {{}}}} +print(json.dumps(value)) +''' + shim = self.root / "npx" + shim.write_text(script, encoding="utf-8") + shim.chmod(0o755) + + def project(self, kind: str) -> Path: + project = self.root / kind + project.mkdir() + config: dict = {} + if "env" in kind: + config["environments"] = ["production", "staging"] + if "sets" in kind: + config["sets"] = True + (project / "config.json").write_text(json.dumps(config)) + (project / "featurevisor.config.js").write_text("module.exports = " + json.dumps(config)) + roots = [project / "sets" / name for name in ("blue", "green")] if config.get("sets") else [project] + for root in roots: + for directory in ("features", "segments", "attributes", "variables", "targets"): + (root / directory).mkdir(parents=True, exist_ok=True) + (root / "attributes/userId.yml").write_text("description: User ID\ntype: string\n") + rules = " - key: everyone\n conditions: '*'\n percentage: 100\n" + feature = "description: Ready\nbucketBy: userId\nrules:\n" + if config.get("environments"): + feature += " production:\n" + "\n".join(" " + line for line in rules.splitlines()) + "\n staging:\n" + "\n".join(" " + line for line in rules.splitlines()) + "\n" + else: + feature += rules + (root / "features/ready.yml").write_text(feature) + for target in ("all", "web"): + (root / f"targets/{target}.yml").write_text("description: Everything\n") + return project + + def run_cli(self, command: str, project: Path, *args: str, success: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run([*self.runner, command, f"--projectDirectoryPath={project}", "--feature=ready", "--n=2", *args], env=self.env, capture_output=True, encoding="utf-8", timeout=30) + self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) + if success: + self.assertIn("Iterations: 2", result.stdout) + if command == "benchmark": + self.assertRegex(result.stdout, r"Evaluated value\s*: true") + else: + self.assertRegex(result.stdout, r"(?:enabled|1): 2") + return result + + def recorded(self) -> list[list[str]]: + return [json.loads(line) for line in self.calls.read_text().splitlines()] + + def test_flat_projects_and_repeated_targets(self) -> None: + project = self.project("flat") + for command in ("benchmark", "assess-distribution"): + self.run_cli(command, project) + output = self.run_cli(command, project, "--target=all", "--target=web").stdout + self.assertIn("Target: all", output) + self.assertIn("Target: web", output) + self.run_cli(command, project, "--target=missing", success=False) + self.assertFalse(any(arg.startswith("--environment=") for call in self.recorded() for arg in call)) + + def test_configured_environments_require_valid_selection(self) -> None: + project = self.project("env") + for command in ("benchmark", "assess-distribution"): + self.run_cli(command, project, success=False) + self.run_cli(command, project, "--environment=missing", success=False) + self.run_cli(command, project, "--environment=production", "--target=all") + + def test_flat_projects_reject_environment(self) -> None: + project = self.project("flat") + for command in ("benchmark", "assess-distribution"): + self.run_cli(command, project, "--environment=production", success=False) + self.run_cli(command, project, "--environment=", success=False) + + def test_sets_delegate_selected_set_and_targets(self) -> None: + project = self.project("sets") + for command in ("benchmark", "assess-distribution"): + result = self.run_cli(command, project, "--set=blue", "--target=all", "--target=web") + self.assertIn('Set "blue"', result.stdout) + self.run_cli(command, project, "--set=missing", success=False) + calls = self.recorded() + self.assertEqual(sum("--set=blue" in call for call in calls if call[0] == "build"), 4) + self.assertTrue(all(any(arg.startswith("--set=") for arg in call) for call in calls if call[0] in ("list", "build"))) + self.assertTrue(all(not any(arg.startswith("--set=") for arg in call) for call in calls if call[0] == "config")) + + def test_flat_projects_preserve_explicit_set_for_node_validation(self) -> None: + project = self.project("flat") + tests = project / "tests/features" + tests.mkdir(parents=True) + (tests / "ready.spec.yml").write_text("feature: ready\nassertions:\n - at: 50\n expectedToBeEnabled: true\n") + for name, contents in (("config.json", '{"sets":false}'), ("featurevisor.config.js", 'module.exports = {sets: false}')): + (project / name).write_text(contents) + for command in ("test", "benchmark", "assess-distribution"): + for options in ([], ["--set=blue"], ["--set="], ["--set=0"]): + with self.subTest(command=command, options=options): + before = len(self.recorded()) if self.calls.exists() else 0 + result = subprocess.run([*self.runner, command, f"--projectDirectoryPath={project}", "--feature=ready", "--n=2", *options], env=self.env, capture_output=True, encoding="utf-8", timeout=30) + self.assertEqual(result.returncode == 0, not options, result.stdout + result.stderr) + delegated = [call for call in self.recorded()[before:] if call[0] != "config"] + self.assertTrue(delegated) + for call in delegated: + self.assertEqual([arg for arg in call if arg.startswith("--set=")], options) + if options: + self.assertIn("Omit --set", result.stdout + result.stderr) + + def test_sets_without_selection_visit_every_set(self) -> None: + project = self.project("sets") + for command in ("benchmark", "assess-distribution"): + output = self.run_cli(command, project).stdout + self.assertIn('Set "blue"', output) + self.assertIn('Set "green"', output) + self.assertEqual(sum("--set=blue" in call for call in self.recorded() if call[0] == "build"), 2) + self.assertEqual(sum("--set=green" in call for call in self.recorded() if call[0] == "build"), 2) + + def test_sets_with_configured_environments(self) -> None: + project = self.project("sets-env") + for command in ("benchmark", "assess-distribution"): + self.run_cli(command, project, "--set=blue", success=False) + self.run_cli(command, project, "--set=blue", "--environment=production", "--target=web") + builds = [call for call in self.recorded() if call[0] == "build"] + self.assertEqual(len(builds), 2) + for call in builds: + self.assertIn("--set=blue", call) + self.assertIn("--environment=production", call) + self.assertIn("--target=web", call) + + def test_invalid_options_fail(self) -> None: + project = self.project("flat") + for command in ("benchmark", "assess-distribution"): + result = subprocess.run([*self.runner, command], env=self.env, capture_output=True, timeout=30) + self.assertNotEqual(result.returncode, 0) + for args in (("--n=0",), ("--context=[]",), ("--context=invalid",), ("--variation", "--variable=colour")): + # Override the default count rather than passing duplicate options. + if args == ("--n=0",): + result = subprocess.run([*self.runner, command, f"--projectDirectoryPath={project}", "--feature=ready", *args], env=self.env, capture_output=True, timeout=30) + self.assertNotEqual(result.returncode, 0) + else: + self.run_cli(command, project, *args, success=False) + + def test_node_failure_returns_unsuccessful_exit(self) -> None: + project = self.project("flat") + (project / "fail-build").touch() + if os.environ.get("FEATUREVISOR_NODE_CLI"): + (project / "features/ready.yml").write_text("rules: [invalid\n") + for command in ("benchmark", "assess-distribution"): + self.run_cli(command, project, success=False) + + def test_invalid_inflate_fails_before_node_delegation(self) -> None: + project = self.project("flat") + for command in ("test", "benchmark", "assess-distribution"): + for option in ("--inflate=0", "--inflate=-5", "--inflate=1.5", "--inflate=invalid", "--inflate=", "--inflate=1foo", "--inflate"): + with self.subTest(command=command, option=option): + result = subprocess.run([*self.runner, command, f"--projectDirectoryPath={project}", "--feature=ready", "--n=2", option], env=self.env, capture_output=True, encoding="utf-8", timeout=30) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("inflate", result.stdout + result.stderr) + self.assertFalse(self.calls.exists(), "Invalid inflation delegated to Node") + + def test_inflate_omission_and_positive_value_are_delegated(self) -> None: + project = self.project("flat") + tests = project / "tests/features" + tests.mkdir(parents=True) + (tests / "ready.spec.yml").write_text("feature: ready\nassertions:\n - at: 50\n expectedToBeEnabled: true\n") + for command in ("test", "benchmark", "assess-distribution"): + for options in ([], ["--inflate=1"]): + with self.subTest(command=command, options=options): + before = len(self.recorded()) if self.calls.exists() else 0 + result = subprocess.run([*self.runner, command, f"--projectDirectoryPath={project}", "--feature=ready", "--n=2", *options], env=self.env, capture_output=True, encoding="utf-8", timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + builds = [call for call in self.recorded()[before:] if call[0] == "build"] + self.assertTrue(builds) + for call in builds: + self.assertEqual([arg for arg in call if arg.startswith("--inflate=")], options) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sdk.py b/tests/test_sdk.py index d0a7172..15ab610 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -418,9 +418,9 @@ def test_child_instance_flow(self) -> None: def test_child_close_removes_delegated_subscriptions_and_empty_sticky_is_isolated(self) -> None: instance = create_featurevisor({ "logLevel": "fatal", - "sticky": {"test": {"enabled": True}}, + "stickyFeatures": {"test": {"enabled": True}}, }) - child = instance.spawn({}, {"sticky": {}}) + child = instance.spawn({}, {"stickyFeatures": {}}) delegated = [] child.on("datafile_set", lambda details: delegated.append(details)) diff --git a/tests/test_sdk_diagnostics.py b/tests/test_sdk_diagnostics.py new file mode 100644 index 0000000..3caa95c --- /dev/null +++ b/tests/test_sdk_diagnostics.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import copy +import json +import unittest +from pathlib import Path + +from featurevisor import create_featurevisor + + +class DiagnosticsConformanceTests(unittest.TestCase): + def test_shared_diagnostic_trace(self): + fixture = json.loads(Path("conformance/sdk-diagnostics.json").read_text()) + self.assertEqual(fixture["kind"], "featurevisor.sdk-diagnostics") + self.assertEqual(fixture["version"], 1) + self.assertTrue(fixture["cases"]) + for case in fixture["cases"] * 2: + captured = [] + reentries = [] + reentering = False + f = create_featurevisor({"logLevel": "error"}) + + def diagnostic(item): + nonlocal reentering + if item["code"] != "conditions_parse_error": + return + captured.append({"code": item["code"], "level": item["level"], + "details": copy.deepcopy(item["details"]), "revision": f.get_revision()}) + if item["details"]["entityType"] == "feature" and not reentering: + reentering = True + try: + start = len(captured) + f.is_enabled(item["details"]["entityKey"]) + reentries.append(len(captured) - start) + finally: + reentering = False + + f.on_diagnostic = diagnostic + try: + for index, operation in enumerate(case["operations"]): + with self.subTest(case=case["name"], operation=index): + start = len(captured) + if operation["action"] == "setDatafile": + result = f.set_datafile(operation["datafile"], operation["replace"]) + elif operation["action"] == "isEnabled": + result = f.is_enabled(operation["feature"]) + else: + self.fail(f"Unsupported action: {operation['action']}") + self.assertIs(result, operation["expectedResult"]) + self.assertCountEqual(captured[start:], operation["diagnostics"]) + self.assertTrue(reentries) + self.assertEqual(reentries, [0] * len(reentries)) + finally: + f.close() diff --git a/tests/test_sdk_stateful.py b/tests/test_sdk_stateful.py new file mode 100644 index 0000000..927cfa3 --- /dev/null +++ b/tests/test_sdk_stateful.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from typing import Any + +from featurevisor import create_featurevisor + + +class SDKStatefulConformanceTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.fixture = json.loads(Path("conformance/sdk-stateful.json").read_text(encoding="utf-8")) + + @staticmethod + def _normalize_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + normalized = [] + for event in events: + copy = {**event, "details": dict(event["details"])} + for key in ("features", "variables"): + if key in copy["details"]: + copy["details"][key] = sorted(copy["details"][key]) + normalized.append(copy) + return normalized + + def test_all_stateful_cases(self) -> None: + self.assertEqual(self.fixture["kind"], "featurevisor.sdk-stateful") + self.assertEqual(self.fixture["schemaVersion"], 1) + self.assertEqual(len(self.fixture["cases"]), 8) + + for case in self.fixture["cases"]: + with self.subTest(case=case["name"]): + diagnostics: list[dict[str, Any]] = [] + events: list[dict[str, Any]] = [] + targets: dict[str, Any] = { + "root": create_featurevisor({ + "datafile": case["initial"]["datafile"], + "context": case["initial"].get("context", {}), + "logLevel": "error", + "onDiagnostic": diagnostics.append, + }) + } + + def watch(target_name: str) -> None: + target = targets[target_name] + for event_name in ("datafile_set", "sticky_features_set", "sticky_variables_set"): + target.on( + event_name, + lambda details, name=target_name, event=event_name: events.append({ + "target": name, + "name": event, + "details": details, + }), + ) + + watch("root") + try: + for operation in case["operations"]: + events.clear() + diagnostics.clear() + error: Exception | None = None + try: + action = operation["action"] + if action == "setDatafile": + targets[operation["target"]].set_datafile( + operation["datafile"], operation["replace"] + ) + elif action == "spawn": + targets[operation["target"]] = targets[operation["parent"]].spawn( + operation.get("context", {}), operation.get("options", {}) + ) + watch(operation["target"]) + elif action == "setStickyFeatures": + targets[operation["target"]].set_sticky_features( + operation["stickyFeatures"], operation["replace"] + ) + elif action == "setStickyVariables": + targets[operation["target"]].set_sticky_variables( + operation["stickyVariables"], operation["replace"] + ) + else: + self.fail(f"Unsupported fixture action: {action}") + except Exception as caught: + error = caught + + expected = operation["expect"] + self.assertEqual( + self._normalize_events(events), + self._normalize_events(expected["events"]), + case["name"], + ) + if "error" in expected: + matching = [ + diagnostic for diagnostic in diagnostics + if diagnostic.get("code") == expected["error"]["code"] + ] + self.assertTrue(matching, case["name"]) + self.assertIsNone(error, case["name"]) + else: + self.assertIsNone(error, case["name"]) + + for result in expected["results"]: + target = targets[result["target"]] + if result["method"] == "getRevision": + actual = target.get_revision() + elif result["method"] == "isEnabled": + actual = target.is_enabled(*result["args"]) + elif result["method"] == "getVariable": + actual = target.get_variable(*result["args"]) + else: + self.fail(f'Unsupported fixture method: {result["method"]}') + self.assertEqual(actual, result["value"], case["name"]) + finally: + for name, target in reversed(list(targets.items())): + if name != "root": + target.close() + targets["root"].close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_semver_conformance.py b/tests/test_semver_conformance.py new file mode 100644 index 0000000..bca12c7 --- /dev/null +++ b/tests/test_semver_conformance.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from pathlib import Path +import unittest + +from featurevisor.compare_versions import compare_versions +from featurevisor.instance import create_featurevisor +from featurevisor.test_checks import evaluate_checks_result + + +class SemverConformanceTests(unittest.TestCase): + def test_portable_comparisons(self): + fixture = json.loads((Path(__file__).parent.parent / "conformance/semver.json").read_text()) + self.assertEqual((fixture["kind"], fixture["version"], len(fixture["cases"])), ("featurevisor.semver", 1, 44)) + operators = ["semverEquals", "semverNotEquals", "semverGreaterThan", "semverGreaterThanOrEquals", "semverLessThan", "semverLessThanOrEquals"] + for case in fixture["cases"]: + for reverse in [False, True]: + a, b = (case["right"], case["left"]) if reverse else (case["left"], case["right"]) + order = case["order"] + if reverse and order is not None: + order = -order + with self.subTest(case=case["name"], reverse=reverse): + if order is None: + with self.assertRaises(ValueError): + compare_versions(a, b) + else: + self.assertEqual(compare_versions(a, b), order) + for index, operator in enumerate(operators): + expected = False if order is None else [order == 0, order != 0, order > 0, order >= 0, order < 0, order <= 0][index] + with self.subTest(case=case["name"], reverse=reverse, operator=operator): + requirement = {"variable": "source", "operator": operator, "value": b} + traffic = [{"key": "all", "segments": "*", "percentage": 100000, "allocation": []}] + datafile = { + "schemaVersion": "2", "revision": "semver", + "segments": {"version": {"conditions": {"attribute": "version", "operator": operator, "value": b}}}, + "features": { + "condition": {"bucketBy": "userId", "traffic": [{**traffic[0], "segments": ["version"]}]}, + "sourceFeature": {"bucketBy": "userId", "traffic": traffic, "variablesSchema": {"source": {"type": "string", "defaultValue": a}}}, + }, + "variables": { + "source": {"type": "string", "defaultValue": a}, + "other": {"type": "string", "defaultValue": b}, + }, + } + for feature_source in [False, True]: + selected = {**requirement, **({"feature": "sourceFeature"} if feature_source else {})} + for negated in [False, True]: + subject = f"subject{feature_source}{negated}" + datafile["variables"][subject] = { + "type": "boolean", "defaultValue": True, "disabledValue": False, + "conditions": [{"not": [selected]}] if negated else [selected], + } + diagnostics = [] + f = create_featurevisor({"datafile": datafile, "logLevel": "fatal", "onDiagnostic": diagnostics.append}) + try: + self.assertEqual(f.is_enabled("condition", {"version": a}), expected) + for feature_source in [False, True]: + for negated in [False, True]: + subject = f"subject{feature_source}{negated}" + self.assertEqual(f.get_variable(subject), None if order is None else expected != negated) + if order is None: + self.assertEqual(f.evaluate_variable(subject)["reason"], "error") + selected = {**requirement, **({"feature": "sourceFeature"} if feature_source else {})} + for compare_to in [False, True]: + check = dict(selected) + if compare_to: + del check["value"] + check["compareTo"] = {"variable": "other"} + for negated in [False, True]: + checks = [{"not": [check]}] if negated else [check] + report = evaluate_checks_result(f, datafile, checks, owner_variable=None, diagnostics=diagnostics) + self.assertEqual(report["passed"], order is not None and expected != negated) + if order is None: + self.assertTrue(report["errors"]) + self.assertEqual(report["errors"][0]["type"], "check") + self.assertIn(report["errors"][0]["expected"], ["valid checks", "successful comparison"]) + finally: + f.close() diff --git a/tests/test_set_datafile_result.py b/tests/test_set_datafile_result.py new file mode 100644 index 0000000..0e350cd --- /dev/null +++ b/tests/test_set_datafile_result.py @@ -0,0 +1,138 @@ +import copy +import io +import json +import unittest +from contextlib import redirect_stdout + +from featurevisor import create_featurevisor + + +def datafile(key="original", revision="1"): + return { + "schemaVersion": "2", "revision": revision, + "segments": {key: {"conditions": "*"}}, + "features": {key: {"hash": key, "bucketBy": "userId", "traffic": [ + {"key": "all", "segments": "*", "percentage": 100000} + ]}}, + "variables": {key: {"hash": key, "type": "string", "defaultValue": key}}, + } + + +class SetDatafileResultTest(unittest.TestCase): + def test_accepts_empty_datafile_and_changed_content_with_the_same_revision(self): + sdk = create_featurevisor({"logLevel": "fatal"}) + self.addCleanup(sdk.close) + empty = {"schemaVersion": "2", "revision": "1", "segments": {}, "features": {}} + self.assertIs(sdk.set_datafile(empty), True) + self.assertIs(sdk.set_datafile(json.dumps(empty)), True) + self.assertIs(sdk.set_datafile(datafile(), True), True) + self.assertTrue(sdk.is_enabled("original")) + self.assertIs(sdk.set_datafile(empty, True), True) + self.assertEqual(sdk.get_feature_keys(), []) + self.assertEqual(sdk.get_variable_keys(), []) + self.assertEqual(sdk.get_revision(), "1") + + def test_accepts_native_and_json_merge_replacement_and_unchanged_for_both_names(self): + for method in ("set_datafile", "setDatafile"): + for encode in (lambda value: value, json.dumps): + for replace in (False, True): + with self.subTest(method=method, encode=encode, replace=replace): + sdk = create_featurevisor({"datafile": datafile(), "logLevel": "fatal"}) + self.addCleanup(sdk.close) + child = sdk.spawn() + self.addCleanup(child.close) + events = [] + sdk.on("datafile_set", events.append) + incoming = datafile("next", "2") + original = copy.deepcopy(incoming) + setter = getattr(sdk, method) + self.assertIs(setter(encode(incoming), True) if replace else setter(encode(incoming)), True) + self.assertEqual(incoming, original) + self.assertEqual(sdk.get_revision(), "2") + self.assertEqual(sdk.get_feature_keys(), ["next"] if replace else ["original", "next"]) + self.assertEqual(sdk.get_variable_keys(), ["next"] if replace else ["original", "next"]) + self.assertEqual(sdk.get_segment("original") is None, replace) + self.assertTrue(child.is_enabled("next")) + self.assertEqual(child.get_variable("next"), "next") + self.assertEqual(events[0], { + "revision": "2", "previousRevision": "1", "revisionChanged": True, + "features": ["original", "next"] if replace else ["next"], + "variables": ["original", "next"] if replace else ["next"], "replaced": replace, + }) + self.assertIs(setter(encode(incoming), replace), True) + self.assertEqual(len(events), 2) + self.assertEqual(events[1], { + "revision": "2", "previousRevision": "2", "revisionChanged": False, + "features": [], "variables": [], "replaced": replace, + }) + + def test_rejection_preserves_state_and_reports_once_at_each_log_level(self): + invalid = ["{", "null", "[]", None, False, 1, [], {}, + {**datafile(), "schemaVersion": 2}, {**datafile(), "revision": 2}, + {**datafile(), "segments": []}, {**datafile(), "features": []}, + {**datafile(), "variables": []}] + for level in ("info", "fatal"): + diagnostics, events, errors = [], [], [] + sdk = create_featurevisor({"datafile": datafile(), "logLevel": level, "onDiagnostic": diagnostics.append}) + self.addCleanup(sdk.close) + child = sdk.spawn() + self.addCleanup(child.close) + diagnostics.clear() + sdk.on("datafile_set", events.append) + sdk.on("error", errors.append) + for value in invalid: + for replace in (False, True): + with self.subTest(level=level, value=value, replace=replace): + self.assertIs(sdk.set_datafile(value, replace), False) + self.assertEqual(sdk.get_revision(), "1") + self.assertEqual(sdk.get_feature_keys(), ["original"]) + self.assertIsNotNone(sdk.get_segment("original")) + self.assertTrue(child.is_enabled("original")) + self.assertEqual(child.get_variable("original"), "original") + self.assertEqual(errors[-1]["diagnostic"]["code"], "invalid_datafile") + self.assertEqual(errors[-1]["diagnostic"]["message"], "Could not parse datafile") + self.assertIsInstance(errors[-1]["diagnostic"]["originalError"], Exception) + self.assertEqual(events, []) + self.assertEqual(len(errors), len(invalid) * 2) + self.assertEqual(len(diagnostics), len(invalid) * 2 if level == "info" else 0) + self.assertIs(sdk.set_datafile(datafile(), True), True) + + def test_closed_returns_false_without_parsing_diagnostics_or_events(self): + diagnostics = [] + sdk = create_featurevisor({"datafile": datafile(), "onDiagnostic": diagnostics.append}) + sdk.close() + diagnostics.clear() + for method in (sdk.set_datafile, sdk.setDatafile): + for value in (datafile("next", "2"), "{"): + self.assertIs(method(value), False) + self.assertEqual(sdk.get_revision(), "1") + self.assertEqual(diagnostics, []) + + def test_throwing_callbacks_do_not_change_acceptance_or_rejection(self): + diagnostics, events, errors = [], [], [] + + def fail(value): + raise RuntimeError("callback failed") + + def diagnostic(value): + diagnostics.append(value["code"]) + fail(value) + + with redirect_stdout(io.StringIO()): + sdk = create_featurevisor({ + "onDiagnostic": diagnostic, + "modules": [{"name": "throwing", "setup": lambda api: api["onDiagnostic"](fail)}], + }) + self.addCleanup(sdk.close) + diagnostics.clear() + sdk.on("datafile_set", fail) + sdk.on("datafile_set", events.append) + sdk.on("error", fail) + sdk.on("error", errors.append) + self.assertIs(sdk.set_datafile(datafile()), True) + self.assertIs(sdk.set_datafile(datafile()), True) + self.assertIs(sdk.set_datafile("{"), False) + self.assertEqual(diagnostics, ["datafile_set", "datafile_set", "invalid_datafile"]) + self.assertEqual(len(events), 2) + self.assertEqual(len(errors), 1) + self.assertEqual(sdk.get_revision(), "1") diff --git a/tests/test_v4_alignment.py b/tests/test_v4_alignment.py new file mode 100644 index 0000000..8384cc8 --- /dev/null +++ b/tests/test_v4_alignment.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +import copy +import json +import sys +import unittest +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import Mock, patch + +from featurevisor import create_featurevisor +from featurevisor.project import FeaturevisorProject +from featurevisor.tester import _run_test_project_set +from featurevisor.segment_tests import assert_segment_references + + +def feature(**values): + return {"bucketBy": "userId", "traffic": [{"key": "all", "conditions": "*", "percentage": 100000}], **values} + + +def datafile(**values): + return {"schemaVersion": "4", "revision": "alignment", "segments": {}, "features": {}, "variables": {}, **values} + + +class V4AlignmentTests(unittest.TestCase): + def test_malformed_selectors_all_sites_and_load_timing(self): + for schema in ("2", "4"): + for raw in ('{"not":[', '[{"feature":', '"unterminated', ' \n {"or": [oops] }'): + for site in ("feature", "variable", "segment", "force", "traffic", "ruleOverride", "variationOverride", "overrides", "overrideProgram"): + with self.subTest(schema=schema, site=site, raw=raw): + d = datafile(schemaVersion=schema, features={"healthy": feature()}, + variables={"healthy": {"type": "integer", "defaultValue": 9}}) + selected = feature(variablesSchema={"copy": {"type": "string", "defaultValue": "fallback"}}) + d["features"]["subject"] = selected + kind, key = "feature", "subject" + if site == "feature": + selected["conditions"] = raw + read = lambda f: f.evaluate_flag("subject")["reason"] + expected = "conditions_unmet" + elif site == "segment": + d["segments"]["bad"] = {"conditions": raw} + d["segments"]["alias"] = {"conditions": {"not": ["bad"]}} + selected["conditions"] = {"not": ["alias"]} + kind, key = "segment", "bad" + read = lambda f: f.evaluate_flag("subject")["reason"] + expected = "conditions_unmet" + elif site in ("variable", "overrides", "overrideProgram"): + variable = {"type": "string", "defaultValue": "fallback", "disabledValue": "disabled"} + d["variables"]["subject"] = variable + kind = "variable" + if site == "variable": + variable["conditions"] = raw + expected = "disabled" + elif site == "overrides": + variable[site] = [{"conditions": raw, "value": "bad"}] + expected = "fallback" + else: + variable[site] = [{"key": "bad", "conditions": raw, + "operations": [{"op": "set", "path": [], "value": "bad"}], + "onMatch": 1, "onMiss": 1}] + expected = "fallback" + read = lambda f: f.get_variable("subject") + else: + expected = "fallback" + read = lambda f: f.get_variable("subject", "copy") + if site == "force": + selected["force"] = [{"conditions": raw, "variables": {"copy": "bad"}}] + elif site == "traffic": + selected["traffic"].insert(0, {"key": "bad", "conditions": raw, "percentage": 100000, + "variables": {"copy": "bad"}}) + elif site == "ruleOverride": + selected["traffic"][0]["variableOverrides"] = {"copy": [{"conditions": raw, "value": "bad"}]} + else: + selected["variations"] = [{"value": "on", "variableOverrides": {"copy": [{"conditions": raw, "value": "bad"}]}}] + selected["traffic"][0]["variation"] = "on" + original = copy.deepcopy(d) + diagnostics = [] + f = create_featurevisor({"logLevel": "error", "onDiagnostic": diagnostics.append}) + self.addCleanup(f.close) + self.assertTrue(f.set_datafile(d, True)) + errors = lambda: [item for item in diagnostics if item["code"] == "conditions_parse_error"] + self.assertEqual(len(errors()), 1 if schema == "2" else 0) + self.assertTrue(f.is_enabled("healthy")) + self.assertEqual(f.get_variable("healthy"), 9) + for _ in range(2): + self.assertEqual(read(f), expected) + self.assertEqual(len(errors()), 1) + self.assertEqual(errors()[0]["details"], {"conditions": raw, "entityType": kind, "entityKey": key}) + self.assertFalse(any(item["code"] == "invalid_datafile" for item in diagnostics)) + f.set_datafile(datafile(revision="unrelated")) + self.assertEqual(read(f), expected) + self.assertEqual(len(errors()), 1) + f.set_datafile(d, True) + self.assertEqual(read(f), expected) + self.assertEqual(len(errors()), 2) + self.assertEqual(d, original) + + def test_diagnostics_reenter_cached_or_accepted_entities(self): + for schema in ("2", "4"): + for kind in ("feature", "segment", "variable"): + with self.subTest(schema=schema, kind=kind): + observations = [] + f = create_featurevisor({"logLevel": "error"}) + self.addCleanup(f.close) + d = datafile(schemaVersion=schema, revision="accepted") + if kind == "feature": + d["features"]["bad"] = feature(conditions="{") + read = lambda: f.evaluate_flag("bad")["reason"] + elif kind == "segment": + d["segments"]["bad"] = {"conditions": "{"} + d["features"]["bad"] = feature(conditions={"not": ["bad"]}) + read = lambda: f.evaluate_flag("bad")["reason"] + else: + d["variables"]["bad"] = {"type": "string", "conditions": "{"} + read = lambda: f.evaluate_variable("bad")["reason"] + + def diagnostic(item): + if item["code"] == "conditions_parse_error": + observations.append((f.get_revision(), read())) + + f.on_diagnostic = diagnostic + self.assertTrue(f.set_datafile(d)) + self.assertEqual(read(), "conditions_unmet") + self.assertEqual(observations, [("accepted", "conditions_unmet")]) + + def test_mixed_versions_preserve_unread_legacy_empty_selectors(self): + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile( + schemaVersion="2", segments={"empty": {"conditions": {"and": []}}}, + features={"legacy": feature(force=[{"conditions": [], "enabled": True}]), + "segment": feature(conditions="empty"), "newRoot": feature(conditions=[])})}) + self.addCleanup(f.close) + f.set_datafile(datafile(variables={"new": {"type": "integer", "defaultValue": 1}})) + self.assertEqual(f.get_schema_version(), "4") + self.assertTrue(f.is_enabled("legacy")) + self.assertTrue(f.is_enabled("segment")) + self.assertFalse(f.is_enabled("newRoot")) + f.set_datafile(datafile(schemaVersion="2", features={"mixed": feature(conditions=[], segments="*")})) + self.assertFalse(f.is_enabled("mixed")) + f.set_datafile(datafile(features={"legacy": feature(conditions=[])})) + self.assertFalse(f.is_enabled("legacy")) + + def test_public_conditions_shapes_match_normalization(self): + country = {"attribute": "country", "operator": "equals", "value": "nl"} + d = datafile(features={ + "mixed": feature(conditions=[country], segments=["missing"], requiredFeatures=["gate"]), + "empty": feature(conditions=[], segments="*"), + "broken": feature(conditions="{"), + }, variables={"broken": {"type": "object", "conditions": "{"}}) + f = create_featurevisor({"datafile": d, "logLevel": "fatal"}) + self.addCleanup(f.close) + self.assertEqual(f.evaluate_flag("mixed")["conditions"], + [country, "missing", {"feature": "gate"}]) + self.assertEqual(f.evaluate_flag("empty")["conditions"], [{"and": []}, "*"]) + self.assertEqual(json.loads(json.dumps(f.evaluate_flag("broken")["conditions"])), {"or": []}) + self.assertEqual(json.loads(json.dumps(f.evaluate_variable("broken")["conditions"])), {"or": []}) + + def test_removed_unreleased_selectors_are_ignored(self): + f = create_featurevisor({"logLevel": "fatal", "datafile": datafile( + features={"subject": feature(required=["missing"], requiredVariables=[{"variable": "missing", "operator": "notNull"}])}, + variables={"subject": {"type": "integer", "defaultValue": 7, + "requiredVariables": [{"variable": "missing", "operator": "notNull"}]}})}) + self.addCleanup(f.close) + self.assertTrue(f.is_enabled("subject")) + self.assertEqual(f.get_variable("subject"), 7) + + def test_composition_memo_preserves_json_once_and_borrowed_values(self): + value = {"items": list(range(10000))} + visits = [] + d = datafile(variables={ + "source": {"type": "object", "defaultValue": value}, + "text": {"type": "json", "defaultValue": '"null"'}, + "alias": {"type": "json", "compose": "text"}, + "root": {"type": "object", "compose": [{"key": "a", "variable": "source"}, + {"key": "b", "variable": "source"}, + {"key": "text1", "variable": "alias"}, + {"key": "text2", "variable": "alias"}]}, + }) + f = create_featurevisor({"logLevel": "fatal", "datafile": d, + "modules": [{"beforeEvaluation": lambda options: visits.append( + (options.get("variableKey"), options.get("dependencyPurpose"))) or options}]}) + self.addCleanup(f.close) + with patch("featurevisor._evaluation_session.copy.deepcopy", side_effect=AssertionError("composition copied")): + result = f.get_variable("root") + self.assertIs(result["a"], value) + self.assertIs(result["b"], value) + self.assertEqual((result["text1"], result["text2"]), ("null", "null")) + self.assertEqual(visits.count(("source", "composition")), 1) + self.assertEqual(visits.count(("alias", "composition")), 1) + + def test_purpose_separation_and_owned_condition_snapshots(self): + source = {"limit": 50} + d = datafile(variables={ + "source": {"type": "object", "defaultValue": source}, + "mutator": {"type": "boolean", "defaultValue": True}, + "root": {"type": "object", "conditions": [ + {"variable": "source", "path": ["limit"], "operator": "equals", "value": 50}, + {"variable": "mutator", "operator": "equals", "value": True}, + {"variable": "source", "path": ["limit"], "operator": "equals", "value": 50}], + "compose": [{"key": "first", "variable": "source"}, {"key": "last", "variable": "source"}]}, + }) + purposes = [] + retained = [] + + def before(options): + retained.append(options) + if options.get("variableKey") == "source": + purposes.append(options["dependencyPurpose"]) + if options.get("variableKey") == "mutator": + source["limit"] = 80 + return options + + f = create_featurevisor({"datafile": d, "logLevel": "fatal", "modules": [{"beforeEvaluation": before}]}) + self.addCleanup(f.close) + self.assertEqual(f.get_variable("root"), {"first": {"limit": 80}, "last": {"limit": 80}}) + self.assertEqual(purposes, ["condition", "composition"]) + for options in retained: + self.assertNotIn("_evaluationSession", options) + self.assertFalse(any(key.startswith("__featurevisor_") for key in options)) + + def test_composition_child_context_sticky_modules_and_immutable_overrides(self): + source = {"items": ["card"]} + original = copy.deepcopy(source) + seen = [] + d = datafile(variables={"source": {"type": "object", "defaultValue": source}, + "root": {"type": "object", "compose": [{"key": "a", "variable": "source"}, {"key": "b", "variable": "source"}], + "overrideProgram": [{"key": "add", "keyPath": ["add"], "conditions": "*", + "operations": [{"op": "append", "path": ["a", "items"], "value": "ideal"}], + "onMatch": 1, "onMiss": 1}]}}) + + def before(options): + if options.get("dependencyOf"): + seen.append((options["context"], options["dependencyPurpose"], options["dependencyOf"])) + else: + return {**options, "context": {"country": "effective"}} + return options + + f = create_featurevisor({"datafile": d, "context": {"stored": True}, "logLevel": "fatal", + "modules": [{"beforeEvaluation": before}]}) + self.addCleanup(f.close) + child = f.spawn({"country": "nl"}, {"stickyVariables": {"source": source}}) + self.addCleanup(child.close) + result = child.get_variable("root", {}, {"defaultVariableValue": "outer"}) + self.assertEqual(result, {"a": {"items": ["card", "ideal"]}, "b": {"items": ["card"]}}) + self.assertEqual(source, original) + self.assertEqual(seen, [({"country": "effective"}, "composition", {"type": "variable", "variableKey": "root"})]) + child.set_sticky_variables({"root": {"whole": True}}) + seen.clear() + self.assertEqual(child.get_variable("root"), {"whole": True}) + self.assertEqual(seen, []) + + def test_combined_depth_bound_cycles_and_recovery(self): + # Exercise the SDK's logical bound independently of the host stack budget. + previous_limit = sys.getrecursionlimit() + sys.setrecursionlimit(max(previous_limit, 8192)) + self.addCleanup(sys.setrecursionlimit, previous_limit) + for depth in (256, 257): + with self.subTest(depth=depth): + variables = {str(i): {"type": "integer", "compose": str(i + 1)} for i in range(depth)} + variables[str(depth)] = {"type": "integer", "defaultValue": 7} + f = create_featurevisor({"datafile": datafile(variables=variables), "logLevel": "fatal"}) + self.addCleanup(f.close) + evaluation = f.evaluate_variable("0") + self.assertEqual(evaluation["reason"], "variable_composed" if depth == 256 else "error") + self.assertEqual(f.get_variable("0"), 7 if depth == 256 else None) + self.assertEqual(f.get_variable(str(depth)), 7) + d = datafile(features={"subject": feature(conditions="s0")}, + segments={f"s{i}": {"conditions": f"s{i + 1}"} for i in range(200)}, + variables={str(i): {"type": "integer", "compose": str(i + 1)} for i in range(60)}) + d["segments"]["s200"] = {"conditions": {"variable": "0", "operator": "notNull"}} + d["variables"]["60"] = {"type": "integer", "defaultValue": 7} + f = create_featurevisor({"datafile": d, "logLevel": "fatal"}) + self.addCleanup(f.close) + self.assertEqual(f.evaluate_flag("subject")["reason"], "error") + f.set_datafile(datafile(segments={"s200": {"conditions": "*"}})) + self.assertTrue(f.is_enabled("subject")) + f.set_datafile(datafile(segments={"s200": {"conditions": {"not": [{"feature": "subject"}]}}})) + self.assertEqual(f.evaluate_flag("subject")["reason"], "error") + with ThreadPoolExecutor(max_workers=4) as pool: + self.assertEqual(list(pool.map(lambda _: f.get_variable("60"), range(20))), [7] * 20) + + def test_preserves_the_host_recursion_budget_and_recovers_after_exhaustion(self): + previous_limit = sys.getrecursionlimit() + sys.setrecursionlimit(1000) + self.addCleanup(sys.setrecursionlimit, previous_limit) + variables = {str(i): {"type": "integer", "compose": str(i + 1)} for i in range(256)} + variables["256"] = {"type": "integer", "defaultValue": 7} + f = create_featurevisor({"datafile": datafile(variables=variables), "logLevel": "fatal"}) + self.addCleanup(f.close) + with patch("sys.setrecursionlimit", side_effect=AssertionError("SDK changed host settings")): + self.assertEqual(f.evaluate_variable("0")["reason"], "error") + self.assertIsNone(f.get_variable("0")) + self.assertEqual(f.get_variable("256"), 7) + self.assertEqual(sys.getrecursionlimit(), 1000) + + def test_mixed_dependency_events_without_warming_lazy_entities(self): + d = datafile(segments={"sourceSegment": {"conditions": '{"variable":"source","operator":"notNull"}'}, + "aliasSegment": {"conditions": "sourceSegment"}, "bad": {"conditions": "{"}}, + features={"gate": feature(conditions="aliasSegment", hash="same")}, + variables={"source": {"type": "integer", "defaultValue": 1, "hash": "one"}, + "map": {"type": "object", "compose": [{"key": "enabled", "feature": "gate"}], "hash": "same"}, + "alias": {"type": "object", "compose": "map", "hash": "same"}}) + diagnostics = [] + f = create_featurevisor({"datafile": d, "logLevel": "error", "onDiagnostic": diagnostics.append}) + self.addCleanup(f.close) + self.assertEqual(f.datafile.entity_cache, {}) + events = [] + f.on("datafile_set", events.append) + f.set_datafile(datafile(variables={"source": {"type": "integer", "defaultValue": 2, "hash": "two"}})) + self.assertEqual(set(events[-1]["variables"]), {"source", "map", "alias"}) + self.assertEqual(events[-1]["features"], ["gate"]) + self.assertEqual(f.datafile.entity_cache, {}) + self.assertEqual(diagnostics, []) + replacement = copy.deepcopy(d) + del replacement["variables"]["source"] + f.set_datafile(replacement, True) + self.assertIn("alias", events[-1]["variables"]) + self.assertIn("gate", events[-1]["features"]) + + def test_sdk_backed_segment_owner_and_target_fallback(self): + project = Mock(spec=FeaturevisorProject) + project.set_name = None + project.get_config.return_value = {"environments": ["production"]} + project.list_targets.return_value = ["web"] + project.list_features.return_value = [] + project.list_segments.return_value = [] + fallback = {"audience": {"conditions": [{"variable": "limit", "path": ["value"], "operator": "equals", "value": 50}, + {"feature": "gate"}]}} + d = datafile(features={"gate": feature()}, variables={"limit": {"type": "object", "defaultValue": {"value": 50}}}) + target = {**d, "segments": {"audience": {"conditions": {"attribute": "country", "operator": "equals", "value": "de"}}}} + project.build_datafile_json.side_effect = lambda **kwargs: target if kwargs.get("target") else d + assertions = [{"environment": "production", "at": 0, "expectedToMatch": True}, + {"environment": "production", "target": "web", "context": {"country": "nl"}, "expectedToMatch": False}, + {"environment": "production", "stickyFeatures": {"gate": {"enabled": False}}, "expectedToMatch": False}] + project.list_tests.return_value = [{"key": "audience", "segment": "audience", "_segments": fallback, "assertions": assertions}] + result = _run_test_project_set(project, json_output=True)[0] + self.assertTrue(result["passed"], result) + self.assertEqual(len(result["cases"]), 3) + project.list_tests.return_value[0]["assertions"] = [{"expectedToMatch": False}] + with patch("featurevisor.tester.create_featurevisor") as create: + result = _run_test_project_set(project, json_output=True)[0] + create.assert_not_called() + self.assertFalse(result["passed"]) + + def test_segment_preflight_prefers_protocol_closure_over_target_specialization(self): + project = Mock(spec=FeaturevisorProject) + project.set_name = None + project.get_config.return_value = {"environments": False} + project.list_targets.return_value = [] + project.list_features.return_value = [] + project.list_segments.return_value = [] + project.build_datafile_json.return_value = datafile(segments={"audience": {"conditions": "*"}}) + project.list_tests.return_value = [{"segment": "audience", "key": "audience", + "_segments": {"audience": {"conditions": {"variable": "missing", "operator": "isNull"}}}, + "assertions": [{"expectedToMatch": True}]}] + with patch("featurevisor.tester.create_featurevisor") as create: + result = _run_test_project_set(project, json_output=True)[0] + create.assert_not_called() + self.assertFalse(result["passed"]) + self.assertIn('variable "missing"', result["cases"][0]["errors"][0]["message"]) + + def test_preflight_walks_only_the_selected_feature_variable_overrides(self): + d = datafile(features={"gate": feature(variablesSchema={"copy": {"type": "string", "defaultValue": "ok"}})}) + d["features"]["gate"]["traffic"][0]["variableOverrides"] = { + "copy": [{"conditions": {"variable": "missing", "operator": "notNull"}, "value": "bad"}]} + segments = {"flag": {"conditions": {"feature": "gate"}}, + "copy": {"conditions": {"feature": "gate", "variable": "copy", "operator": "notNull"}}} + assert_segment_references("flag", d, segments) + with self.assertRaisesRegex(ValueError, 'variable "missing"'): + assert_segment_references("copy", d, segments) diff --git a/tests/test_v4_capabilities.py b/tests/test_v4_capabilities.py new file mode 100644 index 0000000..1633c70 --- /dev/null +++ b/tests/test_v4_capabilities.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import copy +import json +import unittest +from pathlib import Path + +from featurevisor import create_featurevisor + + +def json_equal(actual, expected): + if expected is None or isinstance(expected, bool): + return actual is expected + if isinstance(expected, (int, float)): + return isinstance(actual, (int, float)) and not isinstance(actual, bool) and actual == expected + if isinstance(expected, dict): + return isinstance(actual, dict) and actual.keys() == expected.keys() and all( + json_equal(actual[key], value) for key, value in expected.items()) + if isinstance(expected, list): + return isinstance(actual, list) and len(actual) == len(expected) and all( + json_equal(left, right) for left, right in zip(actual, expected)) + return isinstance(expected, str) and isinstance(actual, str) and actual == expected + + +class V4Capabilities(unittest.TestCase): + def assert_json_equal(self, actual, expected): + self.assertTrue(json_equal(actual, expected), f"{actual!r} != {expected!r} (JSON types included)") + + def test_json_comparator_preserves_types_recursively(self): + for left, right in ((False, 0), (True, 1), (None, False), ({}, []), ([], ()), (1, "1"), + ({"x": [True]}, {"x": [1]}), ([None], [False]), ({"x": None}, {})): + with self.subTest(left=left, right=right): + self.assertFalse(json_equal(left, right)) + self.assertFalse(json_equal(right, left)) + for value in (None, False, True, 0, 1, "1", {}, [], {"x": [None, False, 1.0, {}]}): + self.assertTrue(json_equal(value, copy.deepcopy(value))) + self.assertTrue(json_equal({"x": [1.0, 0]}, {"x": [1, 0.0]})) + + def test_conditions_fixture(self): + fixture = json.loads(Path("conformance/sdk-conditions.json").read_text()) + self.assertEqual(fixture["capability"], "unified-conditions") + self.assertTrue(fixture["cases"]) + executed = [] + for case in fixture["cases"]: + dependencies = [] + + def before(options): + if options.get("dependencyPurpose"): + dependencies.append({**({"feature": options["featureKey"]} if options.get("featureKey") else {}), + **({"variable": options["variableKey"]} if options.get("variableKey") else {}), + "dependencyPurpose": options["dependencyPurpose"]}) + return options + + datafile = copy.deepcopy(case["datafile"]) + original = copy.deepcopy(datafile) + f = create_featurevisor({"datafile": datafile, "logLevel": "fatal", **case.get("options", {}), + "modules": [{"beforeEvaluation": before}]}) + try: + for run in range(2): + with self.subTest(case=case["name"], run=run): + dependencies.clear() + evaluation = (f.evaluate_flag(case["feature"], case["context"]) if case["type"] == "flag" + else f.evaluate_variable(case["variable"], case["context"])) + for key, value in case["expected"].items(): + if key != "value": + self.assertIn(key, evaluation) + self.assert_json_equal(evaluation[key], value) + if "dependencies" in case: + self.assert_json_equal(dependencies, case["dependencies"]) + if "value" in case["expected"]: + self.assert_json_equal(f.get_variable(case["variable"], case["context"]), case["expected"]["value"]) + if case["type"] == "flag": + self.assertIs(f.is_enabled(case["feature"], case["context"]), case["expected"].get("enabled") is True) + self.assert_json_equal(datafile, original) + finally: + f.close() + executed.append(case["name"]) + self.assertEqual(executed, [case["name"] for case in fixture["cases"]]) + + def test_composition_fixture(self): + fixture = json.loads(Path("conformance/sdk-composition.json").read_text()) + self.assertEqual(fixture["capability"], "composition-and-overrides") + self.assertTrue(fixture["cases"]) + executed = [] + for case in fixture["cases"]: + datafile = copy.deepcopy(fixture["datafile"]) + original = copy.deepcopy(datafile) + f = create_featurevisor({"datafile": datafile, "logLevel": "fatal", + "stickyFeatures": case.get("stickyFeatures"), + "stickyVariables": case.get("stickyVariables")}) + try: + for run in range(2): + with self.subTest(case=case["name"], run=run): + self.assert_json_equal(f.get_variable(case["variable"], case["context"]), case["expectedValue"]) + evaluation = f.evaluate_variable(case["variable"], case["context"]) + for key, value in case["expectedEvaluation"].items(): + self.assertIn(key, evaluation) + self.assert_json_equal(evaluation[key], value) + self.assert_json_equal(f.get_variable("policy"), None if case.get("stickyVariables", {}).get("policy", False) is None + else {"limit": 50, "methods": ["card"]}) + self.assert_json_equal(datafile, original) + finally: + f.close() + executed.append(case["name"]) + self.assertEqual(executed, [case["name"] for case in fixture["cases"]]) diff --git a/tests/test_v4_cases.py b/tests/test_v4_cases.py new file mode 100644 index 0000000..e5504e2 --- /dev/null +++ b/tests/test_v4_cases.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import copy +import json +import unittest +from pathlib import Path + +from featurevisor import create_featurevisor + + +class SDKV4PortableCases(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.fixture = json.loads(Path("conformance/sdk-v4-cases.json").read_text()) + + def test_all_portable_cases_twice(self) -> None: + self.assertEqual(self.fixture["version"], 1) + self.assertEqual(len(self.fixture["cases"]), 255) + for case in self.fixture["cases"]: + datafile = copy.deepcopy(case["datafile"]) + context = copy.deepcopy(case.get("context", {})) + options = copy.deepcopy(case.get("options", {})) + original_datafile = copy.deepcopy(datafile) + original_context = copy.deepcopy(context) + original_options = copy.deepcopy(options) + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal"}) + try: + for run in range(2): + with self.subTest(case=case["name"], run=run): + value = sdk.get_variable("subject", context, options) + evaluation = sdk.evaluate_variable("subject", context, options) + expected = case["expected"] + self.assertEqual(value, expected["value"]) + for key, expected_value in expected.items(): + if key == "value": + continue + self.assertEqual(evaluation.get(key), expected_value, key) + self.assertEqual(datafile, original_datafile) + self.assertEqual(context, original_context) + self.assertEqual(options, original_options) + finally: + sdk.close() + + def test_precomputed_and_runtime_programs_stay_equivalent_across_child_state_and_updates(self) -> None: + datafiles = { + case["name"]: case["datafile"] + for case in self.fixture["cases"] + if case["name"] in { + "first match precomputed stringified: child", + "first match runtime stringified: child", + } + } + self.assertEqual(len(datafiles), 2) + expected_child = { + "stage": "parent", + "rows": [{"id": 0, "tags": []}, {"id": 1, "tags": ["parent", "child"]}], + "flags": ["a"], + } + expected_parent = { + "stage": "parent", "rows": [{"id": 1, "tags": ["parent"]}], "flags": ["a"], + } + expected_reset = {"stage": "reset", "rows": [], "flags": []} + expected_default = { + "stage": "default", "rows": [{"id": 1, "tags": []}], "flags": [None, "a"], + } + + pairs = [ + ("first match precomputed stringified: child", "first match runtime stringified: child"), + ("first match runtime stringified: child", "first match precomputed stringified: child"), + ] + for initial, replacement in pairs: + with self.subTest(initial=initial): + root = create_featurevisor({ + "datafile": copy.deepcopy(datafiles[initial]), + "context": {"region": "eu", "eligible": True}, + "logLevel": "fatal", + }) + child = root.spawn({"country": "nl"}) + sticky_child = root.spawn( + {"country": "nl"}, {"stickyVariables": {"permission": False}} + ) + try: + self.assertEqual(child.get_variable("subject"), expected_child) + self.assertEqual(sticky_child.get_variable("subject"), expected_parent) + child.set_context({"reset": True}) + self.assertEqual(child.get_variable("subject"), expected_reset) + + root.set_datafile({ + "schemaVersion": "2", + "revision": "cache-update", + "segments": { + "europe": { + "conditions": '[{"attribute":"region","operator":"equals","value":"asia"}]' + } + }, + "features": {}, + "variables": {}, + }) + self.assertEqual(child.get_variable("subject"), expected_default) + + child.set_context({"reset": False}) + root.set_datafile(copy.deepcopy(datafiles[replacement]), True) + self.assertEqual(child.get_variable("subject"), expected_child) + finally: + sticky_child.close() + child.close() + root.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v4_runtime.py b/tests/test_v4_runtime.py new file mode 100644 index 0000000..49f9c5b --- /dev/null +++ b/tests/test_v4_runtime.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import json +import math +import unittest +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +from featurevisor import create_featurevisor +from featurevisor.conditions import MISSING, value_is_matched +from featurevisor.mutations import apply_variable_operations + + +def feature(**values): + return {"bucketBy": "userId", "traffic": [], **values} + + +class SDKV4RuntimeTests(unittest.TestCase): + def test_empty_operators_work_for_conditions_and_required_variables(self) -> None: + datafile = { + "schemaVersion": "2", "revision": "empty-operators", "segments": {}, + "features": { + "emptyContext": feature(force=[{ + "conditions": [{"attribute": "profile.items", "operator": "empty"}], + "enabled": True, + }]), + "nonemptyContext": feature(force=[{ + "conditions": [{"attribute": "profile.items", "operator": "notEmpty"}], + "enabled": True, + }]), + }, + "variables": { + "emptyArray": {"type": "array", "defaultValue": []}, + "nonemptyObject": {"type": "object", "defaultValue": {"value": None}}, + "noneValue": {"type": "json", "defaultValue": None}, + "emptyGate": { + "type": "boolean", "defaultValue": True, "disabledValue": False, + "conditions": [{"variable": "emptyArray", "operator": "empty"}], + }, + "nonemptyGate": { + "type": "boolean", "defaultValue": True, "disabledValue": False, + "conditions": [{"variable": "nonemptyObject", "operator": "notEmpty"}], + }, + "noneGate": { + "type": "boolean", "defaultValue": True, "disabledValue": False, + "conditions": [{"variable": "noneValue", "operator": "notEmpty"}], + }, + }, + } + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal"}) + self.assertTrue(sdk.is_enabled("emptyContext", {"profile": {"items": []}})) + self.assertTrue(sdk.is_enabled("nonemptyContext", {"profile": {"items": [None]}})) + self.assertTrue(sdk.get_variable("emptyGate")) + self.assertTrue(sdk.get_variable("nonemptyGate")) + self.assertFalse(sdk.get_variable("noneGate")) + + def test_empty_and_malformed_override_programs_do_not_fall_back(self) -> None: + datafile = { + "schemaVersion": "2", "revision": "programs", "segments": {}, "features": {}, + "variables": { + "subject": { + "type": "object", "defaultValue": {"default": True}, "overrideProgram": [], + "overrides": [{"key": "must-not-run", "segments": "*", "value": {"legacy": True}}], + }, + "broken": { + "type": "object", "defaultValue": {}, + "overrideProgram": [{"key": "bad", "segments": "*", "operations": [], "onMatch": 0, "onMiss": 1}], + }, + }, + } + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal"}) + self.assertEqual(sdk.get_variable("subject"), {"default": True}) + self.assertEqual(sdk.evaluate_variable("subject")["reason"], "variable_default") + self.assertIsNone(sdk.get_variable("broken")) + self.assertEqual(sdk.evaluate_variable("broken")["reason"], "error") + + def test_numeric_equality_never_coerces_booleans_and_missing_is_not_null(self) -> None: + get_regex = lambda pattern, _flags="": __import__("re").compile(pattern) + self.assertTrue(value_is_matched(1, {"operator": "equals", "value": 1.0}, get_regex)) + self.assertFalse(value_is_matched(True, {"operator": "equals", "value": 1}, get_regex)) + self.assertFalse(value_is_matched(MISSING, {"operator": "isNull"}, get_regex)) + self.assertFalse(value_is_matched(MISSING, {"operator": "notNull"}, get_regex)) + self.assertTrue(value_is_matched(None, {"operator": "isNull"}, get_regex)) + self.assertFalse(value_is_matched(None, {"operator": "notNull"}, get_regex)) + + selected = apply_variable_operations( + [{"id": True}, {"id": 1}], + [{"op": "set", "path": [{"select": {"property": "id", "values": [1.0]}}], "value": {"id": 2}}], + ) + self.assertEqual(selected, [{"id": True}, {"id": 2}]) + self.assertEqual( + apply_variable_operations([True, 1], [{"op": "removeValue", "path": [], "value": 1.0}]), + [True], + ) + + def test_safe_integral_numeric_path_components(self) -> None: + self.assertEqual( + apply_variable_operations( + ["zero", "one"], + [{"op": "set", "path": [1.0], "value": "changed"}], + ), + ["zero", "changed"], + ) + self.assertEqual( + apply_variable_operations( + ["zero"], + [{"op": "set", "path": [9_007_199_254_740_991.0], "value": "ignored"}], + ), + ["zero"], + ) + for part in (-1, 1.5, 9_007_199_254_740_992, 9_007_199_254_740_992.0, True, math.nan, math.inf): + with self.subTest(mutation_path=part): + with self.assertRaises(ValueError): + apply_variable_operations( + ["zero", "one"], + [{"op": "set", "path": [part], "value": "invalid"}], + ) + + datafile = { + "schemaVersion": "2", "revision": "numeric-paths", "segments": {}, "features": {}, + "variables": { + "source": {"type": "array", "defaultValue": ["zero", "one"]}, + "mutated": { + "type": "array", "defaultValue": ["zero", "one"], + "overrideProgram": [{ + "key": "raw", "segments": "*", + "operations": [{"op": "set", "path": [1.0], "value": "changed"}], + "onMatch": 1, "onMiss": 1, + }], + }, + "gate": { + "type": "string", "defaultValue": "matched", "disabledValue": "miss", + "conditions": [{ + "variable": "source", "path": [1.0], "operator": "equals", "value": "one", + }], + }, + }, + } + raw = json.dumps(datafile) + self.assertIn('"path": [1.0]', raw) + sdk = create_featurevisor({"datafile": raw, "logLevel": "fatal"}) + self.assertEqual(sdk.get_variable("mutated"), ["zero", "changed"]) + self.assertEqual(sdk.get_variable("gate"), "matched") + + for part in (-1, 1.5, 9_007_199_254_740_992, 9_007_199_254_740_992.0, True, math.nan, math.inf): + with self.subTest(requirement_path=part): + invalid = {**datafile, "variables": {**datafile["variables"]}} + invalid["variables"]["gate"] = { + **datafile["variables"]["gate"], + "conditions": [{ + "variable": "source", "path": [part], "operator": "equals", "value": "one", + }], + } + invalid_sdk = create_featurevisor({"datafile": invalid, "logLevel": "fatal"}) + self.assertEqual(invalid_sdk.evaluate_variable("gate")["reason"], "error") + + def test_json_boundaries_reject_non_finite_numbers_without_rejecting_native_values(self) -> None: + diagnostics = [] + sdk = create_featurevisor({ + "datafile": json.dumps({ + "schemaVersion": "2", "revision": "stable", "segments": {}, "features": {}, + "variables": {"healthy": {"type": "string", "defaultValue": "ok"}}, + }), + "logLevel": "debug", + "onDiagnostic": diagnostics.append, + }) + + for constant in ("NaN", "Infinity", "-Infinity"): + with self.subTest(raw_datafile_constant=constant): + sdk.set_datafile( + '{"schemaVersion":"2","revision":"invalid","segments":{},"features":{},' + '"variables":{"bad":{"type":"number","defaultValue":' + + constant + + '}}}', + True, + ) + self.assertEqual(sdk.get_revision(), "stable") + self.assertEqual(sdk.get_variable("healthy"), "ok") + + invalid_datafile_diagnostics = [ + item for item in diagnostics if item.get("code") == "invalid_datafile" + ] + self.assertEqual(len(invalid_datafile_diagnostics), 3) + + native_nan = math.nan + sdk.set_datafile({ + "schemaVersion": "2", "revision": "native", "segments": {}, "features": {}, + "variables": { + "nativeNumber": {"type": "double", "defaultValue": native_nan}, + "nativeJson": {"type": "json", "defaultValue": native_nan}, + "encodedJson": {"type": "json", "defaultValue": "NaN"}, + }, + }, True) + self.assertEqual(sdk.get_revision(), "native") + self.assertTrue(math.isnan(sdk.get_variable("nativeNumber"))) + self.assertTrue(math.isnan(sdk.get_variable("nativeJson"))) + self.assertIsNone(sdk.get_variable("encodedJson")) + + diagnostics.clear() + sdk.set_datafile({ + "schemaVersion": "2", "revision": "expressions", + "segments": { + "known": {"key": "known", "conditions": "*"}, + }, + "features": { + "condition": feature(force=[{ + "conditions": '{"attribute":"score","operator":"equals","value":NaN}', + "enabled": True, + }]), + "segment": feature(traffic=[{ + "key": "bad", "segments": '["known",Infinity]', "percentage": 100000, + }]), + }, + "variables": {}, + }, True) + self.assertFalse(sdk.is_enabled("condition", {"score": 1})) + self.assertEqual(sdk.evaluate_flag("segment")["reason"], "no_match") + codes = [item.get("code") for item in diagnostics] + self.assertIn("conditions_parse_error", codes) + self.assertNotIn("evaluation_error", codes) + + def test_accumulation_publishes_one_final_callback_with_metadata(self) -> None: + observed = [] + datafile = { + "schemaVersion": "2", + "revision": "callbacks", + "segments": {}, + "features": {}, + "variables": { + "subject": { + "type": "object", + "defaultValue": {"trace": []}, + "overrideProgram": [ + {"key": "parent", "keyPath": ["parent"], "segments": "*", "operations": [{"op": "append", "path": ["trace"], "value": "parent"}], "onMatch": 1, "onMiss": 2}, + {"key": "child", "keyPath": ["parent", "child"], "segments": "*", "operations": [{"op": "append", "path": ["trace"], "value": "child"}], "onMatch": 2, "onMiss": 2}, + ], + } + }, + } + sdk = create_featurevisor({ + "datafile": datafile, + "modules": [{ + "name": "observer", + "afterEvaluation": lambda evaluation, _options: observed.append( + (evaluation.get("variableValue"), evaluation.get("variableOverridePaths")) + ) or evaluation, + }], + "logLevel": "fatal", + }) + self.assertEqual(sdk.get_variable("subject"), {"trace": ["parent", "child"]}) + self.assertEqual(observed, [({"trace": ["parent", "child"]}, [["parent"], ["parent", "child"]])]) + self.assertEqual(datafile["variables"]["subject"]["defaultValue"], {"trace": []}) + + def test_child_sticky_sources_are_isolated_for_variable_requirements(self) -> None: + datafile = { + "schemaVersion": "2", "revision": "children", "segments": {}, + "features": { + "source": feature(variablesSchema={"plan": {"type": "string", "defaultValue": "free"}}), + "subject": feature( + conditions=[{"feature": "source", "variable": "plan", "operator": "equals", "value": "pro"}], + traffic=[{"key": "all", "segments": "*", "percentage": 100000}], + ), + }, + "variables": {}, + } + sdk = create_featurevisor({"datafile": datafile, "stickyFeatures": {"source": {"enabled": True, "variables": {"plan": "pro"}}}, "logLevel": "fatal"}) + child = sdk.spawn({}, {"stickyFeatures": {}}) + self.assertTrue(sdk.is_enabled("subject")) + self.assertFalse(child.is_enabled("subject")) + child.set_sticky_features({"source": {"enabled": True, "variables": {"plan": "pro"}}}) + self.assertTrue(child.is_enabled("subject")) + + def test_mixed_dependency_updates_reach_a_fixed_point(self) -> None: + initial = { + "schemaVersion": "2", "revision": "one", "segments": {}, + "features": { + "checkout": feature(conditions=[{"variable": "currency", "operator": "equals", "value": "EUR"}], hash="checkout"), + "receipt": feature(requiredFeatures=["checkout"], hash="receipt"), + }, + "variables": { + "currency": {"type": "string", "defaultValue": "EUR", "hash": "one"}, + "summary": {"type": "string", "defaultValue": "", "conditions": [{"feature": "receipt", "variable": "title", "operator": "equals", "value": "Receipt"}], "hash": "summary"}, + "final": {"type": "string", "defaultValue": "", "conditions": [{"variable": "summary", "operator": "equals", "value": ""}], "hash": "final"}, + }, + } + updated = {"schemaVersion": "2", "revision": "two", "segments": {}, "features": {}, "variables": {"currency": {"type": "string", "defaultValue": "USD", "hash": "two"}}} + events = [] + sdk = create_featurevisor({"datafile": initial, "logLevel": "fatal"}) + sdk.on("datafile_set", events.append) + sdk.set_datafile(updated) + self.assertEqual(events[-1]["features"], ["checkout", "receipt"]) + self.assertEqual(events[-1]["variables"], ["currency", "summary", "final"]) + + def test_required_variable_source_module_errors_fall_back_but_comparator_errors_do_not(self) -> None: + datafile = { + "schemaVersion": "2", "revision": "requirement-errors", "segments": {}, "features": {}, + "variables": { + "source": {"type": "object", "defaultValue": {"items": [None]}}, + "sourceString": {"type": "string", "defaultValue": "value"}, + "subject": { + "type": "string", "defaultValue": "matched", + "conditions": [{"variable": "source", "operator": "isNull"}], + }, + "badComparator": { + "type": "string", "defaultValue": "bad", + "conditions": [{"variable": "sourceString", "operator": "matches", "value": "["}], + }, + }, + } + sdk = create_featurevisor({ + "datafile": datafile, + "modules": [{ + "name": "throw-source-only", + "beforeEvaluation": lambda options: ( + (_ for _ in ()).throw(RuntimeError("source failed")) + if options.get("variableKey") == "source" + else options + ), + }], + "logLevel": "fatal", + }) + evaluation = sdk.evaluate_variable("subject") + self.assertEqual(evaluation["variableValue"], "matched") + self.assertEqual(evaluation["reason"], "variable_default") + + plain = create_featurevisor({"datafile": datafile, "logLevel": "fatal"}) + self.assertEqual(plain.evaluate_variable("badComparator")["reason"], "error") + + def test_variable_requirement_cycles_recover_and_thread_state_is_isolated(self) -> None: + cycle_sdk = create_featurevisor({"datafile": { + "schemaVersion": "2", "revision": "cycle", "segments": {}, "features": {}, + "variables": { + "a": {"type": "string", "defaultValue": "a", "conditions": [{"variable": "b", "operator": "equals", "value": "b"}]}, + "b": {"type": "string", "defaultValue": "b", "conditions": [{"variable": "a", "operator": "equals", "value": "a"}]}, + "healthy": {"type": "string", "defaultValue": "ok"}, + }, + }, "logLevel": "fatal"}) + self.assertEqual(cycle_sdk.evaluate_variable("a")["reason"], "error") + self.assertEqual(cycle_sdk.get_variable("healthy"), "ok") + + rendezvous = Barrier(2) + datafile = { + "schemaVersion": "2", "revision": "threads", "segments": {}, "features": {}, + "variables": { + "source": {"type": "string", "defaultValue": "none", "overrides": [ + {"key": "a", "conditions": [{"attribute": "worker", "operator": "equals", "value": "a"}], "value": "a"}, + {"key": "b", "conditions": [{"attribute": "worker", "operator": "equals", "value": "b"}], "value": "b"}, + ]}, + "subjectA": {"type": "boolean", "defaultValue": True, "conditions": [{"variable": "source", "operator": "equals", "value": "a"}]}, + "subjectB": {"type": "boolean", "defaultValue": True, "conditions": [{"variable": "source", "operator": "equals", "value": "b"}]}, + }, + } + sdk = create_featurevisor({ + "datafile": datafile, + "modules": [{ + "name": "rendezvous", + "beforeEvaluation": lambda options: ( + (rendezvous.wait(timeout=2), options)[1] + if options.get("variableKey") == "source" + else options + ), + }], + "logLevel": "fatal", + }) + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda item: sdk.get_variable(item[0], {"worker": item[1]}), [("subjectA", "a"), ("subjectB", "b")])) + self.assertEqual(results, [True, True]) + + def test_required_variable_paths_follow_javascript_own_array_properties(self) -> None: + datafile = { + "schemaVersion": "2", "revision": "requirement-paths", "segments": {}, "features": {}, + "variables": { + "array": {"type": "array", "defaultValue": ["first"]}, + "object": {"type": "object", "defaultValue": {"0": "object-first"}}, + "stringIndex": {"type": "boolean", "defaultValue": True, "conditions": [{"variable": "array", "path": ["0"], "operator": "equals", "value": "first"}]}, + "numericIndex": {"type": "boolean", "defaultValue": True, "conditions": [{"variable": "array", "path": [0], "operator": "equals", "value": "first"}]}, + "length": {"type": "boolean", "defaultValue": True, "conditions": [{"variable": "array", "path": ["length"], "operator": "equals", "value": 1.0}]}, + "numericObject": {"type": "boolean", "defaultValue": True, "conditions": [{"variable": "object", "path": [0], "operator": "notExists"}]}, + }, + } + sdk = create_featurevisor({"datafile": datafile, "logLevel": "fatal"}) + for key in ("stringIndex", "numericIndex", "length", "numericObject"): + with self.subTest(key=key): + self.assertTrue(sdk.get_variable(key)) + + def test_legacy_required_field_is_ignored(self) -> None: + sdk = create_featurevisor({"datafile": { + "schemaVersion": "2", "revision": "legacy", "segments": {}, + "features": { + "disabled": feature(force=[{"segments": "*", "enabled": False}]), + "subject": feature(required=["disabled"], traffic=[{"key": "all", "segments": "*", "percentage": 100000}]), + }, + }, "logLevel": "fatal"}) + self.assertTrue(sdk.is_enabled("subject")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_openfeature/test_provider.py b/tests_openfeature/test_provider.py index 495f5ec..fa74ce4 100644 --- a/tests_openfeature/test_provider.py +++ b/tests_openfeature/test_provider.py @@ -1,14 +1,19 @@ from __future__ import annotations import math +import json import unittest +from pathlib import Path from datetime import datetime, timedelta, timezone +from threading import Event from unittest.mock import patch from openfeature import api from openfeature.evaluation_context import EvaluationContext -from openfeature.exception import ErrorCode +from openfeature.exception import ErrorCode, ParseError +from openfeature.event import ProviderEvent from openfeature.flag_evaluation import Reason +from openfeature.track import TrackingEventDetails from featurevisor import create_featurevisor from featurevisor.openfeature import FeaturevisorOpenFeatureProvider @@ -84,6 +89,136 @@ def datafile(): class OpenFeatureProviderTest(unittest.TestCase): + def test_owned_provider_still_suppresses_default_console_diagnostics(self): + with patch("featurevisor.instance.write_diagnostic_to_console") as console: + provider = FeaturevisorOpenFeatureProvider({"datafile": "{"}) + self.assertEqual(provider.resolve_boolean_details("checkout", False).error_code, ErrorCode.PARSE_ERROR) + provider.featurevisor.set_datafile(datafile(), True) + provider.shutdown() + console.assert_not_called() + + def test_initial_health_is_independent_of_logging_and_preserves_options(self): + invalid = {"schemaVersion": "2", "revision": "x", "features": [], "segments": {}} + for level in ("info", "error", "fatal"): + for value in (invalid, json.dumps(invalid), "{", "null", {}, False, + {**datafile(), "variables": []}): + for callback_key in ("onDiagnostic", "on_diagnostic"): + with self.subTest(level=level, value=value, callback_key=callback_key): + diagnostics, revisions, closed = [], [], [] + callback = diagnostics.append + modules = [{"setup": lambda module: revisions.append(module["getRevision"]()), + "close": lambda: closed.append(True)}] + options = {"datafile": value, "logLevel": level, callback_key: callback, + "modules": modules, "context": {"country": "nl"}} + provider = FeaturevisorOpenFeatureProvider(options) + self.assertEqual(revisions, ["unknown"]) + self.assertIs(options[callback_key], callback) + self.assertIs(options["modules"], modules) + self.assertEqual(provider.resolve_boolean_details("checkout", True).error_code, ErrorCode.PARSE_ERROR) + with self.assertRaises(ParseError): + provider.initialize(EvaluationContext()) + self.assertEqual(sum(d["code"] == "invalid_datafile" for d in diagnostics), 0 if level == "fatal" else 1) + events = [] + provider.attach(lambda _, event, details: events.append((event, details.error_code))) + provider.featurevisor.set_datafile(datafile(), True) + provider.initialize(EvaluationContext()) + self.assertEqual(provider.resolve_string_details("variable:supportEmail", "").value, "nl@example.com") + provider.featurevisor.set_datafile(invalid, True) + self.assertEqual(events, [(ProviderEvent.PROVIDER_READY, None), + (ProviderEvent.PROVIDER_ERROR, ErrorCode.PARSE_ERROR)]) + provider.shutdown() + provider.shutdown() + self.assertEqual(closed, [True]) + + def test_invalid_owned_registration_fails_and_recovers_through_api(self): + provider = FeaturevisorOpenFeatureProvider({"datafile": {"schemaVersion": "2", "revision": "x", "features": [], "segments": {}}, "logLevel": "fatal"}) + with self.assertRaises(ParseError): + api.set_provider_and_wait(provider) + provider.featurevisor.set_datafile(datafile(), True) + self.assertTrue(api.get_client().get_boolean_value("checkout", False)) + + def test_client_preserves_explicit_empty_targeting_key(self): + fixture = json.loads((Path(__file__).resolve().parents[1] / "conformance/openfeature-provider.json").read_text()) + provider = FeaturevisorOpenFeatureProvider({"datafile": fixture["datafile"], "logLevel": "fatal"}) + api.set_provider_and_wait(provider) + client = api.get_client() + fields = {"userId": "forced"} + self.assertTrue(client.get_boolean_value("targetOnly", False, EvaluationContext(attributes=fields))) + self.assertTrue(client.get_boolean_value("targetOnly", False, EvaluationContext(targeting_key="", attributes=fields))) + self.assertFalse(client.get_boolean_value("targetOnly", False, EvaluationContext(attributes={"userId": ""}))) + self.assertFalse(client.get_boolean_value("targetOnly", False, EvaluationContext(targeting_key="other", attributes=fields))) + self.assertEqual(fields, {"userId": "forced"}) + custom = FeaturevisorOpenFeatureProvider({"datafile": fixture["datafile"], "logLevel": "fatal"}, targeting_key_field="accountId") + api.set_provider_and_wait(custom) + account = {"accountId": "old"} + self.assertFalse(api.get_client().get_boolean_value("accountTargeted", False, EvaluationContext(targeting_key="", attributes=account))) + self.assertTrue(api.get_client().get_boolean_value("accountTargeted", False, EvaluationContext(attributes={"accountId": ""}))) + self.assertEqual(account, {"accountId": "old"}) + + def test_shared_provider_conformance(self): + fixture = json.loads((Path(__file__).resolve().parents[1] / "conformance/openfeature-provider.json").read_text()) + self.assertEqual(fixture["kind"], "featurevisor.openfeature-provider") + self.assertEqual(fixture["schemaVersion"], 1) + self.assertEqual(len(fixture["cases"]), 16) + provider = FeaturevisorOpenFeatureProvider({"datafile": fixture["datafile"], "logLevel": "fatal"}) + resolvers = { + "boolean": provider.resolve_boolean_details, + "string": provider.resolve_string_details, + "object": provider.resolve_object_details, + } + api.set_provider_and_wait(provider) + client = api.get_client() + client_resolvers = {"boolean": client.get_boolean_details, + "string": client.get_string_details, + "object": client.get_object_details} + for case in fixture["cases"]: + with self.subTest(case=case["name"]): + source = json.loads(json.dumps(case["context"])) + context = EvaluationContext(targeting_key=source.pop("targetingKey", None), attributes=source) + result = resolvers[case["type"]](case["flagKey"], case["default"], context) + self.assertEqual(result.value, case["expected"]["value"]) + if "reason" in case["expected"]: + self.assertEqual(result.reason.value if hasattr(result.reason, "value") else result.reason, case["expected"]["reason"]) + self.assertEqual(result.error_code.value if result.error_code else None, case["expected"].get("errorCode")) + if "variant" in case["expected"]: + self.assertEqual(result.variant, case["expected"]["variant"]) + client_result = client_resolvers[case["type"]](case["flagKey"], case["default"], context) + self.assertEqual(client_result.value, case["expected"]["value"]) + self.assertEqual(client_result.error_code, result.error_code) + self.assertEqual(client_result.reason, result.reason) + self.assertEqual(client_result.variant, result.variant) + self.assertEqual(context.attributes, source) + for case in fixture["targetingCases"]: + with self.subTest(case=case["name"]): + source = json.loads(json.dumps(case["context"])) + fields = source.copy() + key = fields.pop("targetingKey", None) + context = EvaluationContext(targeting_key=key, attributes=fields) + result = provider.resolve_boolean_details("targetOnly", False, context) + self.assertEqual(result.value, case["expectedValue"]) + self.assertEqual(context.attributes, fields) + provider.shutdown() + custom = FeaturevisorOpenFeatureProvider({"datafile": fixture["datafile"], "logLevel": "fatal"}, targeting_key_field="accountId") + case = fixture["customTargetingCase"] + fields = case["context"].copy() + key = fields.pop("targetingKey") + result = custom.resolve_boolean_details(case["flagKey"], False, EvaluationContext(targeting_key=key, attributes=fields)) + self.assertEqual(result.value, case["expectedValue"]) + custom.shutdown() + + def test_borrowed_instance_parse_error_recovery_and_cleanup(self): + f = create_featurevisor({"datafile": datafile(), "logLevel": "fatal"}) + provider = FeaturevisorOpenFeatureProvider(featurevisor=f) + self.assertIs(f.set_datafile("{"), False) + self.assertEqual(provider.resolve_boolean_details("checkout", False).error_code, ErrorCode.PARSE_ERROR) + self.assertIs(f.set_datafile(datafile(), replace=True), True) + self.assertIsNone(provider.resolve_boolean_details("checkout", False).error_code) + provider.shutdown() + self.assertIs(f.set_datafile("{"), False) + self.assertIsNone(provider.resolve_boolean_details("checkout", False).error_code) + f.close() + self.assertIs(f.set_datafile(datafile()), False) + def tearDown(self) -> None: api.shutdown() @@ -126,7 +261,7 @@ def test_maps_targeting_key_dates_arrays_and_nested_context_without_mutation(sel "modules": [ { "name": "capture", - "before": lambda options: contexts.append(options["context"]) or options, + "beforeEvaluation": lambda options: contexts.append(options["context"]) or options, } ], }, @@ -259,10 +394,22 @@ def test_maps_disabled_evaluations(self): self.assertEqual(variation.value, "off") self.assertEqual(variation.reason, Reason.DISABLED) + def test_real_unmet_feature_requirement_maps_to_disabled(self): + content = datafile() + content["features"]["checkout"]["requiredFeatures"] = ["missing"] + f = create_featurevisor({"datafile": content, "logLevel": "fatal"}) + self.addCleanup(f.close) + provider = FeaturevisorOpenFeatureProvider(featurevisor=f) + self.addCleanup(provider.shutdown) + result = provider.resolve_boolean_details("checkout", True) + self.assertIs(result.value, False) + self.assertEqual(result.reason, Reason.DISABLED) + self.assertEqual(result.flag_metadata["featurevisorReason"], "conditions_unmet") + self.assertIsNone(result.error_code) + def test_maps_all_featurevisor_reasons(self): mappings = { - "required": Reason.TARGETING_MATCH, - "required_features_unmet": Reason.DISABLED, + "conditions_unmet": Reason.DISABLED, "forced": Reason.TARGETING_MATCH, "sticky": Reason.TARGETING_MATCH, "rule": Reason.TARGETING_MATCH, @@ -272,7 +419,6 @@ def test_maps_all_featurevisor_reasons(self): "disabled": Reason.DISABLED, "variation_disabled": Reason.DISABLED, "variable_disabled": Reason.DISABLED, - "out_of_range": Reason.DEFAULT, "no_match": Reason.DEFAULT, "variable_default": Reason.DEFAULT, } @@ -406,6 +552,65 @@ def test_works_through_openfeature_api(self): client = api.get_client() self.assertTrue(client.get_boolean_value("checkout", False, EvaluationContext(targeting_key="user"))) + def test_client_resolution_tracking_updates_and_shutdown(self): + for borrowed in (False, True): + with self.subTest(borrowed=borrowed): + tracked, closed, events = [], [], [] + error_received, ready_received = Event(), Event() + options = {"datafile": datafile(), "logLevel": "fatal", + "modules": [{"close": lambda: closed.append(True)}]} + f = create_featurevisor(options) if borrowed else None + provider = FeaturevisorOpenFeatureProvider( + options, featurevisor=f, + on_track=lambda *args: tracked.append(args), + ) + try: + api.set_provider_and_wait(provider) + client = api.get_client() + client.add_handler(ProviderEvent.PROVIDER_ERROR, lambda _: (events.append("error"), error_received.set())) + client.add_handler(ProviderEvent.PROVIDER_READY, lambda _: (events.append("ready"), ready_received.set())) + self.assertTrue(ready_received.wait(2)) + context = EvaluationContext(targeting_key="user", attributes={"country": "nl"}) + self.assertEqual(client.get_string_value("variable:supportEmail", "", context), "nl@example.com") + self.assertEqual(client.get_integer_value("checkout:count", 0, context), 3) + self.assertEqual(client.get_float_value("checkout:ratio", 0.0, context), 1.5) + self.assertEqual(client.get_object_value("checkout:json", {}, context), {"nested": True}) + missing = client.get_boolean_details("missing", True) + self.assertTrue(missing.value) + self.assertEqual(missing.error_code, ErrorCode.FLAG_NOT_FOUND) + mismatch = client.get_integer_details("checkout:ratio", 9) + self.assertEqual(mismatch.value, 9) + self.assertEqual(mismatch.error_code, ErrorCode.TYPE_MISMATCH) + details = TrackingEventDetails(3, {"currency": "EUR"}) + client.track("purchase", context, details) + self.assertEqual(tracked[0][0], "purchase") + self.assertEqual(tracked[0][1].targeting_key, "user") + self.assertIs(tracked[0][2], details) + updated = datafile() + updated["features"]["checkout"]["variations"][0]["variables"]["title"] = "Updated" + self.assertTrue(provider.featurevisor.set_datafile(updated, True)) + self.assertEqual(client.get_string_value("checkout:title", ""), "Updated") + self.assertFalse(provider.featurevisor.set_datafile("{")) + self.assertTrue(error_received.wait(2)) + self.assertEqual(client.get_boolean_details("checkout", False).reason, Reason.ERROR) + ready_received.clear() + self.assertTrue(provider.featurevisor.set_datafile(updated, True)) + self.assertTrue(ready_received.wait(2)) + self.assertEqual(client.get_string_value("checkout:title", ""), "Updated") + self.assertEqual(events[-2:], ["error", "ready"]) + api.shutdown() + provider.shutdown() + self.assertEqual(closed, [] if borrowed else [True]) + if borrowed: + self.assertFalse(f.set_datafile("{")) + self.assertIsNone(provider.datafile_error) + finally: + api.shutdown() + provider.shutdown() + if f is not None: + f.close() + self.assertEqual(closed, [True]) + if __name__ == "__main__": unittest.main() diff --git a/tests_openfeature/test_resource_errors.py b/tests_openfeature/test_resource_errors.py new file mode 100644 index 0000000..b5da75e --- /dev/null +++ b/tests_openfeature/test_resource_errors.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import sys +import unittest +from unittest.mock import patch + +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import Reason + +from featurevisor import create_featurevisor +from featurevisor.openfeature import FeaturevisorOpenFeatureProvider + + +class ProviderResourceErrorTests(unittest.TestCase): + def setUp(self): + previous = sys.getrecursionlimit() + sys.setrecursionlimit(1000) + self.addCleanup(sys.setrecursionlimit, previous) + setter = patch("sys.setrecursionlimit", side_effect=AssertionError("SDK changed the host budget")) + setter.start() + self.addCleanup(setter.stop) + + def provider(self): + variables = {str(i): {"type": "integer", "compose": str(i + 1)} for i in range(256)} + variables.update({ + "256": {"type": "integer", "defaultValue": 7}, + "map": {"type": "object", "compose": [{"key": "good", "variable": "256"}, + {"key": "bad", "variable": "0"}]}, + "json": {"type": "json", "defaultValue": "[" * 10000 + "0" + "]" * 10000}, + }) + f = create_featurevisor({"logLevel": "fatal", "datafile": { + "schemaVersion": "4", "revision": "resource", "segments": {}, "variables": variables, + "features": {"root": { + "bucketBy": "userId", "conditions": {"not": [{"variable": "0", "operator": "notNull"}]}, + "traffic": [{"key": "all", "conditions": "*", "percentage": 100000, "variation": "on"}], + "variations": [{"value": "on"}], + "variablesSchema": {"copy": {"type": "integer", "defaultValue": 7}}, + }, "json": { + "bucketBy": "userId", "traffic": [{"key": "all", "conditions": "*", "percentage": 100000}], + "variablesSchema": {"copy": variables["json"]}, + }}, + }}) + self.addCleanup(f.close) + provider = FeaturevisorOpenFeatureProvider(featurevisor=f) + self.addCleanup(provider.shutdown) + return provider + + def assert_general(self, details, fallback): + self.assertEqual(details.value, fallback) + self.assertEqual(details.reason, Reason.ERROR) + self.assertEqual(details.error_code, ErrorCode.GENERAL) + self.assertEqual(details.flag_metadata["featurevisorReason"], "error") + self.assertTrue(details.error_message) + self.assertEqual(sys.getrecursionlimit(), 1000) + + def test_dependency_exhaustion_returns_general_and_preserves_later_calls(self): + provider = self.provider() + for resolve, key, fallback in ((provider.resolve_integer_details, "variable:0", 99), + (provider.resolve_object_details, "variable:map", {"fallback": True}), + (provider.resolve_boolean_details, "root", True), + (provider.resolve_string_details, "root:variation", "fallback"), + (provider.resolve_integer_details, "root:copy", 99)): + with self.subTest(key=key): + self.assert_general(resolve(key, fallback), fallback) + healthy = provider.resolve_integer_details("variable:256", 99) + self.assertEqual(healthy.value, 7) + self.assertIsNone(healthy.error_code) + + def test_json_resource_errors_return_general_for_both_variable_kinds(self): + provider = self.provider() + for key in ("variable:json", "json:copy"): + with self.subTest(key=key): + self.assert_general(provider.resolve_object_details(key, {"fallback": True}), {"fallback": True}) + self.assertEqual(provider.resolve_integer_details("variable:256", 99).value, 7) + + +if __name__ == "__main__": + unittest.main() From 3552e2977c70fe92a0df712f629d52f6a3808d18 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Mon, 19 Oct 2026 00:43:10 +0200 Subject: [PATCH 2/2] fix: behave the same on Python 3.10 and 3.14 --- src/featurevisor/conditions.py | 18 ++++++++---- src/featurevisor/evaluation_data_provider.py | 2 ++ src/featurevisor/normalize.py | 3 ++ tests/test_resource_errors.py | 30 +++++++++++++++++++- tests_openfeature/test_resource_errors.py | 14 ++++++++- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/featurevisor/conditions.py b/src/featurevisor/conditions.py index f4a3785..6aa6775 100644 --- a/src/featurevisor/conditions.py +++ b/src/featurevisor/conditions.py @@ -9,7 +9,7 @@ from .types import AttributeValue, Context MISSING = object() -PORTABLE_TIMESTAMP = re.compile(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})$") +PORTABLE_TIMESTAMP = re.compile(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}))$") MIN_PORTABLE_DATE = dt.datetime(1, 1, 1, tzinfo=dt.timezone.utc) MAX_PORTABLE_DATE = dt.datetime(9999, 12, 31, 23, 59, 59, 999000, tzinfo=dt.timezone.utc) @@ -113,12 +113,18 @@ def _to_datetime(value: Any) -> dt.datetime | None: if native_offset is None or abs(native_offset) > dt.timedelta(hours=14): return None parsed = value - elif isinstance(value, str) and PORTABLE_TIMESTAMP.fullmatch(value): - offset = value[-6:] if value[-1] != "Z" else "+00:00" - if offset != "+00:00" and (int(offset[1:3]) > 14 or int(offset[4:]) > 59 or (offset[1:3] == "14" and offset[4:] != "00")): - return None + elif isinstance(value, str) and (parts := PORTABLE_TIMESTAMP.fullmatch(value)): + year, month, day, hour, minute, second, fraction, sign, offset_hours, offset_minutes = parts.groups() + offset = dt.timedelta() + if sign is not None: + if int(offset_hours) > 14 or int(offset_minutes) > 59 or (offset_hours == "14" and offset_minutes != "00"): + return None + offset = dt.timedelta(hours=int(offset_hours), minutes=int(offset_minutes)) * (-1 if sign == "-" else 1) + # The constructor rejects hour 24, leap seconds and impossible dates on every Python + # version. fromisoformat does not: 3.10 rejects most fractions and 3.14 accepts 24:00. try: - parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + parsed = dt.datetime(int(year), int(month), int(day), int(hour), int(minute), int(second), + int((fraction or "")[:6].ljust(6, "0")), tzinfo=dt.timezone(offset)) except ValueError: return None else: diff --git a/src/featurevisor/evaluation_data_provider.py b/src/featurevisor/evaluation_data_provider.py index 6953cef..ddadb57 100644 --- a/src/featurevisor/evaluation_data_provider.py +++ b/src/featurevisor/evaluation_data_provider.py @@ -307,6 +307,8 @@ def _parse_conditions_if_stringified( self.prepared_expression_cache[conditions] = prepared return prepared return parsed + except RecursionError: + raise except Exception as exc: if raise_errors: raise diff --git a/src/featurevisor/normalize.py b/src/featurevisor/normalize.py index 7ff761b..0dffcd2 100644 --- a/src/featurevisor/normalize.py +++ b/src/featurevisor/normalize.py @@ -38,6 +38,9 @@ def parse(expression): return expression try: return strict_json_loads(expression) + except RecursionError: + # Host exhaustion fails the current call and must not cache the entity as invalid. + raise except Exception as exc: errors.append({"level": "error", "code": "conditions_parse_error", "message": "Error parsing conditions", "originalError": exc, diff --git a/tests/test_resource_errors.py b/tests/test_resource_errors.py index e8e8cd0..4294b8c 100644 --- a/tests/test_resource_errors.py +++ b/tests/test_resource_errors.py @@ -6,6 +6,7 @@ from unittest.mock import patch from featurevisor import create_featurevisor +from featurevisor._json import strict_json_loads from featurevisor._evaluation_session import EvaluationSession, RequirementInputError from featurevisor._evaluation_session import dependency_of @@ -23,6 +24,16 @@ def aliases(): "256": {"type": "integer", "defaultValue": 7}} +DEEP_JSON = "[" * 10000 + "0" + "]" * 10000 + + +def exhausted_on_deep_json(source): + # Python 3.12 and later can decode this depth, so the decoder's exhaustion is simulated. + if source == DEEP_JSON: + raise RecursionError("maximum recursion depth exceeded while decoding a JSON array") + return strict_json_loads(source) + + class ResourceErrorTests(unittest.TestCase): def setUp(self): previous = sys.getrecursionlimit() @@ -120,11 +131,12 @@ def test_mixed_segments_features_and_variables_under_not_cleanup_and_recover(sel self.assertTrue(f.is_enabled("healthy", {}, options)) self.assert_clean(session) + @patch("featurevisor.instance.strict_json_loads", exhausted_on_deep_json) def test_json_decoder_exhaustion_is_not_an_ordinary_null_dependency(self): # The source getter still has ordinary JSON parsing semantics. Once used # as a dependency, host exhaustion must abort its entire requester. f = self.instance(variables={ - "source": {"type": "json", "defaultValue": "[" * 10000 + "0" + "]" * 10000}, + "source": {"type": "json", "defaultValue": DEEP_JSON}, "alias": {"type": "json", "compose": "source"}, "map": {"type": "object", "compose": [{"key": "good", "variable": "healthy"}, {"key": "bad", "variable": "source"}]}, @@ -140,6 +152,22 @@ def test_json_decoder_exhaustion_is_not_an_ordinary_null_dependency(self): self.assertFalse(f.is_enabled("root")) self.assertEqual(f.get_variable("healthy"), 7) + def test_condition_parse_exhaustion_is_not_cached_as_invalid_conditions(self): + # A feature first read near the host limit must still match once the stack is shallow. + errors = [] + f = create_featurevisor({"logLevel": "error", "datafile": { + "schemaVersion": "4", "revision": "parse", "segments": {}, "features": {"leaf": { + "bucketBy": "userId", "traffic": [{"key": "all", "percentage": 100000, + "conditions": '[{"attribute": "userId", "operator": "exists"}]'}], + }}, + }, "onDiagnostic": lambda diagnostic: errors.append(diagnostic["code"]) if diagnostic["level"] == "error" else None}) + self.addCleanup(f.close) + with patch("featurevisor.normalize.strict_json_loads", side_effect=RecursionError), \ + patch("featurevisor.evaluation_data_provider.strict_json_loads", side_effect=RecursionError): + self.assertFalse(f.is_enabled("leaf", {"userId": "u"})) + self.assertTrue(f.is_enabled("leaf", {"userId": "u"})) + self.assertEqual(errors, ["evaluation_error"]) + def test_feature_flag_exhaustion_cannot_continue_into_variation_or_variable(self): f = self.instance(features={"source": feature()}) original = f.datafile.conditions_are_matched diff --git a/tests_openfeature/test_resource_errors.py b/tests_openfeature/test_resource_errors.py index b5da75e..0d44780 100644 --- a/tests_openfeature/test_resource_errors.py +++ b/tests_openfeature/test_resource_errors.py @@ -8,9 +8,20 @@ from openfeature.flag_evaluation import Reason from featurevisor import create_featurevisor +from featurevisor._json import strict_json_loads from featurevisor.openfeature import FeaturevisorOpenFeatureProvider +DEEP_JSON = "[" * 10000 + "0" + "]" * 10000 + + +def exhausted_on_deep_json(source): + # Python 3.12 and later can decode this depth, so the decoder's exhaustion is simulated. + if source == DEEP_JSON: + raise RecursionError("maximum recursion depth exceeded while decoding a JSON array") + return strict_json_loads(source) + + class ProviderResourceErrorTests(unittest.TestCase): def setUp(self): previous = sys.getrecursionlimit() @@ -26,7 +37,7 @@ def provider(self): "256": {"type": "integer", "defaultValue": 7}, "map": {"type": "object", "compose": [{"key": "good", "variable": "256"}, {"key": "bad", "variable": "0"}]}, - "json": {"type": "json", "defaultValue": "[" * 10000 + "0" + "]" * 10000}, + "json": {"type": "json", "defaultValue": DEEP_JSON}, }) f = create_featurevisor({"logLevel": "fatal", "datafile": { "schemaVersion": "4", "revision": "resource", "segments": {}, "variables": variables, @@ -66,6 +77,7 @@ def test_dependency_exhaustion_returns_general_and_preserves_later_calls(self): self.assertEqual(healthy.value, 7) self.assertIsNone(healthy.error_code) + @patch("featurevisor.openfeature.strict_json_loads", exhausted_on_deep_json) def test_json_resource_errors_return_general_for_both_variable_kinds(self): provider = self.provider() for key in ("variable:json", "json:copy"):